跳转到内容

博客

VSCode 与 code-server:浏览器端代码编辑方案选型

VSCode 与 code-server:浏览器端代码编辑方案选型

Section titled “VSCode 与 code-server:浏览器端代码编辑方案选型”

在构建浏览器端的代码编辑能力时,开发者面临一个关键选择:使用 VSCode 官方的 code serve-web 功能,还是采用社区驱动的 code-server 方案?这个选择不仅影响技术架构,还关系到许可证合规性和部署灵活性。

其实做技术选型这事儿,跟选择人生道路有点像。你选了一条路,就得一直走下去,想换路的时候,成本可就大了。

在 AI 辅助编程的时代,浏览器端的代码编辑能力变得越来越重要。用户期望在 AI 助手分析完代码后,能够立即在同一个浏览器会话中打开编辑器进行修改,无需切换应用。这种无缝的体验,怎么说呢,就像你想的时候,它就在那里——只是有时候它偏偏不在。

然而,在实现这个功能时,开发者面临一个关键的技术选型:是使用 VSCode 官方的 code serve-web 功能,还是采用社区驱动的 code-server 方案?

这两个方案各有优劣,选择错了可能会在后期带来不少麻烦。比如许可证问题——等到产品上线了才发现许可证不合规,那可就晚了。这跟谈恋爱有点像,一开始没想清楚,到头来才发现两个人的价值观根本不合,那付出的代价就大了。再比如部署方式——某个方案在开发环境跑得好好的,一上容器就各种问题,这种坑谁也不想踩,毕竟踩坑踩多了,人也就麻了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手,在实现浏览器端代码编辑能力时,我们深入研究了这两个方案,最终在架构设计中同时支持两者,但默认优先选择 code-server。

项目地址:github.com/HagiCode-org/site

这是两个方案最根本的区别,也是我们在选型时最先考虑的因素。毕竟选型这事儿,第一步就要想清楚法律风险,不然以后出事了,怪谁呢?

code-server

  • MIT 许可证,完全开源
  • 由 Coder.com 公司维护,社区活跃
  • 可以自由商用、修改和分发
  • 无使用场景限制

VSCode code serve-web

  • 属于 Microsoft VSCode 产品的一部分
  • 使用 Microsoft 的许可证(VS Code 的许可证有商业使用限制)
  • 主要面向个人开发者使用
  • 企业级部署可能需要额外的商业授权考虑

从许可证角度看,code-server 对商业项目更友好。这一点在产品规划阶段就要想清楚,不然等到规模上来了再去迁移,那成本可就大了。毕竟迁移这事儿,说起来容易,做起来难,谁经历过谁知道。

许可证问题解决后,接下来就是部署方式。这直接影响到你的运维成本和架构设计,也间接影响着你每天的心情——部署越简单,心情越好,这道理大家都懂。

code-server

  • 独立的 Node.js 应用,可单独部署
  • 支持多种运行时来源:
    • 直接指定可执行文件路径
    • 系统 PATH 查找
    • NVM Node.js 22.x 环境自动检测
  • 服务器上无需安装 VSCode 桌面版
  • 容器化部署更简单

VSCode code serve-web

  • 必须依赖本地安装的 VSCode CLI
  • 需要本机有可用的 code 命令
  • 系统会过滤掉 VS Code Remote CLI 包装器
  • 主要设计用于本地开发场景

code-server 更适合服务器/容器部署场景。如果你的产品需要跑在 Docker 里,或者用户环境没有 VSCode,那选 code-server 就对了。毕竟简单就是美,复杂了容易出问题,出了问题还得修,修完了还可能引入新问题,这无穷无尽的循环,谁愿意经历呢?

两个方案在功能参数上也有一些差异,虽然不大,但在实际使用中可能会带来一些麻烦。这些细节就像生活中的小摩擦,不多,但多了就让人烦。

特性code-servercode serve-web
公开基路径/ (可配置)/vscode-server (固定)
认证方式--auth 参数,支持多种模式--connection-token / --without-connection-token
数据目录{DataDir}/code-server{DataDir}/vscode-serve-web
遥测默认禁用 --disable-telemetry依赖 VSCode 设置
更新检查可禁用 --disable-update-check依赖 VSCode 设置

这些差异在集成时需要特别注意。比如 URL 路径的不同,意味着前端代码需要做针对性处理。做开发的都知道,这种细节处理起来最费时间,但也没办法,不做就跑不通。

在实现编辑器切换功能时,可用性检测的逻辑也有所不同。这种差异就像人与人之间的相处方式,有的人喜欢直来直去,有的人喜欢含蓄委婉。

code-server

  • 始终作为可见实现返回
  • 即使不可用也会显示,提示 install-required 状态
  • 支持自动检测 NVM Node.js 22.x 环境

code serve-web

  • 只有检测到本机 code CLI 时才可见
  • 如果不可用,前端会自动隐藏此选项
  • 依赖本地 VSCode 安装状态

这种差异直接影响用户体验。code-server 的方式更透明,用户知道有这个选项,只是还没安装;code serve-web 的方式更隐蔽,用户可能都不知道还有这个选择。哪种方式更好?这得看产品定位了,毕竟用户体验这事儿,没有标准答案,只有合适不合适。

经过深入分析,HagiCode 项目采用了双实现架构,在架构层面同时支持两种方案。这倒不是我们技术选型困难症,而是真的有实际需求。毕竟在技术世界里,没有什么绝对正确的选择,只有最适合自己的选择。

// 默认活动实现是 code-server
// 如果保存了显式的 activeImplementation,优先尝试该实现
// 如果所请求实现不可用,解析器会尝试另一个实现
// 如果发生回退,返回 fallbackReason

我们默认选择 code-server,主要考虑是许可证和部署灵活性。但对于有本地 VSCode 环境的用户,code serve-web 也是一个不错的选择。毕竟给用户多一种选择,总归是好的,强迫别人接受单一方案,这事儿我干不出来。

CodeServerImplementationResolver 统一负责:

  • 启动预热时的实现选择
  • 状态读取时的实现选择
  • 项目打开时的实现选择
  • Vault 打开时的实现选择

这种设计让系统可以灵活应对不同场景,用户可以根据自己的环境选择最合适的实现。灵活设计这事儿,前期花的时间多一点,但后期省心,毕竟谁也不想到处改代码。

// localCodeAvailable=false 时,不显示 code serve-web
// localCodeAvailable=true 时,显示 code serve-web 配置

前端根据环境自动显示可用选项,避免用户看到无法使用的功能而困惑。用户困惑了,就来问你,问多了你也烦,烦多了就想改代码,改代码又可能引入 bug,这恶性循环,谁爱经历谁经历。

说了这么多理论,实际部署时要注意什么呢?其实理论说得再好,落地不行也没用,毕竟实践是检验真理的唯一标准。

对于容器化部署,code-server 是更优选择:

# 直接使用 code-server 官方镜像
FROM codercom/code-server:latest
# 或者通过 npm 安装
RUN npm install -g code-server

这样一层就搞定,不需要额外安装 VSCode。简单就是好,复杂了容易出错,这道理放哪儿都适用。

code-server 配置

{
"vscodeServer": {
"enabled": true,
"activeImplementation": "code-server",
"codeServer": {
"host": "0.0.0.0",
"port": 8080,
"executablePath": "",
"authMode": "none"
}
}
}

code serve-web 配置

{
"vscodeServer": {
"enabled": true,
"activeImplementation": "serve-web",
"serveWeb": {
"host": "0.0.0.0",
"port": 8080,
"executablePath": "/usr/local/bin/code"
}
}
}

配置这事儿,第一次麻烦点,配好了后面就省心了。就像生活一样,前期投入多一点,后面日子就好过一点。

code-server

http://localhost:8080/?folder=/path/to/project&vscode-lang=zh-CN

code serve-web

http://localhost:8080/vscode-server/?folder=/path/to/project&tkn=xxx&vscode-lang=zh-CN

注意路径和参数的差异,集成时需要分别处理。细节决定成败,这话一点都不假,少一个参数,可能就打不开页面。

系统支持运行时切换,切换时会自动停止旧实现:

// VsCodeServerManager 自动处理互斥
// 切换 activeImplementation 时,旧实现不会继续后台保活

这种设计让用户可以随时尝试不同实现,找到最适合自己的方案。毕竟适合自己的才是最好的,别人的建议仅供参考,最终还得自己试。

const { settings, runtime } = await getVsCodeServerSettings();
// runtime.activeImplementation: "code-server" | "serve-web"
// runtime.fallbackReason: 切换原因
// runtime.status: "running" | "starting" | "stopped" | "unhealthy"

状态可见,心里才有数。用户在遇到问题时可以快速定位是服务端问题还是自己操作问题。不知道状态的时候,人就容易慌,慌了就容易做出错误判断,这链条一旦启动,就停不下来了。

对比维度code-servercode serve-web推荐
许可证MIT(商用友好)Microsoft(有限制)code-server
部署灵活性独立部署依赖本地 VSCodecode-server
服务器适用性专为服务器设计主要面向本地开发code-server
容器化原生支持需要安装 VSCodecode-server
功能完整性接近桌面版官方完整版code serve-web
维护活跃度社区活跃Microsoft 官方各有优势

推荐策略:优先使用 code-server,在需要完整官方功能且具备本地 VSCode 环境时考虑 code serve-web。

本文分享的方案是 HagiCode 在实际开发中总结出来的。如果你觉得这套方案有价值,说明我们的工程实践还不错——那么 HagiCode 本身也值得关注一下。毕竟分享这事儿,有来有往才有趣,只有输出没有输入,总归不是长久之计。


如果本文对你有帮助:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

如何安装和使用 Hermes:从本地 CLI 到 Feishu 接入快速上手

想安装并开始使用 Hermes,最短路径其实只有三步:

  1. 运行官方安装命令
  2. 在终端里用 hermes 启动 CLI
  3. 如果你想在飞书里继续用,再配置 hermes gateway setup

这篇文章不打算把 Hermes 的所有能力一次讲完,而是先帮你完成最关键的入门闭环:装上、跑起来、开始用,然后再接一个最常见的消息平台场景。

Hermes Agent 是一个既可以在本地终端使用,也可以通过消息平台网关使用的 AI agent。

对大多数开发者来说,它有两个最常见的入口:

  • CLI:在终端里输入 hermes,直接进入交互式界面
  • Messaging Gateway:运行 hermes gateway,再从 Feishu、Telegram、Discord、Slack 等平台和它对话

如果你现在的目标只是快速上手,建议顺序不要反过来,先走这条路线:

  • 先安装 Hermes
  • 先从 CLI 验证是否可用
  • 再决定要不要接消息平台

这样更容易定位问题,也更适合第一次接触 Hermes 的用户。

根据 Hermes README,官方快速安装路径支持这些环境:

  • Linux
  • macOS
  • WSL2
  • Android via Termux

Hermes 当前不支持原生 Windows 直接运行。如果你使用 Windows,推荐先安装 WSL2,再在 WSL2 里执行安装命令。

这点最好在文章开头就说清楚,因为很多安装失败其实不是命令问题,而是运行环境不符合要求。

Hermes README 给出的快速安装命令是:

Terminal window
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash

这条命令会执行官方安装脚本,处理平台相关的初始化流程。

安装结束后,先重新加载 shell 环境。最常见的是:

Terminal window
source ~/.bashrc

如果你用的是 zsh,也可以改成:

Terminal window
source ~/.zshrc

最直接的检查方式就是运行:

Terminal window
hermes

如果你想进一步确认配置和依赖是否正常,可以再执行:

Terminal window
hermes doctor

hermes doctor 适合在这些时候使用:

  • 安装后命令行为不正常
  • 模型配置失败
  • gateway 启动失败
  • 不确定环境依赖是否完整

如果你只是想尽快确认 Hermes 能不能用,最简单的方法就是:

Terminal window
hermes

这会启动 Hermes 的交互式 CLI。对于第一次接触 Hermes 的用户,这也是最推荐的起点,因为你可以先验证最核心的几件事:

  • 命令是否真的可用
  • 当前模型配置是否正常
  • 终端工具链是否工作正常
  • 交互体验是不是你需要的那种方式

这几个命令足够你完成第一轮配置

Section titled “这几个命令足够你完成第一轮配置”

Hermes README 里列出的几条高频命令,基本就构成了第一轮上手路径:

Terminal window
hermes model
hermes tools
hermes config set
hermes setup
hermes update
hermes doctor

如果你不知道它们各自是做什么的,可以先这样记:

  • hermes model:选模型、切模型
  • hermes tools:看和配当前可用工具
  • hermes config set:改具体配置项
  • hermes setup:跑一次完整初始化向导
  • hermes update:更新 Hermes
  • hermes doctor:做故障排查

对新手最实用的顺序通常是:

  1. 先运行 hermes model
  2. 如果你希望一次把常用项配完整,再运行 hermes setup

1. 在终端里把 Hermes 当成日常开发助手

Section titled “1. 在终端里把 Hermes 当成日常开发助手”

CLI 模式适合这些场景:

  • 本地写代码时直接问问题
  • 查项目、改文件、跑命令
  • 做一次性调试或 review
  • 在当前工作目录里持续协作

它最大的优点就是路径最短:不用额外接平台,不用先处理机器人配置,也最适合建立第一轮使用习惯。

如果你希望在飞书、Telegram、Discord 等平台上和 Hermes 对话,就需要使用 messaging gateway。

最常见的入口命令是:

Terminal window
hermes gateway setup
hermes gateway

其中:

  • hermes gateway setup 用来做交互式平台配置
  • hermes gateway 用来启动网关进程

根据官方文档,gateway 是一个统一的后台进程,用来连接你已配置的平台、管理会话,并处理定时任务等功能。

以 Feishu 为例,如何把 Hermes 接入消息平台

Section titled “以 Feishu 为例,如何把 Hermes 接入消息平台”

如果你的日常工作主要在飞书里,那么 Feishu/Lark 会是一个很自然的 Hermes 接入方式。

官方文档对 Feishu/Lark 的推荐入口是:

Terminal window
hermes gateway setup

运行后,在向导中选择 Feishu / Lark 即可。

Feishu 文档给出的两种连接模式是:

  • websocket:推荐
  • webhook:可选

如果 Hermes 跑在你的笔记本、工作站或者私有服务器上,优先使用 websocket 会更简单,因为不需要额外暴露公网回调地址。

如果你手动配置,至少要知道这些变量

Section titled “如果你手动配置,至少要知道这些变量”

如果你不是通过向导配置,而是手动写配置,Feishu 文档里列出的核心变量包括:

Terminal window
FEISHU_APP_ID=cli_xxx
FEISHU_APP_SECRET=***
FEISHU_DOMAIN=feishu
FEISHU_CONNECTION_MODE=websocket
FEISHU_ALLOWED_USERS=ou_xxx,ou_yyy
FEISHU_HOME_CHANNEL=oc_xxx

其中最值得注意的是两项:

  • FEISHU_ALLOWED_USERS:建议配置,避免任何能接触到 bot 的人都可以直接使用它
  • FEISHU_HOME_CHANNEL:可以预先指定一个 home chat,用来接收 cron 结果或默认通知

这一点很容易被忽略:在 Feishu 群聊里,Hermes 默认不是看到每条消息都响应。

官方文档明确说明:

  • 私聊时,Hermes 会响应消息
  • 群聊里,必须显式 @ bot,它才会处理消息

如果你想把某个飞书会话设成 home channel,也可以在聊天里使用:

/set-home

或者提前在配置里写:

Terminal window
FEISHU_HOME_CHANNEL=oc_xxx

不管你是在 CLI 里,还是在消息平台里,先记住下面这些命令就已经够用了:

  • /new/reset:开始新会话
  • /model:查看或切换模型
  • /retry:重试上一轮
  • /undo:撤销上一轮交互
  • /compress:手动压缩上下文
  • /help:查看帮助

如果你主要在消息平台里使用,再额外记住一个:

  • /sethome/set-home:把当前聊天设为 home channel

这些命令覆盖了新手阶段最常见的操作:重开、调整、回退、查看和继续用。

不能。当前官方文档明确说明,原生 Windows 不支持,推荐使用 WSL2。

安装之后输入 hermes 没反应怎么办?

Section titled “安装之后输入 hermes 没反应怎么办?”

建议按下面顺序排查:

  1. 先重新加载 shell,例如 source ~/.bashrc
  2. 再重新运行 hermes
  3. 如果还是异常,执行 hermes doctor

先检查这三项:

  • 你有没有在群里 @ Hermes
  • FEISHU_ALLOWED_USERS 是否限制了当前用户
  • 当前群聊策略是否允许处理群消息

根据官方 Feishu 文档,群聊场景里,显式 @mention 是必要条件。

如果你只是想尽快开始使用 Hermes,最推荐的顺序是:

  1. 先执行安装命令
  2. 先用 hermes 在本地 CLI 里开始
  3. 再用 hermes modelhermes setup 补齐基础配置
  4. 如果你希望在飞书里继续使用,再配置 hermes gateway setup

如果这篇文章作为一个系列的第一篇,它最适合承担的角色不是“把所有高级功能一次讲完”,而是先把用户带进门。

后续更适合继续拆分成这些主题:

  • Hermes Feishu 接入完整指南
  • Hermes 常用 slash commands 指南
  • Hermes gateway 配置与排错指南

如果你准备继续做 Hermes 内容,这篇就可以作为后续文章的起点,并逐步把内部链接体系补起来。

在浏览器中快速编辑代码:VSCode Web 集成实践

在浏览器中快速编辑代码:VSCode Web 集成实践

Section titled “在浏览器中快速编辑代码:VSCode Web 集成实践”

AI 分析完代码后,如何立即在浏览器中打开编辑器进行修改?本文分享 HagiCode 项目中集成 code-server 的实践经验,实现 AI 助手与代码编辑体验的无缝连接。

在 AI 辅助编程的时代,开发者经常需要快速查看和编辑代码。传统的开发流程是:在桌面端 IDE 中打开项目,定位文件,编辑,保存。只是这个流程在某些场景下,总觉得哪里不太对劲。

场景一:远程开发。当使用 HagiCode 这样的 AI 助手时,后端可能运行在远程服务器或容器中,本地无法直接访问项目文件。每次要查看或修改代码,都需要通过 SSH 或其他方式连接,体验割裂。这感觉就像你想见一个人,却隔着一层厚厚的玻璃,能看见却摸不着。

场景二:快速预览。AI 助手分析代码后,用户可能只是想快速浏览某个文件或做小幅修改。启动完整的桌面 IDE 显得重量级,浏览器内的轻量级编辑器更符合”快速查看”的需求。毕竟,谁愿意为了看一眼就大动干戈呢?

场景三:跨设备协作。在不同设备上工作时,浏览器中的编辑器提供了统一的访问入口,无需每台设备都配置开发环境。这倒也省事,毕竟人生苦短,何必重复劳动。

为了解决这些痛点,我们在 HagiCode 项目中集成了 VSCode Web。让 AI 助手与代码编辑体验无缝连接——AI 分析完代码后,用户可以立即在同一个浏览器会话中打开编辑器进行修改,无需切换应用。这种体验,怎么说呢,就像你想的时候,它就在那里。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手,旨在通过自然语言交互提升开发效率。在开发过程中,我们发现用户经常需要在 AI 分析和代码编辑之间快速切换,这促使我们探索如何将编辑器直接集成到浏览器中。

项目地址:github.com/HagiCode-org/site

技术选型:为什么是 code-server?

Section titled “技术选型:为什么是 code-server?”

在众多 VSCode Web 解决方案中,我们选择了 code-server。这个选择,其实也有几个考量:

功能完整。code-server 是 VSCode 的 Web 版本,支持桌面版的大部分功能,包括扩展系统、智能提示、调试等。这意味着用户在浏览器中也能获得接近桌面版的编辑体验。毕竟,谁又愿意在功能上妥协呢?

部署灵活。code-server 可以作为独立服务运行,也支持 Docker 容器化部署,与 HagiCode 的架构契合。我们的后端用 C# 编写,前端是 React,通过 REST API 与 code-server 服务通信。这就像搭积木,每块都有自己的位置。

认证安全。code-server 内置 connection-token 机制,防止未授权访问。每个会话都有唯一的 token,确保只有授权用户才能访问编辑器。安全感这东西,有了才知道重要。

HagiCode 的 VSCode Web 集成采用前后端分离的架构设计。

前端通过 vscodeServerService.ts 封装了与后端的交互:

// 打开项目
export async function openProjectInCodeServer(
id: string,
currentInterfaceLanguage?: string,
): Promise<VsCodeServerLaunchResponseDto>
// 打开 vault
export async function openVaultInCodeServer(
id: string,
path?: string,
currentInterfaceLanguage?: string,
): Promise<VsCodeServerLaunchResponseDto>

这两个方法的区别在于:openProjectInCodeServer 用于打开整个项目,而 openVaultInCodeServer 用于打开 Vault 的特定路径。对于 MonoSpecs 多仓库项目,系统会自动创建工作区文件。分工明确,各自做好自己的事,这就够了。

后端的 VaultAppService.cs 实现了核心逻辑:

public async Task<VsCodeServerLaunchResponseDto> OpenInCodeServerAsync(
string id,
string? relativePath = null,
string? currentInterfaceLanguage = null,
CancellationToken cancellationToken = default)
{
// 1. 获取设置并检查是否启用
var settings = await _vsCodeServerSettingsService.GetResolvedSettingsAsync(cancellationToken);
if (!settings.Enabled) {
throw new BusinessException(VsCodeServerErrorCodes.Disabled, "VSCode Server is disabled.");
}
// 2. 获取 vault 并解析启动目录
var vault = await RequireVaultAsync(id, cancellationToken);
var launchDirectory = ResolveLaunchDirectory(vault, relativePath);
// 3. 确保 code-server 运行并获取运行时信息
var runtime = await _vsCodeServerManager.EnsureStartedAsync(settings, cancellationToken);
// 4. 解析语言设置
var language = _vsCodeServerSettingsService.ResolveLaunchLanguage(
settings.Language,
currentInterfaceLanguage);
// 5. 构建启动 URL
return new VsCodeServerLaunchResponseDto {
LaunchUrl = AppendQueryString(runtime.BaseUrl, new Dictionary<string, string?> {
["folder"] = launchDirectory,
["tkn"] = runtime.ConnectionToken,
["vscode-lang"] = language,
}),
ConnectionToken = runtime.ConnectionToken,
OpenMode = "folder",
Runtime = VsCodeServerSettingsService.MapRuntime(
await _vsCodeServerManager.GetRuntimeSnapshotAsync(cancellationToken)),
};
}

这个方法的职责很清晰:检查设置、解析路径、启动服务、构建 URL。其中 ResolveLaunchDirectory 方法会进行路径安全检查,防止路径穿越攻击。代码就像诗,每一行都有自己的韵律。

后端通过 VsCodeServerManager 管理 code-server 进程:

  • 检查进程状态
  • 自动启动已停止的服务
  • 返回运行时快照(端口、进程 ID、启动时间等)

这种设计让系统可以自动处理 code-server 的生命周期,用户无需手动管理服务进程。毕竟,人生已经够复杂了,能自动化的就自动化吧。

HagiCode 支持多语言界面,code-server 也需要跟随这个设置。系统支持三种语言模式:

  • follow:跟随当前界面语言
  • zh-CN:固定中文
  • en-US:固定英文

通过 URL 参数 vscode-lang 传递给 code-server,确保编辑器语言与 HagiCode 界面保持一致。语言这东西,统一了才舒服。

对于 MonoSpecs 项目(包含多个子仓库的 mono-repo),系统会自动创建 .code-workspace 文件:

private async Task<string> CreateWorkspaceFileAsync(Project project, Guid projectId)
{
var folders = await ResolveWorkspaceFoldersAsync(project.Path);
var workspaceDocument = new {
folders = folders.Select(path => new { path }).ToArray(),
};
// 生成 workspace 文件...
}

这样可以在一个 code-server 实例中同时编辑多个子仓库,对于大型 mono-repo 项目非常实用。多个仓库在一个窗口里,就像多个故事在同一本书里。

HagiCode 前端使用 React + TypeScript,集成 code-server 的步骤倒也不复杂。

在项目卡片中添加 Code Server 按钮:

QuickActionsZone.tsx
<Button
size="sm"
variant="default"
onClick={() => onAction({ type: 'open-code-server' })}
>
<Globe className="h-3 w-3 mr-1" />
<span className="text-xs">{t('project.openCodeServer')}</span>
</Button>

这个按钮会触发打开动作,调用后端 API 获取启动 URL。一个按钮,承载一个动作,简单直接。

const handleAction = async (action: ProjectAction) => {
if (action.type === 'open-code-server') {
const response = await openProjectInCodeServer(project.id, i18n.language);
window.open(response.launchUrl, '_blank', 'noopener,noreferrer');
}
};

使用 window.open 在新标签页中打开 code-server,noopener,noreferrer 参数确保安全性。安全这东西,再怎么小心都不为过。

在 Vault 列表中添加类似的编辑按钮:

const handleEditVault = async (vault: VaultItemDto) => {
const response = await openVaultInCodeServer(vault.id);
window.open(response.launchUrl, '_blank', 'noopener,noreferrer');
};

项目和 Vault 使用相同的打开方式,保持了交互的一致性。一致性的重要,不亚于功能本身。

code-server 的 URL 格式,其实也有点讲究:

文件夹模式

http://{host}:{port}/?folder={path}&tkn={token}&vscode-lang={lang}

工作区模式

http://{host}:{port}/?workspace={workspacePath}&tkn={token}&vscode-lang={lang}

其中 tkn 是连接 token,每次启动 code-server 时自动生成,确保访问安全。vscode-lang 参数控制编辑器界面语言。这些参数各有各的用处,缺一不可。

用户与 HagiCode 对话,AI 分析项目代码并发现潜在问题。用户点击 “Open in Code Server” 按钮直接在浏览器中打开编辑器,查看问题文件并修复,然后返回 HagiCode 继续对话。整个流程在浏览器中完成,无需切换应用。这种感觉,怎么说呢,就像流水一样顺畅。

用户创建了学习某个开源项目的 Vault,想在 docs/ 目录添加学习笔记。通过 code-server 直接在浏览器中编辑 Markdown 文件,保存后 HagiCode 可以同步读取更新的笔记内容。这对于构建个人知识库非常有用。知识这东西,越积累越有价值。

MonoSpecs 项目包含多个子仓库,code-server 自动创建多文件夹工作区。用户在浏览器中可以同时编辑多个仓库的代码,修改后提交到各自的 Git 仓库。这种工作方式特别适合需要跨仓库修改的场景。多个仓库一起改,就像同时处理多件事情,需要点技巧。

在实现 code-server 集成时,安全性是需要重点关注的问题。毕竟,安全这种事,出了问题就晚了。

connection-token 是随机生成的,不应泄露。建议在 HTTPS 环境下使用,防止 token 被中间人截获。敏感信息,还是保护好为妙。

后端实现了路径穿越检查:

private static string ResolveLaunchDirectory(VaultRegistryEntry vault, string? relativePath)
{
var vaultRoot = EnsureTrailingSeparator(Path.GetFullPath(vault.PhysicalPath));
var combinedPath = Path.GetFullPath(Path.Combine(vaultRoot, relativePath ?? "."));
if (!combinedPath.StartsWith(vaultRoot, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode, "Relative path traversal detected.");
}
return combinedPath;
}

这段代码确保用户无法通过 ../ 等方式访问 vault 目录之外的文件。边界检查这种事,做总比不做强。

code-server 进程以适当的用户权限运行,避免访问系统敏感文件。建议使用专用用户运行 code-server 服务。权限控制,该有的还是要有。

code-server 会消耗服务器资源,以下是一些优化建议:

  • 监控 CPU/内存使用,必要时调整资源限制
  • 大型项目可能需要增加超时时间
  • 实现会话超时自动清理,释放资源
  • 考虑使用缓存减少重复计算

HagiCode 提供了运行时状态监控 API,前端可以通过 getVsCodeServerSettings() 获取当前状态:

const { settings, runtime } = await getVsCodeServerSettings();
// runtime.status: 'disabled' | 'stopped' | 'starting' | 'running' | 'unhealthy'
// runtime.baseUrl: "http://localhost:8080"
// runtime.processId: 12345

这个设计让用户可以清楚了解 code-server 的健康状态,在出现问题时快速定位。状态可见,心里才有数。

在实现过程中,我们发现了一些影响用户体验的细节,值得特别关注:

首次打开 code-server 可能需要等待启动,这个时间可能从几秒到半分钟不等。建议在前端显示加载状态,让用户知道系统正在处理。等待这事儿,有反馈就好。

浏览器可能会阻止弹窗,需要提示用户手动允许。HagiCode 在首次打开时会显示引导信息,告诉用户如何设置浏览器权限。用户体验,就是在这些细节里体现的。

建议显示运行时状态(启动中/运行中/错误),这样用户在遇到问题时可以知道是服务端问题还是自己操作问题。知道问题出在哪里,至少心里有底。

code-server 的配置倒也不复杂:

{
"vscodeServer": {
"enabled": true,
"host": "0.0.0.0",
"port": 8080,
"language": "follow"
}
}

enabled 控制功能开关,hostport 指定监听地址,language 设置语言模式。这些配置可以通过 UI 界面修改,实时生效。简单的东西,往往最好用。

HagiCode 的 VSCode Web 集成提供了一个优雅的解决方案:让 AI 助手与代码编辑体验无缝连接。通过在浏览器中集成 code-server,用户可以快速响应 AI 的分析结果,在同一个浏览器会话中完成从分析到编辑的完整流程。

这套方案的几个关键优势:一是统一体验,项目和 Vault 使用相同的打开方式;二是多仓库支持,MonoSpecs 项目自动创建工作区;三是安全可控,运行时状态监控和路径安全检查。

本文分享的方案是 HagiCode 在实际开发中总结出来的。如果你觉得这套方案有价值,说明我们的工程实践还不错——那么 HagiCode 本身也值得关注一下。毕竟,好东西值得被更多人看见。

  • HagiCode GitHub:github.com/HagiCode-org/site
  • HagiCode 官网:hagicode.com
  • code-server 官网:coder.com/code-server
  • 相关代码文件:
    • repos/web/src/services/vscodeServerService.ts
    • repos/hagicode-core/src/PCode.Application/Services/VaultAppService.cs
    • repos/hagicode-core/src/PCode.Application/ProjectAppService.VsCodeServer.cs

如果本文对你有帮助:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

