React Antfly

UI components for building search applications with React and Antfly

React Antfly provides declarative React components for building search interfaces powered by Antfly. It offers a simple way to create search applications with faceted search, filters, customizable result displays, and AI-powered answers and chat.

Installation#

Install the package along with its peer dependencies. @antfly/components does not bundle @antfly/sdk, react, or react-dom, so install them explicitly:

npm install @antfly/components @antfly/sdk react react-dom
# or
yarn add @antfly/components @antfly/sdk react react-dom

Then import the stylesheet once, at your app's entry point:

import '@antfly/components/styles';

Without it, components render as unstyled markup: autosuggest dropdowns won't position over the input, pagination and facet lists lose their layout, and so on. See Styling for how the shipped stylesheet works and how to override it.

Quick Start#

Here's a basic example of a search interface using React Antfly:

import React from 'react';
import '@antfly/components/styles';
import {
  Antfly,
  QueryBox,
  Facet,
  Results
} from '@antfly/components';

const MySearchApp = () => (
  <Antfly url="http://127.0.0.1:8080/db/v1" table="movies">
    <QueryBox id="mainSearch" mode="live" placeholder="Search movies..." />
    <Facet id="actors" fields={["actors"]} />
    <Facet id="releasedYear" fields={["releasedYear"]} />
    <Results
      id="results"
      searchBoxId="mainSearch"
      fields={["title", "description"]}
      items={data =>
        data.map(item => (
          <div key={item._id}>
            <h3>{item._source.title}</h3>
            <p>{item._source.description}</p>
          </div>
        ))
      }
    />
  </Antfly>
);

Core Components#

Antfly Provider#

The <Antfly> component is the root provider that manages the search state and coordinates all child components. It also mounts the internal Listener that batches and runs queries, so you never render Listener yourself.

<Antfly
  url="http://127.0.0.1:8080/db/v1"
  table="your_table"
  headers={{
    Authorization: "Bearer your-token"
  }}
  onChange={(values) => {
    // Handle state changes
  }}
>
  {/* Your search components */}
</Antfly>

Props:

  • url (string, required): The Antfly API base URL, including the /db/v1 path, e.g. http://127.0.0.1:8080/db/v1
  • table (string, required): Default table used by every widget (individual widgets can override it with their own table prop)
  • headers (object): Custom headers for API requests
  • onChange (function): Callback fired with the current widget state whenever a query-relevant value changes

QueryBox#

The <QueryBox> component provides a unified text input for both search and question-answering interfaces. It supports two modes: live search (updates as you type) and submit mode (updates on form submission).

<QueryBox
  id="main"
  mode="live"
  placeholder="Search..."
  onInputChange={(value) => console.log('Input:', value)}
/>

{/* Submit mode for Q&A */}
<QueryBox
  id="question"
  mode="submit"
  placeholder="Ask a question..."
  buttonLabel="Ask"
  onSubmit={(value) => console.log('Submitted:', value)}
/>

Props:

  • id (string, required): Unique identifier for the component
  • mode ("live" | "submit"): Search mode (default: "live")
    • "live": Updates results as user types (traditional search)
    • "submit": Requires explicit submission (Q&A, complex queries)
  • placeholder (string): Input placeholder text
  • buttonLabel (string): Submit button text (submit mode only, default: "Submit")
  • initialValue (string): Initial search value
  • autoSubmit (boolean): In submit mode, automatically submit when initialValue is set or changes (used to feed a follow-up question back into the box)
  • submitSignal (string | number): Bump this alongside initialValue to force a re-submit of the same text
  • clearOnSubmit (boolean): Clear the input after a submit-mode submission (default: false)
  • onSubmit (function): Callback when query is submitted
  • onInputChange (function): Callback when input value changes
  • onEscape (function): Custom escape key handler; return true to prevent the default clear behavior
  • renderInput (function): Replace the default <input> with a custom component (receives value, onChange, onSubmit, onKeyDown, and more — see CustomInputProps)
  • children (ReactNode): Child components (e.g., Autosuggest)

Autosuggest#

The <Autosuggest> component provides autocomplete suggestions. It can be used standalone or nested inside a <QueryBox> for integrated search-as-you-type.

Standalone usage:

<Autosuggest
  fields={["title", "name"]}
  returnFields={["title", "description"]}
  limit={10}
  minChars={2}
  renderSuggestion={(hit) => (
    <div>
      <strong>{hit._source.title}</strong>
      <p>{hit._source.description}</p>
    </div>
  )}
  onSuggestionSelect={(hit) => {
    console.log('Selected:', hit);
  }}
/>

Nested in QueryBox:

<QueryBox id="search" mode="live" placeholder="Search...">
  <Autosuggest
    fields={["title"]}
    returnFields={["title", "description"]}
    limit={5}
  />
</QueryBox>

