跳转到内容

技术架构

3 篇包含标签 "技术架构" 的文章

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 项目中落地实施,实际效果如何,欢迎大家安装体验后反馈。毕竟理论归理论,实践才是检验真理的唯一标准。


如果本文对你有帮助:

或许我们都是在技术路上摸爬滚打的普通人罢了,可那又怎样呢?普通人也有普通人的坚持。毕竟「竹子本来没有嘴,可也还在拔节生长」,人总得有点追求才是…

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

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。公测已经开始了,欢迎安装体验。

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

HagiCode Skill 系统技术解析:如何打造可扩展的 AI 技能管理平台

HagiCode Skill 系统技术解析:如何打造可扩展的 AI 技能管理平台

Section titled “HagiCode Skill 系统技术解析:如何打造可扩展的 AI 技能管理平台”

本文深入解析 HagiCode 项目中 Skill(技能)管理系统的架构设计与实现方案,涵盖本地全局管理、市场搜索、智能推荐、授信提供者管理四大核心功能的技术实现细节。

在 AI 代码助手这个领域,如何扩展 AI 的能力边界,其实一直是个核心课题。Claude Code 本身的代码辅助能力是挺强的,只是不同开发团队、不同技术栈,往往需要针对特定场景的专业能力——比如处理 Docker 部署、数据库优化、前端组件生成之类的。这时候,Skill(技能)系统就显得尤为重要了。

HagiCode 项目在开发过程中也遇到了类似的挑战:怎么让 Claude Code 能够像人一样「学会」新的专业技能,同时保持良好的用户体验和工程可维护性?毕竟这个问题,说难也难,说简单也简单。围绕这个问题,我们设计并实现了一套完整的 Skill 管理系统。

本文将详细解析这个系统的技术架构和核心实现,适合对 AI 扩展性、命令行工具集成感兴趣的开发者阅读。或许对你有用,也或许没用,但总归是写出来了。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 代码助手项目,旨在帮助开发团队提升研发效率。项目的技术栈涵盖 ASP.NET Core、Orleans 分布式框架、TanStack Start + React 前端,以及本文要介绍的 Skill 管理子系统。

项目的 GitHub 地址是 HagiCode-org/site,如果你觉得本文介绍的技术方案有价值,欢迎给个 Star。毕竟Star多了,心情也会好一些。

Skill 系统采用前后端分离的架构设计,说起来也没什么特别的。

前端部分 使用 TanStack Start + React 构建用户界面,通过 Redux Toolkit 管理状态,四个主要功能分别对应四个 Tab 组件:本地技能、市场画廊、智能推荐、授信提供者。这样设计,其实也是为了用户体验罢了。

后端部分 基于 ASP.NET Core + ABP Framework,使用 Orleans Grain 实现分布式状态管理。在线 API 客户端封装了 IOnlineApiClient 接口,用于与远程技能目录服务通信。

整体架构的设计原则是「命令执行与业务逻辑分离」,通过适配器模式将 npm/npx 命令执行的细节屏蔽在独立模块中。毕竟谁愿意看到一堆命令行散落在代码各处呢?

本地全局管理是最基础的功能模块,负责列出已安装的技能并支持卸载操作。也没什么复杂的,就是把事情做好而已。

实现位置在 LocalSkillsTab.tsxLocalSkillCommandAdapter.cs。核心思路是封装 npx skills 命令,解析其 JSON 输出,转换为内部数据结构。说起来简单,做起来其实也简单。

public async Task<IReadOnlyList<LocalSkillInventoryResponseDto>> GetLocalSkillsAsync(
CancellationToken cancellationToken = default)
{
var result = await _commandAdapter.ListGlobalSkillsAsync(cancellationToken);
return result.Skills.Select(skill => new LocalSkillInventoryResponseDto
{
Name = skill.Name,
Version = skill.Version,
Source = skill.Source,
InstalledPath = skill.InstalledPath,
Description = skill.Description
}).ToList();
}