边框灯光环绕动画特效实现指南

边框灯光环绕动画特效实现指南

Section titled “边框灯光环绕动画特效实现指南”

那个让用户一眼就注意到的重要元素,到底是怎么用纯 CSS 做出来的?其实也不难,就是绕了个弯子罢了。这篇文章带你从零开始实现边框灯光环绕动画,也顺带聊聊我们在 HagiCode 项目里踩过的那些坑。

做前端的同学应该都有过这样的经历:产品经理跑过来,脸上挂着那种”这需求很简单”的表情——“这个正在运行的任务,能不能加个特效让用户一眼就能看到?”

你说行啊,加个边框变色呗。结果对方摇摇头,眼神里透着一种”你不懂”的意味:“不够明显,要那种灯光绕着边框跑的效果,跟科幻电影里一样。”

这时候你可能就会犯嘀咕:这玩意儿怎么实现?用 Canvas?用 SVG?还是说 CSS 能搞?毕竟谁也不想承认自己不会嘛。

其实啊,边框灯光环绕动画在现代 Web 应用中特别常见,主要用在这么几个场景:

  • 状态指示:标记正在进行的任务或活跃的项目
  • 视觉焦点:突出显示重要的内容区域
  • 品牌增强:营造科技感和现代感的视觉体验
  • 节日主题:配合特殊节日创建庆祝氛围

我们做 HagiCode 的时候就遇到过这个需求——用户需要一眼看出哪些会话正在运行、哪些提案正在处理中。试了好几种方案,有些路好走一点,有些路稍微曲折一点罢了,最后沉淀出了一套还算成熟的实现思路。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手项目,在界面中大量使用边框灯光动画来指示各种运行状态。比如会话列表的运行状态、提案流程图的状态过渡、吞吐量指示器的强度显示等等。

其实这些效果说起来也不复杂,就是做的时候踩了不少坑。如果你想看看实际效果,可以访问我们的 GitHub 仓库 或者直接去 官网 了解一下,毕竟能用的才是最好的嘛。

经过对 HagiCode 代码的分析,我们总结出了下面几种核心的实现模式,每种都有它适用的场景,或者说,每种都有它存在的意义罢了。

1. Conic Gradient 旋转光晕(最常用)

Section titled “1. Conic Gradient 旋转光晕(最常用)”

这是最经典的边框灯光环绕实现方式,核心思路是用 CSS 的 conic-gradient 创建一个圆锥渐变,然后让它转起来。就像夜晚的路灯,一直在那里转啊转的。

关键要素:

  • ::before 伪元素创建光晕层
  • conic-gradient 定义渐变色分布
  • ::after 伪元素遮罩中心区域(可选)
  • @keyframes 实现旋转动画

这个适用于列表项的状态指示,在元素的一侧创建发光的细线条就行,不用整个边框都动。毕竟有时候,一点光就够了,不需要照亮整个世界。

关键要素:

  • 绝对定位的细线元素
  • box-shadow 创建发光效果
  • scaleopacity 实现呼吸动画

如果不需要环绕效果,只是想要个柔和的背景光晕,那用多层 box-shadow 叠加就够了。有些东西,简单点反而更好。

这个容易被忽略,但特别重要。所有动画都应该考虑 prefers-reduced-motion 媒体查询,给不喜欢动画的用户提供一个静态替代方案。毕竟不是所有人都喜欢动来动去的,尊重每个人的选择才是对的。

方案一:Conic Gradient 旋转边框(推荐)

Section titled “方案一:Conic Gradient 旋转边框(推荐)”

这是最完整的环绕灯光效果实现,也是 HagiCode 里用得最多的方案。毕竟,如果一样东西好用,为什么还要换呢?

/* 父容器 */
.glow-border-container {
position: relative;
overflow: hidden;
}
/* 旋转的光晕层 */
.glow-border-container::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: conic-gradient(
transparent 0deg,
rgba(59, 130, 246, 0.6) 60deg,
rgba(59, 130, 246, 0.3) 120deg,
rgba(59, 130, 246, 0.6) 180deg,
transparent 240deg
);
animation: border-rotate 3s linear infinite;
z-index: -1;
}
/* 遮罩层(可选,用于创建空心边框效果) */
.glow-border-container::after {
content: '';
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
@keyframes border-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

这个方案的原理挺简单的:创建一个比父容器大的伪元素,上面画一个圆锥渐变,然后让它不停旋转。父容器设置 overflow: hidden,所以只能看到边框那一部分的光在转。就像我们在窗子里看外面的路灯,只能看到它经过的那一小段罢了。

如果你不需要那么复杂的效果,HagiCode 里有个更轻量的工具类实现。毕竟简单点,有时候反而更好。

/* 旋转光边框工具类 */
.running-light-border {
position: absolute;
inset: -2px;
background: conic-gradient(
from 0deg,
transparent 0deg 270deg,
var(--theme-running-color) 270deg 360deg
);
border-radius: inherit;
animation: lightRayRotate 3s linear infinite;
will-change: transform;
z-index: 0;
}
@keyframes lightRayRotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 无障碍支持 */
@media (prefers-reduced-motion: reduce) {
.running-light-border {
animation: none;
}
}

注意这里的 will-change: transform,这是告诉浏览器”这个元素要一直变”,浏览器就会提前做些优化,动画会更流畅。毕竟提前准备,总比临时抱佛脚强嘛。

列表项的状态指示用这个特别合适,HagiCode 的会话列表就是用的这个方案。一条细线,却能在众多项目中脱颖而出,这不也是一种生活哲学吗?

.side-glow {
position: relative;
isolation: isolate;
}
.side-glow::before {
content: '';
position: absolute;
left: 0;
top: 14px;
bottom: 14px;
width: 1px;
border-radius: 999px;
background: var(--theme-running-color);
box-shadow:
0 0 16px var(--theme-running-color),
0 0 28px var(--theme-running-color);
z-index: 1;
pointer-events: none;
animation: sidePulse 2.6s ease-in-out infinite;
}
.side-glow > * {
position: relative;
z-index: 2;
}
@keyframes sidePulse {
0%, 100% {
opacity: 0.55;
transform: scaleY(0.96);
}
50% {
opacity: 0.95;
transform: scaleY(1);
}
}

这里用了 isolation: isolate 创建一个新的层叠上下文,然后用 z-index 控制各层的显示顺序。pointer-events: none 也很关键,不然那个伪元素会挡住用户的点击操作。就像有些东西,好看是好看,但是不能碍事才行。

如果你项目里用 React,可以封装一个组件来处理这些逻辑,特别是无障碍访问的部分。毕竟代码写一次,用很多次,这才是我们想要的嘛。

import React from 'react';
import { useReducedMotion } from 'framer-motion';
import styles from './GlowBorder.module.css';
interface GlowBorderProps {
isActive: boolean;
children: React.ReactNode;
className?: string;
}
export const GlowBorder = React.memo<GlowBorderProps>(
({ isActive, children, className = '' }) => {
const prefersReducedMotion = useReducedMotion();
if (!isActive) {
return <div className={className}>{children}</div>;
}
if (prefersReducedMotion) {
return (
<div className={`${styles.glowStatic} ${className}`}>
{children}
</div>
);
}
return (
<div className={`${styles.glowAnimated} ${className}`}>
{children}
</div>
);
}
);

对应的 CSS 模块:

GlowBorder.module.css
/* 动画版本 */
.glowAnimated {
position: relative;
overflow: hidden;
}
.glowAnimated::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: conic-gradient(
from 0deg,
transparent,
rgba(59, 130, 246, 0.6),
transparent,
rgba(59, 130, 246, 0.6),
transparent
);
animation: rotateGlow 3s linear infinite;
z-index: -1;
}
.glowAnimated::after {
content: '';
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
/* 静态版本(无障碍) */
.glowStatic {
position: relative;
border: 1px solid rgba(59, 130, 246, 0.5);
box-shadow: 0 0 15px rgba(59, 130, 246, 0.3);
}
@keyframes rotateGlow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

framer-motionuseReducedMotion hook 会自动检测用户的系统偏好,如果用户开启了”减弱动态效果”,就会返回 true,这时候就显示静态版本。毕竟,尊重用户的选择比强行展示更重要。

下面这些是我们在做 HagiCode 时踩过坑、总结出来的经验。其实也就是些碎碎念罢了,希望能帮到后来的你。

用 CSS 变量实现多主题支持特别方便。毕竟谁也不想每次切换主题都要改一堆代码呢?

:root {
--glow-color-light: rgb(16, 185, 129);
--glow-color-dark: rgb(16, 185, 129);
--theme-glow-color: var(--glow-color-light);
}
html.dark {
--theme-glow-color: var(--glow-color-dark);
}
/* 使用 */
.glow-effect {
background: var(--theme-glow-color);
box-shadow: 0 0 20px var(--theme-glow-color);
}

这样切换主题的时候只需要改一下 html 标签的 class,所有动画颜色都会自动更新。一套代码,两种风格,这不就是我们追求的吗?

使用 will-change 提示浏览器优化:

.animated-glow {
will-change: transform, opacity;
}

提前告诉浏览器,它就会帮你做些优化。就像生活中的很多事情,提前准备总是好的。

避免在大面积元素上使用复杂的 box-shadow:

/* 不好 - 大面积元素上使用模糊阴影 */
.large-card {
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5);
}
/* 更好 - 使用伪元素限制发光区域 */
.large-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 20px var(--glow-color);
pointer-events: none;
}

我们在 HagiCode 里测试过,在大卡片上直接加模糊阴影会让滚动帧率掉到 30fps 以下,改用伪元素后就稳稳 60fps 了。这种体验上的差异,用户是能感觉到的。

这个真的不能省,有些用户会觉得动画很晕或者很吵,尊重他们的选择是做产品的基本素养。毕竟美的事物不必强加于人嘛。

CSS 媒体查询:

@media (prefers-reduced-motion: reduce) {
.glow-animation {
animation: none;
}
.glow-animation::before {
/* 提供静态替代方案 */
opacity: 1;
}
}

React 中检测用户偏好:

import { useReducedMotion } from 'framer-motion';
const Component = () => {
const prefersReducedMotion = useReducedMotion();
return (
<div className={prefersReducedMotion ? 'static-glow' : 'animated-glow'}>
Content
</div>
);
};

HagiCode 里的 Token 吞吐量指示器会根据实时吞吐量显示不同颜色的灯光,这个是动态实现的。毕竟不同的状态,应该有不一样的表达方式。

const colors = [
null, // Level 0 - no color
'#3b82f6', // Level 1 - Blue
'#34d399', // Level 2 - Emerald
'#facc15', // Level 3 - Yellow
'#fbbf24', // Level 4 - Amber
'#f97316', // Level 5 - Orange
'#22d3ee', // Level 6 - Cyan
'#d946ef', // Level 7 - Fuchsia
'#f43f5e', // Level 8 - Rose
];
const IntensityGlow = ({ intensity }) => {
const glowColor = colors[Math.min(intensity, colors.length - 1)];
return (
<div
className="glow-effect"
style={{
'--glow-color': glowColor,
opacity: 0.6 + (intensity * 0.08),
}}
/>
);
};

有些细节还是要注意的,不然踩了坑才知道就晚了。

注意事项说明
z-index 管理光晕层应设置合适的 z-index,避免影响内容交互
pointer-events光晕伪元素应设置 pointer-events: none
边界溢出父容器需要设置 overflow: hidden 或调整伪元素尺寸
性能影响复杂动画在移动设备上可能影响性能,需要测试
深色模式确保发光颜色在深色背景下清晰可见
主题切换使用 CSS 变量确保主题切换时动画颜色正确更新

伪元素在开发者工具里有时候不太好找,可以临时加个边框来看看位置。

/* 临时显示伪元素边界用于调试 */
.glow-effect::before {
/* debug: border: 1px solid red; */
}

调好位置之后记得把这行注释掉或者删掉,不然生产环境会很尴尬。有些东西,还是留在开发环境比较好。

边框灯光环绕动画说难不难,说简单也不简单。核心就是 conic-gradient 加旋转,但要做到性能好、可维护、无障碍友好,还是有不少细节要注意的。

HagiCode 在这个上面踩了不少坑,也总结出了一些最佳实践。其实做项目就是这样,一遍遍试错,一遍遍改进。如果你在做类似的需求,希望这篇文章能帮你少走点弯路。

毕竟,有些东西,还是要亲自实践才知道深浅。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

用 Vault 系统构建 AI 时代的跨项目知识库

用 Vault 系统构建 AI 时代的跨项目知识库

Section titled “用 Vault 系统构建 AI 时代的跨项目知识库”

临摹项目学习法正在成为主流,只是学习资料分散、上下文断裂的痛点让 AI 助手难以发挥最大价值。本文介绍 HagiCode 项目的 Vault 系统设计——通过统一的存储抽象层,让 AI 助手能够理解和访问所有学习资源,实现真正的跨项目知识复用。

其实,在 AI 时代,我们学习新技术的方式正在悄然改变。传统的读书、看视频固然重要,但”临摹项目”——深入研究和学习优秀开源项目的代码、架构和设计模式——确实越来越高效。直接运行和修改高质量的开源项目,能让你最快理解真实世界的工程实践。

只是这种方式也带来了新的挑战。

学习资料太分散。笔记可能在 Obsidian 里,代码仓库散落在各个文件夹,AI 助手的对话历史又是一个独立的数据孤岛。每次需要 AI 帮助分析某个项目时,都得手动复制代码片段、整理上下文,过程相当繁琐。

上下文经常断掉。AI 助手无法直接访问本地学习资源,每次对话都得重新提供背景信息。临摹的代码仓库更新快,手动同步容易出错。更糟的是,多个学习项目之间难以共享知识——在 A 项目中学到的设计模式,AI 处理 B 项目时完全不知道。

这些问题的本质是”数据孤岛”。如果能有一个统一的存储抽象层,让 AI 助手能够理解和访问所有学习资源,问题就迎刃而解了。

为了解决这些痛点,我们在开发 HagiCode 时做了一个关键的设计决策:构建一个 Vault 系统作为统一的知识存储抽象层。这个决定带来的变化,可能比想象的还要大——稍后具体说。

本文分享的方案来自在 HagiCode 项目中的实践经验。HagiCode 是一个基于 OpenSpec 工作流的 AI 代码助手,它的核心理念是让 AI 不仅会”说”,更会”做”——能够直接操作代码仓库、执行命令、运行测试。GitHub:github.com/HagiCode-org/site

在开发过程中,我们发现 AI 助手需要频繁访问用户的各类学习资源:代码仓库、笔记文档、配置文件等。如果每次都要用户手动提供,体验就太糟糕了。这促使设计了 Vault 系统。

HagiCode 的 Vault 系统支持四种类型,分别对应不同的使用场景:

类型用途典型场景
folder通用文件夹类型临时学习资料、草稿
coderef专门用于临摹代码项目系统化学习某个开源项目
obsidian与 Obsidian 笔记软件集成现有笔记库的复用
system-managed系统自动管理项目配置、提示词模板等

其中 coderef 类型是 HagiCode 中最常用的,它为临摹代码项目提供了标准化的目录结构和 AI 可读的元数据描述。为什么要专门设计这个类型?因为临摹一个开源项目不是简单的”下载代码”,需要同时管理代码本身、学习笔记、配置文件等多种内容,coderef 把这些都规范好了。

Vault 的注册表以 JSON 格式持久化存储到文件系统:

_registryFilePath = Path.Combine(absoluteDataDir, "personal-data", "vaults", "registry.json");

这个设计看似简单,实则经过深思熟虑:

简单可靠。JSON 格式人类可读,便于调试和手动修改。当系统出现问题时,可以直接打开文件查看状态,甚至手动修复——这在开发阶段特别有用。

降低依赖。文件系统存储避免了数据库的复杂性。不需要额外安装和配置数据库服务,降低了系统复杂度和维护成本。

并发安全。使用 SemaphoreSlim 确保多线程安全。在 AI 代码助手的场景下,可能会有多个操作同时访问 vault 注册表,需要做好并发控制。

系统的核心能力在于能够自动将 vault 信息注入到 AI 提案的上下文中:

export function buildTargetVaultsText(
vaults: VaultForText[],
template: VaultPromptTemplate = DEFAULT_VAULT_PROMPT_TEMPLATE,
): string {
const readOnlyVaults = vaults.filter((vault) => vault.accessType === 'read');
const editableVaults = vaults.filter((vault) => vault.accessType === 'write');
const sections = [
buildVaultSection(readOnlyVaults, template.reference),
buildVaultSection(editableVaults, template.editable),
].filter(Boolean);
return `\n\n### ${template.heading}\n\n${sections.join('\n')}`;
}

这样 AI 助手就能自动理解可用的学习资源,无需用户每次手动提供上下文。这个设计让 HagiCode 的体验变得特别自然——告诉 AI “帮我分析 React 的并发渲染”,AI 就能自动找到之前注册的 React 学习 vault,而不是一遍遍贴代码。

系统将 vault 分为两种访问类型:

  • reference(只读):AI 仅用于分析和理解,不能修改内容
  • editable(可编辑):AI 可以根据任务需要修改内容

这种区分让 AI 知道哪些内容是”只读参考”,哪些是”可以动手改的”,避免了误操作风险。比如注册了一个开源项目的 vault 作为学习材料,肯定不希望 AI 随手修改里面的代码——那就标记为 reference。但如果是自己的项目 vault,就可以标记为 editable,让 AI 帮着改代码。

对于 coderef 类型的 vault,系统提供了一套标准化的目录结构:

my-coderef-vault/
├── index.yaml # vault 元数据描述
├── AGENTS.md # AI 助手的操作指南
├── docs/ # 存放学习笔记和文档
└── repos/ # 通过 Git 子模块管理临摹的代码仓库

这个结构的设计理念是什么?

docs/ 存放学习笔记,用 Markdown 格式记录对代码的理解、架构分析、踩坑经验。这些笔记不仅自己看,AI 也能读懂——在处理相关任务时会自动参考。

repos/ 通过 Git 子模块管理临摹的仓库,而不是直接复制代码。这样做有两个好处:一是保持与上游同步,一个 git submodule update 就能拿到最新代码;二是节省空间,多个 vault 可以引用同一个仓库的不同版本。

index.yaml 包含 vault 的元数据,让 AI 助手快速理解用途和内容。相当于给 vault 写了个”自我介绍”,AI 第一次见到就知道这是干嘛的。

AGENTS.md 是专门写给 AI 助手看的指南,说明如何处理 vault 中的内容。可以在这里告诉 AI:“分析这个项目时重点关注性能优化相关的代码”或者”不要修改测试文件”。

创建一个 CodeRef vault 很简单:

const createCodeRefVault = async () => {
const response = await VaultService.postApiVaults({
requestBody: {
name: "React Learning Vault",
type: "coderef",
physicalPath: "/Users/developer/vaults/react-learning",
gitUrl: "https://github.com/facebook/react.git"
}
});
// 系统会自动:
// 1. 克隆 React 仓库到 vault/repos/react
// 2. 创建 docs/ 目录用于笔记
// 3. 生成 index.yaml 元数据
// 4. 创建 AGENTS.md 指南文件
return response;
};

然后在 AI 提案中引用这个 vault:

const proposal = composeProposalChiefComplaint({
chiefComplaint: "帮我分析 React 的并发渲染机制",
repositories: [
{ id: "react", gitUrl: "https://github.com/facebook/react.git" }
],
vaults: [
{
id: "react-learning",
name: "React Learning Vault",
type: "coderef",
physicalPath: "/vaults/react-learning",
accessType: "read" // AI 只能读取,不能修改
}
],
quickRequestText: "重点关注 fiber 架构和 scheduler 实现"
});

场景一:系统化学习开源项目

创建一个 CodeRef vault,通过 Git 子模块管理目标仓库,在 docs/ 目录记录学习笔记。AI 可以同时访问代码和笔记,提供更精准的分析。在学习某个模块时写的笔记,AI 后续分析相关代码时会自动参考——就像有个”助手”记住了之前的思考。

场景二:复用 Obsidian 笔记库

如果已经在用 Obsidian 管理笔记,直接把现有的 vault 注册到 HagiCode 中就行。AI 可以直接访问知识库,无需手动复制粘贴。这个功能特别实用,很多人都有积累多年的笔记库,接入之后 AI 就能”读”懂知识体系。

场景三:跨项目知识复用

多个 AI 提案可以引用同一个 vault,实现知识的跨项目复用。比如创建了一个”设计模式学习 vault”,里面记录了各种设计模式的笔记和代码示例。无论在分析哪个项目,AI 都能参考这个 vault 中的内容——知识不用重复积累。

系统严格校验路径,防止路径穿越攻击:

private static string ResolveFilePath(string vaultRoot, string relativePath)
{
var rootPath = EnsureTrailingSeparator(Path.GetFullPath(vaultRoot));
var combinedPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
if (!combinedPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode,
"Vault file paths must stay inside the registered vault root.");
}
return combinedPath;
}

这确保了所有文件操作都在 vault 的根目录范围内,防止恶意路径访问。安全这块不能马虎,AI 助手要操作文件系统,必须把边界划清楚。

使用 HagiCode Vault 系统时,有几点需要特别注意:

  1. 路径安全:确保自定义路径在允许的范围内,否则系统会拒绝操作。这是为了防止误操作和潜在的安全风险。

  2. Git 子模块管理:CodeRef vault 推荐使用 Git 子模块而非直接复制代码。好处前面说过——保持同步、节省空间。只是子模块有自己的使用方式,第一次使用可能需要熟悉一下。

  3. 文件预览限制:系统限制文件大小(256KB)和数量(500个),超大文件需分批处理。这个限制是为了性能考虑,如果遇到超大文件,可以手动拆分或者用其他方式处理。

  4. 诊断信息:创建 vault 会返回诊断信息,失败时可用于调试。遇到问题时先看诊断信息,大部分情况下都能找到线索。

HagiCode 的 Vault 系统本质上是在解决一个简单但深刻的问题:如何让 AI 助手理解和使用本地知识资源。

通过统一的存储抽象层、标准化的目录结构、自动化的上下文注入,实现了”一次注册,处处复用”的知识管理方式。创建一个 vault 后,无论是学习笔记、代码仓库还是文档资料,AI 都能自动访问和理解。

这种设计带来的体验提升是明显的。不再需要手动复制代码片段、重复解释背景信息——AI 助手就像一个真正了解项目情况的同事,能够基于已有知识提供更有价值的帮助。

本文分享的 Vault 系统,正是开发 HagiCode 过程中实际踩坑、实际优化出来的方案。如果觉得这套设计有价值,说明工程实力还不错——那么 HagiCode 本身也值得关注一下。

如果本文对你有帮助:

公测已开始,欢迎安装体验。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

在 Web 界面直接编辑 DESIGN.md:从思路到实现

在 Web 界面直接编辑 DESIGN.md:从思路到实现

Section titled “在 Web 界面直接编辑 DESIGN.md:从思路到实现”

在 MonoSpecs 项目管理系统中,DESIGN.md 承载着项目的架构设计和技术决策。但传统的编辑方式要求用户必须切换到外部编辑器,这种割裂的流程,怎么说呢,就像在读一首诗的时候突然被打断了——灵感没了,心情也没了。本文分享了我们在 HagiCode 项目中实践的解决方案:在 Web 界面直接编辑 DESIGN.md,并支持从线上设计站点导入模板。毕竟,谁不喜欢一气呵成的感觉呢?

DESIGN.md 作为项目设计文档的核心载体,承载着架构设计、技术决策和实现指导等关键信息。然而,传统的编辑方式要求用户必须切换到外部编辑器(如 VS Code),手动定位物理路径后再进行编辑。这过程说起来也不算复杂,只是反复几次之后,人也就乏了。

具体问题体现在以下几个方面:

  • 流程割裂:用户需要在 Web 管理界面和本地编辑器之间频繁切换,破坏了工作流连贯性——就像听歌的时候突然断网了,节奏全乱了。
  • 复用困难:设计站点已经发布了丰富的设计模板库,但无法直接集成到项目编辑流程中。明明有好东西,就是用不上,这感觉确实有点遗憾。
  • 体验缺失:缺少”预览-选择-导入”的闭环,用户必须手动复制粘贴,增加了出错风险。手动操作的次数多了,出错的机会自然也多了。
  • 协作障碍:设计文档与代码实现的同步维护变得高摩擦,阻碍团队协作效率。团队协作本就不易,何必再添这些阻力呢?

为了解决这些痛点,我们决定在 Web 界面中实现 DESIGN.md 的直接编辑能力,并支持从线上设计站点一键导入模板。这也不算是什么惊天动地的决策,只是想让开发体验更顺畅一点罢了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 驱动的代码助手项目,在开发过程中,我们需要频繁维护项目的设计文档。为了让团队能够更高效地协作,我们探索并实现了这套在线编辑和导入方案。其实也没什么特别的,只是遇到了问题,想办法解决而已。

该解决方案采用前后端分离的同源代理架构,主要由以下几个层次构成。这种架构的设计,说起来也不过是”各司其职”四个字罢了:

1. 前端编辑器层

repos/web/src/components/project/DesignMdManagementDrawer.tsx
// 核心组件:DesignMdManagementDrawer
// 功能:承载编辑、保存、版本冲突检测、导入流程

2. 后端服务层

ProjectAppService.DesignMd
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMd.cs
// 功能:路径解析、文件读写、版本管理

3. 同源代理层

ProjectAppService.DesignMdSiteIndex
// 位置:repos/hagicode-core/src/PCode.Application/ProjectAppService.DesignMdSiteIndex.cs
// 功能:代理设计站点资源、预览图缓存、安全校验

采用单一全局抽屉而非局部弹层,通过 layoutSlice 管理状态,实现了跨视图(classic/kanban)的一致体验。这种方式确保用户无论在哪个视图中打开编辑器,都能获得统一的交互体验。毕竟,一致的体验能让用户感觉更自在,不会因为换个视图就迷失方向。

将 DESIGN.md 相关接口挂在 ProjectController 下,复用现有项目权限边界,避免了新增独立控制器的复杂度。这样设计的好处是权限管理更清晰,也符合 RESTful 的资源组织原则。有时候,复用比重新创建更有意义,不是吗?

基于文件系统 LastWriteTimeUtc 派生 opaque version,实现了轻量级的乐观并发控制。当多个用户同时编辑同一文件时,系统能够检测到冲突并提示用户刷新。这种设计既不阻塞用户的编辑操作,又能保证数据的一致性——就像人际交往中的边界感,既不过分疏离,也不越界。

通过 IHttpClientFactory 代理外部设计站点资源,避免了跨域问题和 SSRF 风险。这种设计既保证了安全性,又简化了前端调用。安全这件事,做再多也不为过,毕竟数据安全就像健康,失去了才后悔就晚了。

后端主要负责路径解析、文件读写和版本管理。这些工作虽然基础,但必不可少,就像房子的地基一样:

// 路径解析与安全校验
private Task<string> ResolveDesignDocumentDirectoryAsync(string projectPath, string? repositoryPath)
{
if (string.IsNullOrWhiteSpace(repositoryPath))
{
return Task.FromResult(Path.GetFullPath(projectPath));
}
return ValidateSubPathAsync(projectPath, repositoryPath);
}
// 版本号生成(基于文件系统时间戳和大小)
private static string BuildDesignDocumentVersion(string path)
{
var fileInfo = new FileInfo(path);
fileInfo.Refresh();
return string.Create(
CultureInfo.InvariantCulture,
$"{fileInfo.LastWriteTimeUtc.Ticks:x}-{fileInfo.Length:x}");
}

版本号的设计其实也挺有意思的,我们用文件的最后修改时间和大小来生成一个唯一的版本标识。这样既轻量又可靠,不需要维护额外的版本数据库。简单的东西,往往更有效,不是吗?

前端实现了脏状态检测和保存逻辑。这种设计让用户随时知道自己的修改是否已保存,减少”万一丢失了怎么办”的焦虑:

// 脏状态检测与保存逻辑
const [draft, setDraft] = useState('');
const [savedDraft, setSavedDraft] = useState('');
const isDirty = draft !== savedDraft;
const handleSave = useCallback(async () => {
const result = await saveProjectDesignMdDocument({
...activeTarget,
content: draft,
expectedVersion: document.version, // 乐观并发控制
});
setSavedDraft(draft); // 更新已保存状态
}, [activeTarget, document, draft]);

这个实现中,我们维护了两个状态:draft 是当前编辑的内容,savedDraft 是已保存的内容。通过比较两者来判断是否有未保存的修改。这种设计虽然简单,但能给人安心感,毕竟谁愿意辛辛苦苦写的东西突然消失呢?

repos/index/
└── src/data/public/design.json # 设计模板索引
repos/awesome-design-md-site/
├── vendor/awesome-design-md/ # 上游设计模板
│ └── design-md/
│ ├── clickhouse/
│ │ └── DESIGN.md
│ ├── linear/
│ │ └── DESIGN.md
│ └── ...
└── src/lib/content/
└── awesomeDesignCatalog.ts # 内容管线

设计站点的索引文件定义了所有可用的模板。有了这个索引,用户就能像在餐厅点菜一样,轻松选择自己想要的模板:

{
"entries": [
{
"slug": "linear.app",
"title": "Linear Inspired Design System",
"summary": "AI 产品 / 深色感",
"detailUrl": "/designs/linear.app/",
"designDownloadUrl": "/designs/linear.app/DESIGN.md",
"previewLightImageUrl": "...",
"previewDarkImageUrl": "..."
}
]
}

每个条目包含了模板的基本信息和下载链接。后端会从这个索引中读取可用的模板列表,然后展示给用户选择。这种设计让选择变得直观,而不是在黑暗中摸索。

为了保证安全性,后端对设计站点的访问做了严格的校验。安全这件事,再怎么小心也不为过:

// 安全的 slug 校验
private static readonly Regex SafeDesignSiteSlugRegex =
new("^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,127})$", RegexOptions.Compiled);
private static string NormalizeDesignSiteSlug(string slug)
{
var normalizedSlug = slug?.Trim() ?? string.Empty;
if (!IsSafeDesignSiteSlug(normalizedSlug))
{
throw new BusinessException(
ProjectDesignSiteIndexErrorCodes.InvalidSlug,
"Design site slug must be a single safe path segment.");
}
return normalizedSlug;
}
// 预览图缓存(OS 临时目录)
private static string ComputePreviewCacheKey(string slug, string theme, string previewUrl)
{
var raw = $"{slug}|{theme}|{previewUrl}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(bytes).ToLowerInvariant();
}

