find_ai_chat 0.2.0
find_ai_chat: ^0.2.0 copied to clipboard
Flutter SDK for the Find AI chat assistant. Drop-in widget with floating, drawer and embedded modes, SSE streaming, and signed-mode visitor auth.
find_ai_chat #
Flutter SDK for embedding the Find AI chat assistant into Flutter apps
(mobile & web) — equivalent to widget.js for websites, talking to the same
public webchat API (/api/v1/channels/webchat/{connectionId}/...).
Features #
- Three display modes: embedded widget, floating bubble, or drawer panel
- Light/dark theme with system auto-detection
- Two visual styles: classic and GPT-style
- Programmatic control: open, close, toggle, send messages, clear conversation
- SSE streaming with automatic reconnection and
Last-Event-IDresume - Signed mode (
auth_mode: "signed"connections): per-visitor JWT auth via atokenProvidercallback, with automatic retry on token expiry - Multiple conversations per visitor (signed mode): list, switch, and start new conversations
- Headless mode: use
FindChatClientdirectly for custom UIs or bots - Session persistence via
shared_preferences(visitor & conversation IDs)
Platform support #
| Platform | Supported |
|---|---|
| Android | ✅ |
| iOS | ✅ |
| Web | ✅ |
| macOS | ✅ |
| Windows | ✅ |
| Linux | ✅ |
Installation #
dependencies:
find_ai_chat: ^0.2.0
Quick start #
import 'package:find_ai_chat/find_ai_chat.dart';
FindChatWidget(
connectionId: 'your-connection-id',
baseUrl: 'https://api.example.com',
theme: FindChatTheme.light,
mode: FindChatMode.floating,
)
Global base URL #
Configure once in main() instead of repeating on every widget:
void main() {
FindChatEnvironment.configure(baseUrl: 'https://api.example.com');
runApp(const MyApp());
}
All parameters #
FindChatWidget (and FindChatController, which shares the first six):
| Parameter | Type | Required | What it does |
|---|---|---|---|
connectionId |
String |
✅ | Public id of the webchat connection created in the Studio. Identifies which flow, branding, and auth mode the chat uses. |
baseUrl |
String? |
— | Backend origin, no trailing slash (e.g. https://api.example.com). Falls back to FindChatEnvironment.configure(...) or --dart-define=FIND_CHAT_BASE_URL. Throws StateError if none is set. |
externalVisitorId |
String? |
signed: ✅ | Your own user id for the visitor (format ^[a-zA-Z0-9_-]{8,64}$). Public mode: optional — if omitted or invalid, an anonymous id is generated and persisted. Signed mode: required and must equal the visitor_id claim of the token (the backend returns 403 on any mismatch). |
tokenProvider |
Future<String> Function()? |
signed: ✅ | Returns the visitor JWT minted by your backend. Called before every request — cache the token and refresh only when expired/near expiry. Null for public connections. |
originOverride |
String? |
— | Public mode only: value sent as the Origin header, must be in the connection's allowed_origins. Ignored by the backend in signed mode. |
controller |
FindChatController? |
— | Bring your own controller for programmatic control from outside the widget tree. If provided, the widget does NOT initialize/dispose it. |
theme |
FindChatTheme? |
— | .light / .dark / .system. Unset ⇒ uses the connection's color_scheme. |
mode |
FindChatMode? |
— | .embedded / .floating / .drawer. Unset ⇒ uses the connection's display_mode. |
visualStyle |
FindChatVisualStyle? |
— | .classic / .gptStyle. Unset ⇒ uses the connection's visual_style. |
FindChatClient (headless) additionally accepts httpClient (for testing /
custom transports).
Programmatic control #
final controller = FindChatController(
connectionId: 'your-connection-id',
baseUrl: 'https://api.example.com',
);
| Member | What it does |
|---|---|
initialize() |
Loads remote config (+ history of the persisted conversation, if any). Call once; FindChatWidget does it for its own implicit controller. |
sendMessage(String) |
Sends one turn and streams the reply (state.streamingText updates per delta). No-op while state.pending. |
open() / close() / toggle() |
Show/hide the chat in floating/drawer modes. |
clearConversation() |
Clears messages and the persisted conversation id. |
startNewConversation() |
Alias of clearConversation() — the next sendMessage starts a fresh conversation on the backend. |
listConversations({limit, offset}) |
Signed mode: the visitor's conversations, newest activity first. See below. |
selectConversation(String id) |
Switches the active conversation: persists it and loads its history into state.messages. |
conversationId |
Currently active conversation id (null until the first message of a new one). |
state |
Immutable FindChatState: status (idle / loadingConfig / ready / sending / streaming / error), pending, messages, streamingText, errorMessage, config. |
isOpen |
Whether the panel is open (floating/drawer). |
It's a standard ChangeNotifier — listen with ListenableBuilder /
AnimatedBuilder.
If you provide your own controller to FindChatWidget, the widget will not
initialize or dispose it — you are responsible for calling
controller.initialize() and controller.dispose().
Display modes #
| Mode | Behavior |
|---|---|
embedded |
Inline widget in the tree. Place it inside an Expanded, SizedBox, or Scaffold.body. |
floating |
Floating bubble (bottom-right corner) that opens a chat card overlay. |
drawer |
No bubble — opened only via code (controller.open()). Side panel on wide screens, bottom sheet on narrow screens (<600dp). |
When mode is not specified, the SDK uses the display_mode configured on the
connection (defaults to floating). embedded is a Flutter-only mode with no
backend equivalent.
Both floating and drawer render via Overlay — they float above the
entire app. Mount FindChatWidget once anywhere under
MaterialApp/WidgetsApp; no need to remount on every screen.
Theming #
FindChatTheme.light/.dark/.system— explicit override. Without an override, the SDK uses thecolor_schemefrom the connection config (defaults to light).FindChatVisualStyle.classic/.gptStyle— explicit override. Without an override, uses thevisual_stylefrom the connection config (defaults to classic).
Precedence: explicit widget parameter → remote connection config → factory default.
Architecture #
FindChatWidget — entry point; resolves theme/mode and delegates
├─ FindChatEmbeddedView
├─ FindChatFloatingBubble (via Overlay)
└─ FindChatDrawerView (via Overlay)
FindChatController — ChangeNotifier: init, sendMessage, open/close/toggle, clearConversation
FindChatClient — HTTP + SSE, stateless
FindChatSessionStore — visitor_id/conversation_id (shared_preferences)
FindChatClient can be used standalone (without the UI) for headless
integrations or fully custom UIs — it depends only on package:http.
Signed mode (authenticated visitors) #
For apps whose users are already authenticated, create the webchat connection
with config.auth_mode: "signed" in the Studio. Your app backend (never
the app itself) mints a short-lived JWT for its logged-in user — HS256, signed
with the connection's signing secret (obtained via the Studio's
"rotate secret" action), claims visitor_id + exp — and the SDK attaches it
to every request:
FindChatWidget(
connectionId: 'your-connection-id',
externalVisitorId: currentUser.id, // must equal the token's visitor_id claim
tokenProvider: () async => myAuthService.getFindChatToken(),
mode: FindChatMode.drawer,
)
The tokenProvider is called before every request (including each SSE
reconnection), so it should cache the JWT and only hit your backend when the
token is missing or about to expire. On a 401 the SDK asks the provider for a
fresh token and retries once; a second 401 surfaces as
FindChatAuthException — renew the user's session then.
In signed mode the backend ignores the Origin header entirely (native apps
don't send one), so neither originOverride nor allowed_origins matter.
Multiple conversations — fetching a user's chats #
Signed connections unlock GET /conversations: every conversation the
authenticated visitor ever had on this connection, newest activity first. The
SDK exposes it on the controller and leaves the list UI to your app.
final result = await controller.listConversations(limit: 20, offset: 0);
result.visitorId; // the visitor the token belongs to
result.total; // total count (for pagination)
result.conversations; // List<FindChatConversationSummary>, one per chat:
// .conversationId — pass to selectConversation()
// .startedAt — ISO-8601 timestamp of the first message
// .lastMessageAt — ISO-8601 timestamp of the latest activity (sort key)
// .messageCount — number of messages in the conversation
A minimal "my chats" screen wired to the drawer:
class MyChatsScreen extends StatefulWidget {
const MyChatsScreen({super.key, required this.controller});
final FindChatController controller;
@override
State<MyChatsScreen> createState() => _MyChatsScreenState();
}
class _MyChatsScreenState extends State<MyChatsScreen> {
late Future<FindChatConversationsResult> _chats;
@override
void initState() {
super.initState();
_chats = widget.controller.listConversations();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _chats,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('No se pudieron cargar tus chats');
final chats = snapshot.data?.conversations ?? const [];
return ListView(
children: [
ListTile(
leading: const Icon(Icons.add),
title: const Text('Nueva conversación'),
onTap: () async {
await widget.controller.startNewConversation();
widget.controller.open();
},
),
for (final c in chats)
ListTile(
title: Text('Chat del ${DateTime.parse(c.lastMessageAt).toLocal()}'),
subtitle: Text('${c.messageCount} mensajes'),
selected: c.conversationId == widget.controller.conversationId,
onTap: () async {
await widget.controller.selectConversation(c.conversationId);
widget.controller.open(); // opens the chat with its history loaded
},
),
],
);
},
);
}
}
Notes:
- The listing is scoped server-side to the token's visitor — a user can never see (or guess their way into) another user's chats.
- On public connections
listConversations()throws a 404FindChatApiException: the endpoint only exists where visitor identity is verified. Anonymous visitors keep the single persisted conversation. - The backend has no per-conversation titles yet — build labels from
lastMessageAt/messageCount, or load a preview via the history endpoint.
Error handling #
All API errors are typed:
| Exception | When | Typical reaction |
|---|---|---|
FindChatAuthException (extends FindChatApiException, always 401) |
Signed mode: token missing/invalid/expired, even after one retry with a fresh token | Renew the user's session; check the signing secret matches the connection |
FindChatApiException .statusCode == 403 |
Origin not allowed (public) or visitor_id ≠ token claim (signed) |
Fix the connection config / pass the right externalVisitorId |
FindChatApiException .statusCode == 404 |
Connection missing/inactive, or listConversations() on a public connection |
— |
FindChatApiException .statusCode == 429 |
Per-visitor or per-connection rate limit | Show "wait a moment" |
FindChatTurnFailedException |
The flow emitted a terminal error event |
Offer retry |
FindChatTurnUnavailableException |
SSE could not be sustained (3 failed attempts) or cancelled | Offer retry |
FindChatController already maps all of these to user-facing
state.errorMessage strings — the table matters when you call
FindChatClient directly.
Advanced topics #
Origin header in native apps (public mode) #
On auth_mode: "public" connections the backend validates the Origin header
against the connection's allowed_origins. Since native HTTP clients can set
this header freely, two options exist:
- Set
allowed_origins: ["*"]on the connection (recommended for first-party apps). - Pass
originOverridetoFindChatWidget/FindChatController/FindChatClientwith a value in the allowlist.
For real access control in native apps, prefer signed mode (above).
SSE reconnection #
FindChatClient.streamTurn reconnects automatically using Last-Event-ID on
network cuts or server timeout (CHAT_STREAM_SSE_MAX_SECONDS, 300s default).
Reconnection never duplicates already-delivered text. Gives up after 3
consecutive failures with no events received
(FindChatTurnUnavailableException).
Limitations #
- No unread message badge in
floatingmode. - Cancellation sets a client-side flag but cannot abort the underlying TCP
connection (
package:httplimitation). - Mid-stream app kills cannot resume the specific turn on restart (conversation
history is still available via
/history).
Running tests #
flutter test
Covers SSE frame parsing (including split UTF-8 chunks), turn accumulator
(node retries, multi-node text, terminal events), FindChatClient (request
building, error mapping, reconnection with Last-Event-ID), and signed mode
(Bearer token on every endpoint, 401 retry-once, token renewal across SSE
reconnections, conversation listing/switching).
License #
MIT — see LICENSE.