数据流非常清晰:前端发起请求 → SkillGalleryAppService 接收 → LocalSkillCommandAdapter 执行 npx 命令 → 解析 JSON 结果 → 返回 DTO 对象。一环扣一环,也没什么好说的。

卸载技能使用 npx skills remove -g <skillName> -y 命令,系统会自动处理依赖关系和清理工作。安装元数据存储在技能目录的 managed-install.json 中,记录了安装时间、来源版本等信息,便于后续更新和审计。毕竟有些东西,记下来总是好的。

技能安装涉及多个步骤的协调,怎么说呢,其实也不算太复杂:

public async Task<SkillInstallResultDto> InstallAsync(
SkillInstallRequestDto request,
CancellationToken cancellationToken = default)
{
// 1. 规范化安装引用
var normalized = _referenceNormalizer.Normalize(
request.SkillId,
request.Source,
request.SkillSlug,
request.Version);
// 2. 检查先决条件
await _prerequisiteChecker.CheckAsync(cancellationToken);
// 3. 获取安装锁
using var installLock = await _lockProvider.AcquireAsync(normalized.SkillId);
// 4. 执行安装命令
var result = await _installCommandRunner.ExecuteAsync(
new SkillInstallCommandExecutionRequest
{
Command = $"npx skills add {normalized.FullReference} -g -y",
Timeout = TimeSpan.FromMinutes(4)
},
cancellationToken);
// 5. 持久化安装元数据
await _metadataStore.WriteAsync(normalized.SkillPath, request);
return new SkillInstallResultDto { Success = result.Success };
}

这里用到了几个关键的设计模式:引用规范化器 负责将各种输入格式(如 tanweai/pua@opencode/docker-skill)转换为统一的内部表示;安装锁机制 确保同一技能同时只有一个安装操作在进行;流式输出 通过 Server-Sent Events 向前端实时推送安装进度,用户可以看到类似终端的实时日志。

这些设计模式,说到底,也还是为了让事情变得简单罢了。

市场搜索让用户能够发现和安装来自社区的技能。毕竟一个人的能力是有限的,众人的智慧才是无穷的。

搜索功能依赖在线 API https://api.hagicode.com/v1/skills/search。为了提升响应速度,系统实现了缓存机制。缓存这东西,就像记忆一样,有些东西记住了,下次就不用再费劲去想。

private async Task<IReadOnlyList<SkillGallerySkillDto>> SearchCatalogAsync(
string query,
CancellationToken cancellationToken,
IReadOnlySet<string>? allowedSources = null)
{
var cacheKey = $"skill_search:{query}:{string.Join(",", allowedSources ?? Array.Empty<string>())}";
if (_memoryCache.TryGetValue(cacheKey, out var cached))
return (IReadOnlyList<SkillGallerySkillDto>)cached!;
var response = await _onlineApiClient.SearchAsync(
new SearchSkillsRequest
{
Query = query,
Limit = _options.LimitPerQuery,
},
cancellationToken);
var results = response.Skills
.Where(skill => allowedSources is null || allowedSources.Contains(skill.Source))
.Select(skill => new SkillGallerySkillDto { ... })
.ToList();
_memoryCache.Set(cacheKey, results, TimeSpan.FromMinutes(10));
return results;
}

搜索结果支持按授信来源过滤,只显示用户信任的技能源。预置的种子查询用于初始化目录,比如「popular」、「recent」等,让用户在首次打开时就能看到推荐的热门技能。毕竟第一印象还是重要的。

智能推荐是系统中最复杂的功能,它能根据用户当前项目的情况,自动推荐最适合的技能。复杂归复杂,但做出来还是值得的。

整个推荐流程分为五个阶段:

1. 构建项目上下文
2. AI 生成搜索查询
3. 并行搜索在线目录
4. AI 对候选进行排名
5. 返回推荐列表