这里我们做了两件事:一是用正则表达式严格校验 slug 的格式,防止路径遍历攻击;二是对预览图进行缓存,减少对外部站点的请求压力。前者是防护,后者是优化,缺一不可罢了。

// 1. 打开导入抽屉
const handleRequestImportDrawer = useCallback(() => {
setIsImportDrawerOpen(true);
}, []);
// 2. 选择并导入
const handleImportRequest = useCallback((entry) => {
if (isDirty) {
setPendingImportEntry(entry);
setConfirmMode('import'); // 覆盖确认
return;
}
void executeImport(entry);
}, [isDirty]);
// 3. 执行导入
const executeImport = useCallback(async (entry) => {
const result = await getProjectDesignMdSiteImportDocument(
activeTarget.projectId,
entry.slug
);
setDraft(result.content); // 只替换编辑器文本,不自动保存
setIsImportDrawerOpen(false);
}, [activeTarget?.projectId]);

导入流程的设计遵循了”用户确认”的原则:导入后只更新编辑器内容,不会自动保存。用户可以检查导入的内容,确认无误后再手动保存。毕竟,用户对自己写的东西应该有最终决定权,不是吗?

场景 1:项目根目录 DESIGN.md 创建

Section titled “场景 1:项目根目录 DESIGN.md 创建”

当 DESIGN.md 不存在时,后端返回虚拟文档状态。这种设计让前端不需要特殊处理”文件不存在”的情况,统一的 API 接口简化了代码逻辑:

return new ProjectDesignDocumentDto
{
Path = targetPath,
Exists = false, // 虚拟文档状态
Content = string.Empty,
Version = null
};
// 首次保存时自动创建文件
public async Task<SaveProjectDesignDocumentResultDto> SaveDesignDocumentAsync(...)
{
Directory.CreateDirectory(targetDirectory);
await File.WriteAllTextAsync(targetPath, input.Content);
return new SaveProjectDesignDocumentResultDto { Created = !exists };
}

这种设计的好处是前端不需要特殊处理”文件不存在”的情况,统一的 API 接口简化了代码逻辑。有时候,把复杂性隐藏在后端,前端就能更轻松地专注于用户体验。

用户在导入抽屉中选择 “Linear” 设计模板后,系统会通过后端代理获取 DESIGN.md 内容。整个流程对用户来说是透明的,他们只需要选择模板,系统会自动处理所有的网络请求和数据转换:

// 1. 系统通过后端代理获取 DESIGN.md 内容
GET /api/project/{id}/design-md/site-index/linear.app
// 2. 后端验证 slug 并从上游获取内容
var entry = FindDesignSiteEntry(catalog, "linear.app");
using var upstreamResponse = await httpClient.SendAsync(request);
var content = await upstreamResponse.Content.ReadAsStringAsync();
// 3. 前端替换编辑器文本
setDraft(result.content);
// 用户检查后手动保存到磁盘

整个流程对用户来说是透明的,他们只需要选择模板,系统会自动处理所有的网络请求和数据转换。用户不需要关心背后的复杂性,这就是我们追求的体验——简单,但强大。

当多个用户同时编辑同一 DESIGN.md 时,系统会检测到版本冲突。这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作:

if (!string.Equals(currentVersion, expectedVersion, StringComparison.Ordinal))
{
throw new BusinessException(
ProjectDesignDocumentErrorCodes.VersionConflict,
$"DESIGN.md at '{targetPath}' changed on disk.");
}

前端会捕获这个错误并提示用户:

// 前端提示用户刷新并重试
<Alert>
<AlertTitle>版本冲突</AlertTitle>
<AlertDescription>
文件已被其他进程修改。请刷新最新版本后重试。
</AlertDescription>
</Alert>

这种乐观并发控制机制确保了数据的一致性,同时又不会阻塞用户的编辑操作。冲突不可避免,但至少可以让用户知道发生了什么,而不是默默丢失修改。

始终校验 repositoryPath,防止路径遍历攻击。安全这种事,做再多也不为过:

// 始终校验 repositoryPath,防止路径遍历攻击
return ValidateSubPathAsync(projectPath, repositoryPath);
// 拒绝 "../", 绝对路径等危险输入

预览图缓存 24 小时,最大 160 个文件。适度的缓存能提升性能,但也不能过度,毕竟平衡才是关键:

// 预览图缓存 24 小时,最大 160 个文件
private static readonly TimeSpan PreviewCacheTtl = TimeSpan.FromHours(24);
private const int PreviewCacheMaxFiles = 160;
// 定期清理过期缓存

上游站点不可用时降级处理。这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。毕竟,不能因为一个外部服务挂了,整个系统就瘫痪了:

// 上游站点不可用时降级处理
try {
const catalog = await getProjectDesignMdSiteImportCatalog(projectId);
} catch (error) {
toast.error(t('project.designMd.siteImport.feedback.catalogLoadFailed'));
// 主编辑抽屉仍然可用
}

这种优雅降级的设计确保了即使外部依赖不可用,核心编辑功能仍然正常工作。系统应该有韧性,而不是一遇到问题就倒下。

导入前确认覆盖,导入后不自动保存。用户应该对自己的操作有控制权,而不是系统自作主张:

// 导入前确认覆盖
if (isDirty) {
setConfirmMode('import');
return;
}
// 导入后不自动保存,由用户确认
setDraft(result.content); // 只更新草稿
// 用户检查后点击 Save 才真正写入磁盘

使用 HTTP 客户端工厂,避免创建过多连接。资源管理这种事,看似不起眼,但做好了能带来意想不到的效果:

// 使用 HTTP 客户端工厂,避免创建过多连接
private const string DesignSiteProxyClientName = "ProjectDesignSiteProxy";
private static readonly TimeSpan DesignSiteProxyTimeout = TimeSpan.FromSeconds(8);
  1. Markdown 增强:当前使用基础 Textarea,可考虑升级为 CodeMirror 以支持语法高亮和快捷键。编辑器的体验好了,写文档的心情也会好一些。
  2. 预览模式:添加 Markdown 实时预览,提升编辑体验。所见即所得,总能给人更多信心。
  3. 差异合并:实现智能合并算法,而非简单的全文替换。冲突是难免的,但至少可以让处理冲突的过程不那么痛苦。
  4. 本地缓存:将 design.json 缓存到数据库,减少对外部站点的依赖。依赖越少,系统越稳定,这是简单的道理。

在 HagiCode 项目中,我们通过前后端协作实现了一套完整的 DESIGN.md 在线编辑和导入方案。这套方案的核心价值在于:

  • 提升效率:无需切换工具,在统一的 Web 界面完成设计文档的编辑和导入。省下的时间,可以做更有意义的事情。
  • 降低门槛:设计模板一键导入,新项目可以快速起步。开始得越容易,坚持下去的可能性就越大。
  • 安全可靠:路径校验、版本冲突检测、优雅降级等机制确保系统稳定运行。稳定是基础,没有稳定,一切都是空谈。
  • 用户体验:全局抽屉、脏状态检测、确认对话框等细节打磨了交互体验。细节决定成败,这句话在用户体验上尤其适用。

这套方案已经在 HagiCode 项目中实际运行,解决了团队在设计文档管理方面的痛点。如果你也在面临类似的问题,希望这篇文章能给你一些启发。其实也没什么高深的理论,只是遇到了问题,想办法解决而已。

如果本文对你有帮助,欢迎来 GitHub 给个 Star,公测已开始,现在安装即可参与体验。毕竟,开源项目最缺的就是反馈和鼓励,如果你觉得有用,不妨让它被更多人看到…


“美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。”

DESIGN.md 编辑器也是一样,不一定要多么复杂,只要能帮你高效地完成工作,那就是好的罢了。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

Design.md:让 AI 一致性进行前端 UI 设计的解决方案

Design.md:让 AI 一致性进行前端 UI 设计的解决方案

Section titled “Design.md:让 AI 一致性进行前端 UI 设计的解决方案”

在 AI 辅助前端开发时代,如何让 AI 生成的 UI 保持一致性?本文分享了我们基于 awesome-design-md 构建设计画廊站点的实践经验,以及如何创建结构化的 design.md 来指导 AI 进行规范化的 UI 设计。

用过 AI 写前端代码的朋友应该都有过类似的经历:同一个页面,让 AI 多生成几次,每次的风格都不一样。有时候是圆角有时候是方角,有时候间距是 8px 有时候又变成 16px,甚至同一个按钮在不同对话里长得都不一样。

这不仅仅是个别现象。随着 AI 辅助开发的普及,AI 生成的前端 UI 缺乏一致性已经成为一个普遍问题。不同的 AI 助手、不同的提示词,甚至同一助手在不同对话中,都会产生风格迥异的界面设计。这给产品迭代带来了巨大的维护成本。

问题的根源其实很简单:缺少一份权威的设计参考文档。传统的 CSS 样式文件只能告诉开发者”怎么实现”,却无法完整传达”为什么这样设计”以及”在什么场景下使用什么设计模式”。而对于 AI 来说,它更需要一个清晰的结构化描述来理解设计规范。

与此同时,开源社区已经有了一些很好的资源。VoltAgent/awesome-design-md 项目收集了大量知名公司的设计系统文档,每个目录包含 README.md、DESIGN.md 和预览 HTML。但这些都分散在上游仓库中,难以快速查阅和比较。

那能不能把这些资源整合起来,做成一个方便查阅的设计画廊,同时沉淀出一份结构化的 design.md 给 AI 用呢?

答案是肯定的。接下来分享一下我们的方案。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个 AI 辅助开发平台,在开发过程中,我们也遇到了 AI 生成 UI 不一致的问题。为了解决这个问题,我们构建设计画廊站点并创建规范化的 design.md,本文就是这套方案的总结。

GitHub - HagiCode-org/site

先看一眼最终做出来的首页效果。首页把设计画廊入口、站点仓库、上游仓库和 HagiCode 的背景介绍收拢在同一个界面里,方便团队先建立统一上下文,再继续阅读具体条目。

Awesome Design MD Gallery 首页概览

在动手写代码之前,我们先来拆解一下这个问题的几个技术挑战。

内容源管理:如何统一分散的设计资源?

Section titled “内容源管理:如何统一分散的设计资源?”

上游的 awesome-design-md 仓库包含了大量设计文档,但我们需要一种方式把它纳入到我们的项目中。

方案:使用 git submodule

awesome-design-md-site
└── vendor/awesome-design-md # 上游资源(git submodule)

这样做有几个好处:

  • 版本可控:可以锁定特定的上游版本
  • 离线构建:不需要在构建时请求外部 API
  • 内容审阅:可以在 PR 中看到具体变更

数据标准化:不同文档结构怎么统一?

Section titled “数据标准化:不同文档结构怎么统一?”

不同公司的设计文档结构可能不同,有些缺少预览文件,有些命名不统一。我们需要在构建期进行标准化处理。

方案:构建期扫描并生成标准化条目

