GLM DeepAgents 智能体应用 — 完整手教程

跟随本教程,从零手写整个项目:FastAPI + DeepAgents 后端 + Vue 3 + Vite 前端

完成后你将得到一个支持双模型切换、SSE/WebSocket 双通信、主题切换、Markdown 渲染、图片上传的完整 AI 智能体应用。


架构
演示

目录

第一部分:项目总览

第二部分:后端实现(Python)

第三部分:前端实现(Vue 3)

第四部分:进阶


第一部分:项目总览

第 1 章:项目介绍与功能清单

本项目是一个完整功能的 AI 智能体对话应用,最终效果类似 Trae IDE / Cursor 的 AI 聊天面板。

功能清单

类别 功能 说明
模型 双模型切换 GLM-4.7-Flash(文本)与 GLM-4.6V-Flash(视觉)
视觉 图片上传与理解 vision 模型下可上传图片,模型直接理解并描述
智能体 任务规划 自动调用 write_todos 创建任务清单
智能体 虚拟文件系统 DeepAgents 内置 write_file / read_file / edit_file
智能体 子代理委派 research-agent 与 code-agent 两个专用子代理
工具 自定义工具 get_current_time / calculate / web_search
通信 SSE 流式 POST 方式 SSE,标准 EventSource 扩展
通信 WebSocket 双向通信,含自动重连
思考 思考过程 显示 reasoning_content,可与正式回复分离
UI 主题切换 深色 / 浅色主题,localStorage 持久化
UI Markdown 渲染 AI 回复使用 marked 渲染为 HTML
UI 复制功能 一键复制纯文本或 Markdown 源码
UI 图片预览 聊天窗口内显示上传图片缩略图

最终目录结构

agents/
├── server/                          # 后端
│   ├── .env
│   ├── requirements.txt
│   ├── .venv/
│   ├── main.py
│   ├── agent.py
│   ├── glm_chat.py
│   ├── stream.py
│   ├── subagents.py
│   ├── tools.py
│   ├── AGENTS.md
│   └── skills/
│       └── research/
│           └── SKILL.md
└── frontend/                        # 前端
    ├── index.html
    ├── package.json
    ├── tsconfig.json
    ├── vite.config.ts
    └── src/
        ├── main.ts
        ├── App.vue
        ├── types/index.ts
        ├── utils/markdown.ts
        ├── composables/
        │   ├── useChat.ts
        │   ├── useSse.ts
        │   ├── useWebSocket.ts
        │   ├── useImageUpload.ts
        │   └── useTheme.ts
        ├── components/
        │   ├── ChatWindow.vue
        │   ├── MessageInput.vue
        │   ├── ThemeSwitcher.vue
        │   ├── ModelSwitcher.vue
        │   ├── TransportSwitcher.vue
        │   ├── ThinkingPanel.vue
        │   ├── TodoPanel.vue
        │   ├── ToolPanel.vue
        │   └── SubagentPanel.vue
        └── styles/main.css

第 2 章:技术栈与版本对照

后端依赖

库名 版本 用途
langchain 1.3.13 LangChain 框架核心
langgraph 1.2.9 LangGraph 图编排
deepagents 0.6.12 DeepAgents 智能体框架
fastapi 0.139.0 Web API 框架
uvicorn 0.51.0 ASGI 服务器
openai 2.45.0 OpenAI 兼容 SDK
python-dotenv 1.2.2 .env 加载
httpx 0.28.1 异步 HTTP 客户端

前端依赖

库名 版本 用途
vue 3.5.39 Vue 3 框架
vite 8.1.4 构建工具(Rolldown)
@vitejs/plugin-vue 6.0.8 Vue SFC 支持
typescript 6.0.3 TypeScript
vue-tsc 3.3.7 Vue 类型检查
@microsoft/fetch-event-source 2.0.1 SSE POST 客户端
marked 18.0.6 Markdown 渲染

关键提示:TypeScript 必须保持 6.x,TS 7 与 vue-tsc 3.3.7 不兼容。

第 3 章:环境准备

3.1 安装 Python 与 Node.js

  • Python 3.11+(推荐 3.11 或 3.12)
  • Node.js 20+

3.2 获取 GLM API Key

  1. 访问 https://bigmodel.cn 注册并登录
  2. 进入 API Key 管理页面创建一个 Key
  3. GLM-4.7-Flash 与 GLM-4.6V-Flash 均为免费模型

3.3 创建项目根目录

mkdir d:\work\agents
cd d:\work\agents
mkdir server
mkdir frontend

第二部分:后端实现(Python)

第 4 章:项目结构与 requirements.txt

文件路径server/requirements.txt

requirements.txt 只包含包名,不写版本号,让 pip 自动安装最新版。

langchain
langgraph
deepagents
fastapi
uvicorn
openai
python-dotenv
httpx

4.1 创建虚拟环境

cd d:\work\agents\server
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install -r requirements.txt

第 5 章:环境变量 .env

文件路径server/.env

# GLM API Key(替换为你的真实 Key)
GLM_API_KEY=你的GLM_API_KEY

# GLM API Base URL
GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4

# 文本模型
GLM_TEXT_MODEL=glm-4.7-flash

# 视觉模型
GLM_VISION_MODEL=glm-4.6v-flash

# 服务端口
SERVER_PORT=8002

提示:可以去 https://bigmodel.cn 免费申请 glm-4.7-flashglm-4.6v-flash 的 API Key。

第 6 章:自定义工具 tools.py

文件路径server/tools.py

智能体可调用的"工具"在 DeepAgents 中就是普通的 Python 函数,用 @tool 装饰器标记。我们提供三个工具:

  • get_current_time:返回当前时间
  • calculate:安全计算数学表达式(用 AST 白名单)
  • web_search:网络搜索(这里返回模拟结果)
"""自定义工具集。

为 DeepAgents 智能体提供自定义工具:获取当前时间、数学计算、网络搜索。
"""
import ast
import operator
from datetime import datetime

import httpx
from langchain_core.tools import tool


@tool
def get_current_time() -> str:
    """获取当前时间,返回 ISO 8601 格式的时间字符串。"""
    return datetime.now().isoformat()


# 安全的数学运算符映射
_SAFE_OPERATORS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.FloorDiv: operator.floordiv,
    ast.Mod: operator.mod,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
    ast.UAdd: operator.pos,
}


def _safe_eval_node(node: ast.AST) -> float:
    """递归安全求值 AST 节点,仅允许数字与基本算术运算。"""
    if isinstance(node, ast.Constant):
        if isinstance(node.value, (int, float)):
            return node.value
        raise ValueError(f"不支持的常量类型: {type(node.value)}")
    elif isinstance(node, ast.BinOp):
        left = _safe_eval_node(node.left)
        right = _safe_eval_node(node.right)
        op_type = type(node.op)
        if op_type in _SAFE_OPERATORS:
            return _SAFE_OPERATORS[op_type](left, right)
        raise ValueError(f"不支持的运算符: {op_type.__name__}")
    elif isinstance(node, ast.UnaryOp):
        operand = _safe_eval_node(node.operand)
        op_type = type(node.op)
        if op_type in _SAFE_OPERATORS:
            return _SAFE_OPERATORS[op_type](operand)
        raise ValueError(f"不支持的运算符: {op_type.__name__}")
    else:
        raise ValueError(f"不支持的表达式类型: {type(node).__name__}")


@tool
def calculate(expression: str) -> str:
    """安全计算数学表达式。支持加减乘除、乘方、取模等基本运算。

    Args:
        expression: 数学表达式字符串,如 "2 + 3 * 4"
    """
    try:
        tree = ast.parse(expression, mode="eval")
        result = _safe_eval_node(tree.body)
        return str(result)
    except Exception as e:
        return f"计算错误: {e}"


@tool
async def web_search(query: str, max_results: int = 5) -> str:
    """网络搜索,返回搜索结果摘要。

    Args:
        query: 搜索关键词
        max_results: 最大返回结果数
    """
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            await client.get(
                "https://httpbin.org/anything",
                params={"q": query},
            )
            # 实际项目中应替换为真实搜索 API(如 SerpAPI、Tavily)
            return (
                f"搜索 '{query}' 完成(模拟结果)。"
                f"在实际部署中,请替换为真实搜索 API。\n"
                f"请求参数: query={query}, max_results={max_results}"
            )
    except Exception as e:
        return f"搜索失败: {e}"


# 工具列表:智能体可调用的所有自定义工具
CUSTOM_TOOLS = [get_current_time, calculate, web_search]

关键点

  1. @tool 装饰器把普通函数转为 LangChain 工具
  2. 函数的 docstring 是工具描述,模型会看到
  3. 参数类型注解(expression: str)会自动转为 JSON Schema
  4. calculateast 模块解析数学表达式,禁止 eval() 避免任意代码执行
  5. CUSTOM_TOOLS 是工具列表,DeepAgents 会自动绑定

第 7 章:GLM Chat Model glm_chat.py

文件路径server/glm_chat.py

这是整个项目最核心的文件——我们要把"智谱 GLM API"包装成"LangChain 认识的 Chat Model"。

GLM 提供了 OpenAI 兼容的接口(https://open.bigmodel.cn/api/paas/v4),所以我们直接用 openai SDK 调用,继承 LangChain 的 BaseChatModel 类即可。

关键设计

  1. GLM 的特殊字段 reasoning_content:开启思考模式后返回的是 reasoning_content 字段,需要单独提取
  2. 多模态 content:图片消息的 content 是 array 结构(OpenAI 格式),含 image_url
  3. thinking 透传:通过 extra_body={"thinking": {"type": "enabled"}} 启用
  4. bind_tools:DeepAgents 会调用此方法绑定工具,我们要返回新实例而不是原地修改
  5. AsyncOpenAI 原生异步:避免在事件循环中同步阻塞
"""GLM 自定义 LangChain Chat Model。

支持 GLM-4.7-Flash(文本)与 GLM-4.6V-Flash(多模态视觉)两个模型。
透传 thinking 模式,保留 reasoning_content,实现 bind_tools 以兼容 DeepAgents。
"""
from __future__ import annotations

import json
import os
import uuid
from typing import Any, AsyncIterator, Dict, List, Optional, Sequence

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.tools import BaseTool
from openai import AsyncOpenAI, OpenAI
from pydantic import Field


def _build_openai_client() -> OpenAI:
    """构建同步 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 _build_async_openai_client() -> AsyncOpenAI:
    """构建异步 OpenAI 客户端(OpenAI 2.x 推荐)。"""
    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 AsyncOpenAI(api_key=api_key, base_url=base_url)


def _convert_message_to_dict(message: BaseMessage) -> dict:
    """将 LangChain BaseMessage 转为 OpenAI 风格字典,支持多模态 content。"""
    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):
        msg: Dict[str, Any] = {"role": role, "content": content}
    elif isinstance(content, list):
        # 多模态 content:循环处理每一项
        parts = []
        has_multimodal = False
        for item in content:
            if isinstance(item, dict):
                item_type = item.get("type", "")
                if item_type == "image_url":
                    # 图片项原样保留
                    parts.append(item)
                    has_multimodal = True
                elif item_type in ("text", "output_text", "reasoning", "thinking"):
                    parts.append({"type": "text", "text": str(item.get("text", ""))})
                elif item_type == "video_url":
                    parts.append(item)
                    has_multimodal = True
                else:
                    parts.append(item)
            elif isinstance(item, str):
                parts.append({"type": "text", "text": item})
        if has_multimodal:
            msg = {"role": role, "content": parts}
        else:
            # 全部是文本,合并为字符串
            text = "".join(p.get("text", "") for p in parts if isinstance(p, dict))
            msg = {"role": role, "content": text}
    else:
        msg = {"role": role, "content": "" if content is None else str(content)}

    # 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 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 calling 格式。"""
    try:
        schema = tool.args_schema.model_jsonschema() if tool.args_schema else {}
    except Exception:
        schema = {}
    # 清理 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 Chat Model,支持 glm-4.7-flash(文本)与 glm-4.6v-flash(多模态)。"""

    # Pydantic 字段
    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)
    # OpenAI 客户端(不参与序列化)
    client: Optional[OpenAI] = Field(default=None, exclude=True)
    async_client: Optional[AsyncOpenAI] = Field(default=None, exclude=True)
    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())
        if self.async_client is None:
            object.__setattr__(self, "async_client", _build_async_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):
        """绑定工具列表,返回新实例。"""
        tool_dicts: List[dict] = []
        for t in tools:
            if isinstance(t, dict):
                tool_dicts.append(t)
            elif isinstance(t, BaseTool):
                tool_dicts.append(_convert_tool_to_dict(t))
        # 用 model_copy 创建新实例,不修改自身
        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:
        """构建 OpenAI API 调用参数。"""
        kwargs: Dict[str, Any] = {
            "model": self.model_name,
            "stream": True,
            "temperature": self.temperature,
            "max_tokens": self.max_tokens,
        }
        if self.thinking:
            # 透传 GLM 的 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[int, Dict[str, Any]],
    ) -> None:
        """解析流式响应中的工具调用增量。"""
        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 _build_chunk_from_delta(
        self,
        delta: Any,
        tool_calls_acc: Dict[int, Dict[str, Any]],
    ) -> Optional[ChatGenerationChunk]:
        """将单个 delta 转为 ChatGenerationChunk。"""
        reasoning = getattr(delta, "reasoning_content", None) or ""
        content = getattr(delta, "content", None) or ""

        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
        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,
                })

        # content 同时含 reasoning 和 text 段
        structured_content: List[Any] = []
        if reasoning:
            structured_content.append({"type": "reasoning", "text": reasoning})
        if content:
            structured_content.append({"type": "text", "text": content})

        # 没有任何内容时跳过
        if not structured_content and not tool_call_chunks:
            return None

        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 [],
        )
        return ChatGenerationChunk(message=msg_chunk)

    def _stream(self, messages, stop=None, run_manager=None, **kwargs):
        """同步流式接口。"""
        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:
            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
            gen_chunk = self._build_chunk_from_delta(delta, tool_calls_acc)
            if gen_chunk:
                yield gen_chunk

    async def _astream(self, messages, stop=None, run_manager=None, **kwargs) -> AsyncIterator[ChatGenerationChunk]:
        """原生异步流式接口,直接使用 AsyncOpenAI。"""
        request_kwargs = self._build_kwargs()
        request_kwargs["messages"] = self._build_messages(messages)
        try:
            response = await self.async_client.chat.completions.create(**request_kwargs)
        except Exception as e:
            raise RuntimeError(f"GLM 调用失败: {e}") from e

        tool_calls_acc: Dict[int, Dict[str, Any]] = {}
        async for chunk in response:
            if not chunk.choices:
                continue
            delta = chunk.choices[0].delta
            gen_chunk = self._build_chunk_from_delta(delta, tool_calls_acc)
            if gen_chunk:
                yield gen_chunk

    def _generate(self, messages, stop=None, run_manager=None, **kwargs):
        """非流式接口(用于简单调用场景)。"""
        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"]
        # 转换为标准 ToolCall 列表
        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:
                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)])

