CrystaCode.ai通过SignalR实现服务端AI模型对浏览器UI的真实操控(开关灯、弹窗等),详细描述了Client Driver Skill架构设计。
问 Crysta——CrystaCode.ai 上的 AI Agent——把网站切换到深色模式,网站真的变暗了。让它弹出登录框,弹窗真的打开了。大模型不再只是回答问题了,它在操作 UI。
但问题在于:LLM 运行在服务器上,而 UI 运行在浏览器里。模型没法点击按钮。那怎么给一个远程大脑装上"手"呢?
答案是我们最终称之为 Client Driver Skill 的模式:函数调用,SignalR 作为那只手。
我们聊天界面的第一个版本是一条单向道。模型会说"好的,我带你去套餐页面"——然后什么都没发生。回答是文字,UI 毫无反应。
你有两个经典选项:
我们选择了推送。流程变成了:模型调用函数 → 函数在服务器上运行 → 服务器通过 SignalR 推送一条类型化命令 → 客户端执行。
[Browser] [Server]
| |
| 1. "switch to dark mode" |
| ---- InvokeAsync ---------> |
| | 2. Brain runs, model sees
| | the UpdateSiteTheme tool
| | 3. Model calls the function
| | (function calling)
| | 4. Push to the exact tab:
| <--- ChangeSiteTheme ------ | Clients.Client(connId)
| 5. ThemeService flips it |
| 6. "Site theme changed..." | 5b. return value re-enters
| | the model's context
| <--- chat answer ---------- |
用户输入"switch to dark mode"→ Blazor 客户端调用 hub
服务器 session 运行 brain;模型看到一个叫 UpdateSiteTheme 的工具
模型判断用户想要深色模式,调用该函数
服务器将 ChangeSiteTheme(DarkMode) 推送到确切的浏览器连接
客户端应用主题并重新渲染
函数的返回值重新进入模型的上下文,这样 AI 知道主题已更改并在聊天中确认
在客户端,我们的聊天组件订阅了一组 hub 事件。每一个都是服务器可以"按下"的"遥控器":
HubConnection.On<SiteTheme>("ChangeSiteTheme", OnChangeTheme);
HubConnection.On("ShowLoginPopup", ShowLoginPopup);
HubConnection.On<CrystaPages>("NavigateToPage", HubOnNavigateToPage);
HubConnection.On("ShowInvitationModal", HanleOnShowInvitationModal);
就这样。UI 不轮询、不猜测——它只是等待服务器按下按钮。
让这一切工作的诀窍:我们用 AIFunctionFactory.Create 把 skill 方法转换成 LLM 工具。[Description] 属性是模型阅读的用户手册——写的时候要像指令,而不是文档字符串:
[Description("When user ask to change site ui theme, call this method
to change user ui, site theme can be dark or light mode")]
public async Task<string> UpdateSiteTheme(
[Description("Site theme based on user request")] SiteTheme siteTheme)
{
await OnUpdateSiteTheme(siteTheme);
return $"Site theme changed by crysta to {siteTheme}.";
}
然后 skill 暴露它的工具集:
public List<AIFunction> GetTools()
{
return
[
AIFunctionFactory.Create(UpdateSiteTheme),
AIFunctionFactory.Create(NavigateToPage),
AIFunctionFactory.Create(ShowLoginPopup),
AIFunctionFactory.Create(ShowMarkdown),
AIFunctionFactory.Create(StartInitialFreeTrialSubscription),
// ... every UI action the model is allowed to trigger
];
}
brain 从所有已注册的 skills 收集这些工具,并附加到 Microsoft.Extensions.AI 聊天管道上。模型不知道(也不关心)效果发生在某个浏览器里——对它来说,这些只是它可以调用的工具。
这是最关键的部分。函数体在服务器上运行,效果必须落到一个特定的浏览器 tab:
protected override async Task OnUpdateSiteTheme(SiteTheme siteTheme)
{
var lastConnectionId = await interactionCoordinator.GetLastConnectionId(UserSessionId);
if (!string.IsNullOrWhiteSpace(lastConnectionId))
{
await HubContext.Clients.Client(lastConnectionId).ChangeSiteTheme(siteTheme);
}
}
有两个细节很容易错过,而且都让我们花了不少调试时间:
定位到连接,而不是用户。 Clients.User(userId) 会广播到用户打开的每个 tab 和设备。命令必须落到对话发生所在的 tab——所以我们追踪 session 最后活跃的连接 id,推送到 Clients.Client(connectionId)。
给 hub 加上类型。 我们用 IHubContext<CrystaHub, ICrystaHubClient>——客户端契约是一个真正的 C# 接口:
public interface ICrystaHubClient
{
Task ChangeSiteTheme(SiteTheme siteTheme);
Task NavigateToPage(CrystaPages page);
Task ShowLoginPopup();
Task ShowMarkDown(string markdown);
Task ShowInitialTrialSubscriptionStartedModal();
// ...
}
没有魔法字符串,没有在凌晨两点因为拼写错误搞砸生产演示。如果客户端和服务器不一致,编译直接报错。
回到浏览器,已注册的处理器触发:
private async Task OnChangeTheme(SiteTheme theme)
{
await Task.Delay(500);
await themeService.ChangeSiteTheme(theme);
}
一个小细节:翻转前等半秒。我们发现,在聊天气泡渲染后给 UI 一点时间稳定下来,会让主题切换感觉是刻意的,而不是突兀的。
ThemeService 通过组件库的主题管理器做实际的工作,然后通过 pub/sub 通知每个关心的组件:
public async Task ChangeSiteTheme(SiteTheme siteTheme)
{
var current = await bitThemeManager.GetCurrentThemeAsync();
var shouldBeDark = siteTheme == SiteTheme.DarkMode;
if ((shouldBeDark && current != "dark") || (!shouldBeDark && current == "dark"))
{
await bitThemeManager.ToggleDarkLightAsync();
}
await bitDeviceCoordinator.ApplyTheme(shouldBeDark);
pubSubService.Publish(ClientPubSubMessages.THEME_CHANGED, theme);
}
主题是最简单的一个。有趣的部分:打开弹窗只是另一个 hub 事件。模型判断用户需要登录,调用 ShowLoginPopup,服务器推送命令:
private async Task ShowLoginPopup()
{
_ = signInModalService.SignIn();
}
更丰富的例子:当模型想展示团队成员的详情时,它不只是导航——它在命令中带上数据:
HubConnection.On<TeamMemberDto>("OnNavigateToTeamMemberPage", dto =>
{
AddComponentItem(typeof(CrystaMember), new() { { "TeamMemberDto", dto } },
new PageInfo() { FullComponent = typeof(TeamMemberModal) });
});
模型已经从 GetTeamInfo 调用中拿到了数据——所以命令携带了一个类型化的 DTO,客户端打开精确的预填充了数据的弹窗。AI 在聊天中描述这个人,同时弹窗里出现他们的完整资料。
循环不止于服务器→客户端。有时候 UI 需要告诉模型发生了什么——因为模型的下一句话取决于此。
例子:邀请码弹窗关闭了。客户端直接把一条指令推入模型的上下文:
// user closed the modal without a code
var instructionText = """
The user could not enter an invitation code.
Tell them that if they don't have one, they can get it from their friends.
""";
await HubConnection.InvokeAsync("InstructCrysta", instructionText);
服务器把这条指令注入到运行中的 session,模型响应真实的 UI 事件,答案通过同一个聊天通道流回来。浏览器和大脑最终进入了一场真正的双向对话——UI 也有了自己的声音。
LLM 工具不一定要返回数据。它们可以触发副作用。模型的"工具箱"也是它的遥控器。
SignalR 是那只手。服务器端 brain,浏览器端 UI,它们之间是一条类型化的通道。
定位到 session 的最后连接,而不是用户。 多 tab 用户会让你在这上面调试一整天。
给 hub 加上类型。 IHubContext<Hub, IClientContract> 把运行时拼写错误变成编译错误。
返回一个状态字符串。 模型读取函数结果——"Site theme changed to DarkMode"——这样它可以确认真正发生了什么,而不是猜测。
构建反向通道。 让 UI 推送指令回来(InstructCrysta),模型的回答就和屏幕上真实的内容一致了。
这就是"会回答的 AI"和"会行动的 AI"之间的区别——这正是我们在 CrystaCode.ai 构建的东西。