Props:

  • fields (array): Fields to search for suggestions
  • returnFields (array): Fields to return in results (defaults to fields)
  • limit (number): Maximum number of suggestions (default: 10)
  • minChars (number): Minimum characters to trigger suggestions (default: 2)
  • debounceMs (number): Debounce delay before querying (default: 300, 0 to disable)
  • renderSuggestion (function): Custom render function for suggestions (legacy mode, no children)
  • customQuery (function): Custom query transformation function, (value?, fields?) => query
  • semanticIndexes (array): Vector indexes for semantic search
  • table (string): Optional table override
  • filterQuery (object): Query to constrain suggestions
  • exclusionQuery (object): Query to exclude matches
  • onSuggestionSelect (function): Callback when a suggestion is selected (legacy mode, no children)
  • layout ("vertical" | "horizontal" | "grid" | "custom"): Layout mode (default: "vertical")
  • children (ReactNode): Composable children (AutosuggestResults, AutosuggestFacets)

Composable Autosuggest#

Passing children switches <Autosuggest> into composable mode: it stops rendering its own list and instead provides context that <AutosuggestResults> and <AutosuggestFacets> consume.

<QueryBox id="search" mode="live">
  <Autosuggest
    fields={["title", "content"]}
    returnFields={["title", "category"]}
    layout="horizontal"
  >
    <AutosuggestResults
      limit={5}
      renderItem={(hit, index) => (
        <div className="suggestion-item">
          <strong>{hit._source.title}</strong>
          <span className="category">{hit._source.category}</span>
        </div>
      )}
      header={(count) => <h4>{count} results</h4>}
    />
    <AutosuggestFacets
      field="category"
      size={10}
      label="Categories"
      clickable={true}
      onSelect={(facet) => console.log('Selected category:', facet)}
    />
  </Autosuggest>
</QueryBox>

AutosuggestResults#

Renders the matched documents inside a composable <Autosuggest>. Must be a descendant of <Autosuggest> (it calls useAutosuggestContext internally).

Props:

  • limit (number): Cap the number of items shown
  • renderItem (function): (hit, index) => ReactNode
  • onSelect (function): Called when an item is clicked or selected via Enter
  • filter (function): (hit) => boolean to exclude items
  • className, itemClassName, selectedItemClassName (string): CSS hooks
  • header (ReactNode | function): Static node, or (count) => ReactNode
  • footer (ReactNode)
  • emptyMessage (ReactNode): Shown when there are no results after filtering
<AutosuggestResults
  renderItem={(hit) => <span>{hit._source.title}</span>}
  emptyMessage={<em>No matches</em>}
/>

AutosuggestFacets#

Renders one aggregation field's top terms inside a composable <Autosuggest>. Add one <AutosuggestFacets field="..."> per field you want to aggregate on.

Props:

  • field (string, required): Field to aggregate
  • size (number): Max terms to show (default: 5)
  • label (string): Section label
  • order ("count" | "term" | "reverse_count" | "reverse_term"): Sort order (default: "count")
  • renderItem (function): (facet, index) => ReactNode
  • renderSection (function): (field, label, terms) => ReactNode, replaces the whole section
  • onSelect (function): Called when a term is clicked
  • clickable (boolean): Whether terms respond to click/Enter (default: true)
  • filter (function): (facet) => boolean
  • className, itemClassName, sectionClassName (string): CSS hooks
  • header (ReactNode | function), footer (ReactNode), emptyMessage (ReactNode)
<AutosuggestFacets field="category" label="Categories" size={5} />

Facet Filters#

The <Facet> component creates filterable facets for categorical data.

<Facet
  id="category"
  fields={["category.keyword"]}
  itemsPerBlock={10}
  placeholder="Filter categories..."
  seeMore="Show more"
/>

Props:

  • id (string, required): Unique identifier
  • fields (array, required): Fields to create facets from
  • itemsPerBlock (number): Number of items to show initially
  • placeholder (string): Search placeholder within facet
  • seeMore (string): Text for "see more" button

Custom Facet Rendering#

You can customize how facet items are rendered:

<Facet
  id="custom"
  fields={["category"]}
  items={(data, { handleChange, isChecked }) => {
    return data.map((item) => (
      <label key={item.key}>
        <input
          type="checkbox"
          checked={isChecked(item)}
          onChange={() => handleChange(item, !isChecked(item))}
        />
        {item.key} ({item.doc_count})
      </label>
    ));
  }}
/>

Results Display#

The <Results> component displays search results with pagination.

<Results
  id="results"
  searchBoxId="search"
  initialPage={1}
  itemsPerPage={20}
  items={(data) =>
    data.map(({ _source, _score, _id }) => (
      <div key={_id}>
        <h3>{_source.title}</h3>
        <span>Score: {_score}</span>
      </div>
    ))
  }
  pagination={(total, itemsPerPage, page, setPage) => (
    <CustomPagination total={total} itemsPerPage={itemsPerPage} page={page} onChange={setPage} />
  )}
/>