重点理解

  • BaseChatModel 继承后必须实现 _generate_stream_astream 之一
  • 我们的实现是流式优先(响应快、用户体验好)
  • bind_tools 是 LangChain 工具调用约定,DeepAgents 会调用它
  • _build_chunk_from_delta 把 GLM 的 delta 转为 LangChain 的 AIMessageChunk
  • content 字段我们用 list 结构(含 text / reasoning 项),这样 stream.py 可以区分

第 8 章:子代理配置 subagents.py

文件路径server/subagents.py

DeepAgents 支持子代理委派:主智能体可以把子任务交给"专家"处理。我们定义两个专家:

  • research-agent:负责搜索信息、整理资料、生成研究报告
  • code-agent:负责代码编写、数学计算
"""子代理配置。

定义专用子代理,用于 DeepAgents 的 task 工具委派。
"""
from deepagents import SubAgent
from tools import CUSTOM_TOOLS

# 根据工具名称查找 BaseTool 实例
_TOOL_BY_NAME = {t.name: t for t in CUSTOM_TOOLS}


def _resolve_tools(names: list[str]) -> list:
    """将工具名列表解析为 BaseTool 列表,跳过未找到的。"""
    return [_TOOL_BY_NAME[n] for n in names if n in _TOOL_BY_NAME]


# 研究子代理:负责搜索、整理、总结信息
RESEARCH_SUBAGENT: SubAgent = {
    "name": "research-agent",
    "description": "研究子代理,负责搜索信息、整理资料并生成结构化研究报告。适用于需要深入调研的任务。",
    "system_prompt": (
        "你是一个专业的研究助手。你的任务是:\n"
        "1. 分析研究问题,确定搜索方向\n"
        "2. 使用 web_search 工具搜索相关信息\n"
        "3. 整理搜索结果,提取关键信息\n"
        "4. 生成结构化的研究报告,包含摘要、主要发现和结论\n"
        "回答请使用中文。"
    ),
    "tools": _resolve_tools(["web_search", "get_current_time"]),
}

# 代码子代理:负责代码编写、测试
CODE_SUBAGENT: SubAgent = {
    "name": "code-agent",
    "description": "代码子代理,负责编写、修改和测试代码。适用于编程开发相关的任务。",
    "system_prompt": (
        "你是一个专业的编程助手。你的任务是:\n"
        "1. 分析编程需求,确定技术方案\n"
        "2. 使用虚拟文件系统工具(write_file/read_file/edit_file)编写代码\n"
        "3. 使用 calculate 工具进行数学计算\n"
        "4. 返回完整可运行的代码与说明\n"
        "回答请使用中文。代码注释也使用中文。"
    ),
    "tools": _resolve_tools(["calculate", "get_current_time"]),
}

# 子代理列表(DeepAgents 会自动注册这些子代理)
SUBAGENTS = [RESEARCH_SUBAGENT, CODE_SUBAGENT]

关键点

  1. SubAgent 字段名是 system_prompt不是 prompt),这是 DeepAgents 的硬性要求
  2. tools 字段必须是 BaseTool 实例列表(不是字符串名),所以我们用 _resolve_tools() 转换
  3. description 是模型用来判断"是否要委派给此子代理"的依据,要写得清晰

第 9 章:智能体创建 agent.py

文件路径server/agent.py

这是"装配工厂"——把 Chat Model、Tools、SubAgents、Memory 组合成完整的智能体。

"""DeepAgents 智能体创建与配置。

基于 LangGraph + DeepAgents 框架,使用 GLM-4.7-Flash(文本)或 GLM-4.6V-Flash(视觉)驱动。
"""
import os
from typing import Optional

from dotenv import load_dotenv
from deepagents import create_deep_agent
from glm_chat import GLMChat
from tools import CUSTOM_TOOLS
from subagents import SUBAGENTS

load_dotenv()

# ===== 模型配置(从 .env 读取,提供默认值) =====
GLM_BASE_URL = os.getenv("GLM_BASE_URL", "https://open.bigmodel.cn/api/paas/v4")
GLM_TEXT_MODEL = os.getenv("GLM_TEXT_MODEL", "glm-4.7-flash")
GLM_VISION_MODEL = os.getenv("GLM_VISION_MODEL", "glm-4.6v-flash")

# ===== 各模型 max_tokens 上限(来自官方文档) =====
# GLM-4.7-Flash: 最大输出 128K
# GLM-4.6V-Flash: API 限制 [1, 32768]
MAX_TOKENS_LIMIT = {
    "glm-4.7-flash": 65536,
    "glm-4.6v-flash": 32768,
}
DEFAULT_MAX_TOKENS = 32768  # 安全默认值

# ===== 系统提示词 =====
DEFAULT_SYSTEM_PROMPT = (
    "你是一个功能强大的AI助手,能够规划任务并使用工具完成复杂工作。"
    "在处理纯文本复杂任务时,请先使用 write_todos 规划步骤,再逐步执行。\n"
    "你可以使用以下能力:\n"
    "- 任务规划:使用 write_todos 创建任务清单,read_todos 查看进度\n"
    "- 虚拟文件系统:使用 write_file/read_file/edit_file/ls/glob/grep 管理文件\n"
    "- 子代理委派:使用 task 工具将子任务委派给专用子代理\n"
    "- 自定义工具:获取当前时间、数学计算、网络搜索\n\n"
    "【重要规则 - 多模态图片任务】\n"
    "当用户消息中包含 image_url 图片数据时:\n"
    "1. 立即、直接使用你的视觉能力理解和描述图片内容。\n"
    "2. **不要**调用 write_todos 进行任务规划。\n"
    "3. **不要**调用 task 工具委派给子代理(子代理没有视觉能力)。\n"
    "4. **不要**调用任何文件/搜索/计算工具。\n"
    "5. 直接以自然语言输出你对图片的描述、识别结果或分析。\n\n"
    "只有当用户消息是纯文本(无图片)时,才考虑使用工具或委派。\n"
    "回答请使用中文。"
)

# ===== 记忆文件(DeepAgents 会自动读取) =====
MEMORY_FILES = ["AGENTS.md"] if os.path.exists(
    os.path.join(os.path.dirname(__file__), "AGENTS.md")
) else []

# ===== Skills 目录 =====
SKILLS_DIR = os.path.join(os.path.dirname(__file__), "skills")
SKILLS = [SKILLS_DIR] if os.path.exists(SKILLS_DIR) else []

# ===== 当前模型类型(text / vision),可通过 API 切换 =====
_current_model_type = "text"


def get_model_type() -> str:
    return _current_model_type


def set_model_type(model_type: str) -> str:
    """切换模型类型。"""
    global _current_model_type
    if model_type not in ("text", "vision"):
        raise ValueError(f"不支持的模型类型: {model_type},支持: text, vision")
    _current_model_type = model_type
    return _current_model_type


def get_model_name(model_type: Optional[str] = None) -> str:
    """获取指定类型的模型名称。"""
    mt = model_type or _current_model_type
    return GLM_VISION_MODEL if mt == "vision" else GLM_TEXT_MODEL


def _clamp_max_tokens(model_name: str, max_tokens: int) -> int:
    """根据模型名称将 max_tokens 限制在 API 允许范围内。"""
    limit = MAX_TOKENS_LIMIT.get(model_name, DEFAULT_MAX_TOKENS)
    return min(max_tokens, limit)


def build_model(
    model_type: Optional[str] = None,
    thinking: bool = True,
    temperature: float = 1.0,
    max_tokens: int = 65536,
) -> GLMChat:
    """构建连接 GLM 的 Chat Model 实例。

    关键:vision 模型必须关闭 thinking 模式。
    开启 thinking 模式会导致 vision 任务被识别为规划任务而不直接处理图片。
    """
    mt = model_type or _current_model_type
    model_name = get_model_name(mt)
    max_tokens = _clamp_max_tokens(model_name, max_tokens)
    # 视觉模型强制关闭 thinking
    actual_thinking = thinking and mt != "vision"
    return GLMChat(
        model_name=model_name,
        temperature=temperature,
        max_tokens=max_tokens,
        thinking=actual_thinking,
    )


def get_agent(
    model_type: Optional[str] = None,
    thinking: bool = True,
    temperature: float = 1.0,
    max_tokens: int = 65536,
):
    """根据参数构建 DeepAgents 智能体。

    注意:每次请求重新构建以避免 pickle 问题。
    """
    model = build_model(model_type, thinking, temperature, max_tokens)
    agent = create_deep_agent(
        model=model,
        system_prompt=DEFAULT_SYSTEM_PROMPT,
        tools=CUSTOM_TOOLS,
        subagents=SUBAGENTS,
        memory=MEMORY_FILES if MEMORY_FILES else None,
        skills=SKILLS if SKILLS else None,
    )
    return agent

关键设计

  1. 每次请求都新建 agent:LangGraph 的 StateGraph 在某些情况下不能 pickle,每次新建最稳妥
  2. max_tokens 动态 clamp:前端统一发 65536,后端根据模型自动 clamp 到合法范围
  3. vision 模型强制关闭 thinking:参考 GLM 官方文档示例,vision 任务不应开启 thinking

第 10 章:流式事件处理器 stream.py

文件路径server/stream.py

把 DeepAgents 内部的事件(reasoning / content / tool_calls / subagents)转换为前端能理解的"帧字典"。

策略:优先使用 v3 typed projections(实验性),失败则回退到 v2 事件流。