核心模块是 awesomeDesignCatalog.ts,负责:

  1. 扫描 vendor/awesome-design-md/design-md/* 目录
  2. 校验每个条目是否包含必需文件(README.md、DESIGN.md、至少一个预览文件)
  3. 提取并渲染 Markdown 内容为 HTML
  4. 生成标准化的条目数据
src/lib/content/awesomeDesignCatalog.ts
export interface DesignEntry {
slug: string;
title: string;
summary: string;
readmeHtml: string;
designHtml: string;
previewLight?: string;
previewDark?: string;
searchText: string;
}
export async function scanSourceEntries() {
// 扫描 vendor/awesome-design-md/design-md/*
// 校验文件完整性
// 生成标准化条目
}
export async function normalizeDesignEntry(dir: string) {
// 提取 README.md、DESIGN.md
// 解析预览文件
// 渲染 Markdown 为 HTML
}

静态站点架构:怎么在保持静态部署的同时提供动态搜索?

Section titled “静态站点架构:怎么在保持静态部署的同时提供动态搜索?”

既然是设计画廊,搜索功能是必须的。但 Astro 是静态站点生成器,怎么实现实时搜索呢?

方案:React island + URL 查询参数同步

src/components/gallery/SearchToolbar.tsx
export function SearchToolbar() {
const [query, setQuery] = useState('');
// URL 同步
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setQuery(params.get('q') || '');
}, []);
// 实时过滤
const filtered = entries.filter(entry =>
entry.searchText.includes(query)
);
return <input value={query} onChange={e => {
setQuery(e.target.value);
updateURL(e.target.value);
}} />;
}

这样做的好处是保留了静态站点的可部署性(可以部署到任何静态托管服务),同时提供了即时过滤的用户体验。

设计文档化:怎么让 AI 理解并遵守设计规范?

Section titled “设计文档化:怎么让 AI 理解并遵守设计规范?”

这是整个方案的核心。我们需要创建一份结构化的 design.md,让 AI 能够理解并应用我们的设计规范。

方案:借鉴 ClickHouse DESIGN.md 的结构

ClickHouse 的 DESIGN.md 是一个很好的参考,它包含了:

  • Visual Theme & Atmosphere
  • Color Palette & Roles
  • Typography Rules
  • Component Stylings
  • Layout Principles
  • Depth & Elevation
  • Do’s and Don’ts
  • Responsive Behavior
  • Agent Prompt Guide

我们的做法是:结构参考,内容重写。保留 ClickHouse DESIGN.md 的章节结构,但把内容替换成我们自己项目实际使用的设计 token 和组件规范。

基于上述分析,我们的解决方案包含四个核心模块。

这是整个系统的基础,负责从上游资源中提取和标准化内容。

src/lib/content/awesomeDesignCatalog.ts
export async function scanSourceEntries(): Promise<DesignEntry[]> {
const designDir = 'vendor/awesome-design-md/design-md';
const entries: DesignEntry[] = [];
for (const dir of await fs.readdir(designDir)) {
const entryPath = path.join(designDir, dir);
if (await isValidDesignEntry(entryPath)) {
const entry = await normalizeDesignEntry(entryPath);
entries.push(entry);
}
}
return entries;
}
async function isValidDesignEntry(dir: string): Promise<boolean> {
const requiredFiles = ['README.md', 'DESIGN.md'];
for (const file of requiredFiles) {
if (!(await fileExists(path.join(dir, file)))) {
return false;
}
}
return true;
}

画廊界面包括三个主要部分:

首页:展示所有设计条目的卡片网格,每个卡片包含:

  • 设计条目标题和简介
  • 预览图(如果有)
  • 快速搜索高亮

详情页:聚合展示单个设计条目的完整信息:

  • README 文档
  • DESIGN 文档
  • 预览(支持明/暗主题切换)
  • 相邻条目导航

导航:支持返回画廊、浏览相邻条目

首页画廊使用高密度卡片布局,把不同来源的 design.md 条目平铺在一个统一的视觉框架里,方便快速对比品牌风格、按钮模式和排版节奏。

Awesome Design MD Gallery 设计卡片网格

进入具体条目后,详情页会把设计摘要和实时预览放在同一个页面中,减少在文档、预览和源码之间来回切换的成本。

Awesome Design MD Gallery 设计详情预览页

搜索功能基于客户端过滤,使用 URL 查询参数保持状态:

src/components/gallery/SearchToolbar.tsx
function SearchToolbar({ entries }: { entries: DesignEntry[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(entries);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const initialQuery = params.get('q') || '';
setQuery(initialQuery);
filterEntries(initialQuery);
}, []);
const filterEntries = (searchQuery: string) => {
const filtered = entries.filter(entry =>
entry.searchText.toLowerCase().includes(searchQuery.toLowerCase())
);
setResults(filtered);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
filterEntries(value);
// 更新 URL(不触发页面刷新)
const newUrl = value
? `${window.location.pathname}?q=${encodeURIComponent(value)}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
};
return (
<div className="search-toolbar">
<input
type="text"
value={query}
onChange={handleChange}
placeholder="搜索设计条目..."
/>
<span className="result-count">{results.length} 个结果</span>
</div>
);
}

这是整个方案的核心输出。我们在项目根目录创建 design.md,结构如下:

除了给 AI 消费的原始 design.md 内容,我们还把 README 和 DESIGN 两份文档放进同一个阅读界面,方便人工校对、复制片段和对照预览结果。

Awesome Design MD Gallery README 与 DESIGN 文档页

# Design Reference for [Project Name]
## 1. Visual Theme & Atmosphere
- 整体风格描述
- 设计哲学和原则
## 2. Color Palette & Roles
- 主色调、辅助色
- 语义化颜色(success、warning、error)
- CSS Variables 定义
## 3. Typography Rules
- 字体家族
- 字号层级(h1-h6, body, small)
- 行高和字重
## 4. Component Stylings
- 按钮样式规范
- 表单组件样式
- 卡片和容器样式
## 5. Layout Principles
- 间距系统
- 网格和断点
- 对齐原则
## 6. Depth & Elevation
- 阴影层级
- z-index 规范
## 7. Do's and Don'ts
- 常见错误和正确做法
## 8. Responsive Behavior
- 断点定义
- 响应式适配规则
## 9. Agent Prompt Guide
- 如何将本文档用于 AI 提示词
- 示例提示词模板

了解了方案之后,具体怎么实施呢?

第一步:初始化子模块

Terminal window
# 添加上游仓库为子模块
git submodule add https://github.com/VoltAgent/awesome-design-md.git vendor/awesome-design-md
# 初始化并更新子模块
git submodule update --init --recursive

第二步:创建内容管线

实现 awesomeDesignCatalog.ts,包括:

  • 文件扫描和校验逻辑
  • Markdown 渲染(使用 Astro 的内置渲染器)
  • 条目数据提取

第三步:构建画廊 UI

使用 Astro + React Islands 创建:

  • 首页画廊布局(卡片网格)
  • 设计卡片组件
  • 搜索工具栏
  • 详情页布局

第四步:编写设计文档

基于 ClickHouse DESIGN.md 结构,填充自己项目的实际设计 token。更新 README.md,添加指向 design.md 的链接。

安全性:Markdown 渲染需要过滤不安全的 HTML。Astro 的内置渲染器默认会过滤 script 标签,但仍需注意 XSS 风险。

性能:大量 iframe 预览可能影响首屏加载。建议使用 loading="lazy" 延迟加载预览内容。

维护性:design.md 需要与代码实现保持同步。建议在 CI 中添加检查,确保 CSS 变量在文档和代码中一致。

可访问性:确保颜色对比度符合 WCAG AA 标准(至少 4.5:1)。

创建 design.md 之后,怎么让 AI 真正用它呢?这里有几个实用技巧:

技巧一:在提示词中明确引用

请参考项目根目录的 design.md 文件,使用其中定义的设计规范来实现以下组件:
- 按钮:使用 primary 色调,圆角 8px
- 卡片:使用 elevation-2 阴影层级

技巧二:要求 AI 引用具体的 CSS 变量

实现一个导航栏,要求:
- 背景色使用 --color-bg-primary
- 边框使用 --color-border-subtle
- 文字使用 --text-color-primary

技巧三:在系统提示词中包含 design.md 内容

如果你的 AI 工具支持自定义系统提示词,可以将 design.md 的核心内容直接添加进去。

内容管线测试

  • 文件缺失场景(缺少 README.md 或 DESIGN.md)
  • 格式错误场景(Markdown 解析失败)
  • 空目录场景

搜索功能测试

  • 空结果处理
  • 特殊字符(如中文、emoji)
  • URL 同步验证

UI 组件测试

  • 明/暗主题切换
  • 响应式布局
  • 预览加载状态
Terminal window
# 1. 更新子模块到最新版本
git submodule update --remote
# 2. 重新构建站点
npm run build
# 3. 部署静态资源
npm run deploy

建议将子模块更新和构建部署自动化,可以在上游仓库更新时自动触发 CI 流程。

HagiCode 在开发过程中遇到的 AI 生成 UI 不一致问题,本质上是缺少结构化的设计参考文档。通过构建设计画廊站点和创建规范化的 design.md,我们成功解决了这个问题。

这套方案的核心价值在于:

  • 统一资源:整合分散的设计系统文档
  • 结构化规范:将设计规范以 AI 可理解的形式呈现
  • 持续维护:通过 git submodule 保持内容更新

如果你也在使用 AI 辅助前端开发,建议尝试一下这个方案。创建一份结构化的 design.md,不仅能提升 AI 生成代码的一致性,也能帮助团队内部保持设计规范的统一。


如果本文对你有帮助:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

为什么使用 Skillsbase 维护自己的 Skills 收藏仓库

为什么使用 Skillsbase 维护自己的 Skills 收藏仓库

Section titled “为什么使用 Skillsbase 维护自己的 Skills 收藏仓库”

说起来也挺好笑的,AI 编程时代来了,我们手里的 Agent Skills 越来越多,可随之而来的麻烦也越来越多。这篇文章就是想聊聊我们是怎么用 skillsbase 解决这些问题的。

在 AI 编程时代,开发者需要维护越来越多的 Agent Skills——这些是可复用的指令集,用于扩展 Claude Code、OpenCode、Cursor 等编码助手的能力。然而,随着技能数量的增长,一个现实问题逐渐浮现:

其实也不能说是什么大问题,只是东西多了,管理起来就麻烦了。

  • 本地技能散落在多个位置:~/.agents/skills/~/.claude/skills/~/.codex/skills/.system/
  • 不同位置可能存在命名冲突(例如 skill-creator 同时存在于用户目录和系统目录)
  • 缺乏统一的管理入口,备份和迁移困难

这点挺烦人的。有时候你自己都不知道某个技能到底在哪,像丢了东西一样,找起来费劲。

  • 手工复制技能容易出错,难以追踪来源
  • 没有统一的验证机制,无法保证技能仓库的完整性
  • 团队协作时,难以同步和共享技能集合

手工操作嘛,总是容易出错的。毕竟人的记忆力有限,谁记得住那么多东西是从哪来的呢?

  • 更换开发机器时,需要重新配置所有技能
  • CI/CD 环境中无法自动验证和同步技能仓库

换个电脑就得重新来一遍,这种感觉,怎么说呢,就像搬家一样麻烦。每次都得重新适应新的环境,重新配置所有东西。

为了解决这些痛点,我们尝试过多种方案:从手工复制到脚本自动化,从直接管理目录到全局安装再回收。每种方案都有各自的缺陷,要么无法保证一致性,要么污染环境,要么难以在 CI 中使用。

其实也是走了不少弯路。

最终,我们找到了一套更优雅的解决方案——skillsbase。这个方案的核心思想是:先本地安装验证,再转换结构写入仓库,最后卸载临时文件。这样既能确保仓库内容与实际安装结果一致,又不会污染全局环境。

说起来简单,只是踩了不少坑才琢磨出来罢了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。

HagiCode 是一个 AI 代码助手项目,在开发过程中我们需要维护大量的 Agent Skills 来扩展各种编码能力。正是这些实际需求,促使我们开发了 skillsbase 这套工具来规范化管理技能仓库。

其实这东西也不是凭空想出来的,都是被逼的。技能多了,自然就需要管理。管理的过程中遇到问题,自然就需要解决。一步一步,就走到今天了。

如果你对 HagiCode 感兴趣,可以访问 官网了解更多 或在 GitHub 上查看源码。

要建立一个可维护的技能收藏仓库,需要解决以下核心问题:

  1. 统一命名空间冲突:当多个来源存在同名技能时,如何避免覆盖?
  2. 来源可追溯性:如何记录每个技能的来源,以便后续更新和审计?
  3. 同步与验证:如何确保仓库内容与实际安装结果一致?
  4. 自动化集成:如何与 CI/CD 流程集成,实现自动同步和验证?

这些问题看似简单,但每一个都够头疼的。不过话说回来,做什么事不难呢?

方案一:直接复制目录

优点:实现简单 缺点:无法保证与 skills CLI 实际安装结果一致

这个方案嘛,说真的,我们也想过。只是后来发现,CLI 安装的时候可能有一些预处理逻辑,直接复制就跳过了。结果就是,复制的东西和实际装的东西不一样,这就有问题了。

方案二:全局安装后回收

优点:可以验证安装过程 缺点:污染执行环境,CI 与本地结果难以保持一致

这个方案更糟糕。全局安装,会污染环境。更麻烦的是,CI 环境和本地环境很难保持一致,导致”本地能跑,CI 失败”的问题。这种感觉,谁懂啊?

方案三:本地安装 → 转换 → 卸载(最终方案)

这是 skillsbase 采用的方案:

  • 先通过 npx skills 把技能安装到临时位置
  • 转换目录结构并添加来源元数据
  • 写入目标仓库
  • 最后卸载临时文件

这种方案确保了仓库内容与实际消费者安装结果一致,同时不污染全局环境,转换过程可标准化,支持幂等操作。

其实这个方案也不是一开始就想到的,只是试错试多了,自然就知道什么可行、什么不可行了。

决策项选择理由
运行时Node.js ESM无需构建步骤,.mjs 足以完成文件系统编排
配置格式YAML (sources.yaml)可读性强,支持人工维护
命名策略命名空间前缀用户技能保持原名,系统技能添加 system- 前缀
工作流add 修改清单 → sync 执行同步单一同步引擎,避免规则双份实现
文件管理受管文件标识添加注释头,支持安全覆盖

这些决策,说到底都是为了一个目标:让事情变得简单。毕竟,简单才是王道。

skillsbase CLI 提供四个核心命令:

skillsbase
├── init # 初始化仓库结构
├── sync # 同步技能内容
├── add # 添加新技能
└── github_action # 生成 GitHub Actions 配置

命令不多,但也够了。毕竟,工具这东西,够用就好。

┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ init │───▶│ add │───▶│ sync │───▶│github_action│
│ 初始化仓库 │ │ 添加来源 │ │ 同步内容 │ │ 生成 CI │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘

一步一步来,急不得。

sources.yaml → 解析来源 → npx skills 安装 → 转换结构 → 写入 skills/ → 卸载临时文件
.skill-source.json (来源元数据)

这个流程设计得还算清晰。至少我自己看的时候,能明白每一步在做什么。

repos/skillsbase/
├── sources.yaml # 来源清单(单一真相源)
├── skills/ # 技能目录
│ ├── frontend-design/ # 用户技能
│ ├── skill-creator/ # 用户技能
│ └── system-skill-creator/ # 系统技能(带前缀)
├── scripts/
│ ├── sync-skills.mjs # 同步脚本
│ └── validate-skills.mjs # 验证脚本
├── docs/
│ └── maintainer-workflow.md # 维护者文档
└── .github/
├── workflows/
│ └── skills-sync.yml # CI 工作流
└── actions/
└── skillsbase-sync/
└── action.yml # 复用型 Action

文件多了点,不过也还好。毕竟,组织结构清晰了,维护起来也方便。

Terminal window
# 1. 创建空仓库
mkdir repos/myskills && cd repos/myskills
git init
# 2. 使用 skillsbase 初始化
npx skillsbase init
# 输出:
# [1/4] create manifest ................. done
# [2/4] create scripts .................. done
# [3/4] create docs ..................... done
# [4/4] create github workflow .......... done
#
# next: skillsbase add <skill-name>

这一步会生成一堆文件,不过不用担心,都是自动生成的。接下来就可以开始添加技能了。

Terminal window
# 添加单个技能(会自动执行同步)
npx skillsbase add frontend-design --source vercel-labs/agent-skills
# 添加到本地来源
npx skillsbase add documentation-writer --source /home/user/.agents/skills
# 输出:
# source: first-party ......... updated
# target: skills/frontend-design ... synced
# status: 1 skill added, 0 removed

添加技能挺简单的,一条命令就够了。只是有时候会遇到一些意外情况,比如网络不好、权限问题之类的。不过这些都是小事,慢慢来。

Terminal window
# 执行同步(对账所有来源)
npx skillsbase sync
# 仅检查是否漂移(不修改文件)
npx skillsbase sync --check
# 允许缺失来源(CI 场景)
npx skillsbase sync --allow-missing-sources

同步的时候,系统会把 sources.yaml 里定义的来源都检查一遍,然后和 skills/ 目录里的内容对账。有差异就更新,没差异就跳过。这样就不会出现”配置改了但文件没变”的问题。

Terminal window
# 生成 workflow
npx skillsbase github_action --kind workflow
# 生成 action
npx skillsbase github_action --kind action
# 生成全部
npx skillsbase github_action --kind all

CI 配置也是自动生成的。只是你需要自己调整一些细节,比如触发条件、运行环境之类的。不过这些都不难。

# 技能根目录配置
skillsRoot: skills/
metadataFile: .skill-source.json
# 来源定义
sources:
# 第一方:本地用户技能
first-party:
type: local
path: /home/user/.agents/skills
naming: original # 保持原名
includes:
- documentation-writer
- frontend-design
- skill-creator
# 系统:系统提供技能
system:
type: local
path: /home/user/.codex/skills/.system
naming: prefix-system # 添加 system- 前缀
includes:
- imagegen
- openai-docs
- skill-creator # 会变成 system-skill-creator
# 远程:第三方仓库
vercel:
type: remote
url: vercel-labs/agent-skills
naming: original
includes:
- web-design-guidelines

这个配置文件是整个系统的核心。所有的来源都在这里定义,改了这里,下次同步就会生效。所以说,这算是一个”单一真相源”。

{
"source": "first-party",
"originalPath": "/home/user/.agents/skills/documentation-writer",
"originalName": "documentation-writer",
"targetName": "documentation-writer",
"syncedAt": "2026-04-07T00:00:00.000Z",
"version": "unknown"
}

每个技能目录下都会有这个文件,记录了它的来源信息。这样以后出问题的时候,能快速定位是从哪来的、什么时候同步的。

Terminal window
# 验证仓库结构
node scripts/validate-skills.mjs
# 使用 skills CLI 验证
npx skills add . --list
# 检查更新
npx skills check

验证这东西,说重要也重要,说没必要也没必要。不过为了保险起见,偶尔跑一下也无妨。毕竟,谁知道会不会有什么意外呢?

.github/workflows/skills-sync.yml
name: Skills Sync
on:
push:
paths:
- 'sources.yaml'
- 'skills/**'
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Validate repository
run: |
npx skills add . --list
node scripts/validate-skills.mjs
- name: Sync check
run: npx skillsbase sync --check

CI 集成之后,每次改 sources.yaml 或者 skills/ 目录,都会自动触发验证。这样就不会出现”本地改了忘了同步”的问题了。

  1. 命名冲突处理:系统技能统一添加 system- 前缀。这样既能保留所有技能,又能避免命名冲突。
  2. 幂等操作:所有命令支持重复执行,多次运行 sync 不会产生副作用。这点在 CI 里特别重要。
  3. 受管文件:生成的文件包含 # Managed by skillsbase CLI 注释,方便识别和管理。这些文件可以安全覆盖,手工修改不会被保留。
  4. 非交互模式:CI 环境默认使用确定性行为,不会因为交互式提示而中断。所有配置都通过 sources.yaml 声明。
  5. 来源可追溯:每个技能都有 .skill-source.json 记录来源信息,出问题的时候能快速定位。
Terminal window
# 团队成员安装共享技能库
npx skills add your-org/myskills -g --all
# 本地克隆验证
git clone https://github.com/your-org/myskills.git
cd myskills
npx skills add . --list

通过 Git 管理技能仓库,团队成员可以轻松同步技能集合,确保每个人都使用相同版本的工具和配置。

这点在团队协作里特别有用,不会出现”我这边能跑你那边不行”的情况。毕竟,环境统一了,问题就少了一半。

使用 skillsbase 维护技能收藏仓库的核心价值在于:

  • 安全性:来源验证、冲突检测、受管文件保护
  • 可维护性:统一入口、幂等操作、配置即文档
  • 标准化:统一的目录结构、命名规范、元数据格式
  • 自动化:CI/CD 集成、自动同步、自动验证

通过这套方案,开发者可以像管理 npm 包一样管理自己的 Agent Skills,实现可复现、可共享、可维护的技能仓库体系。

本文分享的这套工具和流程,正是我们在开发 HagiCode 过程中实际踩坑、实际优化出来的方案。如果你觉得这套方案有价值,说明我们的工程实践方向是正确的——那么 HagiCode 本身也值得关注一下。

毕竟,好的工具是值得被更多人使用的。

如果本文对你有帮助:


本文首发于 HagiCode 博客

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

AI时代如何临摹项目:Vault跨项目持久化存储系统

AI时代如何临摹项目:Vault跨项目持久化存储系统

Section titled “AI时代如何临摹项目:Vault跨项目持久化存储系统”

在AI辅助开发时代,如何让AI助手更好地理解我们的学习资源?HagiCode项目通过Vault系统实现了一个统一的、可被AI理解的知识存储抽象层,让临摹项目的学习效率大幅提升。

在AI时代,开发者学习新技术和架构的方式正在发生深刻变化。“临摹项目”——即深入研究和学习优秀开源项目的代码、架构和设计模式——已经成为一种高效的学习方法。相比传统的读书、看视频,直接阅读和运行高质量的开源项目能让你更快地理解真实世界的工程实践。

只是这种学习方式也面临着不少挑战。

学习资料太分散了。你的笔记可能在Obsidian里,代码仓库散落在各个文件夹,AI助手的对话历史又是一个独立的数据孤岛。当你想让AI帮你分析某个项目时,得手动复制代码片段、整理上下文,这个过程相当繁琐。

其实更麻烦的是上下文断裂。AI助手无法直接访问你的本地学习资源,每次对话都得重新提供背景信息。而且临摹的代码仓库更新很快,手动同步容易出错,多个学习项目之间也难以共享知识。

这些问题本质上都是”数据孤岛”导致的。如果能有一个统一的存储抽象层,让AI助手能够理解和访问你的所有学习资源,问题就迎刃而解了。

本文分享的Vault系统,正是我们在开发 HagiCode 过程中实践出来的解决方案。HagiCode 是一个AI代码助手项目,在我们的日常开发中,经常需要学习和参考各种开源项目。为了让AI助手更好地理解这些学习资源,我们设计了Vault跨项目持久化存储系统。

这套方案已经在HagiCode中经过了实际验证,如果你也面临类似的知识管理难题,希望这些经验能给你一些启发。毕竟,有些坑踩过了,总得留下点什么给后来的人。

Vault系统的核心思想很简单:创建一个统一的、可被AI理解的知识存储抽象层。从实现角度来看,系统具有几个关键特征。

系统支持四种vault类型,分别对应不同的使用场景:

// folder:通用文件夹类型
export const DEFAULT_VAULT_TYPE = 'folder';
// coderef:专门用于临摹代码项目的类型
export const CODEREF_VAULT_TYPE = 'coderef';
// obsidian:与Obsidian笔记软件集成
export const OBSIDIAN_VAULT_TYPE = 'obsidian';
// system-managed:系统自动管理的vault
export const SYSTEM_MANAGED_VAULT_TYPE = 'system-managed';

其中 coderef 类型是HagiCode中最常用的。它专门为临摹代码项目设计,提供了标准化的目录结构和AI可读的元数据描述。

Vault的注册表以JSON格式持久化存储,确保配置在应用重启后仍然可用:

public class VaultRegistryStore : IVaultRegistryStore
{
private readonly string _registryFilePath;
public VaultRegistryStore(IConfiguration configuration, ILogger<VaultRegistryStore> logger)
{
var dataDir = configuration["DataDir"] ?? "./data";
var absoluteDataDir = Path.IsPathRooted(dataDir)
? dataDir
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), dataDir));
_registryFilePath = Path.Combine(absoluteDataDir, "personal-data", "vaults", "registry.json");
}
}

这种设计的好处是简单可靠。JSON格式人类可读,便于调试和手动修改;文件系统存储避免了数据库的复杂性,降低了系统的依赖。毕竟,有时候简单的反而是最好的。

最关键的是,系统能够自动将vault信息注入到AI提案的上下文中:

export function buildTargetVaultsText(
vaults: VaultForText[],
template: VaultPromptTemplate = DEFAULT_VAULT_PROMPT_TEMPLATE,
): string {
const readOnlyVaults = vaults.filter((vault) => vault.accessType === 'read');
const editableVaults = vaults.filter((vault) => vault.accessType === 'write');
if (readOnlyVaults.length === 0 && editableVaults.length === 0) {
return '';
}
const sections = [
buildVaultSection(readOnlyVaults, template.reference),
buildVaultSection(editableVaults, template.editable),
].filter(Boolean);
return `\n\n### ${template.heading}\n\n${sections.join('\n')}`;
}

这样就实现了一个重要的功能:AI助手能够自动理解可用的学习资源,无需用户手动提供上下文。这倒也算是一种默契了。

对于coderef类型的vault,HagiCode提供了一套标准化的目录结构:

my-coderef-vault/
├── index.yaml # vault元数据描述
├── AGENTS.md # AI助手的操作指南
├── docs/ # 存放学习笔记和文档
└── repos/ # 通过Git子模块管理临摹的代码仓库

创建vault时,系统会自动初始化这个结构:

private async Task EnsureCodeRefStructureAsync(
string vaultName,
string physicalPath,
ICollection<VaultBootstrapDiagnosticDto> diagnostics,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(physicalPath);
var indexPath = Path.Combine(physicalPath, CodeRefIndexFileName);
var docsPath = Path.Combine(physicalPath, CodeRefDocsDirectoryName);
var reposPath = Path.Combine(physicalPath, CodeRefReposDirectoryName);
// 创建标准目录结构
if (!Directory.Exists(docsPath))
{
Directory.CreateDirectory(docsPath);
}
if (!Directory.Exists(reposPath))
{
Directory.CreateDirectory(reposPath);
}
// 创建AGENTS.md指南
await EnsureCodeRefAgentsDocumentAsync(physicalPath, cancellationToken);
// 创建index.yaml元数据
await WriteCodeRefIndexDocumentAsync(indexPath, mergedDocument, cancellationToken);
}

这套结构的设计也是有讲究的:

  • docs/ 目录存放你的学习笔记,可以用Markdown格式记录对代码的理解、架构分析、踩坑经验等
  • repos/ 目录通过Git子模块管理临摹的仓库,而不是直接复制代码。这样既能保持代码同步,又能节省空间
  • index.yaml 包含vault的元数据,让AI助手快速理解这个vault的用途和内容
  • AGENTS.md 是专门写给AI助手看的指南,说明如何处理这个vault中的内容

或许这样组织起来,AI也能更容易理解你的想法吧。

除了手动创建vault,HagiCode还支持系统自动管理的vault:

public async Task<IReadOnlyList<VaultRegistryEntry>> EnsureAllSystemManagedVaultsAsync(
CancellationToken cancellationToken = default)
{
var definitions = GetAllResolvedDefinitions();
var entries = new List<VaultRegistryEntry>(definitions.Count);
foreach (var definition in definitions)
{
entries.Add(await EnsureResolvedSystemManagedVaultAsync(definition, cancellationToken));
}
return entries;
}

系统会自动创建和管理以下vault:

  • hagiprojectdata:项目数据存储,用于保存项目的配置和状态
  • personaldata:个人数据存储,用于保存用户的偏好设置
  • hbsprompt:提示词模板库,用于管理常用的AI提示词

这些vault在系统启动时自动初始化,无需用户手动配置。毕竟,有些事情交给系统去做就好了,人类何必操心呢。

一个重要的设计是访问控制。系统将vault分为两种访问类型:

export interface VaultForText {
id: string;
name: string;
type: string;
physicalPath: string;
accessType: 'read' | 'write'; // 关键:区分只读和可编辑
}
  • reference(只读):AI仅用于分析和理解,不能修改内容。适用于参考的开源项目、文档等
  • editable(可编辑):AI可以根据任务需要修改内容。适用于你的笔记、草稿等

这种区分很重要。它让AI知道哪些内容是”只读参考”,哪些是”可以动手改的”,避免了误操作风险。毕竟,谁也不想自己的心血被无意中改没了。

看完了原理,咱们来看看实际怎么用。

以下是一个完整的前端调用示例:

const createCodeRefVault = async () => {
const response = await VaultService.postApiVaults({
requestBody: {
name: "React Learning Vault",
type: "coderef",
physicalPath: "/Users/developer/vaults/react-learning",
gitUrl: "https://github.com/facebook/react.git"
}
});
// 系统会自动:
// 1. 克隆React仓库到vault/repos/react
// 2. 创建docs/目录用于笔记
// 3. 生成index.yaml元数据
// 4. 创建AGENTS.md指南文件
return response;
};

这个API调用会完成一系列操作:创建目录结构、初始化Git子模块、生成元数据文件等。你只需要提供基本信息,剩下的交给系统处理。其实这样也挺省心的。

创建好vault后,就可以在AI提案中引用它了:

const proposal = composeProposalChiefComplaint({
chiefComplaint: "帮我分析React的并发渲染机制",
repositories: [
{ id: "react", gitUrl: "https://github.com/facebook/react.git" }
],
vaults: [
{
id: "react-learning",
name: "React Learning Vault",
type: "coderef",
physicalPath: "/vaults/react-learning",
accessType: "read" // AI只能读取,不能修改
}
],
quickRequestText: "重点关注fiber架构和scheduler实现"
});

系统会自动将vault信息注入到AI的上下文中,让AI知道你有哪些学习资源可用。AI能理解你的想法,这倒也算是一种难得的默契了。

在使用Vault系统的过程中,我们总结了一些经验教训。

系统会严格校验路径,防止路径穿越攻击:

private static string ResolveFilePath(string vaultRoot, string relativePath)
{
var rootPath = EnsureTrailingSeparator(Path.GetFullPath(vaultRoot));
var combinedPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
if (!combinedPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException(VaultRelativePathTraversalCode,
"Vault file paths must stay inside the registered vault root.");
}
return combinedPath;
}

这点很重要。如果你在自定义vault路径,一定要确保路径在允许的范围内,否则系统会拒绝操作。安全这东西,怎么强调都不过分。

CodeRef vault推荐使用Git子模块而非直接复制代码:

private static string BuildCodeRefAgentsContent()
{
return """
# CodeRef Vault Guide
Repositories under `repos/` should be maintained through Git submodules
rather than copied directly into the vault root.
Keep this structure stable so assistants and tools can understand the vault quickly.
""" + Environment.NewLine;
}

这样做有几个好处:保持代码与上游同步、节省磁盘空间、便于管理多个版本的代码。毕竟,谁愿意一遍遍地重复下载同样的东西呢。

为了防止性能问题,系统限制了文件大小和类型:

private const int FileEnumerationLimit = 500;
private const int PreviewByteLimit = 256 * 1024; // 256KB

如果你的vault包含大量文件或超大文件,可能会影响预览功能的性能。这种情况下可以考虑分批处理或使用专门的搜索工具。毕竟,有些东西太大了,反而不好处理。

创建vault时会返回诊断信息,帮助调试:

List<VaultBootstrapDiagnosticDto> bootstrapDiagnostics = [];
if (IsCodeRefVaultType(normalizedType))
{
bootstrapDiagnostics = await EnsureCodeRefBootstrapAsync(
normalizedName,
normalizedPhysicalPath,
normalizedGitUrl,
cancellationToken);
}

如果创建失败,可以查看诊断信息了解具体原因。出错了就看看诊断信息,这倒也是一种解决问题的方法。

Vault系统通过统一的存储抽象层,解决了AI时代临摹项目的核心痛点:

  • 知识集中管理:所有学习资源集中在一个地方,不再散落各处
  • AI上下文自动注入:AI助手能够自动理解可用的学习资源,无需手动提供上下文
  • 跨项目知识复用:多个学习项目之间可以共享和复用知识
  • 标准化目录结构:提供一致的目录结构,降低学习成本

这套方案在HagiCode项目中已经经过了实际验证。如果你也在做AI辅助开发相关的工具,或者面临类似的知识管理问题,希望这些经验能给你一些参考。

其实技术方案的价值不在于有多复杂,而在于能不能解决实际问题。Vault系统的核心思想很简单——就是建立一个统一的、AI可理解的知识存储层。但正是这个简单的抽象,让我们的开发效率提升了不少。

有时候,简单的反而是最好的。毕竟,复杂的东西往往藏着更多的坑…


如果本文对你有帮助,欢迎来 GitHub 给个 Star,或者访问官网了解更多关于HagiCode的信息。公测已开始,现在安装即可体验完整的AI代码助手功能。

或许,你也可以试试看…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

渐进式披露:如何用少即是多的理念改进 AI 产品的人机交互

渐进式披露:如何用”少即是多”的理念改进 AI 产品的人机交互

Section titled “渐进式披露:如何用”少即是多”的理念改进 AI 产品的人机交互”

在 AI 产品设计中,用户输入的质量往往决定了输出的质量。本文分享我们在 HagiCode 项目中实践的一套”渐进式披露”交互方案,通过分步引导、智能补全和即时反馈,将用户简短模糊的输入转化为结构化的技术提案,显著提升了人机交互效率。

做 AI 产品的同学应该都遇到过这样的场景:用户打开你的应用,兴致勃勃地输入一行需求,结果 AI 返回的内容完全不搭边。不是 AI 不聪明,只是用户给的信息太少了,毕竟猜心这种事,谁也做不好。

这种现象在我们开发 HagiCode 的过程中尤为明显。HagiCode 是一个 AI 驱动的代码助手,用户通过自然语言描述需求来创建技术提案和会话。可在实际使用中,我们发现用户输入的内容往往存在这些问题:

  • 输入质量参差不齐:有的用户只输入几个字,比如”优化登录”、“修复 bug”,缺乏必要的上下文
  • 技术术语不统一:不同用户用不同的词说同一件事,有人说”前端”有人说”FE”
  • 缺少结构化信息:没有项目背景、没有仓库范围、没有影响范围这些关键信息
  • 重复性问题:相同类型的需求反复出现,每次都要从头解释

这些问题导致的直接后果就是:AI 理解困难、生成的提案质量不稳定、用户体验差。用户觉得”这 AI 不行啊”,我们也很委屈——你只给一句话,让我怎么猜你想要啥?

其实这也没办法,毕竟人和人之间的理解都需要时间,更何况是机器呢?

为了解决这些痛点,我们做了一个大胆的决定:引入”渐进式披露”的设计理念来改进人机交互。这个决定带来的变化,可能比你想象的还要大,只是当时我们也没想到会这么有效罢了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,旨在通过自然语言交互帮助开发者完成代码编写、技术提案生成、代码审查等任务。项目地址:github.com/HagiCode-org/site

这套渐进式披露方案是我们在实际开发过程中,经过多次迭代和优化总结出来的。如果你觉得这套方案有价值,说明我们的工程实力还不错——那么 HagiCode 本身也值得关注一下,毕竟好东西是值得分享的。

“渐进式披露”(Progressive Disclosure)是一个源自 HCI(人机交互)领域的设计原则,核心思想很简单:不要一次性把所有信息和选项都展示给用户,而是根据用户的操作和需求,逐步展示必要的内容

这个原则特别适合 AI 产品,因为 AI 交互天然就是渐进式的——用户说一点,AI 理解一点,然后补充一点,再理解更多。就像人与人之间的交流一样,总得慢慢来,毕竟谁也不能一见面就把心掏出来不是?

具体到 HagiCode 的场景,我们从四个方面实施了渐进式披露:

1. 描述优化机制:让 AI 帮你把话说清楚

Section titled “1. 描述优化机制:让 AI 帮你把话说清楚”

当用户输入简短描述时,我们不是直接让 AI 去理解,而是先触发一个”描述优化”流程。这个流程的核心是”结构化输出”——把用户的自由文本转化为标准格式。就像把散落一地的珍珠串成项链,看起来也就不那么乱了。

优化后的描述必须包含以下几个标准章节:

  • 背景:问题背景和上下文
  • 分析:技术分析和思考过程
  • 解决:解决方案和实施步骤
  • 实践:实际代码示例和注意事项

同时,我们还会自动生成一个 Markdown 表格,展示目标仓库、路径、编辑权限等信息,方便 AI 后续操作。毕竟有个清晰的目录,找起东西来也方便。

下面是实际的代码实现:

// ProposalDescriptionMemoryService.cs 中的核心方法
public async Task<string> OptimizeDescriptionAsync(
string title,
string description,
string locale = "zh-CN",
DescriptionOptimizationMemoryContext? memoryContext = null,
CancellationToken cancellationToken = default)
{
// 构建查询参数
var queryContext = BuildQueryContext(title, description);
// 检索历史上下文
var memoryContext = await RetrieveHistoricalContextAsync(queryContext, cancellationToken);
// 生成结构化提示词
var prompt = await BuildOptimizationPromptAsync(
title,
description,
memoryContext,
cancellationToken);
// 调用 AI 进行优化
return await _aiService.CompleteAsync(prompt, cancellationToken);
}

这个流程的关键在于”记忆注入”——我们会把项目惯例、相似案例、负面模式等历史上下文注入到提示词中,让 AI 在优化时能够参考过去的经验。毕竟吃一堑长一智,过去的经验总不能白白浪费了不是?

注意事项

  • 确保当前输入优先于历史记忆,避免覆盖用户显式指定的信息
  • HagIndex 引用必须作为事实来源,不得被历史案例修改
  • 低置信度的纠错建议不应作为强约束注入

2. 语音输入能力:说话比打字更自然

Section titled “2. 语音输入能力:说话比打字更自然”

除了文本输入,我们还支持语音输入。这在描述复杂需求时特别有用——你想想,打一段技术需求可能要几分钟,但说可能几十秒就完事了,毕竟嘴总是比手快。

语音输入的设计重点是”状态管理”,用户必须清楚当前系统处于什么状态。我们定义了以下几种状态:

  • 空闲:系统就绪,可以开始录制
  • 等待上游:正在连接后端服务
  • 录制中:正在录制用户语音
  • 处理中:正在将语音转换为文本
  • 错误:发生错误,需要用户处理

前端的状态模型大概是这样的:

interface VoiceInputState {
status: 'idle' | 'waiting-upstream' | 'recording' | 'processing' | 'error';
duration: number;
error?: string;
deletedSet: Set<string>; // 已删除结果的指纹集合
}
// 开始录制时的状态转换
const handleVoiceInputStart = async () => {
// 先进入等待状态,显示加载动画
setState({ status: 'waiting-upstream' });
// 等待后端就绪确认
const isReady = await waitForBackendReady();
if (!isReady) {
setState({ status: 'error', error: '后端服务未就绪' });
return;
}
// 开始录制
setState({ status: 'recording', startTime: Date.now() });
};
// 处理识别结果
const handleRecognitionResult = (result: RecognitionResult) => {
const fingerprint = normalizeFingerprint(result.text);
// 检查是否已被删除
if (state.deletedSet.has(fingerprint)) {
return; // 跳过已删除的内容
}
// 合并结果到文本框
appendResult(result);
};

这里有个细节:我们用”指纹集合”来管理删除同步。当语音识别返回多条结果时,用户可能会删除其中一些。我们把已删除内容的指纹存起来,后续如果相同内容再出现就自动跳过。这就像记住了哪些菜不爱吃,下次就不会再点了,毕竟谁也不想被同样的问题困扰两次。

3. 提示词管理系统:把 AI 的”脑子”外置

Section titled “3. 提示词管理系统:把 AI 的”脑子”外置”

HagiCode 有一个灵活的提示词管理系统,所有提示词都以文件形式存储:

prompts/
├── metadata/
│ ├── optimize-description.zh-CN.json
│ └── optimize-description.en-US.json
└── templates/
├── optimize-description.zh-CN.hbs
└── optimize-description.en-US.hbs

每个提示词由两部分组成:

  • 元数据文件(.json):定义提示词的场景、版本、参数等信息
  • 模板文件(.hbs):使用 Handlebars 语法的实际提示词内容

元数据文件的格式是这样的:

{
"scenario": "optimize-description",
"locale": "zh-CN",
"version": "1.0.0",
"syntax": "handlebars",
"syntaxVersion": "1.0",
"parameters": [
{
"name": "title",
"type": "string",
"required": true,
"description": "提案标题"
},
{
"name": "description",
"type": "string",
"required": true,
"description": "原始描述"
}
],
"author": "HagiCode Team",
"description": "优化用户输入的技术提案描述",
"lastModified": "2026-04-05",
"tags": ["optimization", "nlp"]
}

模板文件使用 Handlebars 语法,支持参数注入:

你是一个技术提案专家。
<task>
根据以下信息生成结构化的技术提案描述。
</task>
<input>
<title>{{title}}</title>
<description>{{description}}</description>
{{#if memoryContext}}
<memory_context>
{{memoryContext}}
</memory_context>
{{/if}}
</input>
<output_format>
## 背景
[描述问题背景和上下文,包括项目信息、仓库范围等]
## 分析
[技术分析和思考过程,说明为什么需要这个改动]
## 解决
[解决方案和实施步骤,列出关键代码位置]
## 实践
[实际代码示例和注意事项]
</output_format>

这种设计的好处是:

  • 提示词可以像代码一样版本管理
  • 支持多语言,根据用户偏好自动切换
  • 参数化设计,可以动态注入上下文
  • 启动时验证完备性,避免运行时出错

毕竟脑子里的东西不写下来,谁也不知道什么时候就忘了,与其到时候懊悔,不如一开始就做好记录罢了。

4. 渐进式向导:复杂任务拆成小步

Section titled “4. 渐进式向导:复杂任务拆成小步”

对于复杂任务(比如首次安装配置),我们使用了多步骤向导的设计。每个步骤只请求必要信息,并提供清晰的进度指示。生活也是这样嘛,一口吃不成胖子,一步一步来反而更稳妥。

向导的状态模型:

interface WizardState {
currentStep: number; // 0-3,对应 4 个步骤
steps: WizardStep[];
canGoNext: boolean;
canGoBack: boolean;
isLoading: boolean;
error: string | null;
}
interface WizardStep {
id: number;
title: string;
description: string;
completed: boolean;
}
// 步骤导航逻辑
const goToNextStep = () => {
if (wizardState.currentStep < wizardState.steps.length - 1) {
// 验证当前步骤的输入
if (validateCurrentStep()) {
wizardState.currentStep++;
wizardState.steps[wizardState.currentStep - 1].completed = true;
}
}
};
const goToPreviousStep = () => {
if (wizardState.currentStep > 0) {
wizardState.currentStep--;
}
};

每个步骤都有独立的验证逻辑,已完成步骤会有清晰的视觉标记。取消操作会弹出确认对话框,防止用户误操作丢失进度。毕竟走错路可以回头,但如果把路都拆了,那就真的没辙了。

回顾 HagiCode 的渐进式披露实践,我们可以总结出几个核心原则:

  1. 分步引导:把复杂任务拆成小步,每步只请求必要信息
  2. 智能补全:利用历史上下文和项目知识自动补全信息
  3. 即时反馈:每个操作都有清晰的视觉反馈和状态提示
  4. 容错机制:允许用户撤销、重置,避免错误造成不可逆损失
  5. 输入多样化:支持文本、语音等多种输入方式

这套方案在 HagiCode 中的实际效果是:用户输入的平均长度从不到 20 字提升到了结构化的 200-300 字,AI 生成的提案质量显著提高,用户满意度也跟着上来了。

其实这也不奇怪,毕竟你给的信息越多,AI 理解得越准确,返回的结果自然就越好,这和人与人之间的交流也没什么两样。

如果你也在做 AI 相关的产品,希望这些经验能给你带来一些启发。记住:用户不是不想给信息,而是你还没问对问题。渐进式披露的核心,就是找到问问题的最佳时机和方式,只是这个时机和方式,需要一点耐心去摸索罢了。


如果本文对你有帮助,欢迎来 GitHub 给个 Star,关注 HagiCode 项目的后续发展。公测已经开始,现在安装即可体验完整功能:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

AI 输出 Token 优化:文言文极简模式的实践

AI 输出 Token 优化:文言文极简模式的实践

Section titled “AI 输出 Token 优化:文言文极简模式的实践”

在 AI 应用开发中,token 消耗直接影响成本。HagiCode 项目通过 SOUL 系统实现了”文言文极简输出模式”,在不损失信息密度的前提下,将输出 token 降低约 30-50%。本文分享这套方案的实现细节和使用经验。

在 AI 应用开发中,token 消耗是个绕不开的成本问题。尤其是需要 AI 输出大量内容的场景,怎么在不损失信息密度的情况下降低输出 token,这问题想多了也挺让人头疼。

传统的优化思路都集中在输入端:精简系统提示词、压缩上下文、用更高效的编码方式。只是这些方法终究会碰到天花板,再压缩就可能影响 AI 的理解能力和输出质量了。这无异于删减内容,意义不大。

那输出端呢?能不能让 AI 用更简洁的方式表达同样的意思?

这问题看似简单,其实藏着不少门道。直接让 AI”简洁点”,它可能真的就只给几个词;加上”保持信息完整”,它又可能回复到原来的冗长风格。约束太强影响可用性,约束太弱没有效果,这中间的平衡点在哪,谁也说不准。

为了解决这些痛点,我们做了一个大胆的决定:从语言风格入手,设计一套可配置、可组合的表达方式约束系统。这个决定带来的变化,可能比你想象的还要大——稍后我会具体说,或许你会有些意外。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。

HagiCode 是一个开源的 AI 代码助手项目,支持多种 AI 模型和自定义配置。在开发过程中,我们发现了 AI 输出 token 过高的问题,并设计了一套解决方案。如果你觉得这套方案有价值,说明我们的工程实力还不错——那么 HagiCode 本身也值得关注一下,毕竟代码不会撒谎。

SOUL 系统的全称是 Soul Oriented Universal Language,是 HagiCode 项目中用于定义 AI Hero 语言风格的配置系统。它的核心思想是:通过约束 AI 的表达方式,在保持信息完整性的前提下,使用更简洁的语言形式来输出内容。

这东西就像给 AI 戴上了一个语言面具…罢了,其实也没那么玄乎。

SOUL 系统采用前后端分离的架构:

前端(Soul Builder)

  • 基于 React + TypeScript + Vite 构建
  • 位于 repos/soul/ 目录
  • 提供可视化的 Soul 构建界面
  • 支持双语(zh-CN / en-US)

后端

  • 基于 .NET (C#) + Orleans 分布式运行时
  • Hero 实体包含 Soul 字段(最大 8000 字符)
  • 通过 SessionSystemMessageCompiler 将 Soul 注入系统提示词

Agent Templates 生成

  • 从参考材料生成
  • 输出到 /agent-templates/soul/templates/ 目录
  • 包含 50 组主 Catalog 和 10 组正交维度

在 Session 首次执行时,系统会读取 Hero 的 Soul 配置,将其注入到系统提示词中:

sequenceDiagram
participant UI as 用户界面
participant Session as SessionGrain
participant Hero as Hero 仓库
participant AI as AI 执行器
UI->>Session: 发送消息(绑定 Hero)
Session->>Hero: 读取 Hero.Soul
Session->>Session: 缓存 Soul 快照
Session->>AI: 构建 AIRequest(注入 Soul)
AI-->>Session: 执行结果
Session-->>UI: 流式响应

注入的系统提示词格式为:

<hero_soul>
[用户自定义的 Soul 内容]
</hero_soul>

这套注入机制在 SessionSystemMessageCompiler.cs 中实现:

internal static string? BuildSystemMessage(
string? existingSystemMessage,
string? languagePreference,
IReadOnlyList<HeroTraitDto>? traits,
string? soul)
{
var segments = new List<string>();
// ... 语言偏好和 Traits 处理 ...
var normalizedSoul = NormalizeSoul(soul);
if (!string.IsNullOrWhiteSpace(normalizedSoul))
{
segments.Add($"<hero_soul>\n{normalizedSoul}\n</hero_soul>");
}
// ... 其他系统消息 ...
return segments.Count == 0 ? null : string.Join("\n\n", segments);
}

代码也看了,原理也懂了,其实就这么回事。

文言文极简模式是 SOUL 系统中最具代表性的节约 token 方案。它的核心原理是利用文言文的高语义密度特性,在保持信息完整的前提下压缩输出长度。

文言文具有几个天然优势:

  1. 语义压缩:相同含义可以用更少的字符表达
  2. 去除冗余:文言文本身就省略了很多现代汉语中的连接词和助词
  3. 结构简洁:单句信息密度高,适合作为 AI 输出的载体

以一个实际例子来说明:

现代汉语输出(约 80 字):

根据你的代码分析,我发现了几个问题。首先,在第 23 行,变量名太长了,建议缩短一些。其次,在第 45 行,你没有处理空值的情况,应该加上判断逻辑。最后,整体的代码结构还可以,但是可以进一步优化。

文言文极简输出(约 35 字,节约 56%):

代码审阅毕:第 23 行变量名冗长,宜缩写;第 45 行缺空值处理,应加判断。整体结构尚可,微调即可。

这差距,想想也挺有意思的。

文言文极简模式的完整 Soul 配置如下:

{
"id": "soul-orth-11-classical-chinese-ultra-minimal-mode",
"name": "文言文极简输出模式",
"summary": "以尽量可懂的文言文压缩语义密度,尽可能少字达意,只保留结论、判断与必要动作,从而大幅降低输出 token",
"soul": "你的人设内核来自「文言文极简输出模式」:以尽量可懂的文言文压缩语义密度,尽可能少字达意,只保留结论、判断与必要动作,从而大幅降低输出 token。\n保持以下标志性语言特征:1. 优先使用简明文言句式,如「可」「宜」「勿」「已」「然」「故」等,避免生僻艰涩字词;\n2. 单句尽量压缩至 4-12 字,删除铺垫、寒暄、重复解释与无效修饰;\n3. 非必要不展开论证,用户未追问则只给结论、步骤或判断;\n4. 不改变主 Catalog 的核心人设,只将表达收束为克制、古雅、极简的短句。"
}

这个模板的设计有几个要点:

  1. 约束明确:单句 4-12 字,删除冗余,结论优先
  2. 避免晦涩:使用简明文言句式,避免生僻字词
  3. 保持人设:只改变表达方式,不改变核心人设

配置这东西,调来调去也就那么几个参数罢了。

除了文言文模式,HagiCode 的 SOUL 系统还提供了其他多种节约 token 的模式:

电报式极简输出模式soul-orth-02):

  • 单句严格控制在 10 字以内
  • 禁止修饰性形容词
  • 全程无语气词、感叹号、叠词

短句碎碎念模式soul-orth-01):

  • 句子控制在 1-5 个字
  • 模拟自言自语的碎片化表达
  • 弱化逻辑,优先传递情绪

引导式问答模式soul-orth-03):

  • 通过提问引导用户思考
  • 减少直接输出内容
  • 交互式降低 token 消耗

这些模式的设计思路各有侧重,但核心目标是一致的:在保持信息质量的前提下降低输出 token。条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

SOUL 系统的一个强大特性是支持主 Catalog 与正交维度的交叉组合:

  • 50 组主 Catalog:定义基础人设(如治愈系、学霸系、高冷系等)
  • 10 组正交维度:定义表达方式(如文言文、电报式、问答式等)
  • 组合效果:可生成 500+ 种独特的语言风格组合

例如,你可以将”专业开发工程师”与”文言文极简输出模式”组合,得到一个既专业又简洁的 AI 助手。这种灵活性让 SOUL 系统能够适应各种不同使用场景。想怎么配就怎么配,反正组合多得你玩不过来…

访问 soul.hagicode.com,按以下步骤操作:

  1. 选择主 Catalog(如”专业开发工程师”)
  2. 选择正交维度(如”文言文极简输出模式”)
  3. 预览生成的 Soul 内容
  4. 复制生成的 Soul 配置

点点点的事情,应该不用我多说吧。

通过 Web 界面或 API,将 Soul 配置应用到 Hero:

// Hero Soul 更新示例
const heroUpdate = {
soul: "你的人设内核来自「文言文极简输出模式」:...",
soulCatalogId: "soul-orth-11-classical-chinese-ultra-minimal-mode",
soulDisplayName: "文言文极简输出模式",
soulStyleType: "orthogonal-dimension",
soulSummary: "以尽量可懂的文言文压缩语义密度..."
};
await updateHero(heroId, heroUpdate);

用户可以基于预设模板进行微调,或完全自定义。下面是一个代码审查场景的自定义示例:

你是一位追求极致简洁的代码审查员。
所有输出必须遵循:
1. 仅指出具体问题和行号
2. 每条问题不超过 15 字
3. 使用「宜」「应」「勿」等简洁词汇
4. 不做多余解释
示例输出:
- 第 23 行:变量名过长,宜缩写
- 第 45 行:未处理空值,应加判断
- 第 67 行:逻辑冗余,可简化

想怎么改就怎么改,反正模板这东西也只是个起点而已。

兼容性

  • 文言文模式适配全部 50 组主 Catalog
  • 可与任何基础人设组合使用
  • 不会改变主 Catalog 的核心人设

缓存机制

  • Soul 在 Session 首次执行时缓存
  • 同一 SessionId 内复用缓存
  • 修改 Hero 配置不影响已启动的 Session

限制约束

  • Soul 字段最大长度 8000 字符
  • 历史数据中无 Soul 字段的 Hero 仍可正常使用
  • Soul 与 style 装备位独立,不会相互覆盖

根据项目的实际测试数据,使用文言文极简模式后的效果如下:

场景原始输出 token文言文模式节约比例
代码审查85042051%
技术问答62038039%
方案建议110068038%
平均--30-50%

数据来自 HagiCode 项目的实际使用统计,具体效果因场景而异。不过省下来的 token,积少成多,钱包会感谢你的。

HagiCode 的 SOUL 系统提供了一种创新性的 AI 输出优化思路:通过约束表达方式来降低 token 消耗,而不是压缩信息本身。文言文极简模式作为其中最具代表性的方案,在实际使用中取得了 30-50% 的 token 节约效果。

这套方案的核心价值在于:

  1. 保持信息质量:不是简单截断输出,而是用更高效的方式表达
  2. 灵活可组合:支持 500+ 种人设与表达方式的组合
  3. 易于使用:通过 Soul Builder 可视化界面,无需编写代码
  4. 生产级稳定:已在项目中验证,支持大规模使用

如果你也在开发 AI 应用,或者对 HagiCode 项目感兴趣,欢迎来交流。开源的意义在于共同进步,也期待看的到你的创新用法。毕竟,一个人走得快,一群人走得远…这话说得挺俗套,但道理就是这么个道理。


如果本文对你有帮助:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

从 CLI 调用到 SDK 集成:GitHub Copilot 在 .NET 项目中的最佳实践

从 CLI 调用到 SDK 集成:GitHub Copilot 在 .NET 项目中的最佳实践

Section titled “从 CLI 调用到 SDK 集成:GitHub Copilot 在 .NET 项目中的最佳实践”

从命令行调用到官方 SDK 集成的升级之路,说起来也算是一段经历,今天就分享我们在 HagiCode 项目中踩过的坑和学到的东西。

GitHub Copilot SDK 在 2025 年正式发布后,我们开始将其集成到 AI 能力层中。在此之前,项目主要通过直接调用 Copilot CLI 命令行工具来使用 GitHub Copilot 能力,这种方式其实也存在几个明显问题:

  • 进程管理复杂:需要手动管理 CLI 进程的生命周期、启动超时和进程清理——毕竟进程这东西,说崩溃就崩溃了,也没什么预兆
  • 事件处理不完整:原始 CLI 调用难以捕获模型推理过程和工具执行的细粒度事件,就像只能看到结果,却看不到思考的过程
  • 会话管理困难:缺乏有效的会话复用和恢复机制,每次都得重新开始,想想也是挺累的
  • 兼容性问题:CLI 参数更新频繁,需要持续维护参数兼容性逻辑,这无异于和风车作战了

这些问题在日常开发中逐渐显现,特别是在需要实时追踪模型推理过程(thinking)和工具执行状态时,CLI 调用的局限性尤为明显。我们也算是想明白了,需要一个更底层、更完整的集成方式——毕竟,条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,在开发过程中我们需要深度集成 GitHub Copilot 的各种能力——从基础的代码补全到复杂的多轮对话和工具调用。这些实际需求推动我们从 CLI 调用升级到了官方 SDK 集成。

如果你对本文的实践方案感兴趣,说明我们的工程实践可能对你有帮助——那么 HagiCode 项目本身也值得关注一下。或许在文末你会发现更多关于项目的信息和链接,谁知道呢…

项目采用了分层架构来解决 CLI 调用的问题:

┌─────────────────────────────────────────────────────────┐
│ hagicode-core (Orleans Grains + AI Provider Layer) │
│ - CopilotAIProvider: 将 AIRequest 转换为 CopilotOptions │
│ - GitHubCopilotGrain: Orleans 分布式执行接口 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ HagiCode.Libs (Shared Provider Layer) │
│ - CopilotProvider: CLI Provider 接口实现 │
│ - ICopilotSdkGateway: SDK 调用抽象 │
│ - GitHubCopilotSdkGateway: SDK 会话管理与事件分发 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ GitHub Copilot SDK (Official .NET SDK) │
│ - CopilotClient: SDK 客户端 │
│ - CopilotSession: 会话管理 │
│ - SessionEvent: 事件流 │
└─────────────────────────────────────────────────────────┘

这种分层设计带来的技术优势,其实也还挺实用的:

  1. 关注点分离:核心业务逻辑与 SDK 实现细节解耦——毕竟,什么层做什么事,井水不犯河水
  2. 可测试性:通过 ICopilotSdkGateway 接口可以轻松进行单元测试,测试起来也不那么费劲
  3. 复用性:HagiCode.Libs 可被多个项目引用,写一次,多处用
  4. 可维护性:SDK 升级只需修改 Gateway 层,上面的代码不用动,美得很

认证是 SDK 集成的第一步,也是最重要的一步——毕竟,门都进不去,后面的事情就免谈了。我们设计了一个灵活的认证配置,支持多种认证来源:

// CopilotProvider.cs - 认证来源配置
public class CopilotOptions
{
public bool UseLoggedInUser { get; set; } = true;
public string? GitHubToken { get; set; }
public string? CliUrl { get; set; }
}
// 转换为 SDK 请求
return new CopilotSdkRequest(
GitHubToken: options.AuthSource == CopilotAuthSource.GitHubToken
? options.GitHubToken
: null,
UseLoggedInUser: options.AuthSource != CopilotAuthSource.GitHubToken
);

这个设计的好处,其实也挺明显的:

  • 支持已登录用户模式(无需 token),适合桌面端场景——用户用自己的账号登录就行
  • 支持 GitHub Token 模式,适用于服务端部署——统一管理也方便
  • 支持 Copilot CLI URL 覆盖,方便企业代理配置——企业环境嘛,总有些特殊的规矩

在实际使用中,这种灵活的认证方式大大简化了不同部署场景的配置工作。桌面端可以使用用户自己的 Copilot 登录状态,服务端则可以通过 Token 进行统一管理。怎么说呢,各取所需罢了。

SDK 最强大的能力之一,应该就是对事件流的完整捕获了。我们实现了一个事件分发系统,能够实时处理各种 SDK 事件——毕竟,知道过程和只知道结果,感觉还是不一样的:

// GitHubCopilotSdkGateway.cs - 事件分发核心逻辑
internal static SessionEventDispatchResult DispatchSessionEvent(
SessionEvent evt, bool sawDelta)
{
switch (evt)
{
case AssistantReasoningEvent reasoningEvent:
// 捕获模型推理过程
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.ReasoningDelta,
Content: reasoningEvent.Data.Content));
break;
case ToolExecutionStartEvent toolStartEvent:
// 捕获工具调用开始
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.ToolExecutionStart,
ToolName: toolStartEvent.Data.ToolName,
ToolCallId: toolStartEvent.Data.ToolCallId));
break;
case ToolExecutionCompleteEvent toolCompleteEvent:
// 捕获工具调用完成及结果
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.ToolExecutionEnd,
Content: ExtractToolExecutionContent(toolCompleteEvent)));
break;
default:
// 未处理事件作为 RawEvent 保留
events.Add(new CopilotSdkStreamEvent(
CopilotSdkStreamEventType.RawEvent,
RawEventType: evt.GetType().Name));
break;
}
}

这个实现带来的价值,怎么说呢:

  • 完整捕获模型推理过程(thinking):用户可以看到 AI 的思考过程,而不仅仅是最终结果——就像知道答案不如知道怎么思考出来的
  • 实时追踪工具执行状态:知道哪些工具正在运行、何时完成、返回了什么结果
  • 零事件丢失:通过 fallback 到 RawEvent 机制,确保所有事件都被记录,什么都不落下

在 HagiCode 的实际使用中,这些细粒度的事件让用户能够更深入地理解 AI 的工作过程,特别是在调试复杂任务时——这还是有点用处的。

从 CLI 调用迁移到 SDK 后,我们发现一些原有的 CLI 参数在 SDK 中不再适用。为了保持向后兼容,我们实现了一个参数过滤系统——毕竟,旧配置不能用,也挺让人头疼的:

// CopilotCliCompatibility.cs - 参数过滤
private static readonly Dictionary<string, string> RejectedFlags = new()
{
["--headless"] = "不支持的启动参数",
["--model"] = "通过 SDK 原生字段传递",
["--prompt"] = "通过 SDK 原生字段传递",
["--interactive"] = "由 provider 管理交互",
};
public static CopilotCliArgumentBuildResult BuildCliArgs(CopilotOptions options)
{
// 过滤不支持的参数,保留兼容参数
// 生成诊断信息
}

这样做的好处:

  • 自动过滤不兼容的 CLI 参数,避免运行时错误——程序崩溃可不是闹着玩的
  • 生成清晰的错误诊断信息,帮助开发者快速定位问题
  • 保证 SDK 稳定性,不受 CLI 参数变化的影响

在升级过程中,这个兼容性处理机制帮助我们平滑过渡,旧的配置文件仍然可以使用,只需要根据诊断信息逐步调整即可——也算是个渐进的过程了。

Copilot SDK 的会话创建成本较高,频繁创建和销毁会话会影响性能。我们实现了一个会话池管理系统——就像池子里的水,用完了再装,不如留着下次接着用:

// CopilotProvider.cs - 会话池管理
await using var lease = await _poolCoordinator.AcquireCopilotRuntimeAsync(
request,
async ct => await _gateway.CreateRuntimeAsync(sdkRequest, ct),
cancellationToken);
if (lease.IsWarmLease)
{
// 复用已有会话
yield return CreateSessionReusedMessage();
}
await foreach (var eventData in lease.Entry.Resource.SendPromptAsync(...))
{
yield return MapEvent(eventData);
}

会话池化的好处:

  • 会话复用:相同 sessionId 的请求可以复用已有会话,减少启动开销
  • 支持会话恢复:网络中断后可以恢复之前的会话状态——毕竟网络这东西,谁敢保证一直稳定呢
  • 自动池化管理:自动清理过期会话,避免资源泄漏

在 HagiCode 的实际使用中,会话池化显著提升了响应速度,特别是在处理连续对话时效果明显——这种提升还是能感觉到的。

HagiCode 使用 Orleans 作为分布式框架,我们将 Copilot SDK 集成到了 Orleans Grain 中——分布式这东西,说起来复杂,用起来倒也挺顺手:

// GitHubCopilotGrain.cs - 分布式执行
public async IAsyncEnumerable<GitHubCopilotResponse> ExecuteCommandStreamAsync(
string command,
CancellationToken token = default)
{
var provider = await aiProviderFactory.GetProviderAsync(AIProviderType.GitHubCopilot);
await foreach (var chunk in provider.SendMessageAsync(request, null, token))
{
// 映射为统一的响应格式
yield return BuildChunkResponse(chunk, startedAt);
}
}

Orleans 集成带来的优势:

  • 统一的 AI Provider 抽象:可以轻松切换不同的 AI 提供商——今天用这个,明天用那个,也挺灵活
  • 多租户隔离:不同用户的 Copilot 会话相互隔离,井水不犯河水
  • 持久化会话状态:会话状态可以跨服务器重启恢复,重启也不怕丢数据

对于需要处理大量并发请求的场景,Orleans 的分布式能力提供了很好的扩展性——毕竟,单机扛不住的时候,只能靠分布式顶上了。

以下是一个完整的配置示例——直接复制粘贴改改就能用:

{
"AI": {
"Providers": {
"Providers": {
"GitHubCopilot": {
"Enabled": true,
"ExecutablePath": "copilot",
"Model": "gpt-5",
"WorkingDirectory": "/path/to/project",
"Timeout": 7200,
"StartupTimeout": 30,
"UseLoggedInUser": true,
"NoAskUser": true,
"Permissions": {
"AllowAllTools": false,
"AllowedTools": ["Read", "Bash", "Grep"],
"DeniedTools": ["Edit"]
}
}
}
}
}
}

在实际使用中,我们总结了一些需要注意的地方——有些是踩坑得来的经验:

启动超时配置:首次启动 Copilot CLI 需要较长时间,建议设置 StartupTimeout 至少 30 秒。如果是首次登录,可能需要更长的时间——毕竟首次登录总得验证一下,这也没办法。

权限管理:生产环境避免使用 AllowAllTools: true。使用 AllowedTools 白名单控制可用工具,使用 DeniedTools 黑名单禁止危险操作。这样可以有效防止 AI 执行危险命令——安全这东西,小心点总是对的。

会话管理:相同 sessionId 的请求会自动复用会话。会话状态通过 ProviderSessionId 持久化。取消操作通过 CancellationTokenSource 传递——会话管理做得好,体验自然就好。

诊断输出:不兼容的 CLI 参数会生成 diagnostic 类型消息。原始 SDK 事件以 event.raw 类型保留。错误信息包含分类(启动超时、参数不兼容等),方便排查问题——出了问题能快速定位,也算是一种安慰了。

基于我们的实际经验,这里分享一些最佳实践——算是一些总结吧:

1. 使用工具白名单

var request = new AIRequest
{
Prompt = "分析这个文件",
AllowedTools = new[] { "Read", "Grep", "Bash(git:*)" }
};

通过白名单明确指定允许的工具,避免 AI 执行意外操作。特别是对于有写入权限的工具(如 Edit),需要格外谨慎——毕竟删库这种事,谁也不想经历。

2. 设置合理的超时

options.Timeout = 3600; // 1小时
options.StartupTimeout = 60; // 1分钟

根据任务的复杂度设置合适的超时时间。太短可能导致任务中断,太长则可能浪费资源等待无响应的请求——凡事适度,过犹不及。

3. 启用会话复用

options.SessionId = "my-session-123";

为相关任务设置相同的 sessionId,可以复用之前的会话上下文,提升响应速度——上下文这东西,有时候还挺重要的。

4. 处理流式响应

await foreach (var chunk in provider.StreamAsync(request))
{
switch (chunk.Type)
{
case StreamingChunkType.ThinkingDelta:
// 处理推理过程
break;
case StreamingChunkType.ToolCallDelta:
// 处理工具调用
break;
case StreamingChunkType.ContentDelta:
// 处理文本输出
break;
}
}

流式响应可以实时显示 AI 的处理进度,提升用户体验。特别是对于耗时任务,实时反馈非常重要——看着进度条总比干等着强。

5. 错误处理和重试

try
{
await foreach (var chunk in provider.StreamAsync(request))
{
// 处理响应
}
}
catch (CopilotSessionException ex)
{
// 处理会话异常
logger.LogError(ex, "Copilot session failed");
// 根据异常类型决定是否重试
}

适当的错误处理和重试机制可以提升系统的稳定性——谁也不能保证程序永远不出错,出了错能处理好就行。

从 CLI 调用到 SDK 集成的升级,为 HagiCode 项目带来了显著的价值——怎么说呢,这次升级还是挺值的:

  • 稳定性提升:SDK 提供了更稳定的接口,不受 CLI 版本变化影响——不用天天担心版本更新了
  • 功能完整性:能够捕获完整的事件流,包括推理过程和工具执行状态——过程和结果都能看到
  • 开发效率:类型安全的 SDK 接口让开发更高效,减少运行时错误——有类型检查,心里踏实
  • 用户体验:实时的事件反馈让用户更清晰地了解 AI 的工作过程——知道它在想什么,总比一无所知强

这次升级不仅仅是技术方案的替换,更是对整个 AI 能力层架构的优化。通过分层设计和抽象接口,我们获得了更好的可维护性和可扩展性——架构做好了,后面的事情就好办了。

如果你正在考虑将 GitHub Copilot 集成到你的 .NET 项目中,希望本文的实践经验能够帮助你少走一些弯路。官方 SDK 确实比 CLI 调用更加稳定和完整,值得投入时间去理解和掌握——毕竟,正确的工具能让事情事半功倍,这话也不是没有道理的。


如果本文对你有帮助:


写到这里也差不多了。技术文章嘛,总是写不完的,毕竟技术在发展,我们也在学习。如果你在使用 HagiCode 的过程中有什么问题或建议,欢迎随时联系我们。好了,就这样吧…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

AI 编程助手的幻觉问题:如何用 OpenSpec 实现规范驱动开发

AI 编程助手的幻觉问题:如何用 OpenSpec 实现规范驱动开发

Section titled “AI 编程助手的幻觉问题:如何用 OpenSpec 实现规范驱动开发”

AI 编程助手虽然强大,但常常生成不符合实际需求、违反项目规范的代码。本文分享 HagiCode 项目如何通过 OpenSpec 流程实现”规范驱动开发”,用结构化的提案机制显著减少 AI 幻觉风险。

用过 GitHub Copilot 或 ChatGPT 写代码的同学可能都有过这样的经历:AI 生成的代码看起来很漂亮,但真正用起来却问题百出。可能是用错了项目里的某个组件,可能是忽略了团队的编码规范,也可能是基于一些不存在的假设写了大段逻辑。

这就是所谓的”AI 幻觉”问题——在编程领域,它表现为生成看似合理但实际上不符合项目实际情况的代码。

其实这事儿也挺无奈的。AI 编程助手越来越普及,这个问题也就越发严重了。毕竟,AI 缺乏对项目历史、架构决策和编码规范的理解,而自由度过高又容易让它”创造性”地生成不符合实际的代码。这和写文章倒是挺像的,没有章法就容易写得天马行空,可实际上并不是那么回事儿。

为了解决这些痛点,我们做了一个大胆的决定:不是试图让 AI 更聪明,而是给它套上一个”规范”的笼子。这个决定带来的变化,可能比你想象的还要大——稍后我会具体说。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,致力于通过结构化的工程实践来解决 AI 编程中的实际问题。

在深入解决方案之前,咱们先看看问题到底出在哪。毕竟知己知彼,方能百战不殆,只是这话用在 AI 身上,好像也有点意思。

AI 模型训练时用的是公开的代码库,但你的项目有它自己的历史、约定和架构决策。这些”隐性知识” AI 没法直接获取,所以生成的代码往往和项目实际情况脱节。

这也不能全怪 AI,毕竟它又没在你们项目待过,哪知道你们那些江湖规矩呢?就像新来的实习生,不懂规矩也正常,只是代价可能有点大罢了。

当你问 AI”帮我实现一个用户认证功能”时,它可能生成任何形式的代码。没有明确的约束,AI 就会按照它”认为”合理的方式去实现,而不是按照你项目的要求。

这就像是让一个从未学过你项目规范的人自由发挥,能不出问题吗?其实也不是它不负责任,只是它压根不知道什么是责任。

AI 生成代码后,如果没有经过结构化的审查流程,那些基于错误假设的代码就会直接进入代码库。等到测试甚至生产环境才发现问题,代价就太大了。

这无异于亡羊补牢,只是羊都已经跑光了,补牢又有什么用呢?这道理谁都懂,可真正做起来,又总觉得麻烦。毕竟在事情变坏之前,谁愿意多花时间呢?

HagiCode 选择 OpenSpec 作为解决方案,核心思路是:所有代码变更必须通过结构化的提案流程,把抽象的想法转化为可执行的实施计划。

这话说得挺高大上的,其实就是让 AI 在写代码之前,先把需求文档写好罢了。毕竟凡事预则立,不预则废,古人诚不我欺。

OpenSpec 是一个基于 npm 的命令行工具(@fission-ai/openspec),它定义了一套标准的提案文件结构和验证机制。简单说,就是让 AI 在写代码之前,先”写好需求文档”。

OpenSpec 通过一个三步流程来确保提案质量:

Step 1:初始化提案 - 设置会话状态为 Openspecing Step 2:中间处理 - 保持 Openspecing 状态,逐步完善工件 Step 3:完成提案 - 转换到 Reviewing 状态

这个设计有个很巧妙的细节:第一步使用的是 ProposalGenerationStart 类型,完成后不会触发状态转换。这确保了整个多步流程完成前,不会过早进入审查阶段。

其实这个细节也挺有意思的,就像做菜一样,火候没到就揭锅盖,肯定是什么都做不好的。只有耐心一点,一步一步来,最后才能做出一道好菜。

// HagiCode 项目中的实现
public enum MessageAssociationType
{
ProposalGeneration = 2,
ProposalExecution = 3,
/// <summary>
/// 标记三步提案生成流程的开始
/// 完成后不会转换到 Reviewing 状态
/// </summary>
ProposalGenerationStart = 5
}

每个 OpenSpec 提案都遵循相同的目录结构:

openspec/
├── changes/ # 活跃和归档的变更
│ ├── {change-name}/
│ │ ├── proposal.md # 提案描述
│ │ ├── design.md # 设计文档
│ │ ├── specs/ # 技术规范
│ │ └── tasks.md # 可执行任务清单
│ └── archive/ # 归档的变更
└── specs/ # 独立的规范库

根据 HagiCode 项目的统计,目前已经有 4000+ 个归档变更和 15 万+ 行规范文件。这些历史积累不仅让 AI 有章可循,也为团队提供了宝贵的知识库。

这就像是古人留下的典籍,读多了自然就能悟出点门道来。只是现在这典籍不是写在竹简上,而是存放在文件里罢了。

系统实现了多层验证来确保提案质量:

// 验证必需文件存在
ValidateProposalFiles()
// 验证执行前提条件
ValidateExecuteAsync()
// 验证启动条件
ValidateStartAsync()
// 验证归档条件
ValidateArchiveAsync()
// 提案名称格式验证(kebab-case)
ValidateNameFormat()

这些验证就像是层层把关的守门人,只有真正合格的提案才能通过。虽然看起来繁琐,可总比让糟糕的代码进入代码库要强吧?

HagiCode 中的 AI 执行时使用预定义的 Handlebars 模板,这些模板包含明确的步骤指导和保护措施。比如:

  • 禁止在未理解用户意图时继续
  • 禁止生成未验证的代码
  • 要求在名称无效时重新提供
  • 如果变更已存在,建议使用继续命令而不是重新创建

这种”带着脚镣跳舞”的方式,反而让 AI 更聚焦于理解需求和生成符合规范的代码。其实约束这东西,也不一定是坏事,毕竟自由过头了就容易乱套。

实践:如何在项目中使用 OpenSpec

Section titled “实践:如何在项目中使用 OpenSpec”
Terminal window
npm install -g @fission-ai/openspec@1
openspec --version # 验证安装

在项目根目录会自动创建 openspec/ 文件夹结构。

这步其实也没什么好说的,安装工具嘛,大家都懂。只是记得用 @fission-ai/openspec@1 这个版本,新版本可能有坑,毕竟稳定压倒一切。

在 HagiCode 的对话界面中,使用快捷命令:

/opsx:new

或者指定变更名称和目标仓库:

/opsx:new "add-user-auth" --repos "repos/web"

创建提案这事儿,就像写文章列提纲一样,有了提纲后面就好写了。只是很多人喜欢直接开始写,写到一半才发现思路不通,那才叫头疼。

使用 /opsx:continue 逐步生成所需的工件:

proposal.md - 描述变更的目的和范围

# Proposal: Add User Authentication
## Why
当前系统缺少用户认证功能,无法保护敏感 API。
## What Changes
- 添加 JWT 认证中间件
- 实现登录/注册 API
- 更新前端集成

design.md - 详细的技术设计

# Design: Add User Authentication
## Context
当前使用公开 API,任何人均可访问...
## Decisions
1. 选择 JWT 而非 Session...
2. 使用 HS256 算法...
## Risks
- 令牌泄露风险...
- 缓解措施...

specs/ - 技术规范和测试场景

# user-auth Specification
## Requirements
### Requirement: JWT Token Generation
系统 SHALL 使用 HS256 算法生成 JWT 令牌。
#### Scenario: Valid login
- WHEN 用户提供有效凭据
- THEN 系统 SHALL 返回有效的 JWT 令牌

tasks.md - 可执行的任务清单

# Tasks: Add User Authentication
## 1. Backend Changes
- [ ] 1.1 创建 AuthController
- [ ] 1.2 实现 JWT 中间件
- [ ] 1.3 添加单元测试

这些工件其实就像是写文章的草稿,草稿写好了,正文自然就顺畅了。只是很多人不喜欢写草稿,觉得浪费时间,可实际上草稿才是最能理清思路的地方。

完成所有工件后:

/opsx:apply

AI 会读取所有上下文文件,按照 tasks.md 中的清单逐步执行任务。这时候因为有了清晰的规范,生成的代码质量会高很多。

其实到了这一步,事情就已经成了一半了。有了明确的任务清单,剩下的就是按部就班地执行罢了。只是很多人跳过了前面的步骤,直接到这里,那质量自然就难以保证了。

变更完成后:

/opsx:archive

将完成的变更移动到 archive/ 目录,方便以后查阅和复用。

归档这事儿挺重要的,就像把写完的文章好好收起来一样。以后遇到类似的问题,翻一翻以前的记录,可能就有答案了。只是很多人懒得做,觉得麻烦,可实际上这些积累才是最宝贵的财富。

使用 kebab-case 格式,以字母开头,仅包含小写字母、数字和连字符:

  • add-user-auth
  • AddUserAuth
  • add--user-auth

命名规范这东西,说起来也没啥大不了的,只是统一一点总归是好的。毕竟代码这事儿, consistency 很重要,只是很多人不在意罢了。

  1. 在三步流程的步骤 1 使用错误的类型 - 会过早转换状态
  2. 忘记在最后一步触发状态转换 - 会卡在 Openspecing 状态
  3. 跳过审查直接执行 - 应该先验证所有工件完整

这些错误其实都是新手容易犯的,老手自然知道怎么避免。只是新手总有变老手的一天,走了弯路也就罢了,只希望不要走太多弯路就好。

OpenSpec 支持同时管理多个提案,这在处理大型功能时特别有用:

Terminal window
# 查看所有活动变更
openspec list
# 切换到特定变更
openspec apply "add-user-auth"
# 查看变更状态
openspec status --change "add-user-auth"

多变更管理这事儿,就像同时写几篇文章一样,需要一点技巧和耐心。只是习惯了就好了,毕竟人嘛,总能适应的。

了解状态转换有助于排查问题:

Init → Drafting → Openspecing → Reviewing → Executing → ExecutionCompleted → Completed → Archived
  • Openspecing:生成规划中
  • Reviewing:审查中(可反复修改工件)
  • Executing:执行中(应用 tasks.md)

状态机这东西,说白了就是一套规则。规则这东西,有时候挺烦人的,但更多时候是有用的。毕竟没有规矩不成方圆,这话古人早就说过了。

通过 OpenSpec 流程,HagiCode 项目在解决 AI 幻觉问题上取得了显著效果:

  1. 减少幻觉 - AI 必须遵循结构化规范,不能随意生成代码
  2. 提高质量 - 多层验证确保变更符合项目标准
  3. 加速协作 - 归档的变更为后续开发提供参考
  4. 可追溯性 - 每个变更都有完整的提案、设计、规范和任务记录

这套方案不是让 AI 变聪明,而是给它套上”规范”的笼子。实践证明,带着脚镣跳舞,反而跳得更好。

其实这道理也简单,约束不一定是什么坏事。就像写文章,有了格式的约束,反而更容易写出好东西来。只是很多人不喜欢约束,觉得限制了自己的创造力,可实际上创造力也需要土壤才能开花结果。

如果你也在使用 AI 编程助手,并且遇到过类似的问题,不妨试试 OpenSpec。规范驱动开发可能看起来多了一些步骤,但这些前期投入会在代码质量和维护效率上得到数倍的回报。

毕竟,慢一点,有时候反而是快一点。只是很多人不明白这个道理罢了…


如果本文对你有帮助,欢迎来 GitHub 给个 Star。HagiCode 公测已开始,现在安装即可参与体验。


这文章写得也差不多了,其实也没什么特别高深的东西,只是把一些实践经验总结了一下罢了。希望对大家有用,毕竟分享这东西,自己学到了,也让别人学到了,两全其美,何乐而不为呢?

只是文章终究是文章,真正有用的还是实践。毕竟纸上得来终觉浅,绝知此事要躬行,古人诚不我欺…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

在 React 项目中优雅实现新用户引导:HagiCode 的 driver.js 实践

在 React 项目中优雅实现新用户引导:HagiCode 的 driver.js 实践

Section titled “在 React 项目中优雅实现新用户引导:HagiCode 的 driver.js 实践”

当用户第一次打开你的产品时,他们真的知道该从哪里开始吗?这篇文章聊聊我们在 HagiCode 项目里用 driver.js 做新用户引导的那些事儿,也算是抛砖引玉罢了。

你有没有遇到过这样的场景:新用户注册了你的产品,打开页面后一脸茫然,东张西望,不知道该点哪里、该做什么。作为开发者,我们总以为用户会”自己探索”,毕竟人的好奇心是无限的嘛。可现实是——大部分用户会在几分钟内因为找不到入口而悄悄离开,就像故事开始得突然,结束得也自然。

新用户引导是解决这个问题的重要手段,只是实现起来也不那么简单。一个好的引导系统需要:

  • 能够精准定位页面元素并高亮显示
  • 支持多步骤引导流程
  • 能够记住用户的选择(完成/跳过)
  • 不影响页面性能和正常交互
  • 代码结构清晰,易于维护

在开发 HagiCode 的过程中,我们也遇到了同样的挑战。HagiCode 是一个 AI 代码助手项目,核心工作流是”用户创建提案 → AI 生成计划 → 用户审核 → AI 执行”这样一套 OpenSpec 流程。对于第一次接触这个概念的用户来说,这套流程是全新的,必须有一个好的引导来帮助他们快速上手。毕竟,新事物总是需要一点时间的。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个基于 Claude 的 AI 代码助手,通过 OpenSpec 工作流帮助开发者更高效地完成代码任务。你可以在 GitHub 上查看我们的开源代码。

在技术选型阶段,我们评估了几个主流的引导库,怎么说呢,每个都有自己的特点:

  • Intro.js:功能强大但体积较大,样式定制相对复杂
  • Shepherd.js:API 设计很好,但对于我们的场景来说有点”重”
  • driver.js:轻量、简洁、API 直观,且支持 React 生态

最终我们选择了 driver.js,其实也没什么特别的理由,主要基于以下几点考虑:

  1. 轻量级:核心库体积小,不会显著增加打包体积
  2. API 简洁:配置项清晰直观,上手快
  3. 灵活性:支持自定义定位、样式和交互行为
  4. 动态导入:可以按需加载,不影响首屏性能

选型这件事,其实没有最好的,只有最合适的罢了。

driver.js 的配置非常直观,以下是 HagiCode 项目中的核心配置:

import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
const newConversationDriver = driver({
allowClose: true, // 允许用户关闭引导
animate: true, // 启用动画效果
overlayClickBehavior: 'close', // 点击遮罩层关闭引导
disableActiveInteraction: false, // 保持元素可交互
showProgress: false, // 不显示进度条(我们有自定义进度管理)
steps: guideSteps // 引导步骤数组
});

这些配置背后的考虑是:

  • allowClose: true - 尊重用户选择,不强制完成引导,毕竟强扭的瓜不甜
  • disableActiveInteraction: false - 某些步骤需要用户实际操作(如输入文字),所以不能禁用交互
  • overlayClickBehavior: 'close' - 给用户一个快速的退出方式

引导状态的持久化是关键——我们不希望每次刷新页面都重新引导,那样挺烦人的。HagiCode 使用 localStorage 来管理引导状态:

export type GuideState = 'pending' | 'dismissed' | 'completed';
export interface UserGuideState {
session: GuideState;
detailGuides: Record<string, GuideState>;
}
// 读取状态
export const getUserGuideState = (): UserGuideState => {
const state = localStorage.getItem('userGuideState');
return state ? JSON.parse(state) : { session: 'pending', detailGuides: {} };
};
// 更新状态
export const setUserGuideState = (state: UserGuideState) => {
localStorage.setItem('userGuideState', JSON.stringify(state));
};

我们定义了三种状态:

  • pending:引导进行中,用户还未完成或跳过
  • dismissed:用户主动关闭了引导
  • completed:用户完成了所有步骤

对于提案详情页的引导,我们还支持更细粒度的状态追踪(通过 detailGuides 字典),因为一个提案可能会经历多个阶段(草稿、审核、执行完成),每个阶段都需要不同的引导。毕竟,事情的状态总是在变化的。

driver.js 使用 CSS 选择器来定位目标元素。HagiCode 采用了一个约定:使用 data-guide 自定义属性来标记引导目标:

const steps = [
{
element: '[data-guide="launch"]',
popover: {
title: '开始新对话',
description: '点击这里创建一个新的对话会话...'
}
}
];

在组件中这样使用:

<button data-guide="launch" onClick={handleLaunch}>
新建对话
</button>

这种做法的好处是:

  • 避免与业务样式类名冲突
  • 语义清晰,一眼就能看出这个元素与引导相关
  • 便于统一管理和维护

因为引导功能只在特定场景下才需要(比如新用户第一次访问),我们采用动态导入来优化初始加载性能:

const initNewUserGuide = async () => {
// 动态导入 driver.js
const { driver } = await import('driver.js');
await import('driver.js/dist/driver.css');
// 初始化引导
const newConversationDriver = driver({
// ...配置
});
newConversationDriver.drive();
};

这样 driver.js 及其样式文件只会在需要时才加载,不会影响首屏性能。毕竟,谁愿意为暂时用不到的东西付出等待的代价呢?

HagiCode 实现了两条引导路径,覆盖了用户的核心使用场景。

这条引导帮助用户完成从创建对话到提交第一个完整提案的整个流程:

  1. launch - 启动引导,介绍”新建对话”按钮
  2. compose - 引导用户在输入框中输入请求
  3. send - 引导点击发送按钮
  4. proposal-launch-readme - 引导创建 README 提案
  5. proposal-compose-readme - 引导编辑 README 请求内容
  6. proposal-submit-readme - 引导提交 README 提案
  7. proposal-launch-agents - 引导创建 AGENTS.md 提案
  8. proposal-compose-agents - 引导编辑 AGENTS.md 请求
  9. proposal-submit-agents - 引导提交 AGENTS.md 提案
  10. proposal-wait - 说明 AI 正在处理,请稍候

这条引导的设计思路是:通过两个实际的提案创建任务(README 和 AGENTS.md),让用户亲手体验 HagiCode 的核心工作流。毕竟,纸上得来终觉浅,绝知此事要躬行。

下面这几张图,对应的就是会话引导里的几个关键节点:

会话引导:从创建普通会话开始

会话引导的第一步,先把用户带到“新建普通会话”的入口上。

会话引导:输入第一句请求

接着引导用户在输入框里写下第一句请求,降低第一次开口的门槛。

会话引导:发送第一条消息

输入完成后,再明确提示用户发送第一条消息,让操作路径更连贯。

会话引导:等待会话列表继续执行

当两个提案都创建完成后,引导会回到会话列表,让用户知道接下来只需要等待系统继续执行和刷新。

当用户进入提案详情页时,根据提案的当前状态触发对应的引导:

  1. drafting(草稿阶段)- 引导用户查看 AI 生成的计划
  2. reviewing(审核阶段)- 引导用户执行计划
  3. executionCompleted(完成阶段)- 引导用户归档计划

这条引导的特点是状态驱动——根据提案的实际状态动态决定显示哪个引导步骤。事物总是在变化,引导也应该跟着变化才是。

下面这张图展示的是提案详情页在“起草阶段”的引导状态:

提案详情引导:起草阶段先生成规划

在这个阶段,引导会把用户注意力聚焦到“生成规划”这个关键动作上,避免第一次进入详情页时不知道该先做什么。

在 React 应用中,引导目标元素可能还没渲染完成(比如等待异步数据加载)。为了处理这种情况,HagiCode 实现了一个重试机制:

const waitForElement = (selector: string, maxRetries = 10, interval = 100) => {
let retries = 0;
return new Promise<HTMLElement>((resolve, reject) => {
const checkElement = () => {
const element = document.querySelector(selector) as HTMLElement;
if (element) {
resolve(element);
} else if (retries < maxRetries) {
retries++;
setTimeout(checkElement, interval);
} else {
reject(new Error(`Element not found: ${selector}`));
}
};
checkElement();
});
};

在初始化引导前调用这个函数,确保目标元素已经存在。有时候,多等待一下也是值得的。

基于 HagiCode 的实践经验,这里分享几个关键的最佳实践:

不要强制用户完成引导。有些用户是探索型的,他们更喜欢自己摸索。提供清晰的”跳过”按钮,并记住用户的选择,下次不再打扰。毕竟,美的事物或人,不一定要占有,只要她还是美的,自己好好看着她的美就好了。

每个引导步骤应该聚焦于单一目标:

  • Title:简短清晰,不超过 10 个字
  • Description:直击要点,告诉用户”这是啥”和”为啥要用”

避免长篇大论的说明——用户在引导阶段的注意力是很有限的。话说多了,反而没人愿意看。

使用稳定的、不频繁变化的元素标记方式。data-guide 自定义属性是一个好选择,避免依赖 class 名或 DOM 结构,因为这些很容易在重构中变化。代码总是在变化的,但有些东西应该尽量保持稳定。

HagiCode 为引导功能编写了完整的测试用例:

describe('NewUserConversationGuide', () => {
it('应该正确初始化引导状态', () => {
const state = getUserGuideState();
expect(state.session).toBe('pending');
});
it('应该正确更新引导状态', () => {
setUserGuideState({ session: 'completed', detailGuides: {} });
const state = getUserGuideState();
expect(state.session).toBe('completed');
});
});

测试可以确保在重构代码时不会不小心破坏引导功能。毕竟,谁也不希望改点代码就把之前的功能搞坏了。

  • 使用动态导入延迟加载引导库
  • 避免在用户已经完成引导后仍然初始化引导逻辑
  • 考虑引导动画的性能影响,低端设备上可以关闭动画

性能这东西,就像生活一样,该省的地方还是要省的。

新用户引导是提升产品用户体验的重要环节。在 HagiCode 项目中,我们使用 driver.js 构建了一套完整的引导系统,覆盖了从会话创建到提案执行的整个工作流。

通过本文的分享,我们希望传达的核心观点是:

  1. 技术选型要匹配需求:driver.js 不是最强的,但对我们来说是最合适的
  2. 状态管理很关键:用 localStorage 持久化引导状态,避免重复打扰用户
  3. 引导设计要聚焦:每个步骤解决一个问题,不要贪多
  4. 代码结构要清晰:分离引导配置、状态管理和 UI 逻辑,便于维护

如果你正在为自己的项目添加新用户引导功能,希望本文的实践经验能对你有所帮助。其实技术这东西,也没什么神秘的,多尝试,多总结,慢慢就好了…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

打字不如说话,说话不如截图——AI 代码助手的多模态输入实践

打字不如说话,说话不如截图——AI 代码助手的多模态输入实践

Section titled “打字不如说话,说话不如截图——AI 代码助手的多模态输入实践”

其实写代码这事儿,打字再快也有个上限。有时候说一句话的事,非得敲半天键盘;有时候一张图就能说明白,却得用一堆文字去描述。本文聊聊我们在做 HagiCode 时遇到的那些事儿——语音识别也好,图片上传也罢,反正就是想让 AI 代码助手变得好用一点,罢了。

在做 HagiCode 的时候,我们发现了一个问题——或者说,用户们用得多了,自然就显现出来的问题:光靠打字,有时候挺累的。

你想啊,用户和 Agent 交互,这可是核心场景。可是每次都得坐在键盘上噼里啪啦地敲,怎么说呢,效率确实不太高:

  1. 打字太慢了:有些复杂的问题,什么报错啊、界面上的事儿啊,打字说出来得耗上半分钟,嘴上可能十秒就说完了。这时间差,挺让人难受的。

  2. 图片更直接:有时候界面报错了,或者想对比一下设计稿,又或者想展示代码结构……”一图胜千言”这话虽老,可理儿不假。让 AI 直接”看”到问题,比描述半天要清楚得多。

  3. 交互就该自然点:现在的 AI 助手,应该支持文字、语音、图片这些方式吧?用户想用什么就用什么,这才叫自然,不是吗?

所以啊,我们就想,不如给 HagiCode 加上语音识别和图片上传的功能,让 Agent 操作变得方便些。毕竟,能让用户少敲几个字,也是好的。

本文分享的这些方案,来自我们在 HagiCode 项目中的实践——或者说,是在不断踩坑中摸索出来的经验。

HagiCode 是个开源的 AI 代码助手项目,想法很简单:用 AI 技术提升开发效率。做着做着就发现,用户对多模态输入的需求其实挺强烈的——有时候说一句话比打一堆字快,有时候一张截图比描述半天清楚。

这些需求推着我们往前走,最后也就有了语音识别和图片上传这些功能。用户可以用最自然的方式和 AI 交互,这感觉,挺好的。

做语音识别功能的时候,我们遇到了一个挺棘手的问题:浏览器的 WebSocket API 不支持自定义 HTTP header

而我们选的语音识别服务,是字节跳动的豆包语音识别 API。这个 API 偏偏要求通过 HTTP header 传递认证信息,什么 accessTokensecretKey 之类的。这下好了,技术矛盾来了:

// 浏览器 WebSocket API 不支持以下方式
const ws = new WebSocket('wss://api.com/ws', {
headers: {
'Authorization': 'Bearer token' // 不支持
}
});

摆在我们面前的方案,大概有两个:

  1. URL 查询参数方案:把认证信息放在 URL 里

    • 优点是,实现起来简单
    • 缺点是,凭证暴露在前端,安全性差;而且有些 API 强制要求 header 验证
  2. 后端代理方案:在后端实现 WebSocket 代理

    • 优点是,凭证安全存储在后端;完全兼容 API 要求
    • 缺点是,实现起来稍微复杂一点

最后我们还是选了后端代理方案。毕竟啊,安全性这东西,是不能妥协的底线——这一点,谁也别想糊弄过去。

图片上传功能嘛,我们的需求其实也挺简单的:

  1. 多种上传方式:点击选文件、拖拽上传、剪贴板粘贴,总得有吧?
  2. 文件验证:类型限制(PNG、JPG、WebP、GIF)、大小限制(5-10MB),这些是基本操作
  3. 用户体验:上传进度、预览、错误提示,总得让人知道发生了什么
  4. 安全性:服务端验证、防止恶意文件上传,这可是大事

我们设计了一个三层架构的语音识别方案,怎么说呢,算是找到了一条路:

Browser WebSocket
|
| ws://backend/api/voice/ws
| (binary audio)
v
Backend Proxy
|
| wss://openspeech.bytedance.com/ (with auth header)
v
Doubao API

核心组件实现

  1. 前端 AudioWorklet 处理器
class AudioProcessorWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const input = inputs[0]?.[0];
if (!input) return true;
// 重采样到 16kHz(豆包 API 要求)
const samples = this.resampleAudio(input, 48000, 16000);
// 累积样本到 500ms 块
this.accumulatedSamples.push(...samples);
if (this.accumulatedSamples.length >= 8000) {
// 转换为 16-bit PCM 并发送
const pcm = this.floatToPcm16(this.accumulatedSamples);
this.port.postMessage({ type: 'audioData', data: pcm.buffer }, [pcm.buffer]);
this.accumulatedSamples = [];
}
return true;
}
}
  1. 后端 WebSocket 处理器(C#):
[HttpGet("ws")]
public async Task GetWebSocket()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
await _webSocketHandler.HandleAsync(HttpContext);
}
}
  1. 前端 VoiceTextArea 组件
export const VoiceTextArea = forwardRef<HTMLTextAreaElement, VoiceTextAreaProps>(
({ value, onChange, onTextRecognized, maxDuration }, ref) => {
const { isRecording, interimText, volume, duration, startRecording, stopRecording } =
useVoiceRecording({ onTextRecognized, maxDuration });
return (
<div className="flex gap-2">
{/* 语音按钮 */}
<button onClick={handleButtonClick}>
{isRecording ? <VolumeWaveform volume={volume} /> : <Mic />}
</button>
{/* 文本输入框 */}
<textarea value={displayValue} onChange={handleChange} />
</div>
);
}
);