首先,系统分析项目的技术栈、编程语言、域名结构等特征,构建一个「项目画像」。这个画像,就像一个人的简历一样,记录着所有的特征。

然后,使用 AI Grain 生成针对性的搜索查询。这里的设计其实挺有意思——不是直接问 AI「推荐什么技能」,而是让它先思考「什么样的搜索词能找到相关技能」。毕竟有时候,问问题的方法比答案本身更重要:

var queryGeneration = await aiGrain.GenerateSkillRecommendationQueriesAsync(
projectContext, // 项目上下文
locale, // 用户语言偏好
maxQueries, // 最大查询数量
effectiveSearchHero); // AI 模型选择

接着,并行执行这些搜索查询,获取候选技能列表。并行处理,说到底也是为了节省时间罢了。

最后,使用另一个 AI Grain 对候选技能进行排名。这一步会综合考虑技能与项目的相关性、授信状态、用户历史偏好等因素:

var ranking = await aiGrain.RankSkillRecommendationsAsync(
projectContext,
candidates,
installedSkillNames,
locale,
maxRecommendations,
effectiveRankingHero);
response.Items = MergeRecommendations(projectContext, candidates, ranking, maxRecommendations);

AI 模型可能出现响应慢或暂时不可用的情况。毕竟再好的系统,也有掉链子的时候。为此,系统设计了确定性回退机制:当 AI 服务不可用时,使用基于规则启发式算法生成推荐,比如根据 package.json 中的依赖推断可能需要的技能。

这个回退机制,说穿了,也就是给系统留了一条后路罢了。

授信提供者管理允许用户控制哪些技能源是可信的。毕竟信任这东西,还是要自己把握的。

授信提供者支持两种匹配规则:精确匹配(exact)和前缀匹配(prefix)。

public static TrustedSkillProviderResolutionSnapshot Resolve(
TrustedSkillProviderSnapshot snapshot,
string source)
{
var normalizedSource = Normalize(source);
foreach (var entry in snapshot.Entries.OrderBy(e => e.SortOrder))
{
if (!entry.IsEnabled) continue;
foreach (var rule in entry.MatchRules)
{
bool isMatch = rule.MatchType switch
{
TrustedSkillProviderMatchRuleType.Exact
=> string.Equals(normalizedSource, Normalize(rule.Value),
StringComparison.OrdinalIgnoreCase),
TrustedSkillProviderMatchRuleType.Prefix
=> normalizedSource.StartsWith(Normalize(rule.Value) + "/",
StringComparison.OrdinalIgnoreCase),
_ => false
};
if (isMatch)
return new TrustedSkillProviderResolutionSnapshot
{
IsTrustedSource = true,
ProviderId = entry.ProviderId,
DisplayName = entry.DisplayName
};
}
}
return new TrustedSkillProviderResolutionSnapshot { IsTrustedSource = false };
}

预置的授信提供者包括 Vercel、Azure、anthropics、Microsoft、browser-use 等知名组织和项目。自定义提供者可以通过配置文件添加,指定提供者 ID、显示名称、徽章标签、匹配规则等。毕竟世界那么大,不可能只有几家是可信的。

授信配置使用 Orleans Grain 持久化存储:

public class TrustedSkillProviderGrain : Grain<TrustedSkillProviderState>,
ITrustedSkillProviderGrain
{
public async Task UpdateConfigurationAsync(TrustedSkillProviderSnapshot snapshot)
{
State.Snapshot = snapshot;
await WriteStateAsync();
}
public Task<TrustedSkillProviderSnapshot> GetConfigurationAsync()
{
return Task.FromResult(State.Snapshot);
}
}

这种方式的好处是配置变更会自动同步到所有节点,无需手动刷新缓存。毕竟自动化,说到底也是为了让人少操心罢了。

