@ai-me-chat/react 0.1.0 → 0.2.1

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.
package/README.md CHANGED
@@ -42,6 +42,165 @@ export function Providers({ children }: { children: React.ReactNode }) {
42
42
  - **`useAIMe()`** — full chat state (messages, input, submit) for custom UIs
43
43
  - **`useAIMeContext()`** — access provider context
44
44
 
45
+ ## Syncing Client State After Tool Execution
46
+
47
+ When the AI executes a tool that mutates data (POST/PUT/DELETE), your client-side
48
+ state may be stale. Use `onToolComplete` to trigger a refresh and `onMessageComplete`
49
+ to know when the full response is done:
50
+
51
+ ```tsx
52
+ "use client";
53
+
54
+ import { useRouter } from "next/navigation";
55
+ import { AIMeProvider, AIMeChat } from "@ai-me-chat/react";
56
+
57
+ export function Providers({ children }: { children: React.ReactNode }) {
58
+ const router = useRouter();
59
+
60
+ return (
61
+ <AIMeProvider endpoint="/api/ai-me">
62
+ {children}
63
+ <AIMeChat
64
+ onToolComplete={(tool) => {
65
+ // Refresh the page whenever the AI calls a mutating tool.
66
+ // You can narrow by tool.name or tool.httpMethod for finer control.
67
+ router.refresh();
68
+ }}
69
+ onMessageComplete={(message) => {
70
+ // The assistant finished its full response — all tool calls are done.
71
+ console.log("Assistant reply:", message.content);
72
+ }}
73
+ />
74
+ </AIMeProvider>
75
+ );
76
+ }
77
+ ```
78
+
79
+ `onToolComplete` fires once per tool execution, immediately after the result
80
+ is available in the message stream. Fields:
81
+
82
+ | Field | Type | Description |
83
+ |---|---|---|
84
+ | `name` | `string` | Tool (function) name |
85
+ | `httpMethod` | `string \| undefined` | HTTP method, if surfaced |
86
+ | `path` | `string \| undefined` | API path called, if surfaced |
87
+ | `result` | `unknown` | Raw tool result |
88
+ | `requiresConfirmation` | `boolean \| undefined` | Whether confirmation was required |
89
+
90
+ `onMessageComplete` fires once when the assistant finishes a full response
91
+ (status transitions from `"streaming"` to `"ready"`). Fields:
92
+
93
+ | Field | Type | Description |
94
+ |---|---|---|
95
+ | `role` | `string` | Always `"assistant"` |
96
+ | `content` | `string` | Concatenated text content |
97
+ | `toolCalls` | `unknown[] \| undefined` | Tool-call parts, if any |
98
+
99
+ ## Custom Confirmation Rendering
100
+
101
+ By default, AI-Me shows its built-in `<AIMeConfirm>` dialog before executing
102
+ any tool that requires user confirmation (destructive actions, etc.).
103
+
104
+ Use `renderConfirmation` on `<AIMeChat>` to replace the default dialog with
105
+ your own UI — a branded modal, a slide-over panel, an inline card, whatever
106
+ fits your design system:
107
+
108
+ ```tsx
109
+ "use client";
110
+
111
+ import { AIMeProvider, AIMeChat } from "@ai-me-chat/react";
112
+
113
+ export function Providers({ children }: { children: React.ReactNode }) {
114
+ return (
115
+ <AIMeProvider endpoint="/api/ai-me">
116
+ {children}
117
+ <AIMeChat
118
+ renderConfirmation={({ tool, params, onConfirm, onCancel }) => (
119
+ <MyConfirmModal
120
+ title={`Run "${tool.name}"?`}
121
+ description={tool.description}
122
+ details={`${tool.httpMethod} ${tool.path}`}
123
+ params={params}
124
+ onConfirm={onConfirm}
125
+ onCancel={onCancel}
126
+ />
127
+ )}
128
+ />
129
+ </AIMeProvider>
130
+ );
131
+ }
132
+ ```
133
+
134
+ The `renderConfirmation` callback receives:
135
+
136
+ | Prop | Type | Description |
137
+ |---|---|---|
138
+ | `tool.name` | `string` | Tool (function) name |
139
+ | `tool.httpMethod` | `string` | HTTP method, e.g. `"POST"` |
140
+ | `tool.path` | `string` | API path, e.g. `"/api/projects"` |
141
+ | `tool.description` | `string` | Human-readable description |
142
+ | `params` | `Record<string, unknown>` | Resolved call parameters |
143
+ | `onConfirm` | `() => void` | Call to proceed with execution |
144
+ | `onCancel` | `() => void` | Call to abort |
145
+
146
+ If `renderConfirmation` is omitted, the default dialog is used.
147
+
148
+ ## Navigation Intents (Action Callbacks)
149
+
150
+ Sometimes the AI should guide the UI — navigate to a route, pre-fill a form,
151
+ open a modal — rather than making an API call directly. Use the `onAction`
152
+ prop on `<AIMeProvider>` to handle these client-side intents:
153
+
154
+ ```tsx
155
+ "use client";
156
+
157
+ import { useRouter } from "next/navigation";
158
+ import { AIMeProvider, AIMeChat } from "@ai-me-chat/react";
159
+
160
+ export function Providers({ children }: { children: React.ReactNode }) {
161
+ const router = useRouter();
162
+
163
+ return (
164
+ <AIMeProvider
165
+ endpoint="/api/ai-me"
166
+ onAction={(action) => {
167
+ switch (action.type) {
168
+ case "navigate":
169
+ router.push(action.href as string);
170
+ break;
171
+ case "prefill":
172
+ // Broadcast to a form using a custom event, context, or state manager
173
+ window.dispatchEvent(
174
+ new CustomEvent("ai-me:prefill", { detail: action.fields }),
175
+ );
176
+ break;
177
+ case "open-modal":
178
+ // Open whichever modal the AI identified
179
+ openModal(action.modalId as string);
180
+ break;
181
+ }
182
+ }}
183
+ >
184
+ {children}
185
+ <AIMeChat />
186
+ </AIMeProvider>
187
+ );
188
+ }
189
+ ```
190
+
191
+ The `onAction` callback receives an object with at least a `type` field plus
192
+ any additional payload the tool provides:
193
+
194
+ | Field | Type | Description |
195
+ |---|---|---|
196
+ | `type` | `string` | Action kind — `"navigate"`, `"prefill"`, `"open-modal"`, etc. |
197
+ | `...rest` | `unknown` | Flexible payload defined per action type |
198
+
199
+ The `onAction` callback is stored in context and available to any component
200
+ via `useAIMeContext().onAction`. The actual tool registrations that emit these
201
+ actions live in your AI-Me handler (server-side), so client and server
202
+ concerns stay separated.
203
+
45
204
  ## Theming
46
205
 
47
206
  ```tsx
package/dist/index.cjs CHANGED
@@ -44,8 +44,8 @@ function useAIMeContext() {
44
44
 
45
45
  // src/provider.tsx
46
46
  var import_jsx_runtime = require("react/jsx-runtime");
47
- function AIMeProvider({ endpoint, headers, children }) {
48
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AIMeContext, { value: { endpoint, headers }, children });
47
+ function AIMeProvider({ endpoint, headers, onAction, children }) {
48
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AIMeContext, { value: { endpoint, headers, onAction }, children });
49
49
  }
50
50
 
51
51
  // src/chat.tsx
@@ -356,13 +356,17 @@ function AIMeChat({
356
356
  welcomeMessage = "Hi! I can help you navigate and use this app. What would you like to do?",
357
357
  suggestedPrompts,
358
358
  defaultOpen = false,
359
- onToggle
359
+ onToggle,
360
+ onToolComplete,
361
+ onMessageComplete
360
362
  }) {
361
363
  const [open, setOpen] = (0, import_react4.useState)(defaultOpen);
362
364
  const messagesEndRef = (0, import_react4.useRef)(null);
363
365
  const inputRef = (0, import_react4.useRef)(null);
364
366
  const panelRef = (0, import_react4.useRef)(null);
365
367
  const triggerRef = (0, import_react4.useRef)(null);
368
+ const firedToolResults = (0, import_react4.useRef)(/* @__PURE__ */ new Set());
369
+ const prevStatus = (0, import_react4.useRef)(null);
366
370
  const {
367
371
  messages,
368
372
  input,
@@ -393,6 +397,44 @@ function AIMeChat({
393
397
  (0, import_react4.useEffect)(() => {
394
398
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
395
399
  }, [messages]);
400
+ (0, import_react4.useEffect)(() => {
401
+ if (!onToolComplete) return;
402
+ for (const message of messages) {
403
+ for (const part of message.parts) {
404
+ if (part.type !== "tool-result") continue;
405
+ const id = part.toolCallId;
406
+ const dedupeKey = id ?? `${message.id}:${part.type}`;
407
+ if (firedToolResults.current.has(dedupeKey)) continue;
408
+ firedToolResults.current.add(dedupeKey);
409
+ onToolComplete({
410
+ name: part.toolName ?? "",
411
+ result: part.result
412
+ });
413
+ }
414
+ }
415
+ }, [messages, onToolComplete]);
416
+ (0, import_react4.useEffect)(() => {
417
+ const prev = prevStatus.current;
418
+ prevStatus.current = status;
419
+ if (!onMessageComplete) return;
420
+ if (status !== "ready") return;
421
+ if (prev !== "streaming" && prev !== "submitted") return;
422
+ let lastAssistant;
423
+ for (let i = messages.length - 1; i >= 0; i--) {
424
+ if (messages[i].role === "assistant") {
425
+ lastAssistant = messages[i];
426
+ break;
427
+ }
428
+ }
429
+ if (!lastAssistant) return;
430
+ const textContent = lastAssistant.parts.filter((p) => p.type === "text").map((p) => p.text).join("");
431
+ const toolCalls = lastAssistant.parts.filter((p) => p.type === "tool-call");
432
+ onMessageComplete({
433
+ role: lastAssistant.role,
434
+ content: textContent,
435
+ toolCalls: toolCalls.length > 0 ? toolCalls : void 0
436
+ });
437
+ }, [status, messages, onMessageComplete]);
396
438
  (0, import_react4.useEffect)(() => {
397
439
  if (open) {
398
440
  panelRef.current?.focus();
@@ -455,7 +497,8 @@ function AIMeChat({
455
497
  bottom: 24,
456
498
  ...position === "bottom-right" ? { right: 24 } : { left: 24 },
457
499
  width: 380,
458
- maxHeight: "70vh",
500
+ height: "70vh",
501
+ maxHeight: 600,
459
502
  display: open ? "flex" : "none",
460
503
  flexDirection: "column",
461
504
  fontFamily: "var(--ai-me-font)",
@@ -649,30 +692,34 @@ function AIMeChat({
649
692
  }
650
693
  )
651
694
  ] }),
652
- messages.map((m) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
653
- "div",
654
- {
655
- style: {
656
- alignSelf: m.role === "user" ? "flex-end" : "flex-start",
657
- maxWidth: "85%",
658
- padding: "8px 12px",
659
- borderRadius: 8,
660
- backgroundColor: m.role === "user" ? "var(--ai-me-primary)" : "var(--ai-me-bg-secondary)",
661
- color: m.role === "user" ? "#fff" : "var(--ai-me-text)",
662
- fontSize: 14,
663
- lineHeight: 1.5,
664
- whiteSpace: "pre-wrap",
665
- wordBreak: "break-word"
695
+ messages.map((m) => {
696
+ const hasTextContent = m.parts.some((p) => p.type === "text");
697
+ if (!hasTextContent && m.role === "assistant") return null;
698
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
699
+ "div",
700
+ {
701
+ style: {
702
+ alignSelf: m.role === "user" ? "flex-end" : "flex-start",
703
+ maxWidth: "85%",
704
+ padding: "8px 12px",
705
+ borderRadius: 8,
706
+ backgroundColor: m.role === "user" ? "var(--ai-me-primary)" : "var(--ai-me-bg-secondary)",
707
+ color: m.role === "user" ? "#fff" : "var(--ai-me-text)",
708
+ fontSize: 14,
709
+ lineHeight: 1.5,
710
+ whiteSpace: "pre-wrap",
711
+ wordBreak: "break-word"
712
+ },
713
+ children: [
714
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: srOnly, children: m.role === "user" ? "You: " : "Assistant: " }),
715
+ m.parts.map(
716
+ (p, i) => p.type === "text" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: m.role === "assistant" ? renderMarkdown(p.text) : p.text }, i) : null
717
+ )
718
+ ]
666
719
  },
667
- children: [
668
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { style: srOnly, children: m.role === "user" ? "You: " : "Assistant: " }),
669
- m.parts.map(
670
- (p, i) => p.type === "text" ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: m.role === "assistant" ? renderMarkdown(p.text) : p.text }, i) : null
671
- )
672
- ]
673
- },
674
- m.id
675
- )),
720
+ m.id
721
+ );
722
+ }),
676
723
  status === "submitted" && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
677
724
  "div",
678
725
  {