news 2026/5/7 20:50:32

自托管AI代码编辑器MiniCursor:800行JS实现本地化编程助手

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
自托管AI代码编辑器MiniCursor:800行JS实现本地化编程助手

1. 项目概述:一个极简、可自托管的AI代码编辑器

如果你和我一样,对AI辅助编程工具(比如Cursor)的强大功能感到兴奋,但又对它的闭源、云端依赖以及潜在的隐私顾虑感到一丝不安,那么你一定会对今天要聊的这个项目感兴趣。我最近在GitHub上发现了一个名为MiniCursor的开源项目,它用不到800行的JavaScript代码,就实现了一个核心功能完整的AI代码编辑器。这个项目的核心吸引力在于它的“极简”和“可掌控”——没有复杂的构建步骤,只有一个Node.js进程,你可以把它指向本地任何一个项目文件夹,打开文件,然后直接与Claude对话,并通过一键点击来应用AI生成的代码修改。

简单来说,MiniCursor是一个自托管的、轻量级的AI编程伴侣。它不像那些庞大的IDE,需要你安装一堆插件和配置环境。它的架构极其清晰:前端是一个基于Monaco编辑器(也就是VS Code用的那个)的Web界面,后端是一个简单的Node.js + Express服务器,两者通过HTTP/JSON/SSE进行通信。所有文件操作都被沙箱限制在你指定的一个根目录内,安全性有基本保障。对于开发者而言,这意味着你可以完全在本地环境中运行它,你的代码和与AI的对话记录都不会离开你的机器,同时你还能享受到类似Cursor的“聊天即编码”的流畅体验。

这个项目特别适合以下几类朋友:一是注重隐私和代码安全的开发者,不希望将公司或私人项目的代码上传到第三方服务;二是喜欢折腾和定制的极客,800行的代码量意味着你可以轻松阅读、理解并修改它的每一处逻辑,把它改造成最适合自己工作流的样子;三是想学习AI与编辑器如何集成的学生或研究者,这是一个绝佳的教学案例,展示了如何将大语言模型的API(这里是Anthropic的Claude)无缝嵌入到一个真实的编辑环境中。接下来,我将带你深入拆解这个项目的设计思路、核心实现细节,并分享我在部署和试用过程中踩过的坑和总结的经验。

2. 核心架构与设计哲学解析

2.1 为什么选择“极简”作为第一原则?

MiniCursor的作者Forrest Chang在项目伊始就定下了一个明确的目标:代码量要足够少,少到可以一次坐下来读完。这个目标直接决定了项目的技术选型和架构设计。为什么“可读性”如此重要?在AI工具日益复杂和黑盒化的今天,一个透明、可审计、可修改的工具显得尤为珍贵。当你使用一个只有800行代码的工具时,你对其行为有完全的掌控力。你知道你的代码是如何被读取、AI请求是如何被发送、修改又是如何被应用的。这种“一切尽在掌握”的感觉,是使用大型闭源商业软件无法提供的。

为了实现极简,项目做了几个关键取舍:

  1. 无构建步骤:前端直接使用原生ES模块,通过CDN引入Monaco编辑器等大型依赖。这省去了Webpack、Vite等构建工具的配置和编译时间,让“启动”变得瞬间完成。代价是失去了Tree Shaking和优化,但对于这个体量的项目,完全可以接受。
  2. 单一进程:整个应用就是一个Node.js进程,同时承担了静态文件服务和后端API。没有微服务,没有消息队列,架构简单到一目了然。这降低了部署和调试的复杂度。
  3. 全文件替换策略:在v0.2版本中,AI对代码的修改是通过输出一个完整的、包含新内容代码块来实现的,前端会进行全文件替换。这种方式虽然不如增量补丁(diff/patch)精细,但实现起来极其简单和鲁棒,避免了合并冲突等复杂问题。作者明确将“基于Hunk的接受/拒绝”列入了v0.3的路线图,这体现了“先跑起来,再优化”的务实迭代思想。

这种设计哲学背后,是一种对“最小可行产品”(MVP)和“概念验证”(PoC)的深刻理解。它先解决核心痛点——在本地安全地与AI协作编码,然后再去丰富功能。这对于我们自己的项目开发也是一个很好的启示:不要一开始就追求大而全,先用最简单可靠的方式实现核心价值。