Skill 系统需要执行各种 npx 命令,如果把这些逻辑散落在各处,代码会变得难以维护。因此我们设计了适配器接口。设计模式这东西,说到底,也还是为了让代码更好维护而已:

public interface ISkillInstallCommandRunner
{
Task<SkillInstallCommandExecutionResult> ExecuteAsync(
SkillInstallCommandExecutionRequest request,
CancellationToken cancellationToken = default);
}

不同的命令有不同的执行器实现,全部实现同一个接口,便于测试和替换。

安装进度通过 Server-Sent Events 实时推送到前端:

public async Task InstallWithProgressAsync(
SkillInstallRequestDto request,
IServerStreamWriter<SkillInstallProgressEventDto> stream,
CancellationToken cancellationToken)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "npx",
Arguments = $"skills add {request.FullReference} -g -y",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
}
};
process.OutputDataReceived += async (sender, e) =>
{
await stream.WriteAsync(new SkillInstallProgressEventDto
{
EventType = "output",
Data = e.Data ?? string.Empty
});
};
process.Start();
process.BeginOutputReadLine();
await process.WaitForExitAsync(cancellationToken);
}

用户在前端可以看到类似终端的实时输出,体验非常直观。毕竟实时反馈,让人安心。

以安装 pua 技能为例(这是一个流行的社区技能):

  1. 打开 Skills 抽屉,切换到「Skill Gallery」标签
  2. 输入「pua」进行搜索
  3. 点击搜索结果查看技能详情
  4. 点击「Install」按钮安装
  5. 切换到「Local Skills」标签确认安装成功

安装命令是 npx skills add tanweai/pua -g -y,系统会自动处理所有细节。其实也没那么多步骤,一步步来就是了。

如果你的团队有自己的技能仓库,可以添加为授信来源:

providerId: "my-team"
displayName: "My Team Skills"
badgeLabel: "MyTeam"
isEnabled: true
sortOrder: 100
matchRules:
- matchType: "prefix"
value: "my-team/"
- matchType: "exact"
value: "my-team/special-skill"

这样来自你团队的所有技能都会显示授信徽章,用户可以更放心地安装。毕竟有标记的东西,总是让人安心一些。

创建自定义技能需要遵循以下结构:

my-skill/
├── SKILL.md # 技能元数据(YAML front matter)
├── index.ts # 技能入口
├── agents/ # 支持的代理配置
└── references/ # 参考资源

SKILL.md 的格式示例:

---
name: my-skill
description: A brief description of what this skill does
---
# My Skill
Detailed documentation...
  1. 网络要求:技能搜索和安装需要能访问 api.hagicode.com 和 npm registry
  2. Node.js 版本:建议使用 Node.js 18 或更高版本
  3. 权限要求:需要全局 npm 安装权限
  4. 并发控制:同一技能同时只能有一个安装或卸载操作在执行
  5. 超时设置:安装操作默认超时时间为 4 分钟,复杂场景可能需要调整

这些注意事项,说到底,也还是为了让事情顺利进行罢了。

本文介绍了 HagiCode 项目中 Skill 管理系统的完整实现。这个系统通过前后端分离的架构、适配器模式、Orleans 分布式状态管理等技术手段,实现了:

  • 本地全局管理:通过封装 npx skills 命令,提供统一的技能管理接口
  • 市场搜索:利用在线 API 和缓存机制,快速发现社区技能
  • 智能推荐:结合 AI 能力,根据项目上下文推荐最合适的技能
  • 授信管理:灵活的配置系统,让用户掌控信任边界

这套设计思路不仅适用于 Skill 管理,对于任何需要集成命令行工具、兼顾本地存储和在线服务的场景,都有参考价值。

如果本文对你有帮助,欢迎来 GitHub 给个 Star:github.com/HagiCode-org/site。也可以访问官网了解更多:hagicode.com

或许你也会觉得,这套系统设计得还行,或许你不会。但这都罢了,毕竟代码写出来,总有人会用,也总有人不会用…

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