"""v3/v2 流式事件处理器。

将 DeepAgents 智能体的流式执行过程映射为前端帧字典。

帧类型:
  - {"type": "reasoning", "content": "..."}        思考内容
  - {"type": "content", "content": "..."}          正式回复
  - {"type": "tool_start", "content": "...", "tool_name": "...", "tool_input": "..."}  工具开始
  - {"type": "tool_end", "content": "...", "tool_name": "...", "tool_output": "..."}  工具结束
  - {"type": "plan", "content": "..."}             任务规划
  - {"type": "subagent_start", "content": "...", "subagent_name": "..."}  子代理开始
  - {"type": "subagent_message", "content": "...", "subagent_name": "..."}  子代理消息
  - {"type": "subagent_end", "content": "...", "subagent_name": "..."}    子代理结束
  - {"type": "done"}                               流结束
  - {"type": "error", "content": "..."}            错误
"""
import asyncio
import json
from typing import Any, AsyncGenerator, Dict, List, Optional

from agent import get_agent


def _stringify(value: Any) -> str:
    """将任意值转为可读字符串。"""
    if value is None:
        return ""
    if isinstance(value, str):
        return value
    try:
        return json.dumps(value, ensure_ascii=False, default=str)
    except Exception:
        return str(value)


def _extract_reasoning(chunk: Any) -> Optional[str]:
    """从 AIMessageChunk 中提取 GLM reasoning_content。"""
    if getattr(chunk, "additional_kwargs", None):
        reasoning = chunk.additional_kwargs.get("reasoning_content")
        if reasoning:
            return reasoning if isinstance(reasoning, str) else _stringify(reasoning)

    if getattr(chunk, "response_metadata", None):
        reasoning = chunk.response_metadata.get("reasoning_content")
        if reasoning:
            return reasoning if isinstance(reasoning, str) else _stringify(reasoning)

    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


def _is_subagent_event(event: dict) -> Optional[str]:
    """检测事件是否来自子代理,返回子代理名称或 None。"""
    tags = event.get("tags") or []
    for tag in tags:
        if isinstance(tag, str) and tag.startswith("subagent:"):
            return tag.split(":", 1)[1]
    metadata = event.get("metadata") or {}
    if isinstance(metadata, dict):
        subagent_name = metadata.get("subagent_name")
        if subagent_name:
            return subagent_name
        # LangGraph subgraph namespace detection
        langgraph_name = metadata.get("langgraph_checkpoint_ns", "")
        if langgraph_name:
            ns_parts = langgraph_name.split("|") if langgraph_name else []
            for part in ns_parts:
                if "subagent" in part.lower() or "task" in part.lower():
                    return part
    return None


class _FallbackToV2(Exception):
    """内部信号异常,用于从 v3 回退到 v2。"""
    pass


async def _stream_v2(
    agent,
    inputs: dict,
) -> AsyncGenerator[Dict[str, Any], None]:
    """使用 v2 事件流模式获取智能体执行过程。"""
    async for event in agent.astream_events(inputs, version="v2"):
        etype = event.get("event")
        data = event.get("data") or {}
        subagent_name = _is_subagent_event(event)

        if etype == "on_chat_model_stream":
            chunk = data.get("chunk")
            if chunk is None:
                continue
            reasoning = _extract_reasoning(chunk)
            content = _extract_content(chunk)
            if subagent_name:
                if reasoning:
                    yield {"type": "subagent_message", "content": reasoning, "subagent_name": subagent_name}
                if content:
                    yield {"type": "subagent_message", "content": content, "subagent_name": subagent_name}
            else:
                if reasoning:
                    yield {"type": "reasoning", "content": reasoning}
                if content:
                    yield {"type": "content", "content": content}

        elif etype == "on_tool_start":
            name = event.get("name", "")
            tool_input = data.get("input")

            if subagent_name and name not in ("write_todos", "read_todos"):
                yield {
                    "type": "subagent_message",
                    "content": f"🔧 {name}{_stringify(tool_input)}",
                    "subagent_name": subagent_name,
                }
            elif name == "write_todos":
                yield {"type": "plan", "content": _stringify(tool_input)}
            elif name in ("task", "delegate"):
                # 子代理启动
                input_str = _stringify(tool_input)
                sa_name = "subagent"
                try:
                    parsed = json.loads(input_str) if isinstance(input_str, str) else tool_input
                    if isinstance(parsed, dict):
                        sa_name = parsed.get("subagent_type", parsed.get("name", "subagent"))
                except Exception:
                    pass
                yield {
                    "type": "subagent_start",
                    "content": f"子代理 {sa_name} 启动:{input_str}",
                    "subagent_name": sa_name,
                }
            else:
                tool_input_str = _stringify(tool_input)
                yield {
                    "type": "tool_start",
                    "content": f"调用工具 {name}{tool_input_str}",
                    "tool_name": name,
                    "tool_input": tool_input_str,
                }

        elif etype == "on_tool_end":
            name = event.get("name", "")
            output = data.get("output")

            if subagent_name and name not in ("write_todos", "read_todos"):
                yield {
                    "type": "subagent_message",
                    "content": f"✅ {name} 完成",
                    "subagent_name": subagent_name,
                }
            elif name == "read_todos":
                yield {"type": "plan", "content": _stringify(output)}
            elif name == "write_todos":
                continue  # 已在 tool_start 推送
            elif name in ("task", "delegate"):
                output_str = _stringify(output)
                yield {
                    "type": "subagent_end",
                    "content": f"子代理完成:{output_str[:200]}",
                    "subagent_name": "subagent",
                }
            else:
                tool_output = _stringify(output)
                yield {
                    "type": "tool_end",
                    "content": f"工具 {name} 完成",
                    "tool_name": name,
                    "tool_output": tool_output,
                }


async def stream_agent(
    messages: List[Dict[str, Any]],
    thinking: bool = True,
    model_type: Optional[str] = None,
    temperature: float = 1.0,
    max_tokens: int = 65536,
) -> AsyncGenerator[Dict[str, Any], None]:
    """流式运行 DeepAgents 智能体,yield 标准化帧字典。"""
    agent = get_agent(model_type, thinking, temperature, max_tokens)
    inputs = {"messages": messages}

    # 直接使用 v2(v3 是实验性的,v2 在 LangGraph 1.2 中更稳定)
    async for frame in _stream_v2(agent, inputs):
        yield frame
    yield {"type": "done"}

重点理解

  1. 帧字典:每个事件都是简单 dict,可直接 json.dumps 序列化
  2. 子代理检测:通过 LangGraph 的 langgraph_checkpoint_ns 字段识别
  3. v2 优先:v3 version="v3" API 还在变化,v2 在 LangGraph 1.2.9 中最稳定
  4. filter logic:注意 read_todos / write_todos 单独处理,因为它们是规划而非普通工具

第 11 章:FastAPI 应用 main.py

文件路径server/main.py

Web 服务器入口。提供:

  • GET /health:健康检查
  • POST /agent/model:切换模型
  • POST /agent/sse:SSE 流式对话
  • WS /agent/ws:WebSocket 双向通信
"""DeepAgents 智能体服务 - FastAPI 应用入口。"""
import json
import logging
import os
from typing import Any, Dict, List, Optional

from dotenv import load_dotenv
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel

from agent import get_model_type, set_model_type, get_model_name
from stream import stream_agent

load_dotenv()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("deepagents-server")

app = FastAPI(title="GLM DeepAgents Server", version="1.0.0")

# ===== CORS 配置 =====
# 允许 Vue 前端从这些端口访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://localhost:5174"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

PORT = int(os.getenv("SERVER_PORT", "8002"))


# ===== 请求体模型 =====
class AgentRequest(BaseModel):
    """智能体请求体。"""
    messages: List[Dict[str, Any]]
    thinking: bool = True
    model_type: Optional[str] = None
    max_tokens: int = 65536
    temperature: float = 1.0


class ModelSwitchRequest(BaseModel):
    """模型切换请求体。"""
    model_type: str


# ===== 健康检查 =====
@app.get("/health")
async def health():
    return {
        "status": "ok",
        "service": "deepagents-server",
        "model_type": get_model_type(),
        "model_name": get_model_name(),
    }


# ===== 模型切换 =====
@app.post("/agent/model")
async def switch_model(req: ModelSwitchRequest):
    try:
        new_type = set_model_type(req.model_type)
        return {
            "status": "ok",
            "model_type": new_type,
            "model": get_model_name(new_type),
        }
    except ValueError as e:
        return JSONResponse(status_code=400, content={"error": str(e)})


# ===== SSE 端点 =====
@app.post("/agent/sse")
async def agent_sse(req: AgentRequest):
    headers = {
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",
        "Connection": "keep-alive",
    }

    # 检查 API Key
    if not os.environ.get("GLM_API_KEY"):
        async def err_stream():
            error_frame = {"type": "error", "content": "环境变量 GLM_API_KEY 未设置"}
            yield f"data: {json.dumps(error_frame, ensure_ascii=False)}\n\n"
        return StreamingResponse(err_stream(), media_type="text/event-stream", headers=headers)

    async def event_stream():
        try:
            async for frame in stream_agent(
                messages=req.messages,
                thinking=req.thinking,
                model_type=req.model_type,
                temperature=req.temperature,
                max_tokens=req.max_tokens,
            ):
                yield f"data: {json.dumps(frame, ensure_ascii=False)}\n\n"
        except Exception as e:
            logger.exception("SSE 流式处理出错")
            error_frame = {"type": "error", "content": str(e)}
            yield f"data: {json.dumps(error_frame, ensure_ascii=False)}\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers=headers,
    )


# ===== WebSocket 端点 =====
@app.websocket("/agent/ws")
async def agent_ws(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            payload = await ws.receive_json()
            msg_type = payload.get("type")
            if msg_type != "chat":
                await ws.send_json({
                    "type": "error",
                    "content": f"未知消息类型: {msg_type},期望为 'chat'",
                })
                continue

            messages = payload.get("messages", [])
            thinking = payload.get("thinking", True)
            model_type = payload.get("model_type")
            max_tokens = payload.get("max_tokens", 65536)
            temperature = payload.get("temperature", 1.0)

            if not os.environ.get("GLM_API_KEY"):
                await ws.send_json({"type": "error", "content": "环境变量 GLM_API_KEY 未设置"})
                continue

            try:
                async for frame in stream_agent(
                    messages=messages,
                    thinking=thinking,
                    model_type=model_type,
                    temperature=temperature,
                    max_tokens=max_tokens,
                ):
                    await ws.send_json(frame)
            except Exception as e:
                logger.exception("WS 流式处理出错")
                await ws.send_json({"type": "error", "content": str(e)})
    except WebSocketDisconnect:
        logger.info("WebSocket 客户端断开连接")
    except Exception as e:
        logger.exception("WebSocket 异常")
        try:
            await ws.send_json({"type": "error", "content": str(e)})
        except Exception:
            pass


if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="0.0.0.0", port=PORT, reload=True)

重点

  1. CORS 允许 http://localhost:5173(前端 Vite 默认端口)
  2. SSE 格式:每帧 data: {...}\n\n,浏览器会自动解析
  3. WebSocket 协议:客户端发 {type: "chat", messages: [...], ...},服务端流式返回帧

第 12 章:智能体记忆 AGENTS.md

文件路径server/AGENTS.md

DeepAgents 会自动读取 AGENTS.md 作为智能体的"长期记忆"。这里存放用户偏好和行为准则。

# Agent 记忆

## 行为准则
- 回答使用中文
- 处理复杂任务时先使用 write_todos 规划步骤
- 需要搜索信息时委派给 research-agent 子代理
- 需要编写代码时委派给 code-agent 子代理
- 工具调用结果需要简要说明

## 用户偏好
- 默认启用思考模式
- 代码注释使用中文
- 解释尽量简洁明了

第 13 章:Skills 技能定义

文件路径server/skills/research/SKILL.md

DeepAgents 支持"技能(Skills)"机制:当用户消息中出现触发词时,智能体会参考技能定义执行。

---
name: research
description: 研究技能,用于深入调研某个主题并生成结构化报告
triggers:
  - 研究
  - 调研
  - 分析
  - 报告
---

# 研究技能

当用户需要深入研究某个主题时,按以下步骤执行:

1. 分析研究问题,确定搜索方向
2. 使用 write_todos 规划研究步骤
3. 委派给 research-agent 子代理执行搜索
4. 整理搜索结果,提取关键信息
5. 生成结构化研究报告,包含:
   - 摘要
   - 主要发现
   - 详细分析
   - 结论与建议

## 输出格式

研究报告应使用 Markdown 格式,包含标题层级、列表和引用。

第 14 章:启动后端

server 目录下运行:

# 确保已激活虚拟环境
.\.venv\Scripts\Activate.ps1

