「在地铁上用手机写代码」,这个念头最早是怎么蹦出来的,我已经记不清了。只记得那天加班到凌晨两点,拖着疲惫的身躯挤进末班地铁,手里还攥着一个没解决的 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。
更多AIGC文章
RAG技术全解:从原理到实战的简明指南
更多VibeCoding文章