@frontal-labs/react 0.0.0-stage → 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.
@@ -0,0 +1,185 @@
1
+ import {
2
+ applyUIFrame,
3
+ createUIMessage,
4
+ parseUIMessageStream,
5
+ type UIError,
6
+ type UIMessage,
7
+ } from "@frontal-labs/ai";
8
+ import { useCallback, useMemo, useRef, useState } from "react";
9
+
10
+ /** Status of a chat hook. */
11
+ export type ChatStatus = "idle" | "submitted" | "streaming" | "error";
12
+
13
+ /** A transport: given the conversation so far, return a UI-stream Response. */
14
+ export type ChatTransport = (
15
+ messages: UIMessage[],
16
+ init: { signal: AbortSignal }
17
+ ) => Promise<Response>;
18
+
19
+ /** Options for {@link useChat}. */
20
+ export interface UseChatOptions {
21
+ /**
22
+ * Route that returns `toUIMessageStreamResponse(...)`, default
23
+ * `"/api/chat"`. Or pass a function for full control of the transport.
24
+ */
25
+ api?: string | ChatTransport;
26
+ /** Stable conversation id (sent as `id` in the request body). */
27
+ id?: string;
28
+ /** Messages to start with. */
29
+ initialMessages?: UIMessage[];
30
+ /** Extra JSON fields merged into the request body. */
31
+ body?: Record<string, unknown>;
32
+ /** Extra request headers. */
33
+ headers?: Record<string, string>;
34
+ /** Custom fetch (tests, auth wrappers). */
35
+ fetch?: typeof fetch;
36
+ /** Called for every error frame (errors are data — the stream continues). */
37
+ onError?: (error: UIError) => void;
38
+ /** Called when an assistant message finishes streaming. */
39
+ onFinish?: (message: UIMessage) => void;
40
+ }
41
+
42
+ /** Return value of {@link useChat}. */
43
+ export interface UseChatResult {
44
+ messages: UIMessage[];
45
+ status: ChatStatus;
46
+ /** The last error frame, if any. */
47
+ error: UIError | undefined;
48
+ /** Send a user message and stream the reply. */
49
+ send: (text: string) => Promise<void>;
50
+ /** Abort the in-flight stream. */
51
+ stop: () => void;
52
+ /** Re-send the last user message (drops the failed assistant reply). */
53
+ retry: () => Promise<void>;
54
+ /** Replace the conversation. */
55
+ setMessages: (messages: UIMessage[]) => void;
56
+ }
57
+
58
+ function toWireMessages(messages: UIMessage[]) {
59
+ return messages.map((m) => ({ role: m.role, content: m.text }));
60
+ }
61
+
62
+ /**
63
+ * Chat over a route that calls `ai.streamText` and returns
64
+ * `toUIMessageStreamResponse(...)`. Transport-agnostic: works with any
65
+ * framework that can serve a `Response`.
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * const { messages, send, status, error, retry } = useChat({ api: "/api/chat" });
70
+ * ```
71
+ */
72
+ export function useChat(options: UseChatOptions = {}): UseChatResult {
73
+ const {
74
+ api = "/api/chat",
75
+ id,
76
+ initialMessages = [],
77
+ body,
78
+ headers,
79
+ fetch: fetchImpl,
80
+ onError,
81
+ onFinish,
82
+ } = options;
83
+
84
+ const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
85
+ const [status, setStatus] = useState<ChatStatus>("idle");
86
+ const [error, setError] = useState<UIError | undefined>(undefined);
87
+ const abortRef = useRef<AbortController | undefined>(undefined);
88
+ const messagesRef = useRef(messages);
89
+ messagesRef.current = messages;
90
+
91
+ // Keep the latest `body`/`headers` in refs so inline object literals don't
92
+ // change the transport (and therefore `send`/`retry`) identity every render.
93
+ const extrasRef = useRef({ body, headers });
94
+ extrasRef.current = { body, headers };
95
+
96
+ const transport = useMemo<ChatTransport>(() => {
97
+ if (typeof api === "function") return api;
98
+ return async (msgs, init) =>
99
+ (fetchImpl ?? fetch)(api, {
100
+ method: "POST",
101
+ headers: {
102
+ "content-type": "application/json",
103
+ ...extrasRef.current.headers,
104
+ },
105
+ body: JSON.stringify({
106
+ id,
107
+ messages: toWireMessages(msgs),
108
+ ...extrasRef.current.body,
109
+ }),
110
+ signal: init.signal,
111
+ });
112
+ }, [api, fetchImpl, id]);
113
+
114
+ const run = useCallback(
115
+ async (history: UIMessage[]) => {
116
+ abortRef.current?.abort();
117
+ const controller = new AbortController();
118
+ abortRef.current = controller;
119
+ setError(undefined);
120
+ setStatus("submitted");
121
+
122
+ let assistant = createUIMessage("assistant");
123
+ setMessages([...history, assistant]);
124
+
125
+ try {
126
+ const response = await transport(history, {
127
+ signal: controller.signal,
128
+ });
129
+ setStatus("streaming");
130
+ for await (const frame of parseUIMessageStream(response, {
131
+ signal: controller.signal,
132
+ })) {
133
+ if (frame.type === "error") {
134
+ setError(frame.error);
135
+ onError?.(frame.error);
136
+ }
137
+ assistant = applyUIFrame(assistant, frame);
138
+ const snapshot = assistant;
139
+ setMessages([...history, snapshot]);
140
+ }
141
+ } catch (err) {
142
+ const e: UIError = {
143
+ code: "TRANSPORT_ERROR",
144
+ message: err instanceof Error ? err.message : String(err),
145
+ retryable: true,
146
+ };
147
+ setError(e);
148
+ onError?.(e);
149
+ assistant = applyUIFrame(assistant, { type: "error", error: e });
150
+ setMessages([...history, assistant]);
151
+ } finally {
152
+ if (abortRef.current === controller) abortRef.current = undefined;
153
+ }
154
+
155
+ setStatus(
156
+ assistant.parts.some((p) => p.type === "error") ? "error" : "idle"
157
+ );
158
+ onFinish?.(assistant);
159
+ },
160
+ [onError, onFinish, transport]
161
+ );
162
+
163
+ const send = useCallback(
164
+ async (text: string) => {
165
+ const history = [...messagesRef.current, createUIMessage("user", text)];
166
+ await run(history);
167
+ },
168
+ [run]
169
+ );
170
+
171
+ const retry = useCallback(async () => {
172
+ const current = messagesRef.current;
173
+ const lastUser = [...current].reverse().findIndex((m) => m.role === "user");
174
+ if (lastUser === -1) return;
175
+ const cut = current.length - lastUser;
176
+ await run(current.slice(0, cut));
177
+ }, [run]);
178
+
179
+ const stop = useCallback(() => {
180
+ abortRef.current?.abort();
181
+ abortRef.current = undefined;
182
+ }, []);
183
+
184
+ return { messages, status, error, send, stop, retry, setMessages };
185
+ }
@@ -0,0 +1,94 @@
1
+ import type { SdkError } from "@frontal-labs/core";
2
+ import type { Approval, WorkflowsSdk } from "@frontal-labs/workflows";
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ /** Options for {@link useWorkflowApprovals}. */
6
+ export interface UseWorkflowApprovalsOptions {
7
+ /** Filter by status (default `"pending"`). */
8
+ status?: string;
9
+ /** Poll interval in ms; `0` (default) disables polling. */
10
+ pollIntervalMs?: number;
11
+ /** Fetch on mount (default true). */
12
+ enabled?: boolean;
13
+ }
14
+
15
+ /** Return value of {@link useWorkflowApprovals}. */
16
+ export interface UseWorkflowApprovalsResult {
17
+ approvals: Approval[];
18
+ loading: boolean;
19
+ error: SdkError | undefined;
20
+ refresh: () => Promise<void>;
21
+ approve: (id: string, comment?: string) => Promise<Approval | undefined>;
22
+ reject: (id: string, comment?: string) => Promise<Approval | undefined>;
23
+ }
24
+
25
+ /**
26
+ * List and act on workflow approvals — the human side of an agent's
27
+ * `approveWhen` contract.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * const { approvals, approve, reject } = useWorkflowApprovals(f.workflows);
32
+ * ```
33
+ */
34
+ export function useWorkflowApprovals(
35
+ workflows: WorkflowsSdk,
36
+ options: UseWorkflowApprovalsOptions = {}
37
+ ): UseWorkflowApprovalsResult {
38
+ const { status = "pending", pollIntervalMs = 0, enabled = true } = options;
39
+ const [approvals, setApprovals] = useState<Approval[]>([]);
40
+ const [loading, setLoading] = useState(false);
41
+ const [error, setError] = useState<SdkError | undefined>(undefined);
42
+
43
+ const refresh = useCallback(async () => {
44
+ setLoading(true);
45
+ try {
46
+ const page = await workflows.approvals.list({ status });
47
+ setApprovals(page.data);
48
+ setError(undefined);
49
+ } catch (err) {
50
+ setError(err as SdkError);
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ }, [status, workflows]);
55
+
56
+ useEffect(() => {
57
+ if (!enabled) return;
58
+ void refresh();
59
+ if (!pollIntervalMs) return;
60
+ const timer = setInterval(() => void refresh(), pollIntervalMs);
61
+ return () => clearInterval(timer);
62
+ }, [enabled, pollIntervalMs, refresh]);
63
+
64
+ const act = useCallback(
65
+ async (fn: () => Promise<Approval>, id: string) => {
66
+ try {
67
+ const updated = await fn();
68
+ setApprovals((prev) =>
69
+ prev
70
+ .map((a) => (a.id === id ? updated : a))
71
+ .filter((a) => a.status === status)
72
+ );
73
+ return updated;
74
+ } catch (err) {
75
+ setError(err as SdkError);
76
+ return undefined;
77
+ }
78
+ },
79
+ [status]
80
+ );
81
+
82
+ const approve = useCallback(
83
+ (id: string, comment?: string) =>
84
+ act(() => workflows.approvals.approve(id, comment), id),
85
+ [act, workflows]
86
+ );
87
+ const reject = useCallback(
88
+ (id: string, comment?: string) =>
89
+ act(() => workflows.approvals.reject(id, comment), id),
90
+ [act, workflows]
91
+ );
92
+
93
+ return { approvals, loading, error, refresh, approve, reject };
94
+ }