# 启动(启用热重载)
.\.venv\Scripts\python.exe -m uvicorn main:app --host 0.0.0.0 --port 8002 --reload

成功启动后控制台会显示:

INFO:     Uvicorn running on http://0.0.0.0:8002 (Press CTRL+C to quit)
INFO:     Started reloader process

验证

curl http://localhost:8002/health
# {"status":"ok","service":"deepagents-server","model_type":"text","model_name":"glm-4.7-flash"}

第三部分:前端实现(Vue 3)

第 15 章:前端项目结构与 package.json

cd d:\work\agents
mkdir frontend
cd frontend
mkdir src
mkdir src\types, src\utils, src\composables, src\components, src\styles

文件路径frontend/package.json

{
  "name": "glm-deepagents-frontend",
  "private": true,
  "version": "1.0.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.39"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.8",
    "typescript": "^6.0.3",
    "vite": "^8.1.4",
    "vue-tsc": "^3.3.7"
  }
}

安装依赖:

npm install

第 16 章:TypeScript 配置 tsconfig.json

文件路径frontend/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2022", "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,
    "noUncheckedSideEffectImports": true,
    "verbatimModuleSyntax": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

关键点

  • verbatimModuleSyntax: true:强制 import type 显式标注
  • moduleResolution: "bundler":现代 Vite 模式
  • 不要设置 baseUrl(TS 6 已弃用)
  • paths 用相对路径 "./src/*"能用 "@/*"

第 17 章:Vite 配置 vite.config.ts

文件路径frontend/vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    host: '0.0.0.0',
    port: 5173,
    strictPort: true,
    cors: true,
  },
})

注意

  • port: 5173:Vite 默认端口
  • strictPort: true:端口被占用就报错(不自动换端口)
  • cors: true:开发服务器开启 CORS
  • 配置 proxy,前端直接连后端 8002

第 18 章:HTML 入口 index.html

文件路径frontend/index.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 DeepAgents 智能体</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

第 19 章:应用入口 main.ts

文件路径frontend/src/main.ts

import { createApp } from 'vue'
import App from './App.vue'
import './styles/main.css'

createApp(App).mount('#app')

第 20 章:类型定义 types/index.ts

文件路径frontend/src/types/index.ts

// 模型类型
export type ModelType = 'text' | 'vision'

// 通信模式
export type TransportMode = 'sse' | 'websocket'

// 对话消息
export interface ChatMessage {
  role: string
  content: string | ContentItem[]
}

// 多模态内容项
export interface ContentItem {
  type: string
  text?: string
  image_url?: { url: string }
}

// 流式帧
export interface StreamFrame {
  type: 'reasoning' | 'content' | 'done' | 'error' | 'tool_start' | 'tool_end'
    | 'plan' | 'subagent_start' | 'subagent_message' | 'subagent_end'
  content?: string
  tool_name?: string
  tool_input?: string
  tool_output?: string
  subagent_name?: string
}

// 工具调用信息
export interface ToolCallInfo {
  name: string
  input: string
  output: string
  status: 'running' | 'done'
  time: number
}

// 子代理信息
export interface SubagentInfo {
  name: string
  status: 'started' | 'running' | 'completed' | 'failed'
  messages: string[]
  time: number
}

// 任务规划项
export interface TodoItem {
  content: string
  time: number
}

// 后端配置
export interface BackendConfig {
  baseUrl: string
  ssePath: string
  wsPath: string
}

export const BACKEND: BackendConfig = {
  baseUrl: 'http://localhost:8002',
  ssePath: '/agent/sse',
  wsPath: '/agent/ws',
}

第 21 章:Markdown 工具 utils/markdown.ts

文件路径frontend/src/utils/markdown.ts

// 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 字符串。 */
export function renderMarkdown(text: string): string {
  try {
    return marked.parse(text, { async: false }) as string
  } catch {
    return text
  }
}

/** 剥离 Markdown 语法,返回纯文本。 */
export function stripMarkdown(text: string): string {
  try {
    const html = marked.parse(text, { async: false }) as string
    const div = document.createElement('div')
    div.innerHTML = html
    return div.textContent || div.innerText || text
  } catch {
    return text
  }
}

第 22 章:主题管理 composables/useTheme.ts

文件路径frontend/src/composables/useTheme.ts

import { ref, type Ref } from 'vue'

export type ThemeMode = 'dark' | 'light'

const STORAGE_KEY = 'glm-deepagents-theme'
const DEFAULT_THEME: ThemeMode = 'dark'

// Singleton ref so all components share the same theme state
const theme: Ref<ThemeMode> = ref<ThemeMode>(DEFAULT_THEME)

function applyTheme(t: ThemeMode) {
  if (typeof document === 'undefined') return
  document.documentElement.setAttribute('data-theme', t)
  // Save to localStorage
  try { localStorage.setItem(STORAGE_KEY, t) } catch { /* ignore */ }
}

let initialized = false

export function useTheme() {
  if (!initialized && typeof window !== 'undefined') {
    initialized = true
    try {
      const saved = localStorage.getItem(STORAGE_KEY) as ThemeMode | null
      if (saved === 'dark' || saved === 'light') {
        theme.value = saved
      }
    } catch { /* ignore */ }
    applyTheme(theme.value)
  }

  function setTheme(t: ThemeMode) {
    theme.value = t
    applyTheme(t)
  }

  function toggleTheme() {
    setTheme(theme.value === 'dark' ? 'light' : 'dark')
  }

  return { theme, setTheme, toggleTheme }
}

关键设计

  • theme 是模块级单例 ref,所有组件共享同一状态
  • useTheme() 首次调用时从 localStorage 读取
  • 通过 data-theme 属性切换 CSS 变量

第 23 章:图片上传 composables/useImageUpload.ts

文件路径frontend/src/composables/useImageUpload.ts

import { ref } from 'vue'

export function useImageUpload() {
  const uploadedImages = ref<string[]>([])

  function fileToDataURL(file: File): Promise<string> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.onload = () => resolve(reader.result as string)
      reader.onerror = reject
      reader.readAsDataURL(file)
    })
  }

  async function addImage(file: File) {
    const dataUrl = await fileToDataURL(file)
    uploadedImages.value.push(dataUrl)
  }

  function removeImage(index: number) {
    uploadedImages.value.splice(index, 1)
  }

  function clearImages() {
    uploadedImages.value = []
  }

  return { uploadedImages, addImage, removeImage, clearImages }
}

说明:用 FileReader.readAsDataURL 把图片转为 base64,可以直接作为 image_url.url 发送给后端。

第 24 章:SSE 客户端 composables/useSse.ts

文件路径frontend/src/composables/useSse.ts

import { fetchEventSource } from '@microsoft/fetch-event-source'
import type { BackendConfig, ChatMessage, StreamFrame, ModelType } from '../types'

export interface SseCallbacks {
  onReasoning: (content: string) => void
  onContent: (content: string) => void
  onDone: () => void
  onError: (content: string) => void
  onToolStart: (content: string, toolName: string, toolInput: string) => void
  onToolEnd: (content: string, toolName: string, toolOutput: string) => void
  onPlan: (content: string) => void
  onSubagentStart: (content: string, name: string) => void
  onSubagentMessage: (content: string, name: string) => void
  onSubagentEnd: (content: string, name: string) => void
}

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 || '', frame.tool_name || '', frame.tool_input || '')
        break
      case 'tool_end':
        callbacks.onToolEnd(frame.content || '', frame.tool_name || '', frame.tool_output || '')
        break
      case 'plan':
        callbacks.onPlan(frame.content || '')
        break
      case 'subagent_start':
        callbacks.onSubagentStart(frame.content || '', frame.subagent_name || '')
        break
      case 'subagent_message':
        callbacks.onSubagentMessage(frame.content || '', frame.subagent_name || '')
        break
      case 'subagent_end':
        callbacks.onSubagentEnd(frame.content || '', frame.subagent_name || '')
        break
    }
  }

  function sendMessage(
    backend: BackendConfig,
    messages: ChatMessage[],
    thinking: boolean,
    modelType: ModelType,
  ) {
    abort()
    controller = new AbortController()
    const url = backend.baseUrl + backend.ssePath

    fetchEventSource(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages,
        thinking,
        model_type: modelType,
        max_tokens: 65536,
        temperature: 1.0,
      }),
      signal: controller.signal,
      openWhenHidden: true,
      onopen: async (response) => {
        if (!response.ok) {
          throw new Error(`SSE 连接失败:${response.status} ${response.statusText}`)
        }
      },
      onmessage: (ev) => {
        if (!ev.data) return
        try {
          const frame = JSON.parse(ev.data) as StreamFrame
          dispatch(frame)
        } 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 }
}

关键点

  • fetchEventSource 库实现 POST 方式的 SSE(浏览器原生 EventSource 只支持 GET)
  • AbortController 用于取消请求
  • openWhenHidden: true:切到后台标签页时不要断开

第 25 章:WebSocket 客户端 composables/useWebSocket.ts

文件路径frontend/src/composables/useWebSocket.ts

import { ref } from 'vue'
import type { BackendConfig, ChatMessage, StreamFrame, ModelType } 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, toolName: string, toolInput: string) => void
  onToolEnd: (content: string, toolName: string, toolOutput: string) => void
  onPlan: (content: string) => void
  onSubagentStart: (content: string, name: string) => void
  onSubagentMessage: (content: string, name: string) => void
  onSubagentEnd: (content: string, name: string) => void
}

const MAX_RECONNECT = 3

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 {
    const base = backend.baseUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
    return base + 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 || '', frame.tool_name || '', frame.tool_input || ''); break
      case 'tool_end': callbacks.onToolEnd(frame.content || '', frame.tool_name || '', frame.tool_output || ''); break
      case 'plan': callbacks.onPlan(frame.content || ''); break
      case 'subagent_start': callbacks.onSubagentStart(frame.content || '', frame.subagent_name || ''); break
      case 'subagent_message': callbacks.onSubagentMessage(frame.content || '', frame.subagent_name || ''); break
      case 'subagent_end': callbacks.onSubagentEnd(frame.content || '', frame.subagent_name || ''); 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 { /* ignore */ }
      ws = null
    }
  }

  function doConnect() {
    if (!currentBackend) return
    status.value = 'connecting'
    const url = toWsUrl(currentBackend)
    try {
      ws = new WebSocket(url)
    } catch (e) {
      status.value = 'error'
      callbacks.onError('WebSocket 创建失败:' + (e instanceof Error ? e.message : String(e)))
      return
    }
    ws.onopen = () => { reconnectAttempts = 0; status.value = 'open' }
    ws.onmessage = (ev) => {
      try { const frame = JSON.parse(ev.data) as StreamFrame; dispatch(frame) }
      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'
      // 自动重连最多 3 次
      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 && ws.readyState === WebSocket.OPEN) { resolve(); return }
      connect(backend)
      const start = Date.now()
      const timer = setInterval(() => {
        if (ws && ws.readyState === WebSocket.OPEN) { clearInterval(timer); resolve() }
        else if (Date.now() - start > 8000) { clearInterval(timer); resolve() }
      }, 80)
    })
  }

  function sendMessage(messages: ChatMessage[], thinking: boolean, modelType: ModelType) {
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      callbacks.onError('WebSocket 未连接,无法发送消息')
      return
    }
    ws.send(JSON.stringify({
      type: 'chat', messages, thinking, model_type: modelType,
      max_tokens: 65536, temperature: 1.0,
    }))
  }

  function disconnect() {
    manualClose = true; reconnectAttempts = 0; teardownOld(); status.value = 'idle'
  }

  return { status, connect, ensureConnected, sendMessage, disconnect }
}

关键点

  • 自动重连:连接断开时最多重试 3 次,间隔 1.5 秒
  • 状态管理idle / connecting / open / closed / error
  • ensureConnected 返回 Promise,等待连接就绪

第 26 章:聊天核心 composables/useChat.ts

文件路径frontend/src/composables/useChat.ts

这是整个前端的"状态管理中心"——管理消息列表、思考内容、工具调用、子代理等所有状态,并协调 SSE/WebSocket 通信。

import { ref, watch, type Ref } from 'vue'
import type {
  ModelType, TransportMode, ChatMessage,
  ToolCallInfo, SubagentInfo, TodoItem,
} from '../types'
import { BACKEND } from '../types'
import { useSse } from './useSse'
import { useWebSocket } from './useWebSocket'

