@nextclaw/ncp-react 0.1.0 → 0.3.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/dist/index.d.ts CHANGED
@@ -1,14 +1,35 @@
1
- import { NcpAgentClientEndpoint, NcpAgentConversationSnapshot, NcpMessage } from '@nextclaw/ncp';
1
+ import { NcpAgentConversationSnapshot, NcpMessage, NcpRequestEnvelope, NcpAgentClientEndpoint } from '@nextclaw/ncp';
2
2
 
3
- declare function useNcpAgent(sessionId: string, client: NcpAgentClientEndpoint): {
3
+ type NcpAgentSendInput = string | NcpRequestEnvelope;
4
+ type UseNcpAgentResult = {
4
5
  snapshot: NcpAgentConversationSnapshot;
5
6
  visibleMessages: readonly NcpMessage[];
6
7
  activeRunId: string | null;
7
8
  isRunning: boolean;
8
9
  isSending: boolean;
9
- send: (content: string) => Promise<void>;
10
+ send: (input: NcpAgentSendInput) => Promise<void>;
10
11
  abort: () => Promise<void>;
11
12
  streamRun: () => Promise<void>;
12
13
  };
13
14
 
14
- export { useNcpAgent };
15
+ type NcpConversationSeed = {
16
+ messages: readonly NcpMessage[];
17
+ status: "idle" | "running";
18
+ };
19
+ type NcpConversationSeedLoader = (sessionId: string, signal: AbortSignal) => Promise<NcpConversationSeed>;
20
+ type UseHydratedNcpAgentOptions = {
21
+ sessionId: string;
22
+ client: NcpAgentClientEndpoint;
23
+ loadSeed: NcpConversationSeedLoader;
24
+ autoResumeRunningSession?: boolean;
25
+ };
26
+ type UseHydratedNcpAgentResult = UseNcpAgentResult & {
27
+ isHydrating: boolean;
28
+ hydrateError: Error | null;
29
+ reloadSeed: () => Promise<void>;
30
+ };
31
+ declare function useHydratedNcpAgent({ sessionId, client, loadSeed, autoResumeRunningSession, }: UseHydratedNcpAgentOptions): UseHydratedNcpAgentResult;
32
+
33
+ declare function useNcpAgent(sessionId: string, client: NcpAgentClientEndpoint): UseNcpAgentResult;
34
+
35
+ export { type NcpConversationSeed, type NcpConversationSeedLoader, type UseHydratedNcpAgentOptions, type UseHydratedNcpAgentResult, type UseNcpAgentResult, useHydratedNcpAgent, useNcpAgent };
package/dist/index.js CHANGED
@@ -1,77 +1,144 @@
1
- // src/hooks/use-ncp-agent.ts
2
- import { useEffect, useRef, useState } from "react";
1
+ // src/hooks/use-hydrated-ncp-agent.ts
2
+ import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
3
+
4
+ // src/hooks/use-ncp-agent-runtime.ts
5
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
6
  import { DefaultNcpAgentConversationStateManager } from "@nextclaw/ncp-toolkit";
4
- import "@nextclaw/ncp";
5
- function useNcpAgent(sessionId, client) {
7
+ import {
8
+ NcpEventType
9
+ } from "@nextclaw/ncp";
10
+ function shouldDispatchEventToSession(event, sessionId) {
11
+ const payload = "payload" in event ? event.payload : null;
12
+ if (!payload || typeof payload !== "object") {
13
+ return true;
14
+ }
15
+ if (!("sessionId" in payload) || typeof payload.sessionId !== "string") {
16
+ return true;
17
+ }
18
+ return payload.sessionId === sessionId;
19
+ }
20
+ function hasMessageContent(message) {
21
+ return message.parts.some((part) => {
22
+ if (part.type === "text" || part.type === "rich-text" || part.type === "reasoning") {
23
+ return part.text.trim().length > 0;
24
+ }
25
+ return true;
26
+ });
27
+ }
28
+ function normalizeSendEnvelope(input, sessionId) {
29
+ if (typeof input === "string") {
30
+ const content = input.trim();
31
+ if (!content) {
32
+ return null;
33
+ }
34
+ return {
35
+ sessionId,
36
+ message: {
37
+ id: `user-${Date.now().toString(36)}`,
38
+ sessionId,
39
+ role: "user",
40
+ status: "final",
41
+ parts: [{ type: "text", text: content }],
42
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
43
+ }
44
+ };
45
+ }
46
+ if (!hasMessageContent(input.message)) {
47
+ return null;
48
+ }
49
+ return {
50
+ ...input,
51
+ sessionId: input.sessionId,
52
+ message: {
53
+ ...input.message,
54
+ sessionId: input.message.sessionId || input.sessionId
55
+ }
56
+ };
57
+ }
58
+ function toHydrationPayload(sessionId, snapshot) {
59
+ return {
60
+ sessionId,
61
+ messages: snapshot.messages,
62
+ activeRun: snapshot.activeRun
63
+ };
64
+ }
65
+ function useScopedAgentManager(sessionId) {
6
66
  const managerRef = useRef();
7
- if (!managerRef.current) {
8
- managerRef.current = new DefaultNcpAgentConversationStateManager();
67
+ if (!managerRef.current || managerRef.current.sessionId !== sessionId) {
68
+ managerRef.current = {
69
+ sessionId,
70
+ manager: new DefaultNcpAgentConversationStateManager()
71
+ };
9
72
  }
10
- const [snapshot, setSnapshot] = useState(
11
- () => managerRef.current.getSnapshot()
73
+ return managerRef.current.manager;
74
+ }
75
+ function useNcpAgentRuntime({
76
+ sessionId,
77
+ client,
78
+ manager
79
+ }) {
80
+ const snapshot = useSyncExternalStore(
81
+ (onStoreChange) => manager.subscribe(() => onStoreChange()),
82
+ () => manager.getSnapshot(),
83
+ () => manager.getSnapshot()
12
84
  );
13
85
  const [isSending, setIsSending] = useState(false);
14
86
  useEffect(() => {
15
- const manager = managerRef.current;
16
- if (!manager) {
17
- return;
18
- }
19
- const unsubscribe = manager.subscribe((nextSnapshot) => {
20
- setSnapshot(nextSnapshot);
21
- });
22
- return () => {
23
- unsubscribe();
24
- };
25
- }, []);
87
+ setIsSending(false);
88
+ }, [sessionId]);
26
89
  useEffect(() => {
27
- const manager = managerRef.current;
28
- if (!manager) {
29
- return;
30
- }
31
90
  const unsubscribeClient = client.subscribe((event) => {
91
+ if (!shouldDispatchEventToSession(event, sessionId)) {
92
+ return;
93
+ }
32
94
  void manager.dispatch(event);
33
95
  });
34
96
  return () => {
35
97
  unsubscribeClient();
36
98
  void client.stop();
37
99
  };
38
- }, [client]);
100
+ }, [client, manager, sessionId]);
39
101
  const visibleMessages = snapshot.streamingMessage ? [...snapshot.messages, snapshot.streamingMessage] : snapshot.messages;
40
102
  const activeRunId = snapshot.activeRun?.runId ?? null;
41
103
  const isRunning = !!snapshot.activeRun;
42
- const send = async (content) => {
43
- if (!content.trim() || isSending || isRunning) {
104
+ const send = async (input) => {
105
+ if (isSending || isRunning) {
106
+ return;
107
+ }
108
+ const envelope = normalizeSendEnvelope(input, sessionId);
109
+ if (!envelope) {
44
110
  return;
45
111
  }
46
112
  setIsSending(true);
47
- try {
48
- await client.send({
113
+ const previousSnapshot = manager.getSnapshot();
114
+ await manager.dispatch({
115
+ type: NcpEventType.MessageSent,
116
+ payload: {
49
117
  sessionId,
50
- message: {
51
- id: `user-${Date.now().toString(36)}`,
52
- sessionId,
53
- role: "user",
54
- status: "final",
55
- parts: [{ type: "text", text: content.trim() }],
56
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
57
- }
58
- });
118
+ message: envelope.message,
119
+ metadata: envelope.metadata
120
+ }
121
+ });
122
+ try {
123
+ await client.send(envelope);
124
+ } catch (error) {
125
+ manager.hydrate(toHydrationPayload(sessionId, previousSnapshot));
126
+ throw error;
59
127
  } finally {
60
128
  setIsSending(false);
61
129
  }
62
130
  };
63
131
  const abort = async () => {
64
- const runId = snapshot.activeRun?.runId;
65
- if (!runId) {
132
+ if (!snapshot.activeRun) {
66
133
  return;
67
134
  }
68
- await client.abort({ runId });
135
+ await client.abort({ sessionId });
69
136
  };
70
137
  const streamRun = async () => {
71
- if (!activeRunId) {
138
+ if (!snapshot.activeRun) {
72
139
  return;
73
140
  }
74
- await client.stream({ sessionId, runId: activeRunId });
141
+ await client.stream({ sessionId });
75
142
  };
76
143
  return {
77
144
  snapshot,
@@ -84,6 +151,91 @@ function useNcpAgent(sessionId, client) {
84
151
  streamRun
85
152
  };
86
153
  }
154
+
155
+ // src/hooks/use-hydrated-ncp-agent.ts
156
+ function toError(error) {
157
+ return error instanceof Error ? error : new Error(String(error));
158
+ }
159
+ function useHydratedNcpAgent({
160
+ sessionId,
161
+ client,
162
+ loadSeed,
163
+ autoResumeRunningSession = true
164
+ }) {
165
+ const manager = useScopedAgentManager(sessionId);
166
+ const runtime = useNcpAgentRuntime({ sessionId, client, manager });
167
+ const [isHydrating, setIsHydrating] = useState2(true);
168
+ const [hydrateError, setHydrateError] = useState2(null);
169
+ const loadStateRef = useRef2({ requestId: 0, controller: null });
170
+ const reloadSeed = useCallback(async () => {
171
+ loadStateRef.current.controller?.abort();
172
+ const controller = new AbortController();
173
+ const requestId = loadStateRef.current.requestId + 1;
174
+ loadStateRef.current = {
175
+ requestId,
176
+ controller
177
+ };
178
+ await client.stop();
179
+ manager.reset();
180
+ setHydrateError(null);
181
+ setIsHydrating(true);
182
+ try {
183
+ const seed = await loadSeed(sessionId, controller.signal);
184
+ if (controller.signal.aborted || loadStateRef.current.requestId !== requestId) {
185
+ return;
186
+ }
187
+ manager.hydrate({
188
+ sessionId,
189
+ messages: seed.messages,
190
+ activeRun: seed.status === "running" ? {
191
+ runId: null,
192
+ sessionId,
193
+ abortDisabledReason: null
194
+ } : null
195
+ });
196
+ setHydrateError(null);
197
+ setIsHydrating(false);
198
+ if (seed.status === "running" && autoResumeRunningSession) {
199
+ void client.stream({ sessionId }).catch((error) => {
200
+ if (loadStateRef.current.requestId !== requestId) {
201
+ return;
202
+ }
203
+ setHydrateError(toError(error));
204
+ });
205
+ }
206
+ } catch (error) {
207
+ if (controller.signal.aborted || loadStateRef.current.requestId !== requestId) {
208
+ return;
209
+ }
210
+ setHydrateError(toError(error));
211
+ setIsHydrating(false);
212
+ } finally {
213
+ if (loadStateRef.current.controller === controller) {
214
+ loadStateRef.current.controller = null;
215
+ }
216
+ }
217
+ }, [autoResumeRunningSession, client, loadSeed, manager, sessionId]);
218
+ useEffect2(() => {
219
+ void reloadSeed();
220
+ return () => {
221
+ loadStateRef.current.controller?.abort();
222
+ loadStateRef.current.controller = null;
223
+ };
224
+ }, [reloadSeed]);
225
+ return {
226
+ ...runtime,
227
+ isHydrating,
228
+ hydrateError,
229
+ reloadSeed
230
+ };
231
+ }
232
+
233
+ // src/hooks/use-ncp-agent.ts
234
+ function useNcpAgent(sessionId, client) {
235
+ const manager = useScopedAgentManager(sessionId);
236
+ return useNcpAgentRuntime({ sessionId, client, manager });
237
+ }
87
238
  export {
239
+ useHydratedNcpAgent,
88
240
  useNcpAgent
89
241
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ncp-react",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "React bindings for building NCP-based agent applications.",
6
6
  "type": "module",
@@ -15,8 +15,8 @@
15
15
  "dist"
16
16
  ],
17
17
  "dependencies": {
18
- "@nextclaw/ncp": "0.1.1",
19
- "@nextclaw/ncp-toolkit": "0.1.1"
18
+ "@nextclaw/ncp": "0.3.0",
19
+ "@nextclaw/ncp-toolkit": "0.3.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "react": "^18.0.0 || ^19.0.0"