When the Hermes Gateway returns X-Hermes-Session-Id, it is now saved to settings.chatSessionId via the ChatPersistence interface. On restart, the factory restores the session ID into the ApiClient so subsequent requests continue the same conversation. Changes: - Added chatSessionId to HermesChatSettings + DEFAULT_SETTINGS - Extended ChatPersistence with loadSessionId/saveSessionId - Added ApiClient.setSessionId() for restoring saved IDs - ChatView.handleSend() persists sessionId when received - main.ts factory restores session ID and updates status bar Fixes the 'Marco!/Bolo!' test — conversation continuity now survives Obsidian restarts.
383 lines
10 KiB
TypeScript
383 lines
10 KiB
TypeScript
import type { GatewayConfig, ChatCompletionResponse } from "./types";
|
|
|
|
/**
|
|
* HTTP client for the Hermes Gateway's OpenAI-compatible REST API.
|
|
* Handles Bearer auth, SSE streaming, session header continuity,
|
|
* and model/capabilities discovery.
|
|
*
|
|
* Uses the standard fetch() API available in Obsidian's Electron runtime.
|
|
*/
|
|
export class ApiClient {
|
|
private baseUrl: string;
|
|
private apiKey: string;
|
|
private model: string;
|
|
private currentSessionId: string | null;
|
|
|
|
constructor(config: GatewayConfig) {
|
|
this.baseUrl = config.baseUrl.replace(/\/+$/, ""); // strip trailing slashes
|
|
this.apiKey = config.apiKey;
|
|
this.model = config.model;
|
|
this.currentSessionId = null;
|
|
}
|
|
|
|
/** Update configuration without recreating the client. Preserves session. */
|
|
updateConfig(config: Partial<GatewayConfig>): void {
|
|
if (config.baseUrl !== undefined) {
|
|
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
}
|
|
if (config.apiKey !== undefined) {
|
|
this.apiKey = config.apiKey;
|
|
}
|
|
if (config.model !== undefined) {
|
|
this.model = config.model;
|
|
}
|
|
// Do NOT reset currentSessionId — preserve conversation continuity
|
|
}
|
|
|
|
/** Get the current session ID, if one has been established. */
|
|
getSessionId(): string | null {
|
|
return this.currentSessionId;
|
|
}
|
|
|
|
/** Restore a previously-saved session ID (from plugin data). */
|
|
setSessionId(id: string): void {
|
|
this.currentSessionId = id || null;
|
|
}
|
|
|
|
/** The root URL without the /v1 suffix, e.g. http://192.168.1.12:8642 */
|
|
private get rootUrl(): string {
|
|
// baseUrl ends with /v1; strip it to reach the server root
|
|
if (this.baseUrl.endsWith("/v1")) {
|
|
return this.baseUrl.slice(0, -3);
|
|
}
|
|
return new URL("/", this.baseUrl).href.replace(/\/+$/, "");
|
|
}
|
|
|
|
/**
|
|
* Fetch with a configurable timeout. Aborts the request if it takes
|
|
* longer than `timeoutMs` milliseconds.
|
|
*/
|
|
private async fetchWithTimeout(
|
|
url: string,
|
|
options: RequestInit,
|
|
timeoutMs: number = 60000
|
|
): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
});
|
|
return response;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a useful error message from a non-OK response.
|
|
* Tries JSON first, falls back to plain text (truncated), and finally
|
|
* to the HTTP status line.
|
|
*/
|
|
private async extractErrorBody(response: Response): Promise<string> {
|
|
let detail = "";
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
|
|
// Only attempt JSON if the server says it sent JSON
|
|
if (contentType.includes("application/json")) {
|
|
try {
|
|
const errBody = await response.json();
|
|
if (errBody.error?.message) {
|
|
detail = errBody.error.message;
|
|
} else if (typeof errBody.message === "string") {
|
|
detail = errBody.message;
|
|
} else if (typeof errBody.error === "string") {
|
|
detail = errBody.error;
|
|
}
|
|
if (detail) return detail;
|
|
} catch {
|
|
// JSON parsing failed — fall through to text
|
|
}
|
|
}
|
|
|
|
// Try reading as plain text (e.g. HTML error page, gateway nginx page)
|
|
try {
|
|
const text = await response.text();
|
|
if (text.trim()) {
|
|
// Truncate to avoid dumping huge HTML pages into error messages
|
|
detail = text.trim().substring(0, 300);
|
|
return detail;
|
|
}
|
|
} catch {
|
|
// Body isn't readable at all
|
|
}
|
|
|
|
// Ultimate fallback
|
|
return response.statusText || `HTTP ${response.status}`;
|
|
}
|
|
|
|
/**
|
|
* Send a chat completion request (non-streaming).
|
|
* Returns the assistant's response with session and usage metadata.
|
|
*/
|
|
async chat(
|
|
messages: { role: string; content: string }[],
|
|
stream: boolean = false
|
|
): Promise<ChatCompletionResponse> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${this.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
if (this.currentSessionId) {
|
|
headers["X-Hermes-Session-Id"] = this.currentSessionId;
|
|
}
|
|
|
|
const response = await this.fetchWithTimeout(
|
|
`${this.baseUrl}/chat/completions`,
|
|
{
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: this.model,
|
|
messages,
|
|
stream,
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const detail = await this.extractErrorBody(response);
|
|
throw new Error(`API error ${response.status}: ${detail}`);
|
|
}
|
|
|
|
// Capture session ID from response headers for continuity
|
|
const sessionId = response.headers.get("X-Hermes-Session-Id");
|
|
if (sessionId) {
|
|
this.currentSessionId = sessionId;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
throw new Error(data.error.message || "Unknown API error");
|
|
}
|
|
|
|
const choice = data.choices?.[0];
|
|
const content: string = choice?.message?.content ?? "";
|
|
|
|
return {
|
|
content,
|
|
sessionId: this.currentSessionId ?? "",
|
|
model: data.model ?? this.model,
|
|
usage: data.usage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send a streaming chat completion request.
|
|
* Calls `onDelta` with each text chunk as it arrives via SSE.
|
|
* Returns the final ChatCompletionResponse after the stream concludes.
|
|
*/
|
|
async streamChat(
|
|
messages: { role: string; content: string }[],
|
|
onDelta: (text: string) => void
|
|
): Promise<ChatCompletionResponse> {
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Bearer ${this.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
if (this.currentSessionId) {
|
|
headers["X-Hermes-Session-Id"] = this.currentSessionId;
|
|
}
|
|
|
|
const response = await this.fetchWithTimeout(
|
|
`${this.baseUrl}/chat/completions`,
|
|
{
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: this.model,
|
|
messages,
|
|
stream: true,
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const detail = await this.extractErrorBody(response);
|
|
throw new Error(`API error ${response.status}: ${detail}`);
|
|
}
|
|
|
|
// Capture session ID from response headers
|
|
const sessionId = response.headers.get("X-Hermes-Session-Id");
|
|
if (sessionId) {
|
|
this.currentSessionId = sessionId;
|
|
}
|
|
|
|
// Read the SSE stream
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
throw new Error("Response body is not readable");
|
|
}
|
|
|
|
const decoder = new TextDecoder("utf-8");
|
|
let buffer = "";
|
|
let fullContent = "";
|
|
let model = this.model;
|
|
let usage: ChatCompletionResponse["usage"];
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// SSE events are separated by double newlines
|
|
const parts = buffer.split("\n\n");
|
|
// The last part may be incomplete; keep it in the buffer
|
|
buffer = parts.pop() ?? "";
|
|
|
|
for (const part of parts) {
|
|
const lines = part.split("\n");
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
const jsonStr = line.slice(6).trim();
|
|
|
|
if (jsonStr === "[DONE]") {
|
|
// Stream finished signal — continue processing any remaining data
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const event = JSON.parse(jsonStr);
|
|
|
|
if (event.error) {
|
|
throw new Error(event.error.message || "Stream error");
|
|
}
|
|
|
|
const delta = event.choices?.[0]?.delta?.content;
|
|
if (delta) {
|
|
fullContent += delta;
|
|
onDelta(delta);
|
|
}
|
|
|
|
// Capture model and usage from the final event
|
|
if (event.model) {
|
|
model = event.model;
|
|
}
|
|
if (event.usage) {
|
|
usage = event.usage;
|
|
}
|
|
} catch (e) {
|
|
// If it's our own thrown error, rethrow
|
|
if (
|
|
e instanceof Error &&
|
|
e.message !== "Unexpected token" &&
|
|
!e.message.includes("JSON")
|
|
) {
|
|
throw e;
|
|
}
|
|
// Otherwise skip malformed SSE lines
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush any remaining data in the buffer
|
|
if (buffer.trim()) {
|
|
const lines = buffer.split("\n");
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
const jsonStr = line.slice(6).trim();
|
|
if (jsonStr === "[DONE]") continue;
|
|
try {
|
|
const event = JSON.parse(jsonStr);
|
|
const delta = event.choices?.[0]?.delta?.content;
|
|
if (delta) {
|
|
fullContent += delta;
|
|
onDelta(delta);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: fullContent,
|
|
sessionId: this.currentSessionId ?? "",
|
|
model,
|
|
usage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List available models from the gateway.
|
|
* GET /v1/models
|
|
*/
|
|
async getModels(): Promise<{ id: string }[]> {
|
|
const response = await this.fetchWithTimeout(
|
|
`${this.baseUrl}/models`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${this.apiKey}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const detail = await this.extractErrorBody(response);
|
|
throw new Error(`Failed to fetch models: ${response.status} — ${detail}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.data ?? [];
|
|
}
|
|
|
|
/**
|
|
* Fetch gateway capabilities.
|
|
* GET /v1/capabilities
|
|
*/
|
|
async getCapabilities(): Promise<any> {
|
|
const response = await this.fetchWithTimeout(
|
|
`${this.baseUrl}/capabilities`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${this.apiKey}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const detail = await this.extractErrorBody(response);
|
|
throw new Error(
|
|
`Failed to fetch capabilities: ${response.status} — ${detail}`
|
|
);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Health check against the gateway root.
|
|
* GET /health → returns true if status === "ok"
|
|
* Uses a shorter 10-second timeout since this is a liveness probe.
|
|
*/
|
|
async healthCheck(): Promise<boolean> {
|
|
try {
|
|
const response = await this.fetchWithTimeout(
|
|
`${this.rootUrl}/health`,
|
|
{},
|
|
10000 // 10-second timeout for health checks
|
|
);
|
|
if (!response.ok) return false;
|
|
const data = await response.json();
|
|
return data.status === "ok";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|