@opengeni/react 1.3.0 → 1.4.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 +21 -1
- package/dist/artifacts.js +2 -2
- package/dist/artifacts.js.map +1 -1
- package/dist/{chunk-ERGYJIBB.js → chunk-AWZLIMKX.js} +2 -2
- package/dist/{chunk-ERGYJIBB.js.map → chunk-AWZLIMKX.js.map} +1 -1
- package/dist/{chunk-FZCP527Y.js → chunk-CJUO7J5Z.js} +2 -2
- package/dist/{chunk-742CZ3L3.js → chunk-H5X55XH7.js} +16 -200
- package/dist/chunk-H5X55XH7.js.map +1 -0
- package/dist/{chunk-PGSX7UCP.js → chunk-LJ2UTJJE.js} +1369 -785
- package/dist/chunk-LJ2UTJJE.js.map +1 -0
- package/dist/{chunk-CY7WK5FD.js → chunk-LN6TCXD3.js} +22 -1
- package/dist/chunk-LN6TCXD3.js.map +1 -0
- package/dist/{chunk-TMYGL6X5.js → chunk-MBMPHOMA.js} +71 -6
- package/dist/chunk-MBMPHOMA.js.map +1 -0
- package/dist/{chunk-RC6BZ44Q.js → chunk-OA6HFRQP.js} +2 -2
- package/dist/{chunk-WWEUO2PL.js → chunk-WZMYFJD3.js} +190 -11
- package/dist/chunk-WZMYFJD3.js.map +1 -0
- package/dist/components/composer-transcription-control.d.ts +3 -0
- package/dist/components/open-geni-chat.d.ts +6 -2
- package/dist/components/session-conversation.d.ts +25 -19
- package/dist/components/session-list.d.ts +4 -1
- package/dist/components/tool-action-review.d.ts +29 -2
- package/dist/components/tool-review-history.d.ts +8 -4
- package/dist/composer.js +1 -1
- package/dist/connect.js +1 -1
- package/dist/hooks/use-client-config-flags.d.ts +37 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +21 -11
- package/dist/index.js.map +1 -1
- package/dist/{latest-question-YTOBHCWR.js → latest-question-GPYM2JNU.js} +3 -3
- package/dist/realtime/realtime-control.d.ts +2 -0
- package/dist/realtime.js +25 -4
- package/dist/realtime.js.map +1 -1
- package/dist/session-capability-policy.d.ts +20 -1
- package/dist/session-ui.js +6 -6
- package/dist/session.js +6 -6
- package/dist/timeline/index.d.ts +2 -0
- package/dist/timeline/retained-loaders.d.ts +27 -0
- package/package.json +3 -3
- package/src/components/approval-surface.tsx +190 -79
- package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
- package/src/components/composer-transcription-control.tsx +14 -0
- package/src/components/composer.tsx +1 -0
- package/src/components/open-geni-chat.tsx +53 -13
- package/src/components/session-conversation.tsx +229 -63
- package/src/components/session-list.tsx +9 -2
- package/src/components/tool-action-review.tsx +502 -239
- package/src/components/tool-review-history.tsx +18 -43
- package/src/hooks/use-client-config-flags.ts +102 -0
- package/src/hooks/use-session-events.ts +19 -16
- package/src/hooks/use-transcription.ts +6 -0
- package/src/hooks/use-voice-input.ts +6 -0
- package/src/index.ts +9 -0
- package/src/realtime/realtime-control.tsx +28 -1
- package/src/session-capability-policy.ts +119 -6
- package/src/timeline/index.ts +10 -0
- package/src/timeline/retained-loaders.ts +86 -0
- package/src/timeline/tool-display-name.ts +5 -1
- package/src/timeline/tool-renderers.tsx +143 -10
- package/styles/compiled.css +65 -21
- package/styles/tokens.css +1 -1
- package/dist/chunk-742CZ3L3.js.map +0 -1
- package/dist/chunk-CY7WK5FD.js.map +0 -1
- package/dist/chunk-PGSX7UCP.js.map +0 -1
- package/dist/chunk-TMYGL6X5.js.map +0 -1
- package/dist/chunk-WWEUO2PL.js.map +0 -1
- /package/dist/{chunk-FZCP527Y.js.map → chunk-CJUO7J5Z.js.map} +0 -0
- /package/dist/{chunk-RC6BZ44Q.js.map → chunk-OA6HFRQP.js.map} +0 -0
- /package/dist/{latest-question-YTOBHCWR.js.map → latest-question-GPYM2JNU.js.map} +0 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
2
|
import type { SessionEvent, ToolActionReview } from "@opengeni/sdk";
|
|
3
3
|
import { approvalsFromRequiresAction } from "../approvals";
|
|
4
|
-
import { ToolActionReviewCard } from "./tool-action-review";
|
|
5
4
|
|
|
6
5
|
type HistoryContext = {
|
|
7
6
|
approvalIds: ReadonlySet<string>;
|
|
@@ -55,59 +54,35 @@ export function useHasToolReview(approvalId: string | null): boolean {
|
|
|
55
54
|
return Boolean(approvalId && history?.approvalIds.has(approvalId));
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The saved review for one recorded call, or null while it loads or when it is
|
|
59
|
+
* unavailable (callers then keep their ordinary tool row).
|
|
60
|
+
*/
|
|
61
|
+
export function useRecordedToolReview(approvalId: string | null): {
|
|
62
|
+
review: ToolActionReview | null;
|
|
63
|
+
onViewDetails: ((path: string) => void) | undefined;
|
|
64
|
+
} {
|
|
60
65
|
const history = useContext(History);
|
|
61
66
|
const [review, setReview] = useState<ToolActionReview | null>(null);
|
|
62
|
-
const [failed, setFailed] = useState(false);
|
|
63
|
-
const [retry, setRetry] = useState(0);
|
|
64
67
|
const load = history?.load;
|
|
65
68
|
const revision = history?.revision;
|
|
66
69
|
useEffect(() => {
|
|
67
|
-
if (!load) return;
|
|
70
|
+
if (!load || !approvalId) return;
|
|
68
71
|
let active = true;
|
|
69
|
-
setFailed(false);
|
|
70
72
|
void load(approvalId).then(
|
|
71
73
|
(value) => {
|
|
72
|
-
if (active)
|
|
73
|
-
if (value.id === approvalId) setReview(value);
|
|
74
|
-
else setFailed(true);
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
() => {
|
|
78
|
-
if (active) setFailed(true);
|
|
74
|
+
if (active && value.id === approvalId) setReview(value);
|
|
79
75
|
},
|
|
76
|
+
() => undefined,
|
|
80
77
|
);
|
|
81
78
|
return () => {
|
|
82
79
|
active = false;
|
|
83
80
|
};
|
|
84
|
-
}, [approvalId, load, revision
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
type="button"
|
|
92
|
-
className="min-h-11 underline"
|
|
93
|
-
onClick={() => setRetry((value) => value + 1)}
|
|
94
|
-
>
|
|
95
|
-
Try again
|
|
96
|
-
</button>
|
|
97
|
-
</p>
|
|
98
|
-
);
|
|
99
|
-
if (!review)
|
|
100
|
-
return (
|
|
101
|
-
<p role="status" className="text-og-sm text-og-fg-muted">
|
|
102
|
-
Loading reviewed action…
|
|
103
|
-
</p>
|
|
104
|
-
);
|
|
105
|
-
return (
|
|
106
|
-
<div data-approval-id={approvalId} data-review-origin="history">
|
|
107
|
-
<ToolActionReviewCard
|
|
108
|
-
review={{ ...review, availableActions: [] }}
|
|
109
|
-
onViewDetails={(path) => history.onViewDetails(review, path)}
|
|
110
|
-
/>
|
|
111
|
-
</div>
|
|
112
|
-
);
|
|
81
|
+
}, [approvalId, load, revision]);
|
|
82
|
+
const onViewDetails = history?.onViewDetails;
|
|
83
|
+
return {
|
|
84
|
+
review: review && review.id === approvalId ? review : null,
|
|
85
|
+
onViewDetails:
|
|
86
|
+
review && onViewDetails ? (path: string) => onViewDetails(review, path) : undefined,
|
|
87
|
+
};
|
|
113
88
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ClientVoiceInputConfig, ReasoningEffort } from "@opengeni/sdk";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/** The client-config fields the stock conversation and chat read. */
|
|
5
|
+
export type ClientConfigFlagsSource = {
|
|
6
|
+
getClientConfig: () => Promise<{
|
|
7
|
+
fileUploads?: { enabled?: boolean };
|
|
8
|
+
modelSelection?: boolean | undefined;
|
|
9
|
+
sandboxFiles?: boolean | undefined;
|
|
10
|
+
defaultReasoningEffort?: ReasoningEffort | undefined;
|
|
11
|
+
artifacts?: unknown;
|
|
12
|
+
sessionCreation?: boolean | undefined;
|
|
13
|
+
archive?: boolean | undefined;
|
|
14
|
+
voiceInput?: ClientVoiceInputConfig | undefined;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ClientConfigFlags = {
|
|
19
|
+
/** Whether the config has loaded. Proxy capability flags stay `true` until it says otherwise. */
|
|
20
|
+
loaded: boolean;
|
|
21
|
+
uploads: boolean;
|
|
22
|
+
modelSelection: boolean;
|
|
23
|
+
sandboxFiles: boolean;
|
|
24
|
+
defaultReasoningEffort: ReasoningEffort | null;
|
|
25
|
+
/**
|
|
26
|
+
* Inline Site previews and the artifact viewer. `false` only when a session
|
|
27
|
+
* proxy says it does not serve them; native deployments omit the flag.
|
|
28
|
+
*/
|
|
29
|
+
artifacts: boolean;
|
|
30
|
+
/** Browser chat creation. `false` only when a session proxy has no `createSession` hook. */
|
|
31
|
+
sessionCreation: boolean;
|
|
32
|
+
/** Chat archive/restore. `false` only when a session proxy disables it. */
|
|
33
|
+
archive: boolean;
|
|
34
|
+
/** Native voice input when the deployment can transcribe, else null. */
|
|
35
|
+
voiceInput: ClientVoiceInputConfig | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const INITIAL_FLAGS: ClientConfigFlags = {
|
|
39
|
+
loaded: false,
|
|
40
|
+
uploads: false,
|
|
41
|
+
modelSelection: false,
|
|
42
|
+
sandboxFiles: false,
|
|
43
|
+
defaultReasoningEffort: null,
|
|
44
|
+
artifacts: true,
|
|
45
|
+
sessionCreation: true,
|
|
46
|
+
archive: true,
|
|
47
|
+
voiceInput: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ClientConfigFlagsInput = Awaited<ReturnType<ClientConfigFlagsSource["getClientConfig"]>>;
|
|
51
|
+
|
|
52
|
+
// One read per client: the chat, its list, the new-chat composer and the
|
|
53
|
+
// conversation all ask, and behind a session proxy each read is a round trip.
|
|
54
|
+
const configReads = new WeakMap<object, Promise<ClientConfigFlagsInput>>();
|
|
55
|
+
|
|
56
|
+
function readClientConfig(client: ClientConfigFlagsSource): Promise<ClientConfigFlagsInput> {
|
|
57
|
+
let read = configReads.get(client);
|
|
58
|
+
if (!read) {
|
|
59
|
+
read = client.getClientConfig();
|
|
60
|
+
configReads.set(client, read);
|
|
61
|
+
// A failed read is retried by the next mount rather than cached.
|
|
62
|
+
read.catch(() => {
|
|
63
|
+
if (configReads.get(client) === read) configReads.delete(client);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return read;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Deployment and session-proxy capabilities from the client config. */
|
|
70
|
+
export function useClientConfigFlags(
|
|
71
|
+
client: Partial<ClientConfigFlagsSource> | ClientConfigFlagsSource,
|
|
72
|
+
): ClientConfigFlags {
|
|
73
|
+
const [flags, setFlags] = useState<ClientConfigFlags>(INITIAL_FLAGS);
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
// Custom clients without a config read keep the defaults.
|
|
76
|
+
if (typeof client.getClientConfig !== "function") return;
|
|
77
|
+
let live = true;
|
|
78
|
+
readClientConfig(client as ClientConfigFlagsSource).then(
|
|
79
|
+
(config) => {
|
|
80
|
+
if (live) {
|
|
81
|
+
setFlags({
|
|
82
|
+
loaded: true,
|
|
83
|
+
uploads: config.fileUploads?.enabled === true,
|
|
84
|
+
// Only an explicit offer shows end users the picker.
|
|
85
|
+
modelSelection: config.modelSelection === true,
|
|
86
|
+
sandboxFiles: config.sandboxFiles !== false,
|
|
87
|
+
defaultReasoningEffort: config.defaultReasoningEffort ?? null,
|
|
88
|
+
artifacts: config.artifacts !== false,
|
|
89
|
+
sessionCreation: config.sessionCreation !== false,
|
|
90
|
+
archive: config.archive !== false,
|
|
91
|
+
voiceInput: config.voiceInput?.available === true ? config.voiceInput : null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
() => undefined,
|
|
96
|
+
);
|
|
97
|
+
return () => {
|
|
98
|
+
live = false;
|
|
99
|
+
};
|
|
100
|
+
}, [client]);
|
|
101
|
+
return flags;
|
|
102
|
+
}
|
|
@@ -225,8 +225,8 @@ export function useSessionEvents(
|
|
|
225
225
|
// new stream identity cannot expose the previous session's event log.
|
|
226
226
|
const [stateStreamKey, setStateStreamKey] = useState(streamKey);
|
|
227
227
|
|
|
228
|
-
//
|
|
229
|
-
//
|
|
228
|
+
// Navigation belongs to the session/client lifetime, not the transport: an
|
|
229
|
+
// SSE reconnect must never cancel an in-flight history page.
|
|
230
230
|
useEffect(() => {
|
|
231
231
|
navigationGenerationRef.current += 1;
|
|
232
232
|
setNewerError(null);
|
|
@@ -621,9 +621,6 @@ export function useSessionEvents(
|
|
|
621
621
|
}
|
|
622
622
|
const current = eventWindowRef.current;
|
|
623
623
|
assertPrependOrder(current.events, window.events);
|
|
624
|
-
// Freeze the live iterator before replacing its in-memory window. Rows
|
|
625
|
-
// pending in the aborted iterator were never cursor-committed and will
|
|
626
|
-
// be replayed from the retained high-water mark below.
|
|
627
624
|
const next = boundBrowserSessionEventWindow([...window.events, ...current.events], {
|
|
628
625
|
direction: "oldest",
|
|
629
626
|
});
|
|
@@ -633,7 +630,6 @@ export function useSessionEvents(
|
|
|
633
630
|
markTailPreserved();
|
|
634
631
|
return false;
|
|
635
632
|
}
|
|
636
|
-
streamAbortRef.current?.abort();
|
|
637
633
|
const status = observeSessionStatus(window.events, sessionStatusRef);
|
|
638
634
|
const retained = {
|
|
639
635
|
...next,
|
|
@@ -645,21 +641,28 @@ export function useSessionEvents(
|
|
|
645
641
|
}
|
|
646
642
|
const retainedNewest = maxResumeSequenceOrNull(retained.events);
|
|
647
643
|
const previousNewest = maxResumeSequenceOrNull(current.events);
|
|
644
|
+
const evictedLiveTail =
|
|
645
|
+
previousNewest !== null && retainedNewest !== null && retainedNewest < previousNewest;
|
|
646
|
+
if (evictedLiveTail && viewModeRef.current === "live") {
|
|
647
|
+
// The prepend leaves the live tip. Freeze the iterator before the
|
|
648
|
+
// window is replaced so a late flush cannot append live rows onto
|
|
649
|
+
// (and newest-bound away) the history the reader just requested.
|
|
650
|
+
streamAbortRef.current?.abort();
|
|
651
|
+
}
|
|
648
652
|
eventWindowRef.current = retained;
|
|
649
653
|
markCommitted();
|
|
650
654
|
oldestSequenceRef.current = retainedOldest;
|
|
651
655
|
newestSequenceRef.current = retainedNewest;
|
|
652
|
-
|
|
656
|
+
if (evictedLiveTail) {
|
|
657
|
+
streamResumeSequenceRef.current = maxResumeSequence(retained.events);
|
|
658
|
+
}
|
|
653
659
|
// Oldest-directed eviction can discard newer in-memory rows. That fact
|
|
654
660
|
// keeps windowTruncated true, but it does not imply older durable rows
|
|
655
661
|
// exist; only the backward DB page can answer hasOlder truthfully.
|
|
656
662
|
const olderStillAvailable = window.hasOlder;
|
|
657
663
|
hasOlderRef.current = olderStillAvailable;
|
|
658
|
-
const evictedLiveTail =
|
|
659
|
-
previousNewest !== null && retainedNewest !== null && retainedNewest < previousNewest;
|
|
660
664
|
let newer: boolean | null = null;
|
|
661
665
|
let enterHistory = false;
|
|
662
|
-
let reconnectLive = false;
|
|
663
666
|
if (viewModeRef.current === "history" || evictedLiveTail) {
|
|
664
667
|
const highWater = lastSequenceRef.current;
|
|
665
668
|
newer = retainedNewest !== null && (retainedNewest < highWater || retained.truncated);
|
|
@@ -672,11 +675,13 @@ export function useSessionEvents(
|
|
|
672
675
|
viewModeRef.current = "history";
|
|
673
676
|
enterHistory = true;
|
|
674
677
|
}
|
|
675
|
-
} else {
|
|
676
|
-
// The retained merge is still one contiguous live suffix. Restart
|
|
677
|
-
// SSE from its cursor so events queued during the fetch are replayed.
|
|
678
|
-
reconnectLive = true;
|
|
679
678
|
}
|
|
679
|
+
// Otherwise the merge is still one contiguous live suffix and the open
|
|
680
|
+
// SSE iterator keeps appending to it: every flush reads
|
|
681
|
+
// eventWindowRef, so rows delivered during or after this fetch land
|
|
682
|
+
// once, in order, on the prepended window. Reopening the stream here
|
|
683
|
+
// would flash a connecting state and re-run session reconciliation on
|
|
684
|
+
// every scroll-up for no data benefit.
|
|
680
685
|
loadingOlderRef.current = false;
|
|
681
686
|
startTransition(() => {
|
|
682
687
|
if (status !== undefined) {
|
|
@@ -689,8 +694,6 @@ export function useSessionEvents(
|
|
|
689
694
|
}
|
|
690
695
|
if (enterHistory) {
|
|
691
696
|
setViewMode("history");
|
|
692
|
-
} else if (reconnectLive) {
|
|
693
|
-
setStreamEpoch((epoch) => epoch + 1);
|
|
694
697
|
}
|
|
695
698
|
setLoadingOlder(false);
|
|
696
699
|
});
|
|
@@ -611,6 +611,12 @@ function classifyStartError(error: unknown): TranscriptionErrorCode {
|
|
|
611
611
|
|
|
612
612
|
export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErrorCode {
|
|
613
613
|
switch (code) {
|
|
614
|
+
case "insufficient_credits":
|
|
615
|
+
case "allowance_exhausted":
|
|
616
|
+
case "monthly_model_cost_limit":
|
|
617
|
+
case "unavailable":
|
|
618
|
+
case "too_large":
|
|
619
|
+
case "invalid_audio":
|
|
614
620
|
case "permission_denied":
|
|
615
621
|
case "not_supported":
|
|
616
622
|
case "network":
|
|
@@ -1503,6 +1503,12 @@ function errorCode(error: unknown): string {
|
|
|
1503
1503
|
}
|
|
1504
1504
|
|
|
1505
1505
|
function isRetryableVoiceInputError(error: unknown): boolean {
|
|
1506
|
+
if (
|
|
1507
|
+
["insufficient_credits", "allowance_exhausted", "monthly_model_cost_limit"].includes(
|
|
1508
|
+
errorCode(error),
|
|
1509
|
+
)
|
|
1510
|
+
)
|
|
1511
|
+
return false;
|
|
1506
1512
|
if (error instanceof OpenGeniApiError) {
|
|
1507
1513
|
return error.retryable && error.status !== 409;
|
|
1508
1514
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ export {
|
|
|
16
16
|
export {
|
|
17
17
|
attachSessionCapability,
|
|
18
18
|
completeSessionCapabilityOAuth,
|
|
19
|
+
prepareSessionCapabilityAccess,
|
|
20
|
+
applySessionCapabilityAccess,
|
|
21
|
+
type SessionCapabilityAccessPlan,
|
|
19
22
|
} from "./session-capability-policy";
|
|
20
23
|
export { retainedImageId } from "./components/markdown";
|
|
21
24
|
// oxlint-disable-next-line typescript/triple-slash-reference -- package consumers must load the ambient type for this optional untyped peer without emitting a runtime import.
|
|
@@ -388,14 +391,20 @@ export type {
|
|
|
388
391
|
// Tool-renderer registry + the per-tool renderers (the timeline's extension API)
|
|
389
392
|
export {
|
|
390
393
|
createDefaultToolRegistry,
|
|
394
|
+
createSessionRetainedScreenshotLoader,
|
|
391
395
|
createToolRegistry,
|
|
396
|
+
createWorkspaceRetainedArtifactLoader,
|
|
397
|
+
createWorkspaceRetainedVideoLoader,
|
|
392
398
|
defaultToolRegistry,
|
|
393
399
|
rawTypeOf,
|
|
394
400
|
} from "./timeline";
|
|
395
401
|
export type {
|
|
396
402
|
CreateToolRegistryOptions,
|
|
397
403
|
RetainedArtifactLoader,
|
|
404
|
+
RetainedArtifactLoaderClient,
|
|
398
405
|
RetainedScreenshotLoader,
|
|
406
|
+
RetainedScreenshotLoaderClient,
|
|
407
|
+
RetainedVideoLoaderClient,
|
|
399
408
|
VideoArtifactPlaybackLoader,
|
|
400
409
|
ToolRegistry,
|
|
401
410
|
ToolRegistryEntry,
|
|
@@ -63,9 +63,26 @@ export type RealtimeModelOption = {
|
|
|
63
63
|
description: string;
|
|
64
64
|
available: boolean;
|
|
65
65
|
unavailableReason: string | null;
|
|
66
|
+
/** Machine-readable reason, e.g. `insufficient_credits` (same codes as voice input). */
|
|
67
|
+
unavailableCode?: string | null | undefined;
|
|
66
68
|
recommended: boolean;
|
|
67
69
|
};
|
|
68
70
|
|
|
71
|
+
/** Short status label for a credit/availability refusal (shared with the model menu). */
|
|
72
|
+
function realtimeRefusalLabel(code: string | null | undefined): string | null {
|
|
73
|
+
switch (code) {
|
|
74
|
+
case "insufficient_credits":
|
|
75
|
+
return "Out of credits";
|
|
76
|
+
case "allowance_exhausted":
|
|
77
|
+
case "monthly_model_cost_limit":
|
|
78
|
+
return "Usage limit reached";
|
|
79
|
+
case "realtime_voice_unavailable":
|
|
80
|
+
return "Voice unavailable";
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
const CODEX_LIVE_MODEL: RealtimeModelOption = {
|
|
70
87
|
id: "gpt-live-1-boulder-alpha",
|
|
71
88
|
label: "Codex Live",
|
|
@@ -822,6 +839,7 @@ export function RealtimeVoiceControl(props: {
|
|
|
822
839
|
props.canStart,
|
|
823
840
|
props.admissionBlocker ?? null,
|
|
824
841
|
selectedModel.label,
|
|
842
|
+
selectedModel.unavailableCode ?? null,
|
|
825
843
|
);
|
|
826
844
|
const modeOwned = props.snapshot.mode?.state === "active";
|
|
827
845
|
const retryConnection =
|
|
@@ -1333,6 +1351,7 @@ function statusContent(
|
|
|
1333
1351
|
canStart: boolean,
|
|
1334
1352
|
admissionBlocker: string | null,
|
|
1335
1353
|
modelLabel: string,
|
|
1354
|
+
unavailableCode: string | null = null,
|
|
1336
1355
|
): { phase: RealtimeVisualPhase; label: string; detail: string } {
|
|
1337
1356
|
if (snapshot.audibleOutput === "blocked" && !snapshot.outputMuted) {
|
|
1338
1357
|
return {
|
|
@@ -1379,6 +1398,13 @@ function statusContent(
|
|
|
1379
1398
|
detail: "Return to the browser that started it, or wait for that connection to expire.",
|
|
1380
1399
|
};
|
|
1381
1400
|
case "error":
|
|
1401
|
+
if (snapshot.refusal) {
|
|
1402
|
+
return {
|
|
1403
|
+
phase: "unavailable",
|
|
1404
|
+
label: realtimeRefusalLabel(snapshot.refusal.code) ?? "Voice unavailable",
|
|
1405
|
+
detail: snapshot.refusal.message,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1382
1408
|
return {
|
|
1383
1409
|
phase: "error",
|
|
1384
1410
|
label: "Voice unavailable",
|
|
@@ -1388,7 +1414,7 @@ function statusContent(
|
|
|
1388
1414
|
if (!modelAvailable) {
|
|
1389
1415
|
return {
|
|
1390
1416
|
phase: "unavailable",
|
|
1391
|
-
label: "Voice model unavailable",
|
|
1417
|
+
label: realtimeRefusalLabel(unavailableCode) ?? "Voice model unavailable",
|
|
1392
1418
|
detail: admissionBlocker ?? "Choose another voice model.",
|
|
1393
1419
|
};
|
|
1394
1420
|
}
|
|
@@ -1580,6 +1606,7 @@ function writeRealtimeModelPreference(workspaceId: string, model: SessionRealtim
|
|
|
1580
1606
|
|
|
1581
1607
|
function isRealtimeModel(value: string | null): value is SessionRealtimeModel {
|
|
1582
1608
|
return (
|
|
1609
|
+
value === "opengeni-azure/gpt-live-1" ||
|
|
1583
1610
|
value === "gpt-live-1-boulder-alpha" ||
|
|
1584
1611
|
value === "opengeni-gateway/openai/gpt-realtime-2.1" ||
|
|
1585
1612
|
value === "opengeni-gateway/openai/gpt-realtime-mini" ||
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
FirstPartyMcpToolName,
|
|
3
|
+
CapabilityCatalogItem,
|
|
4
|
+
Session,
|
|
5
|
+
UpdateSessionToolPolicyRequest,
|
|
6
|
+
} from "@opengeni/sdk";
|
|
2
7
|
import type { OpenGeniBrowserClient as OpenGeniClient } from "@opengeni/sdk/browser";
|
|
3
8
|
|
|
4
9
|
/** Catalog tool discovery may be lazy; selecting the installed server does not
|
|
@@ -25,6 +30,16 @@ export async function attachSessionCapability(
|
|
|
25
30
|
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
26
31
|
const session = await client.getSession(workspaceId, sessionId);
|
|
27
32
|
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
33
|
+
const request = capabilityToolPolicyUpdate(session, item);
|
|
34
|
+
if (request) await client.updateSessionToolPolicy(workspaceId, sessionId, request);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function capabilityToolPolicyUpdate(
|
|
38
|
+
session: Session,
|
|
39
|
+
item: CapabilityCatalogItem,
|
|
40
|
+
): UpdateSessionToolPolicyRequest | null {
|
|
41
|
+
const tools = sessionCapabilityTools(item);
|
|
42
|
+
if (item.kind === "skill" || tools.length === 0) return null;
|
|
28
43
|
// Session.tools includes mandatory runtime servers, which must never be
|
|
29
44
|
// echoed into the human's explicit selection.
|
|
30
45
|
let existing = session.tools.filter(
|
|
@@ -42,10 +57,19 @@ export async function attachSessionCapability(
|
|
|
42
57
|
// automatic selection. Compare against the resolved defaults instead.
|
|
43
58
|
existing = policy.effectiveIds
|
|
44
59
|
.filter((id) => !policy.mandatoryIds.includes(id))
|
|
45
|
-
.map(
|
|
60
|
+
.map(
|
|
61
|
+
(id) =>
|
|
62
|
+
session.tools.find((tool) => tool.kind === "mcp" && tool.id === id) ?? {
|
|
63
|
+
kind: "mcp" as const,
|
|
64
|
+
id,
|
|
65
|
+
optional: true,
|
|
66
|
+
},
|
|
67
|
+
);
|
|
46
68
|
}
|
|
47
69
|
const additions = tools.filter(
|
|
48
|
-
(tool) =>
|
|
70
|
+
(tool) =>
|
|
71
|
+
!session.effectiveToolPolicy?.mandatoryIds.includes(tool.id) &&
|
|
72
|
+
!existing.some((current) => current.kind === tool.kind && current.id === tool.id),
|
|
49
73
|
);
|
|
50
74
|
const recommended = Array.isArray(item.metadata?.firstPartyMcpTools)
|
|
51
75
|
? (item.metadata.firstPartyMcpTools.filter(
|
|
@@ -53,13 +77,102 @@ export async function attachSessionCapability(
|
|
|
53
77
|
) as FirstPartyMcpToolName[])
|
|
54
78
|
: [];
|
|
55
79
|
const firstParty = [...new Set([...session.firstPartyMcpTools, ...recommended])];
|
|
56
|
-
if (additions.length === 0 && firstParty.length === session.firstPartyMcpTools.length)
|
|
57
|
-
|
|
80
|
+
if (additions.length === 0 && firstParty.length === session.firstPartyMcpTools.length)
|
|
81
|
+
return null;
|
|
82
|
+
return {
|
|
58
83
|
mode: "explicit",
|
|
59
84
|
tools: [...existing, ...additions],
|
|
60
85
|
firstPartyMcpTools: firstParty,
|
|
61
86
|
expectedVersion: session.toolPolicyVersion,
|
|
62
|
-
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read-only review. Hosts must show ancestor changes and obtain a separate
|
|
91
|
+
* explicit click before applying. This plan is never authorization. */
|
|
92
|
+
export type SessionCapabilityAccessPlan = {
|
|
93
|
+
workspaceId: string;
|
|
94
|
+
sessionId: string;
|
|
95
|
+
capabilityId: string;
|
|
96
|
+
sessions: Array<{
|
|
97
|
+
id: string;
|
|
98
|
+
title: string | null;
|
|
99
|
+
parentSessionId: string | null;
|
|
100
|
+
toolPolicyVersion: number;
|
|
101
|
+
request: UpdateSessionToolPolicyRequest | null;
|
|
102
|
+
}>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export async function prepareSessionCapabilityAccess(
|
|
106
|
+
client: OpenGeniClient,
|
|
107
|
+
workspaceId: string,
|
|
108
|
+
sessionId: string,
|
|
109
|
+
item: CapabilityCatalogItem,
|
|
110
|
+
stillCurrent: () => boolean = () => true,
|
|
111
|
+
): Promise<SessionCapabilityAccessPlan> {
|
|
112
|
+
const sessions: SessionCapabilityAccessPlan["sessions"] = [];
|
|
113
|
+
if (item.kind === "skill" || sessionCapabilityTools(item).length === 0)
|
|
114
|
+
return { workspaceId, sessionId, capabilityId: item.id, sessions };
|
|
115
|
+
const visited = new Set<string>();
|
|
116
|
+
let id: string | null = sessionId;
|
|
117
|
+
while (id) {
|
|
118
|
+
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
119
|
+
if (visited.has(id)) throw new Error("The parent chat chain could not be verified.");
|
|
120
|
+
visited.add(id);
|
|
121
|
+
const session = await client.getSession(workspaceId, id);
|
|
122
|
+
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
123
|
+
const request = capabilityToolPolicyUpdate(session, item);
|
|
124
|
+
sessions.push({
|
|
125
|
+
id,
|
|
126
|
+
title: session.title ?? null,
|
|
127
|
+
parentSessionId: session.parentSessionId ?? null,
|
|
128
|
+
toolPolicyVersion: session.toolPolicyVersion,
|
|
129
|
+
request,
|
|
130
|
+
});
|
|
131
|
+
// An unchanged immediate parent already supplies this capability's ceiling.
|
|
132
|
+
// Do not read or broaden ancestors irrelevant to this addition.
|
|
133
|
+
if (!request) break;
|
|
134
|
+
id = session.parentSessionId ?? null;
|
|
135
|
+
}
|
|
136
|
+
return { workspaceId, sessionId, capabilityId: item.id, sessions: sessions.reverse() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Preflight the entire review, then apply ordinary CAS-fenced API writes root
|
|
140
|
+
* first. A partial failure may leave approved ancestor changes committed; never
|
|
141
|
+
* claim success or roll back another person's edits. Prepare a new review. */
|
|
142
|
+
export async function applySessionCapabilityAccess(
|
|
143
|
+
client: OpenGeniClient,
|
|
144
|
+
plan: SessionCapabilityAccessPlan,
|
|
145
|
+
stillCurrent: () => boolean = () => true,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
for (const reviewed of plan.sessions) {
|
|
148
|
+
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
149
|
+
const current = await client.getSession(plan.workspaceId, reviewed.id);
|
|
150
|
+
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
151
|
+
if (
|
|
152
|
+
current.toolPolicyVersion !== reviewed.toolPolicyVersion ||
|
|
153
|
+
(current.parentSessionId ?? null) !== reviewed.parentSessionId
|
|
154
|
+
)
|
|
155
|
+
throw new Error("Chat access changed. Click Add tools to review the current choices again.");
|
|
156
|
+
}
|
|
157
|
+
for (const reviewed of plan.sessions) {
|
|
158
|
+
if (!stillCurrent()) throw new Error("Connection setup was interrupted.");
|
|
159
|
+
if (reviewed.request) {
|
|
160
|
+
const updated = await client.updateSessionToolPolicy(
|
|
161
|
+
plan.workspaceId,
|
|
162
|
+
reviewed.id,
|
|
163
|
+
reviewed.request,
|
|
164
|
+
);
|
|
165
|
+
if (
|
|
166
|
+
reviewed.request.mode === "explicit" &&
|
|
167
|
+
reviewed.request.firstPartyMcpTools.some(
|
|
168
|
+
(tool) => !updated.firstPartyMcpTools.includes(tool),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
throw new Error(
|
|
172
|
+
"This chat's capabilities do not allow these tools. Review its capability settings before adding tools again.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
63
176
|
}
|
|
64
177
|
|
|
65
178
|
/** Native OAuth callbacks have already saved the provider connection. Finish
|
package/src/timeline/index.ts
CHANGED
|
@@ -61,6 +61,16 @@ export type {
|
|
|
61
61
|
|
|
62
62
|
// renderer registry
|
|
63
63
|
export { createToolRegistry, rawTypeOf } from "./registry";
|
|
64
|
+
export {
|
|
65
|
+
createSessionRetainedScreenshotLoader,
|
|
66
|
+
createWorkspaceRetainedArtifactLoader,
|
|
67
|
+
createWorkspaceRetainedVideoLoader,
|
|
68
|
+
} from "./retained-loaders";
|
|
69
|
+
export type {
|
|
70
|
+
RetainedArtifactLoaderClient,
|
|
71
|
+
RetainedScreenshotLoaderClient,
|
|
72
|
+
RetainedVideoLoaderClient,
|
|
73
|
+
} from "./retained-loaders";
|
|
64
74
|
export type {
|
|
65
75
|
CreateToolRegistryOptions,
|
|
66
76
|
RetainedArtifactLoader,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { OpenGeniClient, RetainedArtifactReference } from "@opengeni/sdk";
|
|
2
|
+
import type {
|
|
3
|
+
RetainedArtifactLoader,
|
|
4
|
+
RetainedScreenshotLoader,
|
|
5
|
+
VideoArtifactPlaybackLoader,
|
|
6
|
+
} from "./registry";
|
|
7
|
+
|
|
8
|
+
/** SDK methods the workspace retained-artifact loader needs. */
|
|
9
|
+
export type RetainedArtifactLoaderClient = Pick<
|
|
10
|
+
OpenGeniClient,
|
|
11
|
+
"createRetainedArtifactDownloadUrl" | "downloadRetainedArtifact"
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
/** SDK method the session retained-screenshot loader needs. */
|
|
15
|
+
export type RetainedScreenshotLoaderClient = Pick<OpenGeniClient, "downloadRetainedScreenshot">;
|
|
16
|
+
|
|
17
|
+
/** SDK method the generated-video playback loader needs. */
|
|
18
|
+
export type RetainedVideoLoaderClient = Pick<OpenGeniClient, "createVideoArtifactPlaybackSource">;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Bind permanent retained-artifact retrieval (generated images, published
|
|
22
|
+
* files) to one authenticated workspace, for `MessageTimeline`'s
|
|
23
|
+
* `loadRetainedArtifact`. Images get a short-lived URL; files are assembled
|
|
24
|
+
* from verified byte ranges.
|
|
25
|
+
*/
|
|
26
|
+
export function createWorkspaceRetainedArtifactLoader(
|
|
27
|
+
client: RetainedArtifactLoaderClient,
|
|
28
|
+
workspaceId: string,
|
|
29
|
+
): RetainedArtifactLoader {
|
|
30
|
+
return async (artifact: RetainedArtifactReference, signal: AbortSignal) => {
|
|
31
|
+
if (artifact.kind === "file") {
|
|
32
|
+
const download = await client.downloadRetainedArtifact(workspaceId, artifact, { signal });
|
|
33
|
+
return download.bytes;
|
|
34
|
+
}
|
|
35
|
+
const download = await client.createRetainedArtifactDownloadUrl(workspaceId, artifact, {
|
|
36
|
+
signal,
|
|
37
|
+
});
|
|
38
|
+
return { url: download.url };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Bind the authenticated screenshot downloader to one workspace and session,
|
|
44
|
+
* for `MessageTimeline`'s `loadRetainedScreenshot`. Fetched metadata must match
|
|
45
|
+
* the timeline receipt before bytes are handed to React; errors deliberately
|
|
46
|
+
* contain no storage locator or content.
|
|
47
|
+
*/
|
|
48
|
+
export function createSessionRetainedScreenshotLoader(
|
|
49
|
+
client: RetainedScreenshotLoaderClient,
|
|
50
|
+
workspaceId: string,
|
|
51
|
+
sessionId: string,
|
|
52
|
+
): RetainedScreenshotLoader {
|
|
53
|
+
return async (artifact: RetainedArtifactReference, signal: AbortSignal) => {
|
|
54
|
+
const download = await client.downloadRetainedScreenshot(
|
|
55
|
+
workspaceId,
|
|
56
|
+
sessionId,
|
|
57
|
+
artifact.artifactId,
|
|
58
|
+
{ signal },
|
|
59
|
+
);
|
|
60
|
+
if (!download.metadata.available || !download.bytes) return null;
|
|
61
|
+
if (
|
|
62
|
+
download.metadata.artifactId !== artifact.artifactId ||
|
|
63
|
+
download.metadata.kind !== artifact.kind ||
|
|
64
|
+
download.metadata.contentType !== artifact.contentType ||
|
|
65
|
+
download.metadata.originalBytes !== artifact.originalBytes ||
|
|
66
|
+
download.metadata.sha256 !== artifact.sha256 ||
|
|
67
|
+
download.metadata.dimensions?.width !== artifact.dimensions?.width ||
|
|
68
|
+
download.metadata.dimensions?.height !== artifact.dimensions?.height
|
|
69
|
+
) {
|
|
70
|
+
throw new Error("Retained screenshot receipt verification failed");
|
|
71
|
+
}
|
|
72
|
+
return download.bytes;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Bind expiring generated-video playback sources to one authenticated
|
|
78
|
+
* workspace, for `MessageTimeline`'s `loadVideoArtifactPlayback`.
|
|
79
|
+
*/
|
|
80
|
+
export function createWorkspaceRetainedVideoLoader(
|
|
81
|
+
client: RetainedVideoLoaderClient,
|
|
82
|
+
workspaceId: string,
|
|
83
|
+
): VideoArtifactPlaybackLoader {
|
|
84
|
+
return async (artifactId: string, signal: AbortSignal) =>
|
|
85
|
+
await client.createVideoArtifactPlaybackSource(workspaceId, artifactId, { signal });
|
|
86
|
+
}
|
|
@@ -30,7 +30,11 @@ export function toolMatchesLeaf(wireName: string, leaf: string): boolean {
|
|
|
30
30
|
*/
|
|
31
31
|
export function toolDisplayName(name: string, display?: ToolDisplayMetadata): string {
|
|
32
32
|
if (!display && /^[a-f0-9]{64}$/.test(name)) return "Tool call";
|
|
33
|
-
|
|
33
|
+
// "batch modify messages" → "Modify messages": batching is transport, not the action.
|
|
34
|
+
const phrase = (display?.toolName ?? mcpToolLeaf(name))
|
|
35
|
+
.replace(/[_-]+/g, " ")
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/^batch\s+(?=\S)/i, "");
|
|
34
38
|
const title =
|
|
35
39
|
display?.title ?? (phrase ? phrase.charAt(0).toUpperCase() + phrase.slice(1) : name);
|
|
36
40
|
return display?.accountLabel ? `${title} — ${display.accountLabel}` : title;
|