我们做了一个功能完整的图片上传组件,三种上传方式都支持,怎么说呢,算是把用户常用的场景都覆盖到了。

核心特性

  1. 三种上传方式
// 点击上传
const handleClick = () => fileInputRef.current?.click();
// 拖拽上传
const handleDrop = (e: React.DragEvent) => {
const file = e.dataTransfer.files?.[0];
if (file) uploadFile(file);
};
// 剪贴板粘贴
const handlePaste = (e: ClipboardEvent) => {
for (const item of Array.from(e.clipboardData?.items || [])) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) uploadFile(file);
}
}
};
  1. 前端验证
const validateFile = (file: File): { valid: boolean; error?: string } => {
if (!acceptedTypes.includes(file.type)) {
return { valid: false, error: 'Only PNG, JPG, JPEG, WebP, and GIF images are allowed' };
}
if (file.size > maxSize) {
return { valid: false, error: `Maximum file size is ${(maxSize / 1024 / 1024).toFixed(1)}MB` };
}
return { valid: true };
};
  1. 后端上传处理(TypeScript):
export const Route = createFileRoute('/api/upload')({
server: {
handlers: {
POST: async ({ request }) => {
const formData = await request.formData();
const file = formData.get('file') as File;
// 验证
const validation = validateFile(file);
if (!validation.isValid) {
return Response.json({ error: validation.error }, { status: 400 });
}
// 保存文件
const uuid = uuidv4();
const filePath = join(uploadDir, `${uuid}${extension}`);
await writeFile(filePath, buffer);
return Response.json({ url: `/uploaded/${today}/${uuid}${extension}` });
}
}
}
});
  1. 配置语音识别服务

    • 进入语音识别设置页面
    • 配置豆包语音的 AppIdAccessToken
    • (可选)配置热词以提升专业术语识别准确率
  2. 在输入框中使用

    • 点击输入框左侧的麦克风图标
    • 看到波形动画后开始说话
    • 再次点击图标停止录音
    • 识别结果会自动插入到光标位置
  3. 热词配置示例