Props:

  • id (string, required): Unique identifier
  • searchBoxId (string): ID of the QueryBox that provides the search value (omit to just consume results from other widgets)
  • fields (array): Fields to search when searchBoxId is set and no semanticIndexes are given
  • semanticIndexes (array): Vector indexes to search
  • customQuery (function): (query?) => unknown, overrides the default query built from fields
  • limit (number): Retrieval limit for semantic queries (default: 10)
  • itemsPerPage (number): Results per page (default: 10)
  • initialPage (number): Starting page number (default: 1)
  • items (function, required): (data) => ReactNode, render function for result items
  • pagination (function): (total, itemsPerPage, page, setPage) => ReactNode, replaces the default Pagination
  • stats (function): (total) => ReactNode, replaces the default "N results" text
  • onResults (function): (data, total) => void, called whenever new results arrive
  • sort, table, filterQuery, exclusionQuery: query overrides, same shapes as elsewhere in the library

Pagination#

The control <Results> renders by default for keyword (non-semantic) result sets. Pass pagination on <Results> to replace it, or render <Pagination> directly for a fully custom results flow.

Props:

  • onChange (function, required): (page: number) => void
  • total (number, required): Total number of results
  • itemsPerPage (number, required)
  • page (number, required): Current page
<Pagination page={page} total={total} itemsPerPage={10} onChange={setPage} />

Active Filters#

<ActiveFilters> reads every widget with a non-empty value and lets you clear them:

<ActiveFilters
  items={(filters, removeFilter) => (
    <div>
      {filters.map(filter => (
        <span key={filter.key}>
          {filter.key}: {filter.value}
          <button onClick={() => removeFilter(filter.key)}>×</button>
        </span>
      ))}
    </div>
  )}
/>

Without an items render prop it falls back to a plain <ul> of key: value pairs with a remove button.

Props:

  • items (function): (activeFilters, removeFilter) => ReactNode, where each filter is { key, value } and removeFilter(key) clears that widget

CustomWidget#

Extensibility point for widgets that need direct access to the shared search state without importing useSharedContext themselves. It clones its children and injects ctx (the current shared state) and dispatch as props.

Props:

  • children (ReactNode, required)
function RawTableLabel({ ctx }) {
  return <span>Table: {ctx?.table}</span>;
}

<CustomWidget>
  <RawTableLabel />
</CustomWidget>

Calling useSharedContext() directly from a component anywhere under <Antfly> achieves the same thing; CustomWidget exists for components that would rather receive ctx/dispatch as props.

Listener#

<Antfly> mounts Listener internally around your tree — you never render it yourself. It watches every registered widget, batches their queries with a 15ms debounce, runs them through multiquery(), and writes results back into the shared state. It's exported for advanced testing and typing scenarios only.

AI Answers (Retrieval Agent)#

Antfly's Retrieval Agent handles query classification, retrieval, reasoning, generation, confidence scoring, and follow-up questions in one streamed request. <AnswerResults> is the React component for it.

AnswerResults#

<AnswerResults> links to a <QueryBox> (usually mode="submit") and streams the agent's response.

import { QueryBox, AnswerResults } from '@antfly/components';

const generator = {
  provider: "ollama",
  model: "qwen2.5:7b"
};

<QueryBox id="question" mode="submit" placeholder="Ask a question..." />
<AnswerResults
  id="answer"
  searchBoxId="question"
  generator={generator}
  systemPrompt="You are a helpful research assistant."
  fields={["content", "title"]}
  semanticIndexes={["embeddings"]}
  showReasoning={true}
  showFollowUpQuestions={true}
/>

Props:

  • id (string, required): Unique identifier
  • searchBoxId (string, required): ID of the QueryBox that provides the search value
  • generator (object, required): LLM configuration — { provider: "antfly" | "ollama" | "openai" | "gemini" | "vertex", model, api_key? }
  • agentKnowledge (string): Additional domain context passed to the agent
  • systemPrompt (string): Custom system prompt for the LLM
  • generationContext (string): Additional generation-step context
  • table (string): Optional table override (inherits from QueryBox/Antfly if not specified)
  • filterQuery (object): Query to constrain search results
  • exclusionQuery (object): Query to exclude matches
  • fields (array): Fields to search for context
  • semanticIndexes (array): Vector indexes to search
  • limit (number): Results limit per search (default: 10)
  • followUpCount (number): Number of follow-up questions to request
  • eval (object): Inline evaluation config (EvalConfig from @antfly/sdk) to score the answer as it streams

Visibility Controls:

  • showClassification (boolean): Display query classification (default: false)
  • showReasoning (boolean): Display reasoning process (default: false)
  • showFollowUpQuestions (boolean): Display follow-up questions (default: true)
  • showConfidence (boolean): Display confidence assessment (default: false)
  • showHits (boolean): Display search results used for context (default: false)

Custom Renderers:

  • renderLoading (function): Custom loading state
  • renderEmpty (function): Custom empty state
  • renderClassification (function): (data) => ReactNode
  • renderReasoning (function): (reasoning, isStreaming) => ReactNode
  • renderAnswer (function): (answer, isStreaming, hits?) => ReactNode
  • renderConfidence (function): (confidence) => ReactNode
  • renderFollowUpQuestions (function): (questions) => ReactNode
  • renderHits (function): (hits) => ReactNode
  • renderEvalResult (function): (evalResult) => ReactNode

