Files
2026-11-14 09:56:45 +00:00

92 lines
3.2 KiB
TypeScript

"use client"
// The search form: one question, cited answers. The disclosure the EU AI Act Article 50 asks for
// rides with the results, and every answer names its source and chunk — a person checks a passage
// against the file, never a model's word.
import { formatMessage } from "@conusai-kit/i18n/format"
import { AiDisclosureBadge, type ProjectSlugs } from "@conusai-kit/ui"
import { useDataClient } from "@conusai-kit/ui/data"
import { FormLayout, type FormValues, PageSection, TextField } from "@conusai-kit/ui/patterns"
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
import { type ReactElement, useState } from "react"
import type { SearchResult } from "../contract/index.js"
import { type RefusalNotice, refusalOf, type SearchFormLabels } from "./labels.js"
export function SearchForm({
slugs,
collectionName,
labels,
locale,
}: {
slugs: ProjectSlugs
collectionName: string
labels: SearchFormLabels
locale: string
}): ReactElement {
const client = useDataClient()
const [results, setResults] = useState<SearchResult[] | null>(null)
const [refused, setRefused] = useState<RefusalNotice | undefined>(undefined)
const search = async ({ query = "" }: FormValues): Promise<void> => {
setResults(null)
setRefused(undefined)
try {
const { value: answer } = await client.call<{ results: SearchResult[] }>("knowledge.search", {
...slugs,
collectionName,
query,
})
setResults(answer.results)
} catch (error) {
// A refusal of the service has its own words. Anything else is FormLayout's to show.
const notice = refusalOf(labels.refusals, error)
if (notice === undefined) throw error
setRefused(notice)
}
}
return (
<PageSection title={labels.heading} level={3} variant="card">
<AiDisclosureBadge label={labels.disclosure} />
<FormLayout submitLabel={labels.submit} busyLabel={labels.busy} onSubmit={search}>
<TextField
name="query"
type="search"
label={labels.label}
placeholder={labels.placeholder}
required
maxLength={500}
/>
</FormLayout>
{refused ? (
<Alert variant="warning" data-state={refused.code}>
<AlertDescription>{refused.text}</AlertDescription>
</Alert>
) : null}
{results === null ? null : results.length === 0 ? (
<p>{labels.empty}</p>
) : (
<ol aria-label={formatMessage(labels.results, { count: results.length }, locale)}>
{results.map((result, at) => (
<li key={`${result.fileId}-${result.chunk}-${at}`}>
<blockquote
data-knowledge-chunk={result.chunkId}
data-source-page={result.page}
data-source-block={result.blockId}
>
{result.snippet}
</blockquote>
<p>
{labels.source}: {result.filename} · {labels.chunk.replace("{chunk}", String(result.chunk))}
{result.page === undefined ? null : ` · ${labels.page.replace("{page}", String(result.page))}`}
{result.blockId ? <code> {result.blockId}</code> : null}
</p>
</li>
))}
</ol>
)}
</PageSection>
)
}