@novoagents/react 0.1.0-alpha.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/LICENSE +19 -0
- package/README.md +31 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/useNovoInteractions.d.ts +48 -0
- package/dist/useNovoInteractions.js +68 -0
- package/dist/useNovoRun.d.ts +41 -0
- package/dist/useNovoRun.js +107 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Robert Pinckney. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are
|
|
4
|
+
proprietary and confidential. No license, express or implied, is granted
|
|
5
|
+
to any party to use, copy, modify, merge, publish, distribute, sublicense,
|
|
6
|
+
or sell copies of the Software without the prior written permission of
|
|
7
|
+
the copyright holder.
|
|
8
|
+
|
|
9
|
+
Unauthorized use, reproduction, or distribution of the Software, or any
|
|
10
|
+
portion of it, is strictly prohibited and may result in civil and criminal
|
|
11
|
+
penalties.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
16
|
+
THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
17
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|
18
|
+
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @novoagents/react
|
|
2
|
+
|
|
3
|
+
Headless React hooks for rendering Novo Agents runs in the browser. The hooks
|
|
4
|
+
manage state and render nothing — bring your own components. This package
|
|
5
|
+
never accepts an API key: it talks to **your** server routes, which proxy to
|
|
6
|
+
`api.novoagents.ai` with the server-only `novoagents` SDK. Its only runtime
|
|
7
|
+
dependency is the browser-safe `novoagents/browser` entry point.
|
|
8
|
+
|
|
9
|
+
## Hooks
|
|
10
|
+
|
|
11
|
+
- `useNovoRun({ transport })` — consumes the run stream through your proxy
|
|
12
|
+
routes and returns live UI state: `messages`, `status`,
|
|
13
|
+
`pendingInteractions`, `usage`, and `resume()`. One component instance keeps
|
|
14
|
+
exactly one store and one stream consumption, so an inline `transport`
|
|
15
|
+
object literal is safe across re-renders; pass a new `runKey` to
|
|
16
|
+
intentionally reset. Reconnects until the `data-novo-terminal` chunk (a
|
|
17
|
+
closed connection is not a finished run), with jittered exponential backoff
|
|
18
|
+
between unproductive reconnects.
|
|
19
|
+
- `useNovoInteractions({ endpoint, run })` — sources pending
|
|
20
|
+
approvals/questions from the same stream state, POSTs the batch resolution
|
|
21
|
+
shape to your proxied resolve route, and calls the run's `resume()` after a
|
|
22
|
+
successful resolution so the parked stream picks back up from the last seen
|
|
23
|
+
event id.
|
|
24
|
+
|
|
25
|
+
See the full guide with proxy routes and a working component:
|
|
26
|
+
[Build a React run UI](https://novoagents.ai/docs/guides/build-a-react-run-ui).
|
|
27
|
+
|
|
28
|
+
## Publishing
|
|
29
|
+
|
|
30
|
+
Publish with `pnpm publish` (not `npm publish`): the `workspace:*` dependency
|
|
31
|
+
on `novoagents` is rewritten to the real version only by pnpm's pack step.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { createNovoRunStore, useNovoRun, type NovoRunStore, type UseNovoRunOptions, type UseNovoRunResult, } from './useNovoRun.js';
|
|
2
|
+
export { resolveNovoInteractions, useNovoInteractions, type NovoInteractionResolutionItem, type NovoInteractionResolutionOutcome, type NovoInteractionRunSource, type NovoPendingInteraction, } from './useNovoInteractions.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NovoUiPendingInteraction } from 'novoagents/browser';
|
|
2
|
+
export type NovoPendingInteraction = {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: 'approval_request' | 'question';
|
|
5
|
+
status: 'pending' | 'resuming';
|
|
6
|
+
} & Record<string, unknown>;
|
|
7
|
+
export type NovoInteractionResolutionItem = {
|
|
8
|
+
id: string;
|
|
9
|
+
inputResponses?: Array<Record<string, unknown>>;
|
|
10
|
+
cancel?: boolean;
|
|
11
|
+
reason?: string;
|
|
12
|
+
};
|
|
13
|
+
export type NovoInteractionResolutionOutcome = {
|
|
14
|
+
id: string;
|
|
15
|
+
status: 'resolved' | 'already_resolved' | 'conflict' | 'not_found' | 'invalid_body';
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The slice of a `useNovoRun` result (or `NovoRunStore`-shaped object) that
|
|
19
|
+
* interactions can be sourced from: the reducer's pending queue plus the
|
|
20
|
+
* resume path invoked after a successful resolution.
|
|
21
|
+
*/
|
|
22
|
+
export type NovoInteractionRunSource = {
|
|
23
|
+
pendingInteractions: readonly NovoUiPendingInteraction[];
|
|
24
|
+
resume?: () => Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
export declare function resolveNovoInteractions(input: {
|
|
27
|
+
endpoint: string;
|
|
28
|
+
items: NovoInteractionResolutionItem[];
|
|
29
|
+
fetch?: typeof globalThis.fetch;
|
|
30
|
+
}): Promise<NovoInteractionResolutionOutcome[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Track and resolve the run's pending approvals/questions through your proxied
|
|
33
|
+
* batch resolve route. Zero-config source: pass the `useNovoRun` result as
|
|
34
|
+
* `run` and pending interactions come from the stream reducer, with the run's
|
|
35
|
+
* `resume()` invoked automatically once a resolution lands. An explicit
|
|
36
|
+
* `interactions` array (e.g. from your own discovery endpoint) overrides the
|
|
37
|
+
* run source.
|
|
38
|
+
*/
|
|
39
|
+
export declare function useNovoInteractions(input: {
|
|
40
|
+
endpoint: string;
|
|
41
|
+
run?: NovoInteractionRunSource;
|
|
42
|
+
interactions?: NovoPendingInteraction[];
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
}): {
|
|
45
|
+
interactions: NovoPendingInteraction[];
|
|
46
|
+
resolve(items: NovoInteractionResolutionItem[]): Promise<NovoInteractionResolutionOutcome[]>;
|
|
47
|
+
error: Error | undefined;
|
|
48
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
export async function resolveNovoInteractions(input) {
|
|
3
|
+
const request = input.fetch ?? globalThis.fetch;
|
|
4
|
+
const response = await request(input.endpoint, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7
|
+
body: JSON.stringify({ items: input.items }),
|
|
8
|
+
});
|
|
9
|
+
const body = (await response.json());
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
throw new Error(body.error?.message ?? `Interaction resolve failed (${response.status}).`);
|
|
12
|
+
}
|
|
13
|
+
return body.items ?? [];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Track and resolve the run's pending approvals/questions through your proxied
|
|
17
|
+
* batch resolve route. Zero-config source: pass the `useNovoRun` result as
|
|
18
|
+
* `run` and pending interactions come from the stream reducer, with the run's
|
|
19
|
+
* `resume()` invoked automatically once a resolution lands. An explicit
|
|
20
|
+
* `interactions` array (e.g. from your own discovery endpoint) overrides the
|
|
21
|
+
* run source.
|
|
22
|
+
*/
|
|
23
|
+
export function useNovoInteractions(input) {
|
|
24
|
+
const [resolvedIds, setResolvedIds] = useState(() => new Set());
|
|
25
|
+
const [error, setError] = useState();
|
|
26
|
+
const resume = input.run?.resume;
|
|
27
|
+
const resolve = useCallback(async (items) => {
|
|
28
|
+
try {
|
|
29
|
+
const outcomes = await resolveNovoInteractions({
|
|
30
|
+
endpoint: input.endpoint,
|
|
31
|
+
items,
|
|
32
|
+
...(input.fetch ? { fetch: input.fetch } : {}),
|
|
33
|
+
});
|
|
34
|
+
const resolved = new Set(outcomes
|
|
35
|
+
.filter((item) => item.status === 'resolved' ||
|
|
36
|
+
item.status === 'already_resolved')
|
|
37
|
+
.map((item) => item.id));
|
|
38
|
+
if (resolved.size > 0) {
|
|
39
|
+
setResolvedIds((current) => new Set([...current, ...resolved]));
|
|
40
|
+
// The run resumed (or will resume) server-side; restart stream
|
|
41
|
+
// consumption so the resolved chunk and subsequent output arrive.
|
|
42
|
+
void resume?.();
|
|
43
|
+
}
|
|
44
|
+
setError(undefined);
|
|
45
|
+
return outcomes;
|
|
46
|
+
}
|
|
47
|
+
catch (caught) {
|
|
48
|
+
const next = caught instanceof Error
|
|
49
|
+
? caught
|
|
50
|
+
: new Error('Interaction resolve failed.');
|
|
51
|
+
setError(next);
|
|
52
|
+
throw next;
|
|
53
|
+
}
|
|
54
|
+
}, [input.endpoint, input.fetch, resume]);
|
|
55
|
+
const sourced = input.interactions ??
|
|
56
|
+
(input.run?.pendingInteractions ?? []).map((interaction) => ({
|
|
57
|
+
...interaction,
|
|
58
|
+
kind: interaction.request.kind,
|
|
59
|
+
status: 'pending',
|
|
60
|
+
}));
|
|
61
|
+
return {
|
|
62
|
+
// resolvedIds bridges the gap between the resolve acknowledgement and the
|
|
63
|
+
// stream's own data-novo-input-resolved chunk removing the entry.
|
|
64
|
+
interactions: sourced.filter((interaction) => !resolvedIds.has(interaction.id)),
|
|
65
|
+
resolve,
|
|
66
|
+
error,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type BrowserRunTransport, type NovoAgentEvent, type NovoRunUiState } from 'novoagents/browser';
|
|
2
|
+
export type NovoRunStore = {
|
|
3
|
+
subscribe(listener: () => void): () => void;
|
|
4
|
+
getSnapshot(): NovoRunUiState;
|
|
5
|
+
/**
|
|
6
|
+
* Begin consuming the run stream. Idempotent: repeat calls return the
|
|
7
|
+
* in-flight (or last) consumption instead of starting another.
|
|
8
|
+
*/
|
|
9
|
+
start(): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Restart consumption from the last seen event id. No-op while consumption
|
|
12
|
+
* is active or after the terminal chunk; the restart path exists because a
|
|
13
|
+
* pause (`waiting_for_approval` / `waiting_for_input`) ends consumption —
|
|
14
|
+
* call this after resolving interactions so the resumed run streams again.
|
|
15
|
+
* Also retries after a stream failure.
|
|
16
|
+
*/
|
|
17
|
+
resume(): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export declare function createNovoRunStore(transport: BrowserRunTransport<NovoAgentEvent> | (() => BrowserRunTransport<NovoAgentEvent>)): NovoRunStore;
|
|
20
|
+
export type UseNovoRunOptions = {
|
|
21
|
+
transport: BrowserRunTransport<NovoAgentEvent>;
|
|
22
|
+
autoStart?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Escape hatch for intentional resets: a new key discards the store and
|
|
25
|
+
* consumes the stream from scratch. The abandoned consumption is not
|
|
26
|
+
* cancelled — it stops at the run's next pause or terminal.
|
|
27
|
+
*/
|
|
28
|
+
runKey?: string | number;
|
|
29
|
+
};
|
|
30
|
+
export type UseNovoRunResult = NovoRunUiState & {
|
|
31
|
+
/** See {@link NovoRunStore.resume}. */
|
|
32
|
+
resume(): Promise<void>;
|
|
33
|
+
store: NovoRunStore;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Consume a run stream through your proxy transport and expose the reduced UI
|
|
37
|
+
* state. One store (one stream consumption) per component instance: passing an
|
|
38
|
+
* inline `transport` object literal is safe across re-renders — the latest
|
|
39
|
+
* transport is read through a ref without re-creating the store.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useNovoRun(input: UseNovoRunOptions): UseNovoRunResult;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
2
|
+
import { consumeRunWithReconnect, createNovoRunUiReducer, } from 'novoagents/browser';
|
|
3
|
+
export function createNovoRunStore(transport) {
|
|
4
|
+
const currentTransport = typeof transport === 'function' ? transport : () => transport;
|
|
5
|
+
const reducer = createNovoRunUiReducer();
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
let state = reducer.getState();
|
|
8
|
+
let lastEventId;
|
|
9
|
+
let active;
|
|
10
|
+
let latest;
|
|
11
|
+
let reachedTerminal = false;
|
|
12
|
+
const publish = (next) => {
|
|
13
|
+
state = next;
|
|
14
|
+
for (const listener of listeners)
|
|
15
|
+
listener();
|
|
16
|
+
};
|
|
17
|
+
const consume = () => {
|
|
18
|
+
const promise = consumeRunWithReconnect({
|
|
19
|
+
// Read the transport per call so the latest closure is always used.
|
|
20
|
+
transport: {
|
|
21
|
+
connect: (fromEventId) => currentTransport().connect(fromEventId),
|
|
22
|
+
pollRun: () => currentTransport().pollRun(),
|
|
23
|
+
},
|
|
24
|
+
...(lastEventId !== undefined ? { initialLastEventId: lastEventId } : {}),
|
|
25
|
+
isTerminalEvent: (event) => event.type === 'data-novo-terminal',
|
|
26
|
+
onFrame: ({ id, event }) => {
|
|
27
|
+
if (id !== undefined)
|
|
28
|
+
lastEventId = id;
|
|
29
|
+
if (event)
|
|
30
|
+
publish(reducer.reduce(event));
|
|
31
|
+
},
|
|
32
|
+
onPoll: (run) => {
|
|
33
|
+
if (run.status === 'waiting_for_approval' ||
|
|
34
|
+
run.status === 'waiting_for_input') {
|
|
35
|
+
publish({ ...state, status: run.status });
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.then((result) => {
|
|
40
|
+
if (result.reachedTerminal)
|
|
41
|
+
reachedTerminal = true;
|
|
42
|
+
}, (error) => {
|
|
43
|
+
publish({
|
|
44
|
+
...state,
|
|
45
|
+
status: 'failed',
|
|
46
|
+
error: {
|
|
47
|
+
type: 'api_error',
|
|
48
|
+
code: 'stream_error',
|
|
49
|
+
message: error instanceof Error ? error.message : 'Stream failed.',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
.then(() => {
|
|
54
|
+
if (active === promise)
|
|
55
|
+
active = undefined;
|
|
56
|
+
});
|
|
57
|
+
active = promise;
|
|
58
|
+
latest = promise;
|
|
59
|
+
return promise;
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
subscribe(listener) {
|
|
63
|
+
listeners.add(listener);
|
|
64
|
+
return () => listeners.delete(listener);
|
|
65
|
+
},
|
|
66
|
+
getSnapshot: () => state,
|
|
67
|
+
start() {
|
|
68
|
+
return latest ?? consume();
|
|
69
|
+
},
|
|
70
|
+
resume() {
|
|
71
|
+
if (active)
|
|
72
|
+
return active;
|
|
73
|
+
if (reachedTerminal)
|
|
74
|
+
return latest ?? Promise.resolve();
|
|
75
|
+
return consume();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Consume a run stream through your proxy transport and expose the reduced UI
|
|
81
|
+
* state. One store (one stream consumption) per component instance: passing an
|
|
82
|
+
* inline `transport` object literal is safe across re-renders — the latest
|
|
83
|
+
* transport is read through a ref without re-creating the store.
|
|
84
|
+
*/
|
|
85
|
+
export function useNovoRun(input) {
|
|
86
|
+
const transportRef = useRef(input.transport);
|
|
87
|
+
const [entry, setEntry] = useState(() => ({
|
|
88
|
+
key: input.runKey,
|
|
89
|
+
store: createNovoRunStore(() => transportRef.current),
|
|
90
|
+
}));
|
|
91
|
+
if (!Object.is(entry.key, input.runKey)) {
|
|
92
|
+
setEntry({
|
|
93
|
+
key: input.runKey,
|
|
94
|
+
store: createNovoRunStore(() => transportRef.current),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const store = entry.store;
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
transportRef.current = input.transport;
|
|
100
|
+
});
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (input.autoStart !== false)
|
|
103
|
+
void store.start();
|
|
104
|
+
}, [input.autoStart, store]);
|
|
105
|
+
const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
|
|
106
|
+
return useMemo(() => ({ ...state, resume: store.resume, store }), [state, store]);
|
|
107
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@novoagents/react",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Browser-safe React hooks for Novo Agents streams and interactions.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**/*.js",
|
|
11
|
+
"dist/**/*.d.ts",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"novoagents": "0.3.0-alpha.51"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "19.2.17",
|
|
31
|
+
"@types/react-dom": "19.2.3",
|
|
32
|
+
"happy-dom": "20.0.11",
|
|
33
|
+
"react": "19.2.7",
|
|
34
|
+
"react-dom": "19.2.7",
|
|
35
|
+
"typescript": "6.0.3",
|
|
36
|
+
"vite": "8.1.4",
|
|
37
|
+
"vitest": "5.0.0-beta.6"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"clean": "rm -rf dist",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|