@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.
@@ -84,7 +84,15 @@ import * as React3 from "react";
84
84
  import { cn as cn3 } from "@contractspec/lib.ui-kit-web/ui/utils";
85
85
  import { Avatar, AvatarFallback } from "@contractspec/lib.ui-kit-web/ui/avatar";
86
86
  import { Skeleton } from "@contractspec/lib.ui-kit-web/ui/skeleton";
87
- import { Bot, User, AlertCircle, Copy as Copy2, Check as Check2 } from "lucide-react";
87
+ import {
88
+ Bot,
89
+ User,
90
+ AlertCircle,
91
+ Copy as Copy2,
92
+ Check as Check2,
93
+ ExternalLink,
94
+ Wrench
95
+ } from "lucide-react";
88
96
  import { Button as Button2 } from "@contractspec/lib.design-system";
89
97
 
90
98
  // src/presentation/components/CodePreview.tsx
@@ -246,12 +254,40 @@ function extractCodeBlocks(content) {
246
254
  }
247
255
  return blocks;
248
256
  }
257
+ function renderInlineMarkdown(text) {
258
+ const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
259
+ const parts = [];
260
+ let lastIndex = 0;
261
+ let match;
262
+ let key = 0;
263
+ while ((match = linkRegex.exec(text)) !== null) {
264
+ if (match.index > lastIndex) {
265
+ parts.push(/* @__PURE__ */ jsxDEV3("span", {
266
+ children: text.slice(lastIndex, match.index)
267
+ }, key++, false, undefined, this));
268
+ }
269
+ parts.push(/* @__PURE__ */ jsxDEV3("a", {
270
+ href: match[2],
271
+ target: "_blank",
272
+ rel: "noopener noreferrer",
273
+ className: "text-primary underline hover:no-underline",
274
+ children: match[1]
275
+ }, key++, false, undefined, this));
276
+ lastIndex = match.index + match[0].length;
277
+ }
278
+ if (lastIndex < text.length) {
279
+ parts.push(/* @__PURE__ */ jsxDEV3("span", {
280
+ children: text.slice(lastIndex)
281
+ }, key++, false, undefined, this));
282
+ }
283
+ return parts.length > 0 ? parts : [text];
284
+ }
249
285
  function MessageContent({ content }) {
250
286
  const codeBlocks = extractCodeBlocks(content);
251
287
  if (codeBlocks.length === 0) {
252
288
  return /* @__PURE__ */ jsxDEV3("p", {
253
289
  className: "whitespace-pre-wrap",
254
- children: content
290
+ children: renderInlineMarkdown(content)
255
291
  }, undefined, false, undefined, this);
256
292
  }
257
293
  let remaining = content;
@@ -262,7 +298,7 @@ function MessageContent({ content }) {
262
298
  if (before) {
263
299
  parts.push(/* @__PURE__ */ jsxDEV3("p", {
264
300
  className: "whitespace-pre-wrap",
265
- children: before.trim()
301
+ children: renderInlineMarkdown(before.trim())
266
302
  }, key++, false, undefined, this));
267
303
  }
268
304
  parts.push(/* @__PURE__ */ jsxDEV3(CodePreview, {
@@ -275,7 +311,7 @@ function MessageContent({ content }) {
275
311
  if (remaining.trim()) {
276
312
  parts.push(/* @__PURE__ */ jsxDEV3("p", {
277
313
  className: "whitespace-pre-wrap",
278
- children: remaining.trim()
314
+ children: renderInlineMarkdown(remaining.trim())
279
315
  }, key++, false, undefined, this));
280
316
  }
281
317
  return /* @__PURE__ */ jsxDEV3(Fragment, {
@@ -393,7 +429,77 @@ function ChatMessage({
393
429
  }, undefined, false, undefined, this)
394
430
  }, undefined, false, undefined, this)
395
431
  ]
396
- }, undefined, true, undefined, this)
432
+ }, undefined, true, undefined, this),
433
+ message.sources && message.sources.length > 0 && /* @__PURE__ */ jsxDEV3("div", {
434
+ className: "mt-2 flex flex-wrap gap-2",
435
+ children: message.sources.map((source) => /* @__PURE__ */ jsxDEV3("a", {
436
+ href: source.url ?? "#",
437
+ target: "_blank",
438
+ rel: "noopener noreferrer",
439
+ 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",
440
+ children: [
441
+ /* @__PURE__ */ jsxDEV3(ExternalLink, {
442
+ className: "h-3 w-3"
443
+ }, undefined, false, undefined, this),
444
+ source.title || source.url || source.id
445
+ ]
446
+ }, source.id, true, undefined, this))
447
+ }, undefined, false, undefined, this),
448
+ message.toolCalls && message.toolCalls.length > 0 && /* @__PURE__ */ jsxDEV3("div", {
449
+ className: "mt-2 space-y-2",
450
+ children: message.toolCalls.map((tc) => /* @__PURE__ */ jsxDEV3("details", {
451
+ className: "bg-muted border-border rounded-md border",
452
+ children: [
453
+ /* @__PURE__ */ jsxDEV3("summary", {
454
+ className: "flex cursor-pointer items-center gap-2 px-3 py-2 text-sm font-medium",
455
+ children: [
456
+ /* @__PURE__ */ jsxDEV3(Wrench, {
457
+ className: "text-muted-foreground h-4 w-4"
458
+ }, undefined, false, undefined, this),
459
+ tc.name,
460
+ /* @__PURE__ */ jsxDEV3("span", {
461
+ 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"),
462
+ children: tc.status
463
+ }, undefined, false, undefined, this)
464
+ ]
465
+ }, undefined, true, undefined, this),
466
+ /* @__PURE__ */ jsxDEV3("div", {
467
+ className: "border-border border-t px-3 py-2 text-xs",
468
+ children: [
469
+ Object.keys(tc.args).length > 0 && /* @__PURE__ */ jsxDEV3("div", {
470
+ className: "mb-2",
471
+ children: [
472
+ /* @__PURE__ */ jsxDEV3("span", {
473
+ className: "text-muted-foreground font-medium",
474
+ children: "Input:"
475
+ }, undefined, false, undefined, this),
476
+ /* @__PURE__ */ jsxDEV3("pre", {
477
+ className: "bg-background mt-1 overflow-x-auto rounded p-2",
478
+ children: JSON.stringify(tc.args, null, 2)
479
+ }, undefined, false, undefined, this)
480
+ ]
481
+ }, undefined, true, undefined, this),
482
+ tc.result !== undefined && /* @__PURE__ */ jsxDEV3("div", {
483
+ children: [
484
+ /* @__PURE__ */ jsxDEV3("span", {
485
+ className: "text-muted-foreground font-medium",
486
+ children: "Output:"
487
+ }, undefined, false, undefined, this),
488
+ /* @__PURE__ */ jsxDEV3("pre", {
489
+ className: "bg-background mt-1 overflow-x-auto rounded p-2",
490
+ children: typeof tc.result === "object" ? JSON.stringify(tc.result, null, 2) : String(tc.result)
491
+ }, undefined, false, undefined, this)
492
+ ]
493
+ }, undefined, true, undefined, this),
494
+ tc.error && /* @__PURE__ */ jsxDEV3("p", {
495
+ className: "text-destructive mt-1",
496
+ children: tc.error
497
+ }, undefined, false, undefined, this)
498
+ ]
499
+ }, undefined, true, undefined, this)
500
+ ]
501
+ }, tc.id, true, undefined, this))
502
+ }, undefined, false, undefined, this)
397
503
  ]