export function useChat(
  modelType: Ref<ModelType>,
  transportMode: Ref<TransportMode>,
) {
  // ===== 状态 =====
  const messages = ref<ChatMessage[]>([])
  const thinkingContent = ref('')
  const currentReply = ref('')
  const loading = ref(false)
  const toolCalls = ref<ToolCallInfo[]>([])
  const subagents = ref<SubagentInfo[]>([])
  const todoList = ref<TodoItem[]>([])
  const errorMsg = ref('')
  const wsStatus = ref<string>('idle')

  // ===== 流式回调 =====
  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, name: string, input: string) => {
      toolCalls.value.push({ name, input, output: '', status: 'running', time: Date.now() })
    },
    onToolEnd: (_c: string, name: string, output: string) => {
      const running = toolCalls.value.find(t => t.name === name && t.status === 'running')
      if (running) { running.output = output; running.status = 'done' }
    },
    onPlan: (c: string) => {
      todoList.value.push({ content: c, time: Date.now() })
    },
    onSubagentStart: (_c: string, name: string) => {
      subagents.value.push({ name, status: 'running', messages: [], time: Date.now() })
    },
    onSubagentMessage: (c: string, name: string) => {
      let sa = subagents.value.find(s => s.name === name && s.status === 'running')
      if (!sa) {
        sa = { name, status: 'running', messages: [], time: Date.now() }
        subagents.value.push(sa)
      }
      sa.messages.push(c)
    },
    onSubagentEnd: (_c: string, name: string) => {
      const sa = subagents.value.find(s => s.name === name && s.status === 'running')
      if (sa) sa.status = 'completed'
    },
  }

  const sse = useSse(callbacks)
  const ws = useWebSocket(callbacks)

  // ===== 内部方法 =====
  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 = ''
    toolCalls.value = []
    subagents.value = []
    todoList.value = []
  }

  // ===== 公开方法 =====
  async function sendMessage(content: string, thinking: boolean, images?: string[]) {
    if (loading.value || !content.trim()) return
    abort()
    resetTurn()

    // 构建消息内容(vision 模型下含图片)
    let msgContent: string | any[]
    if (images && images.length > 0 && modelType.value === 'vision') {
      msgContent = [
        ...images.map(url => ({ type: 'image_url', image_url: { url } })),
        { type: 'text', text: content },
      ]
    } else {
      msgContent = content
    }

    messages.value.push({ role: 'user', content: msgContent })
    loading.value = true

    // 根据通信模式发送
    if (transportMode.value === 'sse') {
      sse.sendMessage(BACKEND, messages.value, thinking, modelType.value)
    } else {
      await ws.ensureConnected(BACKEND)
      ws.sendMessage(messages.value, thinking, modelType.value)
    }
  }

  function abort() {
    if (transportMode.value === 'sse') { sse.abort() }
    else { ws.disconnect() }
    if (loading.value) {
      if (currentReply.value.trim()) {
        messages.value.push({ role: 'assistant', content: currentReply.value })
      }
      currentReply.value = ''
    }
    loading.value = false
  }

  // 切换通信模式时重置
  watch([() => transportMode.value], () => {
    if (loading.value) abort()
    ws.disconnect()
    resetTurn()
  })

  // 同步 ws 状态
  watch(ws.status, (v) => { wsStatus.value = v })

  return {
    messages, thinkingContent, currentReply, loading,
    toolCalls, subagents, todoList, errorMsg, wsStatus,
    sendMessage, abort,
  }
}

关键设计

  • 多模态消息:vision 模型下 content 是 array(含 image_url + text),文本模型下是 string
  • 流式累积onContent 持续追加到 currentReplyonDone 时固化到 messages
  • 统一状态:所有面板数据(toolCalls / subagents / todoList)都从后端帧推导

第 27 章:模型切换器 components/ModelSwitcher.vue

文件路径frontend/src/components/ModelSwitcher.vue

<script setup lang="ts">
import type { ModelType } from '../types'

defineProps<{
  modelType: ModelType
}>()

const emit = defineEmits<{
  (e: 'update:modelType', value: ModelType): void
}>()

const options: { value: ModelType; label: string; sub: string }[] = [
  { value: 'text', label: 'GLM-4.7', sub: '文本' },
  { value: 'vision', label: 'GLM-4.6V', sub: '视觉' },
]
</script>

<template>
  <div class="mode-switch" title="切换模型">
    <button
      v-for="opt in options"
      :key="opt.value"
      :class="{ active: modelType === opt.value }"
      @click="emit('update:modelType', opt.value)"
    >
      <span class="label">{{ opt.label }}</span>
      <span class="sub">{{ opt.sub }}</span>
    </button>
  </div>
</template>

第 28 章:通信模式切换器 components/TransportSwitcher.vue

文件路径frontend/src/components/TransportSwitcher.vue

<script setup lang="ts">
import type { TransportMode } from '../types'

defineProps<{
  transport: TransportMode
}>()

const emit = defineEmits<{
  (e: 'update:transport', value: TransportMode): void
}>()

const options: { value: TransportMode; label: string }[] = [
  { value: 'sse', label: 'SSE' },
  { value: 'websocket', label: 'WebSocket' },
]
</script>

<template>
  <div class="mode-switch" title="切换通信模式">
    <button
      v-for="opt in options"
      :key="opt.value"
      :class="{ active: transport === opt.value }"
      @click="emit('update:transport', opt.value)"
    >
      {{ opt.label }}
    </button>
  </div>
</template>

第 29 章:主题切换器 components/ThemeSwitcher.vue

文件路径frontend/src/components/ThemeSwitcher.vue

<script setup lang="ts">
import { useTheme } from '../composables/useTheme'

const { theme, toggleTheme } = useTheme()
</script>

<template>
  <button
    class="theme-toggle"
    :title="theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'"
    @click="toggleTheme"
  >
    <!-- 暗色主题显示太阳(点击切到亮色) -->
    <svg v-if="theme === 'dark'" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
      <path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278z"/>
    </svg>
    <!-- 亮色主题显示月亮(点击切到暗色) -->
    <svg v-else viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
      <path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z"/>
    </svg>
  </button>
</template>

第 30 章:思考面板 components/ThinkingPanel.vue

文件路径frontend/src/components/ThinkingPanel.vue

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  content: string
  loading: boolean
}>()

const hasContent = computed(() => props.content.length > 0)
</script>

<template>
  <div class="panel-section thinking-section">
    <div class="section-header">
      <span>🧠 思考过程</span>
      <span v-if="loading" class="status-pill running">
        <span class="status-dot pulse"></span>推理中
      </span>
      <span v-else-if="hasContent" class="count">{{ content.length }} 字</span>
    </div>

    <div v-if="!hasContent && !loading" class="empty-text">
      尚未产生思考内容。启用思考模式后,模型会先展示 reasoning_content。
    </div>
    <div v-else class="thinking-card" :class="{ active: loading }">
      <div class="label">
        <span class="status-dot" :class="{ pulse: loading }"></span>
        Reasoning Stream
      </div>
      <pre class="body">{{ content }}<span v-if="loading" class="cursor"></span></pre>
    </div>
  </div>
</template>

第 31 章:任务规划面板 components/TodoPanel.vue

文件路径frontend/src/components/TodoPanel.vue

<script setup lang="ts">
import type { TodoItem } from '../types'

defineProps<{
  todos: TodoItem[]
}>()
</script>

<template>
  <div class="panel-section" v-if="todos.length">
    <div class="section-header">
      <span>📋 任务规划</span>
      <span class="count">{{ todos.length }}</span>
    </div>

    <div v-for="(todo, i) in todos" :key="i" class="todo-card fade-in">
      <span class="icon">☐</span>
      <pre>{{ todo.content }}</pre>
    </div>
  </div>
  <div v-else class="panel-section">
    <div class="section-header"><span>📋 任务规划</span></div>
    <div class="empty-text">任务清单为空。当主代理调用 <code>write_todos</code> 创建任务列表时会显示在此处。</div>
  </div>
</template>

第 32 章:工具面板 components/ToolPanel.vue

文件路径frontend/src/components/ToolPanel.vue

<script setup lang="ts">
import type { ToolCallInfo } from '../types'

defineProps<{
  toolCalls: ToolCallInfo[]
}>()
</script>

<template>
  <div class="panel-section" v-if="toolCalls.length">
    <div class="section-header">
      <span>🔧 工具调用</span>
      <span class="count">{{ toolCalls.length }}</span>
    </div>

    <div v-for="(tool, i) in toolCalls" :key="i" class="tool-card fade-in">
      <div class="head">
        <span class="badge" :class="tool.status">
          {{ tool.status === 'running' ? '⏳ 运行中' : '✓ 完成' }}
        </span>
        <span class="name">{{ tool.name }}</span>
      </div>
      <div v-if="tool.input" class="body">
        <div class="label">Input</div>
        <pre>{{ tool.input }}</pre>
      </div>
      <div v-if="tool.output" class="body">
        <div class="label">Output</div>
        <pre>{{ tool.output }}</pre>
      </div>
    </div>
  </div>
  <div v-else class="panel-section">
    <div class="section-header"><span>🔧 工具调用</span></div>
    <div class="empty-text">尚未调用工具。智能体内置 write_todos / read_todos / write_file / read_file / ls / glob / grep / task,自定义工具包括 get_current_time / calculate / web_search。</div>
  </div>
</template>

第 33 章:子代理面板 components/SubagentPanel.vue

文件路径frontend/src/components/SubagentPanel.vue

<script setup lang="ts">
import { ref } from 'vue'
import type { SubagentInfo } from '../types'

defineProps<{
  subagents: SubagentInfo[]
}>()

function statusIcon(status: string) {
  return ({ running: '⏳', completed: '✅', failed: '❌', started: '▶' } as Record<string, string>)[status] || '▶'
}

const expanded = ref<Record<string, boolean>>({})
function toggle(name: string) {
  expanded.value[name] = !expanded.value[name]
}
</script>

<template>
  <div class="panel-section" v-if="subagents.length">
    <div class="section-header">
      <span>🤖 子代理任务</span>
      <span class="count">{{ subagents.length }}</span>
    </div>

    <div v-for="(sa, i) in subagents" :key="i" class="subagent-card fade-in">
      <div class="head" @click="toggle(sa.name)">
        <div class="name">
          <span>{{ statusIcon(sa.status) }}</span>
          <span>{{ sa.name }}</span>
        </div>
        <div class="head-right">
          <span class="status" :class="sa.status">{{ sa.status }}</span>
          <span class="toggle">{{ expanded[sa.name] !== false ? '▾' : '▸' }}</span>
        </div>
      </div>
      <div v-if="expanded[sa.name] !== false && sa.messages.length" class="messages">
        <div v-for="(msg, j) in sa.messages" :key="j" class="msg-line">
          <pre>{{ msg }}</pre>
        </div>
      </div>
    </div>
  </div>
  <div v-else class="panel-section">
    <div class="section-header"><span>🤖 子代理任务</span></div>
    <div class="empty-text">尚未启动子代理。当主代理调用 <code>task</code> 工具时将显示研究子代理或代码子代理的执行过程。</div>
  </div>
</template>

第 34 章:对话窗口 components/ChatWindow.vue

文件路径frontend/src/components/ChatWindow.vue

对话窗口是核心 UI,负责:

  • 渲染消息列表
  • Markdown 渲染(AI 消息)
  • 图片预览(用户消息中的多模态图片)
  • 复制按钮(hover 显示,下拉菜单含纯文本/Markdown 两种复制)
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import type { ChatMessage } from '../types'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'

const props = defineProps<{
  messages: ChatMessage[]
  streamingReply: string
  loading: boolean
  error: string
}>()

const scrollRef = ref<HTMLDivElement | null>(null)
const hoveredIndex = ref<number | null>(null)
const copyDropdownIndex = ref<number | null>(null)
const copiedIndex = ref<number | null>(null)

// 合并历史消息与当前流式回复
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], scrollToBottom)

// 从消息 content 提取纯文本(处理 string 或 array 两种形态)
function getContentText(content: string | any[]): string {
  if (typeof content === 'string') return content
  if (Array.isArray(content)) {
    return content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('')
  }
  return ''
}

// 提取消息中的图片 URL
function getContentImages(content: string | any[]): string[] {
  if (!Array.isArray(content)) return []
  return content
    .filter((c: any) => c.type === 'image_url' && c.image_url?.url)
    .map((c: any) => c.image_url.url as string)
}

