Hermes Desktop Office 空间交互解析:银行柜员/ATM 代表(Representative)系统与钱包面板实现
【免费下载链接】hermes-desktopDesktop Companion for Hermes Agent项目地址: https://gitcode.com/gh_mirrors/her/hermes-desktop
本文围绕 Hermes Desktop(Hermes Agent 桌面伴侣)Office 标签页中的空间代表(Space Representative)系统展开:以银行柜员(bank teller)与自助 ATM 为第一个落地场景,完整剖析从 3D 场景的可交互对象(Interactable)、代表注册表(registry)、交互面板(RepInteractionPanel)到主进程后端钱包调用(wallet-actions)的整条调用链。读者将掌握该模块"一个注册表条目 + 一处 Interior 挂载 + 面板动作接线"的可扩展架构,以及账户作用域缓存、请求令牌防串号等关键实现细节。
背景:什么是"空间代表"系统
在 Office 标签页的 3D 城市中,银行是第一个"交易空间"(transaction space)。空间代表指的是空间中供 Agent 办理业务的 NPC——今天只有银行柜员,未来还有展厅销售(showroom sales)、建筑空间(building space)等。点击银行柜员或自助 ATM,会打开一个共享的动作模态框(balances、account status 等),针对所选 Agent 运行在 hermes-one 后端上的操作。
从源码结构看,新增一个空间代表只需四件事:
- 在 代表注册表 增加一条注册项;
- 在对应的 3D 室内场景(Interior)中用一个 Interactable 包裹代表模型;
- 在 RepInteractionPanel 中接线面板动作(仅当新空间需要新能力时);
- 补齐 i18n 字符串。
"即将上线"的动作(如 ATM 的 withdraw/deposit)通过disabled标志注册,在面板中渲染为弱化的"Soon" 芯片,直到对应流程落地。除点击外,面板还能由 Agent 自己打开:一条由聊天命令(chat command)驱动的"差事"(errand)会让 Agent 走到代表身边并自动打开面板执行指定动作——这一机制详见 office-world-actions。
代表注册表(Representative Registry)
注册表将每个代表的身份与菜单内容与 3D 场景解耦:注册项只描述 id、所属空间、i18n 标签键和有序动作列表,3D 场景通过 id 反查它。
核心类型与条目
registry.ts 定义了三个核心类型:
RepActionId:动作 id 的联合类型,含checkBalance、accountStatus、createAccount、sendMoney、withdraw、deposit六种;RepAction:动作描述,含id、i18n 键labelKey(位于office.命名空间)和可选disabled标志;SpaceRepresentative:代表描述,含id、spaceId、显示名labelKey、空间名spaceLabelKey和有序动作列表actions。
当前注册的两个代表如下(完整源码见 registry.ts):
| id | spaceId | 动作 | 说明 |
|---|---|---|---|
bank-teller | bank | checkBalance / accountStatus / createAccount | 银行柜员,全部可执行 |
atm | bank | checkBalance / accountStatus / withdraw(disabled)/ deposit(disabled) | 自助 ATM,存取款为 coming-soon |
值得注意的细节:
sendMoney("Send to agent")在类型层面存在、在注册表中被刻意省略。源码注释明确说明:在转账流程落地前,该动作不会出现在任何注册项中,但RepActionId类型与面板渲染已预留支持——这是"先扩展类型、后落地功能"的典型做法。getRepresentative(id)负责按 id 解析代表,未知 id 或空值返回null,Office 屏幕据此决定是否渲染面板。- 面板校验保证"每个代表至少有 ≥1 个可执行动作",避免出现纯 coming-soon 的死菜单(见下文测试节)。
3D 交互物:Interactable 与银行场景挂载
Interactable 通用组件
Interactable.tsx 是把室内物体(ATM、展示车、办公桌)变为可交互对象的基础设施,行为如下:
- 悬停:显示一个浮空的 Billboard 标签(深色半透明底板 + 浅色文字)和一个半透明的地面高亮环(
ringGeometry),并切换指针光标(useCursor); - 点击:触发
onActivate回调; enabled关闭时:裸渲染子节点,不挂任何交互——这样在城市视角下,点击语义(选中 Agent、开发者建筑移动器)不会受室内交互影响。
组件还暴露position、indicatorPosition、labelHeight、ringRadius等参数,用于微调标签高度与高亮环半径(如 ATM 用labelHeight={1.9}、ringRadius={0.75},柜员用labelHeight={2.1}、ringRadius={0.55})。
银行室内:柜员与 ATM
Bank.tsx 是银行室内的实现,包含两个关键包装:
BankTellers:在柜台后排布 3 个StaffPerson(银行员工模型,制服色调比顾客更沉稳,见TELLER_TINTS),每个都用Interactable包裹;BankATMs:按 4 个位置摆放atm.glb网格,同样用Interactable包裹。
两者都只在interactive(室内模式)下启用,onTellerActivate/onAtmActivate事件从 Bank → Office3D → Office.tsx 逐层冒泡,最终由 Office.tsx 设置当前激活的代表 id(bank-teller或atm);进出建筑会清空该值。
一个值得注意的 i18n 边界:柜员的悬停标签tellerLabel是在 Office.tsx 中预先翻译后通过 props 层层下传的,因为 i18n 上下文无法跨越 r3f 的<Canvas>边界(useI18n只能在 React 树外使用);ATM 的标签则是静态字符串 "ATM"。
另外从代码演进看:ATM 之前打开的是个人资料模态框的钱包页,现已改为打开atm代表面板,与其他代表共用同一套模态机制。
交互面板:RepInteractionPanel
RepInteractionPanel.tsx 是所有代表(银行柜员、ATM)共用的居中模态框:半透明暗色背景 + 主题化卡片。面板采用纯扁平的主题 CSS 变量(var(--bg-tertiary)、var(--border)等),不使用渐变。
Props 与打开方式
| Prop | 类型 | 说明 |
|---|---|---|
rep | SpaceRepresentative | 当前代表(由getRepresentative(activeRepId)解析) |
agents | OfficeAgent[] | 可选 Agent 列表 |
initialAgentId | string \| null | 初始选中 Agent |
visible | boolean | Office 标签页是否可见(用于账户重解析) |
autoAction | RepActionId \| null | 差事驱动的自动动作(chat world actions) |
onClose | () => void | 关闭回调 |
Office 传入的初始 Agent 为selectedId ?? defaultAgentId:defaultAgentId是当前激活 profile(按 agent id 匹配)或第一个 Agent——这样打开时选择器预选当前 profile,而不是空白的 "Choose an agent…"。
布局与交互细节
- 背景层:
fixed inset-0,点击背景或按 Escape 关闭;卡片挂载时淡入/缩放(opacity+transform过渡),布局全部用内联样式(非 Tailwind class)以自包含、防定位破坏。 - 头部身份区:按代表区分
REP_ICONS图标(柜员 = 银行地标Landmark,ATM = 卡片CreditCard),下方是实时的状态行(带绿点):柜员显示 "Open · serving {agent}",ATM 显示 "Online · {agent}";未选 Agent 时显示 idle 文案。 - Agent 选择芯片:头像 + 原生
<select>组合,保证键盘可达性(appearance: none去掉默认样式,视觉上是胶囊芯片)。 - Hero 卡片:随最近一次动作结果变化——
- loading:骨架屏 + 旋转 Loader;
- balance:总额数字 + 装饰性扁平 sparkline(BalanceSparkline 纯静态折线,不携带任何数据,因为后端不提供价格历史);
- created:成功卡(地址缩写 · Base);
- error:错误卡 +Retry按钮(
runAction(activeAction)重跑上次动作); - hint:警告提示卡(signed-out / unlinked / foreign);
- 空钱包卡:
$0.00+ "no funds in this account yet"——注意不提供创建提示,因为能达到余额说明账户已存在(一个 Agent 只有一个账户)。
- Token 行:余额加载后渲染圆形的 symbol 徽章(tokenBadge 按 symbol 着色:HD/H1/HERMES 系用主黄色,ETH/WETH 用主题强调色,其余用通用色)、名称、数量与 USD 估值。
- 账户状态行:渲染 Transactable / Receive-only 徽章。
- 数字字体:总额、Token 数量与 USD 均使用
--font-numeric(Space Grotesk)渲染,通过自托管的@font-face引入src/renderer/src/assets/main.css(fonts/SpaceGrotesk-Variable.ttf),无 CDN。
动作分发逻辑
动作渲染为flex-wrap 的芯片组(visibleActions)而非固定网格,这样动作数量可变时永远整齐排布、不会出现空单元格:
- check balance(主强调色芯片):优先读取缓存的可交易钱包 id(
readBank),缺失时才走syncWallets查钱包列表,再渲染后端投资组合; - account status:通过
syncWallets列出链接云端 Agent 的钱包; - create account(仅柜员):在后端创建钱包,后端幂等返回的 409 "already provisioned" 被映射为友好提示(见下节);
- disabled 动作(ATM 的 withdraw/deposit):渲染为弱化芯片 + "Soon" 徽章。
因为一个 Agent 恰好只有一个账户,一旦确认该 Agent 已有账户,"Create account" 芯片会被直接移除(rememberBank记录的hasAccount标志驱动visibleActions过滤)。signed-out、unlinked、foreign 三种状态渲染为提示而不是报错。
并发安全:每次请求都持有单调递增的requestSeq令牌。切换 Agent 选择器会使任何 in-flight 请求失效——晚到的响应永远不会把 A 的钱包渲染到 B 的上下文之下(apply只在requestSeq.current === request时生效)。
差事驱动的自动打开(autoAction)
来自 office-world-actions 编排的差事式打开会传入autoAction,面板在账户作用域解析完成后恰好执行一次该动作。这里的accountResolved门闩至关重要:账户 id 的解析会改变缓存键、递增requestSeq,若在挂载瞬间就触发动作,结果会被当作过期请求丢弃。autoRanRef保证同一动作只跑一次,切换autoAction时重置。
会话缓存:账户作用域 + 内存私有
缓存结构与键
每个 Agent 的银行状态(唯一的钱包 + 最近一次投资组合 +hasAccount)缓存在进程级内存Map(bankCache)中,键为`${signed-in account id}::${agent id}`。这样:
- 重新打开面板可免请求瞬间渲染已知组合;
- 缓存金融数据永不跨越登出或重新链接(不同账户产生不同键);
- 纯内存存储,不落盘任何云端钱包数据,延续 wallet-token-balances 中 "never persist cloud wallets" 原则。
readBank/rememberBank是唯二访问入口。账户 id 通过window.hermesAPI.getAccount()(应用级)解析后拼入cacheKey;id 为 null(未解析或已登出)时,所有读都 miss、所有写都是 no-op——没有已知账户就既不提供也不存储任何金融数据。
关键安全语义
- 同 profile 重链接到不同 Hermes 账户:键的账户半段变化,上一账户的投资组合与钱包 id 永远不会被读回;
- 写按请求自身的账户 + Agent 键控:即使选择器已切换,晚到的结果仍会缓存到正确条目;
- Office 标签页每次变为可见时重新解析账户,而非仅挂载时一次。原因:面板可以保持挂载(Office 只隐藏、从不卸载——见 office-3d-walk-mode),而用户可能在别处切换 Hermes 账户。
visibleprop 把 Office 的显示状态传下来:隐藏时面板忘记已解析的账户(accountId → null),防止返回时在重新校验前闪现过期余额;返回时重新运行getAccount,账户变化则产生新键与缓存 miss。
后端钱包动作:主进程对 hermes-one 的调用
面板的动作全部通过主进程调用 hermes-one 后端完成——桌面端不持有任何密钥、也不在本地读取链状态。
IPC 通道与调用链
wallet-actions.ts 是主进程侧实现,通过 IPC 注册表 暴露两个通道:
| IPC 通道 | 主进程函数 | 后端端点 | 说明 |
|---|---|---|---|
wallet-portfolio | getWalletPortfolio(profile, walletId) | GET /api/wallets/:id/portfolio | 读取钱包投资组合 |
wallet-provision | provisionAgentWallet(profile) | POST /api/wallets | 创建 Bankr 钱包 |
preload 层(src/preload/index.ts)把这两个通道封装为window.hermesAPI.getWalletPortfolio/provisionCloudWallet,结果类型WalletPortfolioResult/ProvisionWalletResult定义在 src/shared/wallets.ts。
getWalletPortfolio:读组合
- 先经
resolveLinkedAgent解析账户/令牌/链接 Agent id 前置信息; - 请求
GET /api/wallets/:id/portfolio,要求是可交易钱包(后端用钱包存储的密钥鉴权读取;receive-only 钱包会返回后端错误字符串); - 响应中的原始 token 行会被归一化:缺失 symbol 补
"?"、缺失 name 补"Token"、非数字余额归零——畸形行有默认值兜底而不是崩溃; - 网络失败时返回
Couldn't reach ${apiUrl}: …错误信息。
provisionAgentWallet:创建钱包
- 请求体为
{ agentId, kind: "bankr" }; - 后端幂等:重复创建返回 409,被映射为
status: "exists",UI 据此提示"该 Agent 已存在账户"而非报错。
resolveLinkedAgent:公共前置
两个函数共用 resolveLinkedAgent(从钱包同步流程中提取的公共前奏),依次处理:
- signed-out:无账户或无令牌,直接短路(不发网络请求);
- 从未同步:先自动执行一次
syncAgents()获得 Agent id; - unlinked:同步后仍无 Agent id;
- foreign:链接的 Agent 归属另一个 Hermes 账户(或后端地址不匹配)——钱包动作不得作用于他人账户的 Agent(后端同样强制所有权,客户端提前显式拒绝);
- ok:返回
{ apiUrl, token, agentId }。
测试保障
模块配套三套 Vitest 测试:
- registry.test.ts——校验每个代表都有标签且 ≥1 个可执行动作、id 唯一、银行柜员以 bank 空间注册了银行动作、未知 id 解析为 null;
- wallet-actions.test.ts——portfolio:signed-out 短路(0 次网络调用)、token 映射、畸形行默认值、后端错误字符串透传、网络失败;provisioning:请求体、409 → exists、自动同步失败后 unlinked、HTTP 错误;
- RepInteractionPanel.test.tsx——面板的四个关键行为保证:
- 面板跟随 Office 选择:选择变化时面板保持挂载,新非空选择被跟随,选择清空时保留面板自身选择,动作不会静默作用于 UI 已离开的 Agent;
- 丢弃过期动作结果:为 A 发起的动作在响应到达前切到 B,则结果被丢弃,B 的上下文永不显示 A 的钱包;重跑 B 的动作则正常渲染 B 的数据;
- 钱包缓存按账户作用域:同一 profile 重链接到另一账户后,之前账户下缓存的余额不再展示,重新打开渲染中性占位符(缓存键含账户 id);
- 账户作用域在重新显示时刷新:面板保持挂载期间账户变化(Office 隐藏再显示、从不卸载),重新显示会重新解析账户,旧账户缓存余额让位于占位符。
小结:可扩展的空间交互模式
银行是这套"空间代表"模式的第一个落地场景,其核心抽象——注册表驱动身份与菜单、Interactable 统一 3D 交互、面板按动作 id 分发、账户作用域缓存保证金融数据隔离、主进程统一走后端 API——为未来的展厅销售(showroom sales)与建筑空间(building space)提供了直接可复用的骨架。开发者若要新增一个空间,只需按文首的四步接入;而sendMoney类型预留与 ATM 的 disabled 存取款则展示了"先占位、后落地"的迭代路径。
进一步阅读:办公室 3D 室内与可交互对象总览见 office-3d-interiors,差事驱动的自动打开机制见 office-world-actions,钱包同步与余额机制见 wallet-token-balances。
【免费下载链接】hermes-desktopDesktop Companion for Hermes Agent项目地址: https://gitcode.com/gh_mirrors/her/hermes-desktop
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考