<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Lorenzo Feng</title><description>Blog Site</description><link>https://lorenzofeng.top/</link><language>zh_CN</language><item><title>给 OpenCode 换个 Logo —— TUI 插件开发入门</title><link>https://lorenzofeng.top/posts/opencode-tui-logo-customization/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/opencode-tui-logo-customization/</guid><description>通过 TUI 插件的 slot 机制给 OpenCode 终端界面换上自定义 Logo，附带双色 ASCII 艺术字和主题色适配教程</description><pubDate>Tue, 02 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;最近给 OpenCode 换了个启动 Logo，把默认的 OpenCode 字样换成了自己的品牌名「LorenzoFeng」。整个过程踩了几个坑，记录一下完整的 TUI 插件开发流程。&lt;/p&gt;
&lt;h2&gt;为什么不能用配置文件&lt;/h2&gt;
&lt;p&gt;直觉上，换个 Logo 应该是配置项的事。OpenCode 确实有过一个 PR（&lt;a href=&quot;https://github.com/anomalyco/opencode/pull/12017&quot;&gt;#12017&lt;/a&gt;）试图在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 里加 &lt;code&gt;&quot;logo&quot;&lt;/code&gt; 字段，但被维护者关了，理由是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;You can customize it with tui plugins.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;也就是说，OpenCode 的 TUI 定制走的是&lt;strong&gt;插件机制&lt;/strong&gt;而非&lt;strong&gt;配置项&lt;/strong&gt;。Logo、侧边栏、底部状态栏都通过 slot 机制暴露给插件。&lt;/p&gt;
&lt;h2&gt;TUI 插件的 Slot 机制&lt;/h2&gt;
&lt;p&gt;OpenCode 的 TUI 界面由一系列 &lt;strong&gt;slot（插槽）&lt;/strong&gt; 组成，插件可以注册到其中进行替换或追加：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slot 名称&lt;/th&gt;
&lt;th&gt;位置&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_logo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;首页 Logo&lt;/td&gt;
&lt;td&gt;我们要替换的目标&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;首页输入框&lt;/td&gt;
&lt;td&gt;可替换为自定义输入组件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_bottom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;首页底部&lt;/td&gt;
&lt;td&gt;OS / Provider 信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;home_footer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页脚&lt;/td&gt;
&lt;td&gt;状态栏下方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sidebar_title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;侧边栏标题&lt;/td&gt;
&lt;td&gt;会话标题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sidebar_content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;侧边栏内容&lt;/td&gt;
&lt;td&gt;文件变更 / MCP / LSP 状态&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;注册方式分两种模式：&lt;code&gt;&quot;replace&quot;&lt;/code&gt; 替换整个 slot，不传 mode 则添加进去。&lt;/p&gt;
&lt;h2&gt;插件目录结构&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;关键踩坑点&lt;/strong&gt;：TUI 插件&lt;strong&gt;必须&lt;/strong&gt;有 &lt;code&gt;package.json&lt;/code&gt; 并声明 &lt;code&gt;exports[&quot;./tui&quot;]&lt;/code&gt;，光放一个 &lt;code&gt;.tsx&lt;/code&gt; 文件不生效。正确的结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;~/.config/opencode/plugins/custom-logo/
├── package.json
└── tui.tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;package.json&lt;/code&gt; 长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;custom-logo&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;type&quot;: &quot;module&quot;,
  &quot;exports&quot;: {
    &quot;./tui&quot;: &quot;./tui.tsx&quot;
  },
  &quot;peerDependencies&quot;: {
    &quot;@opencode-ai/plugin&quot;: &quot;*&quot;,
    &quot;@opentui/core&quot;: &quot;*&quot;,
    &quot;@opentui/solid&quot;: &quot;*&quot;,
    &quot;solid-js&quot;: &quot;*&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenCode 通过 &lt;code&gt;exports[&quot;./tui&quot;]&lt;/code&gt; 来定位 TUI 入口，没有这个声明插件就不会被加载。&lt;/p&gt;
&lt;h2&gt;插件代码&lt;/h2&gt;
&lt;p&gt;插件基于 &lt;a href=&quot;https://www.solidjs.com/&quot;&gt;Solid.js&lt;/a&gt; 和 &lt;code&gt;@opentui/solid&lt;/code&gt; 渲染。把「Lorenzo」和「Feng」拆成两个独立数组，用 flex 布局左右排列，分别上蓝色和正文色：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginModule, TuiThemeCurrent } from &quot;@opencode-ai/plugin/tui&quot;

const LORENZO = [
`
██╗      ██████╗ ██████╗ ███████╗███╗   ██╗███████╗ ██████╗ 
██║     ██╔═══██╗██╔══██╗██╔════╝████╗  ██║╚══███╔╝██╔═══██╗
██║     ██║   ██║██████╔╝█████╗  ██╔██╗ ██║  ███╔╝ ██║   ██║
██║     ██║   ██║██╔══██╗██╔══╝  ██║╚██╗██║ ███╔╝  ██║   ██║
███████╗╚██████╔╝██║  ██║███████╗██║ ╚████║███████╗╚██████╔╝
╚══════╝ ╚═════╝ ╚═╝  ╚═╝╚══════╝╚═╝  ╚═══╝╚══════╝ ╚═════╝ 
`
]

const FENG = [
`
███████╗███████╗███╗   ██╗ ██████╗ 
██╔════╝██╔════╝████╗  ██║██╔════╝ 
█████╗  █████╗  ██╔██╗ ██║██║  ███╗
██╔══╝  ██╔══╝  ██║╚██╗██║██║   ██║
██║     ███████╗██║ ╚████║╚██████╔╝
╚═╝     ╚══════╝╚═╝  ╚═══╝ ╚═════╝ 
`
]

const SUBTITLE = &quot;Web Site: https://lorenzofeng.top/&quot;

function HomeLogo(props: { theme: TuiThemeCurrent }) {
  const lorenzoColor = props.theme.info      // 蓝色
  const fengColor = props.theme.text          // 正文色
  const muted = props.theme.textMuted
  const gap = &quot;    &quot;

  return (
    &amp;lt;box flexDirection=&quot;column&quot; alignItems=&quot;center&quot;&amp;gt;
      &amp;lt;box flexDirection=&quot;row&quot;&amp;gt;
        {/* LORENZO —— 蓝色 */}
        &amp;lt;box flexDirection=&quot;column&quot;&amp;gt;
          {LORENZO.map((line) =&amp;gt; (
            &amp;lt;text fg={lorenzoColor}&amp;gt;{line}&amp;lt;/text&amp;gt;
          ))}
        &amp;lt;/box&amp;gt;
        {/* 间距 */}
        &amp;lt;box flexDirection=&quot;column&quot;&amp;gt;
          {LORENZO.map(() =&amp;gt; (
            &amp;lt;text fg={muted}&amp;gt;{gap}&amp;lt;/text&amp;gt;
          ))}
        &amp;lt;/box&amp;gt;
        {/* FENG —— 正文色 */}
        &amp;lt;box flexDirection=&quot;column&quot;&amp;gt;
          {FENG.map((line) =&amp;gt; (
            &amp;lt;text fg={fengColor}&amp;gt;{line}&amp;lt;/text&amp;gt;
          ))}
        &amp;lt;/box&amp;gt;
      &amp;lt;/box&amp;gt;
      &amp;lt;text&amp;gt; &amp;lt;/text&amp;gt;
      &amp;lt;box flexDirection=&quot;row&quot; gap={1}&amp;gt;
        &amp;lt;text fg={muted}&amp;gt;━&amp;lt;/text&amp;gt;
        &amp;lt;text fg={muted} bold&amp;gt;{SUBTITLE}&amp;lt;/text&amp;gt;
        &amp;lt;text fg={muted}&amp;gt;━&amp;lt;/text&amp;gt;
      &amp;lt;/box&amp;gt;
      &amp;lt;text&amp;gt; &amp;lt;/text&amp;gt;
    &amp;lt;/box&amp;gt;
  )
}

const tui: TuiPlugin = async (api) =&amp;gt; {
  api.slots.register({
    mode: &quot;replace&quot;,
    slots: {
      home_logo(ctx) {
        return &amp;lt;HomeLogo theme={ctx.theme.current} /&amp;gt;
      },
    },
  })
}

export default { id: &quot;custom-logo&quot;, tui }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个要点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/** @jsxImportSource @opentui/solid */&lt;/code&gt; 声明 JSX 编译目标，这句不能丢&lt;/li&gt;
&lt;li&gt;Logo 拆成两个数组 → &lt;code&gt;&amp;lt;box flexDirection=&quot;row&quot;&amp;gt;&lt;/code&gt; 横向排列 → 左边的 map 遍历 &lt;code&gt;LORENZO&lt;/code&gt;，右边的 map 遍历 &lt;code&gt;FENG&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;slots.register()&lt;/code&gt; 接受 &lt;code&gt;{ mode, slots }&lt;/code&gt; 对象，&lt;code&gt;&quot;replace&quot;&lt;/code&gt; 表示完全替换 OpenCode 默认 Logo&lt;/li&gt;
&lt;li&gt;slot 渲染函数接收 &lt;code&gt;ctx&lt;/code&gt; 上下文，通过 &lt;code&gt;ctx.theme.current&lt;/code&gt; 拿到当前主题色&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;颜色配置&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;变量&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.primary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;主色&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.accent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;强调色&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;蓝色系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.success&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;绿色系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.warning&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;黄色系&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;theme.error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;红色系&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;也可以用硬编码色值 &lt;code&gt;fg=&quot;#ff6b6b&quot;&lt;/code&gt;，但不会随主题切换。&lt;/p&gt;
&lt;h2&gt;ASCII 艺术字生成&lt;/h2&gt;
&lt;p&gt;Logo 用的字体风格是 &lt;strong&gt;ANSI Shadow&lt;/strong&gt;，生成工具推荐 &lt;a href=&quot;https://patorjk.com/software/taag&quot;&gt;patorjk.com/software/taag&lt;/a&gt;，有上百种字体可选。推荐几个适合终端显示的：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;字体&lt;/th&gt;
&lt;th&gt;效果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANSI Shadow&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;带阴影的块状字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Big&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;简洁大方&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Block&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;实心方块&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Doom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;游戏风格&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Graffiti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;涂鸦风&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;注册插件&lt;/h2&gt;
&lt;p&gt;插件写好后需要注册。有两种方式：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;方式一——自动发现&lt;/strong&gt;：把插件放在 &lt;code&gt;~/.config/opencode/plugins/&lt;/code&gt; 目录下，OpenCode 启动时自动扫描加载。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;方式二——显式声明&lt;/strong&gt;：在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 或 &lt;code&gt;tui.json&lt;/code&gt; 的 &lt;code&gt;plugin&lt;/code&gt; 数组中加绝对路径：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;plugin&quot;: [
    &quot;~/.config/opencode/plugins/custom-logo&quot;
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两种方式我都用了，确保万无一失。插件支持热更新，改 &lt;code&gt;tui.tsx&lt;/code&gt; 后重进 TUI 即可生效，不需要重启 OpenCode。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;OpenCode 的 TUI 插件系统比预期灵活很多——slot 机制让几乎所有界面元素都可替换。虽然 Logo 不能通过配置文件直接改，但写一个十几行的插件就能搞定，而且能做的事情远不止换 Logo：侧边栏、输入框、状态栏都能深度定制。&lt;/p&gt;
</content:encoded></item><item><title>AgentEnv 路线图——从包管理器到完整环境管理，寻找合作者</title><link>https://lorenzofeng.top/posts/agentenv-runtime-roadmap/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agentenv-runtime-roadmap/</guid><description>AgentEnv 的下一个目标：像 conda 管 Python 版本一样管理 OpenCode/Cursor 的版本，让 agent.yaml 成为 Agent 开发环境的唯一真相来源。寻找 Go 后端合作者。</description><pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;::github{repo=&quot;7emotions/agentenv&quot;}&lt;/p&gt;
&lt;h2&gt;AgentEnv 是什么&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/7emotions/agentenv&quot;&gt;AgentEnv&lt;/a&gt; 是一个 &lt;strong&gt;AI Agent 的环境管理器&lt;/strong&gt;——把它想象成 conda，但管理的是 skills、MCP 服务器、agent 定义、tools、hooks 和 prompts。六种包类型、四种框架适配器（Claude Code、OpenCode、Cursor、Codex）、六种源协议——声明式 &lt;code&gt;agent.yaml&lt;/code&gt; + PubGrub 依赖解析 + 锁文件。&lt;/p&gt;
&lt;p&gt;目前它能做什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agentenv create my-project --agent opencode
agentenv add skill code-reviewer --source github:myorg/code-reviewer
agentenv lock &amp;amp;&amp;amp; agentenv install &amp;amp;&amp;amp; agentenv activate my-project
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;但它缺了最关键的一步——框架版本管理。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;缺失的拼图&lt;/h2&gt;
&lt;p&gt;假设你的团队用 OpenCode 开发 Agent。你在 &lt;code&gt;agent.yaml&lt;/code&gt; 里声明了 skills 和 MCPs，&lt;code&gt;agentenv lock&lt;/code&gt; 锁定了所有依赖的版本。你的同事 clone 了项目，&lt;code&gt;agentenv activate&lt;/code&gt; 激活了环境。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但你们的 OpenCode 版本可能不一样。&lt;/strong&gt; 你的 &lt;code&gt;agent.yaml&lt;/code&gt; 不定义框架版本——框架版本是隐式的，取决于每个人本地装了什么。如果 OpenCode 0.8 的某个 MCP 行为变了，你的环境在同事的机器上可能表现不一致。&lt;/p&gt;
&lt;p&gt;类比 conda：conda 不只管 numpy 的版本，也管 Python 本身的版本。&lt;code&gt;environment.yml&lt;/code&gt; 里的 &lt;code&gt;python=3.10&lt;/code&gt; 是环境定义的核心部分。AgentEnv 也应该管框架版本。&lt;/p&gt;
&lt;h2&gt;解决方案——框架版本管理&lt;/h2&gt;
&lt;p&gt;核心思路非常简单：在 &lt;code&gt;agent.yaml&lt;/code&gt; 中声明框架及其版本约束。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: my-project
framework:
  type: opencode
  version: &quot;&amp;gt;=0.7.0&quot;

skills:
  code-reviewer:
    source: github:myorg/code-reviewer
    version: &quot;^1.0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;agentenv activate&lt;/code&gt; 时检查本地安装的 OpenCode 版本是否满足约束。如果不满足，直接报错告诉你需要哪个版本。&lt;/p&gt;
&lt;p&gt;这和 conda 的 &lt;code&gt;python=3.10&lt;/code&gt; 一模一样——AgentEnv 管框架版本，OpenCode/Cursor/Claude Code 仍然是激活目标，开发者仍然用自己熟悉的工具。&lt;/p&gt;
&lt;h2&gt;实现计划&lt;/h2&gt;
&lt;p&gt;完整的实现计划在 &lt;a href=&quot;https://github.com/7emotions/agentenv/blob/main/docs/superpowers/plans/2026-05-26-runtime-management.md&quot;&gt;docs/superpowers/plans/2026-05-26-runtime-management.md&lt;/a&gt;。核心是 8 个任务，约 300 行新增代码：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;任务&lt;/th&gt;
&lt;th&gt;变更&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Types&lt;/td&gt;
&lt;td&gt;+15 行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FrameworkSpec&lt;/code&gt; 类型 + &lt;code&gt;Environment.Framework&lt;/code&gt; 字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Envfile&lt;/td&gt;
&lt;td&gt;+60 行&lt;/td&gt;
&lt;td&gt;reader/writer/validator 支持 &lt;code&gt;framework&lt;/code&gt; 字段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;+10 行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--agent opencode&lt;/code&gt; 写入 &lt;code&gt;agent.yaml&lt;/code&gt;（不只是 &lt;code&gt;state.json&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adapter&lt;/td&gt;
&lt;td&gt;+30 行&lt;/td&gt;
&lt;td&gt;新增 &lt;code&gt;GetInstalledVersion()&lt;/code&gt; 方法，检查本地框架版本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activate&lt;/td&gt;
&lt;td&gt;+30 行&lt;/td&gt;
&lt;td&gt;激活时校验框架版本是否满足约束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Info&lt;/td&gt;
&lt;td&gt;+15 行&lt;/td&gt;
&lt;td&gt;&lt;code&gt;agentenv info&lt;/code&gt; 显示框架声明&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;+80 行&lt;/td&gt;
&lt;td&gt;framework 字段 round-trip + create 集成测试&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docs&lt;/td&gt;
&lt;td&gt;+30 行&lt;/td&gt;
&lt;td&gt;README + agent-yaml.md 更新&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;不做的事情：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不造 AgentMini 或内置任何运行时。激活目标仍然是 OpenCode/Cursor/Claude Code/Codex。&lt;/li&gt;
&lt;li&gt;不加 deploy 命令。环境管理不变成部署工具。&lt;/li&gt;
&lt;li&gt;不把 gocode 特殊对待。如果将来有人想用 gocode——写一个适配器就行，和 OpenCode 完全对等。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;为什么这个方向是正确的&lt;/h2&gt;
&lt;p&gt;经过几轮架构讨论，我意识到之前&quot;部署给甲方&quot;的思路把 AgentEnv 带偏了。AgentEnv 的价值不是让非开发者跑 Agent——而是让开发者之间的环境可复现。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;conda → 管 Python 版本 + numpy 版本
AgentEnv → 管 OpenCode 版本 + skill/MCP 版本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个类比，同一个抽象层。框架版本管理是这个类比的最后一环——补上它，AgentEnv 的环境模型就完整了。&lt;/p&gt;
&lt;h2&gt;寻找合作者&lt;/h2&gt;
&lt;p&gt;这个项目目前是我一个人在维护。路线图上的事情比我一个人能干的多。我在找对这些方向感兴趣的人：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;核心需求（Go 后端）：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;适配器扩展（实现各框架的 &lt;code&gt;GetInstalledVersion&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agentenv activate&lt;/code&gt; 的版本校验逻辑完善&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agentenv lock&lt;/code&gt; 对框架版本的支持（将 framework 纳入锁文件）&lt;/li&gt;
&lt;li&gt;新增 Aider、Gemini CLI 等框架适配器&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;锦上添花（全栈）：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;agentenv doctor&lt;/code&gt; 健康检查命令（并行检查 runtime + adapters + store）&lt;/li&gt;
&lt;li&gt;无参运行时的信息面板（TTY 检测 + 环境状态概览）&lt;/li&gt;
&lt;li&gt;Golden 测试基础设施&lt;/li&gt;
&lt;li&gt;下一步提示（&lt;code&gt;create&lt;/code&gt; 后提示 &lt;code&gt;add&lt;/code&gt;，&lt;code&gt;lock&lt;/code&gt; 后提示 &lt;code&gt;install&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你对以上任何方向感兴趣，欢迎：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在 &lt;a href=&quot;https://github.com/7emotions/agentenv/issues&quot;&gt;GitHub Issues&lt;/a&gt; 里开 discussion&lt;/li&gt;
&lt;li&gt;直接提 PR——路线图上的每一项都有详细的实现计划&lt;/li&gt;
&lt;li&gt;发邮件给我：lorenzo@lorenzofeng.top&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Agent 开发正在从&quot;每个人在自己机器上调配置&quot;的阶段走向工程化。AgentEnv 想成为 conda 对 Python 生态做的事——让 Agent 开发环境可以被声明、版本化、复现。如果你认同这个方向，一起来做。&lt;/p&gt;
</content:encoded></item><item><title>AgentEnv —— 当 AI 智能体也需要「conda 环境」</title><link>https://lorenzofeng.top/posts/agentenv-intro/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/agentenv-intro/</guid><description>从「什么是包」讲起，详解 AgentEnv 的六种包类型（skill/mcp/agent/tool/hook/prompt）、声明式配置格式、6 种源协议、4 种框架适配器，以及 PubGrub 依赖解析和内容寻址存储。</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这几个月我在不同项目间频繁切换时发现一个尴尬的问题：每个项目需要的 skill、MCP 服务器、agent 配置都不一样。给项目 A 装了某个 skill，项目 B 不兼容；在 Claude Code 里配好的 MCP 服务器，切到 OpenCode 又要重新来一遍。我管这个叫「AI 智能体的依赖地狱」。&lt;/p&gt;
&lt;p&gt;AgentEnv 就是为了解决这个问题而生的。&lt;/p&gt;
&lt;h2&gt;问题的本质&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;7emotions/agentenv&quot;}&lt;/p&gt;
&lt;p&gt;AI 编码工具的生态正在爆炸式增长。Claude Code、OpenCode、Cursor、Codex 各有各的配置格式和路径。skill、MCP 服务器、agent 定义、hook、prompt 模板——这些「智能体依赖」混在一起，没有隔离、没有版本管理、没有可重现性。&lt;/p&gt;
&lt;p&gt;Python 社区有 conda 和 virtualenv 来解决环境隔离；Node.js 有 npm workspace；Go 有 go modules。但 AI 智能体的环境管理，一直是一个手工拷贝配置文件的原始状态。&lt;/p&gt;
&lt;p&gt;AgentEnv 要做的就是这件事：&lt;strong&gt;像 conda 管理 Python 环境一样，管理 AI 智能体的依赖环境。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;设计哲学&lt;/h2&gt;
&lt;h3&gt;声明式，不拼凑&lt;/h3&gt;
&lt;p&gt;AgentEnv 的核心是 &lt;code&gt;agent.yaml&lt;/code&gt; —— 一个声明式的环境定义文件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: my-ai-env
description: &quot;我的 AI 开发环境&quot;

skills:
  code-reviewer:
    source: github:myorg/code-review-skill
    version: &quot;&amp;gt;=0.5&quot;

mcps:
  filesystem:
    source: npm:@modelcontextprotocol/server-filesystem
    version: &quot;0.6.0&quot;
    config:
      root: /home/user/projects
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你只需要声明&lt;strong&gt;想要什么&lt;/strong&gt;，AgentEnv 负责&lt;strong&gt;找到、下载、安装、激活&lt;/strong&gt;。这跟 &lt;code&gt;package.json&lt;/code&gt; + &lt;code&gt;npm install&lt;/code&gt; 的思路一脉相承，但适配的是 AI 编码工具的生态。&lt;/p&gt;
&lt;h3&gt;可重现，版本锁定&lt;/h3&gt;
&lt;p&gt;声明之后执行 &lt;code&gt;agentenv lock&lt;/code&gt;，它会跑一个 &lt;strong&gt;PubGrub CDCL 解析器&lt;/strong&gt;——就是 Dart/Flutter 用那个算法——来求解依赖图：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动发现传递依赖（skill A 依赖 skill B，B 又依赖 C）&lt;/li&gt;
&lt;li&gt;处理语义化版本约束（&lt;code&gt;^1.0&lt;/code&gt;、&lt;code&gt;&amp;gt;=2.0&lt;/code&gt;、&lt;code&gt;~3.5.1&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;检测冲突并给出精确的冲突报告&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;error: conflict detected
  code-reviewer@&amp;gt;=1.0 requires lint-helper@^2.0
  my-other-skill@&amp;gt;=0.5 requires lint-helper@^1.0
  lint-helper@^2.0 and lint-helper@^1.0 are incompatible
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解析完成后生成 &lt;code&gt;agent.lock&lt;/code&gt;，包含每个包的 SHA-256 校验、解析版本、来源——这是你在另一台机器上得到&lt;strong&gt;完全相同环境&lt;/strong&gt;的保证。&lt;/p&gt;
&lt;h3&gt;六种源，不分来源&lt;/h3&gt;
&lt;p&gt;AgentEnv 支持 6 种包来源协议：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;协议&lt;/th&gt;
&lt;th&gt;示例&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;github:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;github:myorg/code-review-skill&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npm:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm:@modelcontextprotocol/server-filesystem&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;local:./vendor/my-tool&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git:https://github.com/org/repo.git&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;file:/home/user/packages/skill.tar.gz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;url:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;url:https://example.com/pkg.tar.gz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;也就是说，你可以把一个 GitHub 上的 skill、一个 npm 上的 MCP 服务器、一个本地开发中的 hook 脚本，扔进&lt;strong&gt;同一个环境&lt;/strong&gt;管理。这对「正在开发 skill 同时又在用别人的 MCP」这种常见场景极其友好。&lt;/p&gt;
&lt;h3&gt;六种包，各司其职&lt;/h3&gt;
&lt;p&gt;AgentEnv 中，「包」（package）是一个抽象概念，覆盖了 AI 智能体生态中所有需要版本管理和环境隔离的东西。目前支持六种包类型：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;类型&lt;/th&gt;
&lt;th&gt;含义&lt;/th&gt;
&lt;th&gt;典型场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;可安装的技能&lt;/td&gt;
&lt;td&gt;代码审查 skill、文档生成 skill、Git 操作 skill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MCP 服务器配置&lt;/td&gt;
&lt;td&gt;文件系统访问、浏览器自动化、数据库查询&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;智能体定义&lt;/td&gt;
&lt;td&gt;自定义 agent 的行为描述和系统提示词&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;工具包&lt;/td&gt;
&lt;td&gt;代码格式化工具、lint 工具配置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hook&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;生命周期钩子&lt;/td&gt;
&lt;td&gt;激活前校验脚本、停用后清理脚本&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prompt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;提示词模板&lt;/td&gt;
&lt;td&gt;代码审查提示词、提交信息生成模板&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;每种包在 &lt;code&gt;agent.yaml&lt;/code&gt; 中的声明格式完全一致，只需要三个字段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 必填：source —— 从哪里获取这个包
source: github:owner/repo

# 可选：version —— 语义化版本约束（默认 *，即任意版本）
version: &quot;&amp;gt;=0.5&quot;

# 可选：config —— 包专属的配置项，由包的消费者自行解读
config:
  root: /home/user/projects
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个包含所有六种类型的完整 &lt;code&gt;agent.yaml&lt;/code&gt; 长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: my-ai-env
description: &quot;我的全栈 AI 开发环境&quot;

# 技能（skill）：可复用的智能体能力模块
skills:
  code-reviewer:
    source: github:myorg/code-review-skill
    version: &quot;&amp;gt;=0.5&quot;

# MCP 服务器：连接外部工具的桥梁
mcps:
  filesystem:
    source: npm:@modelcontextprotocol/server-filesystem
    version: &quot;0.6.0&quot;
    config:
      root: /home/user/projects

# 智能体（agent）：自定义 agent 的完整规格
agents:
  senior-dev:
    source: github:myorg/agents
    version: &quot;~1.2&quot;

# 工具（tool）：辅助工具和配置
tools:
  formatter:
    source: url:https://example.com/tools/formatter.tar.gz

# 钩子（hook）：生命周期事件触发脚本
hooks:
  pre-activate:
    source: file:/home/user/hooks/check-env.sh
  post-deactivate:
    source: local:./hooks/cleanup.sh

# 提示词（prompt）：可复用的提示词模板
prompts:
  code-review:
    source: git:https://github.com/myorg/prompts.git
    version: &quot;&amp;gt;=2.0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一个环境可以混用任意类型的包，不限制种类和数量。同一个类型下包名必须唯一，但不同类型之间可以重名（比如可以同时有一个叫 &lt;code&gt;code-reviewer&lt;/code&gt; 的 skill 和一个叫 &lt;code&gt;code-reviewer&lt;/code&gt; 的 prompt）。&lt;/p&gt;
&lt;p&gt;这种设计的妙处在于——&lt;strong&gt;不论是 GitHub 上的开源 skill、npm 上的 MCP 包、还是本地开发中的 hook 脚本，对 AgentEnv 来说都是「包」，都走同一套解析、下载、安装、激活的流水线。&lt;/strong&gt; 你不用为不同种类的东西学不同的命令，也不用操心它们的存储路径和配置格式。&lt;/p&gt;
&lt;h3&gt;四个框架，一套流程&lt;/h3&gt;
&lt;p&gt;不管你用的是 Claude Code、OpenCode、Cursor 还是 Codex，AgentEnv 的命令都一样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agentenv create --agent opencode my-env
agentenv add skill code-reviewer --source github:myorg/reviewer
agentenv lock
agentenv install
agentenv activate my-env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;框架切换只是一个 &lt;code&gt;--agent&lt;/code&gt; 参数的区别。每个框架适配器知道自己的配置路径、格式（JSON、JSONC、TOML）、以及如何正确地读写而不破坏已有配置。&lt;/p&gt;
&lt;p&gt;特别值得一提的是 &lt;strong&gt;OpenCode 适配器的 JSONC 支持&lt;/strong&gt;：OpenCode 的配置文件是带注释的 JSON（JSONC），AgentEnv 用了 &lt;code&gt;tailscale/hujson&lt;/code&gt; 来做注释保留的读写，不会像 &lt;code&gt;encoding/json&lt;/code&gt; 那样把注释全部吃光。&lt;/p&gt;
&lt;h3&gt;事务性激活，不留下残局&lt;/h3&gt;
&lt;p&gt;激活环境时，AgentEnv 做的不是简单「往配置文件里塞几行」：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;备份（backup）→ 校验（validate）→ 应用（apply）→ 失败回滚（rollback）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果校验失败或者任何步骤出错，它会自动恢复到变更前的状态。&lt;strong&gt;不会出现「配置改了一半 agent 跑不起来了」的情况。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;内容寻址存储&lt;/h3&gt;
&lt;p&gt;所有下载的包存在一个内容寻址的本地仓库里。两个环境都用到 &lt;code&gt;lint-helper@1.2.0&lt;/code&gt;？只存一份。某个包不再被任何环境引用？垃圾回收可以清理。跨文件系统的话，symlink 降级为文件复制。&lt;/p&gt;
&lt;h2&gt;快速上手&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 一行安装
curl -fsSL https://raw.githubusercontent.com/7emotions/agentenv/main/install.sh | sh

# 创建一个环境
agentenv create --agent opencode my-ai-setup

# 加几个包
agentenv add skill code-reviewer --source github:myorg/code-review-skill
agentenv add mcp filesystem \
  --source npm:@modelcontextprotocol/server-filesystem \
  --command npx --arg -y \
  --arg @modelcontextprotocol/server-filesystem \
  --arg /tmp

# 解析依赖
agentenv lock

# 下载并激活
agentenv install
agentenv activate my-ai-setup

# 搞定——你的 agent 现在有完整的运行环境了
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有命令都支持 &lt;code&gt;--json&lt;/code&gt; 输出，方便脚本化集成。&lt;/p&gt;
&lt;h2&gt;技术栈&lt;/h2&gt;
&lt;p&gt;AgentEnv 本身是一个 Go 项目（Go 1.26），单二进制发布，支持 macOS / Linux（amd64 / arm64）。核心依赖：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CLI 框架&lt;/strong&gt;：cobra（14 条命令）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;依赖解析&lt;/strong&gt;：&lt;code&gt;contriboss/pubgrub-go&lt;/code&gt;（CDCL 算法）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git 操作&lt;/strong&gt;：&lt;code&gt;go-git&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;版本约束&lt;/strong&gt;：&lt;code&gt;Masterminds/semver&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;配置格式&lt;/strong&gt;：YAML（&lt;code&gt;gopkg.in/yaml.v3&lt;/code&gt;）、TOML（&lt;code&gt;BurntSushi/toml&lt;/code&gt;）、JSONC（&lt;code&gt;tailscale/hujson&lt;/code&gt;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;28 个测试文件覆盖所有包，CI 跑 &lt;code&gt;go test -race&lt;/code&gt; 在 ubuntu 和 macOS 上。&lt;/p&gt;
&lt;h2&gt;几条设计红线&lt;/h2&gt;
&lt;p&gt;做这个项目时我给自己画了几条硬线：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;零遥测&lt;/strong&gt;——不上报、不追踪、不联网发 ping。环境管理器碰触的是最敏感的本地配置文件，信任是第一位的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无插件系统&lt;/strong&gt;——所有适配器在编译时注册（&lt;code&gt;init()&lt;/code&gt;），不搞运行时动态加载。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无 Windows 支持&lt;/strong&gt;——AI 编码工具的生态集中在 macOS 和 Linux，Windows 不在范围内。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不破坏 JSONC&lt;/strong&gt;——这点值得再强调一遍：OpenCode 用户的配置文件里的注释&lt;strong&gt;一个字都不会丢&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;在生态中的位置&lt;/h2&gt;
&lt;p&gt;AI agent 环境管理 / 配置管理这个方向，已经有不少开发者在探索。这里介绍几个我关注的代表性项目——它们和 AgentEnv 解决的问题各有侧重，不存在谁更「好」，更像是在同一个大方向上填补不同的空白。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agenv&lt;/code&gt;（combinatrix-ai）&lt;/strong&gt; 定位为「nvm for AI agents」——管理的是 agent 二进制本身的版本和 profile 隔离。你可以用 &lt;code&gt;agenv install claude&lt;/code&gt; 安装 Claude Code，再用 &lt;code&gt;agenv run&lt;/code&gt; 启动，不同 profile 之间二进制、配置、权限完全隔离。这是一个非常实用的运行时版本管理工具，和 AgentEnv 的「包依赖管理」是问题的上下游——可以先用 agenv 管理 agent 本身的版本，再用 AgentEnv 管理 agent 内部的环境依赖。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agenv-cli&lt;/code&gt;（npm）&lt;/strong&gt; 走的是配置生成路线：从一个 &lt;code&gt;ai-workspace.json&lt;/code&gt; manifest 出发，自动检测项目类型、框架、已有配置，然后生成 Claude Code、Copilot、Codex、Cursor、Windsurf 等多个工具的配置文件。交互式的 &lt;code&gt;agenv init&lt;/code&gt; 和自动化的 &lt;code&gt;agenv generate&lt;/code&gt;，对于「想让多个工具共享一套配置规则」的场景是一个简洁高效的选择。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agentctl&lt;/code&gt;（OrgLoop）&lt;/strong&gt; 是 agent session 监控面板，提供启动、监控、恢复、终止、对比 agent 会话的完整运维能力。它带有守护进程、目录锁、webhook 和 Prometheus 指标，做得非常专业——关注的是「同时跑多个 agent 会话怎么可靠管理」这一运维层面的问题。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;agentctl&lt;/code&gt;（iheanyi）&lt;/strong&gt; 的定位和 AgentEnv 最接近——跨框架配置管理，覆盖了 Claude Code、Codex、OpenCode、Cursor、Cline、Windsurf、Zed、Continue、Gemini 等 10 个框架。它可以从各框架的原生配置目录中发现已有资源（MCP 服务器、rules、commands、skills），再同步到其他框架，还提供一个交互式 TUI，使用体验很流畅。&lt;/p&gt;
&lt;p&gt;这些项目从不同角度推动着同一件事：让 AI 智能体的配置管理从「手工拷贝文件」走向「工具化、自动化」。AgentEnv 选择的角度是&lt;strong&gt;声明式包管理与依赖解析&lt;/strong&gt;——把 PubGrub 求解器、确定性锁文件、内容寻址存储这套成熟的包管理范式，带到 AI 智能体的世界。没有哪个项目是「唯一正确答案」，每个都解决了一类真实的痛点，也在互相启发。&lt;/p&gt;
&lt;h2&gt;下一步&lt;/h2&gt;
&lt;p&gt;AgentEnv 还远没有完成。接下来计划：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;依赖图可视化&lt;/strong&gt;：&lt;code&gt;agentenv graph&lt;/code&gt; 画依赖关系&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;环境对比&lt;/strong&gt;：&lt;code&gt;agentenv diff env-a env-b&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;远程锁文件共享&lt;/strong&gt;：团队里一份 &lt;code&gt;agent.lock&lt;/code&gt; 就能复现全队环境&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;更多的适配器&lt;/strong&gt;：Windsurf、Aider、Continue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果你每天在多个 AI 编码工具之间切换，或者在维护多个项目的不同配置，欢迎试试 AgentEnv。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/agentenv.git
cd agentenv
make build &amp;amp;&amp;amp; ./dist/agentenv --help
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Star、Issue、PR 都欢迎。让 AI 智能体的环境管理，走上正轨。&lt;/p&gt;
</content:encoded></item><item><title>为什么 AI Agent 总说「用 CloakBrowser」却偷偷调了 Playwright？—— 一次跨层排障与开源贡献</title><link>https://lorenzofeng.top/posts/cloakbrowser-omo-pr/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/cloakbrowser-omo-pr/</guid><description>从发现 OpenCode 会话中 cloakbrowser 反复退化为 playwright 的诡异行为，到写了一个无效的 skill，再到追踪根因至 OMO 插件的「MUST USE」死命令，最终提了一个 feat PR 把 cloakbrowser 接入框架的全过程。</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;事情开始于一个简单的测试：「测试一下 cloakbrowser 能不能用。」&lt;/p&gt;
&lt;h2&gt;一个诡异的退化&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;CloakHQ/cloakbrowser&quot;}&lt;/p&gt;
&lt;p&gt;CloakBrowser 是一个防检测浏览器——它在 Chromium 源码层面打了 48 个 C++ 补丁，能通过 reCAPTCHA v3、Cloudflare Turnstile、FingerprintJS 等几乎所有 bot 检测。npm 安装后，API 长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { launch } from &apos;cloakbrowser&apos;;
const browser = await launch();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是它 README 的第一行代码。&lt;/p&gt;
&lt;p&gt;但 OpenCode 里的 agent 写了这个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { chromium } from &apos;playwright&apos;;
const browser = await chromium.launch({
  executablePath: &apos;~/.cloakbrowser/chromium-.../chrome&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;它把 cloakbrowser 当成了 Playwright 的 drop-in 二进制，而不是一个有自己 API 的包。&lt;/strong&gt; 完全跳过了 cloakbrowser 自己的 &lt;code&gt;launch()&lt;/code&gt; 函数，直接把它当原始 Chromium 喂给 Playwright。&lt;/p&gt;
&lt;h2&gt;第一步：我以为问题在 agent 身上&lt;/h2&gt;
&lt;p&gt;我的第一反应很自然：「agent 没读文档。」&lt;/p&gt;
&lt;p&gt;cloakbrowser 有 &lt;code&gt;package.json&lt;/code&gt;，有 README，有 exports 字段。只要读了其中任何一个，都不会犯这个错。问题是 agent 没读——它看到 &lt;code&gt;cloakbrowser info&lt;/code&gt; 输出一个二进制路径，就直接模式匹配到了 &lt;code&gt;chromium.launch({executablePath})&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这看起来是一个可以通过规则约束解决的问题。我花了相当的时间做了一个 &lt;code&gt;docs-before-code&lt;/code&gt; skill，按照完整的 RED-GREEN-REFACTOR 方法论：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;阶段&lt;/th&gt;
&lt;th&gt;测试&lt;/th&gt;
&lt;th&gt;结果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RED 基线&lt;/td&gt;
&lt;td&gt;agent 用 listr2 写代码（不读文档）&lt;/td&gt;
&lt;td&gt;暴露了「我知道这个库」的合理化&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GREEN 验证&lt;/td&gt;
&lt;td&gt;agent 用 vitest 程序化 API（按 skill 规则）&lt;/td&gt;
&lt;td&gt;先查 &lt;code&gt;vitest/node&lt;/code&gt; 子路径，再写代码 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REFACTOR 压力&lt;/td&gt;
&lt;td&gt;agent 用 execa v9（2 分钟限制 + 旧记忆）&lt;/td&gt;
&lt;td&gt;发现 tagged template 新语法，避免写旧 API ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这个 skill 本身是有效的。它的 Gate Function（五个步骤：IDENTIFY → LOCATE → VERIFY → CONFIRM → 写代码）能在 agent 写 &lt;code&gt;import&lt;/code&gt; 语句之前拦下它，强制它先去读 &lt;code&gt;package.json&lt;/code&gt; 的 exports 字段和 README 的第一段代码。execa v9 的压力测试证明了这一点——即使 agent 的记忆里是 &lt;code&gt;execa(&apos;cmd&apos;, [args])&lt;/code&gt; 的旧 API，skill 也强迫它先查了文档，发现了 v9 的 tagged template 新语法。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但我很快就发现，cloakbrowser 的问题完全没有被解决。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;第二步：发现 skill 完全没用&lt;/h2&gt;
&lt;p&gt;我回去翻其他会话记录。三个不同的 session 里，用户明确说了「用 cloakbrowser」，agent 全都退化到了 Playwright MCP 工具。&lt;/p&gt;
&lt;p&gt;其中一个 session 的转写特别刺眼：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User:  &quot;设置界面和你说的不一样，你用 cloakbrowser 配置&quot;
Agent: → 加载 dev-browser skill
Agent: → 加载 playwright skill
Agent: → 调用 playwright MCP 的 browser_navigate
Agent: → 完全没用 cloakbrowser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;agent 根本没走到写 &lt;code&gt;import&lt;/code&gt; 那一步。&lt;/strong&gt; 它没有写 Node.js 脚本、没有 &lt;code&gt;npm install&lt;/code&gt;、没有 &lt;code&gt;import&lt;/code&gt; 语句。它直接调了 Playwright MCP 的 &lt;code&gt;browser_navigate&lt;/code&gt;、&lt;code&gt;browser_click&lt;/code&gt; 这些现成的工具。&lt;/p&gt;
&lt;p&gt;我花了两天写的 &lt;code&gt;docs-before-code&lt;/code&gt; skill，在真实场景里一次都没触发过。不是它写错了，是它的触发条件（写 &lt;code&gt;import&lt;/code&gt; 语句）和故障场景（MCP 工具选择）根本不在同一层。&lt;/p&gt;
&lt;p&gt;这就好比你给司机装了一个「开车前检查后视镜」的提醒，但他连车都没上——他在车库门口就被导航系统指引到另一辆车上了。&lt;/p&gt;
&lt;h2&gt;第三步：追到 OMO 源码&lt;/h2&gt;
&lt;p&gt;如果 agent 是在「工具选择」阶段被拐走的，那一定有什么东西在系统 prompt 里告诉它「浏览器任务必须用 Playwright」。&lt;/p&gt;
&lt;p&gt;我开始翻系统配置。OpenCode 的 &lt;code&gt;opencode.jsonc&lt;/code&gt; 里没有 Playwright MCP 配置。&lt;code&gt;AGENTS.md&lt;/code&gt; 里也没有写死 Playwright。那这个偏好是哪里来的？&lt;/p&gt;
&lt;p&gt;答案在 OMO（Oh-My-OpenCode）插件里。在其缓存安装目录 &lt;code&gt;oh-my-openagent/dist/&lt;/code&gt; 的源码中，我找到了这段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var playwrightSkill = {
  name: &quot;playwright&quot;,
  description: &quot;MUST USE for any browser-related tasks. Browser automation via Playwright MCP ...&quot;,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&quot;MUST USE for any browser-related tasks&quot;&lt;/strong&gt; —— 这是一条注入到 agent 系统 prompt 里的死命令。当用户说「用 cloakbrowser」时，agent 的语义解析是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;cloakbrowser&quot; → 包含 &quot;browser&quot; → 这是 browser-related task → &quot;MUST USE playwright&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;系统级指令（skill description）的优先级比用户消息更高。用户的 &lt;code&gt;cloakbrowser&lt;/code&gt; 被语义压缩成了 &lt;code&gt;browser&lt;/code&gt;，然后被 MUST USE 抢占了。&lt;/p&gt;
&lt;p&gt;进一步分析确认了 OMO 有一个 &lt;code&gt;browser_automation_engine&lt;/code&gt; 配置项：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const BrowserAutomationProviderSchema = z.enum([
  &quot;playwright&quot;,
  &quot;agent-browser&quot;,
  &quot;dev-browser&quot;,
  &quot;playwright-cli&quot;,
  // 没有 &quot;cloakbrowser&quot;
])

provider: BrowserAutomationProviderSchema.default(&quot;playwright&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;可选值只有四个，默认是 playwright。cloakbrowser 不在选项里。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这意味着：即使用户完全不配置任何东西，agent 看到的系统 prompt 里也会有一行 &lt;code&gt;playwright: MUST USE for any browser-related tasks&lt;/code&gt;。这不是 OMO 故意排斥 cloakbrowser——它只是还没被适配进去。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;但这件事揭示了一个更深的问题：&lt;strong&gt;当框架有一个「强制指定工具」的机制时，不在名单里的工具根本就没有被正确使用的可能性。&lt;/strong&gt; 无论你写多少 skill、优化多少 prompt，agent 都会被上游的「MUST USE」指令拖走。&lt;/p&gt;
&lt;h2&gt;修框架本身&lt;/h2&gt;
&lt;p&gt;理解了根因之后，修复就很清晰了：把 cloakbrowser 加进 &lt;code&gt;browser_automation_engine&lt;/code&gt; 的 provider 选项。&lt;/p&gt;
&lt;p&gt;我 fork 了 OMO 仓库，在 &lt;code&gt;feat/cloakbrowser-provider&lt;/code&gt; 分支上改了 4 个文件，总共 +126 行：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/config/schema/browser-automation.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;添加 &lt;code&gt;&quot;cloakbrowser&quot;&lt;/code&gt; 到 provider enum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/features/builtin-skills/skills/cloakbrowser.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;新建，119 行的 skill 模板，覆盖完整 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/features/builtin-skills/skills/index.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;导出 &lt;code&gt;cloakbrowserSkill&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/features/builtin-skills/skills.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;在 &lt;code&gt;createBuiltinSkills&lt;/code&gt; 里添加 cloakbrowser 分支&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;新增的 cloakbrowser skill 包含了完整的 API 参考：三种启动方式（&lt;code&gt;launch&lt;/code&gt;、&lt;code&gt;launchContext&lt;/code&gt;、&lt;code&gt;launchPersistentContext&lt;/code&gt;）、配置选项（headless、proxy、timezone、geoip、humanize）、CLI 工具、reCAPTCHA 优化技巧、与原生 Playwright 的关键差异。&lt;/p&gt;
&lt;p&gt;合并后，用户只需在 &lt;code&gt;oh-my-openagent.json&lt;/code&gt; 里加一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ &quot;browser_automation_engine&quot;: { &quot;provider&quot;: &quot;cloakbrowser&quot; } }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;agent 就不会再看到 &lt;code&gt;playwright: MUST USE for any browser-related tasks&lt;/code&gt;，而是看到 cloakbrowser 的 skill，知道什么时候用它、怎么用它。&lt;/p&gt;
&lt;h2&gt;PR 已提交&lt;/h2&gt;
&lt;p&gt;::github{repo=&quot;code-yeongyu/oh-my-openagent&quot;}&lt;/p&gt;
&lt;p&gt;PR #4337：&lt;a href=&quot;https://github.com/code-yeongyu/oh-my-openagent/pull/4337&quot;&gt;feat: add cloakbrowser as browser automation engine provider&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TypeScript 类型检查：✅ 零错误&lt;/li&gt;
&lt;li&gt;&lt;code&gt;browser_automation_engine&lt;/code&gt; schema 测试：✅ 3/3 通过&lt;/li&gt;
&lt;li&gt;构建：✅ 成功&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;反思&lt;/h2&gt;
&lt;p&gt;这件事最反直觉的地方在于：&lt;strong&gt;你以为是 agent 没读文档，最后发现是文档根本不在 agent 的视野里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我花了两天写了一个精心测试过的 skill，它确实能解决它设计要解决的问题（agent 写 &lt;code&gt;import&lt;/code&gt; 之前先查 API），但它对 cloakbrowser 的退化问题毫无作用——因为那个问题根本不在代码编写层。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docs-before-code&lt;/code&gt; 在工厂流水线的「组装」环节装了一个质检站，但我真正需要修的是「零件进料口」——工具选择那一层。错误不在 agent 怎么写代码，而在 agent 一开始就被给了错误的工具。&lt;/p&gt;
&lt;p&gt;这也是一条给 AI agent 框架开发者的原则：&lt;strong&gt;如果你的框架有一个「强制指定工具」的机制，确保新工具能平等地接入它。&lt;/strong&gt; 否则，你的框架就不是助推器，而是壁垒。&lt;/p&gt;
</content:encoded></item><item><title>为 CodeGraph 修复 inotify 耗尽问题：从 fs.watch 到 chokidar</title><link>https://lorenzofeng.top/posts/codegraph-inotify-fix/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/codegraph-inotify-fix/</guid><description>向 17k Star 项目 CodeGraph 提交 PR：用 chokidar + .gitignore 过滤替代 fs.watch 递归监听，将单实例 inotify watch 数从几十万降至几百，避免大仓库上内核 watch 预算耗尽</description><pubDate>Sat, 23 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;前几天我在使用 &lt;a href=&quot;https://github.com/colbymchenry/codegraph&quot;&gt;CodeGraph&lt;/a&gt; 时发现了一个问题：改完代码后 watcher 不总是能自动更新索引。深入排查后发现这与一个已知的 Linux inotify 内核限制有关，于是动手修了并 &lt;a href=&quot;https://github.com/colbymchenry/codegraph/pull/346&quot;&gt;提交了 PR&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;colbymchenry/codegraph&quot;}&lt;/p&gt;
&lt;h2&gt;背景：CodeGraph 是什么&lt;/h2&gt;
&lt;p&gt;CodeGraph 是一个用 tree-sitter 解析代码、构建语义知识图谱的工具，18 天冲到 17k Star。它为 Claude Code、Cursor、Codex CLI、OpenCode 等 AI Agent 提供毫秒级的代码符号搜索——谁调用了这个函数、这个变量定义在哪、改了这个类会影响什么——都是预建的索引直接返回。&lt;/p&gt;
&lt;p&gt;Agent 用 CodeGraph 能 &lt;strong&gt;减少 50% 以上的 token 消耗和 80% 以上的工具调用&lt;/strong&gt;。它自带文件监听器，代码改了就自动增量同步索引。&lt;/p&gt;
&lt;h2&gt;问题：改代码后索引不自动刷新&lt;/h2&gt;
&lt;p&gt;在用 OpenCode 对话时，我发现了一个现象：Agent 改了代码，然后用 CodeGraph 去查新写的函数，查不到。只有手动跑 &lt;code&gt;codegraph sync&lt;/code&gt; 后才能搜到。&lt;/p&gt;
&lt;p&gt;我开始以为是 watcher 压根没有自动同步功能，但翻源码后发现，&lt;strong&gt;watcher 是有的&lt;/strong&gt;——&lt;code&gt;src/sync/watcher.ts&lt;/code&gt; 里用 &lt;code&gt;fs.watch(root, {recursive: true})&lt;/code&gt; 监听文件变更，检测到 &lt;code&gt;.ts&lt;/code&gt; / &lt;code&gt;.py&lt;/code&gt; 等源文件改动后就触发增量 sync。&lt;/p&gt;
&lt;p&gt;那为什么我的项目里不生效？&lt;/p&gt;
&lt;h2&gt;根因：inotify 被 node_modules 吃光了&lt;/h2&gt;
&lt;p&gt;翻 GitHub Issues，找到了 &lt;a href=&quot;https://github.com/colbymchenry/codegraph/issues/276&quot;&gt;#276&lt;/a&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;fs.watch(root, {recursive: true})&lt;/code&gt; 在 Linux 上为每个目录注册一个 inotify watch —— 包括 &lt;code&gt;node_modules/&lt;/code&gt;、&lt;code&gt;.git/&lt;/code&gt;、&lt;code&gt;.next/&lt;/code&gt;、&lt;code&gt;dist/&lt;/code&gt; 等目录。过滤逻辑写在回调里，此时 watch 早已注册完成。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Linux 内核默认允许一个用户最多持有 &lt;strong&gt;65536&lt;/strong&gt; 个 inotify watch。而 &lt;code&gt;node_modules/&lt;/code&gt; 下面动辄几千个小包目录，每个包里又有自己的 &lt;code&gt;node_modules/&lt;/code&gt;——一个中等规模的 monorepo 很容易就跑出几万甚至十几万个目录。&lt;/p&gt;
&lt;p&gt;CodeGraph 的递归 watcher 会为每个目录注册 watch，哪怕回调里会把这些目录的事件扔掉。结果是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单个 &lt;code&gt;codegraph serve --mcp&lt;/code&gt; 实例占掉大半的 inotify 预算&lt;/li&gt;
&lt;li&gt;同一仓库开两个 agent 会话 → 直接触顶&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;其他工具报错&lt;/strong&gt;：&lt;code&gt;ENOSPC: System limit for number of file watchers reached&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;更糟的是，PR #286 发现 agent 会话结束时子进程可能变成孤儿继续跑，积压的僵尸进程带着全部 watch 赖着不走&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;根本原因是 &lt;strong&gt;&quot;先注册，再过滤&quot;&lt;/strong&gt;。正确的做法应该是 &lt;strong&gt;&quot;先过滤，再注册&quot;&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;方案：用 chokidar 替代 fs.watch&lt;/h2&gt;
&lt;p&gt;Issue 里提出了两个修复方向：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;遍历树，手动过滤目录，对通过过滤的目录注册非递归 &lt;code&gt;fs.watch&lt;/code&gt; —— 等于自己写一个 mini chokidar&lt;/li&gt;
&lt;li&gt;直接用 chokidar，它的 &lt;code&gt;ignored&lt;/code&gt; 回调在注册 watch &lt;strong&gt;之前&lt;/strong&gt;过滤&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我选了方案 2。chokidar 是一个周下载 4000 万+ 的纯 JS 库，早把目录遍历、过滤、watch 管理、跨平台兼容这些 edge case 踩平了。而且它是纯 JavaScript，零 native 编译——不破坏 CodeGraph &quot;self-contained，零编译&quot; 的卖点。&lt;/p&gt;
&lt;p&gt;核心改动不到 100 行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 之前：先注册所有 watch，回调里再过滤（太晚）
this.watcher = fs.watch(this.projectRoot, { recursive: true }, (_eventType, filename) =&amp;gt; {
    if (!isSourceFile(filename)) return;  // 过滤在注册之后
});

// 之后：用 chokidar 的 ignored 回调，先过滤再注册
this.watcher = chokidar.watch(this.projectRoot, {
    ignored: (testPath) =&amp;gt; {
        // .gitignore 规则 + .codegraph/ + .git/
        // 返回 true 的路径不会被注册 inotify watch
    },
});

this.watcher.on(&apos;all&apos;, (_event, filePath) =&amp;gt; {
    if (!isSourceFile(filePath)) return; // 防御性过滤仍然保留
    // ... 触发 debounced sync
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;过滤逻辑：从项目根目录向上遍历，加载所有 &lt;code&gt;.gitignore&lt;/code&gt; 文件（跟 git 的行为一致），用已有的 &lt;code&gt;ignore&lt;/code&gt; npm 包做匹配，同时硬编码排除 &lt;code&gt;.codegraph/&lt;/code&gt; 和 &lt;code&gt;.git/&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;改动量：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;文件&lt;/th&gt;
&lt;th&gt;改动&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;package.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;加 &lt;code&gt;chokidar&lt;/code&gt; 依赖（v4，CommonJS 兼容）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/sync/watcher.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+90 行：&lt;code&gt;.gitignore&lt;/code&gt; 加载 + &lt;code&gt;ignored&lt;/code&gt; 回调 + 事件处理重构&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对现有功能无影响：WSL2 &lt;code&gt;/mnt/*&lt;/code&gt; 自动禁用、&lt;code&gt;CODEGRAPH_NO_WATCH&lt;/code&gt; 环境变量、git hook 替代方案全部保持不变。&lt;/p&gt;
&lt;h2&gt;测试&lt;/h2&gt;
&lt;h3&gt;单元测试&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vitest __tests__/watcher.test.ts      → 9/9 ✅
vitest __tests__/watch-policy.test.ts → 8/8 ✅
tsc --noEmit                          → clean ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有 watcher 单元测试原封不动通过——chokidar 的事件模型与 &lt;code&gt;fs.watch&lt;/code&gt; 兼容，debounce 逻辑、过滤逻辑、lifecycle 全部保持一致。sync 测试因测试环境缺少 &lt;code&gt;node:sqlite&lt;/code&gt; 而失败（已有问题）。&lt;/p&gt;
&lt;h3&gt;inotify watch 实测&lt;/h3&gt;
&lt;h4&gt;策略&lt;/h4&gt;
&lt;p&gt;不能靠目录遍历估算——事后对比发现，&lt;code&gt;fs.watch({recursive: true})&lt;/code&gt; 注册的 watch 数远超肉眼可见的目录数（博客项目目录树 ~9,000 个，实测 watch 数 ~48,000，偏差 5 倍）。必须从内核的真实数据源读取。&lt;/p&gt;
&lt;p&gt;测试方法：在同一进程内先后跑旧方案和新方案，各自从 &lt;code&gt;/proc/self/fdinfo/&lt;/code&gt; 读取内核分配的 inotify watch 计数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. fs.watch(root, {recursive: true})
2. 等待 3 秒 → 内核完成所有目录的 inotify 注册
3. 遍历 /proc/self/fd/* → 找到类型为 anon_inode:inotify 的 fd
4. 读对应 /proc/self/fdinfo/{fd} → 统计 inotify wd: 行数
5. → OLD 值

6. 关闭旧 watcher
7. 加载 .gitignore 规则链
8. chokidar.watch(root, { ignored: gitignoreFilter })
9. 等待 &apos;ready&apos; 事件 → 确保 watch 全部注册完毕
10. 再次读 /proc/self/fdinfo/*
11. → NEW 值
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;为什么不用 &lt;code&gt;lsof&lt;/code&gt;、&lt;code&gt;pgrep&lt;/code&gt;、spawn 子进程？&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方法&lt;/th&gt;
&lt;th&gt;问题&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lsof -p PID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不显示单个 inotify 实例内的 watch 条目数&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spawn 子进程 + 父进程读 &lt;code&gt;/proc/{child}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stdout 管道捕获不可靠，子进程可能先于测量退出&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;目录遍历计数&lt;/td&gt;
&lt;td&gt;严重低估——&lt;code&gt;fs.watch&lt;/code&gt; 的内核行为比&quot;每个目录一个 watch&quot;复杂得多&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;结果&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;中等项目&lt;/strong&gt;（博客，pnpm + Astro + Tailwind，~9,000 个目录）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;修复前&lt;/th&gt;
&lt;th&gt;修复后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inotify watch 数&lt;/td&gt;
&lt;td&gt;48,368&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;节省&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;48,193（99.6%）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;小型项目&lt;/strong&gt;（codegraph 仓库本身，~250 个目录）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;修复前&lt;/th&gt;
&lt;th&gt;修复后&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inotify watch 数&lt;/td&gt;
&lt;td&gt;2,045&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;节省&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1,836（90%）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;测试环境内核限额：&lt;strong&gt;65,536&lt;/strong&gt;。旧方案下，博客项目一个 agent 会话就吃掉限额的 74%——开两个会话直接触顶。issue #276 reporter 的 monorepo（~20 万文件）实测每实例 ~44 万个 watch，单项就把预算撑爆 6 次以上。&lt;/p&gt;
&lt;h4&gt;局限性&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;只测了两次，没算方差。&lt;/strong&gt; 单次运行。如果 &lt;code&gt;fs.watch&lt;/code&gt; 的异步注册在 3 秒内没走完，OLD 值可能偏低。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只有两个项目，规模跨度有限。&lt;/strong&gt; 最大的也就 ~9,000 个目录、48k watch，远未达到 issue #276 reporter 的 44 万级别。更大规模项目上 OLD/NEW 的绝对差距会更大，但比例不一定线性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每个项目只跑了一轮对比。&lt;/strong&gt; 没有测多次取均值排除抖动。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没测并发。&lt;/strong&gt; 单实例测试，没有验证两个 watcher 同时运行时是否有额外的资源竞争（虽然理论上不会，但没有实测）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没测 macOS / Windows。&lt;/strong&gt; 只测了 Linux。chokidar 在其他平台上使用 FSEvents / ReadDirectoryChangesW，行为可能不同——但对于这个 PR 来说，这两个平台不是问题域。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;修复效果完全依赖 &lt;code&gt;.gitignore&lt;/code&gt; 质量。&lt;/strong&gt; 如果项目没写 &lt;code&gt;node_modules/&lt;/code&gt; 到 &lt;code&gt;.gitignore&lt;/code&gt;，修复后 watch 数几乎不变。这不是 bug，但需要明确。&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;对维护者的建议&lt;/h4&gt;
&lt;p&gt;如果上游合并这个 PR，建议在 release notes 或 README 中补充一句提醒：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;On Linux, the file watcher now respects &lt;code&gt;.gitignore&lt;/code&gt; to avoid consuming inotify watches on excluded directories (e.g. &lt;code&gt;node_modules/&lt;/code&gt;, &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;.git/&lt;/code&gt;). Make sure your &lt;code&gt;.gitignore&lt;/code&gt; covers these directories, or explicitly add &lt;code&gt;CODEGRAPH_NO_WATCH=1&lt;/code&gt; if the watcher still causes issues on very large repos.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;同时，如果合并后有人报告 watch 数依然很高，第一反应应该是检查他们的 &lt;code&gt;.gitignore&lt;/code&gt; 是否包含了 &lt;code&gt;node_modules/&lt;/code&gt;、&lt;code&gt;dist/&lt;/code&gt;、&lt;code&gt;build/&lt;/code&gt;、&lt;code&gt;.next/&lt;/code&gt; 等常见构建输出目录。&lt;/p&gt;
&lt;h2&gt;提 PR&lt;/h2&gt;
&lt;p&gt;改动确认无误后，fork 了上游仓库，推送分支，提交了 PR：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;colbymchenry/codegraph&quot;}&lt;/p&gt;
&lt;p&gt;PR #346 已链接到 issue #276，合并后自动关闭。CodeGraph 仓库目前有 60+ 个 open PR，维护者应该比较忙，等待 review 中。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;从发现到修复的全过程&lt;/strong&gt;：用户报 issue → 看代码怀疑 watcher 不工作 → 查 issues 发现是 inotify 预算问题 → 评估修复方案 → 选 chokidar → 改代码 → 跑测试 → 提 PR。整个过程下来，对 Linux inotify 机制、chokidar 的工作原理、以及 CodeGraph 的 watcher 架构有了更深的理解。&lt;/p&gt;
&lt;p&gt;开源就是这样——你用一个工具，发现它的 bug，然后顺手把它修了。17k Star 的项目也不例外。&lt;/p&gt;
</content:encoded></item><item><title>自建邮件服务器：Stalwart + 阿里云绕过 25 端口限制</title><link>https://lorenzofeng.top/posts/email-server/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/email-server/</guid><description>在阿里云 ECS 上使用 Docker 搭建 Stalwart 邮件服务器，通过阿里云邮件推送绕过出站 25 端口封锁，实现完整收发邮件。</description><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我把自己搭建邮件服务器的过程整理成了一个 &lt;a href=&quot;https://github.com/7emotions/email-server&quot;&gt;GitHub 仓库&lt;/a&gt;，包含完整的 Docker Compose 配置、Caddy 反向代理、部署前检查脚本和部署后验证脚本。本文是对整个方案的说明。&lt;/p&gt;
&lt;h2&gt;背景&lt;/h2&gt;
&lt;p&gt;在云服务器上自建邮件服务，最大的障碍不是安装配置，而是&lt;strong&gt;出站 25 端口被封锁&lt;/strong&gt;。阿里云、腾讯云等国内厂商默认封锁 SMTP 出站端口，意味着服务器可以接收邮件，但无法直接投递到外部邮箱。&lt;/p&gt;
&lt;p&gt;解决方案：通过&lt;strong&gt;阿里云邮件推送（DirectMail）&lt;/strong&gt; 作为 SMTP 中继 — 邮件先提交到阿里云的 465 端口，再由阿里云代为投递到目标服务器。这也是生产环境的标准做法。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/email-server&quot;}&lt;/p&gt;
&lt;h2&gt;为什么选 Stalwart？&lt;/h2&gt;
&lt;p&gt;市面上常见的自建邮局方案有 Mailu、Mailcow、iRedMail 等，它们功能完善但组件繁多（动辄 8-10 个容器），对 2GB 以下内存的服务器不友好。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://stalw.art&quot;&gt;Stalwart Mail Server&lt;/a&gt; 是一个用 Rust 从头编写的现代化邮件服务器，&lt;strong&gt;单个容器&lt;/strong&gt;同时提供 SMTP、IMAP、JMAP 和 Web 管理面板，内存占用约 300MB。对比 Mailu 需要 9 个容器（Redis、Postfix、Dovecot、ClamAV、Rspamd 等），这在资源有限的 VPS 上是巨大的优势。&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方案&lt;/th&gt;
&lt;th&gt;容器数&lt;/th&gt;
&lt;th&gt;内存占用&lt;/th&gt;
&lt;th&gt;管理面板&lt;/th&gt;
&lt;th&gt;JMAP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mailu&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;~1.5 GB&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailcow&lt;/td&gt;
&lt;td&gt;~15&lt;/td&gt;
&lt;td&gt;~4 GB&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stalwart&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~300 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;项目结构&lt;/h2&gt;
&lt;p&gt;仓库包含以下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;email-server/
├── compose.yaml           # Stalwart Docker Compose 配置
├── caddy/
│   └── Caddyfile.mail     # Caddy 反向代理配置
├── scripts/
│   ├── preflight.sh       # 部署前 8 项环境检查
│   └── verify.sh          # 部署后 14 项验证
└── README.md              # 完整部署指南
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;compose.yaml — 极简配置&lt;/h3&gt;
&lt;p&gt;核心的 &lt;code&gt;compose.yaml&lt;/code&gt; 仅 31 行，一个 service、两个 volume、几行日志配置，干净利落。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Stalwart Email Server — Docker Compose
# Domain: &amp;lt;YOUR_DOMAIN&amp;gt; | Image: ghcr.io/stalwartlabs/stalwart:v0.16
#
# Ports:
#   Mail: 25 (SMTP), 465 (SMTPS), 587 (Submission), 993 (IMAPS), 143 (IMAP)
#   Admin: 127.0.0.1:8080 (internal, behind Caddy reverse proxy)

services:
  stalwart:
    image: ghcr.io/stalwartlabs/stalwart:v0.16
    container_name: stalwart
    restart: unless-stopped
    ports:
      - &quot;25:25&quot;
      - &quot;465:465&quot;
      - &quot;587:587&quot;
      - &quot;143:143&quot;
      - &quot;993:993&quot;
      - &quot;127.0.0.1:8080:8080&quot;
    volumes:
      - stalwart-data:/opt/stalwart
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;
    environment:
      - TZ=Asia/Shanghai

volumes:
  stalwart-data:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署只需要三条命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker volume create email-server_stalwart-data
docker compose up -d
# 浏览器打开 http://&amp;lt;服务器IP&amp;gt;:8080/admin 完成初始配置
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;preflight.sh — 部署前环境检查&lt;/h3&gt;
&lt;p&gt;在 &lt;code&gt;docker compose up -d&lt;/code&gt; 之前运行，自动检测 8 项环境前提：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Docker daemon 是否运行&lt;/li&gt;
&lt;li&gt;Docker Compose 是否安装&lt;/li&gt;
&lt;li&gt;端口 25/465/587/993/8080 是否被占用&lt;/li&gt;
&lt;li&gt;iptables DOCKER 链是否正常&lt;/li&gt;
&lt;li&gt;内存 ≥ 1 GiB&lt;/li&gt;
&lt;li&gt;磁盘可用 ≥ 20 GB&lt;/li&gt;
&lt;li&gt;Swap 是否配置&lt;/li&gt;
&lt;li&gt;DNS 解析是否正常&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;核心检查逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Check 1: Docker Daemon
docker info &amp;gt;/dev/null 2&amp;gt;&amp;amp;1

# Check 3: Required Ports Free (25, 465, 587, 993, 8080)
for port in 25 465 587 993 8080; do
    ss -tlnp &quot;sport = :$port&quot; | grep -q LISTEN &amp;amp;&amp;amp; occupied=true
done

# Check 5: RAM &amp;gt;= 1.0 GiB
total_mib=$(free -m | awk &apos;/Mem:/ {print $2}&apos;)
[ &quot;$total_mib&quot; -ge 1024 ]

# Check 6: Disk Space &amp;gt;= 20 GB Free
free_gb=$(df -BG / | awk &apos;NR==2 {print $4}&apos; | sed &apos;s/G//&apos;)
[ &quot;$free_gb&quot; -ge 20 ]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;全部通过才建议继续部署，避免半路踩坑。&lt;/p&gt;
&lt;h3&gt;verify.sh — 部署后全面验证&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;docker compose up -d&lt;/code&gt; 完成后运行，14 项检查覆盖容器健康、端口连通性、服务响应和 DNS 解析：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;所有容器是否正常运行&lt;/li&gt;
&lt;li&gt;是否有容器在重启循环&lt;/li&gt;
&lt;li&gt;SMTP/IMAP 端口是否可达&lt;/li&gt;
&lt;li&gt;SMTP banner 是否返回&lt;/li&gt;
&lt;li&gt;DNS 解析是否正常&lt;/li&gt;
&lt;li&gt;磁盘使用率 &amp;lt; 80%&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;全部通过意味着邮件服务器部署成功。&lt;/p&gt;
&lt;h2&gt;架构总览&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;flowchart TB
    subgraph Internet[&quot;🌐 Internet&quot;]
        Incoming[&quot;外部邮件服务器&quot;]
        Recipient[&quot;收件人邮箱&amp;lt;br/&amp;gt;QQ / Gmail / Outlook&quot;]
    end

    subgraph Aliyun[&quot;☁️ 阿里云 ECS&quot;]
        subgraph Proxy[&quot;反向代理&quot;]
            Caddy[&quot;Caddy&amp;lt;br/&amp;gt;:80 / :443&quot;]
        end

        subgraph MailServer[&quot;Stalwart Mail Server&quot;]
            SMTP[&quot;SMTP&amp;lt;br/&amp;gt;:25 :465 :587&quot;]
            IMAP[&quot;IMAP&amp;lt;br/&amp;gt;:143 :993&quot;]
            Admin[&quot;Admin Panel&amp;lt;br/&amp;gt;:8080 内网&quot;]
            Storage[(&quot;RocksDB&amp;lt;br/&amp;gt;存储&quot;)]
        end
    end

    subgraph Relay[&quot;📡 阿里云 DirectMail&quot;]
        DM[&quot;SMTP 中继&amp;lt;br/&amp;gt;smtpdm.aliyun.com:465&quot;]
    end

    User[&quot;👤 管理员&amp;lt;br/&amp;gt;浏览器&quot;] --&amp;gt;|&quot;HTTPS&quot;| Caddy
    Caddy --&amp;gt;|&quot;反向代理&quot;| Admin
    Client[&quot;📱 邮件客户端&quot;] --&amp;gt;|&quot;SMTP/IMAP&quot;| SMTP
    Client --&amp;gt;|&quot;SMTP/IMAP&quot;| IMAP

    Incoming --&amp;gt;|&quot;📥 收信 :25&quot;| SMTP
    SMTP --&amp;gt;|&quot;本地投递&quot;| Storage

    SMTP --&amp;gt;|&quot;📤 外发 :465&quot;| DM
    DM --&amp;gt;|&quot;阿里云代投&quot;| Recipient

    style Aliyun fill:#f0f9ff,stroke:#0284c7
    style MailServer fill:#ecfdf5,stroke:#059669
    style Relay fill:#fef3c7,stroke:#d97706
    style Proxy fill:#fdf2f8,stroke:#db2777
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;关键配置点&lt;/h2&gt;
&lt;h3&gt;1. DNS 记录&lt;/h3&gt;
&lt;p&gt;部署前需要在域名 DNS 配置 6 条记录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MX&lt;/strong&gt; — 收信路由，指向 &lt;code&gt;mail.&amp;lt;DOMAIN&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SPF&lt;/strong&gt; — 声明阿里云和自有服务器可代表域名发信&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DKIM&lt;/strong&gt; — 邮件签名，防止伪造（从 Stalwart 管理面板获取公钥）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DMARC&lt;/strong&gt; — 收信方验证失败时的处理策略&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SRV&lt;/strong&gt; — 客户端自动发现（可选）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 阿里云 DirectMail 中继&lt;/h3&gt;
&lt;p&gt;在 Stalwart 的 Outbound Settings 中配置 Routing Strategy：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IF  is_local_domain(rcpt_domain)  →  本地投递
ELSE                               →  aliyun 中继外发
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样发往自己域名的邮件本地存储，发往外部的邮件走阿里云中继，互不干扰。&lt;/p&gt;
&lt;h3&gt;3. SSL 证书&lt;/h3&gt;
&lt;p&gt;Stalwart 支持挂载 certbot 证书。需要注意的是 Let&apos;s Encrypt 的 &lt;code&gt;live&lt;/code&gt; 目录只有 root 可遍历，需要额外执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chmod o+x /etc/letsencrypt/live /etc/letsencrypt/archive
chmod 644 /etc/letsencrypt/archive/&amp;lt;DOMAIN&amp;gt;/privkey*.pem
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;客户端使用&lt;/h2&gt;
&lt;p&gt;配置完成后，可以用任意邮件客户端连接：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;协议&lt;/th&gt;
&lt;th&gt;服务器&lt;/th&gt;
&lt;th&gt;端口&lt;/th&gt;
&lt;th&gt;加密&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SMTP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mail.&amp;lt;DOMAIN&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;465&lt;/td&gt;
&lt;td&gt;SSL/TLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mail.&amp;lt;DOMAIN&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;993&lt;/td&gt;
&lt;td&gt;SSL/TLS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;用户名填完整邮箱地址，密码填 Stalwart 中设置的邮箱密码。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这个仓库把「自建邮件服务器」这件事做成了一个可复现的方案：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;一条 compose up&lt;/strong&gt; 启动服务&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;preflight.sh&lt;/strong&gt; 帮你检查环境，避免部署到一半才发现问题&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;verify.sh&lt;/strong&gt; 帮你验证部署结果，不用自己逐项排查&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;README&lt;/strong&gt; 记录每一步操作，包括常见的坑和解决方案&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;300MB 内存跑一个完整邮件服务器，比 Mailu/Mailcow 轻量得多，适合个人 VPS 自用。&lt;/p&gt;
&lt;p&gt;仓库地址：&lt;a href=&quot;https://github.com/7emotions/email-server&quot;&gt;github.com/7emotions/email-server&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>给 AI Agent 装上「嘴巴」和「耳朵」—— 让 OpenCode 真实打电话</title><link>https://lorenzofeng.top/posts/phone-agent-mcp/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/phone-agent-mcp/</guid><description>Phone Agent MCP 开发：通过 ADB + 蓝牙 HSP 让 AI Agent 真正拨打电话、自主对话，edge-tts + whisper + DeepSeek 实现端到端语音交互</description><pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;背景&lt;/h1&gt;
&lt;p&gt;在使用 OpenCode 这样的 AI Agent 平台时，Agent 可以写代码、读文件、搜文档、部署服务，但有一个根本的能力空白：&lt;strong&gt;它不能打电话&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;想象这些场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Agent 帮你完成了代码部署，需要通知王经理 —— 你只能自己拿起手机&lt;/li&gt;
&lt;li&gt;Agent 从数据库里找到了一批欠费用户，需要逐一确认 —— 你手动拨号&lt;/li&gt;
&lt;li&gt;Agent 检测到服务器宕机，想通知运维 —— 它只能发文字消息&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Agent 不缺信息，缺的是&lt;strong&gt;触达真人的通道&lt;/strong&gt;。电话仍然是最高优先级的通知方式 —— 微信可能静音，邮件可能淹没，但电话铃响，对方一定会接。&lt;/p&gt;
&lt;p&gt;于是我写了&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/phone-agent&quot;}&lt;/p&gt;
&lt;h1&gt;功能&lt;/h1&gt;
&lt;p&gt;Phone Agent 是一个 MCP Server，跑在连接了 Android 手机的 Linux 机器上，向 OpenCode Agent 暴露 7 个工具：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP Tool&lt;/th&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;典型场景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_dial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;拨号 + 预生成开场白 TTS&lt;/td&gt;
&lt;td&gt;任何电话的第一步&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_converse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;多轮自主对话（LLM 驱动）&lt;/td&gt;
&lt;td&gt;确认出席、通知延期、收集信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_ask&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单轮：问 → 录 → 识别 → 提取&lt;/td&gt;
&lt;td&gt;简单的是/否问题&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_speak&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单向 TTS 播放&lt;/td&gt;
&lt;td&gt;纯通知，不需要回复&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_check&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查询通话状态&lt;/td&gt;
&lt;td&gt;拨号前确认、挂断后确认&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_hangup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;挂断&lt;/td&gt;
&lt;td&gt;结束时调用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;phone_filler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;播放预生成的垫话&lt;/td&gt;
&lt;td&gt;桥接 TTS 生成延迟&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Agent 只需要组合这些工具就可以完成完整的通话任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;phone_dial(number, opening=&quot;您好，我是XX公司的AI助手...&quot;)
  → 拨号 + TTS 后台生成，接通瞬间播放

phone_converse(goal=&quot;确认明天评审会出席&quot;, skip_opening=true)
  → LLM 自主对话：录 → 识别 → 决策 → TTS → 回复 → 循环

phone_hangup()
  → 挂断，Agent 总结对话结果
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;架构设计&lt;/h1&gt;
&lt;h2&gt;整体架构&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────────────┐
│                  OpenCode Agent                       │
│  phone_dial / hangup / check / speak / ask / converse │
└─────────────────────┬────────────────────────────────┘
                      │ MCP stdio (JSON-RPC)
┌─────────────────────▼────────────────────────────────┐
│              phone_call_mcp.py (688行)                │
│  ├─ 拨号调度    → ADB am start CALL                  │
│  ├─ 对话引擎    → DeepSeek API 方向盘                 │
│  ├─ 语音合成    → edge-tts (云端) / espeak-ng (本地)   │
│  ├─ 语音识别    → faster-whisper tiny                │
│  ├─ 录音控制    → WebRTC VAD + parecord               │
│  ├─ 回声管理    → 5次重试清除 loopback                │
│  └─ 蓝牙管理    → HSP 自动重连                        │
└──────┬──────────────────────────┬────────────────────┘
       │                          │
   ┌───▼────┐              ┌──────▼──────┐
   │  ADB   │              │  蓝牙 HSP    │
   │ (拨号) │              │ paplay 上行  │
   └────────┘              │ parecord 下行│
                           └─────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;为什么是 MCP？&lt;/h2&gt;
&lt;p&gt;OpenCode 本身没有「打电话」的能力，但它有 MCP 扩展机制。我把整个电话能力包装成一个 MCP Server：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Agent 不需要知道蓝牙、ADB、PulseAudio 的存在&lt;/strong&gt; —— 它只需要调用工具&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;对话决策在远端 LLM 完成&lt;/strong&gt; —— Agent 只负责编排，不参与每轮决定&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skill 提供使用指南&lt;/strong&gt; —— &lt;code&gt;phone-call&lt;/code&gt; Skill 告诉 Agent 什么时候用哪个工具、怎么组合&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;双引擎设计：API + 本地各司其职&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;对话决策 (方向盘)  → DeepSeek API  → 约 1 秒延迟，语义理解强
简单提取 (辅助)    → 本地 1.5B LM → 更快的结构化提取
TTS               → edge-tts      → 2-5 秒，后台并行生成
ASR               → whisper tiny  → 1-3 秒，速度优先
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;为什么不用纯本地？&lt;/strong&gt; 1.5B 的本地模型做多轮对话决策不稳定，容易陷入重复提问。DeepSeek API 做方向盘，本地模型做简单提取，各做自己擅长的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;为什么用 whisper tiny 而不是 small？&lt;/strong&gt; 实测 tiny 转写日常中文对话准确率 90% 以上，但比 small 快 3-5 倍。电话场景需要「够用就好」的转写速度，而不是精准字幕。&lt;/p&gt;
&lt;h1&gt;关键技术&lt;/h1&gt;
&lt;h2&gt;四层停止机制&lt;/h2&gt;
&lt;p&gt;多轮对话最大的坑是「不知道什么时候停」。Phone Agent 实现了四层防御：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;层&lt;/th&gt;
&lt;th&gt;机制&lt;/th&gt;
&lt;th&gt;触发条件&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. 模型 &lt;code&gt;done&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LLM 判断对话完成&lt;/td&gt;
&lt;td&gt;信息收集完毕 / 对方拒绝 / 对话结束&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. 关键词&lt;/td&gt;
&lt;td&gt;检测推脱/拒绝语&lt;/td&gt;
&lt;td&gt;&quot;帮你记&quot;、&quot;打错&quot;、&quot;稍后联系&quot;、&quot;尽快&quot; 等 25 个以上关键词&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. 去重&lt;/td&gt;
&lt;td&gt;连续两轮相同回复&lt;/td&gt;
&lt;td&gt;&quot;喂？&quot; → &quot;喂？&quot;（对方不配合）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. max_turns&lt;/td&gt;
&lt;td&gt;硬上限&lt;/td&gt;
&lt;td&gt;安全网，防止死循环&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;经过 184 条多场景模拟对话测试，停止准确率达到 &lt;strong&gt;98%&lt;/strong&gt;。模型 &lt;code&gt;done&lt;/code&gt; 是第一道线，关键词是安全网 —— 模型有时会被&quot;我帮你记一下&quot;这类委婉拒绝骗过去，关键词层补上这个缺口。&lt;/p&gt;
&lt;h2&gt;两段式开场：零延迟通话&lt;/h2&gt;
&lt;p&gt;一个容易被忽略但体验差距巨大的细节：&lt;strong&gt;TTS 生成需要 2-5 秒&lt;/strong&gt;。如果接通后才开始生成 TTS，对方会听到 2-5 秒的沉默，以为是骚扰电话直接挂掉。&lt;/p&gt;
&lt;p&gt;解决方案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;phone_dial(number, opening=&quot;您好，我是...&quot;)
    │
    ├── 拨号中 (ringing) ───→ 后台生成 opening TTS
    │
    └── 对方接听 ───→ 检测 state=ACTIVE ───→ 立即播放 TTS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;开场白在拨号振铃期间后台生成，对方接通的瞬间就听到声音 —— 就像真人打电话一样自然。后续对话通过 &lt;code&gt;phone_converse(skip_opening=true)&lt;/code&gt; 无缝衔接。&lt;/p&gt;
&lt;h2&gt;TTS 间隙填补&lt;/h2&gt;
&lt;p&gt;录音结束后，LLM 决策 + TTS 生成需要 3-8 秒。如果对方在这期间听不到任何声音，会以为断线。&lt;/p&gt;
&lt;p&gt;解决：录音结束 2 秒后，如果 TTS 还没生成完，自动播放预录的垫话「请稍等，让我思考一下」。如果 TTS 在垫话期间完成，等垫话播完再切入 TTS —— 不抢话、不重叠、不断线。&lt;/p&gt;
&lt;p&gt;第一轮永远不播垫话（对方刚接电话，垫话显得突兀）。&lt;/p&gt;
&lt;h2&gt;「不知道就说不知道」&lt;/h2&gt;
&lt;p&gt;LLM 最大的问题之一是&lt;strong&gt;幻觉&lt;/strong&gt;——被问到不知道的信息时会编造答案。在电话场景里这尤其危险。&lt;/p&gt;
&lt;p&gt;Phone Agent 在 System Prompt 里明确要求：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;遇到你不知道的信息，诚实说&quot;这个我不确定，我确认后再回复您&quot;，返回 done，reason 写 &quot;callback: 需要确认XXX后再回电&quot;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent 拿到 &lt;code&gt;done_reason: &quot;callback:...&quot;&lt;/code&gt; 后会告知用户需要查证，然后带着确认后的信息重拨。&lt;/p&gt;
&lt;h1&gt;如何使用&lt;/h1&gt;
&lt;h2&gt;1. 硬件准备&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;一台 Linux 电脑&lt;/strong&gt;（蓝牙 + USB）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;一台 Android 手机&lt;/strong&gt;（已 root，已测试 Xiaomi 22041216C / Android 14 / MTK Dimensity 8100）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;USB 数据线&lt;/strong&gt;连接电脑和手机&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 蓝牙配对 (HSP)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;bluetoothctl pair F8:AB:82:92:08:76
bluetoothctl trust F8:AB:82:92:08:76
pactl list cards short | grep bluez  # 确认识别
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HSP（Headset Profile）是关键——它能提供双向 8kHz 语音通路。A2DP 只能单向放音乐，不能录音。&lt;/p&gt;
&lt;h2&gt;3. 安装依赖&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install mcp edge-tts webrtcvad
apt install pulseaudio pulseaudio-module-bluetooth ffmpeg
pip install faster-whisper  # ASR
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 生成垫话音频&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;python3 gen_fillers.py
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 配置 MCP Server&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/phone-agent.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 中添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;phone-call&quot;: {
  &quot;type&quot;: &quot;local&quot;,
  &quot;command&quot;: [&quot;python3&quot;, &quot;/path/to/phone_call_mcp.py&quot;],
  &quot;enabled&quot;: true,
  &quot;timeout&quot;: 300000,
  &quot;environment&quot;: {
    &quot;PHONE_CONVERSE_BACKEND&quot;: &quot;api&quot;,
    &quot;PHONE_TTS_BACKEND&quot;: &quot;edge&quot;,
    &quot;PHONE_LLM_URL&quot;: &quot;https://api.deepseek.com/chat/completions&quot;,
    &quot;PHONE_LLM_KEY&quot;: &quot;sk-xxx&quot;,
    &quot;PHONE_LLM_MODEL&quot;: &quot;deepseek-chat&quot;,
    &quot;PHONE_LLM_CONTEXT&quot;: &quot;你是XX公司的AI助手。&quot;,
    &quot;PHONE_BT_MAC&quot;: &quot;F8:AB:82:92:08:76&quot;,
    &quot;PHONE_BT_CARD&quot;: &quot;bluez_card.F8_AB_82_92_08_76&quot;,
    &quot;PHONE_BT_SINK&quot;: &quot;bluez_sink.F8_AB_82_92_08_76.headset_audio_gateway&quot;,
    &quot;PHONE_BT_SOURCE&quot;: &quot;bluez_source.F8_AB_82_92_08_76.headset_audio_gateway&quot;,
    &quot;PHONE_ADB&quot;: &quot;/path/to/adb&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. 安装 Agent Skill&lt;/h2&gt;
&lt;p&gt;将仓库里的 &lt;code&gt;skill-phone-call.md&lt;/code&gt; 复制到 &lt;code&gt;~/.agents/skills/phone-call/SKILL.md&lt;/code&gt;，重启 OpenCode。&lt;/p&gt;
&lt;h2&gt;7. 使用&lt;/h2&gt;
&lt;p&gt;配置完成后，Agent 就获得了打电话能力。对它说：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;帮我打电话给 138xxxx，确认他明天下午能否参加评审会&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent 会自动：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;调用 &lt;code&gt;phone_dial&lt;/code&gt; 拨号，附带预生成的「你好，我是 XX 公司 AI 助手...」开场白&lt;/li&gt;
&lt;li&gt;调用 &lt;code&gt;phone_converse&lt;/code&gt; 进行多轮对话&lt;/li&gt;
&lt;li&gt;在对话完成后挂断&lt;/li&gt;
&lt;li&gt;从返回的 &lt;code&gt;transcripts&lt;/code&gt; 中提取信息，用自然语言回复你&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;对话演示&lt;/h1&gt;
&lt;p&gt;一段真实通话记录（对方为某公司客服）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Agent: 您好，我是南京青赋驭境的AI助手，想跟您确认一下
       明天下午的评审会您这边能参加吗？

Caller: 现上评审会是吧？请问具体是哪个部门或者项目的会议呢？

Agent: 是明天下午的线上评审会，具体是南京青赋驭境的项目评审会。

Caller: 嗯，收到了，我会尽快帮您确认一下是否能参加的。

→ 触发停止（关键词：&quot;尽快&quot;）— 对方表达了帮忙意愿但未承诺
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;另一段成功回电流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Agent: 您好，刚才关于XXX的问题，我已经确认了，请问您现在方便吗？

Caller: 方便的，你说。

Agent: 根据我们的记录，您在系统中的状态是XXX，所以需要您...

Caller: 好的，明白了，我马上去处理。

→ 触发停止（模型 done）— 信息传达清楚，对方已承诺
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;局限与后续计划&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;当前局限：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;需要 root 手机&lt;/strong&gt;：ADB 拨号不要求 root，但蓝牙 HSP profile 管理在非 root 设备上有限制&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;仅限中文&lt;/strong&gt;：whisper 支持多语言，但 System Prompt 和 filler 目前只写中文&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linux 单机部署&lt;/strong&gt;：MCP 要求 stdio 通道，不能远程调用&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单路通话&lt;/strong&gt;：HSP 只能处理一路双向音频，不能同时打两个电话&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要 USB 连接&lt;/strong&gt;：ADB over WiFi 也可以，但有线更稳定&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;计划中的改进：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 支持 ADB over WiFi，摆脱 USB 线&lt;/li&gt;
&lt;li&gt;[ ] 多语言支持（英文 System Prompt + 英文 filler）&lt;/li&gt;
&lt;li&gt;[ ] 回电调度：从 &lt;code&gt;done_reason&lt;/code&gt; 提取待确认事项，自动创建回电任务&lt;/li&gt;
&lt;li&gt;[ ] GPU 加速 whisper（CUDA / Apple Silicon）&lt;/li&gt;
&lt;li&gt;[ ] 通话录音存档，供 Agent 回顾历史对话&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;结语&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/7emotions/phone-agent&quot;&gt;phone-agent&lt;/a&gt; 给 AI Agent 装上了一个真正的「嘴巴」和「耳朵」——不是模拟的，是真实能打通电话的。&lt;/p&gt;
&lt;p&gt;它的核心价值不在于技术多么复杂（688 行 Python），而在于&lt;strong&gt;打通了 Agent 到真人之间的最后一公里&lt;/strong&gt;。Agent 能写代码、能搜文档、能部署服务，但在此之前，它需要一个人类来替它打电话。现在不需要了。&lt;/p&gt;
&lt;p&gt;如果你也在用 OpenCode 或其他支持 MCP 的 AI Agent 平台，欢迎尝试。项目开源在 GitHub，MIT 协议。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/7emotions/phone-agent&quot;&gt;github.com/7emotions/phone-agent&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;背景文章：&lt;a href=&quot;https://www.bilibili.com/video/BV1kvPnz9EcA&quot;&gt;Terminator-AI — 「普通人用 OpenClaw 实战：自动打电话」&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>让 AI Agent 也能「看」视频 —— 开发 video-transcript-skill</title><link>https://lorenzofeng.top/posts/video-transcript-skill/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/video-transcript-skill/</guid><description>OpenCode skill 插件开发：利用 yt-dlp + local Whisper 将在线视频转为文本，让 AI 编程助手从视频内容中学习</description><pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;背景&lt;/h1&gt;
&lt;p&gt;在使用 AI 编程助手（如 OpenCode、Claude Code 等）时，有一个明显的痛点：&lt;strong&gt;AI Agent 本质上是文本模型，它无法「观看」视频&lt;/strong&gt;。当我遇到一个技术视频想丢给它学习时，只能自己看一遍再做总结转述。而 OpenCode 的 Skill 机制恰好提供了扩展能力的接口——我可以写一个 Skill，让 Agent 在需要时自动调用，把视频转成它读得懂的文本。&lt;/p&gt;
&lt;p&gt;于是有了&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;7emotions/video-transcript-skill&quot;}&lt;/p&gt;
&lt;h1&gt;功能&lt;/h1&gt;
&lt;p&gt;这个 Skill 做的事情很直接：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;收到视频链接&lt;/strong&gt; → 触发 Skill&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;尝试获取已有字幕&lt;/strong&gt; → 如果平台提供了字幕（B站、YouTube 通常有），几秒内返回结果&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;字幕不可用时&lt;/strong&gt; → 用 &lt;code&gt;yt-dlp&lt;/code&gt; 下载音频 → &lt;code&gt;ffmpeg&lt;/code&gt; 转码 → 本地 Whisper 转录 → 返回完整文本&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;支持的平台：&lt;strong&gt;Bilibili（B站）、YouTube、Vimeo、Twitch&lt;/strong&gt;，以及 &lt;a href=&quot;https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md&quot;&gt;yt-dlp 支持&lt;/a&gt; 的数百个视频网站。&lt;/p&gt;
&lt;h1&gt;架构设计&lt;/h1&gt;
&lt;h2&gt;为什么是 MCP？&lt;/h2&gt;
&lt;p&gt;Skill 本身只是一份指令文件（&lt;code&gt;SKILL.md&lt;/code&gt;），告诉 Agent 在什么场景下该做什么。真正的「能力」需要由外部的 MCP Server 提供。&lt;/p&gt;
&lt;p&gt;这里依赖的是 &lt;code&gt;video-toolkit&lt;/code&gt; MCP Server，它暴露了三个关键工具：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MCP Tool&lt;/th&gt;
&lt;th&gt;功能&lt;/th&gt;
&lt;th&gt;耗时&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get-transcript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;获取平台已有字幕&lt;/td&gt;
&lt;td&gt;秒级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generate-subtitles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;下载音频 + Whisper 转录&lt;/td&gt;
&lt;td&gt;分钟级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list-transcript-languages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查询可用字幕语言&lt;/td&gt;
&lt;td&gt;秒级&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;双轨策略：字幕优先，Whisper 兜底&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;视频链接
    │
    ├── 有字幕？──→ get-transcript ──→ 直接返回（秒级）
    │
    └── 无字幕？──→ yt-dlp 下载音频
                        │
                    ffmpeg 转 16kHz 单声道
                        │
                    faster-whisper 转录
                        │
                    返回带时间戳的完整文本
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个策略的好处是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;大多数视频走得通第一条路径&lt;/strong&gt;，用户感知延迟很低&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二条路径是终极兜底&lt;/strong&gt;，只要 &lt;code&gt;yt-dlp&lt;/code&gt; 能下载音频，就一定能出文本&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;技术选型&lt;/h1&gt;
&lt;h2&gt;yt-dlp — 通用视频下载&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/yt-dlp/yt-dlp&quot;&gt;yt-dlp&lt;/a&gt; 是 youtube-dl 的活跃 fork，支持数百个视频平台。用它做音频下载层，意味着不需要为每个平台写适配代码。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 只下载音频，转为 mp3
yt-dlp -x --audio-format mp3 &quot;https://www.bilibili.com/video/BVxxxxxx&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;faster-whisper — CPU 上也能用的转录&lt;/h2&gt;
&lt;p&gt;OpenAI 的 Whisper 效果很好，但原版在 CPU 上太慢。&lt;code&gt;faster-whisper&lt;/code&gt; 是 CTranslate2 加速的 Whisper 实现，同样的模型在 CPU 上快 4 倍，内存占用更低。&lt;/p&gt;
&lt;p&gt;实际测试数据（4 核 CPU，small 模型）：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;视频时长&lt;/th&gt;
&lt;th&gt;转录耗时&lt;/th&gt;
&lt;th&gt;倍速&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 分钟&lt;/td&gt;
&lt;td&gt;~1 分钟&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 分钟&lt;/td&gt;
&lt;td&gt;~3 分钟&lt;/td&gt;
&lt;td&gt;5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 分钟&lt;/td&gt;
&lt;td&gt;~6.5 分钟&lt;/td&gt;
&lt;td&gt;4.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 小时&lt;/td&gt;
&lt;td&gt;~13 分钟&lt;/td&gt;
&lt;td&gt;4.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对于日常使用的 10-20 分钟视频，等待 2-3 分钟完全在可接受范围内。&lt;/p&gt;
&lt;h2&gt;Whisper 包装脚本&lt;/h2&gt;
&lt;p&gt;MCP Server 需要调用 Whisper，但 &lt;code&gt;faster-whisper&lt;/code&gt; 是 Python 库而非 CLI 工具。为了让 MCP Server 能像调用命令行工具一样使用它，我写了一个薄包装脚本（&lt;code&gt;scripts/whisper&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;Thin wrapper around faster-whisper for CLI usage.&quot;&quot;&quot;
import sys
import json
from faster_whisper import WhisperModel

model_size = &quot;small&quot;
model = WhisperModel(model_size, device=&quot;cpu&quot;, compute_type=&quot;int8&quot;)

segments, info = model.transcribe(sys.argv[1])
result = [{&quot;start&quot;: s.start, &quot;end&quot;: s.end, &quot;text&quot;: s.text} for s in segments]
print(json.dumps(result, ensure_ascii=False))
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;如何使用&lt;/h1&gt;
&lt;h2&gt;1. 安装依赖&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;pip install yt-dlp faster-whisper
# ffmpeg 请根据系统自行安装
# Ubuntu/Debian: sudo apt install ffmpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. 安装 Whisper 包装脚本&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/video-transcript-skill.git
cp video-transcript-skill/scripts/whisper ~/.local/bin/whisper
chmod +x ~/.local/bin/whisper
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 配置 video-toolkit MCP Server&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/JamesANZ/video-transcript-mcp.git
cd video-transcript-mcp
npm install &amp;amp;&amp;amp; npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;opencode.jsonc&lt;/code&gt; 中添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;video-toolkit&quot;: {
  &quot;type&quot;: &quot;local&quot;,
  &quot;command&quot;: [&quot;node&quot;, &quot;/path/to/video-toolkit-mcp/dist/index.js&quot;],
  &quot;enabled&quot;: true,
  &quot;timeout&quot;: 600000,
  &quot;environment&quot;: {
    &quot;YT_DLP_PATH&quot;: &quot;yt-dlp&quot;,
    &quot;FFMPEG_PATH&quot;: &quot;ffmpeg&quot;,
    &quot;TRANSCRIPT_MCP_WHISPER_ENGINE&quot;: &quot;local&quot;,
    &quot;WHISPER_BINARY_PATH&quot;: &quot;/home/ubuntu/.local/bin/whisper&quot;,
    &quot;WHISPER_MODEL_PATH&quot;: &quot;small&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 安装 Skill&lt;/h2&gt;
&lt;p&gt;将 &lt;code&gt;SKILL.md&lt;/code&gt; 放到 OpenCode 的 skills 目录，重启即可。&lt;/p&gt;
&lt;h2&gt;5. 使用&lt;/h2&gt;
&lt;p&gt;直接发视频链接给 Agent：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;帮我总结一下这个视频 https://www.bilibili.com/video/BVxxxxxx&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Agent 会自动调用 Skill，先尝试获取字幕，失败则用 Whisper 转录，最终返回完整的文本内容供你分析和总结。&lt;/p&gt;
&lt;h2&gt;6. 案例&lt;/h2&gt;
&lt;p&gt;我在&lt;code&gt;Bilibili&lt;/code&gt;中找到了&lt;code&gt;Terminator-AI&lt;/code&gt;的视频&lt;a href=&quot;https://www.bilibili.com/video/BV1kvPnz9EcA&quot;&gt;普通人用 OpenClaw 实战系列(6) ：自动打电话&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;::bilibili{bvid=&quot;BV1kvPnz9EcA&quot;}&lt;/p&gt;
&lt;p&gt;以下是&lt;code&gt;Agent&lt;/code&gt;学习的结果&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./images/video-transcript-skill/ai_result.png&quot; alt=&quot;ai_result&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;局限与后续计划&lt;/h1&gt;
&lt;p&gt;&lt;strong&gt;当前局限：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPU 转录速度有上限&lt;/strong&gt;：1 小时视频需要 ~13 分钟。如果有 GPU，速度可以提升 10 倍以上&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;部分平台需要认证&lt;/strong&gt;：B站某些视频需要登录才能获取字幕，此时会自动走 Whisper 降级路径&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;长视频可能超出上下文窗口&lt;/strong&gt;：超过 1 小时的视频转录文本可能超过 LLM 的上下文限制，需要分段处理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;计划中的改进：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] 支持 GPU 加速（CUDA / Apple Silicon）&lt;/li&gt;
&lt;li&gt;[ ] 长视频自动分段 + 逐段总结&lt;/li&gt;
&lt;li&gt;[ ] 缓存已转录视频，避免重复转录&lt;/li&gt;
&lt;li&gt;[ ] 支持更多 Whisper 模型大小选择（tiny ~ large-v3）&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;结语&lt;/h1&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/7emotions/video-transcript-skill&quot;&gt;video-transcript-skill&lt;/a&gt; 本质上做了一件事：&lt;strong&gt;给基于文本的 AI Agent 装上一双「耳朵」&lt;/strong&gt;。它不需要 Agent 理解视频画面，只需要让 Agent 听到视频里说了什么——而这已经覆盖了绝大多数技术教程、演讲、会议记录等场景。&lt;/p&gt;
&lt;p&gt;如果你也在用 OpenCode 或其他支持 Skill/MCP 的 AI 编程工具，欢迎尝试。项目开源在 GitHub，MIT 协议，任何形式的贡献都欢迎。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/7emotions/video-transcript-skill&quot;&gt;github.com/7emotions/video-transcript-skill&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>yolov10模型Docker训练环境搭建</title><link>https://lorenzofeng.top/posts/yolov10s/yolov10s/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/yolov10s/yolov10s/</guid><description>使用Docker容器化搭建yolov10训练环境</description><pubDate>Thu, 17 Apr 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;环境&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;docker compose&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;代码克隆&lt;/h2&gt;
&lt;p&gt;使用&lt;code&gt;git&lt;/code&gt;克隆仓库&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/7emotions/robot-vision.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;::github{repo=&quot;7emotions/robot-vision&quot;}&lt;/p&gt;
&lt;h2&gt;数据集标定&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;yolo/&lt;/code&gt;文件夹下（后续都在该工作目录），新建&lt;code&gt;dataset/images/&lt;/code&gt;与&lt;code&gt;dataset/labels/&lt;/code&gt;。使用&lt;code&gt;labelimg&lt;/code&gt;标定数据集，&lt;code&gt;yolo&lt;/code&gt;支持的数据集格式为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;class_id&amp;gt; &amp;lt;x_center&amp;gt; &amp;lt;y_center&amp;gt; &amp;lt;width&amp;gt; &amp;lt;height&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
&lt;code&gt;label&lt;/code&gt;文件的文件名需要与&lt;code&gt;image&lt;/code&gt; 文件的文件名保持一致。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;将图片文件与标签文件分别移动到&lt;code&gt;dataset/images/&lt;/code&gt;与&lt;code&gt;dataset/labels&lt;/code&gt;下。&lt;/p&gt;
&lt;h2&gt;数据集划分&lt;/h2&gt;
&lt;p&gt;运行&lt;code&gt;split.py&lt;/code&gt;划分数据集&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose run trainer python3 split.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;split.py&lt;/code&gt;会将数据集的&lt;code&gt;80%&lt;/code&gt;划分为&lt;strong&gt;训练集&lt;/strong&gt;，&lt;code&gt;20%&lt;/code&gt;划分为&lt;strong&gt;验证集&lt;/strong&gt;。对于缺失&lt;code&gt;labels&lt;/code&gt;的负样本，会新建空白&lt;code&gt;label&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;划分后，&lt;code&gt;dataset/&lt;/code&gt;的目录结构为&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dataset/
├── images/
│   ├── train/
│   │   ├── image1.jpg
│   │   ├── image2.png
│   │   └── ...
│   └── val/
│       ├── image3.jpg
│       ├── image4.jpeg
│       └── ...
└── labels/
    ├── train/
    │   ├── image1.txt
    │   ├── image2.txt
    │   └── ...
    └── val/
        ├── image3.txt
        ├── image4.txt
        └── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;数据配置文件&lt;/h2&gt;
&lt;p&gt;训练之前需要编写数据配置文件。修改&lt;code&gt;conf.yml&lt;/code&gt;文件&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;train: dataset/images/train
val: dataset/images/val
nc: 7
names: [&quot;red target&quot;, &quot;blue area&quot;, &quot;blue target&quot;, &quot;starting point&quot;, &quot;black target&quot;,&quot;yellow target&quot;,&quot;red area&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中，&lt;code&gt;nc&lt;/code&gt;表示类别数目，&lt;code&gt;names&lt;/code&gt;表示类别名称列表。&lt;/p&gt;
&lt;h2&gt;预训练权重文件&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;yolov10&lt;/code&gt;仓库的&lt;a href=&quot;https://github.com/THU-MIG/yolov10/releases&quot;&gt;&lt;code&gt;Release&lt;/code&gt;&lt;/a&gt;中提供预训练权重文件。此处以&lt;code&gt;yolov10s.pt&lt;/code&gt;为例。&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;THU-MIG/yolov10&quot;}&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
YOLOv10 系列通常会包含多个不同大小和复杂度的变体，以适应不同的计算资源和性能需求。这些变体通常会用后缀来区分，例如 -n（极小）、-s（小型）、-m（中型）、-l（大型）、-x（极大）等等。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
若更换预训练权重文件，请在&lt;code&gt;compose.yml&lt;/code&gt;中替换&lt;code&gt;yolov10s.pt&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;模型训练&lt;/h2&gt;
&lt;p&gt;启动容器进行训练&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;查看日志&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose logs trainer -f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;模型将输出在&lt;code&gt;runs/&lt;/code&gt;目录下， 可查看到结果&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./labels.jpg&quot; alt=&quot;labels.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./labels_correlogram.jpg&quot; alt=&quot;labels_correlogram&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./train_batch0.jpg&quot; alt=&quot;train_batch0.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./train_batch1.jpg&quot; alt=&quot;train_batch1.jpg&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./train_batch2.jpg&quot; alt=&quot;train_batch2.jpg&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;模型导出&lt;/h2&gt;
&lt;p&gt;保存在&lt;code&gt;yolo/runs/deteçt/train/weights/&lt;/code&gt;下的&lt;code&gt;.pt&lt;/code&gt;文件可以通过以下命令导出为&lt;code&gt;.onnx&lt;/code&gt;模型文件。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose run trainer yolo export model=runs/detect/train/weights/best.pt format=onnx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.onnx&lt;/code&gt;模型会保存在&lt;code&gt;yolo/runs/detect/train/weights/&lt;/code&gt;目录下。&lt;/p&gt;
&lt;h2&gt;模型使用&lt;/h2&gt;
&lt;p&gt;以&lt;code&gt;cpp&lt;/code&gt;为例，采用&lt;code&gt;OpenCV&lt;/code&gt;导入&lt;code&gt;.onnx&lt;/code&gt;模型。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;fstream&amp;gt;
#include &amp;lt;string&amp;gt;
#include &amp;lt;vector&amp;gt;
#include &amp;lt;opencv2/opencv.hpp&amp;gt;
#include &amp;lt;opencv2/dnn.hpp&amp;gt;
#include &amp;lt;format&amp;gt;

int main() {
    std::string onnx_model_path = &quot;best.onnx&quot;;
    std::string classes_path = &quot;classes.txt&quot;;
    std::string image_path = &quot;test.jpg&quot;;

    float confidenceThreshold = 0.5;
    float nmsThreshold = 0.4;

    cv::Net net = cv::dnn::readNetFromONNX(onnx_model_path);
    if (net.empty()) {
        std::cerr &amp;lt;&amp;lt; &quot;Error: Could not load ONNX model: &quot; &amp;lt;&amp;lt; onnx_model_path &amp;lt;&amp;lt; std::endl;
        return -1;
    }
    std::cout &amp;lt;&amp;lt; &quot;ONNX model loaded successfully.&quot; &amp;lt;&amp;lt; std::endl;

    std::vector&amp;lt;std::string&amp;gt; classes;
    std::ifstream ifs(classes_path);
    std::string line;
    if (ifs.is_open()) {
        while (getline(ifs, line)) {
            classes.push_back(line);
        }
    } else {
        std::cerr &amp;lt;&amp;lt; &quot;Error: Could not open classes file: &quot; &amp;lt;&amp;lt; classes_path &amp;lt;&amp;lt; std::endl;
        return -1;
    }
    std::cout &amp;lt;&amp;lt; &quot;Loaded &quot; &amp;lt;&amp;lt; classes.size() &amp;lt;&amp;lt; &quot; classes.&quot; &amp;lt;&amp;lt; std::endl;

    cv::Mat frame = cv::imread(image_path);
    if (frame.empty()) {
        std::cerr &amp;lt;&amp;lt; &quot;Error: Could not read image: &quot; &amp;lt;&amp;lt; image_path &amp;lt;&amp;lt; std::endl;
        return -1;
    }
    int frameWidth = frame.cols;
    int frameHeight = frame.rows;

    cv::Mat blob;
    cv::dnn::blobFromImage(frame, blob, 1 / 255.0, cv::Size(640, 640), cv::Scalar(0, 0, 0), true, false);
    net.setInput(blob);

    cv::Mat output = net.forward();

    std::vector&amp;lt;int&amp;gt; classIds;
    std::vector&amp;lt;float&amp;gt; confidences;
    std::vector&amp;lt;cv::Rect&amp;gt; boxes;

    int rows = output.size[2];
    int cols = output.size[3];

    for (int i = 0; i &amp;lt; rows; ++i) {
        float confidence = output.at&amp;lt;float&amp;gt;(0, 0, i, 4);

        if (confidence &amp;gt; confidenceThreshold) {
            cv::Mat scores = output.row(i).colRange(5, cols);
            cv::Point classIdPoint;
            double maxScore;
            cv::minMaxLoc(scores, 0, &amp;amp;maxScore, 0, &amp;amp;classIdPoint);
            int classId = classIdPoint.x;

            if (maxScore &amp;gt; confidenceThreshold) {
                float centerX = output.at&amp;lt;float&amp;gt;(0, 0, i, 0) * frameWidth;
                float centerY = output.at&amp;lt;float&amp;gt;(0, 0, i, 1) * frameHeight;
                float width = output.at&amp;lt;float&amp;gt;(0, 0, i, 2) * frameWidth;
                float height = output.at&amp;lt;float&amp;gt;(0, 0, i, 3) * frameHeight;
                cv::Rect box(cv::Point(cvRound(centerX - width / 2), cvRound(centerY - height / 2)),
                             cv::Size(cvRound(width), cvRound(height)));

                classIds.push_back(classId);
                confidences.push_back(confidence * maxScore);
                boxes.push_back(box);
            }
        }
    }

    std::vector&amp;lt;int&amp;gt; indices;
    cv::dnn::NMSBoxes(boxes, confidences, confidenceThreshold, nmsThreshold, indices);

    for (int idx : indices) {
        cv::Rect box = boxes[idx];
        int classId = classIds[idx];
        float confidence = confidences[idx];

        cv::rectangle(frame, box, cv::Scalar(0, 255, 0), 2);
        
  std::string label = classes[classId] + &quot;: &quot; + std::format(&quot;{:.2f}&quot;, confidence);
        cv::putText(frame, label, cv::Point(box.x, box.y - 10), cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 0), 2);
    }

    cv::imshow(&quot;Detected Objects&quot;, frame);
    cv::waitKey(0);
    cv::destroyAllWindows();

    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>AtomGit Pages 使用自定义域名</title><link>https://lorenzofeng.top/posts/blog-net/blog-net/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/blog-net/blog-net/</guid><description>采用nginx与caddy反向代理，为AtomGit Pages设置自定义域名</description><pubDate>Wed, 19 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前置条件&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;已经备案的域名&lt;/li&gt;
&lt;li&gt;已经配置Docker的云服务器&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;域名解析配置&lt;/h1&gt;
&lt;p&gt;为域名添加A记录，解析值为云服务器的IP地址。&lt;/p&gt;
&lt;h1&gt;云服务器配置&lt;/h1&gt;
&lt;p&gt;为云服务添加出站规则，开放&lt;code&gt;443&lt;/code&gt;端口与&lt;code&gt;80&lt;/code&gt;端口。&lt;/p&gt;
&lt;h1&gt;&lt;code&gt;Caddy&lt;/code&gt;配置&lt;/h1&gt;
&lt;p&gt;新建&lt;code&gt;Caddyfile&lt;/code&gt;，并写入&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;yours.domain.com {
    reverse_proxy nginx:80
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
请修改&lt;code&gt;yours.domain.com&lt;/code&gt;为你的域名。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;&lt;code&gt;Nginx&lt;/code&gt;配置&lt;/h1&gt;
&lt;p&gt;新建&lt;code&gt;nginx.conf&lt;/code&gt;，并写入&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;events {
    worker_connections 1024;
}

http {
    server {
        listen 80;
        server_name yours.domain.com;

        location / {
            proxy_pass https://you.atomgit.net/your-blog/;
            proxy_set_header Host you.atomgit.net;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header User-Agent $http_user_agent;
            proxy_ssl_server_name on;
            proxy_ssl_protocols TLSv1.2 TLSv1.3;
            proxy_ssl_ciphers &apos;HIGH:!aNULL:!MD5&apos;;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
请修改&lt;code&gt;yours.domain.com&lt;/code&gt;为你的域名，&lt;code&gt;you.atomgit.net&lt;/code&gt;为你的AtomGit Pages的域名，&lt;code&gt;your-blog&lt;/code&gt;为你的博客的目录。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;&lt;code&gt;Docker&lt;/code&gt;配置&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;version: &apos;3.8&apos;

services:
    nginx:
        container_name: blog-nginx
        image: nginx:latest
        ports:
            - &quot;80:80&quot; 
        volumes:
            - ./nginx.conf:/etc/nginx/nginx.conf
        networks:
            - blognetwork

    caddy:
        container_name: blog-caddy
        image: caddy:latest
        ports:
            - &quot;443:443&quot;
        volumes:
            - ./Caddyfile:/etc/caddy/Caddyfile
        networks:
            - blognetwork

networks:
  blognetwork:
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;构建容器&lt;/h1&gt;
&lt;p&gt;运行以下指令构建容器&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;访问&lt;code&gt;https://yours.domain.com/&lt;/code&gt;以查看博客。&lt;/p&gt;
</content:encoded></item><item><title>国内自动化静态博客搭建</title><link>https://lorenzofeng.top/posts/blog-guide/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/blog-guide/</guid><description>采用Github Action向AtomGit部署Fuwari静态博客</description><pubDate>Fri, 14 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前言&lt;/h1&gt;
&lt;p&gt;最近在搭建静态博客，发现国内访问&lt;code&gt;Github Pages&lt;/code&gt;的速度很慢，于是想通过国内的服务器来部署静态博客。经过一番搜索，发现&lt;code&gt;AtomGit&lt;/code&gt;是一个很好的选择，它支持&lt;code&gt;Pages&lt;/code&gt;服务，并且在国内有较好的访问速度。&lt;/p&gt;
&lt;h1&gt;准备&lt;/h1&gt;
&lt;h2&gt;AtomGit&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://atomgit.com/&quot;&gt;AtomGit&lt;/a&gt;是国内由开放原子基金会运营的&lt;code&gt;Git&lt;/code&gt;托管平台，它支持&lt;code&gt;Pages&lt;/code&gt;服务，并且具有良好的访问速度。&lt;/p&gt;
&lt;p&gt;你需要先在&lt;code&gt;AtomGit&lt;/code&gt;上注册一个账号，并创建一个仓库，用于部署静态博客的站点构建文件。并且，该仓库需要开启&lt;code&gt;Pages&lt;/code&gt;服务。参考&lt;a href=&quot;https://docs.atomgit.com/app/pageshelp&quot;&gt;AtomGit Pages&lt;/a&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
开启Pages服务后，AtomGit会自动为你的仓库分配一个uri，例如&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;。你可以通过这个url来访问你的静态博客。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Github&lt;/h2&gt;
&lt;p&gt;你需要一个&lt;code&gt;Github&lt;/code&gt;账号，并且需要创建一个仓库，用于存放静态博客的源文件。这个仓库不需要开启&lt;code&gt;Pages&lt;/code&gt;服务。从该仓库中，你可以通过&lt;code&gt;Github Action&lt;/code&gt;来生成静态博客的站点文件，并将其部署到&lt;code&gt;AtomGit&lt;/code&gt;上。&lt;/p&gt;
&lt;h2&gt;Node.js （可选）&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Fuwari&lt;/code&gt;从&lt;code&gt;Node.js&lt;/code&gt;构建 ，你需要安装&lt;code&gt;Node.js Version 20&lt;/code&gt;。你可以从&lt;a href=&quot;https://nodejs.org/&quot;&gt;Node.js官网&lt;/a&gt;下载并安装。&lt;/p&gt;
&lt;h1&gt;开始&lt;/h1&gt;
&lt;h2&gt;1. Fork Fuwari&lt;/h2&gt;
&lt;p&gt;首先，你需要Fork &lt;a href=&quot;https://github.com/saicaca/fuwari&quot;&gt;Fuwari&lt;/a&gt; 到你的Github账号下。&lt;code&gt;Fuwari&lt;/code&gt;是基于&lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;的静态博客模板。&lt;/p&gt;
&lt;p&gt;:sparkle: 功能特性&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] 基于 &lt;code&gt;Astro&lt;/code&gt; 和 &lt;code&gt;Tailwind CSS&lt;/code&gt; 开发&lt;/li&gt;
&lt;li&gt;[x] 流畅的动画和页面过渡&lt;/li&gt;
&lt;li&gt;[x] 亮色 / 暗色模式&lt;/li&gt;
&lt;li&gt;[x] 自定义主题色和横幅图片&lt;/li&gt;
&lt;li&gt;[x] 响应式设计&lt;/li&gt;
&lt;li&gt;[ ] 评论&lt;/li&gt;
&lt;li&gt;[x] 搜索&lt;/li&gt;
&lt;li&gt;[ ] 文内目录&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 修改配置文件&lt;/h2&gt;
&lt;p&gt;你可以通过配置文件 &lt;code&gt;src/config.ts&lt;/code&gt; 自定义博客。以我的配置文件为例。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import type {
  LicenseConfig,
  NavBarConfig,
  ProfileConfig,
  SiteConfig,
} from &apos;./types/config&apos;
import { LinkPreset } from &apos;./types/config&apos;

export const siteConfig: SiteConfig = {
  title: &apos;Lorenzo Feng&apos;,
  subtitle: &apos;Blog Site&apos;,
  lang: &apos;zh_CN&apos;,         // &apos;en&apos;, &apos;zh_CN&apos;, &apos;zh_TW&apos;, &apos;ja&apos;, &apos;ko&apos;, &apos;es&apos;, &apos;th&apos;
  themeColor: {
    hue: 250,         // Default hue for the theme color, from 0 to 360. e.g. red: 0, teal: 200, cyan: 250, pink: 345
    fixed: false,     // Hide the theme color picker for visitors
  },
  banner: {
    enable: true,
    src: &apos;assets/images/demo-banner.png&apos;,   // Relative to the /src directory. Relative to the /public directory if it starts with &apos;/&apos;
    position: &apos;center&apos;,      // Equivalent to object-position, only supports &apos;top&apos;, &apos;center&apos;, &apos;bottom&apos;. &apos;center&apos; by default
    credit: {
      enable: false,         // Display the credit text of the banner image
      text: &apos;&apos;,              // Credit text to be displayed
      url: &apos;&apos;                // (Optional) URL link to the original artwork or artist&apos;s page
    }
  },
  toc: {
    enable: true,           // Display the table of contents on the right side of the post
    depth: 2                // Maximum heading depth to show in the table, from 1 to 3
  },
  favicon: [    // Leave this array empty to use the default favicon
    // {
    //   src: &apos;/favicon/icon.png&apos;,    // Path of the favicon, relative to the /public directory
    //   theme: &apos;light&apos;,              // (Optional) Either &apos;light&apos; or &apos;dark&apos;, set only if you have different favicons for light and dark mode
    //   sizes: &apos;32x32&apos;,              // (Optional) Size of the favicon, set only if you have favicons of different sizes
    // }
  ]
}

export const navBarConfig: NavBarConfig = {
  links: [
    LinkPreset.Home,
    LinkPreset.Archive,
    LinkPreset.About,
    {
      name: &apos;GitHub&apos;,
      url: &apos;https://github.com/7emotions/astro-blog/&apos;,     // Internal links should not include the base path, as it is automatically added
      external: true,                               // Show an external link icon and will open in a new tab
    },
  ],
}

export const profileConfig: ProfileConfig = {
  avatar: &apos;assets/images/demo-avatar.png&apos;,  // Relative to the /src directory. Relative to the /public directory if it starts with &apos;/&apos;
  name: &apos;Lorenzo Feng&apos;,
  bio: &apos;An algorithm engineer of Alliance, NJUST&apos;,
  links: [
    {
        name: &apos;Telegram&apos;,
        icon: &apos;logos:telegram&apos;,
        url: &apos;https://t.me/lorenzofeng&apos;
    },
    {
      name: &apos;QQ&apos;,
      icon: &apos;mdi:qqchat&apos;,
      url: &apos;https://qm.qq.com/q/gszht217Fu&apos;,
    },
    {
      name: &apos;GitHub&apos;,
      icon: &apos;fa6-brands:github&apos;,
      url: &apos;https://github.com/7emotions&apos;,
    },
  ],
}

export const licenseConfig: LicenseConfig = {
  enable: true,
  name: &apos;CC BY-NC-SA 4.0&apos;,
  url: &apos;https://creativecommons.org/licenses/by-nc-sa/4.0/&apos;,
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 创建文章&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Astro&lt;/code&gt;框架是基于&lt;code&gt;Markdown&lt;/code&gt;的，所以你可以在&lt;code&gt;src/content/posts/&lt;/code&gt;目录中创建新的&lt;code&gt;Markdown&lt;/code&gt;文件，编辑文章内容。 你也可以在终端中执行 &lt;code&gt;pnpm new-post &amp;lt;filename&amp;gt;&lt;/code&gt; 创建新文章，并在&lt;code&gt;src/content/posts/&lt;/code&gt;目录中编辑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;文章头格式&lt;/strong&gt;如下&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
title: My First Blog Post //文章标题
published: 2023-09-09 //文章发布日期
description: This is the first post of my new Astro blog.  //文章描述
image: ./cover.jpg  //这是文章封面，路径可以是相对路径，也可以是绝对路径
tags: [Foo, Bar] //文章标签
category: Front-end //文章分类
draft: false //是否为草稿
lang: jp      //仅当文章语言与 `config.ts` 中的网站语言不同时需要设置
---

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 本地构建（可选）&lt;/h2&gt;
&lt;p&gt;安装依赖&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm install -g pnpm
pnpm install &amp;amp;&amp;amp; pnpm add sharp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行以下命令，可以在本地&lt;a href=&quot;https://localhost:4321/&quot;&gt;https://localhost:4321/&lt;/a&gt;预览博客&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;构建博客&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 部署&lt;/h2&gt;
&lt;p&gt;修改在根目录下的 &lt;code&gt;astro.config.mjs&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export default defineConfig({
    site: &quot;https://7emotions.atomgit.net&quot;, // &amp;lt;username&amp;gt;.atomgit.net
    base: &quot;/blog&quot;, // &amp;lt;repo-name&amp;gt;
    trailingSlash: &quot;always&quot;
    // ...
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将 &lt;code&gt;base&lt;/code&gt; 修改为你的仓库名称，将 &lt;code&gt;&amp;lt;username&amp;gt;&lt;/code&gt; 修改为你的用户名。例如，如果你的用户名是 &lt;code&gt;lorenzofeng&lt;/code&gt;，仓库名称是 &lt;code&gt;blog&lt;/code&gt;，那么 &lt;code&gt;base&lt;/code&gt; 应该是 &lt;code&gt;/blog&lt;/code&gt;，&lt;code&gt;site&lt;/code&gt; 应该是 &lt;code&gt;https://lorenzofeng.atomgit.net&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在根目录新建 &lt;code&gt;.github/workflows/astro.yml&lt;/code&gt;， 并写入以下内容&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: Deploy to AtomGit Pages

on:
  push:
    branches: [main] # 修改为Github源码仓库的分支名
  workflow_dispatch:

permissions:
  contents: read

jobs:
  deployment:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout your repository using git
        uses: actions/checkout@v4
        with:
          ref: main # 修改为Github源码仓库的分支名
      - name: Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Install dependence
        run: |
          npm install -g pnpm
          pnpm install
          pnpm add sharp
      - name: Build dist
        run: pnpm run build
      - name: Publish branch
        uses: 7emotions/branch-pub@v4
        with:
          token: ${{ secrets.ATOM_TOKEN }} 
          user: 7emotions # 修改为你的AtomGit用户名
          repo: 7emotions/blog # 修改为AtomGit的仓库路径
          github_domain: atomgit.com
          branch: pages # 要部署的分支名称
          folder: dist 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在&lt;code&gt;AtomGit&lt;/code&gt;的&lt;a href=&quot;https://atomgit.com/-/profile/tokens&quot;&gt;&lt;strong&gt;访问令牌&lt;/strong&gt;&lt;/a&gt;中创建个人令牌（&lt;code&gt;Personal Access Token&lt;/code&gt;），权限为&lt;code&gt;repo&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;在&lt;code&gt;Github&lt;/code&gt;仓库&lt;code&gt;Settings&lt;/code&gt;中，找到&lt;code&gt;Secrets and variables&lt;/code&gt;，选择&lt;code&gt;Action&lt;/code&gt;。点击&lt;code&gt;New repository secret&lt;/code&gt;，添加&lt;code&gt;ATOM_TOKEN&lt;/code&gt;，值为你的&lt;code&gt;AtomGit&lt;/code&gt;的&lt;code&gt;Personal Access Token&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;推送代码到&lt;code&gt;Github&lt;/code&gt;仓库，等待&lt;code&gt;Github Action&lt;/code&gt;执行完成，即可在&lt;code&gt;AtomGit&lt;/code&gt;上看到新发布的分支。&lt;/p&gt;
&lt;p&gt;在&lt;code&gt;AtomGit&lt;/code&gt;的仓库中，点击设置-&amp;gt;&lt;code&gt;Pages&lt;/code&gt;，选择&lt;code&gt;pages&lt;/code&gt;分支，即可在&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;访问你的博客。&lt;/p&gt;
&lt;h1&gt;参考&lt;/h1&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.lapis.cafe/posts/technicaltutorials/%E6%96%B0%E4%B8%80%E4%BB%A3%E9%9D%99%E6%80%81%E5%8D%9A%E5%AE%A2%E6%A1%86%E6%9E%B6astro%E7%9A%84%E9%83%A8%E7%BD%B2%E4%BC%98%E5%8C%96%E6%8C%87%E5%8D%97%E4%B8%8E%E4%BD%BF%E7%94%A8%E4%BD%93%E9%AA%8C/&quot;&gt;新一代静态博客框架Astro的部署优化指南与使用体验&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.atomgit.com/app/pageshelp&quot;&gt;AtomGit Pages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>国内静态博客一键部署</title><link>https://lorenzofeng.top/posts/onetap/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/onetap/</guid><description>Docker或Github Action一键部署Fuwari静态博客</description><pubDate>Fri, 14 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前言&lt;/h1&gt;
&lt;p&gt;手动部署以及博客配置参考&lt;a href=&quot;../blog-guide/&quot;&gt;国内自动化静态博客搭建&lt;/a&gt;。&lt;/p&gt;
&lt;h1&gt;准备&lt;/h1&gt;
&lt;h2&gt;AtomGit&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://atomgit.com/&quot;&gt;AtomGit&lt;/a&gt;是国内由开放原子基金会运营的&lt;code&gt;Git&lt;/code&gt;托管平台，它支持&lt;code&gt;Pages&lt;/code&gt;服务，并且具有良好的访问速度。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;一个账号与一个空仓库&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;你需要先在&lt;code&gt;AtomGit&lt;/code&gt;上注册一个账号，并创建一个&lt;strong&gt;空仓库&lt;/strong&gt;，用于部署静态博客的站点构建文件。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pages服务申请&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;该仓库需要开启&lt;code&gt;Pages&lt;/code&gt;服务。参考&lt;a href=&quot;https://docs.atomgit.com/app/pageshelp&quot;&gt;AtomGit Pages&lt;/a&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://atomgit.com/atomgit_operate/feedback/issues/create?name=3.Apply_Pages&amp;amp;dirType=1&amp;amp;page=issueTemplate&quot;&gt;点击此处申请Pages服务&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://atomgit.com/marketplace/pages?ref_app_id=4005817059772432&quot;&gt;点击此处安装Pages应用&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;开启Pages服务后，AtomGit会自动为你的仓库分配一个uri，例如&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;。你可以通过这个url来访问你的静态博客。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;访问令牌&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;你需要创建一个访问令牌（&lt;code&gt;Personal Access Token&lt;/code&gt;，&lt;code&gt;PAT&lt;/code&gt;），用于&lt;code&gt;Github Action&lt;/code&gt;访问你的仓库。&lt;a href=&quot;https://atomgit.com/-/profile/tokens&quot;&gt;点击此处生成&lt;/a&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意&lt;/strong&gt;: 访问令牌需要勾选&lt;code&gt;repo&lt;/code&gt;权限，否则&lt;code&gt;Github Action&lt;/code&gt;无法访问你的仓库。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Github&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;一个账号&lt;/strong&gt;
你需要一个&lt;code&gt;Github&lt;/code&gt;账号，并且需要创建一个仓库，用于存放静态博客的源文件。这个仓库不需要开启&lt;code&gt;Pages&lt;/code&gt;服务。从该仓库中，你可以通过&lt;code&gt;Github Action&lt;/code&gt;来生成静态博客的站点文件，并将其部署到&lt;code&gt;AtomGit&lt;/code&gt;上。&lt;/li&gt;
&lt;/ol&gt;
&lt;h1&gt;Github Action 部署&lt;/h1&gt;
&lt;h2&gt;1. Fork 7emotions/Fuwari&lt;/h2&gt;
&lt;p&gt;首先，你需要Fork 我的&lt;a href=&quot;https://github.com/7emotions/fuwari&quot;&gt;Fuwari&lt;/a&gt; 到你的Github账号下。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/zh/get-started/quickstart/fork-a-repo&quot;&gt;如何Fork仓库&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 配置Secret&lt;/h2&gt;
&lt;p&gt;在Fork后的仓库中，点击&lt;code&gt;Settings&lt;/code&gt;，然后点击&lt;code&gt;Secrets and variables&lt;/code&gt;，选择&lt;code&gt;Actions&lt;/code&gt;，点击&lt;code&gt;New repository secret&lt;/code&gt;，添加以下三个&lt;code&gt;Secret&lt;/code&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TOKEN&lt;/code&gt;：AtomGit的访问令牌(&lt;code&gt;PAT&lt;/code&gt;)，用于&lt;code&gt;Github Action&lt;/code&gt;访问你的AtomGit仓库。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;USER&lt;/code&gt;：AtomGit的用户名，用于&lt;code&gt;Github Action&lt;/code&gt;访问你的AtomGit仓库。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REPO&lt;/code&gt;：AtomGit的仓库名，用于&lt;code&gt;Github Action&lt;/code&gt;访问你的AtomGit仓库。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;例如，我的AtomGit仓库的uri为&lt;code&gt;https://atomgit.com/&amp;lt;username&amp;gt;/&amp;lt;repo-name&amp;gt;&lt;/code&gt;，那么&lt;code&gt;USER&lt;/code&gt;为&lt;code&gt;&amp;lt;username&amp;gt;&lt;/code&gt;，&lt;code&gt;REPO&lt;/code&gt;为&lt;code&gt;&amp;lt;repo-name&amp;gt;&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;3. 启用Actions&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;Github&lt;/code&gt;仓库中，点击&lt;code&gt;Actions&lt;/code&gt;，选择&lt;code&gt;Deploy Pages&lt;/code&gt;，点击&lt;code&gt;Run workflow&lt;/code&gt;，选择分支为&lt;code&gt;main&lt;/code&gt;，点击&lt;code&gt;Run workflow&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/onetap/1.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;刷新页面，可以看到&lt;code&gt;Github Action&lt;/code&gt;正在运行。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/onetap/2.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;约莫1分钟后，出现如下提示，说明&lt;code&gt;Github Action&lt;/code&gt;运行完成。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;images/onetap/3.png&quot; alt=&quot;alt text&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当&lt;code&gt;Github Action&lt;/code&gt;运行完成后，你可以访问&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;查看你的静态博客。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;例如，我的AtomGit仓库的uri为&lt;code&gt;https://atomgit.com/7emotions/blog&lt;/code&gt;，那么我的静态博客的访问地址为&lt;code&gt;https://7emotions.atomgit.net/blog&lt;/code&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Docker 部署&lt;/h1&gt;
&lt;h2&gt;1. Fork 7emotions/Fuwari&lt;/h2&gt;
&lt;p&gt;首先，你需要Fork 我的&lt;a href=&quot;https://github.com/7emotions/fuwari&quot;&gt;Fuwari&lt;/a&gt; 到你的Github账号下。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/zh/get-started/quickstart/fork-a-repo&quot;&gt;如何Fork仓库&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. Clone 仓库&lt;/h2&gt;
&lt;p&gt;在本地，使用&lt;code&gt;git clone&lt;/code&gt;命令克隆你的Fork仓库。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git clone https://github.com/&amp;lt;username&amp;gt;/fuwari.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3. 配置环境变量&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;fuwari&lt;/code&gt;目录下，创建一个&lt;code&gt;.env&lt;/code&gt;文件，并添加以下内容。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;TOKEN=&amp;lt;AtomGit-Token&amp;gt;
USER=&amp;lt;username&amp;gt;
REPO=&amp;lt;repo-name&amp;gt;
GIT_DOMAIN=atomgit.com
BRANCH=pages
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;文中&lt;code&gt;&amp;lt;AtomGit-Token&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;username&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;repo-name&amp;gt;&lt;/code&gt;分别代表AtomGit的访问令牌(&lt;code&gt;PAT&lt;/code&gt;)、AtomGit的用户名、AtomGit的仓库名。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;4. 构建与运行&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;fuwari&lt;/code&gt;目录下，使用以下命令构建与运行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker-compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 访问博客&lt;/h2&gt;
&lt;p&gt;当&lt;code&gt;Docker&lt;/code&gt;容器运行完成后，你可以访问&lt;code&gt;https://&amp;lt;username&amp;gt;.atomgit.net/&amp;lt;repo-name&amp;gt;&lt;/code&gt;查看你的静态博客。&lt;/p&gt;
&lt;h1&gt;博文发布&lt;/h1&gt;
&lt;p&gt;无论是&lt;code&gt;Github Action&lt;/code&gt;部署还是&lt;code&gt;Docker&lt;/code&gt;部署，每次向远端推送博文，会自动触发&lt;code&gt;Github Action&lt;/code&gt;，生成静态博客的站点文件，并将其部署到&lt;code&gt;AtomGit&lt;/code&gt;上。&lt;/p&gt;
</content:encoded></item><item><title>hello-world</title><link>https://lorenzofeng.top/posts/hello-world/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/hello-world/</guid><description>你好世界</description><pubDate>Thu, 13 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;你好世界&lt;/h1&gt;
&lt;h2&gt;profile&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Lorenzo Feng&lt;/li&gt;
&lt;li&gt;C/C++&lt;/li&gt;
&lt;li&gt;ROS2&lt;/li&gt;
&lt;li&gt;Docker&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;URL 测试&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/&quot;&gt;Click Here&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;图片测试&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://img.loliapi.cn/i/pc/img535.webp&quot; alt=&quot;pic&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;代码块测试&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;def main(name:str=&quot;lorenzo&quot;)-&amp;gt;int:
    print(&quot;hello, %s&quot;%name)
    return len(name)

if __name__==&quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Markdown Extended Features</title><link>https://lorenzofeng.top/posts/markdown-extended/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/markdown-extended/</guid><description>Read more about Markdown features in Fuwari</description><pubDate>Wed, 01 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;GitHub Repository Cards&lt;/h2&gt;
&lt;p&gt;You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API.&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;Fabrizz/MMM-OnSpotify&quot;}&lt;/p&gt;
&lt;p&gt;Create a GitHub repository card with the code &lt;code&gt;::github{repo=&quot;&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;&quot;}&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;::github{repo=&quot;saicaca/fuwari&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Admonitions&lt;/h2&gt;
&lt;p&gt;Following types of admonitions are supported: &lt;code&gt;note&lt;/code&gt; &lt;code&gt;tip&lt;/code&gt; &lt;code&gt;important&lt;/code&gt; &lt;code&gt;warning&lt;/code&gt; &lt;code&gt;caution&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;:::note
Highlights information that users should take into account, even when skimming.
:::&lt;/p&gt;
&lt;p&gt;:::tip
Optional information to help a user be more successful.
:::&lt;/p&gt;
&lt;p&gt;:::important
Crucial information necessary for users to succeed.
:::&lt;/p&gt;
&lt;p&gt;:::warning
Critical content demanding immediate user attention due to potential risks.
:::&lt;/p&gt;
&lt;p&gt;:::caution
Negative potential consequences of an action.
:::&lt;/p&gt;
&lt;h3&gt;Basic Syntax&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;:::note
Highlights information that users should take into account, even when skimming.
:::

:::tip
Optional information to help a user be more successful.
:::
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Custom Titles&lt;/h3&gt;
&lt;p&gt;The title of the admonition can be customized.&lt;/p&gt;
&lt;p&gt;:::note[MY CUSTOM TITLE]
This is a note with a custom title.
:::&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;:::note[MY CUSTOM TITLE]
This is a note with a custom title.
:::
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;GitHub Syntax&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP]
&lt;a href=&quot;https://github.com/orgs/community/discussions/16925&quot;&gt;The GitHub syntax&lt;/a&gt; is also supported.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;&amp;gt; [!NOTE]
&amp;gt; The GitHub syntax is also supported.

&amp;gt; [!TIP]
&amp;gt; The GitHub syntax is also supported.
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Simple Guides for Fuwari</title><link>https://lorenzofeng.top/posts/guide/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/guide/</guid><description>How to use this blog template.</description><pubDate>Mon, 01 Apr 2024 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Cover image source: &lt;a href=&quot;https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/208fc754-890d-4adb-9753-2c963332675d/width=2048/01651-1456859105-(colour_1.5),girl,_Blue,yellow,green,cyan,purple,red,pink,_best,8k,UHD,masterpiece,male%20focus,%201boy,gloves,%20ponytail,%20long%20hair,.jpeg&quot;&gt;Source&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This blog template is built with &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;. For the things that are not mentioned in this guide, you may find the answers in the &lt;a href=&quot;https://docs.astro.build/&quot;&gt;Astro Docs&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Front-matter of Posts&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;---
title: My First Blog Post
published: 2023-09-09
description: This is the first post of my new Astro blog.
image: ./cover.jpg
tags: [Foo, Bar]
category: Front-end
draft: false
---
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;title&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The title of the post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;published&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The date the post was published.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A short description of the post. Displayed on index page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;image&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The cover image path of the post.&amp;lt;br/&amp;gt;1. Start with &lt;code&gt;http://&lt;/code&gt; or &lt;code&gt;https://&lt;/code&gt;: Use web image&amp;lt;br/&amp;gt;2. Start with &lt;code&gt;/&lt;/code&gt;: For image in &lt;code&gt;public&lt;/code&gt; dir&amp;lt;br/&amp;gt;3. With none of the prefixes: Relative to the markdown file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The tags of the post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;category&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The category of the post.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;If this post is still a draft, which won&apos;t be displayed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Where to Place the Post Files&lt;/h2&gt;
&lt;p&gt;Your post files should be placed in &lt;code&gt;src/content/posts/&lt;/code&gt; directory. You can also create sub-directories to better organize your posts and assets.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/content/posts/
├── post-1.md
└── post-2/
    ├── cover.png
    └── index.md
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Markdown Example</title><link>https://lorenzofeng.top/posts/markdown/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/markdown/</guid><description>A simple example of a Markdown blog post.</description><pubDate>Sun, 01 Oct 2023 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;An h1 header&lt;/h1&gt;
&lt;p&gt;Paragraphs are separated by a blank line.&lt;/p&gt;
&lt;p&gt;2nd paragraph. &lt;em&gt;Italic&lt;/em&gt;, &lt;strong&gt;bold&lt;/strong&gt;, and &lt;code&gt;monospace&lt;/code&gt;. Itemized lists
look like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;this one&lt;/li&gt;
&lt;li&gt;that one&lt;/li&gt;
&lt;li&gt;the other one&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Note that --- not considering the asterisk --- the actual text
content starts at 4-columns in.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Block quotes are
written like so.&lt;/p&gt;
&lt;p&gt;They can span multiple paragraphs,
if you like.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., &quot;it&apos;s all
in chapters 12--14&quot;). Three dots ... will be converted to an ellipsis.
Unicode is supported. ☺&lt;/p&gt;
&lt;h2&gt;An h2 header&lt;/h2&gt;
&lt;p&gt;Here&apos;s a numbered list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;first item&lt;/li&gt;
&lt;li&gt;second item&lt;/li&gt;
&lt;li&gt;third item&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Note again how the actual text starts at 4 columns in (4 characters
from the left side). Here&apos;s a code sample:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Let me re-iterate ...
for i in 1 .. 10 { do-something(i) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you probably guessed, indented 4 spaces. By the way, instead of
indenting the block, you can use delimited blocks, if you like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;define foobar() {
    print &quot;Welcome to flavor country!&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(which makes copying &amp;amp; pasting easier). You can optionally mark the
delimited block for Pandoc to syntax highlight it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import time
# Quick, count to ten!
for i in range(10):
    # (but not *too* quick)
    time.sleep(0.5)
    print i
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;An h3 header&lt;/h3&gt;
&lt;p&gt;Now a nested list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;First, get these ingredients:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;carrots&lt;/li&gt;
&lt;li&gt;celery&lt;/li&gt;
&lt;li&gt;lentils&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Boil some water.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Dump everything in the pot and follow
this algorithm:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; find wooden spoon
 uncover pot
 stir
 cover pot
 balance wooden spoon precariously on pot handle
 wait 10 minutes
 goto first step (or shut off burner when done)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do not bump wooden spoon or it will fall.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Notice again how text always lines up on 4-space indents (including
that last line which continues item 3 above).&lt;/p&gt;
&lt;p&gt;Here&apos;s a link to &lt;a href=&quot;http://foo.bar&quot;&gt;a website&lt;/a&gt;, to a &lt;a href=&quot;local-doc.html&quot;&gt;local
doc&lt;/a&gt;, and to a &lt;a href=&quot;#an-h2-header&quot;&gt;section heading in the current
doc&lt;/a&gt;. Here&apos;s a footnote [^1].&lt;/p&gt;
&lt;p&gt;[^1]: Footnote text goes here.&lt;/p&gt;
&lt;p&gt;Tables can look like this:&lt;/p&gt;
&lt;p&gt;size material color&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;9 leather brown
10 hemp canvas natural
11 glass transparent&lt;/p&gt;
&lt;p&gt;Table: Shoes, their sizes, and what they&apos;re made of&lt;/p&gt;
&lt;p&gt;(The above is the caption for the table.) Pandoc also supports
multi-line tables:&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;keyword text&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;red Sunsets, apples, and
other red or reddish
things.&lt;/p&gt;
&lt;p&gt;green Leaves, grass, frogs
and other things it&apos;s
not easy being.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;A horizontal rule follows.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Here&apos;s a definition list:&lt;/p&gt;
&lt;p&gt;apples
: Good for making applesauce.
oranges
: Citrus!
tomatoes
: There&apos;s no &quot;e&quot; in tomatoe.&lt;/p&gt;
&lt;p&gt;Again, text is indented 4 spaces. (Put a blank line between each
term/definition pair to spread things out more.)&lt;/p&gt;
&lt;p&gt;Here&apos;s a &quot;line block&quot;:&lt;/p&gt;
&lt;p&gt;| Line one
| Line too
| Line tree&lt;/p&gt;
&lt;p&gt;and images can be specified like so:&lt;/p&gt;
&lt;p&gt;Inline math equations go in like so: $\omega = d\phi / dt$. Display
math should get its own line and be put in in double-dollarsigns:&lt;/p&gt;
&lt;p&gt;$$I = \int \rho R^{2} dV$$&lt;/p&gt;
&lt;p&gt;$$
\begin{equation*}
\pi
=3.1415926535
;8979323846;2643383279;5028841971;6939937510;5820974944
;5923078164;0628620899;8628034825;3421170679;\ldots
\end{equation*}
$$&lt;/p&gt;
&lt;p&gt;And note that you can backslash-escape any punctuation characters
which you wish to be displayed literally, ex.: `foo`, *bar*, etc.&lt;/p&gt;
</content:encoded></item><item><title>Include Video in the Posts</title><link>https://lorenzofeng.top/posts/video/</link><guid isPermaLink="true">https://lorenzofeng.top/posts/video/</guid><description>This post demonstrates how to include embedded video in a blog post.</description><pubDate>Tue, 01 Aug 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Just copy the embed code from YouTube or other platforms, and paste it in the markdown file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
title: Include Video in the Post
published: 2023-10-19
// ...
---

&amp;lt;iframe width=&quot;100%&quot; height=&quot;468&quot; src=&quot;https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_&quot; title=&quot;YouTube video player&quot; frameborder=&quot;0&quot; allowfullscreen&amp;gt;&amp;lt;/iframe&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;YouTube&lt;/h2&gt;
&lt;p&gt;&amp;lt;iframe width=&quot;100%&quot; height=&quot;468&quot; src=&quot;https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_&quot; title=&quot;YouTube video player&quot; frameborder=&quot;0&quot; allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&quot; allowfullscreen&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/p&gt;
&lt;h2&gt;Bilibili&lt;/h2&gt;
&lt;p&gt;::bilibili{bvid=&quot;BV1fK4y1s7Qf&quot;}&lt;/p&gt;
&lt;h2&gt;NetEase Music&lt;/h2&gt;
&lt;p&gt;Just use &lt;code&gt;::music{id=&quot;songId&quot;}&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;::music{id=&quot;531065605&quot;}&lt;/p&gt;
&lt;p&gt;Playlist example:&lt;/p&gt;
&lt;p&gt;::music{id=&quot;7232302652&quot; type=&quot;playlist&quot;}&lt;/p&gt;
&lt;h2&gt;Link Preview Card&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;::link{href=&quot;...&quot;}&lt;/code&gt; automatically fetches OG metadata:&lt;/p&gt;
&lt;p&gt;::link{href=&quot;https://github.com&quot;}&lt;/p&gt;
</content:encoded></item></channel></rss>