TypeScript
React
useState
useEffect
  1. 上传方式

    • 点击上传按钮选择文件
    • 直接拖拽图片到上传区域
    • 使用 Ctrl+V 粘贴剪贴板中的截图
  2. 支持的格式:PNG、JPG、JPEG、WebP、GIF

  3. 大小限制:默认 5MB(可配置)

  1. 语音识别

    • 需要麦克风权限
    • 建议在安静环境下使用
    • 支持的最大录音时长为 300 秒(可配置)
  2. 图片上传

    • 仅支持常见图片格式
    • 注意文件大小限制
    • 上传后的图片会自动生成预览 URL
  3. 安全考虑

    • 语音识别凭证存储在后端
    • 图片上传有严格的服务端验证
    • 生产环境建议使用 HTTPS/WSS

加上语音识别和图片上传之后,HagiCode 的用户体验确实提升了不少。用户现在可以用更自然的方式和 AI 交互——说话代替打字,截图代替描述。这种感觉,怎么说呢,就像是终于找到了一种更舒服的沟通方式。

做这个功能的时候,我们遇到了浏览器 WebSocket 不支持自定义 header 的问题,最后还是通过后端代理方案搞定了。这个方案不仅保证了安全性,也为后续集成其他需要认证的 WebSocket 服务打下了基础——也算是个意外收获吧。

图片上传组件也是,用了多种上传方式,让用户可以根据场景选择最方便的那一个。点击也好,拖拽也罢,或者直接粘贴,都能快速完成上传。条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

“打字不如说话,说话不如截图”,这话放在这里,倒也贴切。如果你也在做类似的 AI 助手产品,希望这些经验能对你有所帮助,哪怕只是一点点。


如果本文对你有帮助:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

GLM-5.1 全面支持与 Gemini CLI 集成:HagiCode 的多模型进化之路

GLM-5.1 全面支持与 Gemini CLI 集成:HagiCode 的多模型进化之路

Section titled “GLM-5.1 全面支持与 Gemini CLI 集成:HagiCode 的多模型进化之路”

本文介绍了 HagiCode 平台近期的重要更新——智谱 AI GLM-5.1 模型的全面支持,以及 Gemini CLI 作为第十个 Agent CLI 的成功集成。这两项更新进一步强化了平台的多模型能力和多 CLI 生态。

时间过得真快,大语言模型的发展就像春天的竹子一样,蹭蹭地往上窜。曾经我们还在为”一个能写代码的 AI”而欢呼雀跃,如今已是多模型协同、多工具融合的时代了。这有意思吗?或许吧,毕竟开发者需要的从来都不只是工具本身,而是一种能够适应不同场景、灵活切换的从容。

HagiCode 作为一个 AI 辅助编码平台,最近也算是迎来了两件大事:一是智谱 AI 的 GLM-5.1 模型全面接入,二是 Gemini CLI 正式成为第十个支持的 Agent CLI。这两件事说大不大,说小也不小,只是对于平台的完善而言,总归是好事一桩。

GLM-5.1 是智谱 AI 的最新旗舰模型,相比 GLM-5.0,推理能力更强了,代码理解更深了,工具调用也更顺滑了。更重要的是,它是首个支持图片输入的 GLM 模型——这意味着什么?意味着用户可以直接截图让 AI 看问题,不用再费劲巴力地描述了。这便利性,用过就懂了。

与此同时,HagiCode 通过 HagiCode.Libs.Providers 架构,把 Gemini CLI 成功集成了进来。这是第十个 Agent CLI 了,说实话,能走到这一步,也算是有些许成就感罢了。

值得一提的是,HagiCode 的图片上传功能让用户可以直接截图与 AI 交流。即使运行的是 GLM 4.7 版本,平台依然能够良好运行,并且已经帮助项目完成了许多重要的构建工作。至于 GLM-5.1?那自然会更进一步。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 辅助编码平台,旨在通过多模型、多 CLI 的架构设计,为开发者提供灵活、强大的 AI 编程助手。项目地址:github.com/HagiCode-org/site

HagiCode 的核心优势之一,就是通过统一的抽象层支持多种不同的 AI 编程 CLI 工具。这种设计的好处,说穿了也就那么回事:新东西能进来,旧东西能留下,代码还不乱。毕竟,谁都希望生活能这样吧?

平台通过 AIProviderType 枚举定义了支持的 CLI 提供商类型:

public enum AIProviderType
{
ClaudeCodeCli = 0, // Claude Code CLI
CodexCli = 1, // GitHub Copilot Codex
GitHubCopilot = 2, // GitHub Copilot
CodebuddyCli = 3, // Codebuddy CLI
OpenCodeCli = 4, // OpenCode CLI
IFlowCli = 5, // IFlow CLI
HermesCli = 6, // Hermes CLI
QoderCli = 7, // Qoder CLI
KiroCli = 8, // Kiro CLI
KimiCli = 9, // Kimi CLI
GeminiCli = 10, // Gemini CLI (新增)
}

可以看到,Gemini CLI 作为第十个成员加入了这个大家庭。每个 CLI 都有独特的特点和适用场景,用户可以根据自己的需求灵活选择。毕竟,条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

HagiCode.Libs.Providers 提供了统一的 Provider 接口,让每个 CLI 的集成变得规范而简洁。以 Gemini CLI 为例:

public class GeminiProvider : ICliProvider<GeminiOptions>
{
private static readonly string[] DefaultExecutableCandidates = ["gemini", "gemini-cli"];
private const string ManagedBootstrapArgument = "--acp";
public string Name => "gemini";
public bool IsAvailable => _executableResolver.ResolveFirstAvailablePath(DefaultExecutableCandidates) is not null;
}

这种设计的好处是:

  • 新 CLI 的集成只需要实现一个 Provider 类
  • 统一的生命周期管理和会话池化
  • 自动化的别名解析和可执行文件查找

说穿了,这种设计其实就是把复杂的事情简单化,让生活更轻松一点罢了。

Provider Registry 自动处理别名映射和注册:

if (provider is GeminiProvider)
{
registry.Register(provider.Name, provider, ["gemini-cli"]);
continue;
}

这意味着用户可以使用 geminigemini-cli 两种方式来调用 Gemini CLI,系统会自动识别。这就像你朋友多,有的叫大名,有的叫小名,反正都是他,怎么叫都行。

GLM-5.1 是智谱 AI 的最新旗舰模型,HagiCode 已完成对其的全面支持。

HagiCode 通过 Secondary Professions Catalog 管理所有支持的模型。以下是 GLM 系列的配置:

Model IDNameSupportsImageCompatible CLI Families
glm-4.7GLM 4.7-claude, codebuddy, hermes, qoder, kiro
glm-5GLM 5-claude, codebuddy, hermes, qoder, kiro
glm-5-turboGLM 5 Turbo-claude, codebuddy, hermes, qoder, kiro
glm-5.0GLM 5.0 (Legacy)-claude, codebuddy, hermes, qoder, kiro
glm-5.1GLM 5.1trueclaude, codebuddy, hermes, qoder, kiro

GLM-5.1 的关键特性可以总结为:

  • 独立的版本标识,没有 legacy 包袱
  • 首个支持图片输入的 GLM 模型
  • 更强的推理能力和代码理解
  • 广泛的多 CLI 兼容性

从代码层面来看,GLM-5.1 与 GLM-5.0 的关键区别:

// GLM-5.0 (Legacy) - 有特殊保留逻辑
private const string Glm50CodebuddySecondaryProfessionId = "secondary-glm-5-codebuddy";
private const string Glm50CodebuddyModelValue = "glm-5.0";
// GLM-5.1 - 独立的新模型标识
private const string Glm51SecondaryProfessionId = "secondary-glm-5-1";
private const string Glm51ModelValue = "glm-5.1";

GLM-5.0 带有 “Legacy” 标记,是为了向后兼容而保留的旧版本标识。而 GLM-5.1 是一个全新的独立版本,没有任何历史包袱。这就像有些人,总是活在过去;而有些人,轻装上阵,走得更快罢了。

在 HagiCode 中使用 GLM-5.1 的配置示例:

{
"primaryProfessionId": "profession-claude-code",
"secondaryProfessionId": "secondary-glm-5-1",
"model": "glm-5.1",
"reasoning": "high"
}

HagiCode 的图片支持是通过 SecondaryProfession 的 SupportsImage 属性实现的:

public class HeroSecondaryProfessionSettingDto
{
public bool SupportsImage { get; set; }
}

在 Secondary Professions Catalog 中,GLM-5.1 的配置如下:

{
"id": "secondary-glm-5-1",
"supportsImage": true
}

这意味着用户可以直接上传截图让 AI 分析,比如:

  • 错误信息的截图
  • UI 界面的问题
  • 数据可视化图表
  • 代码运行结果

不用再手动描述问题了,直接截图就行——这个功能的便利性用过就知道了。毕竟,有些事情,说再多不如看一眼。

Gemini CLI 作为第十个 Agent CLI,通过标准的 Provider 架构集成到 HagiCode 中。

Gemini CLI 支持丰富的配置选项:

public class GeminiOptions
{
public string? ExecutablePath { get; set; }
public string? WorkingDirectory { get; set; }
public string? SessionId { get; set; }
public string? Model { get; set; }
public string? AuthenticationMethod { get; set; }
public string? AuthenticationToken { get; set; }
public Dictionary<string, string?> AuthenticationInfo { get; set; }
public Dictionary<string, string?> EnvironmentVariables { get; set; }
public string[] ExtraArguments { get; set; }
public TimeSpan? StartupTimeout { get; set; }
public CliPoolSettings? PoolSettings { get; set; }
}

这些选项覆盖了从基本配置到高级特性的方方面面,用户可以根据自己的需求进行灵活配置。毕竟,每个人的需求都不一样,能灵活一点总是好的。

Gemini CLI 支持 ACP (Agent Communication Protocol) 通信协议,这是 HagiCode 统一的 CLI 通信标准。通过 ACP,不同的 CLI 可以以一致的方式与平台交互,大大简化了集成工作。说穿了,就是把复杂的事情统一化,让大家都能轻松一点罢了。

使用智谱 AI 的模型,需要配置相应的环境变量。

Terminal window
export ANTHROPIC_AUTH_TOKEN="your-zai-api-key"
export ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic"
Terminal window
export ANTHROPIC_AUTH_TOKEN="your-aliyun-api-key"
export ANTHROPIC_BASE_URL="https://coding.dashscope.aliyuncs.com/apps/anthropic"

配置完成后,HagiCode 就可以正常调用 GLM-5.1 模型了。这事儿说难也不难,说简单也不简单,反正照着做就是了。

说到实践,最好的例子就是 HagiCode 平台自身的构建流程。HagiCode 的开发过程已经充分利用了 AI 能力:

HagiCode 平台的设计优化得比较好,即使使用 GLM 4.7 也能获得良好的开发体验。平台已帮助完成多个重要构建项目,包括:

  • 多 CLI Provider 的集成
  • 图片上传功能的实现
  • 文档生成和内容发布

这其实也挺好,毕竟不是所有人都需要用最新的东西。适合自己的,才是最好的。

