@opengeni/react 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 +141 -0
- package/dist/index.d.ts +897 -0
- package/dist/index.js +3013 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/approvals.ts +86 -0
- package/src/client.ts +52 -0
- package/src/commands/index.ts +17 -0
- package/src/commands/registry.ts +236 -0
- package/src/commands/types.ts +88 -0
- package/src/components/chat-composer.tsx +619 -0
- package/src/components/command-palette.tsx +94 -0
- package/src/components/fleet-tile.tsx +72 -0
- package/src/components/message-timeline.tsx +416 -0
- package/src/components/session-status.tsx +92 -0
- package/src/hooks/internal.ts +236 -0
- package/src/hooks/use-billing-usage.ts +51 -0
- package/src/hooks/use-composer.ts +213 -0
- package/src/hooks/use-environments.ts +118 -0
- package/src/hooks/use-file-attachments.ts +135 -0
- package/src/hooks/use-goal.ts +154 -0
- package/src/hooks/use-packs.ts +101 -0
- package/src/hooks/use-scheduled-tasks.ts +29 -0
- package/src/hooks/use-session-control.ts +85 -0
- package/src/hooks/use-session-events.ts +130 -0
- package/src/hooks/use-session.ts +33 -0
- package/src/hooks/use-slash-commands.ts +366 -0
- package/src/hooks/use-turn-queue.ts +229 -0
- package/src/hooks/use-workspace-sessions.ts +30 -0
- package/src/hooks/use-workspaces.ts +66 -0
- package/src/index.ts +115 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/format.ts +84 -0
- package/src/provider.tsx +57 -0
- package/src/timeline.ts +632 -0
- package/styles/index.css +157 -0
- package/styles/tokens.css +111 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { SessionEvent } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import type { SessionClientLike } from "../client";
|
|
4
|
+
|
|
5
|
+
export type AsyncListState<T> = {
|
|
6
|
+
data: T | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
error: Error | null;
|
|
9
|
+
refresh: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared fetch + optional polling loop for the list/read hooks. Stale
|
|
14
|
+
* responses (superseded by a newer load or an unmount) are dropped.
|
|
15
|
+
*/
|
|
16
|
+
export function usePolledValue<T>(load: () => Promise<T>, options: { pollIntervalMs?: number | undefined; enabled?: boolean | undefined } = {}): AsyncListState<T> {
|
|
17
|
+
const enabled = options.enabled ?? true;
|
|
18
|
+
const pollIntervalMs = options.pollIntervalMs;
|
|
19
|
+
const [data, setData] = useState<T | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(enabled);
|
|
21
|
+
const [error, setError] = useState<Error | null>(null);
|
|
22
|
+
const generation = useRef(0);
|
|
23
|
+
const loadRef = useRef(load);
|
|
24
|
+
|
|
25
|
+
// A new loader identity means a new query (different session/workspace/...):
|
|
26
|
+
// drop the previous result instead of showing it as the new query's data.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (loadRef.current !== load) {
|
|
29
|
+
loadRef.current = load;
|
|
30
|
+
setData(null);
|
|
31
|
+
setError(null);
|
|
32
|
+
}
|
|
33
|
+
}, [load]);
|
|
34
|
+
|
|
35
|
+
const run = useCallback(async () => {
|
|
36
|
+
const ticket = ++generation.current;
|
|
37
|
+
try {
|
|
38
|
+
const result = await load();
|
|
39
|
+
if (ticket === generation.current) {
|
|
40
|
+
setData(result);
|
|
41
|
+
setError(null);
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
} catch (cause) {
|
|
45
|
+
if (ticket === generation.current) {
|
|
46
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}, [load]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!enabled) {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setLoading(true);
|
|
58
|
+
void run();
|
|
59
|
+
if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
|
|
60
|
+
return () => {
|
|
61
|
+
generation.current += 1;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const timer = setInterval(() => void run(), pollIntervalMs);
|
|
65
|
+
return () => {
|
|
66
|
+
clearInterval(timer);
|
|
67
|
+
generation.current += 1;
|
|
68
|
+
};
|
|
69
|
+
}, [run, enabled, pollIntervalMs]);
|
|
70
|
+
|
|
71
|
+
return { data, loading, error, refresh: run };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type MutationState = {
|
|
75
|
+
mutating: boolean;
|
|
76
|
+
mutationError: Error | null;
|
|
77
|
+
clearMutationError: () => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shared async mutation runner for the write hooks. `run` resolves with the
|
|
82
|
+
* operation's value, or `null` after capturing the error in `mutationError`
|
|
83
|
+
* (callers then roll back optimistic state).
|
|
84
|
+
*/
|
|
85
|
+
export function useMutationRunner(): MutationState & {
|
|
86
|
+
run: <T>(operation: () => Promise<T>) => Promise<T | null>;
|
|
87
|
+
} {
|
|
88
|
+
const [mutating, setMutating] = useState(false);
|
|
89
|
+
const [mutationError, setMutationError] = useState<Error | null>(null);
|
|
90
|
+
const inFlight = useRef(0);
|
|
91
|
+
const mounted = useRef(true);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
mounted.current = true;
|
|
94
|
+
return () => {
|
|
95
|
+
mounted.current = false;
|
|
96
|
+
};
|
|
97
|
+
}, []);
|
|
98
|
+
const run = useCallback(async <T,>(operation: () => Promise<T>): Promise<T | null> => {
|
|
99
|
+
inFlight.current += 1;
|
|
100
|
+
if (mounted.current) {
|
|
101
|
+
setMutating(true);
|
|
102
|
+
setMutationError(null);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return await operation();
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
if (mounted.current) {
|
|
108
|
+
setMutationError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
} finally {
|
|
112
|
+
inFlight.current -= 1;
|
|
113
|
+
if (mounted.current && inFlight.current === 0) {
|
|
114
|
+
setMutating(false);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}, []);
|
|
118
|
+
return {
|
|
119
|
+
mutating,
|
|
120
|
+
mutationError,
|
|
121
|
+
clearMutationError: useCallback(() => setMutationError(null), []),
|
|
122
|
+
run,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type SessionEventFeedOptions = {
|
|
127
|
+
/**
|
|
128
|
+
* Share an existing event log (from `useSessionEvents`) instead of opening
|
|
129
|
+
* a second stream. When omitted the hook tails the session's event stream
|
|
130
|
+
* itself, starting at the current `lastSequence`.
|
|
131
|
+
*/
|
|
132
|
+
events?: SessionEvent[] | undefined;
|
|
133
|
+
enabled?: boolean | undefined;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Invoke `onEvent` for every session event matching `match` — the live-update
|
|
138
|
+
* primitive behind `useTurnQueue` and `useGoal`. Either watches a shared
|
|
139
|
+
* `events` log or tails the stream directly (reconnect handled by the SDK).
|
|
140
|
+
*/
|
|
141
|
+
export function useSessionEventTrigger(
|
|
142
|
+
client: SessionClientLike,
|
|
143
|
+
workspaceId: string,
|
|
144
|
+
sessionId: string | null | undefined,
|
|
145
|
+
match: (event: SessionEvent) => boolean,
|
|
146
|
+
onEvent: (event: SessionEvent) => void,
|
|
147
|
+
options: SessionEventFeedOptions = {},
|
|
148
|
+
): void {
|
|
149
|
+
const enabled = options.enabled ?? true;
|
|
150
|
+
const events = options.events;
|
|
151
|
+
const sharedFeed = events !== undefined;
|
|
152
|
+
const matchRef = useRef(match);
|
|
153
|
+
matchRef.current = match;
|
|
154
|
+
const onEventRef = useRef(onEvent);
|
|
155
|
+
onEventRef.current = onEvent;
|
|
156
|
+
const consumedRef = useRef(0);
|
|
157
|
+
const feedKeyRef = useRef<string | null>(null);
|
|
158
|
+
|
|
159
|
+
// Shared-log mode: scan only the unseen tail on every append.
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (!sharedFeed || !enabled || !sessionId) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const feedKey = `${workspaceId}\u0000${sessionId}`;
|
|
165
|
+
const firstSequence = events[0]?.sequence ?? 0;
|
|
166
|
+
// A new session target or a log reset (sequence restarted below the
|
|
167
|
+
// cursor) restarts consumption from the top of the shared log.
|
|
168
|
+
if (feedKeyRef.current !== feedKey || firstSequence > consumedRef.current + 1) {
|
|
169
|
+
feedKeyRef.current = feedKey;
|
|
170
|
+
consumedRef.current = 0;
|
|
171
|
+
}
|
|
172
|
+
for (const event of events) {
|
|
173
|
+
if (event.sequence <= consumedRef.current) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
consumedRef.current = event.sequence;
|
|
177
|
+
if (matchRef.current(event)) {
|
|
178
|
+
onEventRef.current(event);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}, [sharedFeed, enabled, events, workspaceId, sessionId]);
|
|
182
|
+
|
|
183
|
+
// Self-stream mode: tail from the session's current lastSequence.
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (sharedFeed || !enabled || !sessionId) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const controller = new AbortController();
|
|
189
|
+
void (async () => {
|
|
190
|
+
try {
|
|
191
|
+
const session = await client.getSession(workspaceId, sessionId);
|
|
192
|
+
if (controller.signal.aborted) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const stream = client.streamEvents(workspaceId, sessionId, {
|
|
196
|
+
after: session.lastSequence,
|
|
197
|
+
signal: controller.signal,
|
|
198
|
+
});
|
|
199
|
+
for await (const event of stream) {
|
|
200
|
+
if (matchRef.current(event)) {
|
|
201
|
+
onEventRef.current(event);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// Live updates are best-effort: the read hooks still expose refresh()
|
|
206
|
+
// and the initial load already populated state.
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
return () => {
|
|
210
|
+
controller.abort();
|
|
211
|
+
};
|
|
212
|
+
}, [sharedFeed, enabled, client, workspaceId, sessionId]);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Debounce rapid event bursts into one trailing call (default 150ms). */
|
|
216
|
+
export function useDebouncedCallback(callback: () => void, delayMs = 150): () => void {
|
|
217
|
+
const callbackRef = useRef(callback);
|
|
218
|
+
callbackRef.current = callback;
|
|
219
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
return () => {
|
|
222
|
+
if (timerRef.current !== null) {
|
|
223
|
+
clearTimeout(timerRef.current);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}, []);
|
|
227
|
+
return useCallback(() => {
|
|
228
|
+
if (timerRef.current !== null) {
|
|
229
|
+
clearTimeout(timerRef.current);
|
|
230
|
+
}
|
|
231
|
+
timerRef.current = setTimeout(() => {
|
|
232
|
+
timerRef.current = null;
|
|
233
|
+
callbackRef.current();
|
|
234
|
+
}, delayMs);
|
|
235
|
+
}, [delayMs]);
|
|
236
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BillingBalance, UsageEvent } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { useOpenGeniClient, type ClientOverride } from "../provider";
|
|
4
|
+
import { usePolledValue } from "./internal";
|
|
5
|
+
|
|
6
|
+
export type UseBillingUsageOptions = Pick<ClientOverride, "client"> & {
|
|
7
|
+
/** Account to read. Defaults to the caller's default account server-side. */
|
|
8
|
+
accountId?: string | undefined;
|
|
9
|
+
/** Filter usage to one workspace. */
|
|
10
|
+
workspaceId?: string | undefined;
|
|
11
|
+
/** Refresh interval (ms) for live billing meters. Off by default. */
|
|
12
|
+
pollIntervalMs?: number | undefined;
|
|
13
|
+
enabled?: boolean | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type UseBillingUsageResult = {
|
|
17
|
+
/** Prepaid credit balance (micro-USD), null until loaded. */
|
|
18
|
+
balance: BillingBalance | null;
|
|
19
|
+
/** Recent usage events (runs, tokens, cost, uploads, ...). */
|
|
20
|
+
usage: UsageEvent[];
|
|
21
|
+
loading: boolean;
|
|
22
|
+
error: Error | null;
|
|
23
|
+
refresh: () => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Account billing usage: credit balance + recent usage events — the data
|
|
28
|
+
* behind per-call billing meters. Account-scoped, so it only needs the
|
|
29
|
+
* client; pass `workspaceId` to narrow usage to one workspace.
|
|
30
|
+
*/
|
|
31
|
+
export function useBillingUsage(options: UseBillingUsageOptions = {}): UseBillingUsageResult {
|
|
32
|
+
const client = useOpenGeniClient(options);
|
|
33
|
+
const accountId = options.accountId;
|
|
34
|
+
const workspaceId = options.workspaceId;
|
|
35
|
+
const load = useCallback(
|
|
36
|
+
async () =>
|
|
37
|
+
await client.getBillingUsage({
|
|
38
|
+
...(accountId !== undefined ? { accountId } : {}),
|
|
39
|
+
...(workspaceId !== undefined ? { workspaceId } : {}),
|
|
40
|
+
}),
|
|
41
|
+
[client, accountId, workspaceId],
|
|
42
|
+
);
|
|
43
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
44
|
+
return {
|
|
45
|
+
balance: state.data?.balance ?? null,
|
|
46
|
+
usage: state.data?.usage ?? [],
|
|
47
|
+
loading: state.loading,
|
|
48
|
+
error: state.error,
|
|
49
|
+
refresh: state.refresh,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { SendMessageInput } from "@opengeni/sdk";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
|
|
5
|
+
export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compose-time delivery choice. `queue` (the default) stacks the message
|
|
9
|
+
* behind the running turn — visible, editable, reorderable until claimed.
|
|
10
|
+
* `steer` interrupts the running turn and injects the message now.
|
|
11
|
+
*/
|
|
12
|
+
export type ComposerMode = "queue" | "steer";
|
|
13
|
+
|
|
14
|
+
export type UseComposerOptions = ClientOverride & {
|
|
15
|
+
/** Called with the accepted text after a successful send. */
|
|
16
|
+
onSent?: ((text: string) => void) | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Extra message fields (resources, tools, model, reasoningEffort) merged
|
|
19
|
+
* into every send. A function is evaluated at send time so it can read the
|
|
20
|
+
* surrounding UI state (attachment pickers, model selectors, ...).
|
|
21
|
+
*/
|
|
22
|
+
sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
|
|
23
|
+
/** Initial delivery mode. Defaults to `"queue"`. */
|
|
24
|
+
defaultMode?: ComposerMode | undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ComposerState = {
|
|
28
|
+
value: string;
|
|
29
|
+
setValue: (value: string) => void;
|
|
30
|
+
/** Send the draft (or an explicit text) using the current mode. */
|
|
31
|
+
send: (text?: string) => Promise<boolean>;
|
|
32
|
+
sending: boolean;
|
|
33
|
+
canSend: boolean;
|
|
34
|
+
/** Queue (default) vs steer — the compose-time delivery choice. */
|
|
35
|
+
mode: ComposerMode;
|
|
36
|
+
setMode: (mode: ComposerMode) => void;
|
|
37
|
+
/** Ask the agent to stop the current turn. */
|
|
38
|
+
interrupt: (reason?: string) => Promise<void>;
|
|
39
|
+
interrupting: boolean;
|
|
40
|
+
error: Error | null;
|
|
41
|
+
clearError: () => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Draft + send + interrupt state for the chat composer — the only
|
|
46
|
+
* human-to-agent input surface. The draft survives a failed send (nothing is
|
|
47
|
+
* more hostile than losing a typed message); each send carries a generated
|
|
48
|
+
* `clientEventId` so retries stay idempotent server-side.
|
|
49
|
+
*/
|
|
50
|
+
export function useComposer(sessionId: string | null | undefined, options: UseComposerOptions = {}): ComposerState {
|
|
51
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
52
|
+
const defaultMode = options.defaultMode ?? "queue";
|
|
53
|
+
const [value, setValue] = useState("");
|
|
54
|
+
const [mode, setMode] = useState<ComposerMode>(defaultMode);
|
|
55
|
+
const [sending, setSending] = useState(false);
|
|
56
|
+
const [interrupting, setInterrupting] = useState(false);
|
|
57
|
+
const [error, setError] = useState<Error | null>(null);
|
|
58
|
+
const pendingClientEventId = useRef<string | null>(null);
|
|
59
|
+
const onSent = options.onSent;
|
|
60
|
+
// Read at send time so an in-flight send uses the mode chosen at submit.
|
|
61
|
+
const modeRef = useRef(mode);
|
|
62
|
+
modeRef.current = mode;
|
|
63
|
+
// Read through a ref so a new extras closure (created every render by
|
|
64
|
+
// callers passing inline functions) does not invalidate `send`.
|
|
65
|
+
const sendExtrasRef = useRef(options.sendExtras);
|
|
66
|
+
sendExtrasRef.current = options.sendExtras;
|
|
67
|
+
|
|
68
|
+
// A composer is bound to one session: switching targets must not leak the
|
|
69
|
+
// previous session's draft, error, or retry idempotency key.
|
|
70
|
+
const targetKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
71
|
+
const targetKeyRef = useRef(targetKey);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (targetKeyRef.current !== targetKey) {
|
|
74
|
+
targetKeyRef.current = targetKey;
|
|
75
|
+
pendingClientEventId.current = null;
|
|
76
|
+
setValue("");
|
|
77
|
+
setError(null);
|
|
78
|
+
setMode(defaultMode);
|
|
79
|
+
}
|
|
80
|
+
}, [targetKey, defaultMode]);
|
|
81
|
+
|
|
82
|
+
const send = useCallback(
|
|
83
|
+
async (explicit?: string): Promise<boolean> => {
|
|
84
|
+
const draftAtSend = value;
|
|
85
|
+
const text = (explicit ?? draftAtSend).trim();
|
|
86
|
+
// Resolve the extras once: a file-only message (empty text + ≥1 ready
|
|
87
|
+
// resource) is legitimate, so we must not bail on empty text alone.
|
|
88
|
+
const extras = resolveSendExtras(sendExtrasRef.current);
|
|
89
|
+
const hasResources = (extras.resources?.length ?? 0) > 0;
|
|
90
|
+
if ((!text && !hasResources) || !sessionId || sending) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
// Reuse the clientEventId across retries of the same draft so a
|
|
94
|
+
// timeout + resend cannot double-deliver the message.
|
|
95
|
+
pendingClientEventId.current ??= generateClientEventId();
|
|
96
|
+
setSending(true);
|
|
97
|
+
setError(null);
|
|
98
|
+
try {
|
|
99
|
+
// The wire contract requires non-empty text (z.string().min(1)) and the
|
|
100
|
+
// worker rejects whitespace-only text; a file-only message therefore
|
|
101
|
+
// carries a minimal default so the attachments still get delivered.
|
|
102
|
+
const sendText = text || FILE_ONLY_MESSAGE_TEXT;
|
|
103
|
+
const input = composeSendInput(sendText, pendingClientEventId.current, extras);
|
|
104
|
+
if (modeRef.current === "steer") {
|
|
105
|
+
await client.steerMessage(workspaceId, sessionId, input);
|
|
106
|
+
} else {
|
|
107
|
+
await client.sendMessage(workspaceId, sessionId, input);
|
|
108
|
+
}
|
|
109
|
+
pendingClientEventId.current = null;
|
|
110
|
+
if (explicit === undefined) {
|
|
111
|
+
// Clear only the draft that was sent: edits made while the request
|
|
112
|
+
// was in flight were never delivered and must survive.
|
|
113
|
+
setValue((current) => (current === draftAtSend ? "" : current));
|
|
114
|
+
}
|
|
115
|
+
onSent?.(sendText);
|
|
116
|
+
return true;
|
|
117
|
+
} catch (cause) {
|
|
118
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
119
|
+
return false;
|
|
120
|
+
} finally {
|
|
121
|
+
setSending(false);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[client, workspaceId, sessionId, value, sending, onSent],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// A send is possible with non-empty text OR with ≥1 attached resource (a
|
|
128
|
+
// file-only message). Resources ride in `sendExtras`, so we resolve them here
|
|
129
|
+
// — keeping useComposer attachment-agnostic while still lighting up the send
|
|
130
|
+
// affordance the moment a file is ready. ChatComposer additionally gates this
|
|
131
|
+
// on its `attachments.uploading` flag so a message never departs mid-upload.
|
|
132
|
+
const hasReadyResources = (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
|
|
133
|
+
|
|
134
|
+
const interrupt = useCallback(
|
|
135
|
+
async (reason?: string): Promise<void> => {
|
|
136
|
+
if (!sessionId || interrupting) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
setInterrupting(true);
|
|
140
|
+
setError(null);
|
|
141
|
+
try {
|
|
142
|
+
await client.interrupt(workspaceId, sessionId, reason !== undefined ? { reason } : {});
|
|
143
|
+
} catch (cause) {
|
|
144
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
145
|
+
} finally {
|
|
146
|
+
setInterrupting(false);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[client, workspaceId, sessionId, interrupting],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const updateValue = useCallback((next: string) => {
|
|
153
|
+
pendingClientEventId.current = null;
|
|
154
|
+
setValue(next);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
value,
|
|
159
|
+
setValue: updateValue,
|
|
160
|
+
send,
|
|
161
|
+
sending,
|
|
162
|
+
canSend: Boolean(sessionId) && !sending && (value.trim().length > 0 || hasReadyResources),
|
|
163
|
+
mode,
|
|
164
|
+
setMode,
|
|
165
|
+
interrupt,
|
|
166
|
+
interrupting,
|
|
167
|
+
error,
|
|
168
|
+
clearError: useCallback(() => setError(null), []),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Default text for a file-only message (attachment(s) present, no typed draft).
|
|
174
|
+
* Kept non-empty so the wire contract (`text: z.string().min(1)`) and the
|
|
175
|
+
* worker's non-whitespace guard accept it; the attached files still ride in
|
|
176
|
+
* `resources`. Exported for tests.
|
|
177
|
+
*/
|
|
178
|
+
export const FILE_ONLY_MESSAGE_TEXT = "(see attached files)";
|
|
179
|
+
|
|
180
|
+
/** Resolve possibly-deferred extras to a concrete bag (function evaluated now). */
|
|
181
|
+
export function resolveSendExtras(
|
|
182
|
+
extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
|
|
183
|
+
): ComposerSendExtras {
|
|
184
|
+
return (typeof extras === "function" ? extras() : extras) ?? {};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Merge the draft text + idempotency key with caller-provided extras. The
|
|
189
|
+
* text and clientEventId always win over extras. Exported for tests.
|
|
190
|
+
*/
|
|
191
|
+
export function composeSendInput(
|
|
192
|
+
text: string,
|
|
193
|
+
clientEventId: string,
|
|
194
|
+
extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined,
|
|
195
|
+
): SendMessageInput {
|
|
196
|
+
return { ...resolveSendExtras(extras), text, clientEventId };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */
|
|
200
|
+
export function shouldSubmitOnKey(event: { key: string; shiftKey: boolean; nativeEvent?: { isComposing?: boolean } }): boolean {
|
|
201
|
+
if (event.key !== "Enter" || event.shiftKey) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return event.nativeEvent?.isComposing !== true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function generateClientEventId(): string {
|
|
208
|
+
const cryptoApi = globalThis.crypto;
|
|
209
|
+
if (cryptoApi && "randomUUID" in cryptoApi) {
|
|
210
|
+
return cryptoApi.randomUUID();
|
|
211
|
+
}
|
|
212
|
+
return `ce-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateWorkspaceEnvironmentRequest,
|
|
3
|
+
UpdateWorkspaceEnvironmentRequest,
|
|
4
|
+
WorkspaceEnvironment,
|
|
5
|
+
WorkspaceEnvironmentVariableMetadata,
|
|
6
|
+
} from "@opengeni/sdk";
|
|
7
|
+
import { useCallback } from "react";
|
|
8
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
9
|
+
import { useMutationRunner, usePolledValue } from "./internal";
|
|
10
|
+
|
|
11
|
+
export type UseEnvironmentsOptions = ClientOverride & {
|
|
12
|
+
pollIntervalMs?: number | undefined;
|
|
13
|
+
enabled?: boolean | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type UseEnvironmentsResult = {
|
|
17
|
+
environments: WorkspaceEnvironment[];
|
|
18
|
+
loading: boolean;
|
|
19
|
+
error: Error | null;
|
|
20
|
+
refresh: () => Promise<void>;
|
|
21
|
+
create: (request: CreateWorkspaceEnvironmentRequest) => Promise<WorkspaceEnvironment | null>;
|
|
22
|
+
update: (environmentId: string, request: UpdateWorkspaceEnvironmentRequest) => Promise<WorkspaceEnvironment | null>;
|
|
23
|
+
remove: (environmentId: string) => Promise<boolean>;
|
|
24
|
+
/** Set/rotate a variable. Values are write-only — reads expose metadata only. */
|
|
25
|
+
setVariable: (environmentId: string, name: string, value: string) => Promise<WorkspaceEnvironmentVariableMetadata | null>;
|
|
26
|
+
deleteVariable: (environmentId: string, name: string) => Promise<boolean>;
|
|
27
|
+
mutating: boolean;
|
|
28
|
+
mutationError: Error | null;
|
|
29
|
+
clearMutationError: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Workspace environments (named, encrypted variable sets attached to sessions
|
|
34
|
+
* and scheduled tasks). Variable values are write-only end to end: this hook
|
|
35
|
+
* never sees a value after it is sent.
|
|
36
|
+
*/
|
|
37
|
+
export function useEnvironments(options: UseEnvironmentsOptions = {}): UseEnvironmentsResult {
|
|
38
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
39
|
+
const load = useCallback(async () => await client.listEnvironments(workspaceId), [client, workspaceId]);
|
|
40
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
41
|
+
const mutation = useMutationRunner();
|
|
42
|
+
|
|
43
|
+
const create = useCallback(
|
|
44
|
+
async (request: CreateWorkspaceEnvironmentRequest): Promise<WorkspaceEnvironment | null> => {
|
|
45
|
+
const result = await mutation.run(() => client.createEnvironment(workspaceId, request));
|
|
46
|
+
if (result) {
|
|
47
|
+
await state.refresh();
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
},
|
|
51
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const update = useCallback(
|
|
55
|
+
async (environmentId: string, request: UpdateWorkspaceEnvironmentRequest): Promise<WorkspaceEnvironment | null> => {
|
|
56
|
+
const result = await mutation.run(() => client.updateEnvironment(workspaceId, environmentId, request));
|
|
57
|
+
if (result) {
|
|
58
|
+
await state.refresh();
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
},
|
|
62
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const remove = useCallback(
|
|
66
|
+
async (environmentId: string): Promise<boolean> => {
|
|
67
|
+
const result = await mutation.run(async () => {
|
|
68
|
+
await client.deleteEnvironment(workspaceId, environmentId);
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
if (result) {
|
|
72
|
+
await state.refresh();
|
|
73
|
+
}
|
|
74
|
+
return result === true;
|
|
75
|
+
},
|
|
76
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const setVariable = useCallback(
|
|
80
|
+
async (environmentId: string, name: string, value: string): Promise<WorkspaceEnvironmentVariableMetadata | null> => {
|
|
81
|
+
const result = await mutation.run(() => client.setEnvironmentVariable(workspaceId, environmentId, name, value));
|
|
82
|
+
if (result) {
|
|
83
|
+
await state.refresh();
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
},
|
|
87
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const deleteVariable = useCallback(
|
|
91
|
+
async (environmentId: string, name: string): Promise<boolean> => {
|
|
92
|
+
const result = await mutation.run(async () => {
|
|
93
|
+
await client.deleteEnvironmentVariable(workspaceId, environmentId, name);
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
if (result) {
|
|
97
|
+
await state.refresh();
|
|
98
|
+
}
|
|
99
|
+
return result === true;
|
|
100
|
+
},
|
|
101
|
+
[client, workspaceId, mutation.run, state.refresh],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
environments: state.data ?? [],
|
|
106
|
+
loading: state.loading,
|
|
107
|
+
error: state.error,
|
|
108
|
+
refresh: state.refresh,
|
|
109
|
+
create,
|
|
110
|
+
update,
|
|
111
|
+
remove,
|
|
112
|
+
setVariable,
|
|
113
|
+
deleteVariable,
|
|
114
|
+
mutating: mutation.mutating,
|
|
115
|
+
mutationError: mutation.mutationError,
|
|
116
|
+
clearMutationError: mutation.clearMutationError,
|
|
117
|
+
};
|
|
118
|
+
}
|