ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# 前端依赖与构建产物
frontend/node_modules/
frontend/dist/
node_modules/
# Python
__pycache__/
*.pyc
.venv/
venv/
# 环境变量
.env
# Vite 缓存
frontend/.vite/
+404
View File
@@ -0,0 +1,404 @@
# Experiment 5-11: Conversational UI Customization / 实验 5-11:对话式界面定制系统(★★)
> Companion lab for *AI Agents in Depth*, Chapter 5 — NL UI requests (color/font/copy/layout); Agent edits React source; Vite HMR applies live.
> 《深入理解 AI Agent》第 5 章:自然语言提 UI 定制需求,Agent 改 React 源码,Vite HMR 即时生效。
← [Chapter 5 index / 返回第 5 章目录](../README.md)
---
## English
### Overview
Users describe UI customizations in **natural language** (color / font / copy / layout / component placement). The Agent **locates and edits front-end source**; dev-mode **HMR** applies changes instantly, with multi-turn iteration.
### Purpose
Turn a one-size-fits-all front end into a conversationally customizable UI:
- Base chatbot = **React (Vite) front end + FastAPI back end**;
- Both run in dev mode: Vite **HMR**, uvicorn **--reload**;
- User says “make the send button blue / monospace font / title = XXX”;
Agent (OpenAI, default `gpt-5.6-luna`; if no `OPENAI_API_KEY`, set `OPENROUTER_API_KEY` for OpenRouter) reads the request → edits files under `frontend/src`;
- HMR picks up changes without a full page reload.
### Architecture (brief)
Four parts:
- **`agent.py` (customize Agent)**: core. NL requirement + current editable sources → OpenAI; function-calling `apply_edits` returns full rewritten file contents. Only whitelist files (`src/App.jsx`, `src/theme.css`); path checks after return. Produces rewrites **without writing disk** (for diff display + verification).
- **`baseline/src/`**: factory snapshot of front-end sources. Each `demo.py` run restores from here so runs are reproducible and isolated—also the baseline for diffs.
- **`frontend/` (React + Vite)**: what gets customized. Agent edits `src/*`; Vite **HMR** in dev; `vite build` checks “did not break the app”.
- **`backend/` (FastAPI)**: minimal chatbot (`/api/chat`) so the UI can actually chat; default **echo** mode (no key); `--model` switches to **real LLM chat**; CLI via `python main.py --help`; `--reload` demos backend HMR. Not part of UI customize—supporting actor only.
One line: **Agent reads request → edits front-end source → assert change applied + build still works**; `baseline` for reproducibility; `backend` for real chat.
### Hot reload (HMR)
- **Front end**: `npm run dev` Vite HMR. Agent edits `src/*.jsx` or `src/theme.css` → partial hot replace, state kept.
- **Back end**: `uvicorn main:app --reload` restarts on `.py` changes.
- Customization targets front-end sources; visual effect is front-end HMR.
### Directory layout
```
conversational-ui/
├── frontend/ # React + Vite chatbot UI
│ ├── src/App.jsx # UI + copy (Agent: copy/components)
│ ├── src/theme.css # colors/fonts/layout (Agent: styles)
│ ├── src/main.jsx
│ ├── index.html
│ ├── vite.config.js # HMR + /api proxy to backend
│ └── package.json
├── backend/
│ ├── main.py # FastAPI (/api/chat)
│ └── requirements.txt
├── baseline/src/ # initial snapshot (restored before each demo)
├── agent.py # NL → OpenAI rewrite sources
├── demo.py # e2e demo + auto verify (NL→code→assert→build)
├── requirements.txt # backend + Agent deps
├── env.example
└── .gitignore # node_modules / dist / .env ignored
```
### How to run
#### 1) Environment
```bash
# From the repository root: Python deps (Agent + backend)
uv sync --locked --python 3.12 --extra ch5
# Activate it before changing directories:
# macOS/Linux:
source .venv/bin/activate
# Windows PowerShell: .\.venv\Scripts\Activate.ps1
# Windows cmd: .venv\Scripts\activate.bat
# pip fallback when uv is not installed:
# python -m pip install -e ".[ch5]"
cd chapter5/conversational-ui
# Single-project compatibility path, still supported during migration:
# python -m pip install -r requirements.txt
# Front-end deps (first npm install can be slow)
cd frontend && npm install && cd ..
# OpenAI key
cp env.example .env # OPENAI_API_KEY (or OPENROUTER_API_KEY fallback)
```
#### 2) Auto-verify loop (no browser)
```bash
python demo.py # all 3 customize rounds + full verify
python demo.py --quick # round 1 only (smoke)
python demo.py --rounds 2 # first 2 rounds
python demo.py --no-build # skip vite build (assert apply only; faster)
python demo.py -h
```
`demo.py` runs 3 NL customize rounds: real OpenAI rewrite → print diff → re-read sources and assert → `vite build`. First round may be slow (`npm install` / first build); use `--quick` or `--no-build`.
#### 3) Manual real HMR (optional; needs browser)
```bash
# Terminal A: backend (hot reload). Either:
cd backend && python main.py --reload --port 8000
# or: cd backend && uvicorn main:app --reload --port 8000
# real LLM chat (not echo): add --model gpt-5.6-luna (needs OPENAI_API_KEY or OPENROUTER_API_KEY)
# Terminal B: front end (HMR)
cd frontend && npm run dev
# open http://localhost:5173
# Terminal C: one customize request; watch the browser update
python -c "import agent,pathlib; c,m=agent.build_client_and_model(); \
r=agent.customize(c,m,pathlib.Path('frontend'),'把发送按钮改成橙色'); \
[pathlib.Path('frontend',f['path']).write_text(f['content']) for f in r['files']]"
```
Backend CLI (`cd backend && python main.py --help`):
| Flag | Description | Default |
| --- | --- | --- |
| `--host` | Bind address (`0.0.0.0` for external) | `127.0.0.1` |
| `--port` | Port (front end proxies `/api` here) | `8000` |
| `--reload` / `--no-reload` | Backend hot reload | on |
| `--model NAME` | Real LLM chat; omit = echo (`CHAT_MODEL` env also works) | none (echo) |
| `--log-level` | uvicorn log level | `info` |
| `--print-config` | Print effective config JSON and exit (no listen) | off |
> Echo vs LLM does not affect the UI customize loop—customize acts on **front-end sources**. LLM mode reuses `OPENAI_API_KEY` / `OPENAI_BASE_URL` from `agent.py`; missing key or call failure falls back to a placeholder reply (never invents).
### Verification and limits
- **This demo auto-verifies**: NL → code change **applied correctly** and **build not broken**.
- Source asserts: e.g. blue `#2563eb` appears; monospace appears; new title string appears.
- After each round `vite build` must succeed.
- **This demo does not verify**: real in-browser HMR **visual** refresh (no Playwright/browser here)—use step 3 manually.
- Agent may only rewrite whitelist files (`src/App.jsx`, `src/theme.css`); full-file rewrite is more stable than scattered patches on small files.
### Real run output (excerpt)
```
第 1 轮 NL 定制需求:把发送按钮和用户消息气泡的主题色从绿色改成蓝色,用 #2563eb 这个蓝。
[改动文件] src/theme.css
- --color-primary: #16a34a; /* 初始为绿色 */
+ --color-primary: #2563eb; /* 改为蓝色 */
断言:源码中出现蓝色值 #2563eb -> 通过 ✅
构建结果:通过 ✅
第 2 轮 NL 定制需求:把整个界面的字体换成等宽字体(monospace)。
[改动文件] src/theme.css
- --font-family: system-ui, "PingFang SC", ... sans-serif;
+ --font-family: monospace;
断言:源码中出现 monospace 等宽字体 -> 通过 ✅
构建结果:通过 ✅
第 3 轮 NL 定制需求:把顶部的标题文案改成"我的专属客服"。
[改动文件] src/App.jsx
- const HEADER_TITLE = "智能助手";
+ const HEADER_TITLE = "我的专属客服";
断言:源码中出现新标题文案"我的专属客服" -> 通过 ✅
构建结果:通过 ✅
多轮定制总结:全部通过 ✅
```
### Environment variables
| Variable | Description |
| --- | --- |
| `OPENAI_API_KEY` | One of required; this lab reads it (`OPENROUTER_API_KEY` fallback) |
| `OPENAI_BASE_URL` | Optional OpenAI-compatible endpoint |
| `MODEL` | Optional; default `gpt-5.6-luna` |
### Adapt / extend
- **Model / provider**: standard OpenAI SDK; set `OPENAI_BASE_URL` + `MODEL` + `OPENAI_API_KEY`, e.g. Kimi / ARK / local vLLM / Ollama.
- **Editable surface**: default whitelist `src/App.jsx`, `src/theme.css`—edit `EDITABLE_FILES` in `agent.py` (larger = more flexible, more risk).
- **New verify rounds**: append `{"requirement": ..., "verify": ...}` to `ROUNDS` in `demo.py`.
- **Own UI**: replace `frontend/src/*` and update whitelist + `baseline/`.
- **Own backend / real LLM chat**: `/api/chat` is echo by default; `--model <name>` or `CHAT_MODEL` for real chat; customize `_llm_reply` / `chat` for business logic.
---
## 中文
### 概述
用户用**自然语言**提出 UI 定制需求(颜色 / 字体 / 文案 / 布局 / 组件位置),
Agent 自主**定位并修改前端源码**,开发模式下的**热加载(HMR)**让改动即时生效,
支持多轮迭代定制。
### 目的
把"一刀切"的标准前端,变成"千人千面"的可对话定制界面:
- 基础 chatbot 应用 = **React(Vite) 前端 + FastAPI 后端**
- 前后端都跑在开发模式:前端 Vite **HMR**、后端 uvicorn **--reload**
- 用户说"把发送按钮改成蓝色 / 换成等宽字体 / 标题改成 XXX"
AgentOpenAI,默认 `gpt-5.6-luna`;未配置 `OPENAI_API_KEY` 时设 `OPENROUTER_API_KEY` 自动改走 OpenRouter)读懂需求 → 改 `frontend/src` 里的源码文件;
- 热加载检测到文件变化,浏览器无需整页刷新即可看到界面变化。
### 原理 / 架构(简述)
整个系统由四部分组成,各司其职:
- **`agent.py`(定制 Agent)**:核心。把一条自然语言需求 + 当前可编辑源码喂给 OpenAI,
用 function calling 的 `apply_edits` 工具让模型返回"改写后的文件全文"。
只暴露白名单文件(`src/App.jsx``src/theme.css`)给模型,并在返回后校验路径,
防止模型改错/新增文件。它只产出改写方案,**不落盘**(便于展示 diff 与验证)。
- **`baseline/src/`(基线快照)**:前端源码的"出厂原样"。`demo.py` 每轮开始前把它
拷回 `frontend/src`,保证多次运行结果可重复、互不污染——这也是 Agent 改动与
原始界面做 diff 的对照基准。
- **`frontend/`React + Vite 前端)**:被定制的对象。Agent 改的就是这里的 `src/*`
开发模式下 Vite **HMR** 让改动即时可见,`vite build` 用于验证"改动没破坏应用"。
- **`backend/`FastAPI 后端)**:最小 chatbot 服务(`/api/chat`),为前端提供可对话的载体;
默认 **echo 回声**模式(开箱即用、无需任何 Key),也可用 `--model` 一键切到**真实 LLM 对话**
自带命令行入口(`python main.py --help`),`--reload` 演示"后端热加载"。它不参与 UI 定制,
是让整套界面能真实跑起来的配角。
一句话:**Agent 读需求 → 改前端源码 → 断言改动生效 + 构建不破坏**,
`baseline` 保证可重复,`backend` 让界面能真实对话。
### 关于热加载(HMR
- **前端**`npm run dev` 启动的 Vite dev server 自带 HMR。Agent 一改 `src/*.jsx`
`src/theme.css`,浏览器局部热替换、保留应用状态,界面即时更新。
- **后端**`uvicorn main:app --reload` 监听 `.py` 变化自动重启。
- 本实验的定制主要作用于前端源码,所以视觉效果靠前端 HMR 体现。
### 目录结构
```
conversational-ui/
├── frontend/ # React + Vite 前端(基础 chatbot 界面)
│ ├── src/App.jsx # 界面与 UI 文案(Agent 改"文案/组件"
│ ├── src/theme.css # 颜色/字体/布局样式(Agent 改"样式"
│ ├── src/main.jsx
│ ├── index.html
│ ├── vite.config.js # 开启 HMR + /api 代理到后端
│ └── package.json
├── backend/
│ ├── main.py # FastAPI 后端(/api/chat
│ └── requirements.txt
├── baseline/src/ # 前端源码初始快照(demo 每次运行前恢复,保证可重复)
├── agent.py # 定制 AgentNL 需求 → 用 OpenAI 改写源码
├── demo.py # 端到端演示 + 自动验证(NL→代码→断言→构建)
├── requirements.txt # 后端 + Agent 依赖
├── env.example
└── .gitignore # node_modules / dist / .env 均已忽略
```
### 运行方式
#### 1) 准备环境
```bash
# 在仓库根目录安装 Python 依赖(Agent + 后端)
uv sync --locked --python 3.12 --extra ch5
# 切换目录前先激活环境:
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell.\.venv\Scripts\Activate.ps1
# Windows cmd.venv\Scripts\activate.bat
# 未安装 uv 时可用 pip 兜底:
# python -m pip install -e ".[ch5]"
cd chapter5/conversational-ui
# 迁移期间仍支持单项目兼容路径:
# python -m pip install -r requirements.txt
# 前端依赖(首次 npm install 较慢属正常)
cd frontend && npm install && cd ..
# 配置 OpenAI Key
cp env.example .env # 然后填入 OPENAI_API_KEY(或设 OPENROUTER_API_KEY 兜底)
```
#### 2) 自动验证闭环(无需浏览器)
```bash
python demo.py # 跑全部 3 轮定制并做完整验证
python demo.py --quick # 只跑第 1 轮(省时,用于快速冒烟)
python demo.py --rounds 2 # 只跑前 2 轮
python demo.py --no-build # 跳过 vite build(仅验证"改动被正确应用",更快)
python demo.py -h # 查看全部参数
```
`demo.py` 会连续跑 3 轮自然语言定制,每轮:
调用真实 OpenAI 改写源码 → 打印改动 diff → 读回源码断言"改动符合需求" →
`vite build` 验证"没破坏应用"。首轮较慢多因 `npm install` 或首次构建,
想快速验证可用 `--quick``--no-build`
#### 3) 手动体验真实 HMR(可选,需要浏览器)
```bash
# 终端 A:后端(热加载)。两种启动方式行为一致,任选其一:
cd backend && python main.py --reload --port 8000 # 本文件自带命令行入口
# 或: cd backend && uvicorn main:app --reload --port 8000 # 书中示例写法
# 想让运行起来的 chatbot 真会说话(而非回声):加 --model gpt-5.6-luna(需 OPENAI_API_KEY 或 OPENROUTER_API_KEY
# 终端 B:前端(HMR
cd frontend && npm run dev
# 打开 http://localhost:5173
# 终端 C:跑一条定制需求,回到浏览器即可看到界面即时变化
python -c "import agent,pathlib; c,m=agent.build_client_and_model(); \
r=agent.customize(c,m,pathlib.Path('frontend'),'把发送按钮改成橙色'); \
[pathlib.Path('frontend',f['path']).write_text(f['content']) for f in r['files']]"
```
后端命令行参数(`cd backend && python main.py --help`):
| 参数 | 说明 | 默认 |
| --- | --- | --- |
| `--host` | 监听地址(对外可用 `0.0.0.0` | `127.0.0.1` |
| `--port` | 监听端口(前端把 `/api` 代理到此端口) | `8000` |
| `--reload` / `--no-reload` | 是否开启后端热加载 | 开启 |
| `--model NAME` | 指定模型名,切到真实 LLM 对话;缺省为 echo 回声模式(也可用环境变量 `CHAT_MODEL` | 无(echo |
| `--log-level` | uvicorn 日志/输出级别 | `info` |
| `--print-config` | 只打印生效配置(JSON)后退出,不监听端口(便于无端口环境下校验) | 关 |
> echo 与 LLM 两种模式都不影响 UI 定制闭环——定制作用于**前端源码**,后端只是让界面能真实对话的载体。
> LLM 模式复用与 `agent.py` 相同的 `OPENAI_API_KEY` / `OPENAI_BASE_URL` 配置;缺 Key 或调用失败会自动回退占位提示,绝不编造回复。
### 验证方式与局限
- **本 demo 自动验证的是**:自然语言 → 代码修改被**正确应用**且**不破坏构建**的闭环。
- 读回源码断言:如"改成蓝色 #2563eb"→ 源码里确实出现该色值;
"换成等宽字体"→ 出现 `monospace`"标题改成 XXX"→ 出现该文案。
- 每轮改动后 `vite build` 必须编译通过,证明改动没破坏应用。
- **本 demo 不做的**:真实浏览器内 HMR 的**视觉**即时刷新。
本机无 Playwright/浏览器,无法自动截图验证视觉效果——
这部分需手动 `npm run dev` + 打开浏览器查看(见上文第 3 步)。
- Agent 只被允许改写白名单文件(`src/App.jsx``src/theme.css`),
降低改错文件的风险;改写采用"整文件重写",对小文件比零散替换更稳。
### 真实运行输出(节选)
```
第 1 轮 NL 定制需求:把发送按钮和用户消息气泡的主题色从绿色改成蓝色,用 #2563eb 这个蓝。
[改动文件] src/theme.css
- --color-primary: #16a34a; /* 初始为绿色 */
+ --color-primary: #2563eb; /* 改为蓝色 */
断言:源码中出现蓝色值 #2563eb -> 通过 ✅
构建结果:通过 ✅
第 2 轮 NL 定制需求:把整个界面的字体换成等宽字体(monospace)。
[改动文件] src/theme.css
- --font-family: system-ui, "PingFang SC", ... sans-serif;
+ --font-family: monospace;
断言:源码中出现 monospace 等宽字体 -> 通过 ✅
构建结果:通过 ✅
第 3 轮 NL 定制需求:把顶部的标题文案改成"我的专属客服"。
[改动文件] src/App.jsx
- const HEADER_TITLE = "智能助手";
+ const HEADER_TITLE = "我的专属客服";
断言:源码中出现新标题文案"我的专属客服" -> 通过 ✅
构建结果:通过 ✅
多轮定制总结:全部通过 ✅
```
### 环境变量
| 变量 | 说明 |
| --- | --- |
| `OPENAI_API_KEY` | 必填其一,本实验读取此项(未配置时用 `OPENROUTER_API_KEY` 兜底) |
| `OPENAI_BASE_URL` | 可选,切换到兼容 OpenAI 协议的服务端点 |
| `MODEL` | 可选,默认 `gpt-5.6-luna` |
### 如何适配 / 扩展
- **换模型 / 换供应商**Agent 走标准 OpenAI SDK,任何"兼容 OpenAI 协议"的服务都能接。
只需在 `.env` 或环境变量里设置 `OPENAI_BASE_URL` + `MODEL` + 对应的 `OPENAI_API_KEY`
代码无需改动。例如:
- Kimi / Moonshot`OPENAI_BASE_URL=https://api.moonshot.cn/v1``MODEL=kimi-k3`
- 火山方舟(ARK)`OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3``MODEL=<endpoint-id>`
- 本地 vLLM / Ollama 等:把 `OPENAI_BASE_URL` 指向本地端点即可。
- **扩展可定制范围**:默认只允许改 `src/App.jsx``src/theme.css`。想让 Agent 能改更多文件,
`agent.py``EDITABLE_FILES` 白名单里增删路径即可(白名单越大越灵活,但改错风险也越大)。
- **新增验证轮次**:在 `demo.py``ROUNDS` 里追加 `{"requirement": ..., "verify": ...}`
即可把自己的定制需求纳入自动断言闭环。
- **接前端**`frontend/` 是标准 Vite 工程,`npm run dev` 起 HMR、`npm run build` 出静态产物。
想接自己的界面,替换 `src/*` 并同步更新白名单与 `baseline/` 快照即可。
- **接后端 / 真实 LLM 对话**:`backend/main.py``/api/chat` 默认是回声式占位回复,
`--model <模型名>`(或设 `CHAT_MODEL`)即可切到真实 LLM 对话(复用上面的 `OPENAI_*` 配置)变成真实客服;
想换成自定义业务逻辑,改写 `_llm_reply``chat` 里的返回即可。
---
## Notes / 说明
- `demo.py --quick` / `--no-build` for cheap smoke; step 3 for real HMR. / 冒烟用 `--quick`/`--no-build`;真实 HMR 见手动第 3 步。
- Commands/code/paths/env vars are identical in both language sections. / 命令、代码、路径与环境变量在中英文两侧保持一致。
+197
View File
@@ -0,0 +1,197 @@
"""实验 5-11:对话式界面定制 Agent。
职责:接收一条自然语言 UI 定制需求(如"把发送按钮改成蓝色"),读取前端源码,
调用 OpenAI 让模型定位并改写相应源文件(颜色 / 字体 / 文案 / 布局 / 组件)。
设计要点
--------
- 只暴露少量"可定制文件"给模型(frontend/src 下的 App.jsx 与 theme.css),
降低模型改错文件的概率,也让改动可控、可验证。
- 通过 function calling 的 `apply_edits` 工具,让模型返回"要整体改写的文件全文"
相比零散的 search/replace,整文件改写对小文件更稳定、更少破坏语法。
- 修改前先把原文件内容快照下来,改后可计算 diff、读回断言,并跑构建验证。
环境变量:
OPENAI_API_KEY (必填,本实验读取此项)
OPENAI_BASE_URL (可选,切换到兼容 OpenAI 协议的服务端点)
MODEL (可选,默认 gpt-5.6-luna
OPENROUTER_API_KEY(可选,无直连 key 时自动改走 OpenRouter 兜底)
"""
import os
import json
from pathlib import Path
from openai import OpenAI
try:
from dotenv import load_dotenv
load_dotenv()
except Exception: # dotenv 是可选依赖
pass
# 可被 Agent 定制的前端源文件(相对 frontend/ 的路径)。
EDITABLE_FILES = [
"src/App.jsx",
"src/theme.css",
]
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def map_model_to_openrouter(model: str) -> str:
"""把直连模型名映射为 OpenRouter 上的 id(非可映射 id 统一兜底到当前廉价旗舰)。"""
if not model or "/" in model:
return model or "openai/gpt-5.6-luna"
m = model.lower()
if m.startswith(("gpt-", "o1", "o3", "o4")):
return "openai/" + model
if m.startswith("claude"):
if "haiku" in m:
return "anthropic/claude-haiku-4.5"
if "sonnet" in m:
return "anthropic/claude-sonnet-4.6"
return "anthropic/claude-opus-4.8"
if m.startswith("gemini"):
return "google/" + model
return "openai/gpt-5.6-luna"
def build_client_and_model():
model = os.getenv("MODEL", "gpt-5.6-luna")
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
orkey = os.getenv("OPENROUTER_API_KEY")
# 通用 OpenRouter 兜底:无直连 key,或默认 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
if prefer_or or (not api_key and orkey):
api_key, base_url, model = orkey, OPENROUTER_BASE_URL, map_model_to_openrouter(model)
if not api_key:
raise SystemExit("未找到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底),请先在环境变量或 .env 中设置。")
# timeout / max_retries:让偶发的网络/SSL 抖动自动重试,不至于整轮崩溃
client_kwargs = {"api_key": api_key, "timeout": 60.0, "max_retries": 3}
if base_url:
client_kwargs["base_url"] = base_url
client = OpenAI(**client_kwargs)
return client, model
APPLY_EDITS_TOOL = {
"type": "function",
"function": {
"name": "apply_edits",
"description": (
"根据用户的界面定制需求,改写一个或多个前端源文件。"
"只返回真正需要改动的文件;每个文件返回改写后的完整内容。"
),
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "用一句话说明本次改了什么(中文)。",
},
"files": {
"type": "array",
"description": "需要改写的文件列表。",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "相对 frontend/ 的文件路径,"
"必须是可编辑文件之一。",
},
"content": {
"type": "string",
"description": "改写后的文件完整内容。",
},
},
"required": ["path", "content"],
},
},
},
"required": ["summary", "files"],
},
},
}
SYSTEM_PROMPT = """你是一个前端界面定制 Agent,负责把用户的自然语言 UI 需求落到 React(Vite) 源码上。
规则:
1. 只能修改用户提供的"可编辑文件",不要新增或删除文件。
2. 优先做最小改动:改颜色/字体/间距等样式,改 theme.css;改文案/组件结构,改 App.jsx。
3. 颜色请使用明确的 CSS 颜色值(如十六进制 #2563eb)。如果用户给了具体色值,就用它。
4. 保持代码可编译:JSX/CSS 语法必须正确,不要破坏原有功能。
5. 必须调用 apply_edits 工具返回结果,files 里给出改写后的完整文件内容。
"""
def read_editable_sources(frontend_dir: Path) -> dict:
"""读取所有可编辑文件当前内容,返回 {相对路径: 内容}。"""
sources = {}
for rel in EDITABLE_FILES:
p = frontend_dir / rel
sources[rel] = p.read_text(encoding="utf-8")
return sources
def customize(client, model, frontend_dir: Path, requirement: str) -> dict:
"""让模型针对一条自然语言需求改写源码,返回 apply_edits 的参数 dict。
仅调用模型并解析工具参数,不落盘(写文件、验证在 demo.py 里做,便于展示 diff)。
"""
sources = read_editable_sources(frontend_dir)
file_blocks = "\n\n".join(
f"===== 文件: {rel} =====\n{content}" for rel, content in sources.items()
)
user_prompt = (
f"可编辑文件当前内容如下:\n\n{file_blocks}\n\n"
f"用户的定制需求:{requirement}\n\n"
f"请调用 apply_edits 返回需要改写的文件全文。"
)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
tools=[APPLY_EDITS_TOOL],
tool_choice={"type": "function", "function": {"name": "apply_edits"}},
temperature=(1 if any(k in (model or "").lower()
for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
else 0),
)
msg = resp.choices[0].message
if not msg.tool_calls:
raise RuntimeError("模型没有返回 apply_edits 工具调用。")
raw_args = msg.tool_calls[0].function.arguments or "{}"
try:
args = json.loads(raw_args)
except json.JSONDecodeError:
# Tolerate bad apply_edits JSON; degrade to empty edits.
args = {}
# 安全校验:只允许改写白名单内的文件。
# 要求每个文件项带有字符串 content:否则下游写盘循环会对
# {"path": ...}(缺少 content)抛出 KeyError 而中断整个 demo。
# 形状不合法的项直接丢弃(与非 dict 过滤一致);白名单校验仍会对
# 带 content 但路径非法的项抛错。
files = [
f
for f in (args.get("files") or [])
if isinstance(f, dict) and isinstance(f.get("content"), str)
]
for f in files:
path = f.get("path")
if path not in EDITABLE_FILES:
raise RuntimeError(f"模型试图修改非白名单文件:{path}")
args["files"] = files
return args
+219
View File
@@ -0,0 +1,219 @@
"""实验 5-11:对话式界面定制系统 —— FastAPI 后端。
一个最小的 chatbot 后端:前端把用户消息 POST 到 /api/chat,后端返回回复。
开发模式下用 `uvicorn main:app --reload` 启动,改动后端代码会自动 reload
(对应书中所说的"FastAPI 的热加载")。
两种回复模式(默认保持"回声式"占位,聚焦 UI 定制这一主题):
- **echo(默认)**:后端把用户消息原样回显,无需任何模型 Key,开箱即用;
- **llm(可选)**:设置模型后走真实 LLM 对话,让运行起来的 chatbot 真会说话。
通过命令行 `--model` 或环境变量 `CHAT_MODEL` 打开,复用与 agent.py 相同的
OPENAI_API_KEY / OPENAI_BASE_URL 配置。
启动方式(二选一,行为一致):
uvicorn main:app --reload --port 8000 # 书中示例:模块级 app + uvicorn 热加载
python main.py --reload --port 8000 # 本文件自带的命令行入口(见 --help)
"""
import os
import argparse
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
try: # dotenv 可选:让 --model 模式也能读到 .env 里的 OPENAI_API_KEY
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
app = FastAPI(title="Conversational UI Backend")
# 允许前端(Vite dev server, 5173)直接跨域访问,方便本地开发。
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
message: str
def _chat_model() -> str:
"""当前回复模式:返回模型名则走真实 LLM,返回空串则为默认 echo 模式。
从环境变量 CHAT_MODEL 读取(而非模块级常量),这样即使 `--reload` 触发的
子进程重新 import 本模块,也能透过环境变量拿到命令行传入的模型设置。
"""
return (os.getenv("CHAT_MODEL") or "").strip()
def _llm_reply(message: str, model: str) -> str:
"""走真实 LLM 生成回复,复用 agent.py 同款 OPENAI_* 配置。
任何异常都降级为清晰的提示(绝不编造回复),保证前端不至于白屏。
"""
try:
from openai import OpenAI
except Exception:
return "(未安装 openai 依赖,无法启用 LLM 模式;已回退占位回复)"
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
orkey = os.getenv("OPENROUTER_API_KEY")
# 通用 OpenRouter 兜底:无直连 key,或 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
if prefer_or or (not api_key and orkey):
api_key, base_url = orkey, "https://openrouter.ai/api/v1"
if "/" not in model:
model = ("openai/" + model) if model.lower().startswith(("gpt-", "o1", "o3", "o4")) else "openai/gpt-5.6-luna"
if not api_key:
return "(未配置 OPENAI_API_KEY 或 OPENROUTER_API_KEY,无法启用 LLM 模式;已回退占位回复)"
client_kwargs = {"api_key": api_key, "timeout": 60.0, "max_retries": 2}
if base_url:
client_kwargs["base_url"] = base_url
try:
client = OpenAI(**client_kwargs)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一个乐于助人的中文智能助手,回答简洁友好。"},
{"role": "user", "content": message},
],
temperature=(1 if any(k in (model or "").lower()
for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
else 0.7),
)
return resp.choices[0].message.content or "(模型返回了空回复)"
except Exception as e: # 网络/鉴权/模型名等问题都在此兜底
return f"(调用 LLM 失败:{e};已回退占位回复)"
@app.get("/api/health")
def health():
model = _chat_model()
return {"status": "ok", "mode": "llm" if model else "echo", "model": model or None}
@app.post("/api/chat")
def chat(req: ChatRequest):
"""默认回声式回复;设置 CHAT_MODEL 后走真实 LLM 对话。
本实验聚焦"对话式 UI 定制",后端逻辑刻意保持最小;
如需真实客服体验,用 `python main.py --model <模型名>` 打开 LLM 模式即可。
"""
model = _chat_model()
if model:
return {"reply": _llm_reply(req.message, model)}
return {"reply": f"我收到了你的消息:{req.message}"}
# ---------------------------------------------------------------------------
# 命令行入口:让后端既能 `uvicorn main:app --reload` 启动,也能 `python main.py`
# 启动,并通过参数控制 host/port/热加载/回复模式/日志。
# ---------------------------------------------------------------------------
def parse_args(argv=None):
parser = argparse.ArgumentParser(
prog="main.py",
description="实验 5-11:对话式界面定制系统 —— FastAPI 后端(最小 chatbot 服务)。"
"为可对话定制的前端提供 /api/chat 载体,开发模式下配合 --reload 演示后端热加载。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="监听地址;对外可用 0.0.0.0。",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="监听端口(前端 vite.config.js 默认把 /api 代理到 8000)。",
)
reload_group = parser.add_mutually_exclusive_group()
reload_group.add_argument(
"--reload",
dest="reload",
action="store_true",
default=True,
help="开启热加载:改动后端 .py 自动重启(开发默认开启)。",
)
reload_group.add_argument(
"--no-reload",
dest="reload",
action="store_false",
help="关闭热加载(更接近生产运行)。",
)
parser.add_argument(
"--model",
default=os.getenv("CHAT_MODEL") or None,
metavar="NAME",
help="打开真实 LLM 对话并指定模型名(如 gpt-5.6-luna);"
"缺省则为默认的 echo 回声模式。也可用环境变量 CHAT_MODEL 设置。",
)
parser.add_argument(
"--log-level",
default="info",
choices=["critical", "error", "warning", "info", "debug", "trace"],
help="uvicorn 日志/输出级别。",
)
parser.add_argument(
"--print-config",
action="store_true",
help="只打印生效配置(JSON)后退出,不真正监听端口(便于无网络/无端口环境下校验)。",
)
return parser.parse_args(argv)
def main(argv=None):
import json
args = parse_args(argv)
# 把 --model 写回环境变量:这样 --reload 派生的子进程重新 import 本模块时,
# 也能通过 CHAT_MODEL 感知到 LLM 模式(子进程不共享本函数的局部状态)。
if args.model:
os.environ["CHAT_MODEL"] = args.model
else:
os.environ.pop("CHAT_MODEL", None)
config = {
"host": args.host,
"port": args.port,
"reload": args.reload,
"mode": "llm" if args.model else "echo",
"model": args.model,
"log_level": args.log_level,
}
if args.print_config:
print(json.dumps(config, ensure_ascii=False, indent=2))
return 0
import uvicorn
print(
f"启动 FastAPI 后端:http://{args.host}:{args.port}"
f" 模式={config['mode']}"
f" 热加载={'' if args.reload else ''}"
)
# 用 import string 才能在 --reload 下工作;从 backend/ 目录运行 `python main.py`。
uvicorn.run(
"main:app",
host=args.host,
port=args.port,
reload=args.reload,
log_level=args.log_level,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,6 @@
# 后端单独运行所需依赖(与根目录 requirements.txt 一致的子集)
fastapi>=0.110
uvicorn[standard]>=0.27
# 以下仅在用 `--model` 打开真实 LLM 对话模式时才需要;默认 echo 模式无需安装:
# openai>=1.30
# python-dotenv>=1.0
@@ -0,0 +1,78 @@
import { useState } from "react";
// ===========================================================================
// 基础 chatbot 界面。
// 这是一个"可被自然语言定制"的最小 React 应用:
// - 标题文案、按钮文字等 UI 文本都写在这里(Agent 可按需求改文案);
// - 颜色、字体、布局等样式集中在 theme.css(Agent 可按需求改样式)。
// 用户在对话中说"把发送按钮改成蓝色 / 换成等宽字体 / 标题改成 XXX"
// Agent 会定位并修改这些源码文件,Vite HMR 让改动即时生效。
// ===========================================================================
// UI 文案(Agent 定制"文案"需求时修改这里)
const HEADER_TITLE = "智能助手";
const HEADER_SUBTITLE = "有什么可以帮你的吗?";
const SEND_BUTTON_LABEL = "发送";
export default function App() {
const [messages, setMessages] = useState([
{ role: "assistant", text: "你好!我是你的智能助手,随时为你服务。" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
setMessages((m) => [...m, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
setMessages((m) => [...m, { role: "assistant", text: data.reply }]);
} catch (e) {
setMessages((m) => [
...m,
{ role: "assistant", text: "(后端未连接,这是本地占位回复)" },
]);
} finally {
setLoading(false);
}
}
return (
<div className="app">
<header className="header">
<h1 className="header-title">{HEADER_TITLE}</h1>
<p className="header-subtitle">{HEADER_SUBTITLE}</p>
</header>
<main className="chat-window">
{messages.map((m, i) => (
<div key={i} className={`bubble bubble-${m.role}`}>
{m.text}
</div>
))}
{loading && <div className="bubble bubble-assistant">思考中</div>}
</main>
<footer className="composer">
<input
className="composer-input"
value={input}
placeholder="输入消息…"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
<button className="send-button" onClick={handleSend}>
{SEND_BUTTON_LABEL}
</button>
</footer>
</div>
);
}
@@ -0,0 +1,108 @@
/* ==========================================================================
主题样式表(Agent 定制"颜色 / 字体 / 布局"需求时修改这里)
为方便自然语言定制,关键视觉变量集中在 :root 里。
========================================================================== */
:root {
--color-primary: #16a34a; /* 主色(发送按钮、用户气泡)——初始为绿色 */
--color-primary-text: #ffffff; /* 主色上的文字颜色 */
--color-bg: #f5f5f5; /* 页面背景 */
--color-panel: #ffffff; /* 面板/卡片背景 */
--color-text: #1f2937; /* 正文文字 */
--font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
}
.app {
max-width: 640px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-panel);
}
.header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.header-title {
margin: 0;
font-size: 20px;
}
.header-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.chat-window {
flex: 1;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: var(--radius);
line-height: 1.5;
white-space: pre-wrap;
}
.bubble-assistant {
align-self: flex-start;
background: #eef2f5;
color: var(--color-text);
}
.bubble-user {
align-self: flex-end;
background: var(--color-primary);
color: var(--color-primary-text);
}
.composer {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
.composer-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
}
.send-button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--color-primary);
color: var(--color-primary-text);
font-size: 14px;
cursor: pointer;
}
.send-button:hover {
opacity: 0.9;
}
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""Live Vite-HMR/browser campaign for Chapter 5, Experiment 5-11."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import signal
import shutil
import socket
import subprocess
import time
import urllib.request
from pathlib import Path
from typing import Any
from openai import OpenAI
from playwright.sync_api import sync_playwright
import agent
HERE = Path(__file__).resolve().parent
ROUNDS = [
{
"requirement": "把发送按钮和用户消息气泡的主题色从绿色改成蓝色,必须使用 #2563eb。",
"kind": "color",
"expected": "rgb(37, 99, 235)",
},
{
"requirement": "把整个界面的字体换成等宽字体(monospace),保留上一轮蓝色主题。",
"kind": "font",
"expected": "monospace",
},
{
"requirement": "把顶部标题改成“我的专属客服”,保留前两轮的蓝色和等宽字体。",
"kind": "title",
"expected": "我的专属客服",
},
]
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def atomic_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def wait_url(url: str, timeout: float = 45.0) -> dict[str, Any] | str:
deadline = time.monotonic() + timeout
last_error = ""
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
body = response.read().decode("utf-8")
try:
return json.loads(body)
except json.JSONDecodeError:
return body
except Exception as exc:
last_error = f"{type(exc).__name__}: {exc}"
time.sleep(0.25)
raise TimeoutError(f"server did not become ready: {url}; last={last_error}")
def resolve_backend(provider: str, model: str | None) -> tuple[OpenAI, str, str]:
choices = {
"ark": (os.getenv("ARK_API_KEY"), "https://ark.cn-beijing.volces.com/api/v3", model or "doubao-seed-1-6-flash-250615"),
"moonshot": (os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY"), "https://api.moonshot.cn/v1", model or "kimi-k3"),
"openrouter": (os.getenv("OPENROUTER_API_KEY"), "https://openrouter.ai/api/v1", model or "openai/gpt-5.6-luna"),
"openai": (os.getenv("OPENAI_API_KEY"), os.getenv("OPENAI_BASE_URL"), model or "gpt-5.6-luna"),
}
key, base_url, resolved = choices[provider]
if not key:
raise RuntimeError(f"provider={provider} has no configured credential")
kwargs: dict[str, Any] = {"api_key": key, "timeout": 180.0, "max_retries": 4}
if base_url:
kwargs["base_url"] = base_url
return OpenAI(**kwargs), resolved, base_url or "https://api.openai.com/v1"
def customize(
client: OpenAI,
model: str,
frontend: Path,
requirement: str,
round_number: int,
receipts: list[dict[str, Any]],
receipt_checkpoint: Path,
) -> dict[str, Any]:
sources = {
relative: (frontend / relative).read_text(encoding="utf-8")
for relative in agent.EDITABLE_FILES
}
blocks = "\n\n".join(f"===== {name} =====\n{content}" for name, content in sources.items())
feedback = ""
for attempt in range(1, 4):
messages = [
{"role": "system", "content": agent.SYSTEM_PROMPT},
{"role": "user", "content": (
f"可编辑文件当前内容:\n\n{blocks}\n\n本轮需求:{requirement}\n"
"请只返回完成本轮所需的最少文件,并调用 apply_edits;工具参数必须是完整有效的 JSON。"
+ (f"\n上次调用未通过可执行校验:{feedback}\n请修复该错误后重新调用。" if feedback else "")
)},
]
request = {
"model": model,
"messages": messages,
"tools": [agent.APPLY_EDITS_TOOL],
"tool_choice": {"type": "function", "function": {"name": "apply_edits"}},
"temperature": 1 if any(marker in model.casefold() for marker in ("kimi-k3", "gpt-5", "o1", "o3", "o4")) else 0,
"max_tokens": 8192,
}
started = time.monotonic()
response = client.chat.completions.create(**request)
choice = response.choices[0]
message = choice.message
calls = message.tool_calls or []
call = calls[0] if calls else None
usage = response.usage
receipt = {
"round": round_number,
"attempt": attempt,
"called_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
"latency_s": round(time.monotonic() - started, 3),
"request": request,
"response": {
"id": response.id,
"model": response.model,
"finish_reason": choice.finish_reason,
"message": {
"content": message.content,
"tool_calls": [] if call is None else [{
"id": call.id,
"type": call.type,
"function": {"name": call.function.name, "arguments": call.function.arguments},
}],
},
},
"usage": {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
"cached_prompt_tokens": getattr(getattr(usage, "prompt_tokens_details", None), "cached_tokens", None),
},
"accepted": False,
}
if not response.id or not receipt["usage"]["total_tokens"]:
raise RuntimeError("provider omitted required receipt metadata")
try:
if choice.finish_reason == "length" or call is None:
raise RuntimeError("incomplete apply_edits call")
payload = json.loads(call.function.arguments or "{}")
if not isinstance(payload, dict):
raise RuntimeError("apply_edits arguments are not an object")
files = payload.get("files") or []
if not files:
raise RuntimeError("empty edit set")
for item in files:
if not isinstance(item, dict) or item.get("path") not in agent.EDITABLE_FILES or not isinstance(item.get("content"), str):
raise RuntimeError("invalid/disallowed edit item")
receipt["accepted"] = True
receipts.append(receipt)
atomic_json(receipt_checkpoint, receipts)
return payload
except (json.JSONDecodeError, RuntimeError) as exc:
feedback = f"{type(exc).__name__}: {exc}"
receipt["validation_error"] = feedback
receipts.append(receipt)
atomic_json(receipt_checkpoint, receipts)
raise RuntimeError(f"round {round_number}: model never returned valid apply_edits arguments: {feedback}")
def copy_app(run_dir: Path, backend_port: int) -> tuple[Path, Path]:
app = run_dir / "app"
frontend = app / "frontend"
backend = app / "backend"
frontend.mkdir(parents=True)
backend.mkdir(parents=True)
for filename in ("index.html", "package.json", "package-lock.json", "vite.config.js"):
shutil.copyfile(HERE / "frontend" / filename, frontend / filename)
shutil.copytree(HERE / "frontend" / "src", frontend / "src")
for relative in agent.EDITABLE_FILES:
shutil.copyfile(HERE / "baseline" / relative, frontend / relative)
# Reuse the installed dependency tree without duplicating its disk footprint.
(frontend / "node_modules").symlink_to(HERE / "frontend" / "node_modules", target_is_directory=True)
config = (frontend / "vite.config.js").read_text(encoding="utf-8")
(frontend / "vite.config.js").write_text(config.replace("127.0.0.1:8000", f"127.0.0.1:{backend_port}"), encoding="utf-8")
shutil.copyfile(HERE / "backend" / "main.py", backend / "main.py")
return frontend, backend
def browser_value(page, kind: str) -> str:
if kind == "color":
return page.locator(".send-button").evaluate("el => getComputedStyle(el).backgroundColor")
if kind == "font":
return page.locator("body").evaluate("el => getComputedStyle(el).fontFamily")
if kind == "title":
return page.locator(".header-title").inner_text()
raise ValueError(kind)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--provider", choices=["ark", "moonshot", "openrouter", "openai"], default="ark")
parser.add_argument("--model", default=None)
parser.add_argument("--run-id", default=None)
args = parser.parse_args()
started = dt.datetime.now(dt.timezone.utc)
run_id = args.run_id or started.strftime("%Y%m%dT%H%M%SZ-5_11-hmr")
run_dir = HERE / "validation" / "runs" / run_id
if run_dir.exists():
raise FileExistsError(f"immutable run exists: {run_dir}")
run_dir.mkdir(parents=True)
frontend_port, backend_port = free_port(), free_port()
frontend, backend = copy_app(run_dir, backend_port)
client, model, endpoint = resolve_backend(args.provider, args.model)
backend_log = (run_dir / "backend.log").open("w", encoding="utf-8")
frontend_log = (run_dir / "frontend.log").open("w", encoding="utf-8")
backend_process = subprocess.Popen(
["python", "main.py", "--reload", "--port", str(backend_port), "--log-level", "info"],
cwd=backend, stdout=backend_log, stderr=subprocess.STDOUT, text=True,
start_new_session=True,
)
frontend_process = subprocess.Popen(
["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", str(frontend_port), "--strictPort"],
cwd=frontend, stdout=frontend_log, stderr=subprocess.STDOUT, text=True,
start_new_session=True,
)
receipts: list[dict[str, Any]] = []
round_records: list[dict[str, Any]] = []
browser_facts: dict[str, Any] = {}
build_result: dict[str, Any] = {}
try:
backend_health = wait_url(f"http://127.0.0.1:{backend_port}/api/health")
wait_url(f"http://127.0.0.1:{frontend_port}")
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1100, "height": 850})
websocket_events: list[dict[str, Any]] = []
page.on("websocket", lambda ws: websocket_events.append({"event": "opened", "url": ws.url}))
navigation_count = 0
def navigated(_frame):
nonlocal navigation_count
navigation_count += 1
page.on("framenavigated", navigated)
page.goto(f"http://127.0.0.1:{frontend_port}", wait_until="networkidle")
chromium_version = browser.version
page.locator(".composer-input").fill("HMR_STATE_SENTINEL")
page.locator(".send-button").click()
page.wait_for_function("[...document.querySelectorAll('.bubble')].some(x => x.textContent.includes('HMR_STATE_SENTINEL'))")
baseline_navigation_count = navigation_count
for index, definition in enumerate(ROUNDS, 1):
before = {relative: (frontend / relative).read_text(encoding="utf-8") for relative in agent.EDITABLE_FILES}
payload = customize(
client, model, frontend, definition["requirement"], index,
receipts, run_dir / "receipts.checkpoint.json",
)
changed = []
for item in payload["files"]:
target = frontend / item["path"]
target.write_text(item["content"], encoding="utf-8")
changed.append({"path": item["path"], "before_sha256": hashlib.sha256(before[item["path"]].encode()).hexdigest(), "after_sha256": sha256(target)})
expected = definition["expected"]
page.wait_for_function(
"""([kind, expected]) => {
if (kind === 'color') return getComputedStyle(document.querySelector('.send-button')).backgroundColor === expected;
if (kind === 'font') return getComputedStyle(document.body).fontFamily.toLowerCase().includes(expected);
return document.querySelector('.header-title')?.textContent.trim() === expected;
}""",
arg=[definition["kind"], expected], timeout=30000,
)
observed = browser_value(page, definition["kind"])
state_retained = page.locator(".chat-window").inner_text().find("HMR_STATE_SENTINEL") >= 0
screenshot = run_dir / f"round-{index}.png"
page.screenshot(path=str(screenshot), full_page=True)
round_records.append({
"round": index, "requirement": definition["requirement"], "kind": definition["kind"],
"expected": expected, "observed": observed, "changed_files": changed,
"chat_state_retained": state_retained, "screenshot": screenshot.name,
})
page.screenshot(path=str(run_dir / "final.png"), full_page=True)
browser_facts = {
"browser": "Chromium", "version": chromium_version,
"vite_hmr_websockets": websocket_events,
"navigation_count_after_initial_load": navigation_count - baseline_navigation_count,
"sentinel_chat_state_retained": "HMR_STATE_SENTINEL" in page.locator(".chat-window").inner_text(),
"final_title": page.locator(".header-title").inner_text(),
"final_color": browser_value(page, "color"),
"final_font": browser_value(page, "font"),
}
browser.close()
build_started = time.monotonic()
built = subprocess.run(["npm", "run", "build"], cwd=frontend, capture_output=True, text=True, timeout=180)
build_result = {
"returncode": built.returncode,
"latency_s": round(time.monotonic() - build_started, 3),
"stdout": built.stdout,
"stderr": built.stderr,
}
finally:
for process in (frontend_process, backend_process):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
for process in (frontend_process, backend_process):
try:
process.wait(timeout=12)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
frontend_log.close()
backend_log.close()
atomic_json(run_dir / "receipts.json", receipts)
atomic_json(run_dir / "rounds.json", round_records)
atomic_json(run_dir / "build.json", build_result)
gates = {
"real_react_vite_dev_server": "vite" in (run_dir / "frontend.log").read_text(encoding="utf-8").casefold(),
"real_fastapi_backend_reload_mode": backend_health == {"status": "ok", "mode": "echo", "model": None} and "reloader process" in (run_dir / "backend.log").read_text(encoding="utf-8").casefold(),
"real_model_generated_three_sequential_edits": (
len(round_records) == 3
and sum(receipt.get("accepted") is True for receipt in receipts) == 3
),
"vite_hmr_websocket_observed": bool(browser_facts["vite_hmr_websockets"]),
"no_full_page_navigation_during_three_edits": browser_facts["navigation_count_after_initial_load"] == 0,
"react_chat_state_preserved_across_hmr": browser_facts["sentinel_chat_state_retained"] and all(row["chat_state_retained"] for row in round_records),
"all_color_font_title_requests_visible": browser_facts["final_color"] == "rgb(37, 99, 235)" and "monospace" in browser_facts["final_font"].casefold() and browser_facts["final_title"] == "我的专属客服",
"final_vite_build_passed": build_result["returncode"] == 0,
"raw_provider_receipts_complete": all(r["response"]["id"] and r["usage"]["total_tokens"] for r in receipts),
"rendered_browser_images_retained": all((run_dir / f"round-{i}.png").is_file() for i in range(1, 4)) and (run_dir / "final.png").is_file(),
}
artifacts = {}
for path in sorted(run_dir.iterdir()):
if path.is_file() and path.name != "manifest.json":
artifacts[path.name] = {"path": path.name, "sha256": sha256(path), "bytes": path.stat().st_size}
for relative in agent.EDITABLE_FILES:
path = frontend / relative
artifacts[f"final-source/{relative}"] = {"path": str(path.relative_to(run_dir)), "sha256": sha256(path), "bytes": path.stat().st_size}
manifest = {
"schema_version": "1.0", "experiment": "5-11", "run_id": run_id,
"started_at_utc": started.isoformat(), "completed_at_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
"provider": args.provider, "endpoint": endpoint, "model": model,
"source": {"manuscript": "book/chapter5.md#实验-5-11", "campaign_sha256": sha256(Path(__file__))},
"servers": {
"frontend": {"command": ["npm", "run", "dev", "--", "--host", "127.0.0.1", "--port", str(frontend_port), "--strictPort"]},
"backend": {"command": ["python", "main.py", "--reload", "--port", str(backend_port)], "health": backend_health},
},
"rounds": round_records, "browser": browser_facts, "build": build_result,
"usage": {
"calls": len(receipts), "prompt_tokens": sum(r["usage"]["prompt_tokens"] or 0 for r in receipts),
"completion_tokens": sum(r["usage"]["completion_tokens"] or 0 for r in receipts),
"total_tokens": sum(r["usage"]["total_tokens"] or 0 for r in receipts),
"latency_s": round(sum(r["latency_s"] for r in receipts), 3),
},
"artifacts": artifacts, "acceptance_gates": gates, "official_complete": all(gates.values()),
}
atomic_json(run_dir / "manifest.json", manifest)
(HERE / "validation").mkdir(exist_ok=True)
if manifest["official_complete"]:
shutil.copyfile(run_dir / "manifest.json", HERE / "validation" / "latest.json")
print(json.dumps({"run_id": run_id, "official_complete": manifest["official_complete"], "gates": gates}, ensure_ascii=False, indent=2))
if not manifest["official_complete"]:
raise SystemExit(2)
if __name__ == "__main__":
main()
+240
View File
@@ -0,0 +1,240 @@
"""实验 5-11:对话式界面定制系统 —— 端到端演示与自动验证。
本 demo 在"无浏览器"的环境里,把可验证性落在【自然语言 → 代码修改被正确应用】的闭环上:
对每一条自然语言定制需求:
1) 调用真实 OpenAI,让 Agent 定位并改写前端源码(agent.customize);
2) 打印改动前后的 diff 片段(difflib),并把改动写回 frontend/src
3) 读回源码做断言,确认改动"确实符合需求"(颜色值/字体/文案按要求变化);
4) 运行 `npm run build`vite build),确认改动没有破坏应用(可编译通过)。
连续跑多轮(本例 3 轮),验证多轮迭代定制均生效且不破坏构建。
注意:真正"浏览器内 HMR 的视觉即时刷新"需手动 `npm run dev` + 打开浏览器查看;
本 demo 自动验证的是"代码修改被正确应用,且构建始终通过"
运行:
python demo.py # 跑全部 3 轮定制并做完整验证
python demo.py --quick # 只跑第 1 轮(省时,用于快速冒烟)
python demo.py --rounds 2 # 只跑前 2 轮
python demo.py --no-build # 跳过 vite build(仅验证"改动被正确应用"
python demo.py -h # 查看全部参数
"""
import sys
import shutil
import difflib
import argparse
import subprocess
from pathlib import Path
import agent
HERE = Path(__file__).resolve().parent
FRONTEND = HERE / "frontend"
BASELINE = HERE / "baseline" # 前端源码的初始快照,保证 demo 可重复运行
# ---------------------------------------------------------------------------
# 每一轮的定制需求 + 对应的验证函数。
# verify(sources) 接收 {相对路径: 改写后内容},返回 (是否通过, 说明)。
# ---------------------------------------------------------------------------
def _all_text(sources: dict) -> str:
return "\n".join(sources.values())
ROUNDS = [
{
"requirement": "把发送按钮和用户消息气泡的主题色从绿色改成蓝色,用 #2563eb 这个蓝。",
"verify": lambda s: (
"#2563eb" in _all_text(s).lower().replace("#2563EB".lower(), "#2563eb"),
"源码中出现蓝色值 #2563eb",
),
},
{
"requirement": "把整个界面的字体换成等宽字体(monospace)。",
"verify": lambda s: (
"monospace" in _all_text(s).lower(),
"源码中出现 monospace 等宽字体",
),
},
{
"requirement": "把顶部的标题文案改成“我的专属客服”。",
"verify": lambda s: (
"我的专属客服" in _all_text(s),
"源码中出现新标题文案“我的专属客服”",
),
},
]
def restore_baseline():
"""把 frontend/src 下的可编辑文件恢复为初始快照,保证可重复运行。"""
for rel in agent.EDITABLE_FILES:
src = BASELINE / rel
dst = FRONTEND / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dst)
def ensure_node_modules():
"""确保依赖已安装;首次 npm install 较慢属正常现象。"""
if (FRONTEND / "node_modules").exists():
return
print(">> 未发现 node_modules,正在执行 npm install(首次较慢,请耐心等待)…")
r = subprocess.run(["npm", "install"], cwd=FRONTEND)
if r.returncode != 0:
raise SystemExit("npm install 失败,请检查 Node/npm 环境。")
def run_build() -> bool:
"""运行 vite build,返回是否编译通过。"""
r = subprocess.run(
["npm", "run", "build"],
cwd=FRONTEND,
capture_output=True,
text=True,
)
tail = (r.stdout + r.stderr).strip().splitlines()
for line in tail[-6:]:
print(" | " + line)
return r.returncode == 0
def print_diff(rel: str, old: str, new: str):
"""打印一个文件改动前后的 unified diff 片段(最多若干行)。"""
diff = list(
difflib.unified_diff(
old.splitlines(),
new.splitlines(),
fromfile=f"a/{rel}",
tofile=f"b/{rel}",
lineterm="",
)
)
if not diff:
print(f" {rel} 无变化)")
return
shown = 0
for line in diff:
if line.startswith("+++") or line.startswith("---") or line.startswith("@@"):
print(" " + line)
elif line.startswith("+"):
print(" \033[32m" + line + "\033[0m") # 绿:新增
shown += 1
elif line.startswith("-"):
print(" \033[31m" + line + "\033[0m") # 红:删除
shown += 1
else:
continue # 省略上下文行,只看真正改动
if shown >= 20:
print(" …(diff 片段已截断)")
break
def parse_args(argv=None):
"""解析命令行参数:控制跑几轮、是否跳过构建。"""
parser = argparse.ArgumentParser(
description="实验 5-11:对话式界面定制系统 —— NL → 代码修改 闭环验证。"
"对每条自然语言 UI 定制需求,让 Agent 改写前端源码,"
"并断言改动生效、vite build 不被破坏。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--quick",
action="store_true",
help="快速模式:只跑第 1 轮定制(等价于 --rounds 1)。",
)
parser.add_argument(
"--rounds",
type=int,
default=None,
metavar="N",
help=f"只跑前 N 轮定制(1..{len(ROUNDS)});默认跑全部。",
)
parser.add_argument(
"--no-build",
action="store_true",
help="跳过 vite build,仅验证'改动被正确应用'(更快,但不校验构建)。",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
# 决定本次运行的轮次数:--quick 优先,其次 --rounds,默认全部。
limit = len(ROUNDS)
if args.quick:
limit = 1
elif args.rounds is not None:
limit = max(1, min(args.rounds, len(ROUNDS)))
rounds = ROUNDS[:limit]
do_build = not args.no_build
print("=" * 72)
print("实验 5-11:对话式界面定制系统 —— NL → 代码修改 闭环验证")
print("=" * 72)
client, model = agent.build_client_and_model()
print(f"模型: {model}")
print(f"本次运行轮次: {limit}/{len(ROUNDS)} 构建验证: {'开启' if do_build else '关闭'}")
restore_baseline()
if do_build:
ensure_node_modules()
print("\n>> 基线构建校验(未定制前,确保应用本身可编译)…")
if not run_build():
raise SystemExit("基线构建失败,请先修复前端工程。")
print(" 基线构建:通过 ✅")
all_pass = True
for i, round_def in enumerate(rounds, 1):
req = round_def["requirement"]
print("\n" + "-" * 72)
print(f"{i} 轮 NL 定制需求:{req}")
print("-" * 72)
old_sources = agent.read_editable_sources(FRONTEND)
# 1) 调用 Agent(真实 OpenAI)得到改写方案
result = agent.customize(client, model, FRONTEND, req)
print(f"Agent 说明:{result.get('summary', '(无)')}")
# 2) 写回 + 打印 diff
changed = {}
for f in result["files"]:
rel = f["path"]
new_content = f["content"]
print(f"\n[改动文件] {rel}")
print_diff(rel, old_sources[rel], new_content)
(FRONTEND / rel).write_text(new_content, encoding="utf-8")
changed[rel] = new_content
# 3) 读回源码断言"改动符合需求"
current = agent.read_editable_sources(FRONTEND)
ok, desc = round_def["verify"](current)
print(f"\n断言:{desc} -> {'通过 ✅' if ok else '失败 ❌'}")
if not ok:
all_pass = False
# 4) 构建验证"没破坏应用"--no-build 时跳过)
if do_build:
print("构建验证(vite build):")
build_ok = run_build()
print(f" 构建结果:{'通过 ✅' if build_ok else '失败 ❌'}")
if not build_ok:
all_pass = False
else:
print("构建验证(vite build):已跳过(--no-build")
print("\n" + "=" * 72)
print(f"多轮定制总结:{'全部通过 ✅' if all_pass else '存在失败项 ❌'}")
print("提示:手动 `npm run dev` + 打开 http://localhost:5173 可看到 HMR 视觉即时生效。")
print("=" * 72)
return 0 if all_pass else 1
if __name__ == "__main__":
sys.exit(main())
+17
View File
@@ -0,0 +1,17 @@
# 必填其一:OpenAI API Key(本实验读取此项)
OPENAI_API_KEY=your_openai_api_key_here
# 通用兜底:未配置 OPENAI_API_KEY 时自动改走 OpenRouter
# 默认模型 gpt-5.6-lunagpt-5.x)直连 OpenAI 需组织实名认证,
# 故设置了本 key 时会优先走 OpenRouterroute openai/gpt-5.6-luna)。
# OPENROUTER_API_KEY=your_openrouter_api_key_here
# 可选:切换到兼容 OpenAI 协议的服务端点
# OPENAI_BASE_URL=https://api.openai.com/v1
# 可选:指定定制 Agent 使用的模型(默认 gpt-5.6-luna
# MODEL=gpt-5.6-luna
# 可选:让后端 chatbot 走真实 LLM 对话(缺省为 echo 回声模式)
# 等价于 `python backend/main.py --model <模型名>`
# CHAT_MODEL=gpt-5.6-luna
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话式界面定制系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"name": "conversational-ui-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "实验 5-11:对话式界面定制系统 —— React(Vite) 前端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
@@ -0,0 +1,78 @@
import { useState } from "react";
// ===========================================================================
// 基础 chatbot 界面。
// 这是一个"可被自然语言定制"的最小 React 应用:
// - 标题文案、按钮文字等 UI 文本都写在这里(Agent 可按需求改文案);
// - 颜色、字体、布局等样式集中在 theme.css(Agent 可按需求改样式)。
// 用户在对话中说"把发送按钮改成蓝色 / 换成等宽字体 / 标题改成 XXX"
// Agent 会定位并修改这些源码文件,Vite HMR 让改动即时生效。
// ===========================================================================
// UI 文案(Agent 定制"文案"需求时修改这里)
const HEADER_TITLE = "智能助手";
const HEADER_SUBTITLE = "有什么可以帮你的吗?";
const SEND_BUTTON_LABEL = "发送";
export default function App() {
const [messages, setMessages] = useState([
{ role: "assistant", text: "你好!我是你的智能助手,随时为你服务。" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
setMessages((m) => [...m, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
setMessages((m) => [...m, { role: "assistant", text: data.reply }]);
} catch (e) {
setMessages((m) => [
...m,
{ role: "assistant", text: "(后端未连接,这是本地占位回复)" },
]);
} finally {
setLoading(false);
}
}
return (
<div className="app">
<header className="header">
<h1 className="header-title">{HEADER_TITLE}</h1>
<p className="header-subtitle">{HEADER_SUBTITLE}</p>
</header>
<main className="chat-window">
{messages.map((m, i) => (
<div key={i} className={`bubble bubble-${m.role}`}>
{m.text}
</div>
))}
{loading && <div className="bubble bubble-assistant">思考中</div>}
</main>
<footer className="composer">
<input
className="composer-input"
value={input}
placeholder="输入消息…"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
<button className="send-button" onClick={handleSend}>
{SEND_BUTTON_LABEL}
</button>
</footer>
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./theme.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,108 @@
/* ==========================================================================
主题样式表(Agent 定制"颜色 / 字体 / 布局"需求时修改这里)
为方便自然语言定制,关键视觉变量集中在 :root 里。
========================================================================== */
:root {
--color-primary: #16a34a; /* 主色(发送按钮、用户气泡)——初始为绿色 */
--color-primary-text: #ffffff; /* 主色上的文字颜色 */
--color-bg: #f5f5f5; /* 页面背景 */
--color-panel: #ffffff; /* 面板/卡片背景 */
--color-text: #1f2937; /* 正文文字 */
--font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
}
.app {
max-width: 640px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-panel);
}
.header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.header-title {
margin: 0;
font-size: 20px;
}
.header-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.chat-window {
flex: 1;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: var(--radius);
line-height: 1.5;
white-space: pre-wrap;
}
.bubble-assistant {
align-self: flex-start;
background: #eef2f5;
color: var(--color-text);
}
.bubble-user {
align-self: flex-end;
background: var(--color-primary);
color: var(--color-primary-text);
}
.composer {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
.composer-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
}
.send-button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--color-primary);
color: var(--color-primary-text);
font-size: 14px;
cursor: pointer;
}
.send-button:hover {
opacity: 0.9;
}
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Vite 开发服务器默认开启 HMR(热模块替换)。
// 当 Agent 修改 src/ 下的源码时,浏览器无需整页刷新即可即时看到界面变化。
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// 后端 FastAPI 跑在 8000,前端把 /api 请求代理过去,避免跨域。
proxy: {
"/api": "http://127.0.0.1:8000",
},
},
});
@@ -0,0 +1,5 @@
# 后端 + Agent/演示脚本依赖
fastapi>=0.110
uvicorn[standard]>=0.27
openai>=1.30
python-dotenv>=1.0
@@ -0,0 +1,58 @@
"""模型返回的 apply_edits 参数缺字段/为 null 时,customize 应干净处理而非崩溃。"""
import json
import types
import pytest
import agent
def _fake_client(arguments):
fn = types.SimpleNamespace(name="apply_edits", arguments=arguments)
tc = types.SimpleNamespace(id="c1", type="function", function=fn)
msg = types.SimpleNamespace(tool_calls=[tc], content=None)
resp = types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)])
completions = types.SimpleNamespace(create=lambda **kw: resp)
return types.SimpleNamespace(chat=types.SimpleNamespace(completions=completions))
@pytest.fixture
def frontend_dir(tmp_path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "App.jsx").write_text("// app", encoding="utf-8")
(tmp_path / "src" / "theme.css").write_text("/* css */", encoding="utf-8")
return tmp_path
def test_files_null_normalized_to_empty(frontend_dir):
"""files 为显式 null → 归一化为空列表,下游迭代不崩溃。"""
args = agent.customize(
_fake_client(json.dumps({"summary": "s", "files": None})),
"model", frontend_dir, "把按钮改成蓝色")
assert args["files"] == []
def test_file_entry_missing_path_rejected_cleanly(frontend_dir):
"""文件项缺 path → 清晰的白名单拒绝(RuntimeError),而非 KeyError。"""
with pytest.raises(RuntimeError, match="白名单"):
agent.customize(
_fake_client(json.dumps({"summary": "s", "files": [{"content": "x"}]})),
"model", frontend_dir, "把按钮改成蓝色")
def test_normal_edits_pass(frontend_dir):
"""合法参数不受影响。"""
files = [{"path": "src/theme.css", "content": "body { color: red; }"}]
args = agent.customize(
_fake_client(json.dumps({"summary": "s", "files": files})),
"model", frontend_dir, "把文字改成红色")
assert args["files"] == files
def test_file_entry_missing_content_dropped(frontend_dir):
"""文件项有合法 path 但缺 content → 丢弃该项,避免下游写盘 f["content"]
抛出 KeyError 而中断整个 demo(与缺 path 抛白名单错误对称处理)。"""
args = agent.customize(
_fake_client(json.dumps({"summary": "s", "files": [{"path": "src/theme.css"}]})),
"model", frontend_dir, "把按钮改成蓝色")
assert args["files"] == []
@@ -0,0 +1,41 @@
"""Malformed apply_edits JSON must not abort customize with JSONDecodeError."""
import types
import agent
def _fake_client(arguments):
fn = types.SimpleNamespace(name="apply_edits", arguments=arguments)
tc = types.SimpleNamespace(id="c1", type="function", function=fn)
msg = types.SimpleNamespace(tool_calls=[tc], content=None)
resp = types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)])
completions = types.SimpleNamespace(create=lambda **kw: resp)
return types.SimpleNamespace(chat=types.SimpleNamespace(completions=completions))
def test_malformed_json_degrades_to_empty_files(tmp_path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "App.jsx").write_text("// app", encoding="utf-8")
(tmp_path / "src" / "theme.css").write_text("/* css */", encoding="utf-8")
args = agent.customize(
_fake_client('{"files": [{"path": "src/theme.css",}],'), # trailing junk
"model",
tmp_path,
"把按钮改成蓝色",
)
assert args["files"] == []
def test_valid_json_still_returns_files(tmp_path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "App.jsx").write_text("// app", encoding="utf-8")
(tmp_path / "src" / "theme.css").write_text("/* css */", encoding="utf-8")
files = [{"path": "src/theme.css", "content": "body { color: red; }"}]
import json
args = agent.customize(
_fake_client(json.dumps({"summary": "s", "files": files})),
"model",
tmp_path,
"把文字改成红色",
)
assert args["files"] == files
@@ -0,0 +1,43 @@
"""apply_edits files 列表含 null/非 dict 项时,customize 应丢弃而非崩溃。"""
import json
import types
import pytest
import agent
def _fake_client(arguments):
fn = types.SimpleNamespace(name="apply_edits", arguments=arguments)
tc = types.SimpleNamespace(id="c1", type="function", function=fn)
msg = types.SimpleNamespace(tool_calls=[tc], content=None)
resp = types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)])
completions = types.SimpleNamespace(create=lambda **kw: resp)
return types.SimpleNamespace(chat=types.SimpleNamespace(completions=completions))
@pytest.fixture
def frontend_dir(tmp_path):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "App.jsx").write_text("// app", encoding="utf-8")
(tmp_path / "src" / "theme.css").write_text("/* css */", encoding="utf-8")
return tmp_path
def test_non_dict_file_items_dropped(frontend_dir):
kept = {"path": "src/theme.css", "content": "body { color: blue; }"}
args = agent.customize(
_fake_client(json.dumps({
"summary": "s",
"files": [None, kept, "x", 1],
})),
"model", frontend_dir, "把按钮改成蓝色")
assert args["files"] == [kept]
def test_normal_edits_unchanged(frontend_dir):
files = [{"path": "src/theme.css", "content": "body { color: red; }"}]
args = agent.customize(
_fake_client(json.dumps({"summary": "s", "files": files})),
"model", frontend_dir, "把文字改成红色")
assert args["files"] == files
@@ -0,0 +1,211 @@
{
"schema_version": "1.0",
"experiment": "5-11",
"run_id": "20260729T212933Z-5_11-hmr",
"started_at_utc": "2026-07-29T21:29:33.901387+00:00",
"completed_at_utc": "2026-07-29T21:30:12.405005+00:00",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"model": "doubao-seed-1-6-flash-250615",
"source": {
"manuscript": "book/chapter5.md#实验-5-11",
"campaign_sha256": "11972f611b1ce9cbeff086955c29e562b0e516b8b468a55d3c072ddafcf1d3dc"
},
"servers": {
"frontend": {
"command": [
"npm",
"run",
"dev",
"--",
"--host",
"127.0.0.1",
"--port",
"56043",
"--strictPort"
]
},
"backend": {
"command": [
"python",
"main.py",
"--reload",
"--port",
"56044"
],
"health": {
"status": "ok",
"mode": "echo",
"model": null
}
}
},
"rounds": [
{
"round": 1,
"requirement": "把发送按钮和用户消息气泡的主题色从绿色改成蓝色,必须使用 #2563eb。",
"kind": "color",
"expected": "rgb(37, 99, 235)",
"observed": "rgb(37, 99, 235)",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545"
},
{
"path": "src/theme.css",
"before_sha256": "499a1d01a5dfe83bb8496349a51a9a9abd8c49df94afcb7d9615920c5653ff16",
"after_sha256": "c494cac4e0c0eb23b5b7f91925cb0997ab7eceae5e3d517736b8a71b61338a3b"
}
],
"chat_state_retained": true,
"screenshot": "round-1.png"
},
{
"round": 2,
"requirement": "把整个界面的字体换成等宽字体(monospace),保留上一轮蓝色主题。",
"kind": "font",
"expected": "monospace",
"observed": "monospace",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545"
},
{
"path": "src/theme.css",
"before_sha256": "c494cac4e0c0eb23b5b7f91925cb0997ab7eceae5e3d517736b8a71b61338a3b",
"after_sha256": "ab503f9f2821a97af18c0386893bf93cba4d9da92debad5cd740e37614a1eb68"
}
],
"chat_state_retained": true,
"screenshot": "round-2.png"
},
{
"round": 3,
"requirement": "把顶部标题改成“我的专属客服”,保留前两轮的蓝色和等宽字体。",
"kind": "title",
"expected": "我的专属客服",
"observed": "我的专属客服",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "76af82f286e3a6affb85a7c2835e78d5c97708aa5eb9088ae5693256aac76e11"
},
{
"path": "src/theme.css",
"before_sha256": "ab503f9f2821a97af18c0386893bf93cba4d9da92debad5cd740e37614a1eb68",
"after_sha256": "4b217ab0f2c33dda062217f7090b1c0da9d3cc4192add8ca8acb72a70b649e3b"
}
],
"chat_state_retained": true,
"screenshot": "round-3.png"
}
],
"browser": {
"browser": "Chromium",
"version": "139.0.7258.5",
"vite_hmr_websockets": [
{
"event": "opened",
"url": "ws://127.0.0.1:56043/?token=ylSMD3fv1F2P"
}
],
"navigation_count_after_initial_load": 0,
"sentinel_chat_state_retained": true,
"final_title": "我的专属客服",
"final_color": "rgb(37, 99, 235)",
"final_font": "monospace"
},
"build": {
"returncode": 0,
"latency_s": 0.819,
"stdout": "\n> conversational-ui-frontend@0.1.0 build\n> vite build\n\nvite v6.4.3 building for production...\ntransforming...\n✓ 27 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.42 kB │ gzip: 0.31 kB\ndist/assets/index-CCn-3TaK.css 1.35 kB │ gzip: 0.56 kB\ndist/assets/index-CvgOOKX3.js 145.07 kB │ gzip: 46.92 kB\n✓ built in 405ms\n",
"stderr": ""
},
"usage": {
"calls": 4,
"prompt_tokens": 9157,
"completion_tokens": 6656,
"total_tokens": 15813,
"latency_s": 34.724
},
"artifacts": {
"backend.log": {
"path": "backend.log",
"sha256": "5f932b9b3bb7dfe8069dfb88d5bec4cdeacc454fbdd7472dd066999089b3c090",
"bytes": 822
},
"build.json": {
"path": "build.json",
"sha256": "bc9bd81991925315ad5a9045ec8db3d6fa025f6cf7de810770f27e3804b857a7",
"bytes": 472
},
"final.png": {
"path": "final.png",
"sha256": "06d905eaf43a7db3730ae2aebcb65976a880e1761bedf9854ceb3c6bf85ce352",
"bytes": 30534
},
"frontend.log": {
"path": "frontend.log",
"sha256": "c38d7e06f6656d8a14ab216329383fedd4feb924eee00dfbd6ac371669cdc7fb",
"bytes": 598
},
"receipts.checkpoint.json": {
"path": "receipts.checkpoint.json",
"sha256": "88664e677ef9a1665dd4b64d94163fe9d75188e2a34eedd833802f66882756c2",
"bytes": 58118
},
"receipts.json": {
"path": "receipts.json",
"sha256": "88664e677ef9a1665dd4b64d94163fe9d75188e2a34eedd833802f66882756c2",
"bytes": 58118
},
"round-1.png": {
"path": "round-1.png",
"sha256": "76c8b80499d55d5a2247ceed6064fcea3470fe516b4efa17a37fb3b3b0539c2c",
"bytes": 31747
},
"round-2.png": {
"path": "round-2.png",
"sha256": "cf092ba62997a36b21e129dc252b1dd18c37c7a8b572c4aec98d0f8a6c3d9395",
"bytes": 29210
},
"round-3.png": {
"path": "round-3.png",
"sha256": "06d905eaf43a7db3730ae2aebcb65976a880e1761bedf9854ceb3c6bf85ce352",
"bytes": 30534
},
"rounds.json": {
"path": "rounds.json",
"sha256": "253ff0223525af311c3ed8eb279d37f24cdf570b3b27e83c1fae039362335d91",
"bytes": 2369
},
"final-source/src/App.jsx": {
"path": "app/frontend/src/App.jsx",
"sha256": "76af82f286e3a6affb85a7c2835e78d5c97708aa5eb9088ae5693256aac76e11",
"bytes": 2750
},
"final-source/src/theme.css": {
"path": "app/frontend/src/theme.css",
"sha256": "4b217ab0f2c33dda062217f7090b1c0da9d3cc4192add8ca8acb72a70b649e3b",
"bytes": 2209
}
},
"acceptance_gates": {
"real_react_vite_dev_server": true,
"real_fastapi_backend_reload_mode": true,
"real_model_generated_three_sequential_edits": true,
"vite_hmr_websocket_observed": true,
"no_full_page_navigation_during_three_edits": true,
"react_chat_state_preserved_across_hmr": true,
"all_color_font_title_requests_visible": true,
"final_vite_build_passed": true,
"raw_provider_receipts_complete": true,
"rendered_browser_images_retained": true
},
"official_complete": true
}
@@ -0,0 +1,219 @@
"""实验 5-11:对话式界面定制系统 —— FastAPI 后端。
一个最小的 chatbot 后端:前端把用户消息 POST 到 /api/chat,后端返回回复。
开发模式下用 `uvicorn main:app --reload` 启动,改动后端代码会自动 reload
(对应书中所说的"FastAPI 的热加载")。
两种回复模式(默认保持"回声式"占位,聚焦 UI 定制这一主题):
- **echo(默认)**:后端把用户消息原样回显,无需任何模型 Key,开箱即用;
- **llm(可选)**:设置模型后走真实 LLM 对话,让运行起来的 chatbot 真会说话。
通过命令行 `--model` 或环境变量 `CHAT_MODEL` 打开,复用与 agent.py 相同的
OPENAI_API_KEY / OPENAI_BASE_URL 配置。
启动方式(二选一,行为一致):
uvicorn main:app --reload --port 8000 # 书中示例:模块级 app + uvicorn 热加载
python main.py --reload --port 8000 # 本文件自带的命令行入口(见 --help)
"""
import os
import argparse
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
try: # dotenv 可选:让 --model 模式也能读到 .env 里的 OPENAI_API_KEY
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
app = FastAPI(title="Conversational UI Backend")
# 允许前端(Vite dev server, 5173)直接跨域访问,方便本地开发。
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
message: str
def _chat_model() -> str:
"""当前回复模式:返回模型名则走真实 LLM,返回空串则为默认 echo 模式。
从环境变量 CHAT_MODEL 读取(而非模块级常量),这样即使 `--reload` 触发的
子进程重新 import 本模块,也能透过环境变量拿到命令行传入的模型设置。
"""
return (os.getenv("CHAT_MODEL") or "").strip()
def _llm_reply(message: str, model: str) -> str:
"""走真实 LLM 生成回复,复用 agent.py 同款 OPENAI_* 配置。
任何异常都降级为清晰的提示(绝不编造回复),保证前端不至于白屏。
"""
try:
from openai import OpenAI
except Exception:
return "(未安装 openai 依赖,无法启用 LLM 模式;已回退占位回复)"
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
orkey = os.getenv("OPENROUTER_API_KEY")
# 通用 OpenRouter 兜底:无直连 key,或 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
if prefer_or or (not api_key and orkey):
api_key, base_url = orkey, "https://openrouter.ai/api/v1"
if "/" not in model:
model = ("openai/" + model) if model.lower().startswith(("gpt-", "o1", "o3", "o4")) else "openai/gpt-5.6-luna"
if not api_key:
return "(未配置 OPENAI_API_KEY 或 OPENROUTER_API_KEY,无法启用 LLM 模式;已回退占位回复)"
client_kwargs = {"api_key": api_key, "timeout": 60.0, "max_retries": 2}
if base_url:
client_kwargs["base_url"] = base_url
try:
client = OpenAI(**client_kwargs)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一个乐于助人的中文智能助手,回答简洁友好。"},
{"role": "user", "content": message},
],
temperature=(1 if any(k in (model or "").lower()
for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
else 0.7),
)
return resp.choices[0].message.content or "(模型返回了空回复)"
except Exception as e: # 网络/鉴权/模型名等问题都在此兜底
return f"(调用 LLM 失败:{e};已回退占位回复)"
@app.get("/api/health")
def health():
model = _chat_model()
return {"status": "ok", "mode": "llm" if model else "echo", "model": model or None}
@app.post("/api/chat")
def chat(req: ChatRequest):
"""默认回声式回复;设置 CHAT_MODEL 后走真实 LLM 对话。
本实验聚焦"对话式 UI 定制",后端逻辑刻意保持最小;
如需真实客服体验,用 `python main.py --model <模型名>` 打开 LLM 模式即可。
"""
model = _chat_model()
if model:
return {"reply": _llm_reply(req.message, model)}
return {"reply": f"我收到了你的消息:{req.message}"}
# ---------------------------------------------------------------------------
# 命令行入口:让后端既能 `uvicorn main:app --reload` 启动,也能 `python main.py`
# 启动,并通过参数控制 host/port/热加载/回复模式/日志。
# ---------------------------------------------------------------------------
def parse_args(argv=None):
parser = argparse.ArgumentParser(
prog="main.py",
description="实验 5-11:对话式界面定制系统 —— FastAPI 后端(最小 chatbot 服务)。"
"为可对话定制的前端提供 /api/chat 载体,开发模式下配合 --reload 演示后端热加载。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="监听地址;对外可用 0.0.0.0。",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="监听端口(前端 vite.config.js 默认把 /api 代理到 8000)。",
)
reload_group = parser.add_mutually_exclusive_group()
reload_group.add_argument(
"--reload",
dest="reload",
action="store_true",
default=True,
help="开启热加载:改动后端 .py 自动重启(开发默认开启)。",
)
reload_group.add_argument(
"--no-reload",
dest="reload",
action="store_false",
help="关闭热加载(更接近生产运行)。",
)
parser.add_argument(
"--model",
default=os.getenv("CHAT_MODEL") or None,
metavar="NAME",
help="打开真实 LLM 对话并指定模型名(如 gpt-5.6-luna);"
"缺省则为默认的 echo 回声模式。也可用环境变量 CHAT_MODEL 设置。",
)
parser.add_argument(
"--log-level",
default="info",
choices=["critical", "error", "warning", "info", "debug", "trace"],
help="uvicorn 日志/输出级别。",
)
parser.add_argument(
"--print-config",
action="store_true",
help="只打印生效配置(JSON)后退出,不真正监听端口(便于无网络/无端口环境下校验)。",
)
return parser.parse_args(argv)
def main(argv=None):
import json
args = parse_args(argv)
# 把 --model 写回环境变量:这样 --reload 派生的子进程重新 import 本模块时,
# 也能通过 CHAT_MODEL 感知到 LLM 模式(子进程不共享本函数的局部状态)。
if args.model:
os.environ["CHAT_MODEL"] = args.model
else:
os.environ.pop("CHAT_MODEL", None)
config = {
"host": args.host,
"port": args.port,
"reload": args.reload,
"mode": "llm" if args.model else "echo",
"model": args.model,
"log_level": args.log_level,
}
if args.print_config:
print(json.dumps(config, ensure_ascii=False, indent=2))
return 0
import uvicorn
print(
f"启动 FastAPI 后端:http://{args.host}:{args.port}"
f" 模式={config['mode']}"
f" 热加载={'' if args.reload else ''}"
)
# 用 import string 才能在 --reload 下工作;从 backend/ 目录运行 `python main.py`。
uvicorn.run(
"main:app",
host=args.host,
port=args.port,
reload=args.reload,
log_level=args.log_level,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话式界面定制系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -0,0 +1,20 @@
{
"name": "conversational-ui-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "实验 5-11:对话式界面定制系统 —— React(Vite) 前端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
@@ -0,0 +1,78 @@
import { useState } from "react";
// ===========================================================================
// 基础 chatbot 界面。
// 这是一个"可被自然语言定制"的最小 React 应用:
// - 标题文案、按钮文字等 UI 文本都写在这里(Agent 可按需求改文案);
// - 颜色、字体、布局等样式集中在 theme.css(Agent 可按需求改样式)。
// 用户在对话中说"把发送按钮改成蓝色 / 换成等宽字体 / 标题改成 XXX"
// Agent 会定位并修改这些源码文件,Vite HMR 让改动即时生效。
// ===========================================================================
// UI 文案(Agent 定制"文案"需求时修改这里)
const HEADER_TITLE = "智能助手";
const HEADER_SUBTITLE = "有什么可以帮你的吗?";
const SEND_BUTTON_LABEL = "发送";
export default function App() {
const [messages, setMessages] = useState([
{ role: "assistant", text: "你好!我是你的智能助手,随时为你服务。" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
setMessages((m) => [...m, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
setMessages((m) => [...m, { role: "assistant", text: data.reply }]);
} catch (e) {
setMessages((m) => [
...m,
{ role: "assistant", text: "(后端未连接,这是本地占位回复)" },
]);
} finally {
setLoading(false);
}
}
return (
<div className="app">
<header className="header">
<h1 className="header-title">{HEADER_TITLE}</h1>
<p className="header-subtitle">{HEADER_SUBTITLE}</p>
</header>
<main className="chat-window">
{messages.map((m, i) => (
<div key={i} className={`bubble bubble-${m.role}`}>
{m.text}
</div>
))}
{loading && <div className="bubble bubble-assistant">思考中</div>}
</main>
<footer className="composer">
<input
className="composer-input"
value={input}
placeholder="输入消息…"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
<button className="send-button" onClick={handleSend}>
{SEND_BUTTON_LABEL}
</button>
</footer>
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./theme.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,108 @@
/* ==========================================================================
主题样式表(Agent 定制"颜色 / 字体 / 布局"需求时修改这里)
为方便自然语言定制,关键视觉变量集中在 :root 里。
========================================================================== */
:root {
--color-primary: #2563eb; /* 主色(发送按钮、用户气泡)——改为蓝色 */
--color-primary-text: #ffffff; /* 主色上的文字颜色 */
--color-bg: #f5f5f5; /* 页面背景 */
--color-panel: #ffffff; /* 面板/卡片背景 */
--color-text: #1f2937; /* 正文文字 */
--font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif;
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
}
.app {
max-width: 640px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-panel);
}
.header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.header-title {
margin: 0;
font-size: 20px;
}
.header-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.chat-window {
flex: 1;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: var(--radius);
line-height: 1.5;
white-space: pre-wrap;
}
.bubble-assistant {
align-self: flex-start;
background: #eef2f5;
color: var(--color-text);
}
.bubble-user {
align-self: flex-end;
background: var(--color-primary);
color: var(--color-primary-text);
}
.composer {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
.composer-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
}
.send-button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--color-primary);
color: var(--color-primary-text);
font-size: 14px;
cursor: pointer;
}
.send-button:hover {
opacity: 0.9;
}
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Vite 开发服务器默认开启 HMR(热模块替换)。
// 当 Agent 修改 src/ 下的源码时,浏览器无需整页刷新即可即时看到界面变化。
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// 后端 FastAPI 跑在 8000,前端把 /api 请求代理过去,避免跨域。
proxy: {
"/api": "http://127.0.0.1:55409",
},
},
});
@@ -0,0 +1,219 @@
"""实验 5-11:对话式界面定制系统 —— FastAPI 后端。
一个最小的 chatbot 后端:前端把用户消息 POST 到 /api/chat,后端返回回复。
开发模式下用 `uvicorn main:app --reload` 启动,改动后端代码会自动 reload
(对应书中所说的"FastAPI 的热加载")。
两种回复模式(默认保持"回声式"占位,聚焦 UI 定制这一主题):
- **echo(默认)**:后端把用户消息原样回显,无需任何模型 Key,开箱即用;
- **llm(可选)**:设置模型后走真实 LLM 对话,让运行起来的 chatbot 真会说话。
通过命令行 `--model` 或环境变量 `CHAT_MODEL` 打开,复用与 agent.py 相同的
OPENAI_API_KEY / OPENAI_BASE_URL 配置。
启动方式(二选一,行为一致):
uvicorn main:app --reload --port 8000 # 书中示例:模块级 app + uvicorn 热加载
python main.py --reload --port 8000 # 本文件自带的命令行入口(见 --help)
"""
import os
import argparse
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
try: # dotenv 可选:让 --model 模式也能读到 .env 里的 OPENAI_API_KEY
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
app = FastAPI(title="Conversational UI Backend")
# 允许前端(Vite dev server, 5173)直接跨域访问,方便本地开发。
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
message: str
def _chat_model() -> str:
"""当前回复模式:返回模型名则走真实 LLM,返回空串则为默认 echo 模式。
从环境变量 CHAT_MODEL 读取(而非模块级常量),这样即使 `--reload` 触发的
子进程重新 import 本模块,也能透过环境变量拿到命令行传入的模型设置。
"""
return (os.getenv("CHAT_MODEL") or "").strip()
def _llm_reply(message: str, model: str) -> str:
"""走真实 LLM 生成回复,复用 agent.py 同款 OPENAI_* 配置。
任何异常都降级为清晰的提示(绝不编造回复),保证前端不至于白屏。
"""
try:
from openai import OpenAI
except Exception:
return "(未安装 openai 依赖,无法启用 LLM 模式;已回退占位回复)"
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
orkey = os.getenv("OPENROUTER_API_KEY")
# 通用 OpenRouter 兜底:无直连 key,或 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
if prefer_or or (not api_key and orkey):
api_key, base_url = orkey, "https://openrouter.ai/api/v1"
if "/" not in model:
model = ("openai/" + model) if model.lower().startswith(("gpt-", "o1", "o3", "o4")) else "openai/gpt-5.6-luna"
if not api_key:
return "(未配置 OPENAI_API_KEY 或 OPENROUTER_API_KEY,无法启用 LLM 模式;已回退占位回复)"
client_kwargs = {"api_key": api_key, "timeout": 60.0, "max_retries": 2}
if base_url:
client_kwargs["base_url"] = base_url
try:
client = OpenAI(**client_kwargs)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一个乐于助人的中文智能助手,回答简洁友好。"},
{"role": "user", "content": message},
],
temperature=(1 if any(k in (model or "").lower()
for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
else 0.7),
)
return resp.choices[0].message.content or "(模型返回了空回复)"
except Exception as e: # 网络/鉴权/模型名等问题都在此兜底
return f"(调用 LLM 失败:{e};已回退占位回复)"
@app.get("/api/health")
def health():
model = _chat_model()
return {"status": "ok", "mode": "llm" if model else "echo", "model": model or None}
@app.post("/api/chat")
def chat(req: ChatRequest):
"""默认回声式回复;设置 CHAT_MODEL 后走真实 LLM 对话。
本实验聚焦"对话式 UI 定制",后端逻辑刻意保持最小;
如需真实客服体验,用 `python main.py --model <模型名>` 打开 LLM 模式即可。
"""
model = _chat_model()
if model:
return {"reply": _llm_reply(req.message, model)}
return {"reply": f"我收到了你的消息:{req.message}"}
# ---------------------------------------------------------------------------
# 命令行入口:让后端既能 `uvicorn main:app --reload` 启动,也能 `python main.py`
# 启动,并通过参数控制 host/port/热加载/回复模式/日志。
# ---------------------------------------------------------------------------
def parse_args(argv=None):
parser = argparse.ArgumentParser(
prog="main.py",
description="实验 5-11:对话式界面定制系统 —— FastAPI 后端(最小 chatbot 服务)。"
"为可对话定制的前端提供 /api/chat 载体,开发模式下配合 --reload 演示后端热加载。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="监听地址;对外可用 0.0.0.0。",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="监听端口(前端 vite.config.js 默认把 /api 代理到 8000)。",
)
reload_group = parser.add_mutually_exclusive_group()
reload_group.add_argument(
"--reload",
dest="reload",
action="store_true",
default=True,
help="开启热加载:改动后端 .py 自动重启(开发默认开启)。",
)
reload_group.add_argument(
"--no-reload",
dest="reload",
action="store_false",
help="关闭热加载(更接近生产运行)。",
)
parser.add_argument(
"--model",
default=os.getenv("CHAT_MODEL") or None,
metavar="NAME",
help="打开真实 LLM 对话并指定模型名(如 gpt-5.6-luna);"
"缺省则为默认的 echo 回声模式。也可用环境变量 CHAT_MODEL 设置。",
)
parser.add_argument(
"--log-level",
default="info",
choices=["critical", "error", "warning", "info", "debug", "trace"],
help="uvicorn 日志/输出级别。",
)
parser.add_argument(
"--print-config",
action="store_true",
help="只打印生效配置(JSON)后退出,不真正监听端口(便于无网络/无端口环境下校验)。",
)
return parser.parse_args(argv)
def main(argv=None):
import json
args = parse_args(argv)
# 把 --model 写回环境变量:这样 --reload 派生的子进程重新 import 本模块时,
# 也能通过 CHAT_MODEL 感知到 LLM 模式(子进程不共享本函数的局部状态)。
if args.model:
os.environ["CHAT_MODEL"] = args.model
else:
os.environ.pop("CHAT_MODEL", None)
config = {
"host": args.host,
"port": args.port,
"reload": args.reload,
"mode": "llm" if args.model else "echo",
"model": args.model,
"log_level": args.log_level,
}
if args.print_config:
print(json.dumps(config, ensure_ascii=False, indent=2))
return 0
import uvicorn
print(
f"启动 FastAPI 后端:http://{args.host}:{args.port}"
f" 模式={config['mode']}"
f" 热加载={'' if args.reload else ''}"
)
# 用 import string 才能在 --reload 下工作;从 backend/ 目录运行 `python main.py`。
uvicorn.run(
"main:app",
host=args.host,
port=args.port,
reload=args.reload,
log_level=args.log_level,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话式界面定制系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -0,0 +1,20 @@
{
"name": "conversational-ui-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "实验 5-11:对话式界面定制系统 —— React(Vite) 前端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
@@ -0,0 +1,78 @@
import { useState } from "react";
// ===========================================================================
// 基础 chatbot 界面。
// 这是一个"可被自然语言定制"的最小 React 应用:
// - 标题文案、按钮文字等 UI 文本都写在这里(Agent 可按需求改文案);
// - 颜色、字体、布局等样式集中在 theme.css(Agent 可按需求改样式)。
// 用户在对话中说"把发送按钮改成蓝色 / 换成等宽字体 / 标题改成 XXX"
// Agent 会定位并修改这些源码文件,Vite HMR 让改动即时生效。
// ===========================================================================
// UI 文案(Agent 定制"文案"需求时修改这里)
const HEADER_TITLE = "智能助手";
const HEADER_SUBTITLE = "有什么可以帮你的吗?";
const SEND_BUTTON_LABEL = "发送";
export default function App() {
const [messages, setMessages] = useState([
{ role: "assistant", text: "你好!我是你的智能助手,随时为你服务。" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
setMessages((m) => [...m, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
setMessages((m) => [...m, { role: "assistant", text: data.reply }]);
} catch (e) {
setMessages((m) => [
...m,
{ role: "assistant", text: "(后端未连接,这是本地占位回复)" },
]);
} finally {
setLoading(false);
}
}
return (
<div className="app">
<header className="header">
<h1 className="header-title">{HEADER_TITLE}</h1>
<p className="header-subtitle">{HEADER_SUBTITLE}</p>
</header>
<main className="chat-window">
{messages.map((m, i) => (
<div key={i} className={`bubble bubble-${m.role}`}>
{m.text}
</div>
))}
{loading && <div className="bubble bubble-assistant">思考中</div>}
</main>
<footer className="composer">
<input
className="composer-input"
value={input}
placeholder="输入消息…"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
<button className="send-button" onClick={handleSend}>
{SEND_BUTTON_LABEL}
</button>
</footer>
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./theme.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,108 @@
/* ==========================================================================
主题样式表(Agent 定制"颜色 / 字体 / 布局"需求时修改这里)
为方便自然语言定制,关键视觉变量集中在 :root 里。
========================================================================== */
:root {
--color-primary: #2563eb; /* 主色(发送按钮、用户气泡)——初始为绿色 */
--color-primary-text: #ffffff; /* 主色上的文字颜色 */
--color-bg: #f5f5f5; /* 页面背景 */
--color-panel: #ffffff; /* 面板/卡片背景 */
--color-text: #1f2937; /* 正文文字 */
--font-family: monospace; /* 将字体改为等宽字体 */
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
}
.app {
max-width: 640px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-panel);
}
.header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.header-title {
margin: 0;
font-size: 20px;
}
.header-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.chat-window {
flex: 1;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: var(--radius);
line-height: 1.5;
white-space: pre-wrap;
}
.bubble-assistant {
align-self: flex-start;
background: #eef2f5;
color: var(--color-text);
}
.bubble-user {
align-self: flex-end;
background: var(--color-primary);
color: var(--color-primary-text);
}
.composer {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
.composer-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
}
.send-button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--color-primary);
color: var(--color-primary-text);
font-size: 14px;
cursor: pointer;
}
.send-button:hover {
opacity: 0.9;
}
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Vite 开发服务器默认开启 HMR(热模块替换)。
// 当 Agent 修改 src/ 下的源码时,浏览器无需整页刷新即可即时看到界面变化。
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// 后端 FastAPI 跑在 8000,前端把 /api 请求代理过去,避免跨域。
proxy: {
"/api": "http://127.0.0.1:55670",
},
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,219 @@
"""实验 5-11:对话式界面定制系统 —— FastAPI 后端。
一个最小的 chatbot 后端:前端把用户消息 POST 到 /api/chat,后端返回回复。
开发模式下用 `uvicorn main:app --reload` 启动,改动后端代码会自动 reload
(对应书中所说的"FastAPI 的热加载")。
两种回复模式(默认保持"回声式"占位,聚焦 UI 定制这一主题):
- **echo(默认)**:后端把用户消息原样回显,无需任何模型 Key,开箱即用;
- **llm(可选)**:设置模型后走真实 LLM 对话,让运行起来的 chatbot 真会说话。
通过命令行 `--model` 或环境变量 `CHAT_MODEL` 打开,复用与 agent.py 相同的
OPENAI_API_KEY / OPENAI_BASE_URL 配置。
启动方式(二选一,行为一致):
uvicorn main:app --reload --port 8000 # 书中示例:模块级 app + uvicorn 热加载
python main.py --reload --port 8000 # 本文件自带的命令行入口(见 --help)
"""
import os
import argparse
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
try: # dotenv 可选:让 --model 模式也能读到 .env 里的 OPENAI_API_KEY
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
app = FastAPI(title="Conversational UI Backend")
# 允许前端(Vite dev server, 5173)直接跨域访问,方便本地开发。
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
message: str
def _chat_model() -> str:
"""当前回复模式:返回模型名则走真实 LLM,返回空串则为默认 echo 模式。
从环境变量 CHAT_MODEL 读取(而非模块级常量),这样即使 `--reload` 触发的
子进程重新 import 本模块,也能透过环境变量拿到命令行传入的模型设置。
"""
return (os.getenv("CHAT_MODEL") or "").strip()
def _llm_reply(message: str, model: str) -> str:
"""走真实 LLM 生成回复,复用 agent.py 同款 OPENAI_* 配置。
任何异常都降级为清晰的提示(绝不编造回复),保证前端不至于白屏。
"""
try:
from openai import OpenAI
except Exception:
return "(未安装 openai 依赖,无法启用 LLM 模式;已回退占位回复)"
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
orkey = os.getenv("OPENROUTER_API_KEY")
# 通用 OpenRouter 兜底:无直连 key,或 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
prefer_or = bool(orkey) and (model or "").lower().startswith("gpt-5")
if prefer_or or (not api_key and orkey):
api_key, base_url = orkey, "https://openrouter.ai/api/v1"
if "/" not in model:
model = ("openai/" + model) if model.lower().startswith(("gpt-", "o1", "o3", "o4")) else "openai/gpt-5.6-luna"
if not api_key:
return "(未配置 OPENAI_API_KEY 或 OPENROUTER_API_KEY,无法启用 LLM 模式;已回退占位回复)"
client_kwargs = {"api_key": api_key, "timeout": 60.0, "max_retries": 2}
if base_url:
client_kwargs["base_url"] = base_url
try:
client = OpenAI(**client_kwargs)
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一个乐于助人的中文智能助手,回答简洁友好。"},
{"role": "user", "content": message},
],
temperature=(1 if any(k in (model or "").lower()
for k in ("gpt-5", "o1", "o3", "o4", "thinking", "reasoner", "kimi-k3"))
else 0.7),
)
return resp.choices[0].message.content or "(模型返回了空回复)"
except Exception as e: # 网络/鉴权/模型名等问题都在此兜底
return f"(调用 LLM 失败:{e};已回退占位回复)"
@app.get("/api/health")
def health():
model = _chat_model()
return {"status": "ok", "mode": "llm" if model else "echo", "model": model or None}
@app.post("/api/chat")
def chat(req: ChatRequest):
"""默认回声式回复;设置 CHAT_MODEL 后走真实 LLM 对话。
本实验聚焦"对话式 UI 定制",后端逻辑刻意保持最小;
如需真实客服体验,用 `python main.py --model <模型名>` 打开 LLM 模式即可。
"""
model = _chat_model()
if model:
return {"reply": _llm_reply(req.message, model)}
return {"reply": f"我收到了你的消息:{req.message}"}
# ---------------------------------------------------------------------------
# 命令行入口:让后端既能 `uvicorn main:app --reload` 启动,也能 `python main.py`
# 启动,并通过参数控制 host/port/热加载/回复模式/日志。
# ---------------------------------------------------------------------------
def parse_args(argv=None):
parser = argparse.ArgumentParser(
prog="main.py",
description="实验 5-11:对话式界面定制系统 —— FastAPI 后端(最小 chatbot 服务)。"
"为可对话定制的前端提供 /api/chat 载体,开发模式下配合 --reload 演示后端热加载。",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--host",
default="127.0.0.1",
help="监听地址;对外可用 0.0.0.0。",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="监听端口(前端 vite.config.js 默认把 /api 代理到 8000)。",
)
reload_group = parser.add_mutually_exclusive_group()
reload_group.add_argument(
"--reload",
dest="reload",
action="store_true",
default=True,
help="开启热加载:改动后端 .py 自动重启(开发默认开启)。",
)
reload_group.add_argument(
"--no-reload",
dest="reload",
action="store_false",
help="关闭热加载(更接近生产运行)。",
)
parser.add_argument(
"--model",
default=os.getenv("CHAT_MODEL") or None,
metavar="NAME",
help="打开真实 LLM 对话并指定模型名(如 gpt-5.6-luna);"
"缺省则为默认的 echo 回声模式。也可用环境变量 CHAT_MODEL 设置。",
)
parser.add_argument(
"--log-level",
default="info",
choices=["critical", "error", "warning", "info", "debug", "trace"],
help="uvicorn 日志/输出级别。",
)
parser.add_argument(
"--print-config",
action="store_true",
help="只打印生效配置(JSON)后退出,不真正监听端口(便于无网络/无端口环境下校验)。",
)
return parser.parse_args(argv)
def main(argv=None):
import json
args = parse_args(argv)
# 把 --model 写回环境变量:这样 --reload 派生的子进程重新 import 本模块时,
# 也能通过 CHAT_MODEL 感知到 LLM 模式(子进程不共享本函数的局部状态)。
if args.model:
os.environ["CHAT_MODEL"] = args.model
else:
os.environ.pop("CHAT_MODEL", None)
config = {
"host": args.host,
"port": args.port,
"reload": args.reload,
"mode": "llm" if args.model else "echo",
"model": args.model,
"log_level": args.log_level,
}
if args.print_config:
print(json.dumps(config, ensure_ascii=False, indent=2))
return 0
import uvicorn
print(
f"启动 FastAPI 后端:http://{args.host}:{args.port}"
f" 模式={config['mode']}"
f" 热加载={'' if args.reload else ''}"
)
# 用 import string 才能在 --reload 下工作;从 backend/ 目录运行 `python main.py`。
uvicorn.run(
"main:app",
host=args.host,
port=args.port,
reload=args.reload,
log_level=args.log_level,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
:root{--color-primary: #2563eb;--color-primary-text: #ffffff;--color-bg: #f5f5f5;--color-panel: #ffffff;--color-text: #1f2937;--font-family: monospace;--radius: 12px}*{box-sizing:border-box}body{margin:0;background:var(--color-bg);color:var(--color-text);font-family:var(--font-family)}.app{max-width:640px;margin:0 auto;min-height:100vh;display:flex;flex-direction:column;background:var(--color-panel)}.header{padding:20px 24px;border-bottom:1px solid #e5e7eb}.header-title{margin:0;font-size:20px}.header-subtitle{margin:4px 0 0;font-size:13px;color:#6b7280}.chat-window{flex:1;padding:20px 24px;display:flex;flex-direction:column;gap:12px;overflow-y:auto}.bubble{max-width:75%;padding:10px 14px;border-radius:var(--radius);line-height:1.5;white-space:pre-wrap}.bubble-assistant{align-self:flex-start;background:#eef2f5;color:var(--color-text)}.bubble-user{align-self:flex-end;background:var(--color-primary);color:var(--color-primary-text)}.composer{display:flex;gap:8px;padding:16px 24px;border-top:1px solid #e5e7eb}.composer-input{flex:1;padding:10px 12px;border:1px solid #d1d5db;border-radius:var(--radius);font-size:14px;font-family:inherit}.send-button{padding:10px 20px;border:none;border-radius:var(--radius);background:var(--color-primary);color:var(--color-primary-text);font-size:14px;cursor:pointer}.send-button:hover{opacity:.9}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话式界面定制系统</title>
<script type="module" crossorigin src="/assets/index-CvgOOKX3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CCn-3TaK.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>对话式界面定制系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
@@ -0,0 +1,20 @@
{
"name": "conversational-ui-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "实验 5-11:对话式界面定制系统 —— React(Vite) 前端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
@@ -0,0 +1,78 @@
import { useState } from "react";
// ===========================================================================
// 基础 chatbot 界面。
// 这是一个"可被自然语言定制"的最小 React 应用:
// - 标题文案、按钮文字等 UI 文本都写在这里(Agent 可按需求改文案);
// - 颜色、字体、布局等样式集中在 theme.css(Agent 可按需求改样式)。
// 用户在对话中说"把发送按钮改成蓝色 / 换成等宽字体 / 标题改成 XXX"
// Agent 会定位并修改这些源码文件,Vite HMR 让改动即时生效。
// ===========================================================================
// UI 文案(Agent 定制"文案"需求时修改这里)
const HEADER_TITLE = "我的专属客服";
const HEADER_SUBTITLE = "有什么可以帮你的吗?";
const SEND_BUTTON_LABEL = "发送";
export default function App() {
const [messages, setMessages] = useState([
{ role: "assistant", text: "你好!我是你的智能助手,随时为你服务。" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
setMessages((m) => [...m, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
const data = await resp.json();
setMessages((m) => [...m, { role: "assistant", text: data.reply }]);
} catch (e) {
setMessages((m) => [
...m,
{ role: "assistant", text: "(后端未连接,这是本地占位回复)" },
]);
} finally {
setLoading(false);
}
}
return (
<div className="app">
<header className="header">
<h1 className="header-title">{HEADER_TITLE}</h1>
<p className="header-subtitle">{HEADER_SUBTITLE}</p>
</header>
<main className="chat-window">
{messages.map((m, i) => (
<div key={i} className={`bubble bubble-${m.role}`}>
{m.text}
</div>
))}
{loading && <div className="bubble bubble-assistant">思考中</div>}
</main>
<footer className="composer">
<input
className="composer-input"
value={input}
placeholder="输入消息…"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
<button className="send-button" onClick={handleSend}>
{SEND_BUTTON_LABEL}
</button>
</footer>
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./theme.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
@@ -0,0 +1,108 @@
/* ==========================================================================
主题样式表(Agent 定制"颜色 / 字体 / 布局"需求时修改这里)
为方便自然语言定制,关键视觉变量集中在 :root 里。
========================================================================== */
:root {
--color-primary: #2563eb; /* 主色(发送按钮、用户气泡)——改为蓝色 */
--color-primary-text: #ffffff; /* 主色上的文字颜色 */
--color-bg: #f5f5f5; /* 页面背景 */
--color-panel: #ffffff; /* 面板/卡片背景 */
--color-text: #1f2937; /* 正文文字 */
--font-family: monospace; /* 将字体改为等宽字体 */
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
}
.app {
max-width: 640px;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-panel);
}
.header {
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.header-title {
margin: 0;
font-size: 20px;
}
.header-subtitle {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.chat-window {
flex: 1;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
border-radius: var(--radius);
line-height: 1.5;
white-space: pre-wrap;
}
.bubble-assistant {
align-self: flex-start;
background: #eef2f5;
color: var(--color-text);
}
.bubble-user {
align-self: flex-end;
background: var(--color-primary);
color: var(--color-primary-text);
}
.composer {
display: flex;
gap: 8px;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
}
.composer-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
}
.send-button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--color-primary);
color: var(--color-primary-text);
font-size: 14px;
cursor: pointer;
}
.send-button:hover {
opacity: 0.9;
}
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Vite 开发服务器默认开启 HMR(热模块替换)。
// 当 Agent 修改 src/ 下的源码时,浏览器无需整页刷新即可即时看到界面变化。
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
// 后端 FastAPI 跑在 8000,前端把 /api 请求代理过去,避免跨域。
proxy: {
"/api": "http://127.0.0.1:56044",
},
},
});
@@ -0,0 +1,6 @@
{
"returncode": 0,
"latency_s": 0.819,
"stdout": "\n> conversational-ui-frontend@0.1.0 build\n> vite build\n\nvite v6.4.3 building for production...\ntransforming...\n✓ 27 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.42 kB │ gzip: 0.31 kB\ndist/assets/index-CCn-3TaK.css 1.35 kB │ gzip: 0.56 kB\ndist/assets/index-CvgOOKX3.js 145.07 kB │ gzip: 46.92 kB\n✓ built in 405ms\n",
"stderr": ""
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,211 @@
{
"schema_version": "1.0",
"experiment": "5-11",
"run_id": "20260729T212933Z-5_11-hmr",
"started_at_utc": "2026-07-29T21:29:33.901387+00:00",
"completed_at_utc": "2026-07-29T21:30:12.405005+00:00",
"provider": "ark",
"endpoint": "https://ark.cn-beijing.volces.com/api/v3",
"model": "doubao-seed-1-6-flash-250615",
"source": {
"manuscript": "book/chapter5.md#实验-5-11",
"campaign_sha256": "11972f611b1ce9cbeff086955c29e562b0e516b8b468a55d3c072ddafcf1d3dc"
},
"servers": {
"frontend": {
"command": [
"npm",
"run",
"dev",
"--",
"--host",
"127.0.0.1",
"--port",
"56043",
"--strictPort"
]
},
"backend": {
"command": [
"python",
"main.py",
"--reload",
"--port",
"56044"
],
"health": {
"status": "ok",
"mode": "echo",
"model": null
}
}
},
"rounds": [
{
"round": 1,
"requirement": "把发送按钮和用户消息气泡的主题色从绿色改成蓝色,必须使用 #2563eb。",
"kind": "color",
"expected": "rgb(37, 99, 235)",
"observed": "rgb(37, 99, 235)",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545"
},
{
"path": "src/theme.css",
"before_sha256": "499a1d01a5dfe83bb8496349a51a9a9abd8c49df94afcb7d9615920c5653ff16",
"after_sha256": "c494cac4e0c0eb23b5b7f91925cb0997ab7eceae5e3d517736b8a71b61338a3b"
}
],
"chat_state_retained": true,
"screenshot": "round-1.png"
},
{
"round": 2,
"requirement": "把整个界面的字体换成等宽字体(monospace),保留上一轮蓝色主题。",
"kind": "font",
"expected": "monospace",
"observed": "monospace",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545"
},
{
"path": "src/theme.css",
"before_sha256": "c494cac4e0c0eb23b5b7f91925cb0997ab7eceae5e3d517736b8a71b61338a3b",
"after_sha256": "ab503f9f2821a97af18c0386893bf93cba4d9da92debad5cd740e37614a1eb68"
}
],
"chat_state_retained": true,
"screenshot": "round-2.png"
},
{
"round": 3,
"requirement": "把顶部标题改成“我的专属客服”,保留前两轮的蓝色和等宽字体。",
"kind": "title",
"expected": "我的专属客服",
"observed": "我的专属客服",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "76af82f286e3a6affb85a7c2835e78d5c97708aa5eb9088ae5693256aac76e11"
},
{
"path": "src/theme.css",
"before_sha256": "ab503f9f2821a97af18c0386893bf93cba4d9da92debad5cd740e37614a1eb68",
"after_sha256": "4b217ab0f2c33dda062217f7090b1c0da9d3cc4192add8ca8acb72a70b649e3b"
}
],
"chat_state_retained": true,
"screenshot": "round-3.png"
}
],
"browser": {
"browser": "Chromium",
"version": "139.0.7258.5",
"vite_hmr_websockets": [
{
"event": "opened",
"url": "ws://127.0.0.1:56043/?token=ylSMD3fv1F2P"
}
],
"navigation_count_after_initial_load": 0,
"sentinel_chat_state_retained": true,
"final_title": "我的专属客服",
"final_color": "rgb(37, 99, 235)",
"final_font": "monospace"
},
"build": {
"returncode": 0,
"latency_s": 0.819,
"stdout": "\n> conversational-ui-frontend@0.1.0 build\n> vite build\n\nvite v6.4.3 building for production...\ntransforming...\n✓ 27 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.42 kB │ gzip: 0.31 kB\ndist/assets/index-CCn-3TaK.css 1.35 kB │ gzip: 0.56 kB\ndist/assets/index-CvgOOKX3.js 145.07 kB │ gzip: 46.92 kB\n✓ built in 405ms\n",
"stderr": ""
},
"usage": {
"calls": 4,
"prompt_tokens": 9157,
"completion_tokens": 6656,
"total_tokens": 15813,
"latency_s": 34.724
},
"artifacts": {
"backend.log": {
"path": "backend.log",
"sha256": "5f932b9b3bb7dfe8069dfb88d5bec4cdeacc454fbdd7472dd066999089b3c090",
"bytes": 822
},
"build.json": {
"path": "build.json",
"sha256": "bc9bd81991925315ad5a9045ec8db3d6fa025f6cf7de810770f27e3804b857a7",
"bytes": 472
},
"final.png": {
"path": "final.png",
"sha256": "06d905eaf43a7db3730ae2aebcb65976a880e1761bedf9854ceb3c6bf85ce352",
"bytes": 30534
},
"frontend.log": {
"path": "frontend.log",
"sha256": "c38d7e06f6656d8a14ab216329383fedd4feb924eee00dfbd6ac371669cdc7fb",
"bytes": 598
},
"receipts.checkpoint.json": {
"path": "receipts.checkpoint.json",
"sha256": "88664e677ef9a1665dd4b64d94163fe9d75188e2a34eedd833802f66882756c2",
"bytes": 58118
},
"receipts.json": {
"path": "receipts.json",
"sha256": "88664e677ef9a1665dd4b64d94163fe9d75188e2a34eedd833802f66882756c2",
"bytes": 58118
},
"round-1.png": {
"path": "round-1.png",
"sha256": "76c8b80499d55d5a2247ceed6064fcea3470fe516b4efa17a37fb3b3b0539c2c",
"bytes": 31747
},
"round-2.png": {
"path": "round-2.png",
"sha256": "cf092ba62997a36b21e129dc252b1dd18c37c7a8b572c4aec98d0f8a6c3d9395",
"bytes": 29210
},
"round-3.png": {
"path": "round-3.png",
"sha256": "06d905eaf43a7db3730ae2aebcb65976a880e1761bedf9854ceb3c6bf85ce352",
"bytes": 30534
},
"rounds.json": {
"path": "rounds.json",
"sha256": "253ff0223525af311c3ed8eb279d37f24cdf570b3b27e83c1fae039362335d91",
"bytes": 2369
},
"final-source/src/App.jsx": {
"path": "app/frontend/src/App.jsx",
"sha256": "76af82f286e3a6affb85a7c2835e78d5c97708aa5eb9088ae5693256aac76e11",
"bytes": 2750
},
"final-source/src/theme.css": {
"path": "app/frontend/src/theme.css",
"sha256": "4b217ab0f2c33dda062217f7090b1c0da9d3cc4192add8ca8acb72a70b649e3b",
"bytes": 2209
}
},
"acceptance_gates": {
"real_react_vite_dev_server": true,
"real_fastapi_backend_reload_mode": true,
"real_model_generated_three_sequential_edits": true,
"vite_hmr_websocket_observed": true,
"no_full_page_navigation_during_three_edits": true,
"react_chat_state_preserved_across_hmr": true,
"all_color_font_title_requests_visible": true,
"final_vite_build_passed": true,
"raw_provider_receipts_complete": true,
"rendered_browser_images_retained": true
},
"official_complete": true
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,65 @@
[
{
"round": 1,
"requirement": "把发送按钮和用户消息气泡的主题色从绿色改成蓝色,必须使用 #2563eb。",
"kind": "color",
"expected": "rgb(37, 99, 235)",
"observed": "rgb(37, 99, 235)",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545"
},
{
"path": "src/theme.css",
"before_sha256": "499a1d01a5dfe83bb8496349a51a9a9abd8c49df94afcb7d9615920c5653ff16",
"after_sha256": "c494cac4e0c0eb23b5b7f91925cb0997ab7eceae5e3d517736b8a71b61338a3b"
}
],
"chat_state_retained": true,
"screenshot": "round-1.png"
},
{
"round": 2,
"requirement": "把整个界面的字体换成等宽字体(monospace),保留上一轮蓝色主题。",
"kind": "font",
"expected": "monospace",
"observed": "monospace",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545"
},
{
"path": "src/theme.css",
"before_sha256": "c494cac4e0c0eb23b5b7f91925cb0997ab7eceae5e3d517736b8a71b61338a3b",
"after_sha256": "ab503f9f2821a97af18c0386893bf93cba4d9da92debad5cd740e37614a1eb68"
}
],
"chat_state_retained": true,
"screenshot": "round-2.png"
},
{
"round": 3,
"requirement": "把顶部标题改成“我的专属客服”,保留前两轮的蓝色和等宽字体。",
"kind": "title",
"expected": "我的专属客服",
"observed": "我的专属客服",
"changed_files": [
{
"path": "src/App.jsx",
"before_sha256": "87a2587c89fed419d4109b7db931e0b0177a3c907d20a0c6b08f7e3ffe161545",
"after_sha256": "76af82f286e3a6affb85a7c2835e78d5c97708aa5eb9088ae5693256aac76e11"
},
{
"path": "src/theme.css",
"before_sha256": "ab503f9f2821a97af18c0386893bf93cba4d9da92debad5cd740e37614a1eb68",
"after_sha256": "4b217ab0f2c33dda062217f7090b1c0da9d3cc4192add8ca8acb72a70b649e3b"
}
],
"chat_state_retained": true,
"screenshot": "round-3.png"
}
]