Skip to content

移植

1 post with the tag “移植”

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

Section titled “从 TypeScript 到 C#:Codex SDK 的跨语言移植实践”

怎么说呢,这篇文章也算是个孩子,记录了我们把官方 TypeScript Codex SDK 完整移植到 C# 的全过程。说是”移植”,其实更像是一场漫长的冒险,毕竟两种语言的脾性不太一样,总得想办法让它们好好相处。

Codex 这东西,是 OpenAI 推出的 AI Agent CLI 工具,确实挺强大的。官方给了 TypeScript SDK,放在 @openai/codex 这个包里。它呢,通过调用 codex exec --experimental-json 命令跟 Codex CLI 交互,解析 JSONL 格式的事件流。

可是吧,我们在 HagiCode 项目里,需要在一个纯 .NET 环境中使用它。具体来说,就是 C# 后端服务和桌面端应用。你说这事闹的,总不能为了调用一个 CLI 工具而在 .NET 项目中引入 Node.js 运行时吧?那也太折腾了。

于是摆在我们面前的就两条路:一是维护一套复杂的 Node.js 桥接层,二是自己动手丰衣足食,实现一个原生 C# SDK。

我们选择了后者。

其实这篇文也是来自我们在 HagiCode 项目里的实践经验。HagiCode 是个开源的 AI 代码助手项目,听起来挺高大上的,但说白了也就是同时维护着前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多种组件。这种多语言、多平台的复杂度,正是我们需要原生 C# SDK 的直接原因——总不能真的在 .NET 项目里跑个 Node.js 吧?那也太魔幻了。

如果你觉得这篇文章有点帮助,欢迎来 GitHub 给个 Star:github.com/HagiCode-org/site,也欢迎访问官网了解更多:hagicode.com。毕竟一个人品无限的项目能得到支持,也是件开心的事。

在开始代码层面的转化之前,我们得先理解两套 SDK 的架构设计。毕竟知己知彼,百战不殆嘛。

TypeScript SDK 的核心架构是这样的:

Codex (入口类)
└── CodexExec (执行器,管理子进程)
└── Thread (对话线程)
├── run() / runStreamed() (同步/异步执行)
└── 事件流解析

C# SDK 呢,保持了相同的架构层次,但在实现细节上做了适配。整体思路是:保持 API 的一致性,但在具体实现上充分利用 C# 语言特性。毕竟语言不同,总得有点区别才行。

这是最基础也是最重要的工作。毕竟万丈高楼平地起,基础打不好,后面全是麻烦。

TypeScript 的类型系统比 C# 更灵活,这是事实。我们需要找到合适的映射方式:

TypeScriptC#说明
interface / typerecordC# 使用 record 实现不可变数据结构
string | nullstring?可空引用类型
boolean | undefinedbool?可空布尔值
AsyncGeneratorIAsyncEnumerable异步迭代器

事件类型系统是一个典型的例子。TypeScript 使用联合类型来定义事件:

export type ThreadEvent =
| ThreadStartedEvent
| TurnStartedEvent
| TurnCompletedEvent
| ...

在 C# 中,我们使用继承层次和模式匹配来实现类似的效果:

public abstract record ThreadEvent(string Type);
public sealed record ThreadStartedEvent(string ThreadId) : ThreadEvent("thread.started");
public sealed record TurnStartedEvent() : ThreadEvent("turn.started");
public sealed record TurnCompletedEvent(Usage Usage) : ThreadEvent("turn.completed");
// ...

使用 record 而不是 class,是因为事件对象应该是不可变的,这和 TypeScript 中使用普通对象是一个道理。而 sealed 关键字则确保不会有额外的子类继承,编译器可以进行优化。其实也就那么回事,习惯就好了。

事件解析是整个 SDK 的核心,毕竟这决定了我们能否正确理解 Codex CLI 返回的每一条信息。解析错了,后面全是白忙活。

TypeScript 版本使用 JSON.parse() 来解析每一行 JSON:

export function parseEvent(line: string): ThreadEvent {
const data = JSON.parse(line);
// 处理各种事件类型...
}

C# 版本则使用 System.Text.Json.JsonDocument

public static ThreadEvent Parse(string line)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
var type = GetRequiredString(root, "type", "event.type");
return type switch
{
"thread.started" => new ThreadStartedEvent(GetRequiredString(root, "thread_id", ...)),
"turn.started" => new TurnStartedEvent(),
"turn.completed" => new TurnCompletedEvent(ParseUsage(...)),
// ...
_ => new UnknownThreadEvent(type, root.Clone()),
};
}

这里有一个小技巧:root.Clone() 是必要的,因为 JsonDocument 的元素在文档释放后就会失效,我们需要保留一份副本给未知的事件类型。这也是没办法的事,毕竟 C# 的 JSON 处理和 JavaScript 不太一样。

