@lantos1618/better-ui 0.3.1 → 0.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.
@@ -0,0 +1,257 @@
1
+ "use client";
2
+ import "../chunk-Y6FXYEAI.mjs";
3
+
4
+ // src/react/useTool.ts
5
+ import { useState, useCallback, useEffect, useRef } from "react";
6
+ function useTool(tool, initialInput, options = {}) {
7
+ const [data, setData] = useState(null);
8
+ const [loading, setLoading] = useState(false);
9
+ const [error, setError] = useState(null);
10
+ const [executed, setExecuted] = useState(false);
11
+ const inputRef = useRef(initialInput);
12
+ const optionsRef = useRef(options);
13
+ optionsRef.current = options;
14
+ const executionIdRef = useRef(0);
15
+ const pendingCountRef = useRef(0);
16
+ const execute = useCallback(
17
+ async (input) => {
18
+ const finalInput = input ?? inputRef.current;
19
+ if (finalInput === void 0) {
20
+ const err = new Error("No input provided to tool");
21
+ setError(err);
22
+ optionsRef.current.onError?.(err);
23
+ return null;
24
+ }
25
+ const currentExecutionId = ++executionIdRef.current;
26
+ pendingCountRef.current++;
27
+ setLoading(true);
28
+ setError(null);
29
+ try {
30
+ const context = {
31
+ cache: /* @__PURE__ */ new Map(),
32
+ fetch: globalThis.fetch?.bind(globalThis),
33
+ isServer: false,
34
+ ...optionsRef.current.context
35
+ };
36
+ const result = await tool.run(finalInput, context);
37
+ if (currentExecutionId === executionIdRef.current) {
38
+ setData(result);
39
+ setExecuted(true);
40
+ optionsRef.current.onSuccess?.(result);
41
+ }
42
+ return result;
43
+ } catch (err) {
44
+ const error2 = err instanceof Error ? err : new Error(String(err));
45
+ if (currentExecutionId === executionIdRef.current) {
46
+ setError(error2);
47
+ optionsRef.current.onError?.(error2);
48
+ }
49
+ return null;
50
+ } finally {
51
+ pendingCountRef.current--;
52
+ if (pendingCountRef.current === 0) {
53
+ setLoading(false);
54
+ }
55
+ }
56
+ },
57
+ [tool]
58
+ );
59
+ const reset = useCallback(() => {
60
+ setData(null);
61
+ setError(null);
62
+ setLoading(false);
63
+ setExecuted(false);
64
+ }, []);
65
+ useEffect(() => {
66
+ if (options.auto && initialInput !== void 0) {
67
+ inputRef.current = initialInput;
68
+ execute(initialInput);
69
+ }
70
+ }, [options.auto, initialInput, execute]);
71
+ return {
72
+ data,
73
+ loading,
74
+ error,
75
+ execute,
76
+ reset,
77
+ executed
78
+ };
79
+ }
80
+ function useTools(tools, options = {}) {
81
+ const toolsRef = useRef(tools);
82
+ const optionsRef = useRef(options);
83
+ optionsRef.current = options;
84
+ if (process.env.NODE_ENV !== "production") {
85
+ const prevKeys = Object.keys(toolsRef.current);
86
+ const currKeys = Object.keys(tools);
87
+ if (prevKeys.length !== currKeys.length || !currKeys.every((k) => prevKeys.includes(k))) {
88
+ console.warn(
89
+ "useTools: The tools object keys changed between renders. This may cause unexpected behavior. Define tools outside the component or memoize with useMemo."
90
+ );
91
+ }
92
+ toolsRef.current = tools;
93
+ }
94
+ const [state, setState] = useState(() => {
95
+ const initial = {};
96
+ for (const name of Object.keys(tools)) {
97
+ initial[name] = {
98
+ data: null,
99
+ loading: false,
100
+ error: null,
101
+ executed: false
102
+ };
103
+ }
104
+ return initial;
105
+ });
106
+ const createExecute = useCallback(
107
+ (toolName, tool) => {
108
+ return async (input) => {
109
+ if (input === void 0) {
110
+ const err = new Error("No input provided to tool");
111
+ setState((prev) => ({
112
+ ...prev,
113
+ [toolName]: { ...prev[toolName], error: err }
114
+ }));
115
+ optionsRef.current.onError?.(err);
116
+ return null;
117
+ }
118
+ setState((prev) => ({
119
+ ...prev,
120
+ [toolName]: { ...prev[toolName], loading: true, error: null }
121
+ }));
122
+ try {
123
+ const context = {
124
+ cache: /* @__PURE__ */ new Map(),
125
+ fetch: globalThis.fetch?.bind(globalThis),
126
+ isServer: false,
127
+ ...optionsRef.current.context
128
+ };
129
+ const result = await tool.run(input, context);
130
+ setState((prev) => ({
131
+ ...prev,
132
+ [toolName]: {
133
+ data: result,
134
+ loading: false,
135
+ error: null,
136
+ executed: true
137
+ }
138
+ }));
139
+ optionsRef.current.onSuccess?.(result);
140
+ return result;
141
+ } catch (err) {
142
+ const error = err instanceof Error ? err : new Error(String(err));
143
+ setState((prev) => ({
144
+ ...prev,
145
+ [toolName]: { ...prev[toolName], loading: false, error }
146
+ }));
147
+ optionsRef.current.onError?.(error);
148
+ return null;
149
+ }
150
+ };
151
+ },
152
+ []
153
+ );
154
+ const createReset = useCallback((toolName) => {
155
+ return () => {
156
+ setState((prev) => ({
157
+ ...prev,
158
+ [toolName]: {
159
+ data: null,
160
+ loading: false,
161
+ error: null,
162
+ executed: false
163
+ }
164
+ }));
165
+ };
166
+ }, []);
167
+ const results = {};
168
+ for (const [name, tool] of Object.entries(tools)) {
169
+ const toolName = name;
170
+ const toolState = state[toolName];
171
+ results[toolName] = {
172
+ data: toolState?.data ?? null,
173
+ loading: toolState?.loading ?? false,
174
+ error: toolState?.error ?? null,
175
+ executed: toolState?.executed ?? false,
176
+ execute: createExecute(toolName, tool),
177
+ reset: createReset(toolName)
178
+ // Per-key type safety is enforced by the function's return type annotation.
179
+ };
180
+ }
181
+ return results;
182
+ }
183
+
184
+ // src/react/useToolStream.ts
185
+ import { useState as useState2, useCallback as useCallback2, useRef as useRef2 } from "react";
186
+ function useToolStream(tool, options = {}) {
187
+ const [data, setData] = useState2(null);
188
+ const [finalData, setFinalData] = useState2(null);
189
+ const [streaming, setStreaming] = useState2(false);
190
+ const [loading, setLoading] = useState2(false);
191
+ const [error, setError] = useState2(null);
192
+ const executionIdRef = useRef2(0);
193
+ const optionsRef = useRef2(options);
194
+ optionsRef.current = options;
195
+ const execute = useCallback2(
196
+ async (input) => {
197
+ const currentId = ++executionIdRef.current;
198
+ setLoading(true);
199
+ setStreaming(false);
200
+ setError(null);
201
+ setData(null);
202
+ setFinalData(null);
203
+ try {
204
+ let accumulated = {};
205
+ let result = null;
206
+ let firstChunkReceived = false;
207
+ for await (const chunk of tool.runStream(input, {
208
+ isServer: false,
209
+ ...optionsRef.current.context
210
+ })) {
211
+ if (currentId !== executionIdRef.current) return null;
212
+ if (chunk.done) {
213
+ result = chunk.partial;
214
+ setFinalData(result);
215
+ setData(result);
216
+ setStreaming(false);
217
+ setLoading(false);
218
+ optionsRef.current.onSuccess?.(result);
219
+ } else {
220
+ if (!firstChunkReceived) {
221
+ firstChunkReceived = true;
222
+ setStreaming(true);
223
+ setLoading(false);
224
+ }
225
+ accumulated = { ...accumulated, ...chunk.partial };
226
+ setData({ ...accumulated });
227
+ }
228
+ }
229
+ setLoading(false);
230
+ setStreaming(false);
231
+ return result;
232
+ } catch (err) {
233
+ if (currentId !== executionIdRef.current) return null;
234
+ const e = err instanceof Error ? err : new Error(String(err));
235
+ setError(e);
236
+ setLoading(false);
237
+ setStreaming(false);
238
+ optionsRef.current.onError?.(e);
239
+ return null;
240
+ }
241
+ },
242
+ [tool]
243
+ );
244
+ const reset = useCallback2(() => {
245
+ setData(null);
246
+ setFinalData(null);
247
+ setStreaming(false);
248
+ setLoading(false);
249
+ setError(null);
250
+ }, []);
251
+ return { data, finalData, streaming, loading, error, execute, reset };
252
+ }
253
+ export {
254
+ useTool,
255
+ useToolStream,
256
+ useTools
257
+ };
@@ -0,0 +1,361 @@
1
+ import { z } from 'zod';
2
+ import React, { ReactElement } from 'react';
3
+
4
+ /**
5
+ * Better UI v2 - Tool Definition
6
+ *
7
+ * Clean, type-safe tool definition inspired by TanStack AI,
8
+ * with Better UI's unique view integration.
9
+ */
10
+
11
+ /** Behavioral hints for tools */
12
+ interface ToolHints {
13
+ /** Tool performs destructive/irreversible actions (auto-implies requiresConfirmation) */
14
+ destructive?: boolean;
15
+ /** Tool only reads data, never modifies state */
16
+ readOnly?: boolean;
17
+ /** Tool can be safely retried without side effects */
18
+ idempotent?: boolean;
19
+ }
20
+ /** Entry stored in the tool context cache */
21
+ interface CacheEntry {
22
+ data: unknown;
23
+ expiry: number;
24
+ }
25
+ /**
26
+ * Context passed to tool handlers
27
+ *
28
+ * SECURITY NOTE:
29
+ * - Server-only fields (env, headers, cookies, user, session) are automatically
30
+ * stripped when running on the client to prevent accidental leakage.
31
+ * - Server handlers NEVER run on the client - they auto-fetch via API instead.
32
+ * - Never store secrets in tool output - it gets sent to the client.
33
+ */
34
+ interface ToolContext {
35
+ /** Shared cache for deduplication */
36
+ cache: Map<string, CacheEntry>;
37
+ /** Fetch function (can be customized for auth headers, etc.) */
38
+ fetch: typeof fetch;
39
+ /** Whether running on server */
40
+ isServer: boolean;
41
+ /** Environment variables - NEVER available on client */
42
+ env?: Record<string, string>;
43
+ /** Request headers - NEVER available on client */
44
+ headers?: Headers;
45
+ /** Cookies - NEVER available on client */
46
+ cookies?: Record<string, string>;
47
+ /** Authenticated user - NEVER available on client */
48
+ user?: Record<string, unknown>;
49
+ /** Session data - NEVER available on client */
50
+ session?: Record<string, unknown>;
51
+ /** Optimistic update function */
52
+ optimistic?: <T>(data: Partial<T>) => void;
53
+ }
54
+ interface CacheConfig<TInput> {
55
+ ttl: number;
56
+ key?: (input: TInput) => string;
57
+ }
58
+ /** Configuration for auto-fetch behavior when no client handler is defined */
59
+ interface ClientFetchConfig {
60
+ /** API endpoint for tool execution (default: '/api/tools/execute') */
61
+ endpoint?: string;
62
+ }
63
+ interface ToolConfig<TInput, TOutput> {
64
+ name: string;
65
+ description?: string;
66
+ input: z.ZodType<TInput>;
67
+ output?: z.ZodType<TOutput>;
68
+ tags?: string[];
69
+ cache?: CacheConfig<TInput>;
70
+ /** Configure auto-fetch behavior for client-side execution */
71
+ clientFetch?: ClientFetchConfig;
72
+ /** If true or returns true, tool requires human confirmation before executing (HITL).
73
+ * When a function, it receives the input and can conditionally require confirmation. */
74
+ confirm?: boolean | ((input: TInput) => boolean);
75
+ /** Behavioral hints for the tool */
76
+ hints?: ToolHints;
77
+ /** Group related tool calls by a key derived from input.
78
+ * Calls with the same groupKey are collapsed in the thread — only the latest renders fully. */
79
+ groupKey?: (input: TInput) => string;
80
+ /** When true, a user UI action on this tool automatically sends the updated state
81
+ * to the AI so it can continue without the user typing a message. */
82
+ autoRespond?: boolean;
83
+ }
84
+ type ServerHandler<TInput, TOutput> = (input: TInput, ctx: ToolContext) => Promise<TOutput> | TOutput;
85
+ type ClientHandler<TInput, TOutput> = (input: TInput, ctx: ToolContext) => Promise<TOutput> | TOutput;
86
+ type StreamCallback<TOutput> = (partial: Partial<TOutput>) => void;
87
+ type StreamHandler<TInput, TOutput> = (input: TInput, ctx: ToolContext & {
88
+ /** Send a partial update to the view */
89
+ stream: StreamCallback<TOutput>;
90
+ }) => Promise<TOutput>;
91
+ type ViewState<TInput = unknown> = {
92
+ loading?: boolean;
93
+ /** True while receiving partial streaming updates */
94
+ streaming?: boolean;
95
+ error?: Error | null;
96
+ onAction?: (input: TInput) => void | Promise<void>;
97
+ };
98
+ type ViewComponent<TOutput, TInput = unknown> = (data: TOutput, state?: ViewState<TInput>) => ReactElement | null;
99
+ declare class Tool<TInput = any, TOutput = any> {
100
+ readonly name: string;
101
+ readonly description?: string;
102
+ readonly inputSchema: z.ZodType<TInput>;
103
+ readonly outputSchema?: z.ZodType<TOutput>;
104
+ readonly tags: string[];
105
+ readonly cacheConfig?: CacheConfig<TInput>;
106
+ readonly clientFetchConfig?: ClientFetchConfig;
107
+ readonly confirm: boolean | ((input: TInput) => boolean);
108
+ readonly hints: ToolHints;
109
+ readonly groupKey?: (input: TInput) => string;
110
+ readonly autoRespond: boolean;
111
+ private _server?;
112
+ private _client?;
113
+ private _view?;
114
+ private _stream?;
115
+ constructor(config: ToolConfig<TInput, TOutput>);
116
+ /**
117
+ * Define server-side implementation
118
+ * Runs on server (API routes, server components, etc.)
119
+ */
120
+ server(handler: ServerHandler<TInput, TOutput>): this;
121
+ /**
122
+ * Define client-side implementation
123
+ * Runs in browser. If not specified, auto-fetches to /api/tools/{name}
124
+ */
125
+ client(handler: ClientHandler<TInput, TOutput>): this;
126
+ /**
127
+ * Define view component for rendering results
128
+ * Our differentiator from TanStack AI
129
+ */
130
+ view(component: ViewComponent<TOutput, TInput>): this;
131
+ /**
132
+ * Define streaming implementation
133
+ * The handler receives a `stream` callback to push partial updates.
134
+ */
135
+ stream(handler: StreamHandler<TInput, TOutput>): this;
136
+ /**
137
+ * Execute the tool
138
+ * Automatically uses server or client handler based on environment
139
+ *
140
+ * SECURITY: Server handlers only run on server. Client automatically
141
+ * fetches from /api/tools/execute if no client handler is defined.
142
+ */
143
+ run(input: TInput, ctx?: Partial<ToolContext>): Promise<TOutput>;
144
+ /**
145
+ * Make the tool callable directly: await weather({ city: 'London' })
146
+ */
147
+ call(input: TInput, ctx?: Partial<ToolContext>): Promise<TOutput>;
148
+ /**
149
+ * Execute with streaming - returns async generator of partial results.
150
+ * Falls back to run() if no stream handler is defined.
151
+ */
152
+ runStream(input: TInput, ctx?: Partial<ToolContext>): AsyncGenerator<{
153
+ partial: Partial<TOutput>;
154
+ done: boolean;
155
+ }>;
156
+ /**
157
+ * Default client fetch when no .client() is defined
158
+ *
159
+ * SECURITY: This ensures server handlers never run on the client.
160
+ * The server-side /api/tools/execute endpoint handles execution safely.
161
+ */
162
+ private _defaultClientFetch;
163
+ /**
164
+ * Render the tool's view component
165
+ * Memoized to prevent unnecessary re-renders when parent state changes
166
+ */
167
+ View: React.NamedExoticComponent<{
168
+ data: TOutput | null;
169
+ loading?: boolean;
170
+ streaming?: boolean;
171
+ error?: Error | null;
172
+ onAction?: (input: TInput) => void | Promise<void>;
173
+ }>;
174
+ private _initView;
175
+ /**
176
+ * Check if tool has a view
177
+ */
178
+ get hasView(): boolean;
179
+ /**
180
+ * Check if tool has server implementation
181
+ */
182
+ get hasServer(): boolean;
183
+ /**
184
+ * Check if tool has custom client implementation
185
+ */
186
+ get hasClient(): boolean;
187
+ /**
188
+ * Check if tool has a streaming implementation
189
+ */
190
+ get hasStream(): boolean;
191
+ /**
192
+ * Check if tool requires human confirmation before executing (HITL)
193
+ * Returns true if `confirm` is truthy (boolean true or a function) OR `hints.destructive: true`
194
+ */
195
+ get requiresConfirmation(): boolean;
196
+ /**
197
+ * Determine if a specific input should trigger user confirmation.
198
+ * - If `confirm` is a function, calls it with input
199
+ * - If `confirm` is boolean, returns it
200
+ * - If `hints.destructive`, returns true
201
+ */
202
+ shouldConfirm(input: TInput): boolean;
203
+ /**
204
+ * Get the entity group key for a given input.
205
+ * Returns `"toolName:groupKey(input)"` if groupKey is defined, otherwise undefined.
206
+ */
207
+ getGroupKey(input: TInput): string | undefined;
208
+ /**
209
+ * Convert to plain object (for serialization)
210
+ *
211
+ * SECURITY: This intentionally excludes handlers and schemas to prevent
212
+ * accidental exposure of server logic or validation details.
213
+ */
214
+ toJSON(): {
215
+ name: string;
216
+ description: string | undefined;
217
+ tags: string[];
218
+ hasServer: boolean;
219
+ hasClient: boolean;
220
+ hasView: boolean;
221
+ hasStream: boolean;
222
+ hasCache: boolean;
223
+ confirm: boolean;
224
+ hints: ToolHints;
225
+ requiresConfirmation: boolean;
226
+ };
227
+ /**
228
+ * Convert to AI SDK format (Vercel AI SDK v5 compatible)
229
+ *
230
+ * If `confirm` is true, the execute function is omitted so the AI SDK
231
+ * leaves the tool call at `state: 'input-available'`, enabling HITL
232
+ * confirmation on the client before execution.
233
+ */
234
+ toAITool(): {
235
+ description: string;
236
+ inputSchema: z.ZodType<TInput, z.ZodTypeDef, TInput>;
237
+ execute?: undefined;
238
+ } | {
239
+ description: string;
240
+ inputSchema: z.ZodType<TInput, z.ZodTypeDef, TInput>;
241
+ execute: (input: TInput) => Promise<TOutput>;
242
+ };
243
+ }
244
+ /**
245
+ * Create a new tool with object config
246
+ *
247
+ * @example
248
+ * const weather = tool({
249
+ * name: 'weather',
250
+ * description: 'Get weather for a city',
251
+ * input: z.object({ city: z.string() }),
252
+ * output: z.object({ temp: z.number() }),
253
+ * });
254
+ *
255
+ * weather.server(async ({ city }) => {
256
+ * return { temp: await getTemp(city) };
257
+ * });
258
+ */
259
+ declare function tool<TInput, TOutput = any>(config: ToolConfig<TInput, TOutput>): Tool<TInput, TOutput>;
260
+ /**
261
+ * Create a new tool with fluent builder
262
+ *
263
+ * @example
264
+ * const weather = tool('weather')
265
+ * .description('Get weather for a city')
266
+ * .input(z.object({ city: z.string() }))
267
+ * .output(z.object({ temp: z.number() }))
268
+ * .server(async ({ city }) => ({ temp: 72 }));
269
+ */
270
+ declare function tool(name: string): ToolBuilder;
271
+ declare class ToolBuilder<TInput = any, TOutput = any> {
272
+ private _name;
273
+ private _description?;
274
+ private _input?;
275
+ private _output?;
276
+ private _tags;
277
+ private _cache?;
278
+ private _clientFetch?;
279
+ private _confirm?;
280
+ private _hints?;
281
+ private _groupKey?;
282
+ private _autoRespond?;
283
+ private _serverHandler?;
284
+ private _clientHandler?;
285
+ private _viewComponent?;
286
+ private _streamHandler?;
287
+ constructor(name: string);
288
+ description(desc: string): this;
289
+ /**
290
+ * Define input schema - enables type inference for handlers
291
+ *
292
+ * NOTE: Uses type assertion internally. This is safe because:
293
+ * 1. The schema is stored and used correctly at runtime
294
+ * 2. The return type correctly reflects the new generic parameter
295
+ * 3. TypeScript doesn't support "this type mutation" in fluent builders
296
+ */
297
+ input<T>(schema: z.ZodType<T>): ToolBuilder<T, TOutput>;
298
+ /**
299
+ * Define output schema - enables type inference for results
300
+ */
301
+ output<O>(schema: z.ZodType<O>): ToolBuilder<TInput, O>;
302
+ tags(...tags: string[]): this;
303
+ cache(config: CacheConfig<TInput>): this;
304
+ /** Configure auto-fetch endpoint for client-side execution */
305
+ clientFetch(config: ClientFetchConfig): this;
306
+ /** Require human confirmation before executing (HITL) */
307
+ requireConfirm(value?: boolean | ((input: TInput) => boolean)): this;
308
+ /** Set a groupKey function for collapsing related tool calls */
309
+ groupBy(fn: (input: TInput) => string): this;
310
+ /** Auto-send updated state to AI after user interacts with this tool's UI */
311
+ autoRespondAfterAction(value?: boolean): this;
312
+ /** Set behavioral hints for the tool */
313
+ hints(hints: ToolHints): this;
314
+ server(handler: ServerHandler<TInput, TOutput>): this;
315
+ client(handler: ClientHandler<TInput, TOutput>): this;
316
+ stream(handler: StreamHandler<TInput, TOutput>): this;
317
+ view(component: ViewComponent<TOutput, TInput>): this;
318
+ /**
319
+ * Build the final Tool instance
320
+ */
321
+ build(): Tool<TInput, TOutput>;
322
+ /**
323
+ * Auto-build when accessing Tool methods
324
+ */
325
+ run(input: TInput, ctx?: Partial<ToolContext>): Promise<TOutput>;
326
+ runStream(input: TInput, ctx?: Partial<ToolContext>): AsyncGenerator<{
327
+ partial: Partial<TOutput>;
328
+ done: boolean;
329
+ }>;
330
+ get View(): React.NamedExoticComponent<{
331
+ data: TOutput | null;
332
+ loading?: boolean;
333
+ streaming?: boolean;
334
+ error?: Error | null;
335
+ onAction?: ((input: TInput) => void | Promise<void>) | undefined;
336
+ }>;
337
+ toJSON(): {
338
+ name: string;
339
+ description: string | undefined;
340
+ tags: string[];
341
+ hasServer: boolean;
342
+ hasClient: boolean;
343
+ hasView: boolean;
344
+ hasStream: boolean;
345
+ hasCache: boolean;
346
+ confirm: boolean;
347
+ hints: ToolHints;
348
+ requiresConfirmation: boolean;
349
+ };
350
+ toAITool(): {
351
+ description: string;
352
+ inputSchema: z.ZodType<TInput, z.ZodTypeDef, TInput>;
353
+ execute?: undefined;
354
+ } | {
355
+ description: string;
356
+ inputSchema: z.ZodType<TInput, z.ZodTypeDef, TInput>;
357
+ execute: (input: TInput) => Promise<TOutput>;
358
+ };
359
+ }
360
+
361
+ export { type ClientHandler as C, type ServerHandler as S, Tool as T, type ViewComponent as V, ToolBuilder as a, type ToolConfig as b, type ToolContext as c, type StreamCallback as d, type StreamHandler as e, type ViewState as f, type CacheConfig as g, type ClientFetchConfig as h, tool as t };