Hermes Dashboard
Hermes Agent 是 Nous Research 的开源 Agent。它的 Dashboard(Hermes 内部叫 tui_gateway,TUI 与网页 Dashboard 共用)在一条 WebSocket 上跑 JSON-RPC,服务端保存会话与历史。
这一页说明怎么接 Hermes Dashboard:建连接、建会话、审批与追问卡片、会话级事件,以及这个协议要注意的地方。它和 Hermes(API server)是两套协议、两个入口(adapters/hermes-gateway 与 adapters/hermes),接 Dashboard 不需要开启 API server。请求与事件的字段见 Hermes Dashboard 协议参考。
结论
- 适配器自带连接
createHermesGatewayConnection:请求与事件走一条 WebSocket(api/ws),历史走 Dashboard 的 REST(api/sessions/{id}/messages),审批与追问由服务端推送、客户端回答,能力函数不用接入方写。地址与外层鉴权由连接选项决定。
- 整个应用一个连接、一个适配器实例;ChatKit 的 sessionId 是 Hermes 的持久会话编号(
stored_session_id)。连接上的 live 编号由连接维护,断线重连后重新 session.resume。
- 审批与追问挂在工具片段上,用工具渲染器画卡片,按钮调
connection.respond。
- 只支持请求流模式;不支持续接。
建连接
hermes-gateway-connection.ts
import { createHermesGatewayAdapter, createHermesGatewayConnection } from '@volcengine/chatkit/adapters/hermes-gateway';
// Hermes Dashboard 的地址(可以带路径前缀,如走自己的网关);协议路径(`chat`、`api/ws`、`api/sessions/{id}/messages`)由连接追加
const BASE = 'https://example.com/hermes';
// 整个应用一个连接、一个适配器实例;会话 id 在每次调用时带上
export const connection = createHermesGatewayConnection({
// kind 是 'http'(取 token 的页面、历史)或 'ws'(api/ws);外层鉴权参数放在地址上时在这里追加
resolveUrl: (path, kind) => `${kind === 'ws' ? BASE.replace(/^http/, 'ws') : BASE}/${path}`,
// 不给时每次建连从 `chat` 页面里提取 session token;token 由自己的服务下发时在这里返回
// sessionToken: () => fetchTokenFromMyServer(),
});
export const adapter = createHermesGatewayAdapter({ connection });
session token 能操作整个实例,生产环境建议走自己的服务转发并校验登录态,地址前缀写进 BASE。
会话与界面
hermes-gateway-app.tsx
import '@volcengine/chatkit/styles.css';
import { Chatkit, SessionList, type SessionListProps, useChatSessions } from '@volcengine/chatkit';
import { adapter, connection } from './hermes-gateway-connection';
import { interactionRenderers } from './hermes-gateway-interaction';
// 会话由应用创建,返回的是 Hermes 的持久会话编号,存进自己的会话目录
export async function createSession(cwd: string): Promise<string> {
const { sessionId } = await connection.createSession({ cwd });
return sessionId;
}
export function App({
sessions: items,
activeSessionId,
onSelect,
}: Pick<SessionListProps, 'sessions' | 'activeSessionId' | 'onSelect'>) {
const sessions = useChatSessions({ activeSessionId, adapter });
return (
<div style={{ display: 'flex', height: '100vh' }}>
<SessionList sessions={items} activeSessionId={activeSessionId} onSelect={onSelect} style={{ width: 260 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Chatkit sessions={sessions} renderers={interactionRenderers} />
</div>
</div>
);
}
会话目录由应用维护:connection.createSession(params) 把参数原样交给 session.create,返回持久编号 sessionId,存进自己的目录,交给 SessionList 与 useChatSessions。切到一个会话时,useChatSessions 经适配器取历史,一次取完。
Hermes 不保存客户端的轮次编号:同一页面里,历史的最后几轮按顺序对应回本地结束过的轮次;页面重新加载后按消息行 id 编号(hermes-row:{id})。
审批与追问
hermes-gateway-interaction.tsx
import { type ChatkitProps, type ChatkitToolViewProps, useCurrentSession } from '@volcengine/chatkit';
import {
getHermesGatewayInteraction,
type HermesGatewayInteraction,
} from '@volcengine/chatkit/adapters/hermes-gateway';
import { useState } from 'react';
import { connection } from './hermes-gateway-connection';
/** 默认的工具行照常显示;工具上挂着审批或追问时,在下面加一张卡片。 */
function ToolWithInteraction({ metadata, defaultNode }: ChatkitToolViewProps) {
const interaction = getHermesGatewayInteraction({ metadata });
return (
<>
{defaultNode}
{interaction && <InteractionCard interaction={interaction} />}
</>
);
}
function InteractionCard({ interaction }: { interaction: HermesGatewayInteraction }) {
const sessionId = useCurrentSession().session?.id;
const [text, setText] = useState('');
// 只有 pending 时能回答;answering 是已经点了、等后端确认
const disabled = interaction.status !== 'pending' || !sessionId || !interaction.requestId;
// 审批给 choices 里的一项,追问给答案文本
const answer = (response: string) => {
if (!sessionId || !interaction.requestId) return;
void connection
.respond({ sessionId, requestId: interaction.requestId, response })
// 没送达时状态会退回 pending,可以再点一次
.catch(error => console.error(error));
};
if (interaction.kind === 'approval')
return (
<div className="approval-card">
<p>{interaction.description ?? '允许执行这条命令?'}</p>
{interaction.command && <pre>{interaction.command}</pre>}
{interaction.status === 'answered' ? (
<p>已回答:{interaction.choice}</p>
) : (
interaction.choices.map(choice => (
<button key={choice} disabled={disabled} onClick={() => answer(choice)}>
{choice}
</button>
))
)}
</div>
);
return (
<div className="question-card">
<p>{interaction.question}</p>
{interaction.status === 'answered' ? (
<p>已回答:{interaction.answer}</p>
) : interaction.choices.length ? (
interaction.choices.map(choice => (
<button key={choice} disabled={disabled} onClick={() => answer(choice)}>
{choice}
</button>
))
) : (
// choices 为空时是自由回答
<form
onSubmit={event => {
event.preventDefault();
answer(text);
}}
>
<input value={text} disabled={disabled} onChange={event => setText(event.target.value)} />
<button disabled={disabled || !text}>回答</button>
</form>
)}
</div>
);
}
// 审批挂在被拦下时正在执行的工具上(如 terminal),追问挂在 clarify 上:用 match 接住全部工具
export const interactionRenderers: ChatkitProps['renderers'] = {
tools: { interactions: { match: () => true, render: ToolWithInteraction } },
};
状态依次是 pending(等待回答)→ answering(已调用 respond,等后端确认)→ answered。回答没送达时 respond 抛错、状态退回 pending,可以重试;按钮在非 pending 时置灰即可。等待回答时这一轮的阶段是 awaiting-external。追问过期(clarify.expire)或这一轮结束时仍在等待的请求标为 cancelled。
- 工具渲染器的参数里没有会话 id,卡片里用
useCurrentSession().session?.id 取。
- Hermes 的审批没有编号,连接生成
approval:{n} 作为 requestId;追问用 Hermes 给的 request_id。
- 实例的
approvals.mode 为缺省的 smart 时,审批由 Hermes 自动批准,不交给客户端。
- 刷新后,已回答的追问从
clarify 工具结果还原;审批不进历史。
会话级事件
标题、会话信息、阻塞请求这类内容不在消息里。connection.listen(sessionId, listener) 收到这个会话的全部原始内容,用它驱动插槽或回答阻塞请求:
hermes-gateway-listen.tsx
import { useEffect, useState } from 'react';
import { connection } from './hermes-gateway-connection';
/**
* 会话标题与信息:跟着 session.title、session.info 事件更新。
* 断线期间的变化不补发:先取一次、重连后再取一次。不带 title 调 session.title 返回当前标题,并推一次 session.info。
*/
export function useHermesSessionInfo(sessionId: string | undefined) {
const [title, setTitle] = useState<string>();
const [info, setInfo] = useState<Record<string, unknown>>();
const [online, setOnline] = useState(true);
useEffect(() => {
if (!sessionId) return;
let active = true;
const refetch = async () => {
// 方法参数里的 session_id 是这条连接上的 live 编号
const live = await connection.liveSessionId(sessionId);
const result = await connection.request<{ title?: string }>('session.title', { session_id: live });
if (active) setTitle(result.title || undefined);
};
void refetch().catch(() => undefined);
const off = connection.listen(sessionId, item => {
if (item.kind === 'connection') {
setOnline(item.status === 'reconnected');
if (item.status === 'reconnected') void refetch().catch(() => undefined);
return;
}
if (item.kind !== 'event') return;
const { type, payload } = item.event;
if (type === 'session.title' && typeof payload?.title === 'string') setTitle(payload.title);
if (type === 'session.info') setInfo(payload);
});
return () => {
active = false;
off();
};
}, [sessionId]);
return { title, info, online };
}
/** sudo 密码这类阻塞请求适配器不处理:经 listen 拿到,用 connection.request 回答;不回答时工具等到超时。 */
export function useHermesSudoPrompt(sessionId: string | undefined) {
useEffect(() => {
if (!sessionId) return;
return connection.listen(sessionId, item => {
if (item.kind !== 'event' || item.event.type !== 'sudo.request') return;
const password = window.prompt('sudo 密码') ?? '';
void connection
.request('sudo.respond', { request_id: item.event.payload?.request_id, password })
.catch(error => console.error(error));
});
}, [sessionId]);
}
连接断开时监听先收到 { kind: 'connection', status: 'disconnected' },连接自动重新取 token、建连并 session.resume 后收到 reconnected。断线期间的事件不补发,像上面这样在 reconnected 时补拉一次状态(不带 title 调 session.title 返回当前标题,并推一次 session.info)。阻塞请求的参数是 request_id 加 password / value / text,不回答时工具会等到超时。
业务内容
本轮内的事件(如 message.interim、subagent.*、message.complete 的 usage)也可以在 transformOutput 里转成 data 片段显示在回答里。刷新加载历史时,适配器把消息行回放成同形的事件逐条交给它,同一段逻辑在刷新后也能重建正文、思考与工具相关的内容;只在实时里推送的事件不在历史里。哪些事件属于某一轮、怎么拿,见协议参考的没有内置处理的事件。
每一轮的额外参数用 transformInput 加到 prompt.submit 的请求里,返回完整请求体;session_id 与 queued 由连接覆盖。
输入怎么放进请求
输入里带了不支持的附件时不发请求,这一轮以 UNSUPPORTED_ATTACHMENT 失败。
错误
已知限制
- 只支持请求流模式:同一会话里由其他客户端发起的轮次不实时显示,刷新后从历史出现。
- 不支持续接:断线后这一轮标记连接中断,由会话层经历史确认结果。
- 历史在一轮结束时才写入,一次取完不分页;不记录轮次是否被中断或失败(刷新后都按完成显示)、被中断那一轮的思考与部分正文、审批记录、插话与用户消息里的图片。
- 同一会话有别的客户端同时写入时,历史与本地轮次的对应可能错位。
- tui_gateway 是 Hermes 的内部接口,随版本变化;适配器按 Hermes 0.20.0 核对。