这是两个 SDK 差异最大的地方。毕竟 Node.js 和 .NET 的脾性不太一样,总得适应适应。

TypeScript 使用 Node.js 的 spawn() 函数:

const child = spawn(this.executablePath, commandArgs, { env, signal });

C# 使用 .NET 的 System.Diagnostics.Process

using var process = new Process { StartInfo = startInfo };
process.Start();
// 需要手动管理 stdin/stdout/stderr

具体来说,C# 版本需要这样配置进程:

var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

最大的区别在于取消机制。TypeScript 使用 AbortSignal,这是 Web API 的一部分,用起来挺顺手的:

const child = spawn(cmd, args, { signal: cancellationSignal });

C# 则使用 CancellationToken

public async IAsyncEnumerable<string> RunAsync(
CodexExecArgs args,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 在循环中检查取消状态
while (!cancellationToken.IsCancellationRequested)
{
// 处理输出...
}
// 取消时终止进程
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { }
}
}

这其中的区别,大概就是Web API 和 .NET 生态的差异吧,说到底也就是那么回事。

两套 SDK 都实现了将 JSON 配置转换为 TOML 配置的逻辑,因为 Codex CLI 接受 TOML 格式的配置覆盖。这部分逻辑必须完全保持一致,否则同样的配置在两个 SDK 中会产生不同的行为。

这叫什么?这就叫工匠精神嘛。毕竟细节决定成败,有些事不能将就。

我们创建了这样的项目结构:

CodexSdk/
├── CodexSdk.csproj
├── Codex.cs # 入口类
├── CodexThread.cs # 对话线程
├── CodexExec.cs # 执行器
├── Events.cs # 事件类型定义
├── Items.cs # 项目类型定义
├── EventParser.cs # 事件解析器
├── OutputSchemaTempFile.cs # 临时文件管理
└── ...

看起来也挺整齐的,不是吗?

基本的使用方式和 TypeScript SDK 保持一致:

using CodexSdk;
// 创建 Codex 实例
var codex = new Codex();
var thread = codex.StartThread();
// 执行查询
var result = await thread.RunAsync("Summarize this repository.");
Console.WriteLine(result.FinalResponse);

流式事件处理利用了 C# 的模式匹配能力:

await foreach (var @event in thread.RunStreamedAsync("Analyze the code."))
{
switch (@event)
{
case ItemCompletedEvent itemCompleted
when itemCompleted.Item is AgentMessageItem msg:
Console.WriteLine($"Assistant: {msg.Text}");
break;
case TurnCompletedEvent completed:
Console.WriteLine($"Tokens: in={completed.Usage.InputTokens}");
break;
case CommandExecutionItem command:
Console.WriteLine($"Command: {command.Command}");
break;
}
}

在实现过程中,我们也不算是白忙活,总结点经验如下:

  1. 进程管理:C# 版本需要手动管理进程的生命周期,包括取消时的进程终止。使用 Kill(entireProcessTree: true) 确保子进程也被清理。这叫什么?这就叫有始有终。

  2. 错误处理:我们使用 InvalidOperationException 抛出解析错误,保持与 TypeScript SDK 相似的错误处理方式。毕竟错误处理这事儿,不能太随意。

  3. 资源清理OutputSchemaTempFile 实现 IAsyncDisposable,确保临时文件被正确清理。这也是没办法的事,资源不清理干净,总会有问题。

  4. 环境变量:C# 版本支持通过 CodexOptions.Env 完全覆盖进程环境变量。这功能虽然小,但挺实用的。

  5. 平台差异:C# 版本不包含 TypeScript 版本中自动查找 npm 包中二进制文件的逻辑。这是因为 .NET 项目通常不依赖 npm,所以需要通过 CODEX_EXECUTABLE 环境变量或 CodexPathOverride 指定 codex 可执行文件路径。这叫什么?这就叫因地制宜。

将一个成熟的 TypeScript SDK 移植到 C#,不仅仅是语法层面的转换,更是对两种语言设计哲学的理解。TypeScript 的灵活性和 JavaScript 生态特性(如 AbortSignal)在 C# 中需要找到对应的替代方案。这其中的酸甜苦辣,大概也只有真正做过的人才能体会。

关键体会是:保持 API 的一致性比保持实现细节的一致性更重要。用户关心的是接口是否易用,而不是内部实现是否相同。这话听起来简单,但做起来需要取舍。

如果你也在做类似的跨语言移植工作,我们的经验是:先完整理解原 SDK 的架构设计,然后逐个模块进行转化,最后通过完整的测试用例确保行为一致。毕竟急不得,一口吃不成胖子。

一切都会好的,都会有的…



如果本文对你有帮助


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。