@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
  ]
@@ -961,6 +1067,8 @@ function ContextIndicator({
961
1067
  }
962
1068
  // src/presentation/hooks/useChat.tsx
963
1069
  import * as React6 from "react";
1070
+ import { tool } from "ai";
1071
+ import { z } from "zod";
964
1072
 
965
1073
  // src/core/chat-service.ts
966
1074
  import { generateText, streamText } from "ai";
@@ -1095,6 +1203,9 @@ class ChatService {
1095
1203
  systemPrompt;
1096
1204
  maxHistoryMessages;
1097
1205
  onUsage;
1206
+ tools;
1207
+ sendReasoning;
1208
+ sendSources;
1098
1209
  constructor(config) {
1099
1210
  this.provider = config.provider;
1100
1211
  this.context = config.context;
@@ -1102,6 +1213,9 @@ class ChatService {
1102
1213
  this.systemPrompt = config.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
1103
1214
  this.maxHistoryMessages = config.maxHistoryMessages ?? 20;
1104
1215
  this.onUsage = config.onUsage;
1216
+ this.tools = config.tools;
1217
+ this.sendReasoning = config.sendReasoning ?? false;
1218
+ this.sendSources = config.sendSources ?? false;
1105
1219
  }
1106
1220
  async send(options) {
1107
1221
  let conversation;
@@ -1126,13 +1240,14 @@ class ChatService {
1126
1240
  status: "completed",
1127
1241
  attachments: options.attachments
1128
1242
  });
1129
- const prompt = this.buildPrompt(conversation, options);
1243
+ const messages = this.buildMessages(conversation, options);
1130
1244
  const model = this.provider.getModel();
1131
1245
  try {
1132
1246
  const result = await generateText({
1133
1247
  model,
1134
- prompt,
1135
- system: this.systemPrompt
1248
+ messages,
1249
+ system: this.systemPrompt,
1250
+ tools: this.tools
1136
1251
  });
1137
1252
  const assistantMessage = await this.store.appendMessage(conversation.id, {
1138
1253
  role: "assistant",
@@ -1188,33 +1303,106 @@ class ChatService {
1188
1303
  content: "",
1189
1304
  status: "streaming"
1190
1305
  });
1191
- const prompt = this.buildPrompt(conversation, options);
1306
+ const messages = this.buildMessages(conversation, options);
1192
1307
  const model = this.provider.getModel();
1193
- const self = {
1194
- systemPrompt: this.systemPrompt,
1195
- store: this.store
1196
- };
1308
+ const systemPrompt = this.systemPrompt;
1309
+ const tools = this.tools;
1310
+ const store = this.store;
1311
+ const onUsage = this.onUsage;
1197
1312
  async function* streamGenerator() {
1198
1313
  let fullContent = "";
1314
+ let fullReasoning = "";
1315
+ const toolCallsMap = new Map;
1316
+ const sources = [];
1199
1317
  try {
1200
1318
  const result = streamText({
1201
1319
  model,
1202
- prompt,
1203
- system: self.systemPrompt
1320
+ messages,
1321
+ system: systemPrompt,
1322
+ tools
1204
1323
  });
1205
- for await (const chunk of result.textStream) {
1206
- fullContent += chunk;
1207
- yield { type: "text", content: chunk };
1324
+ for await (const part of result.fullStream) {
1325
+ if (part.type === "text-delta") {
1326
+ const text = part.text ?? "";
1327
+ if (text) {
1328
+ fullContent += text;
1329
+ yield { type: "text", content: text };
1330
+ }
1331
+ } else if (part.type === "reasoning-delta") {
1332
+ const text = part.text ?? "";
1333
+ if (text) {
1334
+ fullReasoning += text;
1335
+ yield { type: "reasoning", content: text };
1336
+ }
1337
+ } else if (part.type === "source") {
1338
+ const src = part;
1339
+ const source = {
1340
+ id: src.id,
1341
+ title: src.title ?? "",
1342
+ url: src.url,
1343
+ type: "web"
1344
+ };
1345
+ sources.push(source);
1346
+ yield { type: "source", source };
1347
+ } else if (part.type === "tool-call") {
1348
+ const toolCall = {
1349
+ id: part.toolCallId,
1350
+ name: part.toolName,
1351
+ args: part.input ?? {},
1352
+ status: "running"
1353
+ };
1354
+ toolCallsMap.set(part.toolCallId, toolCall);
1355
+ yield { type: "tool_call", toolCall };
1356
+ } else if (part.type === "tool-result") {
1357
+ const tc = toolCallsMap.get(part.toolCallId);
1358
+ if (tc) {
1359
+ tc.result = part.output;
1360
+ tc.status = "completed";
1361
+ }
1362
+ yield {
1363
+ type: "tool_result",
1364
+ toolResult: {
1365
+ toolCallId: part.toolCallId,
1366
+ toolName: part.toolName,
1367
+ result: part.output
1368
+ }
1369
+ };
1370
+ } else if (part.type === "tool-error") {
1371
+ const tc = toolCallsMap.get(part.toolCallId);
1372
+ if (tc) {
1373
+ tc.status = "error";
1374
+ tc.error = part.error ?? "Tool execution failed";
1375
+ }
1376
+ } else if (part.type === "finish") {
1377
+ const usage = part.usage;
1378
+ const inputTokens = usage?.inputTokens ?? 0;
1379
+ const outputTokens = usage?.completionTokens ?? 0;
1380
+ await store.updateMessage(conversation.id, assistantMessage.id, {
1381
+ content: fullContent,
1382
+ status: "completed",
1383
+ reasoning: fullReasoning || undefined,
1384
+ sources: sources.length > 0 ? sources : undefined,
1385
+ toolCalls: toolCallsMap.size > 0 ? Array.from(toolCallsMap.values()) : undefined,
1386
+ usage: usage ? { inputTokens, outputTokens } : undefined
1387
+ });
1388
+ onUsage?.({ inputTokens, outputTokens });
1389
+ yield {
1390
+ type: "done",
1391
+ usage: usage ? { inputTokens, outputTokens } : undefined
1392
+ };
1393
+ return;
1394
+ }
1208
1395
  }
1209
- await self.store.updateMessage(conversation.id, assistantMessage.id, {
1396
+ await store.updateMessage(conversation.id, assistantMessage.id, {
1210
1397
  content: fullContent,
1211
- status: "completed"
1398
+ status: "completed",
1399
+ reasoning: fullReasoning || undefined,
1400
+ sources: sources.length > 0 ? sources : undefined,
1401
+ toolCalls: toolCallsMap.size > 0 ? Array.from(toolCallsMap.values()) : undefined
1212
1402
  });
1213
- yield {
1214
- type: "done"
1215
- };
1403
+ yield { type: "done" };
1216
1404
  } catch (error) {
1217
- await self.store.updateMessage(conversation.id, assistantMessage.id, {
1405
+ await store.updateMessage(conversation.id, assistantMessage.id, {
1218
1406
  content: fullContent,
1219
1407
  status: "error",
1220
1408
  error: {
@@ -1249,40 +1437,59 @@ class ChatService {
1249
1437
  async deleteConversation(conversationId) {
1250
1438
  return this.store.delete(conversationId);
1251
1439
  }
1252
- buildPrompt(conversation, options) {
1253
- let prompt = "";
1440
+ buildMessages(conversation, _options) {
1254
1441
  const historyStart = Math.max(0, conversation.messages.length - this.maxHistoryMessages);
1442
+ const messages = [];
1255
1443
  for (let i = historyStart;i < conversation.messages.length; i++) {
1256
1444
  const msg = conversation.messages[i];
1257
1445
  if (!msg)
1258
1446
  continue;
1259
- if (msg.role === "user" || msg.role === "assistant") {
1260
- prompt += `${msg.role === "user" ? "User" : "Assistant"}: ${msg.content}
1261
-
1262
- `;
1263
- }
1264
- }
1265
- let content = options.content;
1266
- if (options.attachments?.length) {
1267
- const attachmentInfo = options.attachments.map((a) => {
1268
- if (a.type === "file" || a.type === "code") {
1269
- return `
1447
+ if (msg.role === "user") {
1448
+ let content = msg.content;
1449
+ if (msg.attachments?.length) {
1450
+ const attachmentInfo = msg.attachments.map((a) => {
1451
+ if (a.type === "file" || a.type === "code") {
1452
+ return `
1270
1453
 
1271
1454
  ### ${a.name}
1272
1455
  \`\`\`
1273
- ${a.content}
1456
+ ${a.content ?? ""}
1274
1457
  \`\`\``;
1275
- }
1276
- return `
1458
+ }
1459
+ return `
1277
1460
 
1278
1461
  [Attachment: ${a.name}]`;
1279
- }).join("");
1280
- content += attachmentInfo;
1462
+ }).join("");
1463
+ content += attachmentInfo;
1464
+ }
1465
+ messages.push({ role: "user", content });
1466
+ } else if (msg.role === "assistant") {
1467
+ if (msg.toolCalls?.length) {
1468
+ messages.push({
1469
+ role: "assistant",
1470
+ content: msg.content || "",
1471
+ toolCalls: msg.toolCalls.map((tc) => ({
1472
+ type: "tool-call",
1473
+ toolCallId: tc.id,
1474
+ toolName: tc.name,
1475
+ args: tc.args
1476
+ }))
1477
+ });
1478
+ messages.push({
1479
+ role: "tool",
1480
+ content: msg.toolCalls.map((tc) => ({
1481
+ type: "tool-result",
1482
+ toolCallId: tc.id,
1483
+ toolName: tc.name,
1484
+ output: tc.result
1485
+ }))
1486
+ });
1487
+ } else {
1488
+ messages.push({ role: "assistant", content: msg.content });
1489
+ }
1490
+ }
1281
1491
  }
1282
- prompt += `User: ${content}
1283
-
1284
- Assistant:`;
1285
- return prompt;
1492
+ return messages;
1286
1493
  }
1287
1494
  }
1288
1495
  function createChatService(config) {
@@ -1294,6 +1501,17 @@ import {
1294
1501
  createProvider
1295
1502
  } from "@contractspec/lib.ai-providers";
1296
1503
  "use client";
1504
+ function toolsToToolSet(defs) {
1505
+ const result = {};
1506
+ for (const def of defs) {
1507
+ result[def.name] = tool({
1508
+ description: def.description ?? def.name,
1509
+ inputSchema: z.object({}).passthrough(),
1510
+ execute: async () => ({})
1511
+ });
1512
+ }
1513
+ return result;
1514
+ }
1297
1515
  function useChat(options = {}) {
1298
1516
  const {
1299
1517
  provider = "openai",
@@ -1307,7 +1525,8 @@ function useChat(options = {}) {
1307
1525
  onSend,
1308
1526
  onResponse,
1309
1527
  onError,
1310
- onUsage
1528
+ onUsage,
1529
+ tools: toolsDefs
1311
1530
  } = options;
1312
1531
  const [messages, setMessages] = React6.useState([]);
1313
1532
  const [conversation, setConversation] = React6.useState(null);
@@ -1326,9 +1545,19 @@ function useChat(options = {}) {
1326
1545
  chatServiceRef.current = new ChatService({
1327
1546
  provider: chatProvider,
1328
1547
  systemPrompt,
1329
- onUsage
1548
+ onUsage,
1549
+ tools: toolsDefs?.length ? toolsToToolSet(toolsDefs) : undefined
1330
1550
  });
1331
- }, [provider, mode, model, apiKey, proxyUrl, systemPrompt, onUsage]);
1551
+ }, [
1552
+ provider,
1553
+ mode,
1554
+ model,
1555
+ apiKey,
1556
+ proxyUrl,
1557
+ systemPrompt,
1558
+ onUsage,
1559
+ toolsDefs
1560
+ ]);
1332
1561
  React6.useEffect(() => {
1333
1562
  if (!conversationId || !chatServiceRef.current)
1334
1563
  return;
@@ -1383,13 +1612,50 @@ function useChat(options = {}) {
1383
1612
  };
1384
1613
  setMessages((prev) => [...prev, assistantMessage]);
1385
1614
  let fullContent = "";
1615
+ let fullReasoning = "";
1616
+ const toolCallsMap = new Map;
1617
+ const sources = [];
1386
1618
  for await (const chunk of result.stream) {
1387
1619
  if (chunk.type === "text" && chunk.content) {
1388
1620
  fullContent += chunk.content;
1389
- setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, content: fullContent } : m));
1621
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? {
1622
+ ...m,
1623
+ content: fullContent,
1624
+ reasoning: fullReasoning || undefined,
1625
+ sources: sources.length ? sources : undefined,
1626
+ toolCalls: toolCallsMap.size ? Array.from(toolCallsMap.values()) : undefined
1627
+ } : m));
1628
+ } else if (chunk.type === "reasoning" && chunk.content) {
1629
+ fullReasoning += chunk.content;
1630
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, reasoning: fullReasoning } : m));
1631
+ } else if (chunk.type === "source" && chunk.source) {
1632
+ sources.push(chunk.source);
1633
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, sources: [...sources] } : m));
1634
+ } else if (chunk.type === "tool_call" && chunk.toolCall) {
1635
+ const tc = chunk.toolCall;
1636
+ const chatTc = {
1637
+ id: tc.id,
1638
+ name: tc.name,
1639
+ args: tc.args,
1640
+ status: "running"
1641
+ };
1642
+ toolCallsMap.set(tc.id, chatTc);
1643
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, toolCalls: Array.from(toolCallsMap.values()) } : m));
1644
+ } else if (chunk.type === "tool_result" && chunk.toolResult) {
1645
+ const tr = chunk.toolResult;
1646
+ const tc = toolCallsMap.get(tr.toolCallId);
1647
+ if (tc) {
1648
+ tc.result = tr.result;
1649
+ tc.status = "completed";
1650
+ }
1651
+ setMessages((prev) => prev.map((m) => m.id === result.messageId ? { ...m, toolCalls: Array.from(toolCallsMap.values()) } : m));
1390
1652
  } else if (chunk.type === "done") {
1391
1653
  setMessages((prev) => prev.map((m) => m.id === result.messageId ? {
1392
1654
  ...m,
1655
+ content: fullContent,
1656
+ reasoning: fullReasoning || undefined,
1657
+ sources: sources.length ? sources : undefined,
1658
+ toolCalls: toolCallsMap.size ? Array.from(toolCallsMap.values()) : undefined,
1393
1659
  status: "completed",
1394
1660
  usage: chunk.usage,
1395
1661
  updatedAt: new Date
@@ -1451,6 +1717,10 @@ function useChat(options = {}) {
1451
1717
  abortControllerRef.current?.abort();
1452
1718
  setIsLoading(false);
1453
1719
  }, []);
1720
+ const addToolApprovalResponse = React6.useCallback((_toolCallId, _result) => {
1721
+ throw new Error(`addToolApprovalResponse: Tool approval requires server route with toUIMessageStreamResponse. ` + `Use createChatRoute and @ai-sdk/react useChat for tools with requireApproval.`);
1722
+ }, []);
1723
+ const hasApprovalTools = toolsDefs?.some((t) => t.requireApproval) ?? false;
1454
1724
  return {
1455
1725
  messages,
1456
1726
  conversation,
@@ -1460,7 +1730,8 @@ function useChat(options = {}) {
1460
1730
  clearConversation,
1461
1731
  setConversationId,
1462
1732
  regenerate,
1463
- stop
1733
+ stop,
1734
+ ...hasApprovalTools && { addToolApprovalResponse }
1464
1735
  };
1465
1736
  }
1466
1737
  // src/presentation/hooks/useProviders.tsx
@@ -1503,8 +1774,12 @@ function useProviders() {
1503
1774
  refresh: loadProviders
1504
1775
  };
1505
1776
  }
1777
+
1778
+ // src/presentation/hooks/index.ts
1779
+ import { useCompletion } from "@ai-sdk/react";
1506
1780
  export {
1507
1781
  useProviders,
1782
+ useCompletion,
1508
1783
  useChat,
1509
1784
  ModelPicker,
1510
1785
  ContextIndicator,