Callbacks:

  • onStreamStart / onStreamEnd (function): Lifecycle callbacks

  • onError (function): (error: string) => void

  • onClassification, onHit, onGenerationChunk, onConfidence, onFollowup: fine-grained streaming callbacks, one call per event

  • children (ReactNode): Rendered inside the same context — this is how <AnswerFeedback> (below) reads the live answer

Example with custom renderers:

<AnswerResults
  id="answer"
  searchBoxId="question"
  generator={generator}
  showReasoning={true}
  showFollowUpQuestions={true}
  renderAnswer={(answer, isStreaming, hits) => (
    <div className="custom-answer">
      <Streamdown>{answer}</Streamdown>
      {isStreaming && <span>Generating...</span>}
    </div>
  )}
  renderFollowUpQuestions={(questions) => (
    <div className="follow-up">
      <h4>Related Questions</h4>
      <ul>
        {questions.map((q, idx) => (
          <li key={idx}>
            <button onClick={() => handleQuestionClick(q)}>{q}</button>
          </li>
        ))}
      </ul>
    </div>
  )}
/>

Custom Answer Rendering#

For production applications, we recommend using streamdown.ai for rendering markdown with beautiful, streaming-aware components:

import Streamdown from 'streamdown';
import { replaceCitations } from '@antfly/components';

<AnswerResults
  id="answer"
  searchBoxId="question"
  generator={generator}
  renderAnswer={(answer, isStreaming, hits) => {
    // Convert citations to numbered links pointing at the hit anchors
    const withLinks = replaceCitations(answer, {
      renderCitation: (ids, allIds) =>
        ids
          .map((id) => `[${allIds.indexOf(id) + 1}](#source-${allIds.indexOf(id) + 1})`)
          .join(', '),
    });

    return (
      <div className="answer">
        <Streamdown>{withLinks}</Streamdown>
        {isStreaming && <span className="streaming-indicator">Generating...</span>}
      </div>
    );
  }}
/>

AnswerFeedback#

Collect user feedback on AI-generated answers with configurable rating systems. It must be rendered as a child of <AnswerResults> — it reads the live answer via useAnswerResultsContext internally.

import { AnswerFeedback, renderThumbsUpDown } from '@antfly/components';

<AnswerResults id="answer" searchBoxId="question" generator={generator}>
  <AnswerFeedback
    scale={1}
    renderRating={renderThumbsUpDown}
    onFeedback={({ feedback, result, query }) => {
      // Store feedback
      console.log('Rating:', feedback.rating); // 0 or 1
      console.log('Query:', query);
      console.log('Comment:', feedback.comment);
    }}
  />
</AnswerResults>

Built-in Renderers:

import {
  renderThumbsUpDown,  // Binary: 👍/👎 (scale=1)
  renderStars,         // 5-star: ⭐⭐⭐⭐⭐ (scale=4)
  renderNumeric        // Numeric: 0,1,2,3 (any scale)
} from '@antfly/components';

// Thumbs up/down
<AnswerFeedback
  scale={1}
  renderRating={renderThumbsUpDown}
  onFeedback={handleFeedback}
/>

// 5-star rating
<AnswerFeedback
  scale={4}
  renderRating={renderStars}
  onFeedback={handleFeedback}
/>

// Numeric scale (0-3)
<AnswerFeedback
  scale={3}
  renderRating={(rating, onRate) => renderNumeric(rating, onRate, 3)}
  onFeedback={handleFeedback}
/>

Custom Renderer:

const customRender = (currentRating, onRate) => {
  const options = [
    { emoji: "😞", label: "Poor", value: 0 },
    { emoji: "🙂", label: "Good", value: 2 },
    { emoji: "🤩", label: "Excellent", value: 4 }
  ];

  return (
    <div>
      {options.map(opt => (
        <button
          key={opt.value}
          onClick={() => onRate(opt.value)}
          className={currentRating === opt.value ? 'active' : ''}
        >
          {opt.emoji} {opt.label}
        </button>
      ))}
    </div>
  );
};

<AnswerFeedback
  scale={4}
  renderRating={customRender}
  renderComment={(comment, setComment) => (
    <textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Tell us more..." />
  )}
  onFeedback={handleFeedback}
/>

Props:

  • scale (number, required): Maximum rating value (e.g., 1 for binary, 4 for 5-star)
  • renderRating (function): Render function (currentRating, onRate) => ReactNode
  • renderComment (function): (comment, setComment) => ReactNode, shown after a rating is picked
  • renderSubmit (function): (onSubmit) => ReactNode
  • renderSubmitted (function): () => ReactNode, shown after submission instead of the default "Thank you" message
  • onFeedback (function, required): Callback with { feedback, result, query, context? }

Feedback Data:

{
  feedback: {
    rating: number,      // 0 to scale
    scale: number,       // Max value
    comment?: string     // Optional text, only present if a comment was entered
  },
  result: RetrievalAgentResult, // from @antfly/sdk — includes generation, hits, followup_questions, etc.
  query: string,
  context?: {
    classification?: { route_type: "question" | "search", confidence: number },
    reasoning?: string,
    agentKnowledge?: string
  }
}

Chat#

<ChatBar> wraps the Retrieval Agent for multi-turn conversations, keeping a full turn history instead of a single answer.

ChatBar#

import { Antfly, ChatBar } from '@antfly/components';

const generator = { provider: "ollama", model: "gemma3:4b" };

<Antfly url="http://127.0.0.1:8080/db/v1" table="docs">
  <ChatBar
    id="support-chat"
    generator={generator}
    semanticIndexes={["body_embeddings"]}
    fields={["title", "body"]}
    showHits
  />
</Antfly>

<ChatBar> renders a ChatMessages log and a ChatInput composer for you, and accepts all of their props directly (ChatBarProps extends both).

Props:

  • id (string, required): Unique identifier
  • generator (object, required): Same shape as AnswerResults.generator
  • table, semanticIndexes, fields, filterQuery, exclusionQuery: retrieval overrides, same shapes as AnswerResults
  • agentKnowledge, systemPrompt, limit, followUpCount: same meaning as AnswerResults
  • maxInternalIterations (number): Cap on agentic tool-call iterations per turn
  • tools (object): ChatToolsConfig from @antfly/sdk, enables agentic tool use
  • steps (object): RetrievalAgentSteps override for full control of the pipeline
  • onStreamStart, onStreamEnd, onError: lifecycle callbacks
  • children (ReactNode): rendered after the built-in messages/input, inside the same chat context
  • plus every prop from ChatMessages and ChatInput

ChatInput#

Renders the message composer. Configure it by passing its props directly on <ChatBar>; for a fully custom composer, call useChatContext() yourself instead of rendering <ChatInput>.

Props:

  • placeholder (string): default "Type a message..."
  • renderInput (function): ({ value, onChange, onSubmit, isStreaming, placeholder, abort }) => ReactNode
<ChatBar id="chat" generator={generator} placeholder="Ask about the docs…" />

ChatMessages#

Renders the turn log. Configure it via the same-named props on <ChatBar>.

Props:

  • showHits, showFollowUpQuestions, showConfidence (boolean): visibility toggles, mirroring AnswerResults
  • renderUserMessage (function): (message, turn) => ReactNode
  • renderAssistantMessage (function): (message, isStreaming, turn) => ReactNode
  • renderHits (function): (hits, turn) => ReactNode
  • renderFollowUpQuestions (function): (questions, onSelect, turn) => ReactNode
  • renderClarification (function): (clarification, onRespond, turn) => ReactNode, for agent clarifying questions
  • renderConfidence (function): (confidence, turn) => ReactNode
  • renderStreamingIndicator (function): () => ReactNode
  • renderError (function): (error, turn) => ReactNode
<ChatBar id="chat" generator={generator} showHits showConfidence />

Citation Utilities#

The @antfly/components package exports helper functions for parsing and rendering citations in AI-generated answers. Citations appear in the text as [resource_id 1, 2] or the shorthand [1, 2].

Available Functions:

import {
  parseCitations,
  replaceCitations,
  renderAsMarkdownLinks,
  renderAsSequentialLinks,
  getCitedDocumentIds,
  getCitedResourceIds
} from '@antfly/components';

parseCitations(text: string): Citation[]

Parses inline citations from the text. Each Citation is { originalText, ids, startIndex, endIndex }.

const citations = parseCitations("The system uses consensus [resource_id 1] and replication [2, 3].");
// [
//   { originalText: "[resource_id 1]", ids: ["1"], startIndex: 27, endIndex: 42 },
//   { originalText: "[2, 3]", ids: ["2", "3"], startIndex: 61, endIndex: 67 }
// ]

replaceCitations(text: string, options: { renderCitation: (ids, allCitationIds) => string })

Replaces every citation in the text with the string your renderCitation function returns.

const formatted = replaceCitations(
  "This is a fact [doc123].",
  { renderCitation: (ids) => ids.map((id) => `<sup><a href="#${id}">[${id}]</a></sup>`).join(', ') }
);

renderAsMarkdownLinks(ids: string[]): string

Renders a citation's IDs as markdown links to #hit-<id> anchors. Pair it with replaceCitations:

const withLinks = replaceCitations(answer, {
  renderCitation: (ids) => renderAsMarkdownLinks(ids),
});

renderAsSequentialLinks(ids: string[], allCitationIds: string[]): string

Same as above, but numbers citations by first-appearance order instead of using the raw IDs:

const numbered = replaceCitations(answer, {
  renderCitation: (ids, allIds) => renderAsSequentialLinks(ids, allIds),
});
// "This is a fact [[1]](#hit-doc123). Another fact [[2]](#hit-doc456)."

getCitedDocumentIds(text: string) / getCitedResourceIds(text: string)

