@contractspec/module.ai-chat 3.2.0 → 4.0.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.
@@ -79,7 +79,15 @@ import * as React3 from "react";
79
79
  import { cn as cn3 } from "@contractspec/lib.ui-kit-web/ui/utils";
80
80
  import { Avatar, AvatarFallback } from "@contractspec/lib.ui-kit-web/ui/avatar";
81
81
  import { Skeleton } from "@contractspec/lib.ui-kit-web/ui/skeleton";
82
- import { Bot, User, AlertCircle, Copy as Copy2, Check as Check2 } from "lucide-react";
82
+ import {
83
+ Bot,
84
+ User,
85
+ AlertCircle,
86
+ Copy as Copy2,
87
+ Check as Check2,
88
+ ExternalLink,
89
+ Wrench
90
+ } from "lucide-react";
83
91
  import { Button as Button2 } from "@contractspec/lib.design-system";
84
92
 
85
93
  // src/presentation/components/CodePreview.tsx
@@ -241,12 +249,40 @@ function extractCodeBlocks(content) {
241
249
  }
242
250
  return blocks;
243
251
  }
252
+ function renderInlineMarkdown(text) {
253
+ const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
254
+ const parts = [];
255
+ let lastIndex = 0;
256
+ let match;
257
+ let key = 0;
258
+ while ((match = linkRegex.exec(text)) !== null) {
259
+ if (match.index > lastIndex) {
260
+ parts.push(/* @__PURE__ */ jsxDEV3("span", {
261
+ children: text.slice(lastIndex, match.index)
262
+ }, key++, false, undefined, this));
263
+ }
264
+ parts.push(/* @__PURE__ */ jsxDEV3("a", {
265
+ href: match[2],
266
+ target: "_blank",
267
+ rel: "noopener noreferrer",
268
+ className: "text-primary underline hover:no-underline",
269
+ children: match[1]
270
+ }, key++, false, undefined, this));
271
+ lastIndex = match.index + match[0].length;
272
+ }
273
+ if (lastIndex < text.length) {
274
+ parts.push(/* @__PURE__ */ jsxDEV3("span", {
275
+ children: text.slice(lastIndex)
276
+ }, key++, false, undefined, this));
277
+ }
278
+ return parts.length > 0 ? parts : [text];
279
+ }
244
280
  function MessageContent({ content }) {
245
281
  const codeBlocks = extractCodeBlocks(content);
246
282
  if (codeBlocks.length === 0) {
247
283
  return /* @__PURE__ */ jsxDEV3("p", {
248
284
  className: "whitespace-pre-wrap",
249
- children: content
285
+ children: renderInlineMarkdown(content)
250
286
  }, undefined, false, undefined, this);
251
287
  }
252
288
  let remaining = content;
@@ -257,7 +293,7 @@ function MessageContent({ content }) {
257
293
  if (before) {
258
294
  parts.push(/* @__PURE__ */ jsxDEV3("p", {
259
295
  className: "whitespace-pre-wrap",
260
- children: before.trim()
296
+ children: renderInlineMarkdown(before.trim())
261
297
  }, key++, false, undefined, this));
262
298
  }
263
299
  parts.push(/* @__PURE__ */ jsxDEV3(CodePreview, {
@@ -270,7 +306,7 @@ function MessageContent({ content }) {
270
306
  if (remaining.trim()) {
271
307
  parts.push(/* @__PURE__ */ jsxDEV3("p", {
272
308
  className: "whitespace-pre-wrap",
273
- children: remaining.trim()
309
+ children: renderInlineMarkdown(remaining.trim())
274
310
  }, key++, false, undefined, this));
275
311
  }
276
312
  return /* @__PURE__ */ jsxDEV3(Fragment, {
@@ -388,7 +424,77 @@ function ChatMessage({
388
424
  }, undefined, false, undefined, this)
389
425
  }, undefined, false, undefined, this)
390
426
  ]
391
- }, undefined, true, undefined, this)
427
+ }, undefined, true, undefined, this),
428
+ message.sources && message.sources.length > 0 && /* @__PURE__ */ jsxDEV3("div", {
429
+ className: "mt-2 flex flex-wrap gap-2",
430
+ children: message.sources.map((source) => /* @__PURE__ */ jsxDEV3("a", {
431
+ href: source.url ?? "#",
432
+ target: "_blank",
433
+ rel: "noopener noreferrer",
434
+ className: "text-muted-foreground hover:text-foreground bg-muted inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors",
435
+ children: [
436
+ /* @__PURE__ */ jsxDEV3(ExternalLink, {
437
+ className: "h-3 w-3"
438
+ }, undefined, false, undefined, this),
439
+ source.title || source.url || source.id
440
+ ]
441
+ }, source.id, true, undefined, this))
442
+ }, undefined, false, undefined, this),
443
+ message.toolCalls && message.toolCalls.length > 0 && /* @__PURE__ */ jsxDEV3("div", {
444
+ className: "mt-2 space-y-2",
445
+ children: message.toolCalls.map((tc) => /* @__PURE__ */ jsxDEV3("details", {
446
+ className: "bg-muted border-border rounded-md border",
447
+ children: [
448
+ /* @__PURE__ */ jsxDEV3("summary", {
449
+ className: "flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-medium",
450
+ children: [
451
+ /* @__PURE__ */ jsxDEV3(Wrench, {
452
+ className: "text-muted-foreground h-4 w-4"
453
+ }, undefined, false, undefined, this),
454
+ tc.name,
455
+ /* @__PURE__ */ jsxDEV3("span", {
456
+ className: cn3("ml-auto rounded px-1.5 py-0.5 text-xs", tc.status === "completed" && "bg-green-500/20 text-green-700 dark:text-green-400", tc.status === "error" && "bg-destructive/20 text-destructive", tc.status === "running" && "bg-blue-500/20 text-blue-700 dark:text-blue-400"),
457
+ children: tc.status
458
+ }, undefined, false, undefined, this)
459
+ ]
460
+ }, undefined, true, undefined, this),
461
+ /* @__PURE__ */ jsxDEV3("div", {
462
+ className: "border-border border-t px-3 py-2 text-xs",
463
+ children: [
464
+ Object.keys(tc.args).length > 0 && /* @__PURE__ */ jsxDEV3("div", {
465
+ className: "mb-2",
466
+ children: [
467
+ /* @__PURE__ */ jsxDEV3("span", {
468
+ className: "text-muted-foreground font-medium",
469
+ children: "Input:"
470
+ }, undefined, false, undefined, this),
471
+ /* @__PURE__ */ jsxDEV3("pre", {
472
+ className: "bg-background mt-1 overflow-x-auto rounded p-2",
473
+ children: JSON.stringify(tc.args, null, 2)
474
+ }, undefined, false, undefined, this)
475
+ ]
476
+ }, undefined, true, undefined, this),
477
+ tc.result !== undefined && /* @__PURE__ */ jsxDEV3("div", {
478
+ children: [
479
+ /* @__PURE__ */ jsxDEV3("span", {
480
+ className: "text-muted-foreground font-medium",
481
+ children: "Output:"
482
+ }, undefined, false, undefined, this),
483
+ /* @__PURE__ */ jsxDEV3("pre", {
484
+ className: "bg-background mt-1 overflow-x-auto rounded p-2",
485
+ children: typeof tc.result === "object" ? JSON.stringify(tc.result, null, 2) : String(tc.result)
486
+ }, undefined, false, undefined, this)
487
+ ]
488
+ }, undefined, true, undefined, this),
489
+ tc.error && /* @__PURE__ */ jsxDEV3("p", {
490
+ className: "text-destructive mt-1",
491
+ children: tc.error
492
+ }, undefined, false, undefined, this)
493
+ ]
494
+ }, undefined, true, undefined, this)
495
+ ]
496
+ }, tc.id, true, undefined, this))
497
+ }, undefined, false, undefined, this)
392
498
  ]
