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
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:
@@ -0,0 +1,16 @@
|
||||
# 前端依赖与构建产物
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
node_modules/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
|
||||
# Vite 缓存
|
||||
frontend/.vite/
|
||||
@@ -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",
|
||||
Agent(OpenAI,默认 `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 # 定制 Agent:NL 需求 → 用 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. / 命令、代码、路径与环境变量在中英文两侧保持一致。
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,17 @@
|
||||
# 必填其一:OpenAI API Key(本实验读取此项)
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
|
||||
# 通用兜底:未配置 OPENAI_API_KEY 时自动改走 OpenRouter;
|
||||
# 默认模型 gpt-5.6-luna(gpt-5.x)直连 OpenAI 需组织实名认证,
|
||||
# 故设置了本 key 时会优先走 OpenRouter(route 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>
|
||||
+1797
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
|
||||
}
|
||||
+219
@@ -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())
|
||||
+12
@@ -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>
|
||||
Generated
+1797
File diff suppressed because it is too large
Load Diff
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
+78
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
@@ -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>
|
||||
);
|
||||
+108
@@ -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;
|
||||
}
|
||||
+15
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
+219
@@ -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())
|
||||
+12
@@ -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>
|
||||
Generated
+1797
File diff suppressed because it is too large
Load Diff
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
+78
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
@@ -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>
|
||||
);
|
||||
+108
@@ -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;
|
||||
}
|
||||
+15
@@ -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 |
+219
@@ -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())
|
||||
+1
@@ -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}
|
||||
+40
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -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>
|
||||
+12
@@ -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>
|
||||
Generated
+1797
File diff suppressed because it is too large
Load Diff
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
+78
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
@@ -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>
|
||||
);
|
||||
+108
@@ -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;
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+383
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"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user