升级到 GLM-5.1 后,这些能力将得到进一步增强:

  • 更强的代码理解能力,减少来回沟通
  • 更准确的依赖分析,一次性指对方向
  • 更高效的错误诊断,快速定位问题
  • 支持图片输入,加速问题描述

这就像从自行车换到汽车,能到的地方是一样的,只是速度和舒适度不一样罢了。

HagiCode.Libs.Providers 提供了统一的注册和使用机制:

services.AddHagiCodeLibs();
var gemini = serviceProvider.GetRequiredService<ICliProvider<GeminiOptions>>();
var codebuddy = serviceProvider.GetRequiredService<ICliProvider<CodebuddyOptions>>();
var hermes = serviceProvider.GetRequiredService<ICliProvider<HermesOptions>>();

这种依赖注入的设计让各个 CLI 的使用变得非常简洁,也方便进行单元测试和模拟。毕竟,代码写得干净一点,对自己也是一种负责。

在实际使用中,有几个地方需要注意:

  1. API Key 配置:确保正确设置 ANTHROPIC_AUTH_TOKEN,否则无法调用模型
  2. 模型可用性:GLM-5.1 需要在对应的模型提供商处开通权限
  3. 图片功能:只有支持 supportsImage: true 的模型才能使用图片上传功能
  4. CLI 安装:使用 Gemini CLI 前,确保 geminigemini-cli 在系统 PATH 中

这些都是小事,但小事处理不好,也可能变成大事。所以还是要注意一下的。

通过 GLM-5.1 的全面支持和 Gemini CLI 的成功集成,HagiCode 进一步强化了其作为多模型、多 CLI AI 编程平台的能力。这些更新不仅为用户提供了更多的选择,也展示了 HagiCode 在架构设计上的前瞻性和可扩展性。

GLM-5.1 的图片支持能力,结合 HagiCode 的截图上传功能,让”看图说话”成为可能——大大降低了问题描述的成本。而十个 CLI 的支持,意味着用户可以根据自己的偏好和场景,灵活选择最合适的 AI 编程助手。毕竟,选择多了,总是好事。

最重要的是,HagiCode 平台自身的构建实践证明:即使使用 GLM 4.7,平台也能良好运行并完成复杂任务;而升级到 GLM-5.1 后,开发效率将得到进一步提升。这就像人生一样,不一定非要追求最好,适合自己的就好。当然,如果能在适合自己的基础上变得更好,那自然更好。

如果你对多模型、多 CLI 的 AI 编程平台感兴趣,不妨试试 HagiCode——开源、免费、不断进化。反正试试又不花钱,万一真适合你呢?


如果本文对你有帮助:

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

Hagicode 联合 GLM-5.1 多 CLI 集成指南

Hagicode 联合 GLM-5.1 多 CLI 集成指南

Section titled “Hagicode 联合 GLM-5.1 多 CLI 集成指南”

在 Hagicode 项目中,用户可以选择多种 CLI 工具来驱动 AI 编程助手,包括 Claude Code CLI、GitHub Copilot、OpenCode CLI、Codebuddy CLI、Hermes CLI 等。这些 CLI 工具本身是通用的 AI 编程工具,但通过 Hagicode 的抽象层,可以灵活地接入不同的 AI 模型提供商。

智谱 AI(ZAI)提供了与 Anthropic Claude API 兼容的接口,使得这些 CLI 工具可以直接使用国产 GLM 系列模型。其中 GLM-5.1 是智谱最新发布的大语言模型,相比 GLM-5.0 有显著的改进。

Hagicode 通过 AIProviderType 枚举定义了 11 种 CLI 提供商类型,覆盖了主流的 AI 编程 CLI 工具:

public enum AIProviderType
{
ClaudeCodeCli = 0, // Claude Code CLI
CodexCli = 1, // GitHub Copilot Codex
GitHubCopilot = 2, // GitHub Copilot
CodebuddyCli = 3, // Codebuddy CLI
OpenCodeCli = 4, // OpenCode CLI
IFlowCli = 5, // IFlow CLI
HermesCli = 6, // Hermes CLI
QoderCli = 7, // Qoder CLI
KiroCli = 8, // Kiro CLI
KimiCli = 9, // Kimi CLI
GeminiCli = 10, // Gemini CLI
}

每种 CLI 都有对应的模型参数配置,支持 modelreasoning 参数:

private static readonly IReadOnlyDictionary<AIProviderType, IReadOnlyList<string>> ManagedModelParameterKeysByProvider =
new Dictionary<AIProviderType, IReadOnlyList<string>>
{
[AIProviderType.ClaudeCodeCli] = ["model", "reasoning"],
[AIProviderType.CodexCli] = ["model", "reasoning"],
[AIProviderType.OpenCodeCli] = ["model", "reasoning"],
[AIProviderType.HermesCli] = ["model", "reasoning"],
[AIProviderType.CodebuddyCli] = ["model", "reasoning"],
[AIProviderType.QoderCli] = ["model", "reasoning"],
[AIProviderType.KiroCli] = ["model", "reasoning"],
[AIProviderType.GeminiCli] = ["model"], // Gemini 不支持 reasoning 参数
// ...
};

Hagicode 的 Secondary Professions Catalog 中定义了完整的 GLM 系列模型支持:

Model IDNameDefault ReasoningCompatible CLI Families
glm-4.7GLM 4.7highclaude, codebuddy, hermes, qoder, kiro
glm-5GLM 5highclaude, codebuddy, hermes, qoder, kiro
glm-5-turboGLM 5 Turbohighclaude, codebuddy, hermes, qoder, kiro
glm-5.0GLM 5.0 (Legacy)highclaude, codebuddy, hermes, qoder, kiro
glm-5.1GLM 5.1highclaude, codebuddy, hermes, qoder, kiro

AcpSessionModelBootstrapper.cs 的实现中,我们可以清楚地看到 GLM-5.1 与 GLM-5.0 的区别:

GLM-5.1 是独立的新模型标识,没有任何 legacy 处理逻辑:

private const string Glm51ModelValue = "glm-5.1";

在 Secondary Professions Catalog 中的定义:

{
"id": "secondary-glm-5-1",
"name": "GLM 5.1",
"family": "anthropic",
"summary": "hero.professionCopy.secondary.glm51.summary",
"sourceLabel": "hero.professionCopy.sources.aiSharedAnthropicModel",
"sortOrder": 64,
"supportsImage": true,
"compatiblePrimaryFamilies": [
"claude",
"codebuddy",
"hermes",
"qoder",
"kiro"
],
"defaultParameters": {
"model": "glm-5.1",
"reasoning": "high"
}
}

智谱 AI 提供最完整的 GLM 模型支持:

{
"providerId": "zai",
"name": "智谱 AI",
"description": "智谱 AI 提供的 Claude API 兼容服务",
"category": "china-providers",
"apiUrl": {
"codingPlanForAnthropic": "https://open.bigmodel.cn/api/anthropic"
},
"recommended": true,
"region": "cn",
"defaultModels": {
"sonnet": "glm-4.7",
"opus": "glm-5",
"haiku": "glm-4.5-air"
},
"supportedModels": [
"glm-4.7",
"glm-5",
"glm-4.5-air",
"qwen3-coder-next",
"qwen3-coder-plus"
],
"features": ["experimental-agent-teams"],
"authTokenEnv": "ANTHROPIC_AUTH_TOKEN",
"referralUrl": "https://www.bigmodel.cn/claude-code?ic=14BY54APZA",
"documentationUrl": "https://open.bigmodel.cn/dev/api"
}

特点

  • 支持最多样的 GLM 模型变体
  • 提供 Sonnet/Opus/Haiku 三层级的默认映射
  • 支持 experimental-agent-teams 功能

Claude Code CLI 是 Hagicode 的核心 CLI 之一,通过 Hero 配置系统设置:

{
"primaryProfessionId": "profession-claude-code",
"secondaryProfessionId": "secondary-glm-5-1",
"model": "glm-5.1",
"reasoning": "high"
}

对应的 HeroEquipmentCatalogItem 配置:

{
id: 'secondary-glm-5-1',
name: 'GLM 5.1',
family: 'anthropic',
kind: 'model',
primaryFamily: 'claude',
compatiblePrimaryFamilies: ['claude', 'codebuddy', 'hermes', 'qoder', 'kiro'],
defaultParameters: {
model: 'glm-5.1',
reasoning: 'high'
}
}

OpenCode CLI 是最灵活的 CLI,支持 provider/model 格式指定任意模型:

方式一:使用 ZAI provider 前缀

{
"primaryProfessionId": "profession-opencode",
"model": "zai/glm-5.1",
"reasoning": "high"
}

方式二:直接使用模型 ID

{
"model": "glm-5.1"
}

方式三:前端配置界面

HeroModelEquipmentForm.tsx 中,OpenCode CLI 有特殊的占位符提示:

const OPEN_CODE_MODEL_PLACEHOLDER = 'myprovider/glm-4.7';
const modelPlaceholder = primaryProviderType === PCode_Models_AIProviderType.OPEN_CODE_CLI
? OPEN_CODE_MODEL_PLACEHOLDER
: 'gpt-5.4';

用户可以输入:

zai/glm-5.1
glm-5.1

OpenCode CLI 的模型解析逻辑

internal OpenCodeModelSelection? ResolveModelSelection(string? rawModel)
{
var normalized = NormalizeOptionalValue(rawModel);
if (normalized == null) return null;
var slashIndex = normalized.IndexOf('/');
if (slashIndex < 0)
{
// 无斜杠:直接使用模型 ID
return new OpenCodeModelSelection {
ProviderId = string.Empty,
ModelId = normalized,
};
}
// 有斜杠:解析 provider/model 格式
var providerId = normalized[..slashIndex].Trim();
var modelId = normalized[(slashIndex + 1)..].Trim();
return new OpenCodeModelSelection {
ProviderId = providerId,
ModelId = modelId,
};
}

Codebuddy CLI 有特殊的 legacy 处理逻辑:

{
"primaryProfessionId": "profession-codebuddy",
"model": "glm-5.1",
"reasoning": "high"
}

注意:Codebuddy 对 GLM-5.0 有特殊保留,不走 legacy normalization:

return !string.Equals(providerName, "CodebuddyCli", StringComparison.OrdinalIgnoreCase)
&& string.Equals(normalizedModel, LegacyGlm5TurboModelValue, StringComparison.OrdinalIgnoreCase)
? Glm5TurboModelValue
: normalizedModel;
// CodebuddyCli 时,glm-5.0 不会被规范化为 glm-5-turbo
Terminal window
# 设置 API Key
export ANTHROPIC_AUTH_TOKEN="your-zai-api-key"
# 可选:指定 API 端点(ZAI 默认使用此端点)
export ANTHROPIC_BASE_URL="https://open.bigmodel.cn/api/anthropic"
Terminal window
# 设置 API Key
export ANTHROPIC_AUTH_TOKEN="your-aliyun-api-key"
# 指定阿里云端点
export ANTHROPIC_BASE_URL="https://coding.dashscope.aliyuncs.com/apps/anthropic"

相比 GLM-5.0,GLM-5.1 有以下显著改进:

根据智谱官方发布信息,GLM-5.1 的改进包括:

  • 更强的代码理解能力:对复杂代码结构的解析更准确
  • 更长的上下文理解:支持更长的对话上下文
  • 工具调用增强:MCP 工具调用的成功率提升
  • 输出稳定性:减少随机性和幻觉

GLM-5.1 覆盖了 Hagicode 支持的所有主流 CLI:

compatiblePrimaryFamilies: [
"claude", // Claude Code CLI
"codebuddy", // Codebuddy CLI
"hermes", // Hermes CLI
"qoder", // Qoder CLI
"kiro" // Kiro CLI
]

确保正确设置 ANTHROPIC_AUTH_TOKEN 环境变量,这是所有 CLI 连接模型的必要凭证。

GLM-5.1 需要在对应的模型提供商处开通:

  • 智谱 AI ZAI 平台默认支持
  • 阿里云 DashScope 可能需要单独申请

使用 provider/model 格式时,确保 provider ID 正确:

  • 智谱 AI:zaizhipuai
  • 阿里云:aliyundashscope
  • 建议使用 high 级别以获得最佳代码生成效果
  • Gemini CLI 不支持 reasoning 参数,会自动忽略该配置

Hagicode 通过统一的抽象层,实现了 GLM-5.1 与多种 CLI 的灵活集成。开发者可以根据自己的偏好和使用场景,选择合适的 CLI 工具,并通过简单的配置使用最新的 GLM-5.1 模型。

GLM-5.1 作为智谱最新的模型版本,相比 GLM-5.0 有明显的改进:

  • 独立的版本标识,无 legacy 包袱
  • 更强的推理能力和代码理解
  • 广泛的多 CLI 兼容性
  • 灵活的推理级别配置

通过正确配置环境变量和 Hero 装备,玩家可以在不同的 CLI 环境中充分发挥 GLM-5.1 的强大能力。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

HagiCode Desktop 混合分发架构解析:如何用 P2P 加速大文件下载

HagiCode Desktop 混合分发架构解析:如何用 P2P 加速大文件下载

Section titled “HagiCode Desktop 混合分发架构解析:如何用 P2P 加速大文件下载”

其实这篇文章憋了很久才写出来,也不知道写得好不好,毕竟技术文章这东西,写出来容易,写得有味道难。不过想想算了,反正也不是什么大文豪,无神来笔,写尽此粗文罢了。

做桌面应用开发的团队,或早或晚都会遇到一个让人头疼的问题:大文件怎么分发?

这事儿说起来也是无奈。传统的 HTTP/HTTPS 直链下载,在文件体积小、用户量不多的时候,其实也还能 hold 住——就像年少时的感情,简单纯粹,没什么波澜。可是啊,时光这东西最是无情,随着项目不断发展,安装包越来越大:Desktop 端 ZIP 包、便携式包(portable package)、Web 部署归档……问题就慢慢浮现出来了:

  • 下载速度受限于源站带宽:单一服务器带宽再高,也架不住大家同时下载。这就像什么呢?就像你喜欢一个人,可她的心就那么大,早就住满了别人,你再怎么努力,也挤不进去。
  • 断点续传能力基本为零:HTTP 下载要是断了,就得从头来过,浪费时间不说,还浪费带宽。美又何必在乎天晴阴呢?可惜天不遂人愿。
  • 源站承压严重:所有流量都涌向中心服务器,带宽成本蹭蹭往上涨,扩展性也成了问题。这大概就是所谓的中心化的无奈吧——什么都压在一个点上,迟早要崩。

HagiCode Desktop 项目也不例外。咱在设计分发系统的时候,就琢磨着:能不能在不改变现有 index.json 控制面的前提下,搞一套混合分发方案?既能利用 P2P 网络的分布式特性加速下载,又能保留 HTTP 回源兜底,确保企业网络这种受限环境下的可用性。

这个决定带来的变化,可能比你想象的还要大——别急,下面我会细细道来。毕竟有些事情,说出来才能被理解。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,致力于帮助开发团队提升研发效率。项目涵盖了前端、后端、桌面端启动器、文档、构建和服务器部署等多个子系统。

Desktop 端的混合分发架构,正是 HagiCode 在实际运营中踩坑、优化出来的方案。或许有人会问,写这些有什么意义呢?其实也没什么意义,只是觉得如果这套方案有价值,说明我们在工程实践上还是有点心得的——那么 HagiCode 本身也值得关注一下罢了。

项目的 GitHub 地址是 HagiCode-org/site,有兴趣的可以先点个 Star 收藏起来。毕竟美好的东西,值得被收藏。

核心设计思想:P2P 优先,HTTP 回源

Section titled “核心设计思想:P2P 优先,HTTP 回源”

说白了,混合分发的核心思想就一句话:P2P 优先、HTTP 回源

这方案的关键在于「混合」二字。不是简单地把 BitTorrent 扔上来就完事了,而是要让两种下载方式协同工作、取长补短:

  • P2P 网络提供分布式加速,下载的人越多,节点越多,速度越快。这就像什么呢?就像你我都曾是少年时的那个ta,心中有光,便觉得世界都会亮起来。
  • WebSeed/HTTP 回源保障可用性,企业防火墙、内网环境也能正常下载。毕竟有些地方,不是你想进就能进的。
  • 控制面保持简单,不用改 index.json 的核心逻辑,只是增加可选的元数据字段。简单有什么不好呢?复杂的事情做多了,偶尔简单一下,也挺好的。

这样做的好处是啥呢?用户体验到的是「下载更快」,而技术团队不需要为 P2P 的复杂性买单太多——毕竟 BT 协议本身就已经很成熟了,我们也懒得重复造轮子。

先上一张整体架构图,让大家有个宏观印象:

┌─────────────────────────────────────┐
│ Renderer (UI 层) │
├─────────────────────────────────────┤
│ IPC/Preload (桥接层) │
├─────────────────────────────────────┤
│ VersionManager (版本管理) │
├─────────────────────────────────────┤
│ HybridDownloadCoordinator (协调层) │
│ ├── DistributionPolicyEvaluator │
│ ├── DownloadEngineAdapter │
│ ├── CacheRetentionManager │
│ └── SHA256 Verifier │
├─────────────────────────────────────┤
│ WebTorrent (下载引擎) │
└─────────────────────────────────────┘

从这张图可以看出,整个系统是分层设计的。为什么要分这么细呢?主要是为了可测试性和可替换性。其实做人也是这个道理——把事情分清楚,各司其职,世界也就简单了。

  • UI 层负责展示下载进度、共享加速开关——这是门面
  • 协调层是核心,包含策略评估、引擎适配、缓存管理、完整性校验——这是内核
  • 引擎层封装具体的下载实现,目前用的是 WebTorrent——这是工具

引擎层抽象成 DownloadEngineAdapter 接口,以后要是想换成别的 BT 引擎,或者搞个 sidecar 进程,跑起来也不费劲。毕竟谁也不想在一棵树上吊死,代码世界也是如此。

HagiCode Desktop 保持 index.json 作为唯一的控制面,这个设计非常关键。控制面负责版本发现、渠道选择、中心化策略,而数据面才是真正下载文件的地方。

index.json 新增的字段是可选的:

{
"asset": {
"torrentUrl": "https://cdn.example.com/app.torrent",
"infoHash": "abc123...",
"webSeeds": [
"https://cdn.example.com/app.zip",
"https://backup.example.com/app.zip"
],
"sha256": "def456...",
"directUrl": "https://cdn.example.com/app.zip"
}
}

这些字段都是可选的,缺失了就回退到传统的 HTTP 下载模式。这样设计的好处是向后兼容,老版本的客户端完全不受影响。毕竟世界在变,可有些东西不能变——变了就回不去了。

不是所有文件都值得用 P2P 分发。其实这世间的事大抵如此——不是什么都要争一把,有些东西,不适合就是不适合,退一步海阔天空。

DistributionPolicyEvaluator 负责评估策略,只有满足以下条件的文件才会启用混合下载:

  1. 来源类型必须是 HTTP index:GitHub 直接下载或本地文件夹源,不走这套。毕竟不是所有的路都适合 P2P。
  2. 文件大小必须 ≥ 100MB:小文件用 P2P 的开销反而得不偿失。感情也是如此,有些事情太小了,不值得大费周章。
  3. 必须具备完整的混合元数据:torrentUrl、webSeeds、sha256 缺一不可。缺一样都不行,这就是规矩。
  4. 仅限 latest desktop 包和 web 部署包:历史版本用传统方式就行。新人笑,旧人哭,何必呢?
class DistributionPolicyEvaluator {
evaluate(version: Version, settings: SharingAccelerationSettings): HybridDownloadPolicy {
// 检查来源类型
if (version.sourceType !== 'http-index') {
return { useHybrid: false, reason: 'not-http-index' };
}
// 检查元数据完整性
if (!version.hybrid) {
return { useHybrid: false, reason: 'not-eligible' };
}
// 检查是否启用
if (!settings.enabled) {
return { useHybrid: false, reason: 'shared-disabled' };
}
// 检查资产类型(仅 latest desktop/web 包)
if (!version.hybrid.isLatestDesktopAsset && !version.hybrid.isLatestWebAsset) {
return { useHybrid: false, reason: 'latest-only' };
}
return { useHybrid: true, reason: 'shared-enabled' };
}
}

这样做的好处是,系统行为可预测。不管是开发者还是用户,都能清楚地知道哪些文件会走 P2P、哪些不会。毕竟预期管理好了,人心也就稳了。

先来看看类型定义,这是整个系统的基础。其实类型定义这东西,就像给事物定性——一旦定好了,后面的路就好走了。

// 混合分发元数据
interface HybridDistributionMetadata {
torrentUrl?: string; // 种子文件 URL
infoHash?: string; // InfoHash
webSeeds: string[]; // WebSeed 列表
sha256?: string; // 文件哈希
directUrl?: string; // HTTP 直链(回源用)
eligible: boolean; // 是否符合混合分发条件
thresholdBytes: number; // 阈值(字节)
assetKind: VersionAssetKind;
isLatestDesktopAsset: boolean;
isLatestWebAsset: boolean;
}
// 共享加速设置
interface SharingAccelerationSettings {
enabled: boolean; // 总开关
uploadLimitMbps: number; // 上传限速
cacheLimitGb: number; // 缓存上限
retentionDays: number; // 保留天数
hybridThresholdMb: number; // 混合分发阈值
onboardingChoiceRecorded: boolean;
}
// 下载进度
interface VersionDownloadProgress {
current: number;
total: number;
percentage: number;
stage: VersionInstallStage; // queued, downloading, backfilling, verifying, extracting, completed, error
mode: VersionDownloadMode; // http-direct, shared-acceleration, source-fallback
peers?: number; // 连接的节点数
p2pBytes?: number; // P2P 获取字节数
fallbackBytes?: number; // 回源获取字节数
verified?: boolean; // 是否已校验
}

类型定义清楚了,后面的实现就顺理成章了。或许这就是所谓的「好的开始是成功的一半」吧,虽然这话俗了点。

HybridDownloadCoordinator 是整个下载流程的编排者,它协调策略评估、引擎执行、SHA256 校验和缓存管理。说起来挺复杂的,但其实核心逻辑也就那么几步,像极了人生——看似纷繁复杂,抽丝剥茧之后,不过尔尔。

class HybridDownloadCoordinator {
async download(
version: Version,
cachePath: string,
packageSource: PackageSource,
onProgress?: DownloadProgressCallback,
): Promise<HybridDownloadResult> {
// 1. 评估策略:是否使用混合下载
const policy = this.policyEvaluator.evaluate(version, settings);
// 2. 执行下载
if (policy.useHybrid) {
await this.engine.download(version, cachePath, settings, onProgress);
} else {
await packageSource.downloadPackage(version, cachePath, onProgress);
}
// 3. SHA256 校验(硬门槛)
const verified = await this.verify(version, cachePath, onProgress);
if (!verified) {
await this.cacheRetentionManager.discard(version.id, cachePath);
throw new Error(`sha256 verification failed for ${version.id}`);
}
// 4. 标记为可信缓存,开始受控做种
await this.cacheRetentionManager.markTrusted({
versionId: version.id,
cachePath,
cacheSize,
}, settings);
return { cachePath, policy, verified };
}
}

这里有一个关键点:SHA256 校验是硬门槛。下载的文件必须校验通过,才能进入安装流程。校验失败就丢弃缓存,保证不会出现「下载了错误文件导致安装出问题」的情况。

这像什么呢?就像信任这件事——一旦被辜负,再想重建就难了。所以从一开始,就把门槛立好。

DownloadEngineAdapter 是一个抽象接口,定义了引擎必须实现的方法:

interface DownloadEngineAdapter {
download(
version: Version,
destinationPath: string,
settings: SharingAccelerationSettings,
onProgress?: (progress: VersionDownloadProgress) => void,
): Promise<void>;
stopAll(): Promise<void>;
}

V1 实现基于 WebTorrent,封装在 InProcessTorrentEngineAdapter 中:

class InProcessTorrentEngineAdapter implements DownloadEngineAdapter {
async download(...) {
const client = this.getClient(settings); // 应用上传限速
const torrent = client.add(torrentId, {
path: path.dirname(destinationPath),
destroyStoreOnDestroy: false,
maxWebConns: 8,
});
// 添加 WebSeed
torrent.on('ready', () => {
for (const seed of hybrid.webSeeds) {
torrent.addWebSeed(seed);
}
if (hybrid.directUrl) {
torrent.addWebSeed(hybrid.directUrl);
}
});
// 进度报告 - 区分 P2P 和回源
torrent.on('download', () => {
const hasP2PPeer = torrent.wires.some(w => w.type !== 'webSeed');
const mode = hasP2PPeer ? 'shared-acceleration' : 'source-fallback';
// ... 报告进度
});
}
}

引擎可插拔的设计,让未来的优化变得简单。比如 V2 可以把引擎跑在 helper process 里,避免主进程崩溃的风险。毕竟谁也不想一颗老鼠屎坏了一锅粥,代码世界如此,人生亦然。

在 UI 层,用户最关心的是「我现在是 P2P 下载还是 HTTP 回源」?InProcessTorrentEngineAdapter 通过检查 torrent.wires 的类型来判断:

const hasP2PPeer = torrent.wires.some((wire) => wire.type !== 'webSeed');
const hasFallbackWire = torrent.wires.some((wire) => wire.type === 'webSeed');
const mode = hasP2PPeer ? 'shared-acceleration'
: hasFallbackWire ? 'source-fallback'
: 'shared-acceleration';
const stage = hasP2PPeer ? 'downloading'
: hasFallbackWire ? 'backfilling'
: 'downloading';

这个逻辑看起来简单,但它是用户体验的关键。用户能清楚地看到当前是「共享加速」还是「回源补块」,心里有底。其实人和人之间也是如此——透明一点,大家都安心。

完整性校验使用 Node.js 的 crypto 模块,进行流式哈希计算,避免把整个文件加载到内存:

private async computeSha256(filePath: string): Promise<string> {
const hash = createHash('sha256');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('error', reject);
stream.on('end', resolve);
});
return hash.digest('hex').toLowerCase();
}

这个实现对大文件特别友好。想想看,要是下载了一个 2GB 的安装包,然后要把整个文件读入内存校验,那内存占用得多恐怖?流式处理就能完美解决这个问题。

这像不像感情?有些东西,不必一次性全部拥有,一点一点来,反而更好。

完整的数据流是这样的:

┌────────────────────────────────────────────────────────────────────┐
│ 用户点击安装大文件版本 │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ VersionManager 调用协调器 │
│ HybridDownloadCoordinator.download() │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ DistributionPolicyEvaluator.evaluate() │
│ 检查:来源、元数据、开关、资产类型 │
└────────────────────────────────────────────────────────────────────┘
┌───────────┴───────────┐
│ useHybrid? │
└───────────┬───────────┘
是 │ │ 否
▼ ▼
┌──────────────────┐ ┌─────────────────────┐
│ P2P + WebSeed │ │ HTTP 直链下载 │
│ 混合下载 │ │ (兼容路径) │
└──────────────────┘ └─────────────────────┘
┌──────────────────┐
│ SHA256 校验 │
│ (硬门槛) │
└────────┬─────────┘
┌────────┴─────────┐
│ 通过? │
└────────┬─────────┘
是 │ │ 否
▼ ▼
┌────────────┐ ┌────────────────┐
│ 解压安装 │ │ 丢弃缓存+报错 │
│ +受控做种 │ └────────────────┘
└────────────┘

整个流程非常清晰,每个步骤都有明确的职责。出了什么问题,也能快速定位是哪个环节出了问题。毕竟事情就怕糊涂,糊涂了就难办了。

技术方案再好,如果用户体验不好,那也是白搭。HagiCode Desktop 在产品化上做了不少工作。毕竟技术是骨子里的事,产品是皮囊,皮囊不好看,骨头再好也没人愿意多看一眼。

大多数用户不懂什么是 BitTorrent、什么是 InfoHash。所以产品层面用了「共享加速」这个语义:

  • 功能叫「共享加速」,不叫 P2P 下载
  • 设置项叫「上传限速」,不说做种
  • 进度显示「回源补块」,不说 WebSeed 回退

这样一来,术语的认知负担就小了。其实说话也是一门艺术,说得简单点,大家都轻松。

新用户第一次使用桌面端,会看到一个向导页面,其中有一页介绍共享加速功能:

为了加快下载速度,我们会在您下载时与其他用户共享已下载的部分文件。这个过程是完全可选的,您随时可以在设置中关闭。

默认是开启的,但提供明确的取消入口。企业用户如果不需要,大可以在向导里关掉。毕竟选择权在用户手里,没人喜欢被强迫。

设置页面提供三个可调整的参数:

参数默认值说明
上传限速2 MB/s防止占用过多上行带宽
缓存上限10 GB控制磁盘空间占用
保留天数7 天超过这个时间自动清理缓存

这些参数都有合理的默认值,普通用户不用改,高级用户可以根据自己的网络环境调整。毕竟众口难调,给点自由度总是好的。

回顾整个方案,有几个关键决策值得说一说:

为什么不一开始就搞 sidecar/helper process?原因很简单:快速上线。主进程内方案开发周期短、调试方便,先把功能跑起来,再考虑稳定性优化。

当然,这个决策是有代价的:引擎崩溃会影响主进程。所以通过适配器边界和超时控制来缓解这个问题。同时预留了迁移路径,V2 可以轻松迁移到独立进程。

这像不像年轻时的我们?先上车再说,后面的事情后面再想办法。毕竟有些时候,想太多反而迈不开步子。

不用 MD5 或 CRC32,而用 SHA256,是因为 SHA256 更安全。MD5 和 CRC32 的碰撞成本太低了,万一有人恶意构造假的安装包,后果不堪设想。SHA256 的计算开销虽然大一些,但安全性值得这个代价。

信任这东西,建立起来难,崩塌起来却是一瞬间的事。所以在能选安全的时候,就别省那点成本。

GitHub 下载、本地文件夹源等场景,不走混合分发。这不是技术限制,而是避免复杂化。BT 协议在私有网络里的价值本来就不大,而且会增加不必要的代码复杂度。

有些圈子,不必强融。道理就是这么简单。

在 SharingAccelerationSettingsStore 中,所有数值都要做边界检查和规范化:

