构建AI代码代理Web UI:从终端日志到可视化工作流
1. 项目缘起:为什么一个代码代理需要一个干净的Web界面?
如果你和我一样,长期在终端里和Claude Code这类AI代码代理打交道,那你一定体会过那种“精神分裂”般的痛苦。一边是AI在疯狂输出代码片段、调试信息和文件变更,另一边是你需要在编辑器、终端、浏览器和项目文件之间来回切换,试图跟上它的节奏。屏幕被分割成无数个窗口,上下文在频繁切换中丢失,一个简单的代码审查或功能实现,最后演变成一场注意力管理的灾难。
我最近就深陷这种困境。Claude Code代理在终端里运行时,它的输出是线性的、混杂的。代码建议、系统命令执行结果、文件读写状态、错误堆栈,所有这些信息都挤在同一个黑色窗口里,按照时间顺序一股脑地涌出来。我需要实现一个API端点,代理生成了路由文件、控制器逻辑、数据库模型,还跑了几个测试。但当我需要回头查看它最初对数据结构的定义时,我得在几百行的终端历史里疯狂滚动搜索。更别提当它同时操作多个文件时,我根本分不清哪段输出对应哪个文件的修改。
这种体验不是在“增强”我的工作流,而是在“杀死”它。效率的瓶颈不再是AI的能力,而是我作为人类消化和处理信息的方式。我需要一个能结构化展示AI工作过程、能让我快速定位关键信息、并且能与我现有的开发环境无缝集成的界面。既然没有现成的,那就自己造一个。这就是“Claude Code Agent Web UI”项目的起点:一个为终端里的AI代码代理量身定制的、清爽的Web控制台,目标是把人从混乱的终端输出中解放出来,重新获得对开发过程的掌控感。
2. 核心设计思路:从终端日志到可视化工作流
这个项目的核心,不是简单地给终端输出套个网页壳子。它的本质是 对AI代理工作过程的信息进行解构、分类和可视化重述 。终端输出是原始的、面向机器的日志流;而Web UI需要将其转化为结构化的、面向人类的操作叙事。
2.1 信息流的解构与分类
首先,我分析了Claude Code代理在终端中的典型输出。它大致可以分解为以下几种类型:
- 思考与规划 :代理在行动前对问题的分析、步骤拆解。例如:“我需要先检查现有的用户模型,然后创建迁移文件来添加
avatar_url字段。” - 命令执行 :代理实际在系统终端中运行的命令及其输出。例如:
$ npm install lodash以及随后的安装日志。 - 代码操作 :对文件的具体修改,包括创建、读取、更新、删除。这是最核心的部分,需要展示代码差异(Diff)。
- 结果与状态 :操作执行后的总结、成功/失败状态、以及下一步建议。
- 用户交互请求 :代理遇到模糊点时,向用户提出的澄清问题。例如:“您希望这个API返回分页数据吗?”
基于这个分类,UI的设计思路就清晰了。我们不能用一个大文本框展示所有内容,而应该像开发者的IDE或Git历史界面一样,提供一个 时间线或活动流 的视图,其中每一项活动都根据其类型被渲染成不同的视觉组件。
2.2 架构选型:轻量、实时、可嵌入
我的目标是快速构建一个可用的工具,而不是一个重型企业应用。因此技术选型上遵循“轻量、实时、可嵌入”的原则:
- 后端框架 :选择了 FastAPI 。原因很简单:异步支持好(对处理实时日志流至关重要),性能高,编写API简洁直观,而且自带交互式API文档(Swagger UI),这在开发调试阶段非常有用。
- 前端框架 :选择了 Vue 3 + Composition API 。对于这种高度动态、状态复杂的单页面应用,Vue的响应式系统非常合适。Vue 3的Composition API让逻辑组织更灵活,尤其适合管理代理活动流的状态。相比React,Vue的模板语法在快速构建结构化UI时,我个人感觉更直观一些。
- 实时通信 :这是关键。终端输出是持续不断的流。传统的HTTP轮询(Polling)效率低下且延迟高。 WebSocket 是唯一的选择。它能在客户端和服务器之间建立全双工通信通道,让服务器在收到代理新输出的瞬间,就主动推送到所有连接的Web UI客户端,实现真正的实时更新。
- 进程交互 :如何捕获终端中Claude Code代理的输出?最直接的方式是使用子进程(Subprocess)管理。我们的后端服务可以启动一个子进程来运行Claude Code代理命令(例如
claude code --project ./myapp),然后实时捕获这个子进程的stdout(标准输出)和stderr(标准错误)。捕获到的每一行数据,通过WebSocket广播出去。 - 代码高亮与Diff展示 :这是提升体验的核心。前端需要集成强大的代码编辑器组件来展示代码片段和高亮。我选择了 Monaco Editor ,它就是VS Code编辑器背后的组件,对代码高亮、Diff显示的支持是行业顶尖的。虽然体积有点大,但为了完美的代码查看体验,值得。
注意 :直接运行用户提供的命令存在安全风险。在这个个人工具场景下,我默认运行的是可信的、本地的开发命令。但如果要将其设计为开放服务,必须对命令进行严格的沙箱(Sandbox)隔离和白名单过滤,防止命令注入攻击。
整个架构的流程可以概括为: 终端代理进程 -> 子进程捕获 -> FastAPI后端(WebSocket广播) -> Vue前端(分类渲染) 。形成了一个从命令行到可视化界面的实时管道。
3. 关键实现细节与踩坑实录
有了设计蓝图,接下来就是动手实现。这个过程充满了“细节魔鬼”,也踩了不少坑。
3.1 后端实现:构建实时日志管道
后端首先要解决的是如何“粘合”子进程输出和WebSocket。
1. 子进程输出捕获与解析 我使用Python的 asyncio 库来创建和管理子进程,因为FastAPI本身是异步框架。
import asyncio
from fastapi import FastAPI, WebSocket
import json
app = FastAPI()
async def run_agent_command(websocket: WebSocket):
# 启动Claude Code代理进程
process = await asyncio.create_subprocess_exec(
'claude', 'code', '--project', './my_project',
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# 实时读取stdout和stderr
while True:
# 同时读取标准输出和错误输出
stdout_data = await process.stdout.readline()
stderr_data = await process.stderr.readline()
if stdout_data:
line = stdout_data.decode('utf-8').rstrip()
# 初步解析:尝试判断行类型
parsed_data = parse_agent_output(line)
# 通过WebSocket发送给前端
await websocket.send_json(parsed_data)
if stderr_data:
line = stderr_data.decode('utf-8').rstrip()
# 错误信息单独归类
await websocket.send_json({"type": "error", "content": line})
# 如果进程结束,退出循环
if process.returncode is not None:
break
这里的 parse_agent_output 函数是关键。Claude Code代理的输出通常有某种模式(比如以特定前缀开头,如 THOUGHT: , COMMAND: , FILE: )。我需要编写规则或简单的正则表达式来初步分类。更复杂的解析(比如从代码块中提取语言类型)可以放在前端或后端更深入的处理器中。
2. WebSocket连接管理 FastAPI的WebSocket支持很简洁。我需要管理多个连接,以便多个浏览器标签页可以同时查看日志。
from fastapi import WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
# 向所有活跃连接广播消息
for connection in self.active_connections:
try:
await connection.send_json(message)
except:
# 如果发送失败,可能是连接已断开,将其移除
self.disconnect(connection)
manager = ConnectionManager()
@app.websocket("/ws/logs")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
# 这里可以发送已有的历史日志,或者等待新日志
await websocket.receive_text() # 保持连接,实际可能不需要接收客户端消息
except WebSocketDisconnect:
manager.disconnect(websocket)
踩坑一:输出流阻塞与缓冲区 最初我直接使用 process.communicate() ,但发现它会等待进程结束才返回所有输出,完全无法实现“实时”。换成 readline() 异步读取才是正解。另外,需要注意子进程的缓冲区。有些程序(或Python自身的print)在输出不是终端时会进行缓冲,导致日志延迟。解决方法是在启动子进程时设置环境变量 PYTHONUNBUFFERED=1 ,或者让代理命令本身使用 flush 机制。
踩坑二:异常处理与连接清理 WebSocket连接很不稳定,浏览器标签页关闭、网络抖动都会导致断开。如果不在 broadcast 方法里做好 try-except ,一个失效的连接会导致向所有连接发送消息失败。同时,子进程也需要在WebSocket断开后被正确终止,防止僵尸进程。
3.2 前端实现:构建动态活动流视图
前端的目标是将后端推送来的、分类好的数据,渲染成一个直观的“活动流”。
1. 状态管理与活动流组织 我使用Vue 3的 reactive 或 ref 来管理一个代表活动流的数组。
import { ref } from 'vue';
const activityStream = ref([]); // 存储所有活动项
// 活动项的数据结构
// {
// id: 'unique-id',
// timestamp: '2023-10-27T10:00:00Z',
// type: 'thought' | 'command' | 'file_create' | 'file_edit' | 'info' | 'error' | 'question',
// title: '创建用户模型',
// content: '...', // 原始内容或结构化数据
// details: { ... } // 附加信息,如文件路径、代码diff等
// }
当WebSocket接收到新消息时,就解析它,创建一个新的活动项对象,并 unshift 到 activityStream 数组的开头(这样最新的活动显示在最上面)。
2. 组件化渲染 根据 activity.type ,渲染不同的UI组件。这是Vue的强项。
<template>
<div class="activity-feed">
<div v-for="activity in activityStream" :key="activity.id" class="activity-item">
<ActivityThought v-if="activity.type === 'thought'" :activity="activity" />
<ActivityCommand v-if="activity.type === 'command'" :activity="activity" />
<ActivityCode v-if="activity.type === 'file_edit'" :activity="activity" />
<ActivityQuestion v-if="activity.type === 'question'" :activity="activity" @answer="handleAnswer"/>
<!-- ... 其他类型 -->
</div>
</div>
</template>
ActivityThought:可能用一个便签纸样式的卡片展示AI的思考过程。ActivityCommand:展示一个终端风格的代码块,包含执行的命令和输出结果。可以用颜色区分命令(如$前缀)和输出。ActivityCode:这是最复杂的组件。它需要接收代码变更的详细信息(旧内容、新内容、文件路径),并使用 Monaco Editor 来展示一个并排的Diff视图。这需要动态加载Monaco,并配置original和modified模型。ActivityQuestion:当代理需要用户输入时,渲染一个输入框和按钮,用户输入的回答可以通过一个新的WebSocket消息发回后端,后端再转发给代理进程的stdin。
3. 集成Monaco Editor展示代码Diff 集成Monaco需要一些步骤。我使用 @monaco-editor/loader 来动态加载。
<template>
<div class="code-activity">
<div class="file-header">{{ activity.details.filePath }}</div>
<div ref="editorContainer" class="diff-editor"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import loader from '@monaco-editor/loader';
const props = defineProps(['activity']);
const editorContainer = ref(null);
let diffEditor = null;
onMounted(async () => {
const monaco = await loader.init();
diffEditor = monaco.editor.createDiffEditor(editorContainer.value, {
readOnly: true,
automaticLayout: true,
theme: 'vs-dark' // 保持和终端类似的深色主题
});
const originalModel = monaco.editor.createModel(
props.activity.details.oldContent || '',
props.activity.details.language // 如 'javascript', 'python'
);
const modifiedModel = monaco.editor.createModel(
props.activity.details.newContent || '',
props.activity.details.language
);
diffEditor.setModel({
original: originalModel,
modified: modifiedModel
});
});
onUnmounted(() => {
if (diffEditor) {
diffEditor.dispose();
}
});
</script>
踩坑三:Monaco Editor的性能与内存 当活动流很长,包含大量代码Diff时,每个Diff编辑器实例都会占用不少内存。如果无限制地创建,会导致浏览器标签页内存占用飙升。解决方案是 虚拟滚动 。只渲染可视区域内的活动项,离开可视区域的活动项对应的Monaco实例被销毁或缓存。这对于Vue来说,可以借助 vue-virtual-scroller 这类库实现,但集成复杂度会上升。我的第一个版本没有做虚拟滚动,在代理进行大规模重构(比如修改几十个文件)时,浏览器确实会变卡。这是一个已知的待优化项。
踩坑四:活动流的排序与过滤 活动项多了之后,查找特定文件或特定类型的操作会变困难。我后来增加了过滤栏(按类型过滤:只看代码修改、只看命令等)和搜索框(按文件路径或内容关键词搜索)。这要求前端在接收数据时,不仅渲染,还要建立索引。我使用了一个独立的 searchIndex 引用,存储所有活动的可搜索文本,过滤和搜索时操作这个索引,而不是直接遍历和过滤 activityStream 这个响应式数组,以避免不必要的UI重渲染。
4. 超越展示:交互与工作流集成
一个只读的仪表板还不够。真正的价值在于让Web UI成为与AI代理 交互 的界面。
4.1 实现双向通信:从界面控制代理
我扩展了WebSocket协议,不仅支持服务器向客户端推送日志,也支持客户端向服务器发送“指令”。
- 回答代理提问 :当
ActivityQuestion组件出现时,用户输入答案,点击发送。前端通过同一个WebSocket连接发送一个如{"type": "response", "query_id": "abc123", "answer": "是的,请使用分页"}的消息。后端接收到后,将其写入代理子进程的stdin。 - 发送自定义命令 :我在UI侧边栏添加了一个小终端输入框。用户可以手动输入任何命令(如
git status,npm test),发送后,后端会创建一个新的子进程执行该命令,并将其输出同样以活动流的形式广播回来。这样,Web UI就变成了一个增强版的终端,所有操作历史都结构化地保存了下来。 - 批准/回滚操作 :这是一个更高级的功能。对于文件修改这类“高风险”操作,UI可以展示一个“批准”按钮。只有用户点击批准后,后端才真正执行文件写入操作。甚至可以提供一个“回滚”按钮,一键将文件恢复到修改前的状态。这需要后端维护一个文件操作的事务日志。
4.2 与开发环境深度集成
为了让这个工具不止是一个独立的网页,我探索了两种集成方式:
方式一:浏览器插件 开发一个简单的浏览器插件(如Chrome Extension)。它可以在任何页面(如GitHub、文档网站)的侧边栏或弹出窗口中,显示本地的Claude Code Agent Web UI。这样,你在查阅文档时,无需切换标签页就能看到代理的执行状态。插件通过 chrome.runtime.connect 与本地后端通信(需要后端开启特定的API端点并处理CORS)。
方式二:编辑器插件 这是更理想的场景。我尝试为VS Code开发了一个插件。这个插件在VS Code内创建一个新的Webview面板,直接嵌入我开发的Web UI。最大的好处是 上下文共享 :
- 文件跳转 :点击Web UI中提到的文件路径,可以直接在VS Code主编辑器中打开该文件。
- 代码定位 :点击Diff视图中的某一行,可以在编辑器中定位到对应位置。
- 直接操作 :在Webview中“批准”的代码变更,可以直接应用到VS Code工作区的文件中。
VS Code插件通过 vscode API与编辑器交互,通过 fetch 或 WebSocket 与本地后端通信。虽然实现起来比纯Web复杂,但提供了无缝的体验,感觉AI代理真正成为了IDE的一部分。
5. 实际效果与未来思考
构建完成并投入使用几周后,这个工具彻底改变了我与Claude Code代理的协作方式。
效率提升是立竿见影的 。以前在终端里,我需要像侦探一样从文字流中拼凑信息。现在,所有操作一目了然:左边是清晰的时间线,点击任何一个“创建文件”或“修改文件”的活动,右边立刻展开一个清晰的代码Diff视图,绿色和红色高亮显示出精确的变更。代理的“思考过程”被放在便签卡片里,让我能理解它的决策逻辑,而不是只看到最终结果。当它提问时,输入框就在问题下方,我不需要切回终端去打字。
更重要的是,它带来了工作流的可追溯性 。这个Web UI本质上成为了一个 AI辅助开发的“黑匣子”记录仪 。项目结束后,我可以回顾整个活动流,清楚地看到AI是如何一步步构建出某个功能的。这对于复盘、知识沉淀乃至团队分享都极具价值。我可以把某个复杂任务的活动流导出为一份可读的报告。
当然,这个初版工具还有很大进化空间。除了前面提到的虚拟滚动性能优化,我还想:
- 引入“会话”概念 :将一次完整的代理交互(从启动到任务完成)保存为一个会话,支持重放、分支对比。
- 更智能的解析 :目前对代理输出的解析还比较基于规则。可以尝试用一个小型LLM来实时分析输出流,进行更精准的分类和摘要生成,甚至自动提取出“本次修改的核心目标是什么”。
- 多代理协同视图 :如果未来同时运行多个AI代理处理不同任务,需要一个总控面板来监控所有代理的状态。
这个项目始于一个简单的痛点——终端太混乱。但解决它的过程,让我深入思考了人机协作界面应有的形态。AI代码代理不是另一个需要我去“命令”的工具,而是一个拥有自主性的协作者。我们的界面,不应该只是传递指令的通道,而应该是一个 共享的工作空间 ,在这里,双方的动作、意图、成果都能被清晰、结构化的呈现和理解。这个Web UI是我向这个方向迈出的一小步,而它已经让我的日常工作舒畅了太多。如果你也受困于终端的混沌,不妨也尝试构建属于你自己的“协作者面板”,你会发现,给AI一个清晰的界面,也是给你自己一片清明的思维空间。
更多推荐

所有评论(0)