2026/5/19 4:26:45
网站建设
项目流程
网站强制qq弹窗代码,全网最便宜的购物软件,汅app下载大全2022,阿里巴巴国际站入驻「在地铁上用手机写代码」#xff0c;这个念头最早是怎么蹦出来的#xff0c;我已经记不清了。只记得那天加班到凌晨两点#xff0c;拖着疲惫的身躯挤进末班地铁#xff0c;手里还攥着一个没解决的 bug。要是这时候能掏出手机#xff0c;让 AI 帮我把代码改了该多好#…「在地铁上用手机写代码」这个念头最早是怎么蹦出来的我已经记不清了。只记得那天加班到凌晨两点拖着疲惫的身躯挤进末班地铁手里还攥着一个没解决的 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 有两种模式为什么不用 WebAssemblyWASM 可是纯前端运行还不用服务器」问题在于这个项目的核心逻辑必须在服务端运行。想想看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-formatstream-jsonCodex 需要exec --json。这个方法负责「翻译」用户输入到具体命令。ParseOutputLine这是最核心的方法。每读到一行输出就调用它把 JSON 字符串解析成统一的CliOutputEvent对象。ExtractSessionIdAI 编程助手通常支持「会话恢复」功能。比如你中途断开下次可以接着聊。但前提是你得保存住会话 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.InvokeAsyncbool(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 ConcurrentDictionarystring, 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_idCodex 叫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-formatstream-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 onclickTogglePreviewPanel classlg: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 } /buttonlg: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()方法获取翻译h2T(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。更多AIGC文章RAG技术全解从原理到实战的简明指南更多VibeCoding文章