393
499
  }, undefined, true, undefined, this)
394
500
  ]
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * React hooks for AI Chat
3
3
  */
4
- export { useChat, type UseChatOptions, type UseChatReturn } from './useChat';
4
+ export { useChat, type UseChatOptions, type UseChatReturn, type UseChatToolDef, } from './useChat';
5
5
  export { useProviders, type UseProvidersReturn } from './useProviders';
6
+ /** Re-export useCompletion from @ai-sdk/react for non-chat completion use cases */
7
+ export { useCompletion } from '@ai-sdk/react';
@@ -3,6 +3,8 @@ var __require = import.meta.require;
3
3
 
4
4
  // src/presentation/hooks/useChat.tsx
5
5
  import * as React from "react";
6
+ import { tool } from "ai";
7
+ import { z } from "zod";
6
8
 
7
9
  // src/core/chat-service.ts
8
10
  import { generateText, streamText } from "ai";
@@ -137,6 +139,9 @@ class ChatService {
137
139
  systemPrompt;
138
140
  maxHistoryMessages;
139
141
  onUsage;
142
+ tools;
143
+ sendReasoning;
144
+ sendSources;
140
145
  constructor(config) {
141
146
  this.provider = config.provider;
142
147
  this.context = config.context;
@@ -144,6 +149,9 @@ class ChatService {
144
149
  this.systemPrompt = config.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
145
150
  this.maxHistoryMessages = config.maxHistoryMessages ?? 20;
146
151
  this.onUsage = config.onUsage;
152
+ this.tools = config.tools;
153
+ this.sendReasoning = config.sendReasoning ?? false;
154
+ this.sendSources = config.sendSources ?? false;
147
155
  }
148
156
  async send(options) {
149
157
  let conversation;
@@ -168,13 +176,14 @@ class ChatService {
168
176
  status: "completed",
169
177
  attachments: options.attachments
170
178
  });
171
- const prompt = this.buildPrompt(conversation, options);
179
+ const messages = this.buildMessages(conversation, options);
172
180
  const model = this.provider.getModel();
173
181
  try {
174
182
  const result = await generateText({
175
183
  model,
176
- prompt,
177
- system: this.systemPrompt
184
+ messages,
185
+ system: this.systemPrompt,
186
+ tools: this.tools
178
187
  });
179
188
  const assistantMessage = await this.store.appendMessage(conversation.id, {
180
189
  role: "assistant",
@@ -230,33 +239,106 @@ class ChatService {
230
239
  content: "",
231
240
  status: "streaming"
232
241
  });
233
- const prompt = this.buildPrompt(conversation, options);
242
+ const messages = this.buildMessages(conversation, options);
234
243
  const model = this.provider.getModel();
235
- const self = {
236
- systemPrompt: this.systemPrompt,
237
- store: this.store
238
- };
244
+ const systemPrompt = this.systemPrompt;
245
+ const tools = this.tools;
246
+ const store = this.store;
247
+ const onUsage = this.onUsage;
239
248
  async function* streamGenerator() {
240
249
  let fullContent = "";
250
+ let fullReasoning = "";
251
+ const toolCallsMap = new Map;
252
+ const sources = [];
241
253
  try {
242
254
  const result = streamText({
243
255
  model,
244
- prompt,
245
- system: self.systemPrompt
256
+ messages,
257
+ system: systemPrompt,
258
+ tools
246
259
  });
247
- for await (const chunk of result.textStream) {
248
- fullContent += chunk;
249
- yield { type: "text", content: chunk };
260
+ for await (const part of result.fullStream) {
261
+ if (part.type === "text-delta") {
262
+ const text = part.text ?? "";
263
+ if (text) {
264
+ fullContent += text;
265
+ yield { type: "text", content: text };
266
+ }
267
+ } else if (part.type === "reasoning-delta") {
268
+ const text = part.text ?? "";
269
+ if (text) {
270
+ fullReasoning += text;
271
+ yield { type: "reasoning", content: text };
272
+ }
273
+ } else if (part.type === "source") {
274
+ const src = part;
275
+ const source = {
276
+ id: src.id,
277
+ title: src.title ?? "",
278
+ url: src.url,
279
+ type: "web"
280
+ };
281
+ sources.push(source);
282
+ yield { type: "source", source };
283
+ } else if (part.type === "tool-call") {
284
+ const toolCall = {
285
+ id: part.toolCallId,
286
+ name: part.toolName,
287
+ args: part.input ?? {},
288
+ status: "running"
289
+ };
290
+ toolCallsMap.set(part.toolCallId, toolCall);
291
+ yield { type: "tool_call", toolCall };
292
+ } else if (part.type === "tool-result") {
293
+ const tc = toolCallsMap.get(part.toolCallId);
294
+ if (tc) {
295
+ tc.result = part.output;
296
+ tc.status = "completed";
297
+ }
298
+ yield {
299
+ type: "tool_result",
300
+ toolResult: {
301
+ toolCallId: part.toolCallId,
302
+ toolName: part.toolName,
303
+ result: part.output
304
+ }
305
+ };
306
+ } else if (part.type === "tool-error") {
307
+ const tc = toolCallsMap.get(part.toolCallId);
308
+ if (tc) {
309
+ tc.status = "error";
310
+ tc.error = part.error ?? "Tool execution failed";
311
+ }
312
+ } else if (part.type === "finish") {
313
+ const usage = part.usage;
314
+ const inputTokens = usage?.inputTokens ?? 0;
315
+ const outputTokens = usage?.completionTokens ?? 0;
316
+ await store.updateMessage(conversation.id, assistantMessage.id, {
317
+ content: fullContent,
318
+ status: "completed",
319
+ reasoning: fullReasoning || undefined,
320
+ sources: sources.length > 0 ? sources : undefined,
321
+ toolCalls: toolCallsMap.size > 0 ? Array.from(toolCallsMap.values()) : undefined,
322
+ usage: usage ? { inputTokens, outputTokens } : undefined
323
+ });
324
+ onUsage?.({ inputTokens, outputTokens });
325
+ yield {
326
+ type: "done",
327
+ usage: usage ? { inputTokens, outputTokens } : undefined
328
+ };
329
+ return;
330
+ }
250
331
  }
251
- await self.store.updateMessage(conversation.id, assistantMessage.id, {
332
+ await store.updateMessage(conversation.id, assistantMessage.id, {
252
333
  content: fullContent,
253
- status: "completed"
334
+ status: "completed",
335
+ reasoning: fullReasoning || undefined,
336
+ sources: sources.length > 0 ? sources : undefined,
337
+ toolCalls: toolCallsMap.size > 0 ? Array.from(toolCallsMap.values()) : undefined
254
338
  });
255
- yield {
256
- type: "done"
257
- };
339
+ yield { type: "done" };
258
340
  } catch (error) {
259
- await self.store.updateMessage(conversation.id, assistantMessage.id, {
341
+ await store.updateMessage(conversation.id, assistantMessage.id, {
260
342
  content: fullContent,
261
343
  status: "error",
262
344
  error: {
@@ -291,40 +373,59 @@ class ChatService {
291
373
  async deleteConversation(conversationId) {
292
374
  return this.store.delete(conversationId);
293
375
  }
294
- buildPrompt(conversation, options) {
295
- let prompt = "";
376
+ buildMessages(conversation, _options) {
296
377
  const historyStart = Math.max(0, conversation.messages.length - this.maxHistoryMessages);
378
+ const messages = [];
297
379
  for (let i = historyStart;i < conversation.messages.length; i++) {
298
380
  const msg = conversation.messages[i];
299
381
  if (!msg)
300
382
  continue;
301
- if (msg.role === "user" || msg.role === "assistant") {
302
- prompt += `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}
303
-
304
- `;
305
- }
306
- }
307
- let content = options.content;
308
- if (options.attachments?.length) {
309
- const attachmentInfo = options.attachments.map((a) => {
310
- if (a.type === "file" || a.type === "code") {
311
- return `
383
+ if (msg.role === "user") {
384
+ let content = msg.content;
385
+ if (msg.attachments?.length) {
386
+ const attachmentInfo = msg.attachments.map((a) => {
387
+ if (a.type === "file" || a.type === "code") {
388
+ return `
312
389
 
313
390
  ### ${a.name}
314
391
  \`\`\`
315
- ${a.content}
392
+ ${a.content ?? ""}
316
393
  \`\`\``;
317
- }
318
- return `
394
+ }
395
+ return `
319
396
 
320
397
  [Attachment: ${a.name}]`;
321
- }).join("");
322
- content += attachmentInfo;
398
+ }).join("");
399
+ content += attachmentInfo;
400
+ }
401
+ messages.push({ role: "user", content });
402
+ } else if (msg.role === "assistant") {
403
+ if (msg.toolCalls?.length) {
404
+ messages.push({
405
+ role: "assistant",
406
+ content: msg.content || "",
407
+ toolCalls: msg.toolCalls.map((tc) => ({
408
+ type: "tool-call",
409
+ toolCallId: tc.id,
410
+ toolName: tc.name,
411
+ args: tc.args
412
+ }))
413
+ });
414
+ messages.push({
415
+ role: "tool",
416
+ content: msg.toolCalls.map((tc) => ({
417
+ type: "tool-result",
418
+ toolCallId: tc.id,
419
+ toolName: tc.name,
420
+ output: tc.result
421
+ }))
422
+ });
423
+ } else {
424
+ messages.push({ role: "assistant", content: msg.content });
425
+ }
426
+ }
323
427
  }
324
- prompt += `User: ${content}
325
-
326
- Assistant:`;
327
- return prompt;
428
+ return messages;
328
429
  }
329
430
  }
330
431
  function createChatService(config) {
@@ -336,6 +437,17 @@ import {
336
437
  createProvider
337
438
  } from "@contractspec/lib.ai-providers";
338
439
  "use client";
440
+ function toolsToToolSet(defs) {
441
+ const result = {};
442
+ for (const def of defs) {
443
+ result[def.name] = tool({
444
+ description: def.description ?? def.name,
445
+ inputSchema: z.object({}).passthrough(),
446
+ execute: async () => ({})
447
+ });
448
+ }
449
+ return result;
450
+ }
339
451
  function useChat(options = {}) {
340
452
  const {
341
453
  provider = "openai",
@@ -349,7 +461,8 @@ function useChat(options = {}) {
349
461
  onSend,
350
462
  onResponse,
351
463
  onError,
352
- onUsage
464
+ onUsage,
465
+ tools: toolsDefs
353
466
  } = options;
354
467
  const [messages, setMessages] = React.useState([]);
355
468
  const [conversation, setConversation] = React.useState(null);
@@ -368,9 +481,19 @@ function useChat(options = {}) {
368
481
  chatServiceRef.current = new ChatService({
369
482
  provider: chatProvider,
370
483
  systemPrompt,
371
- onUsage
484
+ onUsage,
485
+ tools: toolsDefs?.length ? toolsToToolSet(toolsDefs) : undefined
372
486
  });
373
- }, [provider, mode, model, apiKey, proxyUrl, systemPrompt, onUsage]);
487
+ }, [
488
+ provider,
489
+ mode,
490
+ model,
491
+ apiKey,
492
+ proxyUrl,
493
+ systemPrompt,
494
+ onUsage,
495
+ toolsDefs
496
+ ]);
374
497
  React.useEffect(() => {
375
498
  if (!conversationId || !chatServiceRef.current)
376
499
  return;
@@ -425,13 +548,50 @@ function useChat(options = {}) {
425
548
  };
426
549
  setMessages((prev) => [...prev, assistantMessage]);
427
550
  let fullContent = "";
551
+ let fullReasoning = "";
552
+ const toolCallsMap = new Map;
553
+ const sources = [];
428
554
  for await (const chunk of result.stream) {
429
555
  if (chunk.type === "text" && chunk.content) {
430
556
  fullContent += chunk.content;
431
- setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, content: fullContent } : m));
557
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? {
558
+ ...m,
559
+ content: fullContent,
560
+ reasoning: fullReasoning || undefined,
561
+ sources: sources.length ? sources : undefined,
562
+ toolCalls: toolCallsMap.size ? Array.from(toolCallsMap.values()) : undefined
563
+ } : m));
564
+ } else if (chunk.type === "reasoning" && chunk.content) {
565
+ fullReasoning += chunk.content;
566
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, reasoning: fullReasoning } : m));
567
+ } else if (chunk.type === "source" && chunk.source) {
568
+ sources.push(chunk.source);
569
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, sources: [...sources] } : m));
570
+ } else if (chunk.type === "tool_call" && chunk.toolCall) {
571
+ const tc = chunk.toolCall;
572
+ const chatTc = {
573
+ id: tc.id,
574
+ name: tc.name,
575
+ args: tc.args,
576
+ status: "running"
577
+ };
578
+ toolCallsMap.set(tc.id, chatTc);
579
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, toolCalls: Array.from(toolCallsMap.values()) } : m));
580
+ } else if (chunk.type === "tool_result" && chunk.toolResult) {
581
+ const tr = chunk.toolResult;
582
+ const tc = toolCallsMap.get(tr.toolCallId);
583
+ if (tc) {
584
+ tc.result = tr.result;
585
+ tc.status = "completed";
586
+ }
587
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, toolCalls: Array.from(toolCallsMap.values()) } : m));
432
588
  } else if (chunk.type === "done") {
433
589
  setMessages((prev) => prev.map((m) => m.id === result.messageId ? {
434
590
  ...m,
591
+ content: fullContent,
592
+ reasoning: fullReasoning || undefined,
593
+ sources: sources.length ? sources : undefined,
594
+ toolCalls: toolCallsMap.size ? Array.from(toolCallsMap.values()) : undefined,
435
595
  status: "completed",
436
596
  usage: chunk.usage,
437
597
  updatedAt: new Date
@@ -493,6 +653,10 @@ function useChat(options = {}) {
493
653
  abortControllerRef.current?.abort();
494
654
  setIsLoading(false);
495
655
  }, []);
656
+ const addToolApprovalResponse = React.useCallback((_toolCallId, _result) => {
657
+ throw new Error(`addToolApprovalResponse: Tool approval requires server route with toUIMessageStreamResponse. ` + `Use createChatRoute and @ai-sdk/react useChat for tools with requireApproval.`);
658
+ }, []);
659
+ const hasApprovalTools = toolsDefs?.some((t) => t.requireApproval) ?? false;
496
660
  return {
497
661
  messages,
498
662
  conversation,
@@ -502,7 +666,8 @@ function useChat(options = {}) {
502
666
  clearConversation,
503
667
  setConversationId,
504
668
  regenerate,
505
- stop
669
+ stop,
670
+ ...hasApprovalTools && { addToolApprovalResponse }
506
671
  };
507
672
  }
508
673
  // src/presentation/hooks/useProviders.tsx
@@ -545,7 +710,11 @@ function useProviders() {
545
710
  refresh: loadProviders
546
711
  };
547
712
  }
713
+
714
+ // src/presentation/hooks/index.ts
715
+ import { useCompletion } from "@ai-sdk/react";
548
716
  export {
549
717
  useProviders,
718
+ useCompletion,
550
719
  useChat
551
720
  };
@@ -1,5 +1,13 @@
1
1
  import type { ChatAttachment, ChatConversation, ChatMessage } from '../../core/message-types';
2
2
  import { type ProviderMode, type ProviderName } from '@contractspec/lib.ai-providers';
3
+ /** Tool definition for planner integration (reserved for bundle spec 07_ai_native_chat). */
4
+ export interface UseChatToolDef {
5
+ name: string;
6
+ description?: string;
7
+ schema?: Record<string, unknown>;
8
+ /** When true, stream pauses for user approval before tool execution */
9
+ requireApproval?: boolean;
10
+ }
3
11
  /**
4
12
  * Options for useChat hook
5
13
  */
@@ -31,6 +39,11 @@ export interface UseChatOptions {
31
39
  inputTokens: number;
32
40
  outputTokens: number;
33
41
  }) => void;
42
+ /**
43
+ * Tools for the model to call. Passed to streamText.
44
+ * Use requireApproval: true for tools that need user confirmation.
45
+ */
46
+ tools?: UseChatToolDef[];
34
47
  }
35
48
  /**
36
49
  * Return type for useChat hook
@@ -54,6 +67,11 @@ export interface UseChatReturn {
54
67
  regenerate: () => Promise<void>;
55
68
  /** Stop current generation */
56
69
  stop: () => void;
70
+ /**
71
+ * Add tool approval response when tools have requireApproval.
72
+ * Required when stream pauses for approval. Full support requires server route.
73
+ */
74
+ addToolApprovalResponse?: (toolCallId: string, result: unknown) => void;
57
75
  }
58
76
  /**
59
77
  * Hook for managing AI chat state