2.2 前后端分离与通信机制

MiniCursor采用了经典的前后端分离架构,但通信方式值得细说。下图清晰地展示了其数据流:

┌──────────────────────┐ HTTP/JSON/SSE ┌──────────────────────┐ │ 浏览器 (Monaco编辑器) │ ────────────────▶│ Node.js + Express │ │ public/app.js │ │ server/index.js │ │ public/index.html │ │ - /api/tree │ │ public/styles.css │ │ - /api/file (R/W) │ │ │ │ - /api/chat (SSE) │ │ │ │ - 工具调用 API │ └──────────────────────┘ └──────────────────────┘

前端(浏览器端)就是一个静态的单页应用(SPA)。核心是Monaco编辑器,它提供了代码高亮、智能感知(需要额外配置)、多标签页等专业编辑功能。文件树、聊天界面、差异对比视图等UI组件都围绕它展开。所有的业务逻辑都写在public/app.js里,大约500行代码,结构清晰。

后端(Node.js服务器)则提供了四个核心API端点:

  • GET /api/tree: 获取指定目录的文件树结构,并自动忽略node_modules.git等常见目录。
  • GET/PUT /api/file: 分别用于读取和写入文件内容。这是编辑器与磁盘交互的唯一通道。
  • POST /api/chat: 这是一个支持服务器发送事件(SSE)的端点。前端将用户消息和当前文件上下文发送过来,后端调用Claude API,并将AI返回的令牌(Token)以流的形式实时推送给前端。这就是实现“打字机效果”流式响应的关键。
  • 工具调用API:这是与Claude模型深度集成的部分。当Claude在思考过程中需要查看项目中的其他文件时,它可以“调用”read_filelist_dir这两个函数,后端会执行相应的文件操作并将结果返回给Claude,辅助其进行更准确的代码分析和修改。

安全性设计:所有文件API操作都被严格限制在环境变量MINICURSOR_ROOT所定义的根目录下。后端会对所有传入的文件路径进行规范化处理,并检查是否试图跳出沙箱(Path Traversal)。例如,如果请求/api/file?path=../../../etc/passwd,会被直接拒绝。这是一个必不可少的安全措施。

注意:虽然有了路径遍历防护,但在生产环境或处理敏感项目时,仍需谨慎。建议将MINICURSOR_ROOT设置为一个专用于开发的子目录,而非整个用户目录或系统根目录。

3. 核心功能实现与实操要点

3.1 环境搭建与首次运行

让我们从零开始,把这个工具跑起来。整个过程非常顺畅,几乎不会遇到障碍。

步骤一:获取代码与安装依赖

# 克隆项目仓库 git clone https://github.com/forrestchang/minicursor.git cd minicursor # 安装Node.js依赖 (确保你的Node版本在16以上) npm install

这里一切顺利。项目依赖非常干净,主要是express用于服务器,@anthropic-ai/sdk用于调用Claude API,以及一些开发工具。

步骤二:配置API密钥这是最关键的一步。MiniCursor依赖于Anthropic官方的Claude API。

# 复制环境变量示例文件 cp .env.example .env

然后,用你喜欢的文本编辑器打开.env文件。你会看到如下内容:

ANTHROPIC_API_KEY=sk-ant-... MINICURSOR_MODEL=claude-3-5-sonnet-20241022 PORT=5173 MINICURSOR_ROOT=.
  • ANTHROPIC_API_KEY必须填写。你需要去 Anthropic Console 注册并创建一个API密钥。新用户通常有免费额度,足够体验。
  • MINICURSOR_MODEL:默认是Claude 3.5 Sonnet,这是目前性能很强的模型。你也可以根据成本和需求换用claude-3-haiku(更快、更便宜)或claude-3-opus(更强、更贵)。
  • PORT:服务器监听的端口,默认5173,如果冲突可以修改。
  • MINICURSOR_ROOT:编辑器可以访问的根目录。默认是当前目录(.)。如果你想编辑/home/user/my_project,可以在这里设置绝对路径,或者在启动时通过命令行参数指定。

步骤三:启动服务器你有两种启动方式:

# 方式一:使用npm脚本,编辑当前目录 npm start # 这等价于:node server/cli.js . # 方式二:直接指定目标项目目录 node server/cli.js /path/to/your/project

启动后,控制台会输出类似Server running on http://localhost:5173的信息。

步骤四:打开浏览器访问http://localhost:5173。如果一切正常,你将看到一个左侧是文件树、中间是代码编辑器、右侧是聊天面板的简洁界面。恭喜,你的个人AI代码编辑器已经就绪!

实操心得:第一次运行时,如果遇到端口冲突,修改.env中的PORT变量后需要重启服务器。另外,确保你的API密钥有余额且未被禁用。如果聊天没反应,首先打开浏览器的开发者工具(F12),查看“网络”(Network)标签页中向/api/chat的请求是否返回了错误信息,这能快速定位是网络问题、API密钥问题还是服务器问题。

3.2 AI聊天与代码编辑的协同工作流

MiniCursor的核心交互发生在聊天面板和编辑器之间。它的工作流设计得非常直观。

1. 基于上下文的智能对话当你打开一个文件(比如src/utils.js)后,这个文件的全部内容会自动作为上下文的一部分,随着你的聊天消息一起发送给Claude。这意味着你不需要每次都复制粘贴代码。你可以直接问:“这个函数是做什么的?”或者“如何优化这个循环?”。Claude会基于它看到的文件内容来回答。

2. 流式响应与实时反馈你发送消息后,回复不是等全部生成完才显示,而是一个词一个词地“流”出来,就像有人在实时打字。这得益于SSE技术。体验上的好处是,如果AI一开始的生成方向不对,你可以尽早中断或修改问题,不用等待漫长的全量生成。

3. 一键应用编辑这是最酷的部分。当Claude建议修改代码时,它会遵循一个特定的格式来输出:

```edit:src/utils.js // 这里是修改后的整个文件内容 function optimizedFunction() { // ... 新的实现 } ```

