本机运行的单用户 Web 编程助手。浏览器里管理会话并发任务,后端用手写的工具循环调用 OpenAI 兼容的对话接口,在该会话自己的工作目录里读文件、改文件、执行命令,并把正文、思考和工具过程推回页面。对话、工具日志、放行规则和模型配置存在本机 MySQL。
没有登录,也不做成多用户服务。页面只提供本机访问:http://127.0.0.1:5173,接口在 http://127.0.0.1:8000。
| 部分 | 选用 | 在本项目里的职责 |
|---|---|---|
| 语言 | Python 3.12、TypeScript | 后端循环与工具;前端页面 |
| Web | FastAPI、Uvicorn | REST 管会话和设置,WebSocket 推对话过程。Uvicorn 固定 --workers 1 |
| 数据 | MySQL 8、SQLAlchemy 2 asyncio、aiomysql | 会话、消息、工具日志、放行规则、模型配置、搜索缓存。启动时 create_all,没有单独的迁移 |
| 模型调用 | httpx | POST {base_url}/chat/completions。对话用 SSE(stream: true),测试连接用一次非流式短请求 |
| 配置 | pydantic-settings | 从 backend/.env 读数据库、初始模型和工作目录。SEARCH_API_KEY 只放在这里 |
| 搜索 | Brave Search | 先请求 LLM Context,账号没有该接口时回退 Web Search |
| 文本搜索 | regex |
会话目录内的 grep,单行可超时,用来限制灾难性回溯 |
| 前端 | React 19、Vite 6 | 无路由、无组件库。状态放在模块里,用 useSyncExternalStore 订阅 |
| 开发代理 | Vite | /api 和 /ws 转到本机 8000。浏览器只访问 5173 |
模型协议按 OpenAI 兼容的 chat completions 使用:请求里带 messages 和 tools,回复里的 tool_calls 由宿主解析后执行。换地址和模型名即可对接同类接口,代码里没有绑定某一家供应商的 SDK。
每个会话有标题、消息记录和独立工作目录 workspace/<会话 id>/。标题取第一条用户消息去掉空白后的前 40 个字符。删除会话时同时删除消息、工具日志和这个目录。放行规则是全局的,不随会话删除。
同一会话的用户消息进队列,一次只跑一条。压缩和普通对话共用一把锁,不会叠在一起。
模型只返回文本和结构化的工具调用。读文件、写文件、跑命令、搜索都由后端执行,再把结果写成 role=tool 的消息送回下一次请求。同一次回复里的多个工具按顺序执行;前一个失败或被拒绝,后面的仍会执行。
| 工具 | 作用 | 是否先审批 |
|---|---|---|
read |
读会话目录内的文本。单文件约 2MB 上限,正文超过 5 万字符时截断 | 否 |
write |
覆盖写入,父目录不存在会创建 | 是 |
edit |
仅当 old_string 在文件里恰好出现一次时替换 |
是 |
glob |
按模式列文件,最多 200 条 | 否 |
grep |
按正则搜索,整次约 30 秒,单行约 1 秒,最多 200 条 | 否 |
shell |
在会话目录执行一条命令,默认 300 秒超时,输出流式推到页面 | 是 |
web_search |
按查询词搜索,返回标题、链接和片段。模型不能提交 URL | 否 |
finish_task |
结束这一轮,并把说明接到当前助手回复后面 | 否 |
文件工具的路径必须落在该会话目录内。.. 和指向目录外的符号链接会得到错误文本,不会抛出循环。相对路径和目录内的绝对路径都可以。
一轮里向模型请求的次数上限是 50。没有工具调用、出现成功的 finish_task、用户停止或模型接口报错时会更早结束。finish_task 若和别的工具在同一批里,这批会做完,然后不再请求模型。
模型和后端之间用 SSE。每一段正文、思考和命令输出都会经 WebSocket 推到页面,不必等整段生成完。工具参数按调用序号拼成完整 JSON 之后才执行。思考只展示和入库,下一轮不发给模型。
WebSocket 同时负责反向动作:发送消息、停止、压缩上下文、提交审批。停止会丢掉还没开始的用户消息,把正在等的审批按拒绝结束,并结束该会话的命令进程组。
write、edit、shell 默认要等用户选择。其余工具直接执行。三个选择是:本次允许、以后都允许、拒绝。120 秒不选则按拒绝处理,不写规则。拒绝或超时只结束这一次调用,模型还能看到失败说明并继续。
「以后都允许」写入全局前缀,同一轮后面的工具立刻生效:
- 命令取前两个词。之后的命令等于该前缀,或以「前缀 + 空格」开头才放行。不拆
&&、;、||。 write和edit分开记。保存的是当时的路径字符串,之后的路径等于它或以它开头即命中。
等待审批用的是进程内存里的 Future。页面刷新且后端仍在时,重连可以恢复这一条审批。进程重启后,未完成的审批和正在跑的命令会丢掉,已经入库的消息还在。单 worker 是为了让这次等待和用户的决定落在同一个进程。
shell 以当前系统用户执行。工作目录设在会话目录,并去掉环境变量名里带 API_KEY、SECRET、TOKEN、PASSWORD、MYSQL_ 的项。这不是沙箱:批准后的命令仍能访问该用户在系统里原本能访问的路径。
程序不计算 token,也不在快到窗口上限时自动压缩。每次请求发给模型的是:系统提示、已有总结(有压缩边界时)、边界之后最近若干轮。保留轮数默认 20,可在 1 到 200 之间设置。单条工具结果进入模型前大约保留 8000 字符的头尾。
压缩由用户点击触发,是另一次不带工具的模型请求。它把更早的原文收成一段纪要,最近若干轮留在时间线上。成功才覆盖总结和边界;失败、取消或总结为空时,旧数据不动。把保留轮数调大,不会把边界前面的原文自动送回模型。
write 和 edit 给模型的是一句短结果,例如「已写入某路径」。修改前后的文本记在工具日志里,页面用它们做行级 diff。
web_search 使用 SEARCH_API_KEY 调用 Brave。先走 LLM Context;401 和 429 直接失败;账号没有该接口时再走 Web Search,并记下 24 小时内不再重复探测。成功结果按查询条件缓存 24 小时。空结果视为失败,不写入成功缓存。页面只把 http 和 https 链接渲染成可点击地址。
一页完成:左侧会话,中间时间线,设置从顶栏盖住对话。窄屏时会话列表收进抽屉。
时间线按顺序显示用户消息、助手正文、默认收起的思考,以及工具卡片。命令执行中会刷新尾部输出。审批条写明如果选择「以后都允许」将保存的前缀。回车发送,Shift+回车换行。靠近底部时自动跟随新内容。
设置分三块:外观(暗色 / 亮色,强调色青、绿、紫、琥珀,存在浏览器本地)、放行规则、模型(地址、模型名、密钥、保留轮数,以及测试连接)。主题是两套配色,不靠滤镜反色。读取设置时不返回密钥;保存时密钥留空表示不修改。
页面 --WebSocket--> 会话队列
写入用户消息
重复直到结束(最多 50 次):
拼最近历史和工具清单
SSE 请求模型
按顺序执行工具,必要时等待审批
把结果写回消息
页面 <--WebSocket-- 正文增量、工具进度、审批请求
| 表 | 存什么 |
|---|---|
sessions |
标题、总结、压缩边界 |
messages |
用户、助手、工具三种消息。助手可带工具调用和思考 |
tool_logs |
参数、结果、是否失败、耗时、审批决定。write / edit 的 diff 也在这里 |
allow_rules |
全局命令前缀、写入路径、修改路径 |
llm_config |
单行模型地址、模型名、密钥、保留轮数 |
search_cache |
Brave 结果,以及「没有 LLM Context 接口」的标记 |
需要本机 MySQL、uv 和 Node.js。库要事先建好。复制环境文件后填写连接信息:
cp .env.example backend/.envMYSQL_* 用来连接数据库。LLM_BASE_URL、LLM_MODEL、LLM_API_KEY 只在第一次启动、库里还没有模型配置时写入。之后以数据库为准。SEARCH_API_KEY 始终只从环境文件读取。WORKDIR 默认是仓库下的 workspace/。这些值不提交进仓库。
./start.sh脚本检查 backend/.env 和 MySQL,停掉 8000 与 5173 上的旧进程,再启动后端和 Vite。Ctrl+C 两边一起停。
后端测试使用假的模型响应和假的 HTTP,不访问真实模型和 Brave:
cd backend && uv run pytest前端做类型检查和静态检查,没有组件测试:
cd frontend && npm run typecheck && npm run lint