function formatTime() {
  const d = new Date()
  return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
}

function canShowActions(i: number): boolean {
  // 流式中最后一条不显示复制按钮(内容会变化)
  if (props.loading && i === displayMessages.value.length - 1) return false
  return true
}

async function copyPlainText(content: string | any[], index: number) {
  const raw = getContentText(content as any)
  const text = stripMarkdown(raw)
  try { await navigator.clipboard.writeText(text) } catch { /* ignore */ }
  showCopiedFeedback(index)
}

async function copyMarkdownSource(content: string | any[], index: number) {
  const raw = getContentText(content as any)
  try { await navigator.clipboard.writeText(raw) } catch { /* ignore */ }
  showCopiedFeedback(index)
}

function showCopiedFeedback(index: number) {
  copiedIndex.value = index
  copyDropdownIndex.value = null
  setTimeout(() => {
    if (copiedIndex.value === index) copiedIndex.value = null
  }, 2000)
}

function toggleCopyDropdown(index: number) {
  copyDropdownIndex.value = copyDropdownIndex.value === index ? null : index
}

function closeCopyDropdown() {
  copyDropdownIndex.value = null
}
</script>

<template>
  <div class="chat-scroll" ref="scrollRef" @click="closeCopyDropdown">
    <!-- 空状态 -->
    <div v-if="displayMessages.length === 0" class="chat-empty">
      <div class="pixel-logo-large">
        <svg viewBox="0 0 100 140" width="96" height="128">
          <rect x="15" y="15" width="70" height="110" rx="12" fill="var(--bg-input)" stroke="var(--accent)" stroke-width="3"/>
          <rect x="32" y="38" width="14" height="14" fill="var(--accent)" transform="rotate(45 39 45)"/>
          <rect x="54" y="38" width="14" height="14" fill="var(--accent)" transform="rotate(45 61 45)"/>
          <rect x="40" y="78" width="20" height="4" fill="var(--accent)"/>
          <rect x="44" y="92" width="12" height="4" fill="var(--accent)"/>
          <rect x="30" y="110" width="40" height="3" fill="var(--accent)" opacity="0.5"/>
        </svg>
      </div>
      <h2>GLM DeepAgents 已就绪</h2>
      <p>基于 LangChain 1.3.13 + LangGraph 1.2.9 + DeepAgents 0.6.12 构建的完整功能智能体,支持任务规划、虚拟文件系统、子代理委派、自定义工具调用,以及 GLM-4.7-Flash 与 GLM-4.6V-Flash 双模型。</p>
      <div class="empty-tips">
        <div class="tip-chip">💬 输入消息开始对话</div>
        <div class="tip-chip">🧠 切换至 GLM-4.6V 上传图片</div>
        <div class="tip-chip">⚡ 选择子代理委派复杂任务</div>
      </div>
    </div>

    <!-- 消息列表 -->
    <div class="message-list">
      <div
        v-for="(msg, i) in displayMessages"
        :key="i"
        class="message fade-in"
        :class="msg.role === 'user' ? 'user' : 'assistant'"
        @mouseenter="hoveredIndex = i"
        @mouseleave="hoveredIndex = null"
      >
        <div class="avatar">{{ msg.role === 'user' ? 'U' : 'AI' }}</div>
        <div class="body">
          <div class="meta">
            <span class="role-tag">{{ msg.role === 'user' ? 'You' : 'Assistant' }}</span>
            <span class="dot">·</span>
            <span class="time">{{ formatTime() }}</span>
          </div>
          <div class="bubble">
            <template v-if="getContentText(msg.content as any) || getContentImages(msg.content as any).length">
              <!-- 用户上传的图片预览 -->
              <div v-if="getContentImages(msg.content as any).length" class="content-images">
                <img
                  v-for="(img, idx) in getContentImages(msg.content as any)"
                  :key="idx"
                  :src="img"
                  class="content-image"
                  alt="user upload"
                />
              </div>
              <!-- AI 消息:渲染 Markdown -->
              <div
                v-if="msg.role === 'assistant'"
                class="markdown-body"
                v-html="renderMarkdown(getContentText(msg.content as any))"
              ></div>
              <!-- 用户消息:纯文本 -->
              <pre v-else class="content-text">{{ getContentText(msg.content as any) }}</pre>
              <span
                v-if="loading && i === displayMessages.length - 1 && msg.role === 'assistant'"
                class="cursor"
              ></span>
            </template>
            <span v-else class="typing">
              <span class="dot"></span><span class="dot"></span><span class="dot"></span>
            </span>

            <!-- 消息操作按钮(hover 显示) -->
            <div v-if="hoveredIndex === i && canShowActions(i) && (getContentText(msg.content as any) || getContentImages(msg.content as any).length)" class="msg-actions">
              <button
                v-if="msg.role === 'user'"
                class="msg-icon-btn"
                @click.stop="copyPlainText(msg.content, i)"
                :title="copiedIndex === i ? '已复制' : '复制'"
              >
                {{ copiedIndex === i ? '✓' : '⧉' }}
              </button>
              <template v-else>
                <button
                  class="msg-icon-btn"
                  @click.stop="toggleCopyDropdown(i)"
                  :title="copiedIndex === i ? '已复制' : '复制'"
                >
                  {{ 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>

    <div v-if="error" class="error-bar">⚠ {{ error }}</div>
  </div>
</template>

关键设计

  • 统一提取函数getContentTextgetContentImages 处理 string 与 array 两种 content 形态
  • AI 消息 Markdown 渲染v-html="renderMarkdown(...)" 直接输出 HTML
  • 复制下拉菜单:AI 消息点击图标展开"复制 / 复制 Markdown"两个选项
  • 流式光标:最后一条 AI 消息加载中显示光标动画

第 35 章:消息输入框 components/MessageInput.vue

文件路径frontend/src/components/MessageInput.vue

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'

const props = defineProps<{
  loading: boolean
  modelType: string
  uploadedImages: string[]
}>()

const emit = defineEmits<{
  (e: 'send', payload: { content: string; thinking: boolean }): void
  (e: 'abort'): void
  (e: 'imageAdd', file: File): void
  (e: 'imageRemove', index: number): void
}>()

const content = ref('')
const thinking = ref(true)
const textareaRef = ref<HTMLTextAreaElement | null>(null)

// textarea 自适应高度
function autoResize() {
  const el = textareaRef.value
  if (!el) return
  el.style.height = 'auto'
  el.style.height = Math.min(el.scrollHeight, 200) + 'px'
}
watch(content, autoResize)

function onSend() {
  if (props.loading || !content.value.trim()) return
  emit('send', { content: content.value, thinking: thinking.value })
  content.value = ''
  nextTick(autoResize)
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault()
    onSend()
  }
}

function onImageSelect(e: Event) {
  const input = e.target as HTMLInputElement
  if (input.files && input.files[0]) {
    emit('imageAdd', input.files[0])
    input.value = ''
  }
}
</script>

<template>
  <div class="composer">
    <div class="composer-inner">
      <!-- 已上传图片预览 -->
      <div v-if="modelType === 'vision' && uploadedImages.length > 0" class="image-chips">
        <div v-for="(img, i) in uploadedImages" :key="i" class="image-chip">
          <img :src="img" alt="upload" />
          <button class="remove" @click="emit('imageRemove', i)">×</button>
        </div>
      </div>

      <textarea
        ref="textareaRef"
        v-model="content"
        :placeholder="modelType === 'vision' ? '向 GLM-4.6V 输入消息或上传图片提问…' : '向 GLM-4.7 输入消息,Enter 发送,Shift+Enter 换行…'"
        @keydown="onKeydown"
        rows="1"
        :disabled="loading"
      ></textarea>

      <div class="composer-footer">
        <div class="left">
          <!-- vision 模型下显示图片上传按钮 -->
          <label v-if="modelType === 'vision'" class="icon-btn" title="上传图片">
            📷
            <input type="file" accept="image/*" @change="onImageSelect" hidden />
          </label>
          <!-- 思考模式开关 -->
          <button
            class="thinking-chip"
            :class="{ active: thinking }"
            @click="thinking = !thinking"
            title="思考模式(启用后模型会先展示 reasoning_content)"
          >
            <span class="chip-dot" :class="{ on: thinking }"></span>
            思考
          </button>
          <span class="hint">⏎ 发送 · ⇧⏎ 换行</span>
        </div>
        <button v-if="loading" class="stop-btn" @click="emit('abort')">停止</button>
        <button v-else class="send-btn" @click="onSend" :disabled="!content.trim()">发送 ⏎</button>
      </div>
    </div>
  </div>
</template>

关键点

  • Enter 发送 / Shift+Enter 换行:标准聊天体验
  • vision 模型专属:显示 📷 图片上传按钮 + 图片缩略图条
  • 思考模式 chip:独立控制 thinking 开关

第 36 章:根组件 App.vue

文件路径frontend/src/App.vue

<script setup lang="ts">
import { ref } from 'vue'
import type { ModelType, TransportMode } from './types'
import { useChat } from './composables/useChat'
import { useImageUpload } from './composables/useImageUpload'
import ThemeSwitcher from './components/ThemeSwitcher.vue'
import ModelSwitcher from './components/ModelSwitcher.vue'
import TransportSwitcher from './components/TransportSwitcher.vue'
import ThinkingPanel from './components/ThinkingPanel.vue'
import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue'
import ToolPanel from './components/ToolPanel.vue'
import SubagentPanel from './components/SubagentPanel.vue'
import TodoPanel from './components/TodoPanel.vue'

type PanelKey = 'thinking' | 'todo' | 'tools' | 'agents' | 'chat'

const modelType = ref<ModelType>('text')
const transportMode = ref<TransportMode>('sse')
const activePanel = ref<PanelKey>('chat')
const panelOpen = ref(true)

const {
  messages, thinkingContent, currentReply, loading,
  toolCalls, subagents, todoList, errorMsg, wsStatus,
  sendMessage, abort,
} = useChat(modelType, transportMode)

const { uploadedImages, addImage, removeImage, clearImages } = useImageUpload()

function setPanel(p: PanelKey) {
  activePanel.value = p
  panelOpen.value = p !== 'chat'
}

function togglePanel(p: PanelKey) {
  if (panelOpen.value && activePanel.value === p) {
    panelOpen.value = false
    activePanel.value = 'chat'
  } else {
    activePanel.value = p
    panelOpen.value = true
  }
}

function onSend(payload: { content: string; thinking: boolean }) {
  const imgs = uploadedImages.value.length > 0 ? [...uploadedImages.value] : undefined
  sendMessage(payload.content, payload.thinking, imgs)
  clearImages()
}
</script>

<template>
  <div class="app">
    <!-- 顶部头部 -->
    <header class="app-header">
      <div class="brand">
        <div class="logo">
          <svg viewBox="0 0 32 32" width="28" height="28">
            <rect x="4" y="4" width="24" height="24" rx="4" fill="var(--accent)"/>
            <rect x="11" y="11" width="4" height="4" rx="1" fill="var(--bg-panel)"/>
            <rect x="17" y="11" width="4" height="4" rx="1" fill="var(--bg-panel)"/>
            <rect x="12" y="19" width="8" height="2" rx="1" fill="var(--bg-panel)"/>
          </svg>
        </div>
        <div class="brand-text">
          <h1>GLM DeepAgents</h1>
          <p>LangChain 1.3 · LangGraph 1.2 · DeepAgents 0.6</p>
        </div>
      </div>
      <div class="header-controls">
        <ThemeSwitcher />
        <TransportSwitcher :transport="transportMode" @update:transport="transportMode = $event" />
        <ModelSwitcher :modelType="modelType" @update:modelType="modelType = $event" />
        <div v-if="transportMode === 'websocket'" class="ws-status" :class="'st-' + wsStatus">● {{ wsStatus }}</div>
      </div>
    </header>

    <!-- 主体:左侧导航 + 左侧面板 + 对话区 -->
    <main class="app-main">
      <nav class="left-rail">
        <button class="rail-btn" :class="{ active: activePanel === 'chat' && !panelOpen }" @click="setPanel('chat')" title="对话">
          <span class="rail-icon">💬</span>
        </button>
        <button class="rail-btn" :class="{ active: panelOpen && activePanel === 'thinking' }" @click="togglePanel('thinking')" title="思考过程">
          <span class="rail-icon">🧠</span>
        </button>
        <button class="rail-btn" :class="{ active: panelOpen && activePanel === 'todo' }" @click="togglePanel('todo')" title="任务规划">
          <span class="rail-icon">📋</span>
        </button>
        <button class="rail-btn" :class="{ active: panelOpen && activePanel === 'tools' }" @click="togglePanel('tools')" title="工具调用">
          <span class="rail-icon">🔧</span>
        </button>
        <button class="rail-btn" :class="{ active: panelOpen && activePanel === 'agents' }" @click="togglePanel('agents')" title="子代理">
          <span class="rail-icon">🤖</span>
        </button>
      </nav>

      <aside class="left-panel" v-show="panelOpen">
        <ThinkingPanel :content="thinkingContent" :loading="loading" v-show="activePanel === 'thinking'" />
        <TodoPanel :todos="todoList" v-show="activePanel === 'todo'" />
        <ToolPanel :toolCalls="toolCalls" v-show="activePanel === 'tools'" />
        <SubagentPanel :subagents="subagents" v-show="activePanel === 'agents'" />
      </aside>

      <section class="chat-area">
        <ChatWindow :messages="messages" :streamingReply="currentReply" :loading="loading" :error="errorMsg" />
        <MessageInput
          :loading="loading"
          :modelType="modelType"
          :uploadedImages="uploadedImages"
          @send="onSend"
          @abort="abort"
          @imageAdd="addImage"
          @imageRemove="removeImage"
        />
      </section>
    </main>
  </div>
</template>

关键设计

  • 三栏布局:左侧导航栏(图标按钮)+ 左侧面板(详情)+ 右侧对话区
  • 面板切换:点击左侧导航按钮,展开对应面板(聊天面板会收起左侧面板)
  • 响应式连接:把所有状态(messages / loading / toolCalls / subagents)从 useChat 取出后下发给各组件

第 37 章:全局样式 styles/main.css

文件路径frontend/src/styles/main.css

整个样式系统约 850 行,下面给出核心骨架(完整版本见项目源码):

/* ============================================================
   Design Tokens
   Light theme is the default. Dark theme overrides via :root[data-theme="dark"].
   ============================================================ */
:root {
  --bg-base: #f7f9fb;
  --bg-panel: #ffffff;
  --bg-elevated: #eef2f7;
  --bg-input: #fbfcfd;
  --border: #e1e7ee;
  --border-subtle: #eef2f7;
  --text-primary: #0f1419;
  --text-secondary: #57606a;
  --text-tertiary: #8b949e;
  --accent: #00a866;
  --accent-hover: #008f56;
  --accent-dim: #00a86614;
  --thinking: #6b46ff;
  --tool: #d97706;
  --subagent: #1f6feb;
  --plan: #9333ea;
  --danger: #cf222e;
  --success: #1a7f37;
  --shadow-sm: 0 1px 2px rgba(15, 20, 25, 0.06);
  --shadow-md: 0 4px 12px rgba(15, 20, 25, 0.08);
  --scrollbar-thumb: #d0d7de;
  --scrollbar-thumb-hover: #afb8c1;
  --code-bg: #f6f8fa;
  --on-accent: #0a0e14;
  --on-scrim: #ffffff;
  --scrim: rgba(0, 0, 0, 0.5);

  --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  --font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', Consolas, monospace;
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 14px;
  --transition-fast: 0.15s ease;
  --transition-normal: 0.25s ease;
}

:root[data-theme="dark"] {
  --bg-base: #0a0e14;
  --bg-panel: #131820;
  --bg-elevated: #1d242e;
  --bg-input: #0d1117;
  --border: #2a3441;
  --border-subtle: #1f2731;
  --text-primary: #e6edf3;
  --text-secondary: #8b949e;
  --text-tertiary: #6e7681;
  --accent: #00d97e;
  --accent-hover: #00f08a;
  --accent-dim: #00d97e1a;
  --thinking: #7c5cff;
  --tool: #ffa657;
  --subagent: #58a6ff;
  --plan: #d2a8ff;
  --danger: #f85149;
  --success: #3fb950;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
  --scrollbar-thumb: #2a3441;
  --scrollbar-thumb-hover: #3a4554;
  --code-bg: #0d1117;
  --on-accent: #0a0e14;
  --on-scrim: #ffffff;
  --scrim: rgba(0, 0, 0, 0.6);
}

/* ============================================================
   Theme Transition
   ============================================================ */
body, .app, .app-header, .left-rail, .left-panel, .chat-area,
.composer-inner, .thinking-card, .tool-card, .subagent-card, .todo-card,
.message.user .content, .mode-switch {
  transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}

/* ============================================================
   Reset & Base
   ============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app { height: 100%; overflow: hidden; }
body {
  background: var(--bg-base);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}
button { font-family: inherit; font-size: inherit; color: inherit; background: none; border: none; cursor: pointer; }
input, textarea { font-family: inherit; font-size: inherit; color: inherit; }

/* ===== Scrollbars ===== */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px; transition: background 0.15s; }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }

