踩坑三周,我终于把 Claude Code 和 Codex 塞进了浏览器— 一个让手机也能写代码的疯狂想法
《在地铁上写代码:一个移动端AI编程助手的诞生》讲述了作者如何开发一款能在手机上远程调用AI编程工具的项目。文章详细记录了从灵感萌发到技术实现的完整过程,包括采用Blazor Server解决流式输出难题、适配器模式统一不同CLI工具接口、IndexedDB实现本地会话存储等关键技术方案。特别分享了移动端适配的44px触摸优化、工作区隔离的安全设计等细节经验,并坦承了处理JSON边界情况、Wind
「在地铁上用手机写代码」,这个念头最早是怎么蹦出来的,我已经记不清了。只记得那天加班到凌晨两点,拖着疲惫的身躯挤进末班地铁,手里还攥着一个没解决的 bug。要是这时候能掏出手机,让 AI 帮我把代码改了该多好?
于是,一个「远程驱动 AI 编程助手」的项目就这样诞生了。
听起来简单,做起来要命。
一、背景:当 AI 编程助手遇上「移动办公」
先说说痛点。
现在市面上的 AI 编程助手,无论是 Claude Code CLI、OpenAI Codex CLI,还是 GitHub Copilot CLI,都有一个共同的「硬伤」——它们都是命令行工具。
这意味着什么?意味着你得有一台电脑,打开终端,敲命令。手机?平板?想都别想。
但问题是,我们这代程序员,已经被移动互联网惯坏了。微信能在手机上发消息,钉钉能在手机上审批,为什么写代码就必须坐在电脑前?
有没有一种可能,让浏览器成为 AI 编程助手的「遥控器」?
你在手机上输入需求,服务器上的 Claude Code 或 Codex 帮你执行,结果实时推送到你的屏幕上。不管你是在咖啡馆、地铁上,还是躺在沙发上——只要有浏览器,就能写代码。
这就是 WebCodeCli 要做的事情。
二、技术选型:为什么是 Blazor Server?
很多人第一反应可能是:「这不就是个 Web 终端吗?用 xterm.js 套个壳不就完了?」
我最初也是这么想的。但真正动手才发现,事情远没有那么简单。
2.1 流式输出的噩梦
AI 编程助手有一个显著特征——流式输出。
它不是一次性返回结果,而是像打字机一样,一个字一个字地「敲」出来。这对用户体验至关重要:如果你发了一个需求,等 30 秒没任何反馈,你会以为程序挂了。但如果你能看到 AI 正在「思考」、正在「写代码」,就会安心很多。
问题在于,Claude Code 和 Codex 的流式输出格式完全不同。
Claude Code 使用的是 stream-json 格式,输出类似这样:
{"type":"system","subtype":"init","session_id":"abc123","cwd":"/workspace"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"我来帮你..."}]}}
{"type":"tool_use","name":"Read","input":{"path":"src/main.ts"}}
Codex 使用的是 JSONL 格式,结构又是另一套:
{"type":"thread.started","thread_id":"xyz789"}
{"type":"item.started","item":{"type":"agent_message"}}
{"type":"item.updated","item":{"type":"agent_message","text":"让我分析一下..."}}
{"type":"turn.completed","usage":{"input_tokens":1234,"output_tokens":567}}
如果用传统的 REST API + 轮询方案,这种流式体验根本做不出来。用 WebSocket?可以,但状态管理会变得异常复杂。
最终我选择了 Blazor Server。
为什么?因为 Blazor Server 有一个杀手级特性——SignalR 长连接。
服务端和客户端之间天然保持着一条实时通道,DOM 更新通过这条通道即时推送。这意味着我可以在服务端读取 CLI 进程的输出流,直接把结果「推」到用户浏览器上,延迟低到几乎感知不到。
更爽的是,我不用自己处理 WebSocket 的连接管理、心跳检测、断线重连这些脏活累活——Blazor 全给我包了。
2.2 为什么不用 WebAssembly?
可能有人会问:「Blazor 有两种模式,为什么不用 WebAssembly?WASM 可是纯前端运行,还不用服务器!」
问题在于,这个项目的核心逻辑必须在服务端运行。
想想看:Claude Code CLI 和 Codex CLI 是要安装在服务器上的,它们需要访问文件系统、需要执行命令、需要网络权限。这些事情,浏览器沙箱里的 WASM 根本做不了。
Blazor Server 正好满足我的需求:UI 在浏览器渲染,逻辑在服务端执行,两者通过 SignalR 实时同步。
说白了,浏览器只是个「皮」,真正干活的还是服务器。
三、架构设计:适配器模式的优雅与挣扎
确定技术栈后,第一个要解决的问题就是:如何统一处理不同 CLI 工具的差异?
Claude Code 和 Codex 就像两个性格迥异的人——一个喜欢用 type: assistant 表示回复,另一个偏要用 item.type: agent_message;一个把会话 ID 叫 session_id,另一个非得叫 thread_id。
如果每来一个新工具就写一坨 if-else,代码很快就会变成一锅粥。
于是我祭出了老朋友——适配器模式。
3.1 接口设计:一个接口统一天下
首先,我定义了一个 ICliToolAdapter 接口:
public interface ICliToolAdapter
{
string[] SupportedToolIds { get; }
bool SupportsStreamParsing { get; }
bool CanHandle(CliToolConfig tool);
string BuildArguments(CliToolConfig tool, string prompt, CliSessionContext context);
CliOutputEvent? ParseOutputLine(string line);
string? ExtractSessionId(CliOutputEvent outputEvent);
string? ExtractAssistantMessage(CliOutputEvent outputEvent);
string GetEventTitle(CliOutputEvent outputEvent);
string GetEventBadgeClass(CliOutputEvent outputEvent);
string GetEventBadgeLabel(CliOutputEvent outputEvent);
}
看起来有点长,但每个方法都有它存在的意义:
-
BuildArguments:不同 CLI 工具的命令行参数格式不同。Claude Code 需要-p --verbose --output-format=stream-json,Codex 需要exec --json。这个方法负责「翻译」用户输入到具体命令。 -
ParseOutputLine:这是最核心的方法。每读到一行输出,就调用它把 JSON 字符串解析成统一的CliOutputEvent对象。 -
ExtractSessionId:AI 编程助手通常支持「会话恢复」功能。比如你中途断开,下次可以接着聊。但前提是你得保存住会话 ID。这个方法负责从输出中「揪」出会话 ID。 -
GetEventBadgeClass/GetEventBadgeLabel:纯粹为了 UI 显示。不同类型的事件用不同颜色标注,比如「工具调用」是蓝色,「错误」是红色。
3.2 Claude Code 适配器:细节里的魔鬼
以 Claude Code 适配器为例,来看看实际处理有多复杂。
Claude Code 的输出格式看起来规整,但实际上有好几种「方言」:
-
旧版格式:
type直接就是init、message、tool_use这些。 -
新版格式:
type是system或assistant,具体类型要看内嵌的subtype或message.role。 -
非 JSON 行:有时候 CLI 会吐出一些日志或错误信息,根本不是 JSON。
处理逻辑大概是这样的:
public CliOutputEvent? ParseOutputLine(string line)
{
var trimmed = line.Trim();
// 第一关:过滤非 JSON 行
if (!trimmed.StartsWith("{") && !trimmed.StartsWith("["))
{
var isError = trimmed.StartsWith("Error:", StringComparison.OrdinalIgnoreCase);
return new CliOutputEvent
{
EventType = isError ? "error" : "raw",
IsError = isError,
Title = isError ? "错误" : "输出",
Content = trimmed
};
}
// 第二关:尝试 JSON 解析
try
{
using var document = JsonDocument.Parse(trimmed);
var root = document.RootElement;
var eventType = GetStringProperty(root, "type") ?? "unknown";
var outputEvent = new CliOutputEvent { EventType = eventType, RawJson = line };
switch (eventType)
{
case "init":
ParseInitEvent(root, outputEvent);
break;
case "system":
// 新版格式:检查 subtype
if (root.TryGetProperty("subtype", out var subtypeEl) &&
subtypeEl.GetString() == "init")
{
outputEvent.EventType = "init";
ParseInitEvent(root, outputEvent);
}
else
{
ParseSystemEvent(root, outputEvent);
}
break;
case "assistant":
ParseAssistantOrUserEvent(root, outputEvent, isAssistant: true);
break;
// ... 更多 case
}
return outputEvent;
}
catch (JsonException)
{
// JSON 解析失败也不要慌,当普通输出处理
return new CliOutputEvent
{
EventType = "raw",
Title = "输出",
Content = trimmed
};
}
}
这里有个设计决策值得一提:绝不让解析失败破坏用户体验。
早期版本里,我遇到解析不了的行就直接抛异常,结果整个输出流都断了。后来改成「兜底策略」——解析失败就当普通文本显示,至少用户能看到原始输出,而不是一脸懵逼对着空白屏幕。
3.3 工具调用的「待办列表」坑
还有一个让我头疼了整整两天的问题:待办列表(TodoWrite)的渲染。
Claude Code 有个叫 TodoWrite 的工具,AI 会用它来记录任务清单。输出格式是这样的:
{
"type": "assistant",
"message": {
"content": [{
"type": "tool_use",
"name": "TodoWrite",
"input": {
"todos": [
{"content": "分析需求", "status": "completed"},
{"content": "实现功能", "status": "in_progress"},
{"content": "编写测试", "status": "pending"}
]
}
}]
}
}
一开始我把它当普通的「工具调用」处理,UI 上显示的是一坨难看的 JSON。
后来专门加了一段逻辑,检测到是 TodoWrite 工具时,把 JSON 转成用户友好的格式:
✓ 分析需求
◐ 实现功能
○ 编写测试
这个细节花了不少时间,但效果立竿见影——用户终于能看懂 AI 在干什么了。
四、会话管理:IndexedDB + 防抖,小小的优化大大的提升
AI 编程助手的一个核心体验是会话连续性。你跟 AI 聊了半小时,中途刷新一下页面,之前的对话不能丢。
最直接的方案是存服务端数据库,但这样有两个问题:
-
读写频繁:每发一条消息就往数据库里存,对服务器压力很大。
-
隐私顾虑:用户可能不希望对话内容被服务器留存。
所以我选择了 IndexedDB——浏览器内置的本地数据库。
4.1 Blazor 调用 IndexedDB 的「桥接」
Blazor Server 的代码跑在服务端,要操作浏览器的 IndexedDB,必须通过 IJSRuntime 做 JavaScript 互操作。
我在前端写了一套 IndexedDB 的封装:
window.webCliIndexedDB = {
saveSession: async function(session) {
const db = await openDatabase();
const tx = db.transaction('sessions', 'readwrite');
const store = tx.objectStore('sessions');
await store.put(session);
return true;
},
loadSessions: async function() {
const db = await openDatabase();
const tx = db.transaction('sessions', 'readonly');
const store = tx.objectStore('sessions');
return await store.getAll();
},
deleteSession: async function(sessionId) {
const db = await openDatabase();
const tx = db.transaction('sessions', 'readwrite');
const store = tx.objectStore('sessions');
await store.delete(sessionId);
return true;
}
};
然后在 C# 里这样调用:
var success = await _jsRuntime.InvokeAsync<bool>("webCliIndexedDB.saveSession", session);
简单粗暴,但有效。
4.2 防抖:别让保存操作把浏览器干崩
问题来了。
AI 的流式输出是一个字一个字往外蹦的,如果每收到一点内容就存一次 IndexedDB,一条消息可能触发几十上百次写入。浏览器扛不住不说,还会严重影响渲染性能。
解决方案是防抖(Debounce)。
核心思想:收到保存请求后,不立即执行,而是等一小段时间(比如 500ms)。如果这段时间内又来了新请求,就重置计时器。只有「安静」了 500ms 后,才真正执行保存。
public Task SaveSessionAsync(SessionHistory session)
{
lock (_saveLock)
{
_hasPendingSave = true;
_pendingSession = session;
// 重置定时器
_saveTimer?.Dispose();
_saveTimer = new System.Threading.Timer(async _ =>
{
await ExecuteSaveAsync();
}, null, SaveDebounceMs, Timeout.Infinite);
}
return Task.CompletedTask;
}
这招一出,IndexedDB 写入次数直接从每秒几十次降到每秒一两次,浏览器瞬间丝滑。
4.3 存储空间的「优雅降级」
还有个细节:IndexedDB 虽然容量比 localStorage 大得多,但也不是无限的。如果用户存了太多会话,可能会触发 QuotaExceededError。
我的处理策略是:
-
限制单个会话的消息数量(上限 1000 条,超出就删除最早的)
-
捕获配额异常并友好提示
catch (JSException ex) when (ex.Message.Contains("QuotaExceededError"))
{
_logger.LogWarning(ex, "IndexedDB 空间不足");
throw new QuotaExceededException("存储空间不足,请删除一些旧会话以释放空间", ex);
}
五、进程管理:一次性 vs 持久化,两种模式的抉择
接下来聊聊进程管理。
调用 CLI 工具,本质上就是启动一个子进程,把用户输入传进去,再把输出读出来。但怎么管理这个进程,大有讲究。
5.1 一次性进程模式
最简单的方案:每次用户发消息,就启动一个新进程,执行完就杀掉。
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "claude",
Arguments = "-p \"用户的问题\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
// 读取输出...
process.WaitForExit();
process.Dispose();
优点是简单粗暴,每次都是干净的环境。
缺点也很明显:启动开销。每次启动 Claude Code CLI,它都要加载配置、初始化 MCP 服务器、连接 API……这套流程走下来,可能要好几秒。用户体验极差。
5.2 持久化进程模式
更聪明的做法是复用进程。
进程启动后不杀掉,保持在后台运行。每次有新消息,就通过标准输入「喂」进去,然后读取标准输出。这样启动开销只有第一次,后续交互都是毫秒级。
但这带来了新的挑战:
-
进程生命周期管理:怎么知道进程还活着?挂了怎么办?
-
并发控制:多个用户同时使用,进程怎么隔离?
-
输出边界判断:一次性进程可以等
WaitForExit(),持久化进程怎么知道「这轮回答结束了」?
我的方案是用一个 PersistentProcessManager 来统一管理:
public class PersistentProcessManager
{
private readonly ConcurrentDictionary<string, PersistentProcessInfo> _processes = new();
public PersistentProcessInfo GetOrCreateProcess(
string sessionId,
string toolId,
CliToolConfig tool,
string workingDirectory)
{
var key = $"{sessionId}_{toolId}";
return _processes.GetOrAdd(key, _ =>
{
// 启动新进程
var process = StartProcess(tool, workingDirectory);
return new PersistentProcessInfo
{
Process = process,
SessionId = sessionId,
ToolId = toolId
};
});
}
}
输出边界判断用的是「超时检测」:如果连续 2 秒没有新输出,就认为这轮回答结束了。
var noOutputTimeout = TimeSpan.FromSeconds(2);
while (!cancellationToken.IsCancellationRequested)
{
bool hasNewOutput = false;
if (outputReader.Peek() >= 0)
{
int bytesRead = await outputReader.ReadAsync(buffer);
if (bytesRead > 0)
{
hasNewOutput = true;
lastOutputTime = DateTime.UtcNow;
yield return new StreamOutputChunk { Content = new string(buffer, 0, bytesRead) };
}
}
if (!hasNewOutput && (DateTime.UtcNow - lastOutputTime) > noOutputTimeout)
{
// 超时,认为输出结束
break;
}
await Task.Delay(50, cancellationToken);
}
这个 2 秒的阈值是反复调优的结果——太短会误判(AI 思考中间可能停顿一下),太长用户等得难受。
六、会话恢复:让 AI 记住「上次聊到哪儿了」
AI 编程助手一个很爽的功能是「会话恢复」——你可以告诉它「继续上次的工作」,它就能接着之前的上下文继续执行。
但这需要保存「会话 ID」。Claude Code 叫 session_id,Codex 叫 thread_id,本质上是同一个东西。
难点在于:这个 ID 是 CLI 工具在运行时动态生成的,你得从输出流里「捞」出来。
我的做法是:
-
适配器在解析输出时,遇到包含会话 ID 的事件就提取出来
-
执行服务把提取到的 ID 存起来
-
下次执行时,把 ID 传给适配器,让它拼接到命令行参数里
// 适配器构建命令时,检查是否有会话 ID
public string BuildArguments(CliToolConfig tool, string prompt, CliSessionContext context)
{
var argsBuilder = new StringBuilder();
argsBuilder.Append("-p --verbose --output-format=stream-json ");
// 会话恢复参数
if (context.IsResume && !string.IsNullOrEmpty(context.CliThreadId))
{
argsBuilder.Append($"--resume {context.CliThreadId} ");
}
argsBuilder.Append($"\"{escapedPrompt}\"");
return argsBuilder.ToString();
}
// 执行服务保存会话 ID
if (hasAdapter && string.IsNullOrEmpty(cliThreadId))
{
var output = fullOutput.ToString();
var parsedThreadId = ParseCliThreadId(output, adapter);
if (!string.IsNullOrEmpty(parsedThreadId))
{
SetCliThreadId(sessionId, parsedThreadId);
}
}
这套机制跑通后,用户终于可以跨多次交互保持上下文了。比如让 AI 先写一个函数,然后再让它加个测试——AI 知道你说的是哪个函数。
七、移动端适配:44px 的触摸区域有多重要
说了这么多后端,来聊聊前端。
既然目标是「手机也能写代码」,移动端适配就是重中之重。
7.1 响应式布局
桌面端是左右分栏布局:左边是对话区,右边是预览区。
但手机屏幕那么窄,左右分栏根本不现实。我改成了上下布局,并加了一个「折叠预览区」的按钮:
<button @onclick="TogglePreviewPanel"
class="lg:hidden fixed top-1/2 right-2 -translate-y-1/2 z-50
w-10 h-10 bg-gray-800 text-white rounded-full">
@if (_isPreviewCollapsed)
{
<span>▲</span>
}
else
{
<span>▼</span>
}
</button>
lg:hidden 意味着这个按钮只在小屏幕上显示,大屏幕上自动隐藏。
7.2 触摸优化
移动端有个很容易被忽视的细节:手指比鼠标指针粗太多了。
Apple 的人机界面指南建议,触摸目标至少要 44x44 像素。我最初没在意,结果测试时发现按钮根本点不准。
后来统一给交互元素加上了最小尺寸:
.min-h-[44px] .min-w-[44px]
还加了触摸反馈:
.active:scale-95 /* 按下时轻微缩小 */
.active:bg-gray-200 /* 按下时变色 */
7.3 虚拟键盘的坑
iOS Safari 有个臭名昭著的问题:虚拟键盘弹出时,视口高度会变化,但 100vh 还是按原来的高度算,导致页面布局乱掉。
解决方案是用 CSS 自定义属性动态更新视口高度:
function updateViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
window.addEventListener('resize', updateViewportHeight);
然后在 CSS 里用 calc(var(--vh, 1vh) * 100) 代替 100vh。
八、工作区隔离:每个会话一个「沙盒」
AI 编程助手会生成文件、执行命令,必须做好隔离,不能让不同用户的文件混在一起。
我的方案是:每个会话一个独立的工作目录。
private string GetOrCreateSessionWorkspace(string sessionId)
{
lock (_workspaceLock)
{
if (_sessionWorkspaces.TryGetValue(sessionId, out var existingWorkspace))
{
return existingWorkspace;
}
var workspacePath = Path.Combine(workspaceRoot, sessionId);
if (!Directory.Exists(workspacePath))
{
Directory.CreateDirectory(workspacePath);
}
_sessionWorkspaces[sessionId] = workspacePath;
// 创建标记文件,记录创建时间
var markerFile = Path.Combine(workspacePath, ".workspace_info");
File.WriteAllText(markerFile, $"Created: {DateTime.UtcNow:O}\nSessionId: {sessionId}");
return workspacePath;
}
}
启动 CLI 进程时,把工作目录设成这个隔离目录:
startInfo.WorkingDirectory = sessionWorkspace;
这样 AI 生成的文件都在各自的目录里,互不干扰。
8.1 过期清理
长期运行后,工作区目录会越积越多,磁盘迟早撑爆。
我加了一个定时清理的后台服务,默认 24 小时没访问的工作区自动删除:
public void CleanupExpiredWorkspaces()
{
var expirationTime = DateTime.UtcNow.AddHours(-_options.WorkspaceExpirationHours);
var directories = Directory.GetDirectories(workspaceRoot);
foreach (var dir in directories)
{
var markerFile = Path.Combine(dir, ".workspace_info");
var lastAccessTime = File.Exists(markerFile)
? File.GetLastWriteTimeUtc(markerFile)
: Directory.GetLastWriteTimeUtc(dir);
if (lastAccessTime < expirationTime)
{
Directory.Delete(dir, recursive: true);
}
}
}
8.2 安全边界
另一个必须考虑的是路径穿越攻击。
如果用户构造一个类似 ../../../etc/passwd 的路径,可能会读到不该读的文件。
所有涉及文件操作的地方,我都加了路径校验:
var normalizedWorkspace = Path.GetFullPath(workspacePath);
var normalizedFile = Path.GetFullPath(fullPath);
if (!normalizedFile.StartsWith(normalizedWorkspace))
{
_logger.LogWarning("尝试访问工作区外的文件: {File}", relativePath);
return null;
}
九、Markdown 渲染与代码高亮
AI 的回复里经常包含 Markdown 格式的内容,直接显示原始文本太丑了。
我用的是 Markdig,一个高性能的 .NET Markdown 解析库:
private static readonly MarkdownPipeline _outputMarkdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.DisableHtml() // 禁用原始 HTML,防止 XSS
.Build();
private MarkupString RenderMarkdown(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return new MarkupString(string.Empty);
}
// 使用缓存避免重复渲染
if (_markdownCache.TryGetValue(markdown, out var cached))
{
return cached;
}
var html = Markdown.ToHtml(markdown, _outputMarkdownPipeline);
var result = new MarkupString(html);
// 限制缓存大小
if (_markdownCache.Count > 100)
{
_markdownCache.Clear();
}
_markdownCache[markdown] = result;
return result;
}
.DisableHtml() 很重要——AI 生成的内容不可控,如果允许原始 HTML,可能被注入恶意脚本。
代码高亮用的是 Monaco Editor(就是 VS Code 用的那个编辑器),配合前端的语法高亮渲染,效果相当不错。
十、国际化:从硬编码到动态切换
项目一开始,界面上的文字都是硬编码的中文。后来想着要支持海外用户,不得不补国际化。
我用的是 JSON 资源文件 + 动态加载:
// zh-CN.json
{
"codeAssistant.title": "AI 编程助手",
"codeAssistant.newSession": "新建会话",
"codeAssistant.sessionHistory": "会话历史"
}
// en-US.json
{
"codeAssistant.title": "AI Coding Assistant",
"codeAssistant.newSession": "New Session",
"codeAssistant.sessionHistory": "Session History"
}
然后在 Blazor 组件里通过一个 T() 方法获取翻译:
<h2>@T("codeAssistant.sessionHistory")</h2>
语言切换时,重新加载对应的 JSON 文件,刷新缓存。
老实说,这套方案有点「土」,但胜在简单可控。等需求复杂了再考虑引入成熟的 i18n 库。
十一、踩过的坑,你可以绕过去
最后聊聊几个印象深刻的坑。
11.1 Codex 的 stderr 里有正常输出
大多数 CLI 工具,stderr 用来输出错误信息,stdout 用来输出正常内容。
但 Codex 不按套路出牌——它把 JSONL 日志全往 stderr 写。
一开始我只读 stdout,结果啥也读不到。查了半天才发现问题,改成同时读取两个流并合并输出。
11.2 Windows 上的只读属性
清理工作区目录时,偶尔会遇到删除失败。
排查后发现是某些文件被设成了只读属性(不知道是哪个 CLI 工具干的)。
解决方案是先递归清除只读属性,再删除:
private static void NormalizeDirectoryAttributes(string directoryPath)
{
foreach (var file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories))
{
try
{
File.SetAttributes(file, FileAttributes.Normal);
}
catch { }
}
}
11.3 JSON 解析的边界情况
你以为 CLI 的输出永远是规整的 JSON?太天真了。
有时候会混进一些非 JSON 的内容,比如:
-
启动时的 banner 信息
-
调试日志
-
ANSI 颜色码
如果直接扔给 JSON 解析器,必挂。
我的策略是先做一层过滤:
if (!trimmed.StartsWith("{") && !trimmed.StartsWith("["))
{
// 不是 JSON,当普通文本处理
return new CliOutputEvent { EventType = "raw", Content = trimmed };
}
十二、未来的坑和机会
项目跑起来了,但还有很多可以优化的地方:
-
更多 CLI 工具支持:目前只适配了 Claude Code 和 Codex,后续可以加入 GitHub Copilot CLI、Qwen CLI、Gemini CLI 等。适配器模式的好处就是扩展方便,加个新类就行。
-
协作功能:多人同时编辑同一个项目?想想都兴奋,但实现起来是另一个量级的复杂度。
-
AI 生成代码的即时预览:现在只能预览 HTML,如果能直接运行 React/Vue 组件就更爽了。可以考虑集成在线 IDE 的沙箱能力。
-
性能优化:Blazor Server 的 SignalR 连接是有状态的,服务器内存随用户数线性增长。如果要支持大规模并发,可能得考虑 Blazor WebAssembly + 独立 API 的架构。
写在最后
从一个「在地铁上写代码」的念头,到真正把 Claude Code 和 Codex 塞进浏览器,这一路踩了不少坑,也学到了很多东西。
如果你也在做类似的项目,希望这篇文章能给你一些启发。
如果你只是路过看个热闹,那就当听了一个程序员的深夜絮叨吧。
代码已开源,地址:https://github.com/xuzeyu91/WebCode
欢迎 Star、Fork、提 Issue。
更多推荐





所有评论(0)