前端会智能地识别这种```edit:filepath的代码块。它不会直接修改你的文件,而是会弹出一个差异对比(Diff)视图。这个视图并排显示原文件(左侧)和AI建议的新文件(右侧),所有改动行都会高亮显示(通常是绿色代表新增,红色代表删除)。在差异视图的顶部或底部,你会看到“Accept”“Reject”按钮。

  • 点击“Accept”:前端会通过PUT /api/file接口,用新内容完全替换磁盘上的原文件,并刷新编辑器中的视图。
  • 点击“Reject”:差异视图关闭,你的原文件保持不动。

这个“预览-确认”机制至关重要。它给了你最终的决定权,防止AI直接写出有问题的代码覆盖你的工作。在实际使用中,我强烈建议永远不要不看Diff就直接点Accept。快速扫一眼改动,确保它符合你的意图,没有引入奇怪的逻辑或语法错误。

4. 工具调用:让AI“看见”更多有时,要修改一个函数,AI需要了解它被调用的地方,或者相关的类型定义。这时,你可以在聊天中引导它:“看看src/components/Button.js里是怎么调用这个函数的。” 更厉害的是,Claude模型本身可以自主决定调用工具。系统提示词(System Prompt)中已经定义了read_filelist_dir两个工具。当Claude认为需要查看更多代码来更好地回答时,它会自动发起工具调用请求,后端执行后把文件内容返回给它。这个过程对用户是透明的,你会在聊天记录中看到一个小小的“工具调用”标记。这极大地增强了AI对项目的理解能力。

注意事项:工具调用虽然方便,但也意味着AI会读取你项目中的其他文件。请确保你运行MiniCursor的目录不包含极其敏感的凭证文件(如.env.production,除非你已排除)。虽然操作被限制在沙箱内,但多一层警惕总是好的。

4. 关键代码剖析与定制化指南

4.1 服务器端核心逻辑剖析

服务器的核心在server/index.js。我们挑几个最关键的端点看看。

1. 文件树接口 (/api/tree)这个接口递归扫描目录,构建一个前端需要的树形结构。关键点在于“忽略模式”。代码中有一个IGNORE_PATTERNS数组,默认包含了node_modules,.git,.venv,dist,build等。这是通过path.relativeminimatch库进行模式匹配实现的。如果你想忽略其他目录(比如coverage.next),可以很方便地在这里添加。

2. 聊天流接口 (/api/chat)这是最复杂的部分。它接收一个包含messages(对话历史)和currentFile(当前文件内容)的JSON请求。

// 简化的核心逻辑 app.post('/api/chat', async (req, res) => { const { messages, currentFile } = req.body; // 1. 构建最终的消息列表,将当前文件内容作为系统提示的一部分 const finalMessages = [...]; // 包含工具定义和文件上下文 // 2. 设置SSE响应头 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // 3. 调用Claude API,并启用流式输出 const stream = await anthropic.messages.create({ model: process.env.MINICURSOR_MODEL, messages: finalMessages, max_tokens: 4096, stream: true, // 关键参数 tools: [...], // 定义read_file和list_dir工具 }); // 4. 将流中的每个chunk转发给前端 for await (const chunk of stream) { if (chunk.type === 'content_block_delta') { // 这是文本内容流 res.write(`data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`); } else if (chunk.type === 'tool_use') { // 处理AI发起的工具调用 // ... 执行文件操作,并将结果以特定格式写回流中 } } res.write('data: [DONE]\n\n'); res.end(); });

关键点stream: true参数是实现流式响应的核心。服务器接收到AI返回的每一个数据块(chunk)就立刻转发给浏览器,而不是等待整个响应完成。当AI发起工具调用时,服务器需要中断文本流,去执行对应的文件操作,然后将操作结果作为新的消息块发送给AI,让AI继续思考。这个“中断-执行-继续”的循环是工具调用的实现精髓。

3. 工具调用实现工具调用在handleToolCall函数中。当AI发送一个tool_use块,其中包含nameread_filelist_dir)和input(如{ path: 'src/foo.js' })时,服务器会:

  1. 对路径进行安全校验(防止路径遍历)。
  2. 使用fs.readFilefs.readdir读取内容。
  3. 将读取到的内容包装成一个tool_result块,发送回AI流中。 这个过程模拟了函数调用,让AI具备了动态探索文件系统的能力。

4.2 前端交互与Diff视图实现

前端逻辑集中在public/app.js。我们关注两个亮点:聊天消息的处理和Diff视图的渲染。

1. 解析AI响应与提取编辑块前端通过EventSource监听/api/chat的SSE流。每当收到一个data事件,就将文本追加到聊天界面。同时,它需要实时检测AI是否输出了一个编辑块。

// 简化的检测逻辑 function processAITextChunk(text) { // 将文本追加到聊天DOM appendToChat(text); // 检测是否包含 ```edit:path 格式的块 const editBlockRegex = /```edit:([^\s\n]+)\n([\s\S]*?)```/g; let match; while ((match = editBlockRegex.exec(accumulatedText)) !== null) { const filePath = match[1]; const newContent = match[2]; // 触发显示Diff视图 showDiffView(filePath, newContent); } }

这里使用正则表达式来匹配特定格式的代码块。一旦匹配成功,就调用showDiffView函数,传入文件路径和AI建议的新内容。

2. 渲染Diff视图showDiffView函数是前端的一个小核心。它需要做几件事:

  • 获取原文件内容:通常,当前打开的文件内容已经在内存中。如果没有,则需要通过GET /api/file再请求一次。
  • 计算差异:MiniCursor使用了diff库(通过CDN引入)来生成行级别的差异数据。调用diff.createTwoFilesPatchdiff.parsePatch可以生成标准化的patch信息。
  • 渲染并排视图:根据差异数据,生成两个HTML片段:一个显示原文件(删除行高亮为红色),一个显示新文件(新增行高亮为绿色)。这里涉及到一些细致的DOM操作和样式处理,以创建出类似GitHub或VS Code的Diff体验。
  • 绑定按钮事件:“Accept”按钮绑定的事件处理函数会发起PUT /api/file请求,提交新内容,并在成功后刷新编辑器缓冲区。“Reject”按钮则简单地关闭Diff模态框。

3. 文件树与编辑器同步文件树使用简单的递归函数渲染。点击一个文件,会触发fetchFile函数,获取内容并在Monaco编辑器中打开一个新标签页。Monaco编辑器本身非常强大,但MiniCursor只使用了它的基础编辑功能。如果你熟悉Monaco,可以轻松为其添加更多语言支持、主题或自定义快捷键。

定制化建议:如果你觉得全文件替换太“暴力”,想提前体验v0.3的Hunk级别接受功能,可以修改showDiffView函数。在计算差异后,不直接渲染整个文件的并排对比,而是将差异数组(一个包含added,removed,value属性的对象数组)分组为一个个“Hunk”(变更块),然后为每个Hunk单独提供Accept/Reject按钮。这需要更复杂的前端状态管理,但代码逻辑是清晰的。

5. 常见问题、故障排查与进阶技巧

5.1 部署与运行中的典型问题

即使是一个简单的项目,在实际运行中也可能遇到各种小问题。下面是我在测试过程中遇到的一些情况及其解决方法。

问题现象可能原因解决方案
访问localhost:5173无响应1. 服务器未启动成功
2. 端口被占用
3. 防火墙/安全软件阻止
1. 检查终端是否有错误输出,确保npm start成功执行。
2. 运行lsof -i :5173(Mac/Linux)或netstat -ano | findstr :5173(Windows)查看端口占用,修改.env中的PORT变量。
3. 暂时禁用防火墙或添加例外规则。
聊天界面显示“API错误”或一直“思考中”1. API密钥未设置或错误
2. 网络问题,无法访问Anthropic API
3. API额度用尽或模型不可用
1. 确认.env文件中的ANTHROPIC_API_KEY正确无误,且没有多余空格。
2. 在服务器终端尝试curl https://api.anthropic.com,看是否能连通。检查代理设置。
3. 登录Anthropic控制台,检查额度和模型状态。
文件树为空或加载失败1.MINICURSOR_ROOT路径错误或无权访问
2. 项目目录下文件过多,扫描超时
1. 确认启动命令指定的路径存在且有读权限。尝试使用绝对路径。
2. 对于超大项目,可以考虑修改server/index.js中的文件树接口,增加分页或异步加载,但目前版本是同步递归,目录过深可能阻塞。
点击“Accept”后文件未保存1. 文件路径包含特殊字符或权限不足
2. 前端PUT请求失败
1. 打开浏览器开发者工具(F12)的“网络”标签,查看PUT /api/file请求的响应状态码和消息。常见403(无写权限)或404(路径错误)。
2. 检查后端/api/file接口的路径安全校验逻辑是否过于严格。
Diff视图显示混乱或无法生成1. 原文件或新内容编码问题(如含BOM)
2.diff库加载失败
1. 确保文件是UTF-8编码。可以在后端读取文件时进行标准化处理。
2. 检查浏览器控制台是否有JS错误,确认CDN上的diff库链接有效。

一个深度排查案例:我曾遇到点击Accept后,编辑器内容刷新了,但磁盘文件没变。通过浏览器网络工具发现,PUT请求返回了500错误。查看服务器日志,发现是ENOENT错误(文件不存在)。原来,AI生成的编辑块中的路径是src\utils.js(Windows反斜杠),而服务器在安全校验时对路径进行了规范化,但前端在发起请求时可能没有统一处理。解决方法是在前端发送路径前,用path.replace(/\\/g, '/')统一将反斜杠替换为正斜杠,确保前后端路径格式一致。

5.2 性能优化与使用技巧

对于日常使用,这里有一些提升体验的技巧:

  1. 模型选择与成本控制:默认的Claude 3.5 Sonnet能力最强,但Token成本也最高。对于简单的代码补全、解释任务,可以尝试在.env中切换到claude-3-haiku模型。它的响应速度极快,成本只有Sonnet的约1/5,对于大多数日常辅助编码任务已经足够。你可以根据任务复杂度动态切换,这需要稍微修改前端UI,增加一个模型下拉菜单。

  2. 上下文管理:AI的上下文窗口是有限的(Claude 3.5 Sonnet是200K Token)。虽然MiniCursor目前只发送当前打开的文件,但如果你打开一个非常大的文件,或者聊天历史很长,可能会触及限制。一个实用的技巧是:在完成一个复杂问题的讨论后,可以手动清空聊天记录(这需要前端添加一个清空按钮),或者开启新的聊天会话,以释放上下文。

  3. 精准提问获得更好编辑:AI生成编辑块的格式取决于系统提示词。MiniCursor的系统提示词已经写得不错,但你可以通过更精准的提问来引导AI输出更符合你预期的修改。例如,与其问“优化这个函数”,不如问“请用ES6箭头函数和map方法重写这个循环,并输出完整的edit:src/utils.js代码块”。明确的指令能减少AI的猜测,提高输出质量。

  4. 安全强化:如果你计划在团队内网中长期使用,可以考虑以下增强措施:

    • 身份验证:在server/index.js的API路由前添加一个简单的Basic Auth中间件。
    • 更严格的路径限制:修改MINICURSOR_ROOT,不要指向包含敏感信息的父目录。
    • 日志记录:记录所有的聊天请求和文件写入操作,便于审计。
  5. 扩展功能思路

    • 终端集成:参考路线图,添加一个终端标签页,让Claude不仅能看代码,还能执行npm installgit commit等命令(需极其谨慎,可做成需手动确认的模式)。
    • 多模型支持:除了Claude,可以接入OpenAI的GPT系列或本地的Ollama模型。这需要抽象出一个统一的AI Provider接口。
    • 自定义提示词模板:允许用户保存和加载针对不同任务(如“代码审查”、“生成测试”、“重构”)的系统提示词模板。

MiniCursor作为一个v0.2版本的项目,已经展示了一个自托管AI编码工具的完整雏形。它的价值不在于功能有多全面,而在于其极简的代码和清晰的设计,为我们提供了一个绝佳的学习范本和定制起点。你可以把它当作一个玩具,也可以以此为基础,打造一个完全贴合自己习惯的、私有的AI编程环境。这种将强大AI能力“拉下神坛”,封装进一个自己可以完全理解和控制的工具里的过程,本身就是一种充满乐趣和成就感的探索。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/7 20:49:29

别再乱用SVC了!手把手教你用Cortex-M7的PendSV实现RTOS零中断延迟切换

Cortex-M7上下文切换优化:用PendSV实现零中断延迟的RTOS设计 在嵌入式实时系统开发中,中断响应速度直接决定了系统能否满足硬实时需求。许多工程师习惯性地使用SVC指令或全局关中断来实现上下文切换,却不知这种操作可能成为系统实时性的隐形…

作者头像 李华
网站建设 2026/5/7 20:48:37

手把手教你用JMeter和Grafana搭建智能座舱性能监控与压测环境

智能座舱性能监控与压测实战:JMeterGrafana全链路配置指南 在智能座舱系统开发中,性能瓶颈往往成为影响用户体验的关键因素。想象一下,当车辆同时处理导航规划、语音交互和娱乐系统请求时,系统响应延迟或崩溃会直接导致驾驶安全风…

作者头像 李华
网站建设 2026/5/7 20:48:37

大模型KV缓存优化:原理、实践与性能提升

1. 大模型推理优化的核心挑战在大型语言模型(LLM)的实际部署中,推理阶段的性能瓶颈往往比训练阶段更令人头疼。我最近在部署一个70亿参数模型时发现,即使使用高端GPU,生成式任务的响应延迟仍然难以满足实时交互需求。经…

作者头像 李华
网站建设 2026/5/7 20:41:44

Modbus RTU通信不求人:5分钟搞懂CRC校验,附可直接调用的C语言代码

Modbus RTU通信实战指南:CRC校验原理与即插即用代码解析 在工业自动化领域,Modbus RTU协议因其简单可靠而广泛应用。许多工程师在项目集成时,往往被CRC校验这个"黑盒"环节绊住脚步——要么校验失败导致通信中断,要么被迫…

作者头像 李华
网站建设 2026/5/7 20:41:44

你的游戏时间被谁偷走了?揭秘MAA如何用AI算法找回每日30分钟

你的游戏时间被谁偷走了?揭秘MAA如何用AI算法找回每日30分钟 【免费下载链接】MaaAssistantArknights 《明日方舟》小助手,全日常一键长草!| A one-click tool for the daily tasks of Arknights, supporting all clients. 项目地址: https…

作者头像 李华