ZeroClaw Reflex UI
完整搭建流程
ZeroClaw Gateway + LM Studio + Reflex 本地 AI 管理面板
前言:为什么要给 ZeroClaw 做 Web UI?
ZeroClaw 是一个用 Rust 编写的高性能本地 AI 网关工具,设计目标是速度快、体积小、无依赖。但它本身只有命令行界面(CLI),每次使用都需要手动输入命令,管理起来不够直观。
ZeroClaw
https://github.com/zeroclaw-labs/zeroclaw
本文记录了从零开始,用 Python Reflex 框架 为 ZeroClaw 打造一个现代化 Web 管理面板的完整过程,包括踩过的坑和最终解决方案。
Python Reflex 框架
GitHub - reflex-dev/reflex: Web apps in pure Python
| 组件 | 说明 |
|---|
| ZeroClaw | Rust 编写的本地 AI 网关,提供 /webhook HTTP 接口 |
| LM Studio | 本地大模型运行环境,提供 OpenAI 兼容 API |
| Reflex | Python 全栈 Web 框架,前后端均用 Python 编写 |
| llama.cpp | 底层推理引擎(可选) |
第一步:环境准备
1.1 安装依赖
在 ZeroClaw 项目根目录,激活虚拟环境后安装所需 Python 包:
# 激活虚拟环境(Windows PowerShell)
.venv\Scripts\Activate.ps1
# 安装依赖
pip install reflex psutil python-dotenv requests pywin32


1.2 初始化 Reflex 项目
mkdir zeroclaw-reflex-ui
cd zeroclaw-reflex-ui
reflex init
⚠️ Reflex init 会生成同名的 Python 包目录和入口文件,注意不要覆盖错位置。