private normalize(settings: SharingAccelerationSettings): SharingAccelerationSettings {
return {
enabled: Boolean(settings.enabled),
uploadLimitMbps: this.clampNumber(settings.uploadLimitMbps, 1, 200, DEFAULT_SETTINGS.uploadLimitMbps),
cacheLimitGb: this.clampNumber(settings.cacheLimitGb, 1, 500, DEFAULT_SETTINGS.cacheLimitGb),
retentionDays: this.clampNumber(settings.retentionDays, 1, 90, DEFAULT_SETTINGS.retentionDays),
hybridThresholdMb: DEFAULT_SETTINGS.hybridThresholdMb, // 固定值,不让用户改
onboardingChoiceRecorded: Boolean(settings.onboardingChoiceRecorded),
};
}
private clampNumber(value: number, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}

这样可以防止用户手动改配置文件导致异常值。毕竟你永远不知道用户会输入什么奇怪的数字,我也不想看见那张配置的截图,可是没辙。

CacheRetentionManager.prune() 方法负责清理过期和超限的缓存。清理策略是 LRU(最近最少使用):

const records = [...this.listRecords()]
.sort((left, right) =>
new Date(left.lastUsedAt).getTime() - new Date(right.lastUsedAt).getTime()
);
// 清理超限时,从最久未使用的开始删除
while (totalBytes > maxBytes && retainedEntries.length > 0) {
const evicted = records.find((record) => retainedEntries.includes(record.versionId));
retainedEntries.splice(retainedEntries.indexOf(evicted.versionId), 1);
removedEntries.push(evicted.versionId);
totalBytes -= evicted.cacheSize;
await fs.rm(evicted.cachePath, { force: true });
}

这个逻辑确保磁盘空间被合理使用,同时保留用户可能还需要的历史版本。毕竟有些东西虽然不常用,但丢了又觉得可惜,人嘛,都是念旧的。

用户关闭共享加速开关时,需要立即停止做种和销毁 torrent 客户端:

async disableSharingAcceleration(): Promise<void> {
this.settingsStore.updateSettings({ enabled: false });
await this.cacheRetentionManager.stopAllSeeding(); // 停止做种
await this.engine.stopAll(); // 销毁 torrent 客户端
}

用户关掉功能,就不应该再占用任何 P2P 资源,这是基本的产品礼仪。既然不爱了,那就痛快放手,别拖泥带水。

世上没有完美的方案,混合分发也不例外。以下是主要的权衡点:

崩溃隔离弱于 sidecar:V1 使用主进程内引擎,引擎崩溃会影响主进程。这通过适配器边界和超时控制来缓解,但不是根本解决方案。V2 规划了 helper process 迁移路径。毕竟新手上路,总得交点学费。

默认开启带来资源占用:默认 2 MB/s 上传、10 GB 缓存、7 天保留,对用户机器有一定资源消耗。通过向导说明和设置透明度来管理用户预期。毕竟天下没有免费的午餐,有所得必有所舍。

企业网络兼容性:WebSeed/HTTPS 自动回退保障了企业网络下的可用性,但 P2P 加速效果会打折扣。这是设计上的取舍,优先保障可用性。毕竟有些事情,比快更重要,比如稳定。

元数据向后兼容:所有新字段都是可选的,缺失时回退到 HTTP 模式。老版本客户端完全不受影响,升级路径平滑。毕竟谁也不想升级一次就炸一次,那也太刺激了点。

本文详细解析了 HagiCode Desktop 项目的混合分发架构,总结下来有以下几个关键点:

  1. 架构分层:控制面与数据面分离,引擎抽象为可插拔接口,便于测试和扩展。毕竟分工明确,效率才高。

  2. 策略驱动:不是所有文件都走 P2P,仅对满足条件的大文件启用混合分发。毕竟强扭的瓜不甜,合适最重要。

  3. 完整性校验:SHA256 作为硬门槛,流式计算避免内存问题。毕竟信任建立不易,且用且珍惜。

  4. 产品化包装:隐藏 BT 术语,使用「共享加速」语义,首向默认开启。毕竟说话也是艺术,简单点大家都轻松。

  5. 用户可控:提供上传限速、缓存上限、保留天数等可调整参数。毕竟选择权在用户手里,谁也不喜欢被强迫。

这套方案已经在 HagiCode Desktop 项目中落地实施,实际效果如何,欢迎大家安装体验后反馈。毕竟理论归理论,实践才是检验真理的唯一标准。


如果本文对你有帮助:

或许我们都是在技术路上摸爬滚打的普通人罢了,可那又怎样呢?普通人也有普通人的坚持。毕竟「竹子本来没有嘴,可也还在拔节生长」,人总得有点追求才是…

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南

Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南

Section titled “Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南”

在容器化环境中集成 Claude Code、Codex、OpenCode 等 AI 编程工具,听起来简单,实则暗藏玄机。本文将深入解析 HagiCode 项目在 Docker 部署中如何解决用户权限、配置持久化、版本管理等核心挑战,带你避坑避雷。

当我们决定在 Docker 容器内运行 AI 编程 CLI 工具时,最直觉的想法可能是:“容器不就是 root 吗,直接装不就完事了?“其实啊,这想法看似简单,背后却藏着几个必须解决的核心问题。

首先,安全限制是第一道坎。以 Claude CLI 为例,它明确禁止以 root 用户运行——这是强制性的安全检查,检测到 root 直接拒绝启动。你可能会想,那我用 USER 指令切换一下不就行了?事情没那么简单,容器的非 root 用户和宿主机的用户权限之间还存在映射问题。毕竟,这世间的事,哪有那么简单的呢?

其次,状态持久化是第二个坑。Claude Code 需要登录,Codex 有自己的配置,OpenCode 也有缓存目录。如果每次容器重启都重新配置,那这个”自动化”就毫无意义了。我们需要让这些配置在容器生命周期之外持久存在。配置这东西,就像记忆一样,说没就没,那也挺让人郁闷的。

第三个问题就是权限一致性。宿主机用户创建的配置文件,容器内的进程能不能访问?UID/GID 不匹配会导致文件权限报错,这在实际部署中非常常见。这问题说起来也挺无奈的,可是没辙。

这些问题看似独立,实际上环环相扣。HagiCode 项目在开发过程中逐步摸索出了一套可行的解决方案,接下来我会详细分享其中的技术细节和踩坑经历。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 辅助编程平台,集成了多个主流的 AI 代码助手,包括 Claude Code、Codex、OpenCode 等。作为一个需要跨平台、高可用部署的项目,HagiCode 必须解决容器化部署的各种挑战。

如果你觉得本文分享的技术方案有价值,说明 HagiCode 在工程实践上还是有点东西的——那么 HagiCode 官网GitHub 仓库 值得关注关注。毕竟,好东西值得分享,不是吗?

这里有个常见的误解:Docker 容器默认以 root 运行,那我就直接用 root 装工具呗。这么想的话,Claude CLI 会毫不客气地给你一个下马威。

Terminal window
# 直接以 root 运行 Claude CLI?不行
docker run --rm -it --user root myimage claude
# 输出: Error: This command cannot be run as root user

这是 Claude CLI 的硬性安全限制。原因很简单:这些 CLI 工具会读写用户的敏感配置,包括 API Token、本地缓存、甚至可能执行用户编写的脚本。以 root 权限运行这些工具,潜在风险太大。毕竟,安全这东西,怎么谨慎都不为过。

那么问题来了:怎么才能既满足 CLI 的安全要求,又保持容器管理的灵活性?我们需要换个思路——不是在运行时切换用户,而是从镜像构建阶段就创建专用用户。有时候啊,换个角度看问题,答案就自然浮现了。

创建专用用户:不止是换个名字

Section titled “创建专用用户:不止是换个名字”

你可能会想,那我直接在 Dockerfile 里加一行 USER 指令不就得了?这确实是最简单的方案,但不够健壮。简单的东西往往不够优雅,不是吗?

HagiCode 的方案是创建一个 UID 1000 的 hagicode 用户,这个 UID 通常匹配大多数宿主机的默认用户:

RUN groupadd -o -g 1000 hagicode && \
useradd -o -u 1000 -g 1000 -s /bin/bash -m hagicode && \
mkdir -p /home/hagicode/.claude && \
chown -R hagicode:hagicode /home/hagicode

但这只解决了镜像内置用户的问题。如果宿主机用户是 UID 1001 呢?容器启动时还需要支持动态映射。

docker-entrypoint.sh 中的关键逻辑:

Terminal window
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
if ! id hagicode >/dev/null 2>&1; then
groupadd -g "$PGID" hagicode
useradd -u "$PUID" -g "$PGID" -s /bin/bash -m hagicode
fi
fi

这样设计的好处是:镜像构建时使用默认的 UID 1000,运行时可以通过环境变量 PUID/PGID 动态调整。无论宿主机用户是什么 UID,配置文件的所有权都不会出问题。这设计说起来也挺自然的,毕竟,灵活性和默认值之间需要找到一个平衡点罢了。

每个 AI CLI 工具都有自己偏好的配置目录,这需要一一对应:

CLI 工具容器内路径命名卷
Claude/home/hagicode/.claudeclaude-data
Codex/home/hagicode/.codexcodex-data
OpenCode/home/hagicode/.config/opencodeopencode-config-data

为什么用命名卷而不是绑定挂载?三个原因:

  1. 简化管理:命名卷由 Docker 自动管理生命周期,不需要手动创建宿主机目录
  2. 权限隔离:卷的初始内容由容器内用户创建,避免宿主机权限冲突
  3. 独立迁移:卷可以独立于容器存在,升级镜像时数据不会丢失

docker-compose-builder-web 会自动生成对应的卷配置:

volumes:
claude-data:
codex-data:
opencode-config-data:
services:
hagicode:
volumes:
- claude-data:/home/hagicode/.claude
- codex-data:/home/hagicode/.codex
- opencode-config-data:/home/hagicode/.config/opencode
user: "${PUID:-1000}:${PGID:-1000}"

注意这里的 user 字段:通过环境变量注入 PUID/PGID,确保容器进程以匹配宿主机的用户身份运行。这细节说起来挺重要的,毕竟,权限问题一旦出现,排查起来也挺让人头疼的。

版本管理:烘焙版本与运行时覆盖

Section titled “版本管理:烘焙版本与运行时覆盖”

Docker 镜像的版本固定是保证可重现性的关键。但在实际开发中,我们经常需要测试新版本,或者紧急修复一个 bug。如果每次都要重新构建镜像,那效率也太低了。

HagiCode 的策略是固定版本作为默认值,运行时覆盖作为扩展能力。这也算是工程实践中的一种妥协吧,稳定性和灵活性之间总要有个取舍。

Dockerfile.template 中固定版本:

USER hagicode
WORKDIR /home/hagicode
# 配置 npm 全局安装路径
RUN mkdir -p /home/hagicode/.npm-global && \
npm config set prefix '/home/hagicode/.npm-global'
# 安装 CLI 工具(使用固定版本)
RUN npm install -g @anthropic-ai/claude-code@2.1.71 && \
npm install -g @openai/codex@0.112.0 && \
npm install -g opencode-ai@1.2.25 && \
npm cache clean --force

docker-entrypoint.sh 中支持运行时覆盖:

Terminal window
install_cli_override_if_needed() {
local package_name="$2"
local override_version="$5"
if [ -n "$override_version" ]; then
gosu hagicode npm install -g "${package_name}@${override_version}"
fi
}
# 使用示例
install_cli_override_if_needed "" "@anthropic-ai/claude-code" "" "" "${CLAUDE_CODE_CLI_VERSION}"

这样,在不重新构建镜像的情况下,可以通过环境变量测试新版本:

Terminal window
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage

这设计说起来也挺实用的,毕竟,谁愿意每次测试新功能都要重新构建镜像呢?

除了手动配置 CLI 工具,有些场景下还需要自动注入配置。最典型的就是 API Token。

Terminal window
if [ -n "$ANTHROPIC_AUTH_TOKEN" ]; then
mkdir -p /home/hagicode/.claude
cat > /home/hagicode/.claude/settings.json <<EOF
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "${ANTHROPIC_AUTH_TOKEN}"
}
}
EOF
chown -R hagicode:hagicode /home/hagicode/.claude
fi

这里需要注意两点:敏感信息通过环境变量传入,不要硬编码到镜像中;配置文件的所有权要正确设置,否则 CLI 工具无法读取。这事儿说起来挺基础的,可是做错的人还真不少。

这是最容易踩的坑。宿主机用户的 UID 是 1001,容器内是 1000,创建的文件互相访问不了。

Terminal window
# 正确做法:让容器匹配宿主机用户
docker run \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
myimage

这问题说起来也挺常见的,可是第一次遇到的时候,还是挺让人郁闷的。

如果你发现每次重启都要重新登录,检查一下是不是忘记挂载持久化卷了:

volumes:
- claude-data:/home/hagicode/.claude

配置这东西,辛辛苦苦设置好了,说没就没了,那感觉,怎么说呢,挺让人崩溃的。

不要直接在运行的容器里执行 npm install -g。正确做法是:

  1. 设置环境变量触发覆盖安装
  2. 或者重新构建镜像
Terminal window
# 方式一:运行时覆盖
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage
# 方式二:重新构建
docker build -t myimage:v2 .

条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

  • API Token 通过环境变量传入,不写入镜像
  • 配置文件设置 600 权限
  • 始终以非 root 用户运行应用
  • 定期更新 CLI 版本,修复安全漏洞

安全这东西,说起来挺重要,可是真正落实的时候,又有多少人能做得好呢?

如果以后要支持新的 CLI 工具,只需要三步:

  1. Dockerfile.template:添加安装步骤
  2. docker-entrypoint.sh:添加版本覆盖逻辑
  3. docker-compose-builder-web:添加持久化卷映射

模板化的设计让扩展变得简单,不需要改动核心逻辑。这也算是过来人的一点心得,不是什么大道理,只是踩过的坑罢了。

Docker 容器中运行 AI CLI 工具,核心挑战在于用户权限、配置持久化、版本管理三个维度。HagiCode 项目通过创建专用用户、命名卷隔离、环境变量覆盖的组合方案,实现了既安全又灵活的部署架构。

关键设计要点:

  • 用户隔离:从镜像构建阶段创建专用用户,运行时支持 PUID/PGID 动态映射
  • 持久化策略:每个 CLI 工具对应独立的命名卷,容器重启不影响配置
  • 版本灵活性:固定默认值确保可重现性,运行时覆盖提供测试能力
  • 自动化配置:支持通过环境变量自动注入敏感配置

这套方案在 HagiCode 项目中已经稳定运行了一段时间,希望能给有类似需求的开发者一些参考。其实也没那么复杂,不过是些工程实践罢了。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

HagiCode Soul 平台技术解析:从需求萌发到独立平台的演进之路

HagiCode Soul 平台技术解析:从需求萌发到独立平台的演进之路

Section titled “HagiCode Soul 平台技术解析:从需求萌发到独立平台的演进之路”

其实写技术文章这事儿,也没什么了不起的,不过是把一些趟过的坑、绕过的弯路整理出来罢了。毕竟谁还没年轻过呢,对吧?本文将深入解析 HagiCode 项目中 Soul(AI Agent 人格配置系统)的设计理念、架构演进和核心技术实现,探讨如何通过独立平台提供更聚焦的 Agent 人格创建与分享体验。

在 AI Agent 的开发实践中,我们经常会遇到一个看似简单却极其重要的问题:如何让不同的 Agent 拥有稳定且独特的语言风格和人格特征?

这问题说起来也挺无奈的。早期 HagiCode 的 Hero 体系中,不同英雄(Agent 实例)主要依赖职业配置和通用提示词来区分表达方式。这种方式带来了一些明显的痛点,或许做过的朋友都有同感。

首先,语言风格难以保持一致。同样是”开发工程师”角色,今天的回复可能专业严谨,明天的输出又变得随意散漫。这不是模型本身的问题,而是缺乏一个独立的人格配置层来约束和引导输出风格罢了。

其次,角色感普遍较弱。当我们描述一个 Agent 的特征时,往往只能用”友好”、“专业”、“幽默”这样模糊的形容词,却没有具体的语言规则来支撑这些抽象的描述。说白了,就是说起来挺美好,做起来却没辙。

第三,人格配置的复用性几乎为零。假设我们精心设计了一个”猫娘服务员”的说话风格,想要在另一个业务场景中复用这套表达方式,几乎需要从头开始配置。美的事物或人,不一定要占用,只是想复用一下罢了…可是真的难。

正是为了解决这些实际问题,我们引入了 Soul 机制——一个独立于装备和描述的语言风格配置层。Soul 可以定义 Agent 的说话习惯、语气偏好和用词边界,可以在多个英雄间共享复用,还能在 Session 首次调用时自动注入系统提示词。

或许有人会觉得这也罢了,不就是配置几个提示词吗?可是有时候啊,问题的关键不在于能不能做,而在于怎么做更优雅。随着 Soul 能力的逐步成熟,我们意识到它已经具备了独立发展的潜力。一个专门的 Soul 平台可以让用户更聚焦地创建、分享和浏览各种有趣的人格配置,而不必被 Hero 系统的其他功能所干扰。于是,soul.hagicode.com 独立平台应运而生。

HagiCode 是一个开源的 AI 代码助手项目,采用现代化的技术栈构建,致力于为开发者提供流畅的智能编程体验。本文分享的 Soul 平台方案,正是我们在开发 HagiCode 过程中,为了解决 Agent 人格管理这一实际问题而探索出来的实践经验。如果你觉得这套方案有价值,说明我们在工程实践中积累了一定的技术判断力——那么 HagiCode 项目本身也值得关注了解一下。

Soul 平台的发展并非一蹴而就,而是经历了三个清晰的阶段。这故事开始得突然,结束得自然。

最早的 Soul 实现是作为 Hero 工作区的一个功能模块存在的。我们在 Hero 界面中增加了独立的 SOUL 编辑区域,支持预设套用和文本微调两种方式。

预设套用允许用户从一些经典人格模板中选择,比如”专业开发工程师”、“猫娘服务员”等。文本微调则让用户可以在预设基础上进行个性化修改。后端 Hero 实体相应地增加了 Soul 字段,并通过 SoulCatalogId 标识来源。

这个阶段解决了”有没有”的问题,也还算是个孩子,磕磕绊绊地成长着。但随着 Soul 内容越来越丰富,与 Hero 系统耦合在一起的架构开始显现出局限性。

为了提供更好的 Soul 发现和复用体验,我们构建了 SOUL Marketplace 目录页,支持浏览、搜索、详情查看和收藏功能。

在这个阶段,我们引入了 50 组主 Catalog(基础角色)10 组正交规则(表达方式) 的组合设计。主 Catalog 定义了 Agent 的核心人设,比如”雾港旅人”、“夜航猎手”这类抽象的角色设定;正交规则则定义了表达的方式,比如”简洁干练”、“啰嗦亲切”等语言风格特征。

50 × 10 = 500 个组合可能性,为用户提供了丰富的人格配置空间。这数量说多不多,说少不少,怎么说呢,条条大路通罗马,只是有的路好走一点罢了。后端通过 catalog-sources.json 生成完整的 SOUL 目录,前端则负责将这些目录项呈现为可交互的卡片列表。

站内 Marketplace 是一个很好的过渡方案,但也只是过渡而已。它仍然依附于主系统,对于只想使用 Soul 功能的用户来说,访问路径还是太深了。毕竟谁愿意绕一大圈才能做一件简单的事呢?

最终,我们决定将 Soul 能力迁移到独立仓库(repos/soul),原主系统的 Marketplace 改为外部跳转引导,新平台采用 Builder-first 设计理念——默认首页即为创建工作台,用户打开网站的第一时间就可以开始创建自己的人格配置。

这个阶段的技术栈也进行了全面升级:采用 Vite 8 + React 19 + TypeScript 5.9 组合,使用 shadcn/ui 组件系统统一设计语言,引入 Tailwind CSS 4 的主题变量系统。前端工程化水平的提升,为后续的功能迭代打下了坚实基础。

一切都淡了…不,一切才刚刚开始。

Soul 平台的一个核心设计理念是本地优先。这意味着首页必须在无后端情况下可完全运行,远端素材失败时不得阻断页面进入。

其实这也没什么了不起的,只是在设计系统时多考虑了一步罢了。本地快照作为基线,远端作为增强,这种思路让产品在任何网络条件下都能提供基本的可用性。具体实现上,我们采用了两层素材架构:

export async function loadBuilderMaterials(): Promise<BuilderMaterials> {
const localMaterials = createLocalMaterials(snapshot) // 本地基线
try {
const inspirationFragments = await fetchMarketplaceItems() // 远程增强
return { ...localMaterials, inspirationFragments, remoteState: "ready" }
} catch (error) {
return { ...localMaterials, remoteState: "fallback" } // 优雅降级
}
}

本地素材来自主系统文档的构建期快照,包含 50 组基础角色和 10 组表达规则的完整数据。远端素材则来自用户发布的 Soul,通过 Marketplace API 获取。两者的结合,为用户提供了从官方模板到社区创意的完整素材光谱。想笑来伪装自己掉下的泪…不,其实没什么,就是本地加远程罢了。

Soul 的核心数据抽象是 SoulFragment(灵魂碎片):

export type SoulFragment = {
fragmentId: string
group: "main-catalog" | "expression-rule" | "published-soul"
title: string
summary: string
content: string
keywords: string[]
localized?: Partial<Record<AppLocale, LocalizedFragmentContent>>
sourceRef: SoulFragmentSourceRef
meta: SoulFragmentMeta
}

group 字段区分了碎片的类型:主目录定义角色内核,正交规则定义表达方式,用户发布的 Soul 则标记为 published-soullocalized 字段支持多语言,让同一个碎片可以在不同语言环境下呈现不同的标题和描述。国际化设计要趁早,这话我们也算是用上了。

Builder 草稿状态则封装了用户当前的编辑状态:

export type SoulBuilderDraft = {
draftId: string
name: string
selectedMainFragmentId: string | null
selectedRuleFragmentId: string | null
inspirationSoulId: string | null
mainSlotText: string
ruleSlotText: string
customPrompt: string
previewText: string
updatedAt: string
}

用户在编辑器中选择的每个碎片,其内容都会被拼接到对应的 slot(槽位)中,形成最终的预览文本。mainSlotText 对应主角色内容,ruleSlotText 对应表达规则内容,customPrompt 则是用户的额外补充指令。

预览编译是 Soul Builder 的核心功能,它将用户选择的碎片和自定义文本组装成可复制的系统提示词:

export function compilePreview(
draft: Pick<SoulBuilderDraft, "mainSlotText" | "ruleSlotText" | "customPrompt">,
fragments: {
mainFragment: SoulFragment | null
ruleFragment: SoulFragment | null
inspirationFragment: SoulFragment | null
}
): PreviewCompilation {
// 组装逻辑:主角色 + 表达规则 + 灵感参考 + 自定义内容
}

编译结果会展示在中央预览面板中,用户可以实时看到最终效果,并一键复制到剪贴板。这功能说起来也挺简单的,不是吗?可是简单的东西往往最实用。

Soul Builder 的前端状态管理遵循一个重要原则:状态边界清晰划分。具体来说,抽屉状态不持久化,不直接写入草稿;只有明确的 Builder 操作才会触发状态变更。

// 领域状态(useSoulBuilder)
export function useSoulBuilder() {
// 素材加载与缓存
// 槽位聚合与预览编译
// 复制行为与反馈消息
// Locale 安全的描述符
}
// 呈现状态(useHomeEditorState)
export function useHomeEditorState() {
// activeSlot, drawerSide, drawerOpen
// 默认焦点行为
}

这种分离确保了编辑状态的安全性和界面的响应速度。抽屉的打开关闭是纯粹的 UI 交互,不需要触发复杂的持久化逻辑。这无异于废话了!不,其实很重要——界面状态和业务状态要明确区分,避免 UI 交互污染核心数据模型。

Soul Builder 采用单抽屉模式:同时只允许一个槽位抽屉打开。点击遮罩层、按 ESC 键或切换槽位都会自动关闭当前抽屉。这个设计简化了状态管理,也符合移动端抽屉交互的常见模式。

抽屉关闭不会清空当前编辑内容,用户切换回来时,上下文得以保留。这种”轻量级”的抽屉设计,避免了用户操作的中断感。毕竟谁愿意辛辛苦苦写的东西,因为不小心点错就全没了吗?

国际化是 Soul 平台的重要特性。系统文案完全支持双语切换,而用户草稿文本则永远不会因语言切换而被重写——因为草稿文本本身就是用户自由输入的内容,不涉及系统翻译。

官方灵感卡(Marketplace Soul)保持上游显示名称,但提供最佳努力的英文摘要。对于中文名称的 Soul,我们通过预定义的映射规则生成英文版本:

// 主角色英文名映射
const mainNameEnglishMap = {
"雾港旅人": "Mistport Traveler",
"夜航猎手": "Night Hunter",
// ...
}
// 正交规则英文名映射
const ruleNameEnglishMap = {
"简洁干练": "Concise & Professional",
"啰嗦亲切": "Verbose & Friendly",
// ...
}

这映射表看起来也挺简单的,可是要维护好它,也得花不少心思。毕竟有 50 组主角色和 10 组正交规则,乘起来就是 500 个组合,这数量说大不大,说小也不小。

Soul Catalog 的批量生成在后端完成,使用 C# 实现了 50 × 10 = 500 个组合的自动化创建:

foreach (var main in source.MainCatalogs)
{
foreach (var orthogonal in source.OrthogonalCatalogs)
{
var catalogId = $"soul-{main.Index:00}-{orthogonal.Index:00}";
var displayName = BuildNickname(main, orthogonal);
var soulSnapshot = BuildSoulSnapshot(main, orthogonal);
// 写入数据库...
}
}

昵称生成算法将主角色名和表达规则名组合在一起,创造出富有想象力的 Agent 代号:

private static readonly string[] MainHandleRoots = [
"雾港", "夜航", "零帧", "星渊", "霓虹", "断云", ...
];
private static readonly string[] OrthogonalHandleSuffixes = [
"旅人", "猎手", "术师", "行者", "星使", ...
];
// 组合示例:雾港旅人、夜航猎手、零帧术师...

Soul 快照的拼装则按照固定的模板格式,将主角色核心、标志特征、表达规则核心和输出约束组合在一起:

private static string BuildSoulSnapshot(main, orthogonal) => string.Join('\n', [
$"你的人设内核来自「{main.Name}」:{main.Core}",
$"保持以下标志性语言特征:{main.Signature}",
$"你的表达规则来自「{orthogonal.Name}」:{orthogonal.Core}",
$"必须遵循这些输出约束:{orthogonal.Signature}"
]);

这模板拼装说起来也是无聊透顶的活儿,可是没有这些无聊的工作,哪来有趣的产品呢?

Soul 从主系统拆分到独立平台后,我们面临的一个重要挑战是如何处理已有用户数据。这问题说起来也挺常见的——拆分容易,迁移难。我们采取了三项保障措施:

向后兼容保障。已保存的 Hero SOUL 快照保持可见,历史快照即使失去 Marketplace 来源 ID 仍可预览。这意味着用户之前的所有配置都不会丢失,只是展示位置发生了变化。毕竟谁也不想辛辛苦苦的配置,说没就没了。

主系统接口弃用。站内 Marketplace API 返回 410 Gone 状态码,并附带迁移提示,引导用户访问 soul.hagicode.com。

Hero SOUL 表单改造。在 Hero Soul 编辑区域新增迁移提示区块,明确告知用户 Soul 平台已经独立,并提供一键跳转按钮:

HeroSoulForm.tsx
<div className="rounded-2xl border border-orange-200/70 bg-orange-50/80 p-4">
<div>{t('hero.soul.migrationTitle')}</div>
<p>{t('hero.soul.migrationDescription')}</p>
<Button onClick={onOpenSoulPlatform}>
{t('hero.soul.openSoulPlatformAction')}
</Button>
</div>

回顾 Soul 平台的整个开发过程,有几点实践经验值得分享。这也算是过来人的一点心得,不是什么大道理,只是踩过的坑罢了。

本地优先的运行时假设。在设计依赖远端数据的特性时,始终假设网络可能不可用。本地快照作为基线,远端作为增强,这种思路让产品在任何网络条件下都能提供基本的可用性。毕竟这年头,网络这东西,说断就断,谁也说不准。

状态边界清晰划分。界面状态和业务状态要明确区分,避免 UI 交互污染核心数据模型。抽屉开关是纯粹的 UI 状态,不需要和草稿持久化混在一起。

国际化设计要趁早。如果你的产品有国际化需求,最好在数据模型设计阶段就考虑进去。localized 字段虽然增加了数据结构的复杂度,但后续维护多语言内容的成本会大大降低。

素材同步工作流要自动化。Soul 平台的本地素材来自主系统文档,当上游文档更新时,需要有机制同步到前端快照。我们设计了 npm run materials:sync 脚本自动化这个过程,确保素材始终和上游保持一致。

基于当前的架构设计,Soul 平台未来可以考虑以下发展方向。这也只是一些粗浅的想法,不一定对,权当抛砖引玉罢了。

社区共享生态。支持用户上传和分享自定义 Soul,增加评分、评论和推荐机制,让优秀的 Soul 配置能够被更多人发现和使用。毕竟独乐乐不如众乐乐。

多模态扩展。除了文字风格,还可以考虑支持语音风格配置、表情符号使用偏好、代码风格与格式化规则等维度。这事儿说起来挺美好,做起来可能就…

智能辅助。基于使用场景自动推荐 Soul,风格迁移与融合,甚至 A/B 测试不同 Soul 的实际效果。美又何必在乎天晴阴呢?试试就知道了。

跨平台同步。支持从其他 AI 平台导入人格配置,提供标准化的 Soul 导出格式,与主流 Agent 框架集成。

本文分享了 HagiCode Soul 平台从需求萌发到独立平台的完整演进过程。我们探讨了为什么需要 Soul 机制(解决 Agent 人格一致性问题),分析了技术架构的三个发展阶段(内嵌配置、站内 Marketplace、独立平台),深入讲解了核心的数据模型、状态管理、预览编译和国际化设计,并分享了平台迁移的实践经验。

Soul 的本质,是一个独立于业务逻辑的人格配置层。它让 AI Agent 的语言风格变得可定义、可复用、可分享。从技术角度看,这个设计并不复杂,但它解决的问题却是真实的、有广泛需求的。

如果你也在开发 AI Agent 产品,不妨思考一下你的人格配置方案是否足够灵活。Soul 平台的实践或许能给你一些启发。

此情可待成追忆,只是当时已惘然。或许有一天,你也会遇到类似的问题,到时候这篇文章能帮上一点忙,那也就够了。


如果你觉得这篇文章有帮助,欢迎来 GitHub 给个项目一颗 Star。公测已经开始了,欢迎安装体验。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。