Extract all cited document/resource IDs from the text, in order of first appearance (getCitedDocumentIds is a deprecated alias for getCitedResourceIds). Useful for filtering which source documents to display.

// renderAnswer's third argument is the hit list used to generate the answer,
// so no extra wiring is needed to cross-reference citations against sources.
<AnswerResults
  id="answer"
  searchBoxId="question"
  generator={generator}
  renderAnswer={(answer, isStreaming, hits = []) => {
    const citedIds = getCitedResourceIds(answer);
    const citedHits = hits.filter(hit => citedIds.includes(hit._id));
    return (
      <div>
        <Streamdown>{answer}</Streamdown>
        <div className="sources">
          <h4>Sources</h4>
          {citedHits.map(hit => (
            <div key={hit._id} id={hit._id}>{hit._source.title}</div>
          ))}
        </div>
      </div>
    );
  }}
/>

Advanced: Custom Citation Click Handlers

Create interactive citations that scroll to source documents by giving each source card a real id and linking to it:

import Streamdown from 'streamdown';
import { replaceCitations } from '@antfly/components';

<AnswerResults
  id="answer"
  searchBoxId="question"
  generator={generator}
  showHits
  renderAnswer={(answer, isStreaming, hits) => {
    const withClickableCitations = replaceCitations(answer, {
      renderCitation: (ids) =>
        ids
          .map((id) => {
            const index = hits.findIndex((h) => h._id === id);
            return index >= 0 ? `[${index + 1}](#source-${index + 1})` : `[${id}]`;
          })
          .join(', '),
    });

    return (
      <div>
        <Streamdown>{withClickableCitations}</Streamdown>
        <div className="sources">
          {hits.map((hit, idx) => (
            <div key={hit._id} id={`source-${idx + 1}`}>
              <strong>[{idx + 1}]</strong> {hit._source.title}
            </div>
          ))}
        </div>
      </div>
    );
  }}
/>

Hooks#

React Antfly provides React hooks for common search, chat, and AI-answer functionality.

useSearchHistory#

Manage search history with localStorage persistence.

import { useSearchHistory } from '@antfly/components';

function MyComponent() {
  const { history, isReady, upsertSearch, clearHistory } = useSearchHistory(10);

  // Save a search result (upserts by id)
  upsertSearch({
    id: "search-123",
    query: "how does raft work",
    timestamp: Date.now(),
    summary: "Raft is a consensus algorithm...",
    hits: [],
    citations: [{ id: "doc1", score: 0.95 }]
  });

  // Clear all history
  clearHistory();

  // Display history
  return (
    <ul>
      {history.map((result) => (
        <li key={result.id}>
          <strong>{result.query}</strong>
          <p>{result.summary}</p>
        </li>
      ))}
    </ul>
  );
}

Parameters:

  • maxResults (number): Maximum number of search results to store (default: 10, 0 to disable)

Returns:

  • history: array of SearchResult (most recent first)
  • isReady: always true once mounted (history loads synchronously from localStorage)
  • upsertSearch: insert or update a result by id
  • saveSearch: alias for upsertSearch
  • clearHistory: clear all history

useAnswerStream#

Stream Retrieval Agent responses with state management, independent of the <QueryBox>/<AnswerResults> widget wiring. This is what <AnswerResults> uses internally.

import { useAnswerStream } from '@antfly/components';