/* ===== App Shell ===== */
.app { display: flex; flex-direction: column; height: 100vh; width: 100vw; background: var(--bg-base); overflow: hidden; }

/* ===== Top Header ===== */
.app-header {
  height: 52px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 16px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
}
.brand { display: flex; gap: 12px; align-items: center; }
.brand h1 { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.brand p { font-size: 11px; color: var(--text-tertiary); }
.header-controls { display: flex; gap: 8px; align-items: center; }

/* ===== Main Layout ===== */
.app-main { flex: 1; display: flex; min-height: 0; overflow: hidden; }

/* ===== Left Rail ===== */
.left-rail {
  width: 52px; flex-shrink: 0;
  display: flex; flex-direction: column; align-items: center;
  padding: 8px 0; gap: 2px;
  background: var(--bg-panel);
  border-right: 1px solid var(--border);
}
.rail-btn {
  width: 100%; height: 48px;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-secondary);
  font-size: 18px;
  transition: background 0.15s, color 0.15s;
  position: relative;
}
.rail-btn:hover { background: var(--bg-elevated); color: var(--text-primary); }
.rail-btn.active { background: var(--accent-dim); color: var(--accent); }
.rail-btn.active::before {
  content: ''; position: absolute; left: 0; top: 8px; bottom: 8px;
  width: 2px; background: var(--accent); border-radius: 0 2px 2px 0;
}

/* ===== Left Panel ===== */
.left-panel {
  width: 340px; flex-shrink: 0;
  overflow-y: auto;
  background: var(--bg-base);
  border-right: 1px solid var(--border);
}
.panel-section { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); }
.section-header {
  font-size: 11px; font-weight: 600;
  color: var(--text-tertiary);
  text-transform: uppercase; letter-spacing: 0.8px;
  margin-bottom: 10px;
  display: flex; justify-content: space-between; align-items: center;
}
.section-header .count {
  font-size: 10px; padding: 2px 8px; border-radius: 10px;
  background: var(--bg-elevated); color: var(--text-secondary);
  font-weight: 500; letter-spacing: 0; text-transform: none;
}

/* ===== Thinking Card ===== */
.thinking-card {
  position: relative;
  background: var(--bg-panel);
  border: 1px solid var(--thinking);
  border-radius: 8px; padding: 12px 14px; margin-bottom: 8px;
}
.thinking-card .label {
  font-size: 11px; color: var(--thinking);
  font-weight: 600; margin-bottom: 6px;
  display: flex; align-items: center; gap: 6px;
}
.thinking-card .body {
  font-family: var(--font-mono);
  font-size: 12px; line-height: 1.6;
  color: var(--text-secondary);
  white-space: pre-wrap;
  max-height: 200px; overflow-y: auto;
  word-break: break-word;
}

/* ===== Tool Card ===== */
.tool-card {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 8px; margin-bottom: 6px; overflow: hidden;
}
.tool-card .head {
  padding: 8px 12px;
  display: flex; gap: 8px; align-items: center;
  border-bottom: 1px solid var(--border-subtle);
}
.tool-card .badge {
  font-size: 10px; padding: 2px 8px; border-radius: 4px;
  font-family: var(--font-mono); font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.3px;
  flex-shrink: 0;
}
.tool-card .badge.running { background: var(--accent-dim); color: var(--tool); }
.tool-card .badge.done { background: var(--accent-dim); color: var(--success); }
.tool-card .name {
  font-family: var(--font-mono); font-size: 12px;
  color: var(--text-primary); flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tool-card .body {
  padding: 8px 12px;
  font-family: var(--font-mono); font-size: 11px;
  color: var(--text-secondary); background: var(--code-bg);
  white-space: pre-wrap; max-height: 150px; overflow-y: auto;
  word-break: break-word; line-height: 1.5;
}

/* ===== Subagent Card ===== */
.subagent-card {
  background: var(--bg-panel);
  border: 1px solid var(--subagent);
  border-left-width: 3px;
  border-radius: 8px; margin-bottom: 8px; overflow: hidden;
}
.subagent-card .head {
  padding: 10px 12px;
  display: flex; justify-content: space-between; align-items: center;
  cursor: pointer; user-select: none;
}
.subagent-card .name {
  font-size: 13px; font-weight: 600; color: var(--subagent);
  display: flex; gap: 8px; align-items: center;
}

/* ===== Todo Card ===== */
.todo-card {
  background: var(--bg-panel);
  border: 1px solid var(--border);
  border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
  font-size: 12px; color: var(--text-primary);
  display: flex; align-items: flex-start;
  line-height: 1.5; box-shadow: var(--shadow-sm);
}
.todo-card .icon { color: var(--plan); margin-right: 8px; flex-shrink: 0; }

/* ===== Chat Area ===== */
.chat-area { flex: 1; display: flex; flex-direction: column; background: var(--bg-base); overflow: hidden; min-width: 0; }
.chat-scroll { flex: 1; overflow-y: auto; scroll-behavior: smooth; }

/* ===== Message Bubble ===== */
.message {
  max-width: 760px; margin: 0 auto; padding: 24px 24px 0;
  display: flex; gap: 14px; animation: fadeIn 0.25s ease-out;
}
.message .avatar {
  width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 13px; font-weight: 600;
}
.message.user .avatar { background: var(--bg-elevated); color: var(--accent); }
.message.assistant .avatar { background: var(--accent-dim); color: var(--accent); }
.message .body { flex: 1; min-width: 0; }
.message .meta {
  font-size: 11px; color: var(--text-tertiary);
  margin-bottom: 6px;
  display: flex; gap: 8px; align-items: center;
}
.message .meta .role-tag { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.message .content {
  font-size: 14px; line-height: 1.7;
  color: var(--text-primary);
  white-space: pre-wrap; word-break: break-word;
}
.message.user .content {
  background: var(--bg-elevated);
  padding: 12px 16px; border-radius: 10px;
  border: 1px solid var(--border);
  box-shadow: var(--shadow-sm);
}
.bubble { position: relative; }

/* ===== Content Images ===== */
.content-images { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
.content-image {
  max-width: 200px; max-height: 200px;
  border-radius: 8px; border: 1px solid var(--border);
  object-fit: cover; cursor: pointer; transition: transform 0.2s ease;
}
.content-image:hover { transform: scale(1.03); }

/* ===== Markdown Body ===== */
.markdown-body { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
  margin: 16px 0 8px; font-weight: 600; line-height: 1.3;
}
.markdown-body h1 { font-size: 20px; }
.markdown-body h2 { font-size: 18px; }
.markdown-body h3 { font-size: 16px; }
.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 code {
  background: var(--bg-input); color: var(--accent);
  padding: 2px 6px; border-radius: 4px;
  font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.9em;
}
.markdown-body pre {
  background: var(--bg-input); border: 1px solid var(--border);
  border-radius: 8px; padding: 12px 16px;
  overflow-x: auto; margin: 8px 0;
}
.markdown-body pre code { background: transparent; padding: 0; color: var(--text-primary); font-size: 13px; }
.markdown-body blockquote {
  border-left: 3px solid var(--accent);
  padding: 4px 12px; margin: 8px 0;
  color: var(--text-secondary);
  background: var(--bg-elevated);
  border-radius: 0 4px 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 12px; text-align: left; }
.markdown-body th { background: var(--bg-elevated); font-weight: 600; }
.markdown-body a { color: var(--accent); text-decoration: none; }
.markdown-body a:hover { text-decoration: underline; }
.markdown-body strong { font-weight: 600; }
.markdown-body hr { border: 0; border-top: 1px solid var(--border); margin: 12px 0; }

/* ===== Message Action Buttons ===== */
.msg-actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; opacity: 0.85; }
.msg-icon-btn {
  background: var(--bg-elevated); border: 1px solid var(--border);
  border-radius: 6px; width: 26px; height: 26px;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-secondary); font-size: 13px;
  transition: all 0.15s ease; padding: 0;
}
.msg-icon-btn:hover { background: var(--bg-input); color: var(--accent); border-color: var(--accent); }
.copy-dropdown {
  position: absolute; top: 32px; right: 0;
  background: var(--bg-elevated); border: 1px solid var(--border);
  border-radius: 8px; box-shadow: var(--shadow-md);
  display: flex; flex-direction: column; min-width: 140px;
  z-index: 100; overflow: hidden;
}
.copy-option {
  background: transparent; border: 0;
  padding: 8px 12px; text-align: left;
  color: var(--text-primary); font-size: 13px;
  cursor: pointer; transition: background 0.15s ease;
}
.copy-option:hover { background: var(--bg-input); color: var(--accent); }

/* ===== Empty State ===== */
.chat-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px; }
.chat-empty h2 { font-size: 20px; font-weight: 600; color: var(--text-primary); margin: 16px 0 8px; }
.chat-empty p { font-size: 13px; color: var(--text-secondary); max-width: 480px; text-align: center; line-height: 1.6; }
.empty-tips { display: flex; gap: 8px; margin-top: 20px; flex-wrap: wrap; justify-content: center; }
.tip-chip {
  font-size: 12px; padding: 6px 12px;
  background: var(--bg-panel); border: 1px solid var(--border);
  border-radius: 16px; color: var(--text-secondary);
}