398
504
  }, undefined, true, undefined, this)
399
505
  ]
@@ -8,6 +8,8 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
8
8
 
9
9
  // src/presentation/hooks/useChat.tsx
10
10
  import * as React from "react";
11
+ import { tool } from "ai";
12
+ import { z } from "zod";
11
13
 
12
14
  // src/core/chat-service.ts
13
15
  import { generateText, streamText } from "ai";
@@ -142,6 +144,9 @@ class ChatService {
142
144
  systemPrompt;
143
145
  maxHistoryMessages;
144
146
  onUsage;
147
+ tools;
148
+ sendReasoning;
149
+ sendSources;
145
150
  constructor(config) {
146
151
  this.provider = config.provider;
147
152
  this.context = config.context;
@@ -149,6 +154,9 @@ class ChatService {
149
154
  this.systemPrompt = config.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
150
155
  this.maxHistoryMessages = config.maxHistoryMessages ?? 20;
151
156
  this.onUsage = config.onUsage;
157
+ this.tools = config.tools;
158
+ this.sendReasoning = config.sendReasoning ?? false;
159
+ this.sendSources = config.sendSources ?? false;
152
160
  }
153
161
  async send(options) {
154
162
  let conversation;
@@ -173,13 +181,14 @@ class ChatService {
173
181
  status: "completed",
174
182
  attachments: options.attachments
175
183
  });
176
- const prompt = this.buildPrompt(conversation, options);
184
+ const messages = this.buildMessages(conversation, options);
177
185
  const model = this.provider.getModel();
178
186
  try {
179
187
  const result = await generateText({
180
188
  model,
181
- prompt,
182
- system: this.systemPrompt
189
+ messages,
190
+ system: this.systemPrompt,
191
+ tools: this.tools
183
192
  });
184
193
  const assistantMessage = await this.store.appendMessage(conversation.id, {
185
194
  role: "assistant",
@@ -235,33 +244,106 @@ class ChatService {
235
244
  content: "",
236
245
  status: "streaming"
237
246
  });
238
- const prompt = this.buildPrompt(conversation, options);
247
+ const messages = this.buildMessages(conversation, options);
239
248
  const model = this.provider.getModel();
240
- const self = {
241
- systemPrompt: this.systemPrompt,
242
- store: this.store
243
- };
249
+ const systemPrompt = this.systemPrompt;
250
+ const tools = this.tools;
251
+ const store = this.store;
252
+ const onUsage = this.onUsage;
244
253
  async function* streamGenerator() {
245
254
  let fullContent = "";
255
+ let fullReasoning = "";
256
+ const toolCallsMap = new Map;
257
+ const sources = [];
246
258
  try {
247
259
  const result = streamText({
248
260
  model,
249
- prompt,
250
- system: self.systemPrompt
261
+ messages,
262
+ system: systemPrompt,
263
+ tools
251
264
  });
252
- for await (const chunk of result.textStream) {
253
- fullContent += chunk;
254
- yield { type: "text", content: chunk };
265
+ for await (const part of result.fullStream) {
266
+ if (part.type === "text-delta") {
267
+ const text = part.text ?? "";
268
+ if (text) {
269
+ fullContent += text;
270
+ yield { type: "text", content: text };
271
+ }
272
+ } else if (part.type === "reasoning-delta") {
273
+ const text = part.text ?? "";
274
+ if (text) {
275
+ fullReasoning += text;
276
+ yield { type: "reasoning", content: text };
277
+ }
278
+ } else if (part.type === "source") {
279
+ const src = part;
280
+ const source = {
281
+ id: src.id,
282
+ title: src.title ?? "",
283
+ url: src.url,
284
+ type: "web"
285
+ };
286
+ sources.push(source);
287
+ yield { type: "source", source };
288
+ } else if (part.type === "tool-call") {
289
+ const toolCall = {
290
+ id: part.toolCallId,
291
+ name: part.toolName,
292
+ args: part.input ?? {},
293
+ status: "running"
294
+ };
295
+ toolCallsMap.set(part.toolCallId, toolCall);
296
+ yield { type: "tool_call", toolCall };
297
+ } else if (part.type === "tool-result") {
298
+ const tc = toolCallsMap.get(part.toolCallId);
299
+ if (tc) {
300
+ tc.result = part.output;
301
+ tc.status = "completed";
302
+ }
303
+ yield {
304
+ type: "tool_result",
305
+ toolResult: {
306
+ toolCallId: part.toolCallId,
307
+ toolName: part.toolName,
308
+ result: part.output
309
+ }
310
+ };
311
+ } else if (part.type === "tool-error") {
312
+ const tc = toolCallsMap.get(part.toolCallId);
313
+ if (tc) {
314
+ tc.status = "error";
315
+ tc.error = part.error ?? "Tool execution failed";
316
+ }
317
+ } else if (part.type === "finish") {
318
+ const usage = part.usage;
319
+ const inputTokens = usage?.inputTokens ?? 0;
320
+ const outputTokens = usage?.completionTokens ?? 0;
321
+ await store.updateMessage(conversation.id, assistantMessage.id, {
322
+ content: fullContent,
323
+ status: "completed",
324
+ reasoning: fullReasoning || undefined,
325
+ sources: sources.length > 0 ? sources : undefined,
326
+ toolCalls: toolCallsMap.size > 0 ? Array.from(toolCallsMap.values()) : undefined,
327
+ usage: usage ? { inputTokens, outputTokens } : undefined
328
+ });
329
+ onUsage?.({ inputTokens, outputTokens });
330
+ yield {
331
+ type: "done",
332
+ usage: usage ? { inputTokens, outputTokens } : undefined
333
+ };
334
+ return;
335
+ }
255
336
  }
256
- await self.store.updateMessage(conversation.id, assistantMessage.id, {
337
+ await store.updateMessage(conversation.id, assistantMessage.id, {
257
338
  content: fullContent,
258
- status: "completed"
339
+ status: "completed",
340
+ reasoning: fullReasoning || undefined,
341
+ sources: sources.length > 0 ? sources : undefined,
342
+ toolCalls: toolCallsMap.size > 0 ? Array.from(toolCallsMap.values()) : undefined
259
343
  });
260
- yield {
261
- type: "done"
262
- };
344
+ yield { type: "done" };
263
345
  } catch (error) {
264
- await self.store.updateMessage(conversation.id, assistantMessage.id, {
346
+ await store.updateMessage(conversation.id, assistantMessage.id, {
265
347
  content: fullContent,
266
348
  status: "error",
267
349
  error: {
@@ -296,40 +378,59 @@ class ChatService {
296
378
  async deleteConversation(conversationId) {
297
379
  return this.store.delete(conversationId);
298
380
  }
299
- buildPrompt(conversation, options) {
300
- let prompt = "";
381
+ buildMessages(conversation, _options) {
301
382
  const historyStart = Math.max(0, conversation.messages.length - this.maxHistoryMessages);
383
+ const messages = [];
302
384
  for (let i = historyStart;i < conversation.messages.length; i++) {
303
385
  const msg = conversation.messages[i];
304
386
  if (!msg)
305
387
  continue;
306
- if (msg.role === "user" || msg.role === "assistant") {
307
- prompt += `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}
308
-
309
- `;
310
- }
311
- }
312
- let content = options.content;
313
- if (options.attachments?.length) {
314
- const attachmentInfo = options.attachments.map((a) => {
315
- if (a.type === "file" || a.type === "code") {
316
- return `
388
+ if (msg.role === "user") {
389
+ let content = msg.content;
390
+ if (msg.attachments?.length) {
391
+ const attachmentInfo = msg.attachments.map((a) => {
392
+ if (a.type === "file" || a.type === "code") {
393
+ return `
317
394
 
318
395
  ### ${a.name}
319
396
  \`\`\`
320
- ${a.content}
397
+ ${a.content ?? ""}
321
398
  \`\`\``;
322
- }
323
- return `
399
+ }
400
+ return `
324
401
 
325
402
  [Attachment: ${a.name}]`;
326
- }).join("");
327
- content += attachmentInfo;
403
+ }).join("");
404
+ content += attachmentInfo;
405
+ }
406
+ messages.push({ role: "user", content });
407
+ } else if (msg.role === "assistant") {
408
+ if (msg.toolCalls?.length) {
409
+ messages.push({
410
+ role: "assistant",
411
+ content: msg.content || "",
412
+ toolCalls: msg.toolCalls.map((tc) => ({
413
+ type: "tool-call",
414
+ toolCallId: tc.id,
415
+ toolName: tc.name,
416
+ args: tc.args
417
+ }))
418
+ });
419
+ messages.push({
420
+ role: "tool",
421
+ content: msg.toolCalls.map((tc) => ({
422
+ type: "tool-result",
423
+ toolCallId: tc.id,
424
+ toolName: tc.name,
425
+ output: tc.result
426
+ }))
427
+ });
428
+ } else {
429
+ messages.push({ role: "assistant", content: msg.content });
430
+ }
431
+ }
328
432
  }
329
- prompt += `User: ${content}
330
-
331
- Assistant:`;
332
- return prompt;
433
+ return messages;
333
434
  }
334
435
  }
335
436
  function createChatService(config) {
@@ -341,6 +442,17 @@ import {
341
442
  createProvider
342
443
  } from "@contractspec/lib.ai-providers";
343
444
  "use client";
445
+ function toolsToToolSet(defs) {
446
+ const result = {};
447
+ for (const def of defs) {
448
+ result[def.name] = tool({
449
+ description: def.description ?? def.name,
450
+ inputSchema: z.object({}).passthrough(),
451
+ execute: async () => ({})
452
+ });
453
+ }
454
+ return result;
455
+ }
344
456
  function useChat(options = {}) {
345
457
  const {
346
458
  provider = "openai",
@@ -354,7 +466,8 @@ function useChat(options = {}) {
354
466
  onSend,
355
467
  onResponse,
356
468
  onError,
357
- onUsage
469
+ onUsage,
470
+ tools: toolsDefs
358
471
  } = options;
359
472
  const [messages, setMessages] = React.useState([]);
360
473
  const [conversation, setConversation] = React.useState(null);
@@ -373,9 +486,19 @@ function useChat(options = {}) {
373
486
  chatServiceRef.current = new ChatService({
374
487
  provider: chatProvider,
375
488
  systemPrompt,
376
- onUsage
489
+ onUsage,
490
+ tools: toolsDefs?.length ? toolsToToolSet(toolsDefs) : undefined
377
491
  });
378
- }, [provider, mode, model, apiKey, proxyUrl, systemPrompt, onUsage]);
492
+ }, [
493
+ provider,
494
+ mode,
495
+ model,
496
+ apiKey,
497
+ proxyUrl,
498
+ systemPrompt,
499
+ onUsage,
500
+ toolsDefs
501
+ ]);
379
502
  React.useEffect(() => {
380
503
  if (!conversationId || !chatServiceRef.current)
381
504
  return;
@@ -430,13 +553,50 @@ function useChat(options = {}) {
430
553
  };
431
554
  setMessages((prev) => [...prev, assistantMessage]);
432
555
  let fullContent = "";
556
+ let fullReasoning = "";
557
+ const toolCallsMap = new Map;
558
+ const sources = [];
433
559
  for await (const chunk of result.stream) {
434
560
  if (chunk.type === "text" && chunk.content) {
435
561
  fullContent += chunk.content;
436
- setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, content: fullContent } : m));
562
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? {
563
+ ...m,
564
+ content: fullContent,
565
+ reasoning: fullReasoning || undefined,
566
+ sources: sources.length ? sources : undefined,
567
+ toolCalls: toolCallsMap.size ? Array.from(toolCallsMap.values()) : undefined
568
+ } : m));
569
+ } else if (chunk.type === "reasoning" && chunk.content) {
570
+ fullReasoning += chunk.content;
571
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, reasoning: fullReasoning } : m));
572
+ } else if (chunk.type === "source" && chunk.source) {
573
+ sources.push(chunk.source);
574
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, sources: [...sources] } : m));
575
+ } else if (chunk.type === "tool_call" && chunk.toolCall) {
576
+ const tc = chunk.toolCall;
577
+ const chatTc = {
578
+ id: tc.id,
579
+ name: tc.name,
580
+ args: tc.args,
581
+ status: "running"
582
+ };
583
+ toolCallsMap.set(tc.id, chatTc);
584
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, toolCalls: Array.from(toolCallsMap.values()) } : m));
585
+ } else if (chunk.type === "tool_result" && chunk.toolResult) {
586
+ const tr = chunk.toolResult;
587
+ const tc = toolCallsMap.get(tr.toolCallId);
588
+ if (tc) {
589
+ tc.result = tr.result;
590
+ tc.status = "completed";
591
+ }
592
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, toolCalls: Array.from(toolCallsMap.values()) } : m));
437
593
  } else if (chunk.type === "done") {
438
594
  setMessages((prev) => prev.map((m) => m.id === result.messageId ? {
439
595
  ...m,
596
+ content: fullContent,
597
+ reasoning: fullReasoning || undefined,
598
+ sources: sources.length ? sources : undefined,
599
+ toolCalls: toolCallsMap.size ? Array.from(toolCallsMap.values()) : undefined,
440
600
  status: "completed",
441
601
  usage: chunk.usage,
442
602
  updatedAt: new Date
@@ -498,6 +658,10 @@ function useChat(options = {}) {
498
658
  abortControllerRef.current?.abort();
499
659
  setIsLoading(false);
500
660
  }, []);
661
+ const addToolApprovalResponse = React.useCallback((_toolCallId, _result) => {
662
+ throw new Error(`addToolApprovalResponse: Tool approval requires server route with toUIMessageStreamResponse. ` + `Use createChatRoute and @ai-sdk/react useChat for tools with requireApproval.`);
663
+ }, []);
664
+ const hasApprovalTools = toolsDefs?.some((t) => t.requireApproval) ?? false;
501
665
  return {
502
666
  messages,
503
667
  conversation,
@@ -507,7 +671,8 @@ function useChat(options = {}) {
507
671
  clearConversation,
508
672
  setConversationId,
509
673
  regenerate,
510
- stop
674
+ stop,
675
+ ...hasApprovalTools && { addToolApprovalResponse }
511
676
  };
512
677
  }
513
678
  // src/presentation/hooks/useProviders.tsx
@@ -550,7 +715,11 @@ function useProviders() {
550
715
  refresh: loadProviders
551
716
  };
552
717
  }
718
+
719
+ // src/presentation/hooks/index.ts
720
+ import { useCompletion } from "@ai-sdk/react";
553
721
  export {
554
722
  useProviders,
723
+ useCompletion,
555
724
  useChat
556
725
  };