mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-23 23:28:14 +02:00
feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)
Complete rewrite of the application: - Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest; main process rewritten in TypeScript (electron/), typed IPC contract (shared/), sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process. - Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode, global push-to-talk hotkey, system-voice fallback. - Hermes: full API client (capabilities, health, models, skills, toolsets, sessions, jobs) with automatic transport selection: runs API (SSE lifecycle, approvals, steer, stop) > sessions stream > chat completions with local tools; tolerant event normalisation; webhook receiver hardened (secret, size limit). - UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal, holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/ skills/sessions/link tabs, telemetry), settings drawer, approval modals, compact floating widget, four themes, tray icon and global shortcuts. - Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
239 files changed
+11351
-86495
No files matched your search
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Full Hermes Agent API client: discovery, runs (SSE lifecycle + approvals + steering),
|
||||
* sessions (server-side memory), OpenAI-compatible chat completions (with EveFlow tools)
|
||||
* and the cron jobs REST API.
|
||||
*/
|
||||
import { Log } from '../../lib/log';
|
||||
import { SseParser, tryParseJson } from '../../lib/sse';
|
||||
import { httpFetch, httpStream, HttpError, type StreamHandle } from '../../lib/transport';
|
||||
import { normalizeCompletionChunk, normalizeLifecycleEvent, type CompletionToolCallAccumulator } from './events';
|
||||
import type {
|
||||
HermesCapabilities,
|
||||
HermesChatMessage,
|
||||
HermesConfig,
|
||||
HermesHealth,
|
||||
HermesJob,
|
||||
HermesJobDraft,
|
||||
HermesModel,
|
||||
HermesRunInfo,
|
||||
HermesSession,
|
||||
HermesSessionMessage,
|
||||
HermesSkill,
|
||||
HermesStreamEvent,
|
||||
HermesToolset,
|
||||
HermesTransport,
|
||||
SendHandle,
|
||||
SendOptions
|
||||
} from './types';
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
|
||||
|
||||
export function hermesBaseUrl(url: string): string {
|
||||
let base = url.trim().replace(/\/+$/, '');
|
||||
base = base.replace(/\/chat\/completions$/, '');
|
||||
base = base.replace(/\/v1$/, '');
|
||||
return base;
|
||||
}
|
||||
|
||||
function extractArray<T>(payload: unknown, keys: string[]): T[] {
|
||||
if (Array.isArray(payload)) return payload as T[];
|
||||
if (isRec(payload)) {
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (Array.isArray(value)) return value as T[];
|
||||
}
|
||||
if (isRec(payload.data)) return extractArray<T>(payload.data, keys);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function errorMessage(status: number, body: string): string {
|
||||
const parsed = tryParseJson<Rec>(body);
|
||||
if (parsed) {
|
||||
const err = parsed.error;
|
||||
if (isRec(err) && typeof err.message === 'string') return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
for (const key of ['message', 'detail', 'error_description']) {
|
||||
if (typeof parsed[key] === 'string') return parsed[key] as string;
|
||||
}
|
||||
}
|
||||
const text = body.trim().slice(0, 240);
|
||||
if (status === 401 || status === 403) return `Authentification refusée (HTTP ${status}). Vérifiez la clé API_SERVER_KEY.`;
|
||||
if (status === 404) return `Endpoint introuvable (HTTP 404)${text ? ` : ${text}` : ''}`;
|
||||
if (status === 429) return 'Hermes est saturé (HTTP 429, max_concurrent_runs atteint).';
|
||||
return `HTTP ${status}${text ? ` : ${text}` : ''}`;
|
||||
}
|
||||
|
||||
export function resolveTransport(config: HermesConfig, caps: HermesCapabilities | null): ResolvedTransport {
|
||||
if (config.transport !== 'auto') return config.transport;
|
||||
if (!caps) return 'completions';
|
||||
const features = caps.features ?? {};
|
||||
const endpoints = caps.endpoints ?? {};
|
||||
if (features.run_submission && features.run_events_sse) return 'runs';
|
||||
if (endpoints.session_chat) return 'sessions';
|
||||
return 'completions';
|
||||
}
|
||||
|
||||
export class HermesClient {
|
||||
constructor(private readonly config: HermesConfig) {}
|
||||
|
||||
get base(): string {
|
||||
return hermesBaseUrl(this.config.url);
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}, json = true): Record<string, string> {
|
||||
const h: Record<string, string> = { ...extra };
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
h.Accept = h.Accept ?? 'application/json';
|
||||
if (this.config.apiKey.trim()) h.Authorization = `Bearer ${this.config.apiKey.trim()}`;
|
||||
if (this.config.sessionKey.trim()) h['X-Hermes-Session-Key'] = this.config.sessionKey.trim().slice(0, 256);
|
||||
return h;
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
|
||||
if (!this.config.url.trim()) throw new Error("URL Hermes non configurée.");
|
||||
const url = `${this.base}${path}`;
|
||||
const res = await httpFetch({
|
||||
url,
|
||||
method: init.method ?? 'GET',
|
||||
headers: this.headers(),
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
timeoutMs: init.timeoutMs ?? 20_000
|
||||
});
|
||||
if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text);
|
||||
const text = res.text ?? '';
|
||||
if (!text.trim()) return null as T;
|
||||
const parsed = tryParseJson<T>(text);
|
||||
if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// ── Discovery ─────────────────────────────────────────────────────────────
|
||||
|
||||
capabilities(): Promise<HermesCapabilities> {
|
||||
return this.request<HermesCapabilities>('/v1/capabilities');
|
||||
}
|
||||
|
||||
async health(): Promise<HermesHealth> {
|
||||
try {
|
||||
return await this.request<HermesHealth>('/health/detailed', { timeoutMs: 8000 });
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw err;
|
||||
const basic = await this.request<HermesHealth | Rec>('/health', { timeoutMs: 8000 });
|
||||
return { status: 'ok', ...(isRec(basic) ? basic : {}) } as HermesHealth;
|
||||
}
|
||||
}
|
||||
|
||||
async models(): Promise<HermesModel[]> {
|
||||
const payload = await this.request<unknown>('/v1/models');
|
||||
return extractArray<HermesModel>(payload, ['data', 'models']);
|
||||
}
|
||||
|
||||
async skills(): Promise<HermesSkill[]> {
|
||||
const payload = await this.request<unknown>('/v1/skills');
|
||||
return extractArray<HermesSkill>(payload, ['skills', 'data', 'items']);
|
||||
}
|
||||
|
||||
async toolsets(): Promise<HermesToolset[]> {
|
||||
const payload = await this.request<unknown>('/v1/toolsets');
|
||||
return extractArray<HermesToolset>(payload, ['toolsets', 'data', 'items']);
|
||||
}
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
async listSessions(limit = 40): Promise<HermesSession[]> {
|
||||
const payload = await this.request<unknown>(`/api/sessions?limit=${limit}`);
|
||||
return extractArray<HermesSession>(payload, ['sessions', 'items', 'data', 'results']);
|
||||
}
|
||||
|
||||
async createSession(title?: string): Promise<HermesSession> {
|
||||
const payload = await this.request<unknown>('/api/sessions', { method: 'POST', body: title ? { title } : {} });
|
||||
if (isRec(payload) && isRec(payload.session)) return payload.session as HermesSession;
|
||||
return payload as HermesSession;
|
||||
}
|
||||
|
||||
getSession(id: string): Promise<HermesSession> {
|
||||
return this.request<HermesSession>(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async sessionMessages(id: string): Promise<HermesSessionMessage[]> {
|
||||
const payload = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/messages`);
|
||||
return extractArray<HermesSessionMessage>(payload, ['messages', 'items', 'data']);
|
||||
}
|
||||
|
||||
updateSession(id: string, patch: { title?: string; end_reason?: string }): Promise<HermesSession> {
|
||||
return this.request<HermesSession>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch });
|
||||
}
|
||||
|
||||
deleteSession(id: string): Promise<void> {
|
||||
return this.request<void>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async forkSession(id: string): Promise<HermesSession> {
|
||||
const payload = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/fork`, { method: 'POST', body: {} });
|
||||
if (isRec(payload) && isRec(payload.session)) return payload.session as HermesSession;
|
||||
return payload as HermesSession;
|
||||
}
|
||||
|
||||
// ── Jobs (cron) ───────────────────────────────────────────────────────────
|
||||
|
||||
async listJobs(): Promise<HermesJob[]> {
|
||||
const payload = await this.request<unknown>('/api/jobs');
|
||||
return extractArray<HermesJob>(payload, ['jobs', 'items', 'data']).map(normalizeJob);
|
||||
}
|
||||
|
||||
private unwrapJob(payload: unknown): HermesJob {
|
||||
if (isRec(payload) && isRec(payload.job)) return normalizeJob(payload.job as HermesJob);
|
||||
return normalizeJob(payload as HermesJob);
|
||||
}
|
||||
|
||||
async createJob(draft: HermesJobDraft): Promise<HermesJob> {
|
||||
const body: Rec = { name: draft.name, schedule: draft.schedule, prompt: draft.prompt };
|
||||
if (draft.deliver) body.deliver = draft.deliver;
|
||||
if (draft.skills?.length) body.skills = draft.skills;
|
||||
return this.unwrapJob(await this.request<unknown>('/api/jobs', { method: 'POST', body }));
|
||||
}
|
||||
|
||||
async updateJob(id: string, patch: Partial<HermesJobDraft>): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch }));
|
||||
}
|
||||
|
||||
deleteJob(id: string): Promise<void> {
|
||||
return this.request<void>(`/api/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async pauseJob(id: string): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/pause`, { method: 'POST', body: {} }));
|
||||
}
|
||||
|
||||
async resumeJob(id: string): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/resume`, { method: 'POST', body: {} }));
|
||||
}
|
||||
|
||||
async runJob(id: string): Promise<HermesJob> {
|
||||
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/run`, { method: 'POST', body: {}, timeoutMs: 30_000 }));
|
||||
}
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string }): Promise<{ run_id: string; status: string }> {
|
||||
const payload: Rec = { input: body.input };
|
||||
if (body.session_id) payload.session_id = body.session_id;
|
||||
if (body.instructions) payload.instructions = body.instructions;
|
||||
if (body.model) payload.model = body.model;
|
||||
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
|
||||
}
|
||||
|
||||
getRun(runId: string): Promise<HermesRunInfo> {
|
||||
return this.request<HermesRunInfo>(`/v1/runs/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
stopRun(runId: string): Promise<unknown> {
|
||||
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/stop`, { method: 'POST', body: {} });
|
||||
}
|
||||
|
||||
steerRun(runId: string, text: string): Promise<unknown> {
|
||||
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/steer`, { method: 'POST', body: { input: text } });
|
||||
}
|
||||
|
||||
approveRun(runId: string, choice: 'once' | 'session' | 'always' | 'deny', requestId?: string): Promise<unknown> {
|
||||
const body: Rec = { choice };
|
||||
if (requestId) body.request_id = requestId;
|
||||
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/approval`, { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** Subscribe to the lifecycle stream of a run. */
|
||||
async streamRunEvents(runId: string, onEvent: (event: HermesStreamEvent) => void): Promise<StreamHandle> {
|
||||
return this.openSse(`/v1/runs/${encodeURIComponent(runId)}/events`, { method: 'GET' }, onEvent);
|
||||
}
|
||||
|
||||
private async openSse(
|
||||
path: string,
|
||||
init: { method: 'GET' | 'POST'; body?: unknown },
|
||||
onEvent: (event: HermesStreamEvent) => void
|
||||
): Promise<StreamHandle> {
|
||||
const parser = new SseParser((message) => {
|
||||
const data = tryParseJson<unknown>(message.data) ?? message.data;
|
||||
const eventName = message.event !== 'message' ? message.event : isRec(data) && typeof data.event === 'string' ? data.event : 'message';
|
||||
for (const event of normalizeLifecycleEvent(eventName, data)) onEvent(event);
|
||||
});
|
||||
const errorChunks: string[] = [];
|
||||
const handle = await httpStream(
|
||||
{
|
||||
url: `${this.base}${path}`,
|
||||
method: init.method,
|
||||
headers: this.headers({ Accept: 'text/event-stream' }, init.body !== undefined),
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
timeoutMs: 15 * 60_000
|
||||
},
|
||||
{ onChunk: (text) => (handleReady ? parser.feed(text) : errorChunks.push(text)) }
|
||||
);
|
||||
let handleReady = handle.start.ok;
|
||||
if (!handle.start.ok) {
|
||||
await handle.done.catch(() => undefined);
|
||||
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
|
||||
}
|
||||
handleReady = true;
|
||||
const done = handle.done.then(() => parser.end());
|
||||
return { ...handle, done };
|
||||
}
|
||||
|
||||
// ── Unified send ──────────────────────────────────────────────────────────
|
||||
|
||||
send(options: SendOptions, transport: ResolvedTransport): SendHandle {
|
||||
const effective: ResolvedTransport = options.images?.length && transport === 'runs' ? 'completions' : transport;
|
||||
let abort: () => void = () => undefined;
|
||||
const result =
|
||||
effective === 'runs'
|
||||
? this.sendViaRuns(options, (fn) => (abort = fn))
|
||||
: effective === 'sessions'
|
||||
? this.sendViaSessions(options, (fn) => (abort = fn))
|
||||
: this.sendViaCompletions(options, (fn) => (abort = fn));
|
||||
return { result, abort: () => abort(), transport: effective };
|
||||
}
|
||||
|
||||
private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
const run = await this.startRun({
|
||||
input: options.text,
|
||||
session_id: options.sessionId || undefined,
|
||||
instructions: this.config.instructions || undefined,
|
||||
model: this.config.model || undefined
|
||||
});
|
||||
const runId = run.run_id;
|
||||
onEvent({ kind: 'run.started', runId });
|
||||
Log.info('hermes', `run ${runId} started`);
|
||||
|
||||
let stopped = false;
|
||||
let finalText = '';
|
||||
let streamedText = '';
|
||||
let completed = false;
|
||||
let failure: string | null = null;
|
||||
|
||||
const handle = await this.streamRunEvents(runId, (event) => {
|
||||
if (event.kind === 'delta') streamedText += event.text;
|
||||
if (event.kind === 'completed' && event.status !== 'message') {
|
||||
completed = true;
|
||||
finalText = event.text ?? '';
|
||||
}
|
||||
if (event.kind === 'error') failure = event.message;
|
||||
onEvent(event);
|
||||
});
|
||||
setAbort(() => {
|
||||
stopped = true;
|
||||
handle.abort();
|
||||
this.stopRun(runId).catch(() => undefined);
|
||||
});
|
||||
|
||||
await handle.done;
|
||||
if (stopped) return streamedText;
|
||||
if (failure) throw new Error(failure);
|
||||
|
||||
if (!completed) {
|
||||
// The event buffer may have been consumed by a reconnect; fall back to polling the run status.
|
||||
for (let i = 0; i < 120 && !stopped; i++) {
|
||||
const info = await this.getRun(runId);
|
||||
if (['completed', 'failed', 'cancelled', 'canceled', 'stopped'].includes(info.status)) {
|
||||
if (info.status !== 'completed') throw new Error(String(info.error ?? `run ${info.status}`));
|
||||
finalText = info.output ?? '';
|
||||
onEvent({ kind: 'completed', text: finalText, status: info.status, usage: info.usage });
|
||||
if (info.session_id) onEvent({ kind: 'session', sessionId: info.session_id });
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
return finalText || streamedText;
|
||||
}
|
||||
|
||||
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
let sessionId = options.sessionId;
|
||||
if (!sessionId || !sessionId.startsWith('hs:')) {
|
||||
const session = await this.createSession('EveFlow');
|
||||
sessionId = `hs:${session.id}`;
|
||||
onEvent({ kind: 'session', sessionId });
|
||||
}
|
||||
const realId = sessionId.slice(3);
|
||||
const body: Rec = { input: options.text };
|
||||
if (this.config.instructions) body.instructions = this.config.instructions;
|
||||
if (this.config.model) body.model = this.config.model;
|
||||
|
||||
let streamed = '';
|
||||
let finalText = '';
|
||||
let failure: string | null = null;
|
||||
const handle = await this.openSse(`/api/sessions/${encodeURIComponent(realId)}/chat/stream`, { method: 'POST', body }, (event) => {
|
||||
if (event.kind === 'delta') streamed += event.text;
|
||||
if (event.kind === 'completed' && event.text) finalText = event.text;
|
||||
if (event.kind === 'error') failure = event.message;
|
||||
if (event.kind === 'session') return; // keep our prefixed id
|
||||
onEvent(event);
|
||||
});
|
||||
setAbort(() => handle.abort());
|
||||
await handle.done;
|
||||
if (failure) throw new Error(failure);
|
||||
return finalText || streamed;
|
||||
}
|
||||
|
||||
private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
|
||||
const { onEvent } = options;
|
||||
const useTools = this.config.localTools && !!options.localToolExecutor && !!options.localToolDefinitions?.length;
|
||||
const messages: HermesChatMessage[] = [];
|
||||
if (this.config.instructions) messages.push({ role: 'system', content: this.config.instructions });
|
||||
for (const h of options.history) messages.push({ role: h.role, content: h.content });
|
||||
const userContent: HermesChatMessage['content'] = options.images?.length
|
||||
? [{ type: 'text', text: options.text }, ...options.images.map((url) => ({ type: 'image_url', image_url: { url } }))]
|
||||
: options.text;
|
||||
messages.push({ role: 'user', content: userContent });
|
||||
|
||||
let aborted = false;
|
||||
let currentHandle: StreamHandle | null = null;
|
||||
setAbort(() => {
|
||||
aborted = true;
|
||||
currentHandle?.abort();
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
for (let iteration = 0; iteration < 6 && !aborted; iteration++) {
|
||||
const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true };
|
||||
if (useTools) {
|
||||
payload.tools = options.localToolDefinitions;
|
||||
payload.tool_choice = 'auto';
|
||||
}
|
||||
if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort };
|
||||
|
||||
const toolCalls = new Map<number, CompletionToolCallAccumulator>();
|
||||
let finishReason: string | null = null;
|
||||
let iterationText = '';
|
||||
const errorChunks: string[] = [];
|
||||
let ready = false;
|
||||
const parser = new SseParser((message) => {
|
||||
if (message.data === '[DONE]') return;
|
||||
const data = tryParseJson<unknown>(message.data);
|
||||
if (data === null) return;
|
||||
if (message.event !== 'message' && !(isRec(data) && Array.isArray(data.choices))) {
|
||||
for (const event of normalizeLifecycleEvent(message.event, data)) onEvent(event);
|
||||
return;
|
||||
}
|
||||
const { events, finishReason: reason } = normalizeCompletionChunk(data, toolCalls);
|
||||
for (const event of events) {
|
||||
if (event.kind === 'delta') iterationText += event.text;
|
||||
onEvent(event);
|
||||
}
|
||||
if (reason) finishReason = reason;
|
||||
});
|
||||
|
||||
const headers = this.headers({ Accept: 'text/event-stream' });
|
||||
if (options.sessionId) headers['X-Hermes-Session-Id'] = options.sessionId;
|
||||
const handle = await httpStream(
|
||||
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
|
||||
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
|
||||
);
|
||||
currentHandle = handle;
|
||||
if (!handle.start.ok) {
|
||||
await handle.done.catch(() => undefined);
|
||||
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
|
||||
}
|
||||
ready = true;
|
||||
const rotated = handle.start.headers['x-hermes-session-id'];
|
||||
if (rotated && rotated !== options.sessionId) onEvent({ kind: 'session', sessionId: rotated });
|
||||
|
||||
await handle.done;
|
||||
parser.end();
|
||||
fullText += iterationText;
|
||||
|
||||
if (finishReason === 'tool_calls' && useTools && toolCalls.size > 0 && !aborted) {
|
||||
const calls = [...toolCalls.values()];
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: iterationText || null,
|
||||
tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: c.arguments } }))
|
||||
});
|
||||
for (const call of calls) {
|
||||
onEvent({ kind: 'tool.start', id: call.id, name: call.name, args: call.arguments });
|
||||
const output = await options.localToolExecutor!(call.name, call.arguments);
|
||||
onEvent({ kind: 'tool.end', id: call.id, name: call.name, output, ok: !output.includes('"error"') });
|
||||
messages.push({ role: 'tool', content: output, tool_call_id: call.id });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
onEvent({ kind: 'completed', status: aborted ? 'cancelled' : 'completed', text: fullText });
|
||||
return fullText;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeJob(job: HermesJob): HermesJob {
|
||||
const raw = job as Rec;
|
||||
const id = String(raw.id ?? raw.job_id ?? raw.name ?? '');
|
||||
return { ...job, id };
|
||||
}
|
||||
|
||||
export function jobStatus(job: HermesJob): string {
|
||||
if (job.state) return String(job.state);
|
||||
if (job.status) return String(job.status);
|
||||
if (job.enabled === false) return 'paused';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
export function jobSchedule(job: HermesJob): string {
|
||||
const s = job.schedule;
|
||||
if (!s) return '—';
|
||||
if (typeof s === 'string') return s;
|
||||
return s.display || s.expr || s.kind || 'schedule';
|
||||
}
|
||||
|
||||
export function jobOutput(job: HermesJob): string {
|
||||
return String(job.last_output ?? job.last_error ?? job.output ?? job.result ?? '');
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Normalises the various Hermes streaming dialects (runs events, sessions stream,
|
||||
* chat-completions chunks) into one HermesStreamEvent vocabulary.
|
||||
* Hermes releases have renamed a few events (tool.started/tool.start, token.delta/message.delta),
|
||||
* so every reader here is deliberately tolerant.
|
||||
*/
|
||||
import type { HermesStreamEvent, HermesUsage } from './types';
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const s = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||
const first = (obj: Rec, keys: string[]): unknown => {
|
||||
for (const k of keys) if (obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function textOf(data: Rec): string {
|
||||
const direct = first(data, ['delta', 'text', 'content', 'output_text', 'token']);
|
||||
if (typeof direct === 'string') return direct;
|
||||
if (isRec(direct)) return s(first(direct, ['text', 'content', 'delta']));
|
||||
return '';
|
||||
}
|
||||
|
||||
function idOf(data: Rec, fallback = ''): string {
|
||||
return s(first(data, ['tool_call_id', 'call_id', 'id', 'request_id', 'delegation_id'])) || fallback;
|
||||
}
|
||||
|
||||
function toolName(data: Rec): string {
|
||||
const direct = first(data, ['tool_name', 'tool', 'name', 'function']);
|
||||
if (typeof direct === 'string') return direct;
|
||||
if (isRec(direct)) return s(first(direct, ['name', 'tool']));
|
||||
return 'outil';
|
||||
}
|
||||
|
||||
function argsOf(data: Rec): string | undefined {
|
||||
const value = first(data, ['arguments', 'args', 'input', 'params', 'preview']);
|
||||
if (value === undefined) return undefined;
|
||||
return typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function optionsOf(data: Rec): string[] | undefined {
|
||||
const value = first(data, ['options', 'choices']);
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((v) => (isRec(v) ? s(first(v, ['label', 'value', 'id'])) : s(v))).filter(Boolean);
|
||||
}
|
||||
|
||||
function usageOf(data: Rec): HermesUsage | undefined {
|
||||
return isRec(data.usage) ? (data.usage as HermesUsage) : undefined;
|
||||
}
|
||||
|
||||
/** Runs API + sessions stream share the same lifecycle vocabulary. */
|
||||
export function normalizeLifecycleEvent(event: string, payload: unknown): HermesStreamEvent[] {
|
||||
const name = event.toLowerCase();
|
||||
const data: Rec = isRec(payload) ? payload : { value: payload };
|
||||
const out: HermesStreamEvent[] = [];
|
||||
const sessionId = s(first(data, ['session_id', 'sessionId']));
|
||||
|
||||
switch (name) {
|
||||
case 'token.delta':
|
||||
case 'message.delta':
|
||||
case 'assistant.delta':
|
||||
case 'output_text.delta':
|
||||
case 'response.output_text.delta':
|
||||
case 'delta': {
|
||||
const text = textOf(data);
|
||||
if (text) out.push({ kind: 'delta', text });
|
||||
break;
|
||||
}
|
||||
case 'reasoning.delta':
|
||||
case 'reasoning.available':
|
||||
case 'thinking.delta': {
|
||||
const text = textOf(data);
|
||||
if (text) out.push({ kind: 'reasoning', text });
|
||||
break;
|
||||
}
|
||||
case 'tool.start':
|
||||
case 'tool.started':
|
||||
case 'tool_call.start':
|
||||
out.push({ kind: 'tool.start', id: idOf(data, `tool-${Date.now()}`), name: toolName(data), args: argsOf(data) });
|
||||
break;
|
||||
case 'tool.progress':
|
||||
case 'hermes.tool.progress':
|
||||
out.push({
|
||||
kind: 'tool.progress',
|
||||
id: idOf(data) || undefined,
|
||||
name: toolName(data),
|
||||
label: s(first(data, ['label', 'message', 'status'])) || undefined,
|
||||
detail: s(first(data, ['preview', 'detail', 'output'])) || undefined
|
||||
});
|
||||
break;
|
||||
case 'tool.complete':
|
||||
case 'tool.completed':
|
||||
case 'tool.end':
|
||||
case 'tool_call.complete': {
|
||||
const status = s(first(data, ['status', 'result_status'])).toLowerCase();
|
||||
const error = first(data, ['error']);
|
||||
out.push({
|
||||
kind: 'tool.end',
|
||||
id: idOf(data, ''),
|
||||
name: data.tool_name || data.tool || data.name ? toolName(data) : undefined,
|
||||
output: s(first(data, ['output', 'result', 'content'])) || undefined,
|
||||
ok: !error && status !== 'error' && status !== 'failed'
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'subagent.start':
|
||||
out.push({
|
||||
kind: 'subagent.start',
|
||||
id: s(first(data, ['delegation_id', 'id'])) || `sub-${Date.now()}`,
|
||||
childSessionId: s(data.child_session_id) || undefined,
|
||||
label: s(first(data, ['label', 'task', 'goal'])) || undefined
|
||||
});
|
||||
break;
|
||||
case 'subagent.complete':
|
||||
case 'subagent.end':
|
||||
out.push({
|
||||
kind: 'subagent.end',
|
||||
id: s(first(data, ['delegation_id', 'id'])) || 'sub',
|
||||
status: s(data.status) || 'completed',
|
||||
summary: s(data.summary) || undefined,
|
||||
durationMs: typeof data.duration_ms === 'number' ? data.duration_ms : undefined
|
||||
});
|
||||
break;
|
||||
case 'approval.request':
|
||||
case 'approval.requested':
|
||||
out.push({
|
||||
kind: 'approval.request',
|
||||
requestId: s(first(data, ['request_id', 'approval_id', 'id'])),
|
||||
tool: s(first(data, ['tool_name', 'tool', 'command'])) || undefined,
|
||||
description: s(first(data, ['description', 'message', 'prompt', 'reason', 'command', 'preview'])) || 'Hermes demande une autorisation.',
|
||||
args: argsOf(data),
|
||||
options: optionsOf(data)
|
||||
});
|
||||
break;
|
||||
case 'clarify.request':
|
||||
case 'clarification.request':
|
||||
out.push({
|
||||
kind: 'clarify.request',
|
||||
requestId: s(first(data, ['request_id', 'id'])),
|
||||
question: s(first(data, ['question', 'prompt', 'message'])) || 'Hermes a besoin d’une précision.',
|
||||
options: optionsOf(data)
|
||||
});
|
||||
break;
|
||||
case 'sudo.request':
|
||||
case 'secret.request':
|
||||
out.push({
|
||||
kind: 'input.request',
|
||||
requestId: s(first(data, ['request_id', 'id'])),
|
||||
prompt: s(first(data, ['prompt', 'message', 'description'])) || (name === 'sudo.request' ? 'Mot de passe sudo requis' : 'Secret requis'),
|
||||
secret: true,
|
||||
variant: name === 'sudo.request' ? 'sudo' : 'secret'
|
||||
});
|
||||
break;
|
||||
case 'approval.expire':
|
||||
case 'clarify.expire':
|
||||
case 'sudo.expire':
|
||||
case 'secret.expire':
|
||||
case 'request.expire':
|
||||
out.push({ kind: 'request.expire', requestId: s(first(data, ['request_id', 'id'])) });
|
||||
break;
|
||||
case 'run.started':
|
||||
case 'run.created':
|
||||
out.push({ kind: 'run.started', runId: s(first(data, ['run_id', 'id'])) });
|
||||
break;
|
||||
case 'message.complete':
|
||||
case 'message.completed':
|
||||
case 'assistant.complete': {
|
||||
// Final text; a run.completed usually follows, so only surface the text here.
|
||||
const text = textOf(data);
|
||||
if (text) out.push({ kind: 'completed', text, status: 'message', usage: usageOf(data) });
|
||||
break;
|
||||
}
|
||||
case 'run.completed':
|
||||
case 'run.complete':
|
||||
case 'run.finished':
|
||||
case 'done':
|
||||
case 'complete':
|
||||
out.push({
|
||||
kind: 'completed',
|
||||
text: s(first(data, ['output', 'text', 'content', 'final_output'])) || undefined,
|
||||
status: s(data.status) || 'completed',
|
||||
usage: usageOf(data)
|
||||
});
|
||||
break;
|
||||
case 'run.failed':
|
||||
case 'run.cancelled':
|
||||
case 'run.canceled':
|
||||
case 'error':
|
||||
out.push({ kind: 'error', message: s(first(data, ['error', 'message', 'detail', 'reason'])) || name });
|
||||
break;
|
||||
case 'gateway.ready':
|
||||
case 'ping':
|
||||
case 'heartbeat':
|
||||
break;
|
||||
default:
|
||||
out.push({ kind: 'raw', event, data: payload });
|
||||
}
|
||||
if (sessionId) out.push({ kind: 'session', sessionId });
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CompletionToolCallAccumulator {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
/** Chat-completions chunk (OpenAI format). Returns delta events plus any accumulated tool calls. */
|
||||
export function normalizeCompletionChunk(
|
||||
chunk: unknown,
|
||||
toolCalls: Map<number, CompletionToolCallAccumulator>
|
||||
): { events: HermesStreamEvent[]; finishReason: string | null } {
|
||||
const events: HermesStreamEvent[] = [];
|
||||
if (!isRec(chunk)) return { events, finishReason: null };
|
||||
const choice = Array.isArray(chunk.choices) && isRec(chunk.choices[0]) ? (chunk.choices[0] as Rec) : null;
|
||||
if (!choice) return { events, finishReason: null };
|
||||
const delta = isRec(choice.delta) ? (choice.delta as Rec) : isRec(choice.message) ? (choice.message as Rec) : null;
|
||||
if (delta) {
|
||||
if (typeof delta.content === 'string' && delta.content) events.push({ kind: 'delta', text: delta.content });
|
||||
const reasoning = first(delta, ['reasoning_content', 'reasoning']);
|
||||
if (typeof reasoning === 'string' && reasoning) events.push({ kind: 'reasoning', text: reasoning });
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (!isRec(tc)) continue;
|
||||
const index = typeof tc.index === 'number' ? tc.index : toolCalls.size;
|
||||
const fn = isRec(tc.function) ? (tc.function as Rec) : {};
|
||||
const acc = toolCalls.get(index) ?? { id: s(tc.id), name: '', arguments: '' };
|
||||
if (tc.id) acc.id = s(tc.id);
|
||||
if (typeof fn.name === 'string') acc.name += fn.name;
|
||||
if (typeof fn.arguments === 'string') acc.arguments += fn.arguments;
|
||||
toolCalls.set(index, acc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { events, finishReason: typeof choice.finish_reason === 'string' ? choice.finish_reason : null };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Client-side tools offered to Hermes in chat-completions mode (OpenAI tool calling).
|
||||
* With the runs/sessions transports Hermes uses its own server-side toolsets instead.
|
||||
*/
|
||||
import { bridge } from '../../lib/bridge';
|
||||
|
||||
export interface LocalToolContext {
|
||||
setEmotion: (emotion: string) => void;
|
||||
speak: (text: string) => void;
|
||||
getStatus: () => Record<string, unknown>;
|
||||
getHistory: (n: number) => Array<{ role: string; content: string }>;
|
||||
notify: (title: string, body: string) => void;
|
||||
}
|
||||
|
||||
export const LOCAL_TOOL_DEFINITIONS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_hud_state',
|
||||
description: "Change l'état visuel du HUD EveFlow (couleur/animation du noyau) pour accompagner la réponse.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['neutral', 'happy', 'thinking', 'alert', 'error', 'success'] }
|
||||
},
|
||||
required: ['state']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_app_status',
|
||||
description: "Retourne l'état courant d'EveFlow (transport, voix, nombre de messages, hôte).",
|
||||
parameters: { type: 'object', properties: {} }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_conversation_history',
|
||||
description: 'Retourne les N derniers messages affichés dans EveFlow.',
|
||||
parameters: { type: 'object', properties: { n: { type: 'number', minimum: 1, maximum: 30 } }, required: ['n'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'speak_text',
|
||||
description: 'Fait prononcer immédiatement un texte court par la voix du HUD.',
|
||||
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'notify_user',
|
||||
description: 'Affiche une notification système Windows à l’utilisateur.',
|
||||
parameters: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'write_shared_file',
|
||||
description: "Écrit un fichier texte (rapport, script, JSON, HTML, SVG) dans le dossier partagé EveFlow_Shared de l'utilisateur et renvoie son URL.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { filename: { type: 'string' }, content: { type: 'string' } },
|
||||
required: ['filename', 'content']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export async function executeLocalTool(name: string, rawArgs: string, ctx: LocalToolContext): Promise<string> {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = rawArgs ? (JSON.parse(rawArgs) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return JSON.stringify({ error: 'invalid JSON arguments' });
|
||||
}
|
||||
try {
|
||||
switch (name) {
|
||||
case 'set_hud_state':
|
||||
ctx.setEmotion(String(args.state ?? 'neutral'));
|
||||
return JSON.stringify({ ok: true });
|
||||
case 'get_app_status':
|
||||
return JSON.stringify(ctx.getStatus());
|
||||
case 'get_conversation_history':
|
||||
return JSON.stringify({ messages: ctx.getHistory(Math.min(30, Number(args.n) || 5)) });
|
||||
case 'speak_text':
|
||||
ctx.speak(String(args.text ?? '').slice(0, 600));
|
||||
return JSON.stringify({ ok: true });
|
||||
case 'notify_user':
|
||||
ctx.notify(String(args.title ?? 'EveFlow'), String(args.body ?? ''));
|
||||
return JSON.stringify({ ok: true });
|
||||
case 'write_shared_file': {
|
||||
const api = bridge();
|
||||
if (!api) return JSON.stringify({ error: 'file system unavailable outside Electron' });
|
||||
const result = await api.files.writeShared(String(args.filename ?? 'fichier.txt'), String(args.content ?? ''), false);
|
||||
return JSON.stringify({ ok: true, ...result });
|
||||
}
|
||||
default:
|
||||
return JSON.stringify({ error: `unknown tool ${name}` });
|
||||
}
|
||||
} catch (err) {
|
||||
return JSON.stringify({ error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
export type HermesTransport = 'auto' | 'runs' | 'sessions' | 'completions';
|
||||
|
||||
export interface HermesConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** Stable per-user key for long-term memory (X-Hermes-Session-Key). */
|
||||
sessionKey: string;
|
||||
transport: HermesTransport;
|
||||
reasoningEffort: '' | 'low' | 'medium' | 'high';
|
||||
/** Extra instructions layered on top of the Hermes system prompt. */
|
||||
instructions: string;
|
||||
/** Expose EveFlow client tools in chat-completions mode. */
|
||||
localTools: boolean;
|
||||
}
|
||||
|
||||
export interface HermesCapabilities {
|
||||
object?: string;
|
||||
platform?: string;
|
||||
model?: string;
|
||||
version?: string;
|
||||
auth?: { type?: string; required?: boolean };
|
||||
features?: Record<string, boolean | string>;
|
||||
endpoints?: Record<string, boolean>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesHealth {
|
||||
status: string;
|
||||
readiness?: { checks?: Record<string, unknown> };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesModel {
|
||||
id: string;
|
||||
owned_by?: string;
|
||||
provider?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesSkill {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesToolset {
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
tools?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesSession {
|
||||
id: string;
|
||||
title?: string;
|
||||
created_at?: string | number;
|
||||
updated_at?: string | number;
|
||||
end_reason?: string | null;
|
||||
message_count?: number;
|
||||
platform?: string;
|
||||
source?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesSessionMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp?: string | number;
|
||||
tool_calls?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesJobSchedule {
|
||||
kind?: string;
|
||||
expr?: string;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
export interface HermesJob {
|
||||
id: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
schedule?: string | HermesJobSchedule;
|
||||
state?: string;
|
||||
status?: string;
|
||||
enabled?: boolean;
|
||||
deliver?: string;
|
||||
next_run_at?: string | number;
|
||||
last_run_at?: string | number;
|
||||
last_status?: string;
|
||||
last_output?: string;
|
||||
last_error?: string;
|
||||
repeat?: number | { times?: number | null; completed?: number };
|
||||
skills?: string[];
|
||||
skill?: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
created_at?: string | number;
|
||||
updated_at?: string | number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesJobDraft {
|
||||
name: string;
|
||||
schedule: string;
|
||||
prompt: string;
|
||||
deliver?: string;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
export interface HermesRunInfo {
|
||||
run_id: string;
|
||||
status: string;
|
||||
session_id?: string;
|
||||
model?: string;
|
||||
output?: string;
|
||||
usage?: Record<string, number>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HermesUsage {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type HermesStreamEvent =
|
||||
| { kind: 'delta'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'tool.start'; id: string; name: string; args?: string }
|
||||
| { kind: 'tool.progress'; id?: string; name: string; label?: string; detail?: string }
|
||||
| { kind: 'tool.end'; id: string; name?: string; output?: string; ok: boolean }
|
||||
| { kind: 'subagent.start'; id: string; childSessionId?: string; label?: string }
|
||||
| { kind: 'subagent.end'; id: string; status: string; summary?: string; durationMs?: number }
|
||||
| { kind: 'approval.request'; requestId: string; tool?: string; description: string; args?: string; options?: string[] }
|
||||
| { kind: 'clarify.request'; requestId: string; question: string; options?: string[] }
|
||||
| { kind: 'input.request'; requestId: string; prompt: string; secret: boolean; variant: 'sudo' | 'secret' }
|
||||
| { kind: 'request.expire'; requestId: string }
|
||||
| { kind: 'session'; sessionId: string }
|
||||
| { kind: 'run.started'; runId: string }
|
||||
| { kind: 'completed'; text?: string; status: string; usage?: HermesUsage }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'raw'; event: string; data: unknown };
|
||||
|
||||
export interface HermesChatMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string | null | Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: unknown[];
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
text: string;
|
||||
images?: string[];
|
||||
/** Recent turns used only by the stateless chat-completions transport. */
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
sessionId: string;
|
||||
onEvent: (event: HermesStreamEvent) => void;
|
||||
localToolExecutor?: (name: string, args: string) => Promise<string>;
|
||||
localToolDefinitions?: unknown[];
|
||||
}
|
||||
|
||||
export interface SendHandle {
|
||||
/** Resolves with the final assistant text. */
|
||||
result: Promise<string>;
|
||||
abort: () => void;
|
||||
transport: Exclude<HermesTransport, 'auto'>;
|
||||
}
|
||||
Reference in new issue
Block a user