function MyComponent() {
  const {
    answer,
    reasoning,
    classification,
    hits,
    followUpQuestions,
    isStreaming,
    error,
    startStream,
    stopStream,
    reset
  } = useAnswerStream();

  // Start streaming
  const handleAsk = () => {
    startStream({
      url: 'http://127.0.0.1:8080/db/v1',
      request: {
        query: 'how does raft work',
        queries: [{
          table: 'docs',
          semantic_search: 'how does raft work',
          fields: ['content'],
          indexes: ['embeddings']
        }],
        generator: {
          provider: 'ollama',
          model: 'qwen2.5:7b'
        },
        steps: {
          generation: {},
          classification: { with_reasoning: true },
          followup: {}
        }
      },
      headers: { 'X-API-Key': 'key' }
    });
  };

  return (
    <div>
      <button onClick={handleAsk}>Ask</button>
      {isStreaming && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      {answer && <p>{answer}</p>}
      {followUpQuestions.map((q, i) => <li key={i}>{q}</li>)}
    </div>
  );
}

Returns:

  • answer, reasoning: streaming text
  • classification: query classification data
  • hits: search result hits
  • followUpQuestions: array of follow-up question strings
  • isStreaming: boolean streaming status
  • error: Error object if any
  • startStream: ({ url, request, headers }) => Promise<void>
  • stopStream: abort the current stream
  • reset: reset all state

useChatStream#

The lower-level, multi-turn hook behind <ChatBar>. It manages its own turns array and streaming state, so it works outside <Antfly> entirely.

import { useChatStream } from '@antfly/components';

function MyChat() {
  const { turns, isStreaming, sendMessage, abort, reset } = useChatStream();

  const ask = (text) =>
    sendMessage(text, {
      url: 'http://127.0.0.1:8080/db/v1',
      table: 'docs',
      generator: { provider: 'ollama', model: 'gemma3:4b' },
    });

  return (
    <div>
      {turns.map((turn) => (
        <p key={turn.id}>{turn.assistantMessage}</p>
      ))}
    </div>
  );
}

Returns:

  • turns: array of ChatTurn (userMessage, assistantMessage, hits, followUpQuestions, classification, confidence, clarification, steps, error, isStreaming, ...)
  • isStreaming: whether any turn is currently streaming
  • sendMessage: (text, config: ChatConfig) => Promise<void>
  • abort: abort the current stream
  • reset: clear all turns

useCitations#

Parse and render citations in RAG and Retrieval Agent responses.

import { useCitations } from '@antfly/components';

function MyComponent() {
  const {
    parseCitations,
    highlightCitations,
    extractCitationUrls,
    renderAsMarkdown,
    renderAsSequential
  } = useCitations();

  // Parse citations from answer text
  const citations = parseCitations("See docs [resource_id 1, 2]");

  // Get IDs of cited resources
  const citedIds = extractCitationUrls(answer);
  const citedHits = hits.filter(hit => citedIds.includes(hit._id));

  // Render with sequential numbering
  const formatted = renderAsSequential(['1', '2'], citedIds);

  return <div>{formatted}</div>;
}

Returns:

  • parseCitations: parse citation objects from text
  • highlightCitations: (text, options: CitationRenderOptions) => string
  • extractCitationUrls: extract cited document/resource IDs
  • renderAsMarkdown: (ids) => string, markdown links from raw IDs
  • renderAsSequential: (ids, allCitationIds) => string, markdown links numbered by first appearance

useSharedContext#

The low-level hook powering every widget: returns the [state, dispatch] tuple from the <Antfly> provider. Throws if called outside <Antfly>. This is what CustomWidget wraps.

const [{ url, table, widgets }, dispatch] = useSharedContext();

useAnswerResultsContext#

Returns the live AnswerResultsContextValue (query, answer, reasoning, hits, classification, confidence, followUpQuestions, isStreaming, result, evalResult) for a component rendered inside <AnswerResults>. This is how <AnswerFeedback> reads the current answer; call it directly to build custom feedback or citation UI.

useAutosuggestContext#

Returns the AutosuggestContextValue (query, results, facetData, selectedIndex, handleSelect, isLoading, registerItem, unregisterItem, fields) for a component rendered inside composable <Autosuggest> children. This is what <AutosuggestResults> and <AutosuggestFacets> use internally.

useChatContext#

Returns the ChatContextValue (turns, isStreaming, sendMessage, sendFollowUp, respondToClarification, abort, reset, config) for a component rendered inside <ChatBar>. This is what <ChatInput> and <ChatMessages> use internally.

Advanced Features#

Streamdown.ai Integration#

For production-quality answer and chat UIs with markdown rendering, we recommend integrating streamdown.ai, which provides streaming-aware markdown rendering optimized for AI-generated content.

Installation:

npm install streamdown
# or
yarn add streamdown

Complete Example:

import React from 'react';
import { QueryBox, AnswerResults, replaceCitations } from '@antfly/components';
import Streamdown from 'streamdown';

const generator = {
  provider: "ollama",
  model: "gemma3:4b"
};

const AnswerWithStreamdown = () => {
  return (
    <div>
      <QueryBox
        id="question"
        mode="submit"
        placeholder="Ask a question..."
      />

      <AnswerResults
        id="answer"
        searchBoxId="question"
        generator={generator}
        systemPrompt="You are a helpful assistant. Cite sources using [resource_id] format."
        fields={["content", "title"]}
        semanticIndexes={["embeddings"]}
        showHits
        renderAnswer={(answer, isStreaming, hits) => {
          // Convert citations to clickable numbered links
          const withClickableCitations = replaceCitations(answer, {
            renderCitation: (ids) =>
              ids
                .map((id) => {
                  const index = hits.findIndex((h) => h._id === id);
                  return index >= 0 ? `[${index + 1}](#source-${index + 1})` : `[${id}]`;
                })
                .join(', '),
          });

          return (
            <div className="answer">
              {/* Render markdown with streaming support */}
              <Streamdown>{withClickableCitations}</Streamdown>

              {isStreaming && (
                <span className="streaming-indicator">Generating...</span>
              )}

              {/* Display source documents */}
              {!isStreaming && hits.length > 0 && (
                <div className="sources">
                  <h4>Sources</h4>
                  {hits.map((hit, idx) => (
                    <div key={hit._id} id={`source-${idx + 1}`} className="source-card">
                      <div className="source-number">[{idx + 1}]</div>
                      <div className="source-content">
                        <h5>{hit._source.title || 'Untitled'}</h5>
                        <p>{hit._source.content?.substring(0, 200)}...</p>
                      </div>
                    </div>
                  ))}
                </div>
              )}
            </div>
          );
        }}
      />
    </div>
  );
};

export default AnswerWithStreamdown;

Why Streamdown?

  • Streaming-aware: Renders markdown smoothly as tokens arrive
  • Syntax highlighting: Built-in code block highlighting
  • Customizable: Full control over styling and components
  • Production-ready: Battle-tested in production RAG applications

URL State Management#

Sync search state with URL parameters for shareable searches:

import {
  toUrlQueryString,
  fromUrlQueryString
} from '@antfly/components';

const MyApp = () => {
  const [queryString, setQueryString] = useState("");
  const initialValues = fromUrlQueryString(window.location.search);

  return (
    <Antfly
      url="http://127.0.0.1:8080/db/v1"
      table="data"
      onChange={(values) => {
        const qs = toUrlQueryString(values);
        setQueryString(qs);
        window.history.pushState({}, '', `?${qs}`);
      }}
    >
      <QueryBox
        id="main"
        mode="live"
        initialValue={initialValues.get("main")}
      />
      <Results
        id="results"
        searchBoxId="main"
        initialPage={initialValues.get("resultsPage")}
        items={/* ... */}
      />
    </Antfly>
  );
};

Custom Queries#

Implement custom query logic for complex search requirements. customQuery is a prop on <Results> and <Autosuggest>, not on <QueryBox>:

const customQuery = (query, fields) => {
  return {
    bool: {
      should: fields.map(field => ({
        match: {
          [field]: {
            query: query,
            boost: field === "title" ? 2 : 1
          }
        }
      }))
    }
  };
};

<Autosuggest
  fields={["title", "content"]}
  customQuery={customQuery}
/>

Styling#

@antfly/components ships a baseline stylesheet at @antfly/components/styles (dist/components.css). It's not purely cosmetic: it also carries layout-critical CSS such as autosuggest dropdown positioning, pagination and facet list layout, and chat message spacing. Import it once and then override with your own CSS — every element carries a stable react-af-* class name (e.g. react-af-results, react-af-autosuggest-dropdown, react-af-chat-messages) you can target:

import '@antfly/components/styles';
<Results
  id="results"
  searchBoxId="search"
  className="my-results-container"
  items={(data) =>
    data.map(({ _source, _id }) => (
      <div key={_id} className="result-item">
        {/* Your styled content */}
      </div>
    ))
  }
/>

TypeScript Support#

React Antfly includes TypeScript definitions. Import types as needed:

import {
  Antfly,
  QueryBox,
  type FacetProps,
  type ResultsProps,
  type AnswerResultsProps,
  type ChatBarProps
} from '@antfly/components';

const MyFacet: React.FC<Partial<FacetProps>> = (props) => {
  return <Facet {...props} />;
};

Complete Example#

Here's a complete example of a movie search application:

import React from 'react';
import {
  Antfly,
  QueryBox,
  Autosuggest,
  Facet,
  Results,
  ActiveFilters
} from '@antfly/components';

const MovieSearch = () => {
  return (
    <Antfly url="http://127.0.0.1:8080/db/v1" table="movies">
      <div className="search-container">
        <header>
          <QueryBox id="search" mode="live" placeholder="Search movies...">
            <Autosuggest
              fields={["title", "tagline"]}
              returnFields={["title", "release_date", "vote_average"]}
              limit={5}
            />
          </QueryBox>
        </header>

        <div className="layout">
          <aside className="filters">
            <h3>Filters</h3>
            <Facet
              id="genre"
              fields={["genres.keyword"]}
              placeholder="Filter genres"
            />
            <Facet
              id="year"
              fields={["release_year"]}
              placeholder="Filter by year"
            />
            <Facet
              id="rating"
              fields={["vote_average_range"]}
              placeholder="Filter by rating"
            />
          </aside>

          <main className="results">
            <ActiveFilters />
            <Results
              id="results"
              searchBoxId="search"
              itemsPerPage={20}
              items={(data) =>
                data.map(({ _source: movie, _id }) => (
                  <div key={_id} className="movie-card">
                    <img
                      src={movie.poster_path}
                      alt={movie.title}
                    />
                    <div className="movie-info">
                      <h2>{movie.title}</h2>
                      <p className="tagline">{movie.tagline}</p>
                      <p className="overview">{movie.overview}</p>
                      <div className="meta">
                        <span>{movie.vote_average}</span>
                        <span>{movie.release_date}</span>
                      </div>
                    </div>
                  </div>
                ))
              }
            />
          </main>
        </div>
      </div>
    </Antfly>
  );
};

export default MovieSearch;

Performance Tips#

  1. Debounce Search Input: The library automatically debounces queries (15ms), but you can add custom debouncing for other operations
  2. Lazy Load Facets: Load facets on demand for better initial performance
  3. Virtualize Long Result Lists: Use libraries like react-window for large result sets
  4. Cache Results: The library includes built-in caching, but you can implement additional caching strategies

Browser Support#

React Antfly supports all modern browsers, targeting browsers with >0.03% global usage share.

Resources#

License#

React Antfly is released under the Apache 2.0 License.