GLM DeepAgents 智能体应用 — 完整手教程
GLM DeepAgents 智能体应用 — 完整手教程
跟随本教程,从零手写整个项目:FastAPI + DeepAgents 后端 + Vue 3 + Vite 前端
完成后你将得到一个支持双模型切换、SSE/WebSocket 双通信、主题切换、Markdown 渲染、图片上传的完整 AI 智能体应用。


目录
第一部分:项目总览
第二部分:后端实现(Python)
- 第 4 章:项目结构与 requirements.txt
- 第 5 章:环境变量 .env
- 第 6 章:自定义工具 tools.py
- 第 7 章:GLM Chat Model glm_chat.py
- 第 8 章:子代理配置 subagents.py
- 第 9 章:智能体创建 agent.py
- 第 10 章:流式事件处理器 stream.py
- 第 11 章:FastAPI 应用 main.py
- 第 12 章:智能体记忆 AGENTS.md
- 第 13 章:Skills 技能定义
- 第 14 章:启动后端
第三部分:前端实现(Vue 3)
- 第 15 章:前端项目结构与 package.json
- 第 16 章:TypeScript 配置 tsconfig.json
- 第 17 章:Vite 配置 vite.config.ts
- 第 18 章:HTML 入口 index.html
- 第 19 章:应用入口 main.ts
- 第 20 章:类型定义 types/index.ts
- 第 21 章:Markdown 工具 utils/markdown.ts
- 第 22 章:主题管理 composables/useTheme.ts
- 第 23 章:图片上传 composables/useImageUpload.ts
- 第 24 章:SSE 客户端 composables/useSse.ts
- 第 25 章:WebSocket 客户端 composables/useWebSocket.ts
- 第 26 章:聊天核心 composables/useChat.ts
- 第 27 章:模型切换器 components/ModelSwitcher.vue
- 第 28 章:通信模式切换器 components/TransportSwitcher.vue
- 第 29 章:主题切换器 components/ThemeSwitcher.vue
- 第 30 章:思考面板 components/ThinkingPanel.vue
- 第 31 章:任务规划面板 components/TodoPanel.vue
- 第 32 章:工具面板 components/ToolPanel.vue
- 第 33 章:子代理面板 components/SubagentPanel.vue
- 第 34 章:对话窗口 components/ChatWindow.vue
- 第 35 章:消息输入框 components/MessageInput.vue
- 第 36 章:根组件 App.vue
- 第 37 章:全局样式 styles/main.css
- 第 38 章:启动前端
第四部分:进阶
第一部分:项目总览
第 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
- 访问 https://bigmodel.cn 注册并登录
- 进入 API Key 管理页面创建一个 Key
- 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-flash和glm-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]
关键点:
@tool装饰器把普通函数转为 LangChain 工具- 函数的
docstring是工具描述,模型会看到 - 参数类型注解(
expression: str)会自动转为 JSON Schema calculate用ast模块解析数学表达式,禁止eval()避免任意代码执行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 类即可。
关键设计:
- GLM 的特殊字段
reasoning_content:开启思考模式后返回的是reasoning_content字段,需要单独提取 - 多模态 content:图片消息的 content 是 array 结构(OpenAI 格式),含
image_url项 - thinking 透传:通过
extra_body={"thinking": {"type": "enabled"}}启用 - bind_tools:DeepAgents 会调用此方法绑定工具,我们要返回新实例而不是原地修改
- 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 的 AIMessageChunkcontent字段我们用 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]
关键点:
- SubAgent 字段名是
system_prompt(不是prompt),这是 DeepAgents 的硬性要求 tools字段必须是BaseTool实例列表(不是字符串名),所以我们用_resolve_tools()转换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
关键设计:
- 每次请求都新建 agent:LangGraph 的 StateGraph 在某些情况下不能 pickle,每次新建最稳妥
- max_tokens 动态 clamp:前端统一发 65536,后端根据模型自动 clamp 到合法范围
- 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"}
重点理解:
- 帧字典:每个事件都是简单 dict,可直接
json.dumps序列化 - 子代理检测:通过 LangGraph 的
langgraph_checkpoint_ns字段识别 - v2 优先:v3
version="v3"API 还在变化,v2 在 LangGraph 1.2.9 中最稳定 - 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)
重点:
- CORS 允许
http://localhost:5173(前端 Vite 默认端口) - SSE 格式:每帧
data: {...}\n\n,浏览器会自动解析 - 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持续追加到currentReply,onDone时固化到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>
关键设计:
- 统一提取函数:
getContentText和getContentImages处理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 模式会进入规划路径。
解决方案:
- system_prompt 中明确"图片消息直接处理,不规划不委派"
- 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 要求 tools 是 BaseTool 实例列表。
解决方案:用 _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 包装同步流式。
解决方案:使用 AsyncOpenAI(glm_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 智能体应用 🎉
更多推荐



所有评论(0)