@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.
- package/README.md +123 -25
- package/dist/client-1lMkhPMb.d.ts +1066 -0
- package/dist/index.d.ts +5 -1066
- package/dist/react/index.d.ts +277 -0
- package/dist/react/index.js +1529 -0
- package/dist/react/index.js.map +1 -0
- package/dist/tools/index.d.ts +41 -2
- package/dist/{index-DOE19uln.d.ts → tools-DWFaPtFE.d.ts} +1 -38
- package/package.json +28 -3
|
@@ -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
|