1.3 放置主文件
将我们编写的 zeroclaw_reflex_ui.py 覆盖到 Reflex 自动生成的同名文件:
# Windows 命令
move zeroclaw_reflex_ui.py zeroclaw_reflex_ui\
# 提示覆盖时选 Yes(Y)
zeroclaw_reflex_ui.py 完整内容示例:
import re
import time
import tomllib
import reflex as rx
import requests
import subprocess
import os
import threading
from dotenv import load_dotenv
from typing import Dict, List, Optional
_ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07')
load_dotenv(".env")
GATEWAY_URL = "http://127.0.0.1:8080"
ZEROCLAW_PATH = "J:\\PythonProjects4\\zeroclaw\\target\\release\\zeroclaw.exe"
ZEROCLAW_CONFIG = os.path.expanduser("~\\.zeroclaw\\config.toml")
_gateway_process: Optional[subprocess.Popen] = None
_gateway_lock = threading.Lock()
def _start_gateway_process(lm_url: str, lm_key: str, model: str) -> subprocess.Popen:
"""在后台启动 zeroclaw gateway 进程"""
env = os.environ.copy()
env["OPENAI_API_BASE"] = lm_url
env["OPENAI_BASE_URL"] = lm_url
env["OPENAI_API_KEY"] = lm_key
env["LM_STUDIO_API_URL"] = lm_url
env["LM_STUDIO_API_KEY"] = lm_key
env["MODEL_ID"] = model
proc = subprocess.Popen(
[ZEROCLAW_PATH, "gateway"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
)
proc
() -> :
:
r = requests.get(, timeout=)
r.status_code ==
Exception:
(rx.State):
lm_studio_api_url: = os.getenv(, )
lm_studio_api_key: = os.getenv(, )
model_id: = os.getenv(, )
models: [] = []
user_message: =
system_prompt: =
chat_history: [[, ]] = []
is_loading: =
gpu_usage: =
lm_studio_status: =
gateway_status: =
zeroclaw_bin_status: =
():
.lm_studio_api_url = value
():
.lm_studio_api_key = value
():
.model_id = value
():
.user_message = value
():
.system_prompt = value
():
State.send_message
():
:
(ZEROCLAW_CONFIG, ) f:
config = tomllib.load(f)
.system_prompt = config.get(, .system_prompt)
Exception:
():
:
(ZEROCLAW_CONFIG, , encoding=) f:
content = f.read()
escaped = .system_prompt.replace(, ).replace(, )
new_line =
re.search(, content, re.MULTILINE):
content = re.sub(
, new_line, content, flags=re.MULTILINE
)
:
content = new_line + + content
(ZEROCLAW_CONFIG, , encoding=) f:
f.write(content)
State.stop_gateway
State.start_gateway
rx.toast.success()
Exception e:
rx.toast.error()
():
_gateway_process
_gateway_lock:
_check_gateway_alive():
.gateway_status =
rx.toast.info()
_gateway_process _gateway_process.poll() :
_gateway_process.kill()
:
_gateway_process = _start_gateway_process(
.lm_studio_api_url,
.lm_studio_api_key,
.model_id
)
_ ():
time.sleep()
_check_gateway_alive():
.gateway_status =
rx.toast.success()
.gateway_status =
rx.toast.error()
FileNotFoundError:
.gateway_status =
rx.toast.error()
Exception e:
.gateway_status =
rx.toast.error()
():
_gateway_process
_gateway_lock:
_gateway_process _gateway_process.poll() :
_gateway_process.kill()
_gateway_process =
.gateway_status =
rx.toast.success()
:
.gateway_status =
rx.toast.info()
():
:
response = requests.get(
,
headers={: },
timeout=
)
response.status_code == :
data = response.json()
.models = [model[] model data.get(, [])]
.lm_studio_status =
.model_id .models:
.model_id = .models[]
:
.lm_studio_status =
.models = []
Exception:
.lm_studio_status =
.models = []
():
(, ) f:
f.write()
f.write()
f.write()
rx.toast.success()
():
.user_message.strip():
rx.toast.error()
_check_gateway_alive():
rx.toast.error()
user_text = .user_message
.chat_history.append({: , : user_text})
.is_loading =
.user_message =
:
response = requests.post(
,
json={: user_text, : .system_prompt},
timeout=
)
response.status_code == :
data = response.json()
(data, ):
raw = (
data.get()
data.get()
data.get()
data.get()
(data)
)
:
raw = (data)
clean = _ANSI_ESCAPE.sub(, raw)
lines = clean.splitlines()
reply_lines = [
ln ln lines
re.(, ln)
]
reply = .join(reply_lines).strip() clean.strip()
.chat_history.append({: , : reply})
:
.chat_history.append({
: ,
:
})
requests.exceptions.Timeout:
.chat_history.append({
: ,
:
})
Exception e:
.chat_history.append({
: ,
:
})
:
.is_loading =
():
.chat_history = []
():
.zeroclaw_bin_status = os.path.exists(ZEROCLAW_PATH)
.gateway_status = _check_gateway_alive()
:
result = subprocess.run(
[, , ],
capture_output=,
text=,
timeout=
)
.gpu_usage = result.returncode ==
Exception:
.gpu_usage =
.fetch_lm_studio_models()
() -> rx.Component:
rx.box(
rx.text(label, size=, color=, margin_bottom=),
rx.text(value, size=, font_weight=),
border_radius=,
background_color=,
)
() -> rx.Component:
rx.card(
rx.vstack(
rx.heading(, size=),
rx.grid(
status_card(, State.zeroclaw_bin_status),
status_card(, State.gateway_status),
status_card(, State.lm_studio_status),
status_card(, State.gpu_usage),
columns=,
gap=
),
rx.hstack(
rx.button(, on_click=State.start_gateway, color_scheme=, size=),
rx.button(, on_click=State.stop_gateway, color_scheme=, size=),
rx.button(, on_click=State.update_system_status, size=),
spacing=
),
rx.callout(
rx.text(, size=),
color=,
size=
),
spacing=,
),
margin_bottom=
)
() -> rx.Component:
rx.card(
rx.vstack(
rx.heading(, size=),
rx.text(, size=, color=),
rx.(
value=State.lm_studio_api_url,
on_change=State.set_lm_studio_api_url,
placeholder=,
),
rx.text(, size=, color=),
rx.(
value=State.lm_studio_api_key,
on_change=State.set_lm_studio_api_key,
placeholder=,
=,
),
rx.text(, size=, color=),
rx.select(
State.models,
value=State.model_id,
on_change=State.set_model_id,
placeholder=,
),
rx.hstack(
rx.button(, on_click=State.fetch_lm_studio_models, size=),
rx.button(, on_click=State.save_config, color_scheme=, size=),
spacing=
),
rx.divider(),
rx.text(, size=, color=),
rx.callout(
rx.text(, size=),
color=,
size=
),
rx.text_area(
value=State.system_prompt,
on_change=State.set_system_prompt,
placeholder=,
rows=
),
rx.button(
,
on_click=State.save_system_prompt_to_config,
color_scheme=,
size=,
),
spacing=,
),
margin_bottom=
)
() -> rx.Component:
is_user = msg[] ==
rx.box(
rx.hstack(
rx.text(
rx.cond(is_user, , ),
font_weight=,
color=rx.cond(is_user, , ),
white_space=,
min_width=
),
rx.text(, color=),
rx.cond(
is_user,
rx.text(msg[], flex=),
rx.box(
rx.markdown(msg[]),
flex=,
class_name=
)
),
),
background_color=rx.cond(is_user, , ),
border_left=rx.cond(is_user, , ),
border_radius=,
margin_bottom=,
)
() -> rx.Component:
rx.card(
rx.vstack(
rx.hstack(
rx.heading(, size=),
rx.spacer(),
rx.button(, on_click=State.clear_chat, size=, color_scheme=),
),
rx.box(
rx.cond(
State.chat_history.length() == ,
rx.center(
rx.text(, color=, size=),
),
rx.foreach(State.chat_history, chat_bubble)
),
overflow_y=,
border_radius=,
),
rx.form(
rx.hstack(
rx.(
placeholder=,
value=State.user_message,
on_change=State.set_user_message,
name=,
disabled=State.is_loading
),
rx.button(
rx.cond(
State.is_loading,
rx.hstack(rx.spinner(size=), rx.text(), spacing=),
rx.text()
),
=,
disabled=State.is_loading,
color_scheme=,
size=
),
spacing=
),
on_submit=State.handle_form_submit,
),
spacing=,
),
)
() -> rx.Component:
rx.container(
rx.vstack(
rx.heading(, size=, margin_bottom=),
rx.text(, size=, color=, margin_bottom=),
gateway_panel(),
config_panel(),
chat_interface(),
max_width=,
spacing=,
)
)
app = rx.App()
app.add_page(
index,
title=,
on_load=[State.update_system_status, State.load_system_prompt_from_config]
)
__name__ == :
app.run()
第二步:理解 ZeroClaw 网关架构
2.1 正确的通信方式
这是本项目最关键的发现。ZeroClaw 提供了一个 HTTP 网关服务,支持以下接口:
| 接口 | 说明 |
|---|
| POST /webhook | {"message": "你的提问"} → AI 回复 |
| GET /health | 健康检查(用于检测网关是否在线) |
| POST /pair | 配对新客户端 |
错误做法(最初的方案): 直接调用 zeroclaw.exe agent 命令行
zeroclaw.exe agent --message "你好" --model xxx --api-base http://...
正确做法: 启动网关后,直接 POST 到 /webhook 接口
import requests
response = requests.post(
"http://127.0.0.1:8080/webhook",
json={"message": "你好"},
timeout=120
)
reply = response.json().get("response", "")
2.2 网关启动方式
ZeroClaw 网关通过以下命令启动,API 配置通过环境变量传入:
zeroclaw gateway
手动启动方式
zeroclaw gateway
输出示例:
# 🚀 Starting ZeroClaw Gateway on 127.0.0.1:8080
# POST /webhook — {"message": "your prompt"}
# GET /health — health check

在 Reflex UI 里,我们用 subprocess.Popen 在后台启动网关进程,通过环境变量注入配置:
env = os.environ.copy()
env["OPENAI_API_BASE"] = lm_studio_url
env["OPENAI_API_KEY"] = lm_studio_key
proc = subprocess.Popen([ZEROCLAW_PATH, "gateway"], env=env,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
第三步:Reflex 开发关键踩坑记录
Reflex 0.8.x 版本变化较大,以下是本次开发中遇到的所有报错及解决方案:
3.1 Button size 参数
| ❌ 旧写法 | ✅ 新写法(0.8.x) |
|---|
| size="sm" | size="2" |
| size="lg" | size="3" |
| is_disabled=True | disabled=True |
3.2 自动 Setter 弃用
错误: DeprecationWarning: state_auto_setters defaulting to True
Reflex 0.8.9+ 不再自动生成 set_xxx 方法,需要手动在 State 类里定义:
class State(rx.State):
lm_studio_api_url: str
def set_lm_studio_api_url(self, value: str):
self.lm_studio_api_url = value
3.3 rx.foreach 里不能用 Python if/else
错误: VarTypeError: Cannot convert Var to bool
在 rx.foreach 的 lambda 里,变量是 Reflex 响应式 Var,不能用 Python 原生条件:
"你" if msg["role"] == "user" else "AI"
rx.cond(msg["role"] == "user", "你", "AI")
background_color=rx.cond(msg["role"] == "user", "#f0f", "#0ff")
3.4 rx.input 不支持 on_submit
错误: ValueError: TextFieldRoot does not take in an on_submit event trigger
Reflex 的 rx.input 组件不支持 on_submit。解决方案是用 rx.form 包裹,通过表单提交触发:
rx.form(
rx.hstack(
rx.input(value=State.user_message, on_change=State.set_user_message),
rx.button("发送", type="submit")
),
on_submit=State.handle_form_submit
)
def handle_form_submit(self, form_data: dict):
yield State.send_message
3.5 rx.select 的正确用法
Reflex 的 rx.select 将选项列表作为第一个位置参数传入,不用 options= 关键字:
rx.select(label="模型", options=State.models, value=State.model_id)
rx.select(State.models, value=State.model_id, on_change=State.set_model_id)
第四步:System Prompt 的实现
4.1 网关不支持动态传入 system_prompt
通过分析 ZeroClaw 源码(src/gateway/mod.rs),发现网关的 /webhook 接口参数签名为:
_system_prompt: Option<&str>,
这意味着通过 /webhook 传入的 system_prompt 字段会被直接丢弃。
4.2 正确方式:写入配置文件
ZeroClaw 的 system_prompt 在 config.toml 里配置,网关启动时读取:
system_prompt = "你是一个本地运行的 AI 助手,基于开源大模型。"
在 UI 里实现了「保存 System Prompt 并重启网关」功能,通过正则替换写入配置文件后自动重启网关:
def save_system_prompt_to_config(self):
with open(ZEROCLAW_CONFIG, "r", encoding="utf-8") as f:
content = f.read()
new_line = f'system_prompt = "{self.system_prompt}"'
if re.search(r'^system_prompt', content, re.MULTILINE):
content = re.sub(r'^system_prompt.*$', new_line, content, flags=re.MULTILINE)
else:
content = new_line + "\n" + content
with open(ZEROCLAW_CONFIG, "w", encoding="utf-8") as f:
f.write(content)
yield State.stop_gateway
yield State.start_gateway
第五步:清理模型输出的 ANSI 控制码
ZeroClaw 网关返回的 response 字段有时会包含终端 ANSI 控制码和日志行,需要过滤:
import re
_ANSI_ESCAPE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07')
def clean_reply(raw: str) -> str:
clean = _ANSI_ESCAPE.sub("", raw)
lines = clean.splitlines()
reply_lines = [
ln for ln in lines
if not re.match(r'^\s*(INFO|WARN|ERROR|\d{4}-\d{2}-\d{2})', ln)
]
return "\n".join(reply_lines).strip() or clean.strip()
第六步:完整使用流程
每次启动顺序
- 启动 LM Studio,加载模型并开启本地服务器(默认端口 1234)
- 进入项目目录,激活虚拟环境
cd zeroclaw-reflex-ui
.venv\Scripts\Activate.ps1
reflex run
- 浏览器打开 http://localhost:3000
- 在配置面板填写 LM Studio API 地址,点击「刷新模型列表」选择模型
- 点击「▶ 启动网关」,等待状态显示「✅ 运行中」
- 在对话窗口输入消息,开始聊天
⚡ 网关启动后可以直接对话,不需要每次重启 Reflex 服务。配置修改后才需要重启网关。

UI 功能一览
| 功能模块 | 说明 |
|---|
| ▶ 启动网关 | 在后台启动 zeroclaw gateway,自动注入 LM Studio 配置 |
| ■ 停止网关 | 终止网关进程 |
| ↻ 刷新状态 | 检测网关心跳、GPU 使用率、LM Studio 连接状态 |
| 刷新模型列表 | 从 LM Studio API 获取当前加载的模型列表 |
| 💾 保存配置 | 将 API 地址、密钥、模型 ID 保存到 .env 文件 |
| System Prompt | 修改并写入 config.toml,自动重启网关生效 |
| 对话窗口 | 支持 Markdown 渲染,回复显示 AI/用户气泡样式 |
| 🗑 清空对话 | 清除当前会话历史记录 |

总结
本项目从零到能跑,主要经历了以下几个阶段:
- 架构误解纠正: 从「调用 CLI 命令」改为「HTTP 调用 /webhook 接口」
- Reflex API 适配: 解决了 5+ 个 0.8.x 版本的 API 变更问题
- System Prompt 实现: 通过写入 config.toml + 重启网关的方式生效
- 输出清洗: 过滤 ANSI 控制码和日志行,让 AI 回复干净呈现
- Markdown 渲染: 使用 rx.markdown() 组件,AI 回复支持表格、代码块等格式
完整代码见 zeroclaw_reflex_ui.py,单文件约 350 行,涵盖了网关管理、对话、配置保存的完整功能。
ZeroClaw 本身的设计哲学:零依赖、极速、小体积。配合 Reflex UI,终于有了一个对人类友好的操作界面。