@openhex-ai/agent-sdk 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1529 @@
1
+ // src/react/ChatWidget.tsx
2
+ import { useCallback as useCallback3, useState as useState3 } from "react";
3
+
4
+ // src/react/ChatBox.tsx
5
+ import {
6
+ useCallback as useCallback2,
7
+ useEffect as useEffect3,
8
+ useLayoutEffect,
9
+ useMemo as useMemo2,
10
+ useRef as useRef2,
11
+ useState as useState2
12
+ } from "react";
13
+
14
+ // src/react/useOpenhexChat.ts
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+
17
+ // src/chat/events.ts
18
+ function isAgentRecord(record) {
19
+ return record.sender === "assistant" || record.sender === "agent";
20
+ }
21
+ function isTurnComplete(record) {
22
+ return isAgentRecord(record) && record.raw?.type === "result";
23
+ }
24
+ function contentBlocks(record) {
25
+ const msg = record.raw.message;
26
+ if (msg && typeof msg === "object" && Array.isArray(msg.content)) {
27
+ return msg.content;
28
+ }
29
+ return [];
30
+ }
31
+ function extractText(record) {
32
+ if (record.raw?.type === "user" && typeof record.raw.message === "string") {
33
+ return record.raw.message;
34
+ }
35
+ return contentBlocks(record).filter((b) => b.type === "text").map((b) => b.text).join("");
36
+ }
37
+ function extractToolCalls(record) {
38
+ return contentBlocks(record).filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, name: b.name, input: b.input }));
39
+ }
40
+
41
+ // src/http/sse.ts
42
+ function fieldValue(raw) {
43
+ return raw.startsWith(" ") ? raw.slice(1) : raw;
44
+ }
45
+ async function* parseSSEStream(stream, signal) {
46
+ const reader = stream.getReader();
47
+ const decoder = new TextDecoder();
48
+ let buffer = "";
49
+ let dataLines = [];
50
+ let eventType;
51
+ let lastId;
52
+ const onAbort = () => {
53
+ reader.cancel().catch(() => {
54
+ });
55
+ };
56
+ if (signal) {
57
+ if (signal.aborted) onAbort();
58
+ else signal.addEventListener("abort", onAbort, { once: true });
59
+ }
60
+ try {
61
+ while (true) {
62
+ const { done, value } = await reader.read();
63
+ if (done) break;
64
+ buffer += decoder.decode(value, { stream: true });
65
+ let nlIndex;
66
+ while ((nlIndex = buffer.indexOf("\n")) !== -1) {
67
+ let line = buffer.slice(0, nlIndex);
68
+ buffer = buffer.slice(nlIndex + 1);
69
+ if (line.endsWith("\r")) line = line.slice(0, -1);
70
+ if (line === "") {
71
+ if (dataLines.length > 0) {
72
+ yield { id: lastId, event: eventType, data: dataLines.join("\n") };
73
+ }
74
+ dataLines = [];
75
+ eventType = void 0;
76
+ continue;
77
+ }
78
+ if (line.startsWith(":")) continue;
79
+ const colon = line.indexOf(":");
80
+ const field = colon === -1 ? line : line.slice(0, colon);
81
+ const rawVal = colon === -1 ? "" : line.slice(colon + 1);
82
+ const val = fieldValue(rawVal);
83
+ switch (field) {
84
+ case "data":
85
+ dataLines.push(val);
86
+ break;
87
+ case "id":
88
+ lastId = val;
89
+ break;
90
+ case "event":
91
+ eventType = val;
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ if (dataLines.length > 0) {
97
+ yield { id: lastId, event: eventType, data: dataLines.join("\n") };
98
+ }
99
+ } finally {
100
+ signal?.removeEventListener("abort", onAbort);
101
+ reader.releaseLock();
102
+ }
103
+ }
104
+
105
+ // src/http/backoff.ts
106
+ var Backoff = class {
107
+ attempt = 0;
108
+ initialMs;
109
+ maxMs;
110
+ factor;
111
+ constructor(opts = {}) {
112
+ this.initialMs = opts.initialMs ?? 1e3;
113
+ this.maxMs = opts.maxMs ?? 15e3;
114
+ this.factor = opts.factor ?? 2;
115
+ }
116
+ /** Next delay in ms (advances the attempt counter). */
117
+ next() {
118
+ const delay2 = Math.min(this.maxMs, this.initialMs * Math.pow(this.factor, this.attempt));
119
+ this.attempt += 1;
120
+ return delay2;
121
+ }
122
+ /** Reset after a successful connection. */
123
+ reset() {
124
+ this.attempt = 0;
125
+ }
126
+ };
127
+ function delay(ms, signal) {
128
+ return new Promise((resolve, reject) => {
129
+ if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
130
+ const timer = setTimeout(() => {
131
+ signal?.removeEventListener("abort", onAbort);
132
+ resolve();
133
+ }, ms);
134
+ const onAbort = () => {
135
+ clearTimeout(timer);
136
+ reject(new DOMException("Aborted", "AbortError"));
137
+ };
138
+ signal?.addEventListener("abort", onAbort, { once: true });
139
+ });
140
+ }
141
+
142
+ // src/errors.ts
143
+ var OpenhexSdkError = class extends Error {
144
+ constructor(message) {
145
+ super(message);
146
+ this.name = "OpenhexSdkError";
147
+ }
148
+ };
149
+ var AuthenticationError = class extends OpenhexSdkError {
150
+ constructor(message = "No Openhex API key provided. Set OPENHEX_API_KEY or pass { apiKey }.") {
151
+ super(message);
152
+ this.name = "AuthenticationError";
153
+ }
154
+ };
155
+ var ApiError = class extends OpenhexSdkError {
156
+ constructor(message, status, body) {
157
+ super(message);
158
+ this.status = status;
159
+ this.body = body;
160
+ this.name = "ApiError";
161
+ }
162
+ };
163
+ var AbortError = class extends OpenhexSdkError {
164
+ constructor(message = "The agent run was aborted.") {
165
+ super(message);
166
+ this.name = "AbortError";
167
+ }
168
+ };
169
+
170
+ // src/chat/chatClient.ts
171
+ function anySignal(signals) {
172
+ const controller = new AbortController();
173
+ const handlers = [];
174
+ for (const s of signals) {
175
+ if (!s) continue;
176
+ if (s.aborted) {
177
+ controller.abort(s.reason);
178
+ break;
179
+ }
180
+ const handler = () => controller.abort(s.reason);
181
+ s.addEventListener("abort", handler, { once: true });
182
+ handlers.push([s, handler]);
183
+ }
184
+ return {
185
+ signal: controller.signal,
186
+ cleanup: () => handlers.forEach(([s, h]) => s.removeEventListener("abort", h))
187
+ };
188
+ }
189
+ function parseRecord(evt) {
190
+ if (evt.data === "[DONE]") return null;
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(evt.data);
194
+ } catch {
195
+ return null;
196
+ }
197
+ if (parsed._meta === true) return null;
198
+ const record = parsed;
199
+ if (evt.id) record.id = evt.id;
200
+ return record;
201
+ }
202
+ var AgentChatClient = class {
203
+ constructor(http) {
204
+ this.http = http;
205
+ }
206
+ /**
207
+ * Create or continue a conversation and route a message to its agent(s).
208
+ * Returns immediately (non-blocking); consume {@link stream} for the reply.
209
+ */
210
+ async send(req, opts = {}) {
211
+ return this.http.requestJson("/conversations/send", {
212
+ method: "POST",
213
+ body: req,
214
+ signal: opts.signal
215
+ });
216
+ }
217
+ /** Interrupt the conversation's currently-running turn. */
218
+ async interrupt(conversationId, opts = {}) {
219
+ return this.http.requestJson(`/conversations/${conversationId}/interrupt`, {
220
+ method: "POST",
221
+ signal: opts.signal
222
+ });
223
+ }
224
+ /** Full conversation history (all messages as JSON). */
225
+ async messages(conversationId, opts = {}) {
226
+ return this.http.requestJson(`/conversations/${conversationId}/messages`, { signal: opts.signal });
227
+ }
228
+ /** A page of older history, before a cursor. */
229
+ async history(conversationId, params, opts = {}) {
230
+ const q = new URLSearchParams({ before: params.before });
231
+ if (params.turns != null) q.set("turns", String(params.turns));
232
+ if (params.maxEntries != null) q.set("maxEntries", String(params.maxEntries));
233
+ return this.http.requestJson(`/conversations/${conversationId}/history?${q.toString()}`, {
234
+ signal: opts.signal
235
+ });
236
+ }
237
+ /**
238
+ * Open the conversation's SSE record stream. Yields every record
239
+ * (replayed history then live tail), auto-reconnecting with capped
240
+ * backoff on drop. The generator ends when the consumer breaks/returns
241
+ * or `signal` aborts.
242
+ */
243
+ async *stream(conversationId, opts = {}) {
244
+ const { signal, reconnect = true } = opts;
245
+ let cursor = opts.lastEventId;
246
+ const backoff = new Backoff();
247
+ const internal = new AbortController();
248
+ const onAbort = () => internal.abort();
249
+ if (signal) {
250
+ if (signal.aborted) internal.abort();
251
+ else signal.addEventListener("abort", onAbort, { once: true });
252
+ }
253
+ const buildPath = () => {
254
+ const q = new URLSearchParams();
255
+ if (cursor) q.set("lastEventId", cursor);
256
+ if (opts.turns != null) q.set("turns", String(opts.turns));
257
+ if (opts.maxEntries != null) q.set("maxEntries", String(opts.maxEntries));
258
+ const qs = q.toString();
259
+ return `/conversations/${conversationId}/stream${qs ? `?${qs}` : ""}`;
260
+ };
261
+ try {
262
+ while (!internal.signal.aborted) {
263
+ let body = null;
264
+ try {
265
+ const response = await this.http.requestRaw(
266
+ buildPath(),
267
+ { signal: internal.signal, timeoutMs: 0 },
268
+ "text/event-stream"
269
+ );
270
+ body = response.body;
271
+ backoff.reset();
272
+ } catch (err) {
273
+ if (internal.signal.aborted) return;
274
+ if (!reconnect) throw err;
275
+ await delay(backoff.next(), internal.signal);
276
+ continue;
277
+ }
278
+ if (body) {
279
+ try {
280
+ for await (const evt of parseSSEStream(body, internal.signal)) {
281
+ if (evt.data === "[DONE]") return;
282
+ const record = parseRecord(evt);
283
+ if (!record) continue;
284
+ cursor = record.id ?? evt.id ?? cursor;
285
+ yield record;
286
+ }
287
+ } catch (err) {
288
+ if (internal.signal.aborted) return;
289
+ if (!reconnect) throw err;
290
+ }
291
+ }
292
+ if (!reconnect || internal.signal.aborted) return;
293
+ await delay(backoff.next(), internal.signal);
294
+ }
295
+ } finally {
296
+ signal?.removeEventListener("abort", onAbort);
297
+ internal.abort();
298
+ }
299
+ }
300
+ /**
301
+ * Stream a single turn over an existing conversation: resume the event
302
+ * stream strictly after `opts.lastEventId` (the user message's event id)
303
+ * and stop at the agent's terminal `result`. Reconnects on drop; an
304
+ * `idleTimeoutMs` gap with no events aborts (covers cold-start pod
305
+ * provisioning). Use when you sent the message yourself via {@link send}.
306
+ */
307
+ async *resumeTurn(conversationId, opts = {}) {
308
+ const { signal, idleTimeoutMs = 18e4 } = opts;
309
+ const cursor = opts.lastEventId;
310
+ const idleController = new AbortController();
311
+ const merged = anySignal([signal, idleController.signal]);
312
+ let idleTimer;
313
+ const armIdle = () => {
314
+ if (idleTimer) clearTimeout(idleTimer);
315
+ idleTimer = setTimeout(() => idleController.abort(new Error("idle timeout")), idleTimeoutMs);
316
+ };
317
+ armIdle();
318
+ try {
319
+ for await (const record of this.stream(conversationId, {
320
+ lastEventId: cursor,
321
+ signal: merged.signal
322
+ })) {
323
+ armIdle();
324
+ yield record;
325
+ if (isTurnComplete(record)) return;
326
+ }
327
+ if (idleController.signal.aborted && !signal?.aborted) {
328
+ throw new AbortError("Turn timed out waiting for the agent to respond.");
329
+ }
330
+ } finally {
331
+ if (idleTimer) clearTimeout(idleTimer);
332
+ merged.cleanup();
333
+ idleController.abort();
334
+ }
335
+ }
336
+ /**
337
+ * Send a message and stream just this turn's records, ending when the
338
+ * agent's `result` event arrives.
339
+ *
340
+ * The message is sent first; the stream then resumes precisely from the
341
+ * user message's event id, so only the agent's reply is surfaced — no
342
+ * history, no heuristics. To learn the (possibly new) conversation id
343
+ * while streaming, use {@link sendMessage} or the stateful
344
+ * {@link Conversation}.
345
+ *
346
+ * The first message in a new conversation auto-provisions the agent's
347
+ * pod, so the first turn can take tens of seconds before any record
348
+ * arrives; `idleTimeoutMs` (default 180s) bounds the wait.
349
+ */
350
+ async *runTurn(req, opts = {}) {
351
+ const result = await this.send(req, { signal: opts.signal });
352
+ yield* this.resumeTurn(result.conversationId, { ...opts, lastEventId: result.userEventId });
353
+ }
354
+ /**
355
+ * Send a message and resolve with the aggregated turn (assistant text,
356
+ * tool calls, all records, conversation id, and a resume cursor).
357
+ */
358
+ async sendMessage(req, opts = {}) {
359
+ const result = await this.send(req, { signal: opts.signal });
360
+ const turn = {
361
+ conversationId: result.conversationId,
362
+ text: "",
363
+ toolCalls: [],
364
+ records: [],
365
+ sessionId: null
366
+ };
367
+ for await (const record of this.resumeTurn(result.conversationId, {
368
+ ...opts,
369
+ lastEventId: result.userEventId
370
+ })) {
371
+ turn.records.push(record);
372
+ if (record.id) turn.lastEventId = record.id;
373
+ if (record.sessionId) turn.sessionId = record.sessionId;
374
+ if (record.sender === "assistant" || record.sender === "agent") {
375
+ turn.text += extractText(record);
376
+ turn.toolCalls.push(...extractToolCalls(record));
377
+ }
378
+ if (isTurnComplete(record)) turn.result = record.raw;
379
+ }
380
+ return turn;
381
+ }
382
+ /**
383
+ * Start a stateful conversation handle that remembers its id across
384
+ * turns. The first {@link Conversation.send} creates the conversation
385
+ * (routed to `targetAgentIds`); subsequent sends continue it.
386
+ */
387
+ conversation(opts = {}) {
388
+ return new Conversation(this, opts.conversationId, opts.targetAgentIds);
389
+ }
390
+ };
391
+ var Conversation = class {
392
+ constructor(client, conversationId, targetAgentIds) {
393
+ this.client = client;
394
+ this.conversationId = conversationId;
395
+ this.targetAgentIds = targetAgentIds;
396
+ }
397
+ /** The conversation id, once the first message has been sent. */
398
+ get id() {
399
+ return this.conversationId;
400
+ }
401
+ /** Build the send request for the next turn (create vs continue). */
402
+ buildRequest(message, extra) {
403
+ return this.conversationId ? { message, conversationId: this.conversationId, ...extra } : { message, targetAgentIds: this.targetAgentIds, ...extra };
404
+ }
405
+ /** Send a turn and resolve with the aggregated result. */
406
+ async send(message, opts = {}) {
407
+ const turn = await this.client.sendMessage(this.buildRequest(message, opts), opts);
408
+ this.conversationId = turn.conversationId;
409
+ return turn;
410
+ }
411
+ /** Send a turn and stream its records as they arrive. */
412
+ async *stream(message, opts = {}) {
413
+ const sent = await this.client.send(this.buildRequest(message, opts), { signal: opts.signal });
414
+ this.conversationId = sent.conversationId;
415
+ yield* this.client.resumeTurn(sent.conversationId, {
416
+ ...opts,
417
+ lastEventId: sent.userEventId
418
+ });
419
+ }
420
+ /** Interrupt the running turn in this conversation. */
421
+ async interrupt() {
422
+ if (!this.conversationId) return { ok: true, interrupted: false, reason: "No conversation yet" };
423
+ return this.client.interrupt(this.conversationId);
424
+ }
425
+ };
426
+
427
+ // src/transport.ts
428
+ var DEFAULT_BASE_URL = "https://api.openhex.tech";
429
+
430
+ // src/http/httpClient.ts
431
+ var API_PREFIX = "/api/v2";
432
+ function resolveApiKey(apiKey) {
433
+ const key = apiKey ?? (typeof process !== "undefined" ? process.env?.OPENHEX_API_KEY : void 0);
434
+ if (!key) throw new AuthenticationError();
435
+ return key;
436
+ }
437
+ function withTimeout(signal, timeoutMs) {
438
+ if (!timeoutMs || timeoutMs <= 0) {
439
+ return { signal: signal ?? new AbortController().signal, cancel: () => {
440
+ } };
441
+ }
442
+ const controller = new AbortController();
443
+ const onAbort = () => controller.abort(signal?.reason);
444
+ if (signal) {
445
+ if (signal.aborted) controller.abort();
446
+ else signal.addEventListener("abort", onAbort, { once: true });
447
+ }
448
+ const timer = setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);
449
+ return {
450
+ signal: controller.signal,
451
+ cancel: () => {
452
+ clearTimeout(timer);
453
+ signal?.removeEventListener("abort", onAbort);
454
+ }
455
+ };
456
+ }
457
+ var HttpClient = class {
458
+ apiKey;
459
+ baseUrl;
460
+ loginType;
461
+ actAs;
462
+ extraHeaders;
463
+ timeoutMs;
464
+ fetchImpl;
465
+ constructor(config = {}) {
466
+ this.apiKey = resolveApiKey(config.apiKey);
467
+ this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
468
+ this.loginType = config.loginType;
469
+ this.actAs = config.actAs;
470
+ this.extraHeaders = config.headers ?? {};
471
+ this.timeoutMs = config.timeoutMs ?? 3e4;
472
+ const f = config.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
473
+ if (!f) {
474
+ throw new Error("No fetch implementation available. Pass { fetch } in the client config.");
475
+ }
476
+ this.fetchImpl = f.bind(globalThis);
477
+ }
478
+ /** Build the full URL for an API path (prefixing `/api/v2` if needed). */
479
+ url(path) {
480
+ if (/^https?:\/\//.test(path)) return path;
481
+ const p = path.startsWith("/") ? path : `/${path}`;
482
+ const withPrefix = p.startsWith(API_PREFIX) ? p : `${API_PREFIX}${p}`;
483
+ return `${this.baseUrl}${withPrefix}`;
484
+ }
485
+ /** Headers common to every request. `accept` differs for SSE vs JSON. */
486
+ buildHeaders(accept, extra) {
487
+ const headers = {
488
+ Authorization: `Bearer ${this.apiKey}`,
489
+ Accept: accept,
490
+ ...this.extraHeaders,
491
+ ...extra
492
+ };
493
+ if (this.loginType) headers["X-Login-Type"] = this.loginType;
494
+ if (this.actAs) headers["X-Act-As"] = this.actAs;
495
+ return headers;
496
+ }
497
+ /** Map a non-2xx response to an {@link ApiError}, extracting `detail`. */
498
+ async toError(response) {
499
+ let body;
500
+ let detail;
501
+ try {
502
+ body = await response.json();
503
+ detail = body?.detail;
504
+ } catch {
505
+ try {
506
+ detail = await response.text();
507
+ } catch {
508
+ }
509
+ }
510
+ if (response.status === 401 || response.status === 403) {
511
+ return new ApiError(detail || "Unauthorized", response.status, body);
512
+ }
513
+ return new ApiError(detail || `Request failed with status ${response.status}`, response.status, body);
514
+ }
515
+ /** Perform a JSON request and parse the response body. */
516
+ async requestJson(path, options = {}) {
517
+ const response = await this.requestRaw(path, options, "application/json");
518
+ if (response.status === 204) return void 0;
519
+ return await response.json();
520
+ }
521
+ /** Perform a request and return the raw {@link Response} (used for SSE). */
522
+ async requestRaw(path, options = {}, accept = "application/json") {
523
+ const { method = "GET", body, headers, signal } = options;
524
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
525
+ const { signal: combined, cancel } = withTimeout(signal, timeoutMs);
526
+ const reqHeaders = this.buildHeaders(accept, headers);
527
+ if (body !== void 0) reqHeaders["Content-Type"] = "application/json";
528
+ let response;
529
+ try {
530
+ response = await this.fetchImpl(this.url(path), {
531
+ method,
532
+ headers: reqHeaders,
533
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
534
+ signal: combined
535
+ });
536
+ } finally {
537
+ if (accept !== "text/event-stream") cancel();
538
+ }
539
+ if (!response.ok) {
540
+ if (accept !== "text/event-stream") cancel();
541
+ throw await this.toError(response);
542
+ }
543
+ return response;
544
+ }
545
+ };
546
+
547
+ // src/react/auth.ts
548
+ function isAgentChatClient(c) {
549
+ return c instanceof AgentChatClient;
550
+ }
551
+ function tokenInjectingFetch(baseFetch, getToken) {
552
+ const wrapped = async (input, init) => {
553
+ const token = await getToken();
554
+ const headers = new Headers(init?.headers ?? {});
555
+ headers.set("Authorization", `Bearer ${token}`);
556
+ return baseFetch(input, { ...init, headers });
557
+ };
558
+ return wrapped;
559
+ }
560
+ function resolveChatClient(conn) {
561
+ if (conn.client) {
562
+ return isAgentChatClient(conn.client) ? conn.client : conn.client.chat;
563
+ }
564
+ if (!conn.token && !conn.getToken) {
565
+ throw new Error(
566
+ "useOpenhexChat needs a credential: pass { token } or { getToken } (a member session token minted on your backend), or a ready-made { client }."
567
+ );
568
+ }
569
+ const baseFetch = conn.fetch ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : void 0);
570
+ if (!baseFetch) {
571
+ throw new Error("No fetch implementation available. Pass { fetch } in the connection options.");
572
+ }
573
+ const http = new HttpClient({
574
+ // With `getToken`, the real token is injected per-request by the wrapped
575
+ // fetch below; `apiKey` just satisfies HttpClient's non-empty check.
576
+ apiKey: conn.token ?? "session",
577
+ baseUrl: conn.baseUrl,
578
+ loginType: conn.loginType,
579
+ actAs: conn.actAs,
580
+ fetch: conn.getToken ? tokenInjectingFetch(baseFetch, conn.getToken) : baseFetch
581
+ });
582
+ return new AgentChatClient(http);
583
+ }
584
+
585
+ // src/react/fold.ts
586
+ function foldRecords(records, idPrefix = "h") {
587
+ const out = [];
588
+ let current = null;
589
+ let n = 0;
590
+ const flush = () => {
591
+ if (current && (current.text.length > 0 || (current.toolCalls?.length ?? 0) > 0)) {
592
+ out.push(current);
593
+ }
594
+ current = null;
595
+ };
596
+ for (const record of records) {
597
+ const createdAt = typeof record.timestamp === "number" ? record.timestamp : 0;
598
+ if (record.sender === "user") {
599
+ flush();
600
+ const text = extractText(record);
601
+ if (text.length > 0) {
602
+ out.push({ id: `${idPrefix}${n++}`, role: "user", text, createdAt });
603
+ }
604
+ continue;
605
+ }
606
+ if (isAgentRecord(record)) {
607
+ if (!current) {
608
+ current = {
609
+ id: `${idPrefix}${n++}`,
610
+ role: "assistant",
611
+ text: "",
612
+ createdAt,
613
+ toolCalls: []
614
+ };
615
+ }
616
+ current.text += extractText(record);
617
+ const tools = extractToolCalls(record);
618
+ if (tools.length > 0) current.toolCalls = [...current.toolCalls ?? [], ...tools];
619
+ continue;
620
+ }
621
+ }
622
+ flush();
623
+ return out.map(
624
+ (m) => m.toolCalls && m.toolCalls.length === 0 ? { ...m, toolCalls: void 0 } : m
625
+ );
626
+ }
627
+
628
+ // src/react/useOpenhexChat.ts
629
+ function useIdFactory() {
630
+ const counter = useRef(0);
631
+ return useCallback(() => `m${counter.current++}`, []);
632
+ }
633
+ function useOpenhexChat(options) {
634
+ const {
635
+ agentId,
636
+ conversationId: initialConversationId,
637
+ senderName,
638
+ senderAvatar,
639
+ loadHistory = true,
640
+ idleTimeoutMs,
641
+ onTurnComplete,
642
+ onError
643
+ } = options;
644
+ const nextId = useIdFactory();
645
+ const [messages, setMessages] = useState([]);
646
+ const [status, setStatus] = useState("idle");
647
+ const [error, setError] = useState(null);
648
+ const [conversationId, setConversationId] = useState(initialConversationId);
649
+ const client = useMemo(
650
+ () => resolveChatClient(options),
651
+ // eslint-disable-next-line react-hooks/exhaustive-deps
652
+ [options.client, options.baseUrl, options.token, options.actAs, options.loginType]
653
+ );
654
+ const convoRef = useRef(null);
655
+ useMemo(() => {
656
+ convoRef.current = client.conversation({
657
+ conversationId: initialConversationId,
658
+ targetAgentIds: agentId ? [agentId] : void 0
659
+ });
660
+ }, [client, initialConversationId, agentId]);
661
+ const abortRef = useRef(null);
662
+ const lastUserTextRef = useRef(null);
663
+ const cbRef = useRef({ onTurnComplete, onError });
664
+ cbRef.current = { onTurnComplete, onError };
665
+ const historyLoadedFor = useRef(null);
666
+ useEffect(() => {
667
+ if (!loadHistory || !initialConversationId) return;
668
+ if (historyLoadedFor.current === initialConversationId) return;
669
+ historyLoadedFor.current = initialConversationId;
670
+ let cancelled = false;
671
+ (async () => {
672
+ try {
673
+ const { entries } = await client.messages(initialConversationId);
674
+ if (cancelled) return;
675
+ const prior = foldRecords(
676
+ entries.map((e) => e.data),
677
+ "h"
678
+ );
679
+ setMessages((cur) => cur.length === 0 ? prior : cur);
680
+ } catch (err) {
681
+ cbRef.current.onError?.(err instanceof Error ? err : new Error(String(err)));
682
+ }
683
+ })();
684
+ return () => {
685
+ cancelled = true;
686
+ };
687
+ }, [client, initialConversationId, loadHistory]);
688
+ const patch = useCallback((id, next) => {
689
+ setMessages((cur) => cur.map((m) => m.id === id ? { ...m, ...next } : m));
690
+ }, []);
691
+ const runTurn = useCallback(
692
+ async (text) => {
693
+ const convo = convoRef.current;
694
+ if (!convo) {
695
+ const e = new Error(
696
+ "No target agent: pass { agentId } (for a new conversation) or { conversationId }."
697
+ );
698
+ cbRef.current.onError?.(e);
699
+ setError(e);
700
+ setStatus("error");
701
+ return;
702
+ }
703
+ lastUserTextRef.current = text;
704
+ setError(null);
705
+ const assistantId = nextId();
706
+ setMessages((cur) => [
707
+ ...cur,
708
+ { id: nextId(), role: "user", text, createdAt: Date.now() },
709
+ {
710
+ id: assistantId,
711
+ role: "assistant",
712
+ text: "",
713
+ createdAt: Date.now(),
714
+ pending: true,
715
+ streaming: true
716
+ }
717
+ ]);
718
+ setStatus("connecting");
719
+ const controller = new AbortController();
720
+ abortRef.current = controller;
721
+ let acc = "";
722
+ let streaming = false;
723
+ const tools = [];
724
+ try {
725
+ for await (const record of convo.stream(text, {
726
+ signal: controller.signal,
727
+ idleTimeoutMs,
728
+ senderName,
729
+ senderAvatar
730
+ })) {
731
+ if (convo.id) setConversationId(convo.id);
732
+ if (!isAgentRecord(record)) continue;
733
+ acc += extractText(record);
734
+ for (const t of extractToolCalls(record)) tools.push(t);
735
+ patch(assistantId, {
736
+ text: acc,
737
+ pending: false,
738
+ streaming: true,
739
+ toolCalls: tools.length ? [...tools] : void 0
740
+ });
741
+ if (!streaming) {
742
+ streaming = true;
743
+ setStatus("streaming");
744
+ }
745
+ }
746
+ const empty = acc.length === 0 && tools.length === 0;
747
+ if (empty) {
748
+ setMessages((cur) => cur.filter((m) => m.id !== assistantId));
749
+ } else {
750
+ patch(assistantId, { streaming: false, pending: false });
751
+ }
752
+ setStatus("idle");
753
+ if (!empty) {
754
+ cbRef.current.onTurnComplete?.({
755
+ id: assistantId,
756
+ role: "assistant",
757
+ text: acc,
758
+ createdAt: Date.now(),
759
+ toolCalls: tools.length ? tools : void 0
760
+ });
761
+ }
762
+ } catch (err) {
763
+ const e = err instanceof Error ? err : new Error(String(err));
764
+ if (controller.signal.aborted) {
765
+ const empty = acc.length === 0 && tools.length === 0;
766
+ if (empty) setMessages((cur) => cur.filter((m) => m.id !== assistantId));
767
+ else patch(assistantId, { streaming: false, pending: false });
768
+ setStatus("idle");
769
+ return;
770
+ }
771
+ patch(assistantId, { streaming: false, pending: false, error: true });
772
+ setError(e);
773
+ setStatus("error");
774
+ cbRef.current.onError?.(e);
775
+ } finally {
776
+ if (abortRef.current === controller) abortRef.current = null;
777
+ }
778
+ },
779
+ // `status` is read for a micro-optimization only; excluded to keep `send`
780
+ // stable across renders.
781
+ // eslint-disable-next-line react-hooks/exhaustive-deps
782
+ [idleTimeoutMs, senderName, senderAvatar, nextId, patch]
783
+ );
784
+ const send = useCallback(
785
+ async (text) => {
786
+ const trimmed = text.trim();
787
+ if (!trimmed || abortRef.current) return;
788
+ await runTurn(trimmed);
789
+ },
790
+ [runTurn]
791
+ );
792
+ const interrupt = useCallback(() => {
793
+ const convo = convoRef.current;
794
+ abortRef.current?.abort();
795
+ void convo?.interrupt().catch(() => {
796
+ });
797
+ }, []);
798
+ const retry = useCallback(() => {
799
+ if (abortRef.current) return;
800
+ const last = lastUserTextRef.current;
801
+ if (last) void runTurn(last);
802
+ }, [runTurn]);
803
+ const clear = useCallback(() => {
804
+ abortRef.current?.abort();
805
+ abortRef.current = null;
806
+ lastUserTextRef.current = null;
807
+ historyLoadedFor.current = null;
808
+ setMessages([]);
809
+ setError(null);
810
+ setStatus("idle");
811
+ setConversationId(void 0);
812
+ convoRef.current = client.conversation({
813
+ targetAgentIds: agentId ? [agentId] : void 0
814
+ });
815
+ }, [client, agentId]);
816
+ useEffect(() => () => abortRef.current?.abort(), []);
817
+ const isResponding = status === "connecting" || status === "streaming";
818
+ return {
819
+ messages,
820
+ status,
821
+ error,
822
+ conversationId,
823
+ isResponding,
824
+ send,
825
+ interrupt,
826
+ retry,
827
+ clear
828
+ };
829
+ }
830
+
831
+ // src/react/styles.ts
832
+ import { useEffect as useEffect2 } from "react";
833
+ var STYLE_ELEMENT_ID = "ohx-chat-styles";
834
+ var CHAT_CSS = `
835
+ .ohx-root {
836
+ --ohx-accent: #4f46e5;
837
+ --ohx-accent-contrast: #ffffff;
838
+ --ohx-bg: #ffffff;
839
+ --ohx-surface: #f7f7f8;
840
+ --ohx-fg: #1f2328;
841
+ --ohx-muted: #6b7280;
842
+ --ohx-border: #e5e7eb;
843
+ --ohx-user-bg: var(--ohx-accent);
844
+ --ohx-user-fg: var(--ohx-accent-contrast);
845
+ --ohx-assistant-bg: #f1f2f4;
846
+ --ohx-assistant-fg: #1f2328;
847
+ --ohx-radius: 16px;
848
+ --ohx-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
849
+ display: flex;
850
+ flex-direction: column;
851
+ box-sizing: border-box;
852
+ background: var(--ohx-bg);
853
+ color: var(--ohx-fg);
854
+ font-family: var(--ohx-font);
855
+ font-size: 14px;
856
+ line-height: 1.5;
857
+ border: 1px solid var(--ohx-border);
858
+ border-radius: var(--ohx-radius);
859
+ overflow: hidden;
860
+ min-height: 0;
861
+ }
862
+ .ohx-root[data-theme="dark"] {
863
+ --ohx-bg: #1b1c1f;
864
+ --ohx-surface: #242629;
865
+ --ohx-fg: #e8eaed;
866
+ --ohx-muted: #9aa0a6;
867
+ --ohx-border: #34363b;
868
+ --ohx-assistant-bg: #2a2c30;
869
+ --ohx-assistant-fg: #e8eaed;
870
+ }
871
+ .ohx-root *, .ohx-root *::before, .ohx-root *::after { box-sizing: border-box; }
872
+
873
+ .ohx-header {
874
+ display: flex;
875
+ align-items: center;
876
+ gap: 10px;
877
+ padding: 12px 14px;
878
+ border-bottom: 1px solid var(--ohx-border);
879
+ background: var(--ohx-bg);
880
+ flex: none;
881
+ }
882
+ .ohx-avatar {
883
+ width: 34px; height: 34px; border-radius: 50%;
884
+ object-fit: cover; flex: none;
885
+ background: var(--ohx-surface);
886
+ }
887
+ .ohx-header-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
888
+ .ohx-title { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
889
+ .ohx-subtitle { font-size: 12px; color: var(--ohx-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
890
+ .ohx-icon-btn {
891
+ display: inline-flex; align-items: center; justify-content: center;
892
+ width: 30px; height: 30px; border: none; border-radius: 8px;
893
+ background: transparent; color: var(--ohx-muted); cursor: pointer;
894
+ }
895
+ .ohx-icon-btn:hover { background: var(--ohx-surface); color: var(--ohx-fg); }
896
+
897
+ .ohx-messages {
898
+ flex: 1 1 auto;
899
+ min-height: 0;
900
+ overflow-y: auto;
901
+ padding: 16px;
902
+ display: flex;
903
+ flex-direction: column;
904
+ gap: 10px;
905
+ background: var(--ohx-bg);
906
+ scroll-behavior: smooth;
907
+ }
908
+ .ohx-empty {
909
+ margin: auto; text-align: center; color: var(--ohx-muted);
910
+ font-size: 13px; padding: 24px; max-width: 80%;
911
+ }
912
+
913
+ .ohx-row { display: flex; width: 100%; }
914
+ .ohx-row.user { justify-content: flex-end; }
915
+ .ohx-row.assistant, .ohx-row.system { justify-content: flex-start; }
916
+ .ohx-bubble {
917
+ max-width: 82%;
918
+ padding: 9px 13px;
919
+ border-radius: 14px;
920
+ word-break: break-word;
921
+ overflow-wrap: anywhere;
922
+ }
923
+ .ohx-row.user .ohx-bubble {
924
+ background: var(--ohx-user-bg); color: var(--ohx-user-fg);
925
+ border-bottom-right-radius: 4px;
926
+ }
927
+ .ohx-row.assistant .ohx-bubble {
928
+ background: var(--ohx-assistant-bg); color: var(--ohx-assistant-fg);
929
+ border-bottom-left-radius: 4px;
930
+ }
931
+ .ohx-bubble.error { border: 1px solid #ef4444; }
932
+ .ohx-bubble .ohx-p { margin: 0 0 6px; }
933
+ .ohx-bubble .ohx-p:last-child { margin-bottom: 0; }
934
+ .ohx-bubble a { color: inherit; text-decoration: underline; }
935
+ .ohx-row.assistant .ohx-bubble a { color: var(--ohx-accent); }
936
+ .ohx-bubble > :first-child { margin-top: 0; }
937
+ .ohx-bubble > :last-child { margin-bottom: 0; }
938
+ .ohx-bubble .ohx-h { font-weight: 600; line-height: 1.3; margin: 12px 0 6px; }
939
+ .ohx-bubble .ohx-h1 { font-size: 1.3em; }
940
+ .ohx-bubble .ohx-h2 { font-size: 1.18em; }
941
+ .ohx-bubble .ohx-h3 { font-size: 1.08em; }
942
+ .ohx-bubble .ohx-h4, .ohx-bubble .ohx-h5, .ohx-bubble .ohx-h6 { font-size: 1em; }
943
+ .ohx-bubble .ohx-ul, .ohx-bubble .ohx-ol { margin: 6px 0; padding-left: 1.35em; }
944
+ .ohx-bubble .ohx-ul { list-style: disc; }
945
+ .ohx-bubble .ohx-ol { list-style: decimal; }
946
+ .ohx-bubble .ohx-ul li, .ohx-bubble .ohx-ol li { margin: 3px 0; }
947
+ .ohx-bubble .ohx-ul li::marker, .ohx-bubble .ohx-ol li::marker { color: var(--ohx-muted); }
948
+ .ohx-bubble .ohx-hr { border: none; border-top: 1px solid var(--ohx-border); margin: 12px 0; }
949
+ .ohx-bubble .ohx-quote {
950
+ margin: 6px 0; padding: 2px 0 2px 12px;
951
+ border-left: 3px solid var(--ohx-border); color: var(--ohx-muted);
952
+ }
953
+ .ohx-code {
954
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
955
+ font-size: 0.9em; background: rgba(127,127,127,0.16);
956
+ padding: 1px 5px; border-radius: 5px;
957
+ }
958
+ .ohx-pre {
959
+ margin: 6px 0; padding: 10px 12px; border-radius: 10px;
960
+ background: rgba(127,127,127,0.14); overflow-x: auto;
961
+ }
962
+ .ohx-pre code {
963
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
964
+ font-size: 0.86em; white-space: pre; background: none; padding: 0;
965
+ }
966
+ .ohx-tools { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
967
+ .ohx-tool {
968
+ font-size: 11px; color: var(--ohx-muted);
969
+ background: rgba(127,127,127,0.12); border-radius: 6px; padding: 2px 7px;
970
+ font-family: ui-monospace, monospace;
971
+ }
972
+
973
+ .ohx-typing { display: inline-flex; gap: 4px; padding: 3px 2px; align-items: center; }
974
+ .ohx-typing span {
975
+ width: 6px; height: 6px; border-radius: 50%;
976
+ background: var(--ohx-muted); opacity: 0.5;
977
+ animation: ohx-blink 1.2s infinite ease-in-out both;
978
+ }
979
+ .ohx-typing span:nth-child(2) { animation-delay: 0.18s; }
980
+ .ohx-typing span:nth-child(3) { animation-delay: 0.36s; }
981
+ @keyframes ohx-blink { 0%, 80%, 100% { transform: scale(0.7); opacity: 0.3; } 40% { transform: scale(1); opacity: 0.9; } }
982
+
983
+ .ohx-error-bar {
984
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
985
+ margin: 0 16px 8px; padding: 8px 12px; font-size: 12px;
986
+ color: #b91c1c; background: rgba(239,68,68,0.1);
987
+ border: 1px solid rgba(239,68,68,0.3); border-radius: 8px;
988
+ }
989
+ .ohx-root[data-theme="dark"] .ohx-error-bar { color: #fca5a5; }
990
+ .ohx-retry {
991
+ border: none; background: transparent; color: inherit; cursor: pointer;
992
+ text-decoration: underline; font-weight: 600; font-size: 12px; white-space: nowrap;
993
+ }
994
+
995
+ .ohx-composer {
996
+ display: flex; align-items: flex-end; gap: 8px;
997
+ padding: 10px 12px; border-top: 1px solid var(--ohx-border);
998
+ background: var(--ohx-bg); flex: none;
999
+ }
1000
+ .ohx-textarea {
1001
+ flex: 1; resize: none; border: 1px solid var(--ohx-border);
1002
+ border-radius: 12px; padding: 9px 12px; max-height: 140px;
1003
+ font-family: inherit; font-size: 14px; line-height: 1.4;
1004
+ color: var(--ohx-fg); background: var(--ohx-surface); outline: none;
1005
+ }
1006
+ .ohx-textarea:focus { border-color: var(--ohx-accent); }
1007
+ .ohx-textarea::placeholder { color: var(--ohx-muted); }
1008
+ .ohx-send {
1009
+ display: inline-flex; align-items: center; justify-content: center;
1010
+ width: 38px; height: 38px; flex: none; border: none; border-radius: 12px;
1011
+ background: var(--ohx-accent); color: var(--ohx-accent-contrast); cursor: pointer;
1012
+ transition: opacity 0.15s;
1013
+ }
1014
+ .ohx-send:disabled { opacity: 0.45; cursor: not-allowed; }
1015
+ .ohx-send.stop { background: var(--ohx-surface); color: var(--ohx-fg); border: 1px solid var(--ohx-border); }
1016
+ .ohx-footer {
1017
+ text-align: center; font-size: 10px; color: var(--ohx-muted);
1018
+ padding: 0 0 8px; background: var(--ohx-bg); flex: none;
1019
+ }
1020
+ .ohx-footer a { color: var(--ohx-muted); }
1021
+
1022
+ /* ---- Floating widget ---- */
1023
+ .ohx-launcher {
1024
+ position: fixed; z-index: 2147483000;
1025
+ width: 56px; height: 56px; border-radius: 50%; border: none;
1026
+ background: var(--ohx-accent, #4f46e5); color: #fff; cursor: pointer;
1027
+ box-shadow: 0 6px 24px rgba(0,0,0,0.22);
1028
+ display: inline-flex; align-items: center; justify-content: center;
1029
+ transition: transform 0.15s;
1030
+ }
1031
+ .ohx-launcher:hover { transform: scale(1.06); }
1032
+ .ohx-launcher.bottom-right { right: 20px; bottom: 20px; }
1033
+ .ohx-launcher.bottom-left { left: 20px; bottom: 20px; }
1034
+ .ohx-panel {
1035
+ position: fixed; z-index: 2147483000;
1036
+ width: 380px; height: min(620px, calc(100vh - 110px));
1037
+ box-shadow: 0 12px 40px rgba(0,0,0,0.24);
1038
+ border-radius: 18px;
1039
+ overflow: hidden;
1040
+ animation: ohx-pop 0.16s ease-out;
1041
+ }
1042
+ .ohx-panel.bottom-right { right: 20px; bottom: 88px; }
1043
+ .ohx-panel.bottom-left { left: 20px; bottom: 88px; }
1044
+ /* Inside a panel the wrapper owns the shape + shadow, so the ChatBox fills
1045
+ it squared (no double border / radius). */
1046
+ .ohx-root.ohx-panel-inner { border: none; border-radius: 0; height: 100%; width: 100%; }
1047
+ @keyframes ohx-pop { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
1048
+
1049
+ @media (max-width: 480px) {
1050
+ .ohx-panel {
1051
+ right: 0; left: 0; bottom: 0; top: 0;
1052
+ width: 100vw; height: 100vh; height: 100dvh;
1053
+ border-radius: 0;
1054
+ }
1055
+ .ohx-launcher.open-hidden { display: none; }
1056
+ }
1057
+ `;
1058
+ function useInjectStyles(enabled) {
1059
+ useEffect2(() => {
1060
+ if (!enabled) return;
1061
+ if (typeof document === "undefined") return;
1062
+ if (document.getElementById(STYLE_ELEMENT_ID)) return;
1063
+ const style = document.createElement("style");
1064
+ style.id = STYLE_ELEMENT_ID;
1065
+ style.textContent = CHAT_CSS;
1066
+ document.head.appendChild(style);
1067
+ }, [enabled]);
1068
+ }
1069
+
1070
+ // src/react/MarkdownView.tsx
1071
+ import { Fragment, createElement } from "react";
1072
+
1073
+ // src/react/markdown.ts
1074
+ function sanitizeHref(href) {
1075
+ const trimmed = href.trim();
1076
+ if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
1077
+ if (/^\/\//.test(trimmed)) return `https:${trimmed}`;
1078
+ if (/^www\./i.test(trimmed)) return `https://${trimmed}`;
1079
+ return null;
1080
+ }
1081
+ var INLINE = [
1082
+ { type: "code", re: /`([^`]+)`/ },
1083
+ { type: "bold", re: /\*\*([^*]+)\*\*/ },
1084
+ { type: "italic", re: /\*([^*\n]+)\*|_([^_\n]+)_/ },
1085
+ { type: "link", re: /\[([^\]]+)\]\(([^)\s]+)\)/ },
1086
+ { type: "url", re: /(https?:\/\/[^\s<]+[^\s<.,:;"')\]}]|www\.[^\s<]+[^\s<.,:;"')\]}])/ }
1087
+ ];
1088
+ function parseInline(input) {
1089
+ const spans = [];
1090
+ let rest = input;
1091
+ while (rest.length > 0) {
1092
+ let best = null;
1093
+ for (const rule of INLINE) {
1094
+ const m = rule.re.exec(rest);
1095
+ if (!m || m.index == null) continue;
1096
+ if (best && m.index >= best.index) continue;
1097
+ let span = null;
1098
+ if (rule.type === "code") span = { type: "code", text: m[1] };
1099
+ else if (rule.type === "bold") span = { type: "bold", text: m[1] };
1100
+ else if (rule.type === "italic") span = { type: "italic", text: m[1] ?? m[2] };
1101
+ else if (rule.type === "link") {
1102
+ const href = sanitizeHref(m[2]);
1103
+ span = href ? { type: "link", text: m[1], href } : { type: "text", text: m[0] };
1104
+ } else if (rule.type === "url") {
1105
+ const href = sanitizeHref(m[1]);
1106
+ span = href ? { type: "link", text: m[1], href } : { type: "text", text: m[0] };
1107
+ }
1108
+ if (span) best = { index: m.index, length: m[0].length, span };
1109
+ }
1110
+ if (!best) {
1111
+ spans.push({ type: "text", text: rest });
1112
+ break;
1113
+ }
1114
+ if (best.index > 0) spans.push({ type: "text", text: rest.slice(0, best.index) });
1115
+ spans.push(best.span);
1116
+ rest = rest.slice(best.index + best.length);
1117
+ }
1118
+ return spans;
1119
+ }
1120
+ function parseMarkdown(input) {
1121
+ const blocks = [];
1122
+ const lines = input.replace(/\r\n/g, "\n").split("\n");
1123
+ let i = 0;
1124
+ let para = [];
1125
+ const flushPara = () => {
1126
+ if (para.length === 0) return;
1127
+ const text = para.join("\n").trim();
1128
+ if (text) blocks.push({ type: "paragraph", spans: parseInline(text) });
1129
+ para = [];
1130
+ };
1131
+ const LIST_ITEM = /^\s*([-*+]|\d+[.)])\s+(.*)$/;
1132
+ while (i < lines.length) {
1133
+ const line = lines[i];
1134
+ const fence = /^```(.*)$/.exec(line);
1135
+ if (fence) {
1136
+ flushPara();
1137
+ const lang = fence[1].trim() || void 0;
1138
+ const code = [];
1139
+ i++;
1140
+ while (i < lines.length && !/^```/.test(lines[i])) {
1141
+ code.push(lines[i]);
1142
+ i++;
1143
+ }
1144
+ i++;
1145
+ blocks.push({ type: "code", text: code.join("\n"), lang });
1146
+ continue;
1147
+ }
1148
+ const trimmed = line.trim();
1149
+ if (trimmed === "") {
1150
+ flushPara();
1151
+ i++;
1152
+ continue;
1153
+ }
1154
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
1155
+ flushPara();
1156
+ blocks.push({ type: "hr" });
1157
+ i++;
1158
+ continue;
1159
+ }
1160
+ const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
1161
+ if (heading) {
1162
+ flushPara();
1163
+ blocks.push({ type: "heading", level: heading[1].length, spans: parseInline(heading[2]) });
1164
+ i++;
1165
+ continue;
1166
+ }
1167
+ if (/^\s*>\s?/.test(line)) {
1168
+ flushPara();
1169
+ const quote = [];
1170
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
1171
+ quote.push(lines[i].replace(/^\s*>\s?/, ""));
1172
+ i++;
1173
+ }
1174
+ blocks.push({ type: "blockquote", spans: parseInline(quote.join("\n").trim()) });
1175
+ continue;
1176
+ }
1177
+ const firstItem = LIST_ITEM.exec(line);
1178
+ if (firstItem) {
1179
+ flushPara();
1180
+ const ordered = /\d/.test(firstItem[1]);
1181
+ const start = ordered ? parseInt(firstItem[1], 10) || 1 : 1;
1182
+ const items = [];
1183
+ while (i < lines.length) {
1184
+ const m = LIST_ITEM.exec(lines[i]);
1185
+ if (m) {
1186
+ items.push(parseInline(m[2]));
1187
+ i++;
1188
+ } else if (lines[i].trim() !== "" && /^\s+\S/.test(lines[i]) && items.length > 0) {
1189
+ items[items.length - 1].push(...parseInline(" " + lines[i].trim()));
1190
+ i++;
1191
+ } else {
1192
+ break;
1193
+ }
1194
+ }
1195
+ blocks.push({ type: "list", ordered, start, items });
1196
+ continue;
1197
+ }
1198
+ para.push(line);
1199
+ i++;
1200
+ }
1201
+ flushPara();
1202
+ return blocks;
1203
+ }
1204
+
1205
+ // src/react/MarkdownView.tsx
1206
+ import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
1207
+ function InlineSpans({ spans }) {
1208
+ return /* @__PURE__ */ jsx(Fragment2, { children: spans.map((s, i) => {
1209
+ switch (s.type) {
1210
+ case "code":
1211
+ return /* @__PURE__ */ jsx("code", { className: "ohx-code", children: s.text }, i);
1212
+ case "bold":
1213
+ return /* @__PURE__ */ jsx("strong", { children: s.text }, i);
1214
+ case "italic":
1215
+ return /* @__PURE__ */ jsx("em", { children: s.text }, i);
1216
+ case "link":
1217
+ return /* @__PURE__ */ jsx("a", { href: s.href, target: "_blank", rel: "noopener noreferrer nofollow", children: s.text }, i);
1218
+ default:
1219
+ return /* @__PURE__ */ jsx(Fragment, { children: s.text }, i);
1220
+ }
1221
+ }) });
1222
+ }
1223
+ function Markdown({ text }) {
1224
+ const blocks = parseMarkdown(text);
1225
+ return /* @__PURE__ */ jsx(Fragment2, { children: blocks.map((b, i) => {
1226
+ switch (b.type) {
1227
+ case "code":
1228
+ return /* @__PURE__ */ jsx("pre", { className: "ohx-pre", children: /* @__PURE__ */ jsx("code", { children: b.text }) }, i);
1229
+ case "heading": {
1230
+ const level = Math.min(6, Math.max(1, b.level));
1231
+ return createElement(
1232
+ `h${level}`,
1233
+ { key: i, className: `ohx-h ohx-h${level}` },
1234
+ /* @__PURE__ */ jsx(InlineSpans, { spans: b.spans })
1235
+ );
1236
+ }
1237
+ case "hr":
1238
+ return /* @__PURE__ */ jsx("hr", { className: "ohx-hr" }, i);
1239
+ case "blockquote":
1240
+ return /* @__PURE__ */ jsx("blockquote", { className: "ohx-quote", children: /* @__PURE__ */ jsx(InlineSpans, { spans: b.spans }) }, i);
1241
+ case "list": {
1242
+ const items = b.items.map((spans, j) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(InlineSpans, { spans }) }, j));
1243
+ return b.ordered ? /* @__PURE__ */ jsx("ol", { className: "ohx-ol", start: b.start, children: items }, i) : /* @__PURE__ */ jsx("ul", { className: "ohx-ul", children: items }, i);
1244
+ }
1245
+ default:
1246
+ return /* @__PURE__ */ jsx("p", { className: "ohx-p", children: /* @__PURE__ */ jsx(InlineSpans, { spans: b.spans }) }, i);
1247
+ }
1248
+ }) });
1249
+ }
1250
+
1251
+ // src/react/icons.tsx
1252
+ import { jsx as jsx2 } from "react/jsx-runtime";
1253
+ function SendIcon(props) {
1254
+ return /* @__PURE__ */ jsx2("svg", { viewBox: "0 0 24 24", width: "18", height: "18", fill: "none", "aria-hidden": true, ...props, children: /* @__PURE__ */ jsx2(
1255
+ "path",
1256
+ {
1257
+ d: "M4 12l16-8-6 16-3-6-7-2z",
1258
+ fill: "currentColor",
1259
+ stroke: "currentColor",
1260
+ strokeWidth: "1.2",
1261
+ strokeLinejoin: "round"
1262
+ }
1263
+ ) });
1264
+ }
1265
+ function CloseIcon(props) {
1266
+ return /* @__PURE__ */ jsx2("svg", { viewBox: "0 0 24 24", width: "18", height: "18", fill: "none", "aria-hidden": true, ...props, children: /* @__PURE__ */ jsx2("path", { d: "M6 6l12 12M18 6L6 18", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) });
1267
+ }
1268
+ function ChatIcon(props) {
1269
+ return /* @__PURE__ */ jsx2("svg", { viewBox: "0 0 24 24", width: "24", height: "24", fill: "none", "aria-hidden": true, ...props, children: /* @__PURE__ */ jsx2(
1270
+ "path",
1271
+ {
1272
+ d: "M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z",
1273
+ fill: "currentColor"
1274
+ }
1275
+ ) });
1276
+ }
1277
+ function StopIcon(props) {
1278
+ return /* @__PURE__ */ jsx2("svg", { viewBox: "0 0 24 24", width: "16", height: "16", fill: "none", "aria-hidden": true, ...props, children: /* @__PURE__ */ jsx2("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2", fill: "currentColor" }) });
1279
+ }
1280
+
1281
+ // src/react/ChatBox.tsx
1282
+ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
1283
+ function useResolvedTheme(theme) {
1284
+ const getSystem = () => typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
1285
+ const [system, setSystem] = useState2(getSystem);
1286
+ useEffect3(() => {
1287
+ if (theme !== "auto" || typeof window === "undefined" || !window.matchMedia) return;
1288
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
1289
+ const onChange = () => setSystem(mq.matches ? "dark" : "light");
1290
+ mq.addEventListener("change", onChange);
1291
+ return () => mq.removeEventListener("change", onChange);
1292
+ }, [theme]);
1293
+ return theme === "auto" ? system : theme;
1294
+ }
1295
+ var dim = (v) => typeof v === "number" ? `${v}px` : v;
1296
+ function TypingDots() {
1297
+ return /* @__PURE__ */ jsxs("span", { className: "ohx-typing", "aria-label": "Assistant is typing", children: [
1298
+ /* @__PURE__ */ jsx3("span", {}),
1299
+ /* @__PURE__ */ jsx3("span", {}),
1300
+ /* @__PURE__ */ jsx3("span", {})
1301
+ ] });
1302
+ }
1303
+ function MessageBubble({ m, showToolCalls }) {
1304
+ const showTyping = m.role === "assistant" && m.text.length === 0 && (m.pending || m.streaming);
1305
+ return /* @__PURE__ */ jsx3("div", { className: `ohx-row ${m.role}`, children: /* @__PURE__ */ jsxs("div", { className: `ohx-bubble${m.error ? " error" : ""}`, children: [
1306
+ showTyping ? /* @__PURE__ */ jsx3(TypingDots, {}) : /* @__PURE__ */ jsx3(Markdown, { text: m.text }),
1307
+ showToolCalls && m.toolCalls && m.toolCalls.length > 0 && /* @__PURE__ */ jsx3("div", { className: "ohx-tools", children: m.toolCalls.map((t, i) => /* @__PURE__ */ jsx3("span", { className: "ohx-tool", children: t.name }, t.id ?? i)) })
1308
+ ] }) });
1309
+ }
1310
+ function ChatBox(props) {
1311
+ const {
1312
+ title = "Chat",
1313
+ subtitle,
1314
+ placeholder = "Type a message\u2026",
1315
+ greeting,
1316
+ avatarUrl,
1317
+ theme = "light",
1318
+ accentColor,
1319
+ height,
1320
+ width,
1321
+ className,
1322
+ header,
1323
+ emptyState,
1324
+ footer,
1325
+ disabled = false,
1326
+ injectStyles = true,
1327
+ showToolCalls = false
1328
+ } = props;
1329
+ useInjectStyles(injectStyles);
1330
+ const resolvedTheme = useResolvedTheme(theme);
1331
+ const chat = useOpenhexChat(props);
1332
+ const [input, setInput] = useState2("");
1333
+ const scrollRef = useRef2(null);
1334
+ const textareaRef = useRef2(null);
1335
+ useLayoutEffect(() => {
1336
+ const el = scrollRef.current;
1337
+ if (el) el.scrollTop = el.scrollHeight;
1338
+ }, [chat.messages]);
1339
+ useLayoutEffect(() => {
1340
+ const ta = textareaRef.current;
1341
+ if (!ta) return;
1342
+ ta.style.height = "auto";
1343
+ ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`;
1344
+ }, [input]);
1345
+ const submit = useCallback2(() => {
1346
+ const text = input.trim();
1347
+ if (!text || chat.isResponding || disabled) return;
1348
+ setInput("");
1349
+ void chat.send(text);
1350
+ }, [input, chat, disabled]);
1351
+ const onKeyDown = useCallback2(
1352
+ (e) => {
1353
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
1354
+ e.preventDefault();
1355
+ submit();
1356
+ }
1357
+ },
1358
+ [submit]
1359
+ );
1360
+ const rootStyle = useMemo2(() => {
1361
+ const s = {};
1362
+ if (height != null) s.height = dim(height);
1363
+ if (width != null) s.width = dim(width);
1364
+ if (height == null) s.height = "100%";
1365
+ if (accentColor) s["--ohx-accent"] = accentColor;
1366
+ return s;
1367
+ }, [height, width, accentColor]);
1368
+ const showGreeting = chat.messages.length === 0 && chat.status !== "connecting";
1369
+ const respondingLabel = chat.status === "connecting" ? "Connecting\u2026" : subtitle;
1370
+ return /* @__PURE__ */ jsxs(
1371
+ "div",
1372
+ {
1373
+ className: `ohx-root${className ? ` ${className}` : ""}`,
1374
+ "data-theme": resolvedTheme,
1375
+ style: rootStyle,
1376
+ children: [
1377
+ header === false ? null : header != null ? header : /* @__PURE__ */ jsxs("div", { className: "ohx-header", children: [
1378
+ avatarUrl && /* @__PURE__ */ jsx3("img", { className: "ohx-avatar", src: avatarUrl, alt: "" }),
1379
+ /* @__PURE__ */ jsxs("div", { className: "ohx-header-text", children: [
1380
+ /* @__PURE__ */ jsx3("span", { className: "ohx-title", children: title }),
1381
+ (respondingLabel || chat.isResponding) && /* @__PURE__ */ jsx3("span", { className: "ohx-subtitle", children: chat.isResponding ? respondingLabel || "Typing\u2026" : subtitle })
1382
+ ] })
1383
+ ] }),
1384
+ /* @__PURE__ */ jsx3("div", { className: "ohx-messages", ref: scrollRef, children: showGreeting ? emptyState != null ? emptyState : greeting ? /* @__PURE__ */ jsx3(
1385
+ MessageBubble,
1386
+ {
1387
+ m: { id: "greeting", role: "assistant", text: greeting, createdAt: 0 }
1388
+ }
1389
+ ) : /* @__PURE__ */ jsx3("div", { className: "ohx-empty", children: "Ask me anything to get started." }) : chat.messages.map((m) => /* @__PURE__ */ jsx3(MessageBubble, { m, showToolCalls }, m.id)) }),
1390
+ chat.status === "error" && chat.error && /* @__PURE__ */ jsxs("div", { className: "ohx-error-bar", role: "alert", children: [
1391
+ /* @__PURE__ */ jsx3("span", { children: chat.error.message || "Something went wrong." }),
1392
+ /* @__PURE__ */ jsx3("button", { type: "button", className: "ohx-retry", onClick: chat.retry, children: "Retry" })
1393
+ ] }),
1394
+ /* @__PURE__ */ jsxs("div", { className: "ohx-composer", children: [
1395
+ /* @__PURE__ */ jsx3(
1396
+ "textarea",
1397
+ {
1398
+ ref: textareaRef,
1399
+ className: "ohx-textarea",
1400
+ rows: 1,
1401
+ value: input,
1402
+ placeholder,
1403
+ disabled,
1404
+ onChange: (e) => setInput(e.target.value),
1405
+ onKeyDown,
1406
+ "aria-label": "Message"
1407
+ }
1408
+ ),
1409
+ chat.isResponding ? /* @__PURE__ */ jsx3(
1410
+ "button",
1411
+ {
1412
+ type: "button",
1413
+ className: "ohx-send stop",
1414
+ onClick: chat.interrupt,
1415
+ "aria-label": "Stop",
1416
+ title: "Stop",
1417
+ children: /* @__PURE__ */ jsx3(StopIcon, {})
1418
+ }
1419
+ ) : /* @__PURE__ */ jsx3(
1420
+ "button",
1421
+ {
1422
+ type: "button",
1423
+ className: "ohx-send",
1424
+ onClick: submit,
1425
+ disabled: disabled || input.trim().length === 0,
1426
+ "aria-label": "Send",
1427
+ title: "Send",
1428
+ children: /* @__PURE__ */ jsx3(SendIcon, {})
1429
+ }
1430
+ )
1431
+ ] }),
1432
+ footer === false ? null : footer != null ? /* @__PURE__ */ jsx3("div", { className: "ohx-footer", children: footer }) : /* @__PURE__ */ jsx3("div", { className: "ohx-footer", children: "Powered by Openhex" })
1433
+ ]
1434
+ }
1435
+ );
1436
+ }
1437
+
1438
+ // src/react/ChatWidget.tsx
1439
+ import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1440
+ function ChatWidget(props) {
1441
+ const {
1442
+ position = "bottom-right",
1443
+ launcherIcon,
1444
+ launcherLabel = "Open chat",
1445
+ defaultOpen = false,
1446
+ open: controlledOpen,
1447
+ onOpenChange,
1448
+ accentColor,
1449
+ injectStyles = true,
1450
+ className,
1451
+ ...boxProps
1452
+ } = props;
1453
+ useInjectStyles(injectStyles);
1454
+ const [uncontrolledOpen, setUncontrolledOpen] = useState3(defaultOpen);
1455
+ const isControlled = controlledOpen != null;
1456
+ const open = isControlled ? controlledOpen : uncontrolledOpen;
1457
+ const setOpen = useCallback3(
1458
+ (next) => {
1459
+ if (!isControlled) setUncontrolledOpen(next);
1460
+ onOpenChange?.(next);
1461
+ },
1462
+ [isControlled, onOpenChange]
1463
+ );
1464
+ const launcherStyle = accentColor ? { background: accentColor } : void 0;
1465
+ return /* @__PURE__ */ jsxs2(Fragment3, { children: [
1466
+ open && /* @__PURE__ */ jsx4(
1467
+ "div",
1468
+ {
1469
+ className: `ohx-panel ${position}`,
1470
+ role: "dialog",
1471
+ "aria-label": boxProps.title ?? "Chat",
1472
+ children: /* @__PURE__ */ jsx4(
1473
+ ChatBox,
1474
+ {
1475
+ ...boxProps,
1476
+ accentColor,
1477
+ className: `ohx-panel-inner${className ? ` ${className}` : ""}`,
1478
+ injectStyles: false,
1479
+ header: boxProps.header !== void 0 ? boxProps.header : /* @__PURE__ */ jsxs2("div", { className: "ohx-header", children: [
1480
+ boxProps.avatarUrl && /* @__PURE__ */ jsx4("img", { className: "ohx-avatar", src: boxProps.avatarUrl, alt: "" }),
1481
+ /* @__PURE__ */ jsxs2("div", { className: "ohx-header-text", children: [
1482
+ /* @__PURE__ */ jsx4("span", { className: "ohx-title", children: boxProps.title ?? "Chat" }),
1483
+ boxProps.subtitle && /* @__PURE__ */ jsx4("span", { className: "ohx-subtitle", children: boxProps.subtitle })
1484
+ ] }),
1485
+ /* @__PURE__ */ jsx4(
1486
+ "button",
1487
+ {
1488
+ type: "button",
1489
+ className: "ohx-icon-btn",
1490
+ onClick: () => setOpen(false),
1491
+ "aria-label": "Close chat",
1492
+ title: "Close",
1493
+ children: /* @__PURE__ */ jsx4(CloseIcon, {})
1494
+ }
1495
+ )
1496
+ ] })
1497
+ }
1498
+ )
1499
+ }
1500
+ ),
1501
+ /* @__PURE__ */ jsx4(
1502
+ "button",
1503
+ {
1504
+ type: "button",
1505
+ className: `ohx-launcher ${position}${open ? " open-hidden" : ""}`,
1506
+ style: launcherStyle,
1507
+ onClick: () => setOpen(!open),
1508
+ "aria-label": open ? "Close chat" : launcherLabel,
1509
+ "aria-expanded": open,
1510
+ children: open ? /* @__PURE__ */ jsx4(CloseIcon, { width: 22, height: 22 }) : launcherIcon ?? /* @__PURE__ */ jsx4(ChatIcon, {})
1511
+ }
1512
+ )
1513
+ ] });
1514
+ }
1515
+ export {
1516
+ CHAT_CSS,
1517
+ ChatBox,
1518
+ ChatWidget,
1519
+ Markdown,
1520
+ STYLE_ELEMENT_ID,
1521
+ foldRecords,
1522
+ parseInline,
1523
+ parseMarkdown,
1524
+ resolveChatClient,
1525
+ sanitizeHref,
1526
+ useInjectStyles,
1527
+ useOpenhexChat
1528
+ };
1529
+ //# sourceMappingURL=index.js.map