/* ===== Typing & Cursor ===== */
.typing { display: inline-flex; gap: 4px; padding: 8px 0; align-items: center; }
.typing .dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--text-tertiary);
  animation: typingBounce 1.4s infinite ease-in-out both;
}
.typing .dot:nth-child(2) { animation-delay: 0.15s; }
.typing .dot:nth-child(3) { animation-delay: 0.3s; }
.cursor {
  display: inline-block; width: 7px; height: 14px;
  background: var(--accent); margin-left: 2px; vertical-align: text-bottom;
  animation: blink 1s infinite step-end;
}

/* ===== Composer ===== */
.composer {
  position: sticky; bottom: 0;
  padding: 16px 24px 20px;
  background: linear-gradient(to top, var(--bg-base) 70%, transparent);
}
.composer-inner {
  max-width: 760px; margin: 0 auto;
  background: var(--bg-panel); border: 1px solid var(--border);
  border-radius: 14px;
  transition: border-color 0.15s, box-shadow 0.15s;
  box-shadow: var(--shadow-sm);
}
.composer-inner:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.composer textarea {
  width: 100%; min-height: 24px; max-height: 200px;
  resize: none; padding: 12px 16px;
  background: transparent; border: none; outline: none;
  color: var(--text-primary); font-size: 14px; line-height: 1.5;
}
.composer-footer {
  display: flex; justify-content: space-between; align-items: center;
  padding: 8px 12px;
}
.composer-footer .left { display: flex; gap: 4px; align-items: center; color: var(--text-tertiary); font-size: 11px; }
.thinking-chip {
  display: inline-flex; gap: 4px; align-items: center;
  padding: 3px 10px; border-radius: 12px;
  font-size: 11px; color: var(--text-secondary);
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  transition: all 0.15s;
}
.thinking-chip.active { color: var(--accent); border-color: var(--accent); background: var(--accent-dim); }
.chip-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--text-tertiary);
}
.chip-dot.on { background: var(--accent); }
.composer-footer .send-btn {
  background: var(--accent); color: var(--on-accent);
  border: none; padding: 6px 16px; border-radius: 8px;
  font-size: 13px; font-weight: 600; cursor: pointer;
  transition: background 0.15s;
}
.composer-footer .send-btn:hover:not(:disabled) { background: var(--accent-hover); }
.composer-footer .send-btn:disabled { background: var(--bg-elevated); color: var(--text-tertiary); cursor: not-allowed; }
.composer-footer .stop-btn {
  background: var(--danger); color: white;
  border: none; padding: 6px 16px; border-radius: 8px;
  font-size: 13px; font-weight: 600; cursor: pointer;
}

/* ===== Switcher ===== */
.mode-switch {
  display: inline-flex;
  background: var(--bg-input); border: 1px solid var(--border);
  border-radius: 8px; padding: 2px;
}
.mode-switch button {
  padding: 4px 10px; font-size: 12px;
  color: var(--text-secondary);
  background: transparent; border: none; border-radius: 6px;
  cursor: pointer; transition: all 0.15s;
}
.mode-switch button:hover { color: var(--text-primary); }
.mode-switch button.active { background: var(--bg-elevated); color: var(--accent); }

/* ===== Image Chips ===== */
.image-chips { display: flex; gap: 8px; padding: 8px 12px 0; flex-wrap: wrap; }
.image-chip {
  position: relative; width: 56px; height: 56px;
  border-radius: 8px; overflow: hidden;
  border: 1px solid var(--border);
}
.image-chip img { width: 100%; height: 100%; object-fit: cover; }
.image-chip .remove {
  position: absolute; top: 2px; right: 2px;
  width: 18px; height: 18px; border-radius: 50%;
  background: var(--scrim); color: var(--on-scrim);
  border: none; cursor: pointer; font-size: 11px;
  line-height: 1; display: flex; align-items: center; justify-content: center;
}

/* ===== Theme Toggle ===== */
.theme-toggle {
  background: var(--bg-input); border: 1px solid var(--border);
  width: 32px; height: 32px; border-radius: 8px;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-secondary);
  cursor: pointer; transition: all 0.15s;
}
.theme-toggle:hover { background: var(--bg-elevated); color: var(--accent); border-color: var(--accent); }

/* ===== Error Bar ===== */
.error-bar {
  max-width: 760px; margin: 12px auto 0;
  padding: 10px 14px;
  background: var(--accent-dim); border: 1px solid var(--danger);
  border-radius: 8px; color: var(--danger); font-size: 12px;
}

/* ===== WS Status ===== */
.ws-status {
  font-size: 10px; padding: 3px 8px; border-radius: 10px;
  font-family: var(--font-mono);
}
.ws-status.open { background: var(--accent-dim); color: var(--success); }
.ws-status.connecting { background: var(--accent-dim); color: var(--tool); }
.ws-status.closed { background: var(--accent-dim); color: var(--danger); }

/* ===== Empty text (panel fallback) ===== */
.empty-text {
  font-size: 12px; color: var(--text-tertiary);
  padding: 12px 0; line-height: 1.6;
}
.empty-text code {
  background: var(--bg-elevated); padding: 1px 6px; border-radius: 3px;
  font-family: var(--font-mono); font-size: 11px;
  color: var(--accent);
}

/* ===== Animations ===== */
@keyframes pulse {
  0% { box-shadow: 0 0 0 0 currentColor; }
  70% { box-shadow: 0 0 0 6px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}
@keyframes blink { 50% { opacity: 0; } }
@keyframes typingBounce {
  0%, 80%, 100% { transform: scale(0.8); opacity: 0.4; }
  40% { transform: scale(1); opacity: 1; }
}
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.2s ease-out; }

样式系统设计

  • CSS 变量驱动主题:所有颜色都通过 var(--xxx) 引用,切换 data-theme 即可整体换肤
  • 双层主题:root 默认 light,:root[data-theme="dark"] 覆盖暗色
  • 过渡动画background-color 0.2s ease 让主题切换平滑
  • 模块化:每个 UI 区域一组类名(message / thinking-card / tool-card 等),避免互相污染

第 38 章:启动前端

frontend 目录下运行:

cd d:\work\agents\frontend
npm run dev

Vite 启动后控制台会显示:

VITE v8.1.4  ready in 244 ms
➜  Local:   http://localhost:5173/

打开浏览器访问 http://localhost:5173/,即可看到完整的对话界面。


第四部分:进阶

第 39 章:常见问题排查

Q: max_tokens 报错 “限制数值范围[1,32768]”

原因:GLM-4.6V-Flash 的 API 限制 max_tokens ≤ 32768,而 GLM-4.7-Flash 支持 65536。

解决方案:后端 agent.py_clamp_max_tokens() 自动按模型限制:

MAX_TOKENS_LIMIT = {
    "glm-4.7-flash": 65536,
    "glm-4.6v-flash": 32768,
}

前端统一发送 65536,后端自动 clamp。

Q: 上传图片后模型不直接描述,而是走规划/委派

原因:DeepAgents 的 system_prompt 中"先规划再执行"的指令会影响模型;GLM-4.6V 开启 thinking 模式会进入规划路径。

解决方案

  1. system_prompt 中明确"图片消息直接处理,不规划不委派"
  2. vision 模型强制关闭 thinking 模式(agent.py 中的 actual_thinking = thinking and mt != "vision"

Q: SubAgent 配置报 KeyError: ‘system_prompt’

原因:DeepAgents 0.6.12 的 SubAgent 字段名是 system_prompt,不是 prompt

解决方案:使用 "system_prompt": "..." 而非 "prompt": "..."

Q: SubAgent tools 传字符串列表报错

原因create_deep_agent 要求 toolsBaseTool 实例列表。

解决方案:用 _resolve_tools() 函数将工具名解析为 BaseTool 实例。

Q: TypeScript 编译报 ERR_PACKAGE_PATH_NOT_EXPORTED

原因:TypeScript 7 移除了 ./lib/tsc 子路径,与 vue-tsc 3.3.7 不兼容。

解决方案:使用 TypeScript 6.0.3("typescript": "^6.0.3")。

Q: TS 报 baseUrl is deprecated (TS5101)

原因:TypeScript 6 弃用 baseUrl。

解决方案:从 tsconfig.json 移除 baseUrl,paths 用相对路径 "./src/*"

Q: 主题切换按钮不显示

原因:App.vue 中缺少 import ThemeSwitcher from './components/ThemeSwitcher.vue'

解决方案:确保 import 存在。

Q: 聊天窗口中图片不显示预览

原因:ChatWindow.vue 的 getContentText() 只提取文本,未提取 image_url 项。

解决方案:添加 getContentImages() 函数,在模板中用 v-for 渲染 <img>

Q: AI 回复的 Markdown 没有被渲染

原因:ChatWindow.vue 使用 <pre> 纯文本显示,未使用 Markdown 渲染。

解决方案:安装 marked 库,AI 消息使用 v-html="renderMarkdown(content)"

Q: GLM API 返回 429 “该模型当前访问量过大”

原因:GLM 免费模型被太多用户同时调用触发了速率限制。

解决方案:等待 1-2 分钟后重试。

Q: 后端在事件循环中偶发卡顿

原因:原 _astream() 使用 OpenAI + asyncio.to_thread 包装同步流式。

解决方案:使用 AsyncOpenAIglm_chat.py 中已实现)实现原生异步流式。

第 40 章:流式通信协议详解

40.1 前端 → 后端(请求)

两种方式:

SSE(POST)

POST http://localhost:8002/agent/sse
Content-Type: application/json

{
  "messages": [
    {"role": "user", "content": "你好"}
  ],
  "thinking": true,
  "model_type": "text",
  "max_tokens": 65536,
  "temperature": 1.0
}

WebSocket

WS ws://localhost:8002/agent/ws
→ 发送:
{
  "type": "chat",
  "messages": [...],
  "thinking": true,
  "model_type": "text",
  "max_tokens": 65536,
  "temperature": 1.0
}

40.2 后端 → 前端(流式帧)

每帧是简单 JSON 对象,通过 SSE 的 data: ...\n\n 或 WebSocket 的 JSON 文本传输:

帧类型 字段 说明
reasoning content 思考内容(reasoning_content)
content content 正式回复
tool_start content, tool_name, tool_input 工具调用开始
tool_end content, tool_name, tool_output 工具调用完成
plan content 任务规划(write_todos)
subagent_start content, subagent_name 子代理启动
subagent_message content, subagent_name 子代理消息
subagent_end content, subagent_name 子代理完成
done 流结束
error content 错误

40.3 多模态消息格式

vision 模型下用户消息 content 是 array:

{
  "role": "user",
  "content": [
    {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
    {"type": "text", "text": "描述这张图片"}
  ]
}

附录:官方文档链接


教程结束。跟随本教程,从零手写即可得到一个功能完整、可运行的 GLM DeepAgents 智能体应用 🎉

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