@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
// The project's automatic PDF indexing (core-11): an administrator picks the collection uploaded PDFs
|
||||
// go into, or turns it off. The write answers the new state, so the form shows it without re-running
|
||||
// the page's loader (the module declares live collections; test/live-writes.test.ts).
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { FormLayout, PageSection, SelectField } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface AutoIndexFormLabels {
|
||||
readonly heading: string
|
||||
readonly hint: string
|
||||
readonly label: string
|
||||
readonly off: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly done: string
|
||||
}
|
||||
|
||||
/** The select's value for "off": a collection name is lower-case letters, digits and dashes, never this. */
|
||||
const OFF = "_off"
|
||||
|
||||
export function AutoIndexForm({
|
||||
slugs,
|
||||
collections,
|
||||
current,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collections: readonly string[]
|
||||
current: string | null
|
||||
labels: AutoIndexFormLabels
|
||||
}): ReactElement {
|
||||
const setAutoIndex = useWrite<ProjectSlugs & { collectionName: string | null }, { collectionName: string | null }>(
|
||||
"knowledge.autoindex.set",
|
||||
{ org: slugs.org },
|
||||
)
|
||||
const [selected, setSelected] = useState<string | null>(current)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
const save = async (_: unknown, data: FormData): Promise<void> => {
|
||||
const value = data.get("collection")
|
||||
const collectionName = typeof value === "string" && value !== OFF ? value : null
|
||||
setDone(false)
|
||||
const answer = await setAutoIndex.mutate({ ...slugs, collectionName })
|
||||
setSelected(answer.collectionName)
|
||||
setDone(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading} level={3} variant="card">
|
||||
<p>{labels.hint}</p>
|
||||
<FormLayout submitLabel={labels.submit} busyLabel={labels.busy} onSubmit={save}>
|
||||
<SelectField
|
||||
name="collection"
|
||||
label={labels.label}
|
||||
defaultValue={selected ?? OFF}
|
||||
options={[{ value: OFF, label: labels.off }, ...collections.map((name) => ({ value: name, label: name }))]}
|
||||
/>
|
||||
</FormLayout>
|
||||
{done ? <p role="status">{labels.done}</p> : null}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
// Upload and index: the browser sends the file to the files module (the kit's `uploadFile`), then
|
||||
// asks this module to index it (`knowledge.document.index`). The indexing itself is a worker job;
|
||||
// this form only ever waits on the request, which resolves once the live stream has seen the document's
|
||||
// row, so the collection's live list already shows it queued.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import { FormLayout, PageSection, TextField } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { INDEXABLE_CONTENT_TYPES, MAX_INDEX_BYTES } from "../contract/index.js"
|
||||
|
||||
export interface IndexFormLabels {
|
||||
readonly heading: string
|
||||
readonly label: string
|
||||
readonly hint: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly done: string
|
||||
}
|
||||
|
||||
export function IndexForm({
|
||||
slugs,
|
||||
collectionName,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collectionName: string
|
||||
labels: IndexFormLabels
|
||||
}): ReactElement {
|
||||
const upload = useUpload()
|
||||
const indexDocument = useWrite<ProjectSlugs & { collectionName: string; fileId: string }, unknown>(
|
||||
"knowledge.document.index",
|
||||
{ org: slugs.org },
|
||||
)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
const index = async (_: unknown, data: FormData): Promise<void> => {
|
||||
const file = data.get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
setDone(false)
|
||||
const stored = await upload(slugs, file)
|
||||
await indexDocument.mutate({ ...slugs, collectionName, fileId: stored.fileId })
|
||||
setDone(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading} level={3} variant="card">
|
||||
<FormLayout submitLabel={labels.submit} busyLabel={labels.busy} onSubmit={index}>
|
||||
<TextField
|
||||
name="file"
|
||||
type="file"
|
||||
label={labels.label}
|
||||
description={`${labels.hint} · ${Math.round(MAX_INDEX_BYTES / 1024)} KB.`}
|
||||
accept={INDEXABLE_CONTENT_TYPES.join(",")}
|
||||
required
|
||||
/>
|
||||
</FormLayout>
|
||||
{done ? <p role="status">{labels.done}</p> : null}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type { IndexFormLabels } from "./index-form.js"
|
||||
export { IndexForm } from "./index-form.js"
|
||||
export type { SearchFormLabels, StatusLabels } from "./labels.js"
|
||||
export { CollectionDocuments, type DocumentListLabels, useLiveDocuments } from "./live-documents.js"
|
||||
export { knowledgePage } from "./pages.js"
|
||||
export { SearchForm } from "./search-form.js"
|
||||
export type { SettingsSectionLabels } from "./settings-section.js"
|
||||
export { SettingsSection } from "./settings-section.js"
|
||||
@@ -0,0 +1,118 @@
|
||||
// The labels each component takes, built from a message lookup. Every key this module's pages use
|
||||
// is named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
|
||||
// catalogues and fail on a key either language lacks.
|
||||
|
||||
import { KNOWLEDGE_REFUSALS, type KnowledgeRefusal } from "../contract/index.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** A typed refusal as a page or a form shows it. */
|
||||
export interface RefusalNotice {
|
||||
readonly code: KnowledgeRefusal
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
export type RefusalLabels = Readonly<Record<KnowledgeRefusal, string>>
|
||||
|
||||
export const refusalLabels = (t: Translate): RefusalLabels =>
|
||||
Object.fromEntries(KNOWLEDGE_REFUSALS.map((code) => [code, t(`refusals.${code}`)])) as RefusalLabels
|
||||
|
||||
/** The typed refusal a procedure answered, or undefined for any other error (which the form words itself). */
|
||||
export function refusalOf(labels: RefusalLabels, error: unknown): RefusalNotice | undefined {
|
||||
const code = (error as { problem?: { code?: unknown } } | null)?.problem?.code
|
||||
return typeof code === "string" && code in labels
|
||||
? { code: code as KnowledgeRefusal, text: labels[code as KnowledgeRefusal] }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** The words for a failure a document row records: the file's own, or the service's refusal; else the code. */
|
||||
export function failureText(t: Translate, code: string): string {
|
||||
return failureLabels(t)[code] ?? code
|
||||
}
|
||||
|
||||
/** The failures a document row can name with words of their own: the file's, and the service's refusals. */
|
||||
const FILE_FAILURES = ["file_changed", "not_text", "not_indexable", "too_large"] as const
|
||||
|
||||
/** The words of each failure `failureText` knows, by code, for a page that words a live row in the browser. */
|
||||
export const failureLabels = (t: Translate): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries([
|
||||
...KNOWLEDGE_REFUSALS.map((code) => [code, t(`refusals.${code}`)]),
|
||||
...FILE_FAILURES.map((code) => [code, t(`failures.${code}`)]),
|
||||
])
|
||||
|
||||
/** The status shape the labels render, declared here: ui reads no core/. */
|
||||
interface StatusCounts {
|
||||
documents: { queued: number; indexing: number; indexed: number; failed: number; cancelled: number }
|
||||
}
|
||||
|
||||
export interface StatusLabels {
|
||||
readonly heading: string
|
||||
readonly flagOff: string
|
||||
readonly state: string
|
||||
readonly reasons: string
|
||||
readonly indexing: string
|
||||
readonly documents: string
|
||||
}
|
||||
|
||||
export function statusLabels(t: Translate, status: StatusCounts): StatusLabels {
|
||||
return {
|
||||
heading: t("status.heading"),
|
||||
flagOff: t("status.flagOff"),
|
||||
state: t("status.state"),
|
||||
reasons: t("status.reasons"),
|
||||
indexing: t("status.indexing"),
|
||||
documents: t("status.documents", status.documents),
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceStateLabel = (t: Translate, state: string): string => {
|
||||
switch (state) {
|
||||
case "ready":
|
||||
return t("status.stateReady")
|
||||
case "indexing":
|
||||
return t("status.stateIndexing")
|
||||
case "registered":
|
||||
return t("status.stateRegistered")
|
||||
case "degraded":
|
||||
return t("status.stateDegraded")
|
||||
case "disabled":
|
||||
return t("status.stateDisabled")
|
||||
default:
|
||||
return t("status.stateUnavailable")
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchFormLabels {
|
||||
readonly heading: string
|
||||
readonly label: string
|
||||
readonly placeholder: string
|
||||
readonly submit: string
|
||||
readonly busy: string
|
||||
readonly empty: string
|
||||
readonly results: string
|
||||
readonly snippet: string
|
||||
readonly source: string
|
||||
readonly chunk: string
|
||||
readonly page: string
|
||||
readonly disclosure: string
|
||||
readonly refusals: RefusalLabels
|
||||
}
|
||||
|
||||
export function searchFormLabels(t: Translate): SearchFormLabels {
|
||||
return {
|
||||
heading: t("search.heading"),
|
||||
label: t("search.label"),
|
||||
placeholder: t("search.placeholder"),
|
||||
submit: t("search.submit"),
|
||||
busy: t("search.busy"),
|
||||
empty: t("search.empty"),
|
||||
// Not filled in here: only the browser knows the count, and `formatMessage` brings the plural rules.
|
||||
results: t("search.results"),
|
||||
snippet: t("search.snippet"),
|
||||
source: t("search.source"),
|
||||
chunk: t("search.chunk", { chunk: "{chunk}" }),
|
||||
page: t("search.page", { page: "{page}" }),
|
||||
disclosure: t("search.disclosure"),
|
||||
refusals: refusalLabels(t),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
// One collection's documents, live (umbrella TS6.4 and TS6.6): the page's view renders them and hands them
|
||||
// over as the seed, so the page arrives complete; this keeps them current through the organization's live
|
||||
// stream -- a document another member asks to index appears, and each moves from queued to indexed or
|
||||
// failed as the worker and the platform answer, without a reload. The rows come from
|
||||
// `knowledge.document.getMany`, under the same permission as the list; a topic is the whole project, so the
|
||||
// rows of the project's other collections are filtered out here.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { eq, liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
|
||||
import { type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { DOCUMENT_COLLECTION, type DocumentView } from "../contract/index.js"
|
||||
|
||||
export interface DocumentListLabels {
|
||||
readonly caption: string
|
||||
readonly file: string
|
||||
readonly status: string
|
||||
readonly error: string
|
||||
/** The message of each document status, by status. */
|
||||
readonly statuses: Readonly<Record<DocumentView["status"], string>>
|
||||
/** The words of each failure code a row can carry; an unknown code is shown as it is. */
|
||||
readonly failures: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export function useLiveDocuments(
|
||||
slugs: ProjectSlugs,
|
||||
collectionName: string,
|
||||
seed: readonly DocumentView[],
|
||||
): readonly DocumentView[] {
|
||||
const client = useDataClient()
|
||||
// One collection per knowledge collection for the life of the page; the seed is the first render's.
|
||||
const [documents] = useState(() =>
|
||||
liveCollection<DocumentView>({
|
||||
client,
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entity: DOCUMENT_COLLECTION.entity,
|
||||
key: DOCUMENT_COLLECTION.key,
|
||||
id: `${DOCUMENT_COLLECTION.entity}:${slugs.org}:${slugs.workspace}:${slugs.project}:${collectionName}`,
|
||||
seed,
|
||||
list: {
|
||||
procedure: DOCUMENT_COLLECTION.list,
|
||||
input: { ...slugs, collectionName },
|
||||
rows: (out) => (out as { documents: DocumentView[] }).documents,
|
||||
},
|
||||
getMany: {
|
||||
procedure: DOCUMENT_COLLECTION.getMany,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { items: DocumentView[] }).items,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const { data, isReady } = useLive(documents, (q, rows) =>
|
||||
q
|
||||
.from({ document: rows })
|
||||
.where(({ document }: { document: DocumentView }) => eq(document.collectionName, collectionName)),
|
||||
)
|
||||
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
|
||||
return isReady ? data : seed
|
||||
}
|
||||
|
||||
export function CollectionDocuments({
|
||||
slugs,
|
||||
collectionName,
|
||||
seed,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collectionName: string
|
||||
seed: readonly DocumentView[]
|
||||
labels: DocumentListLabels
|
||||
}): ReactElement {
|
||||
const rows = useLiveDocuments(slugs, collectionName, seed)
|
||||
const columns: ResourceColumn<DocumentView>[] = [
|
||||
{ id: "filename", header: labels.file, wrap: true },
|
||||
{ id: "status", header: labels.status, cell: (document) => labels.statuses[document.status] },
|
||||
{
|
||||
id: "error",
|
||||
header: labels.error,
|
||||
wrap: true,
|
||||
cell: (document) => (document.error === null ? "" : (labels.failures[document.error] ?? document.error)),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<ResourceList
|
||||
label={labels.caption}
|
||||
columns={columns}
|
||||
data={rows}
|
||||
rowId={(document) => document.documentId}
|
||||
rowLabel={(document) => document.filename}
|
||||
empty={{ title: labels.caption }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// The knowledge module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
|
||||
// shell runs on the server under `/<org>/<workspace>/<project>/knowledge`, and the view of what it
|
||||
// returned. Every read is a procedure through the same caller the API uses, and every label is translated
|
||||
// in the loader; every write is a client component that calls a procedure through the shell's transport.
|
||||
//
|
||||
// The page renders three things separately on purpose: the service's health (the module flag and
|
||||
// the search service's readiness), the indexing indicator (the per-document progress states), and
|
||||
// the search itself — "indexing" is not "down", and the page never conflates them.
|
||||
|
||||
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
|
||||
import { FactList, Page, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
autoIndexGet,
|
||||
type CollectionView,
|
||||
collectionList,
|
||||
type DocumentView,
|
||||
documentList,
|
||||
knowledgeStatus,
|
||||
} from "../contract/index.js"
|
||||
import { AutoIndexForm, type AutoIndexFormLabels } from "./auto-index-form.js"
|
||||
import { IndexForm, type IndexFormLabels } from "./index-form.js"
|
||||
import {
|
||||
failureLabels,
|
||||
type RefusalNotice,
|
||||
refusalLabels,
|
||||
type SearchFormLabels,
|
||||
type StatusLabels,
|
||||
searchFormLabels,
|
||||
serviceStateLabel,
|
||||
statusLabels,
|
||||
} from "./labels.js"
|
||||
import { CollectionDocuments, type DocumentListLabels } from "./live-documents.js"
|
||||
import { SearchForm } from "./search-form.js"
|
||||
|
||||
/** One collection and its documents: the seed of its live list. */
|
||||
export interface KnowledgeCollectionRow {
|
||||
readonly name: string
|
||||
readonly documents: readonly DocumentView[]
|
||||
}
|
||||
|
||||
export interface KnowledgePageData {
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly status: {
|
||||
readonly enabled: boolean
|
||||
readonly labels: StatusLabels
|
||||
/** The service's state in the reader's language. */
|
||||
readonly state: string
|
||||
readonly reasons: readonly string[]
|
||||
/** Why the service will not serve this deployment, when that is the answer. */
|
||||
readonly refusal?: RefusalNotice
|
||||
}
|
||||
readonly collections: readonly KnowledgeCollectionRow[]
|
||||
readonly documents: DocumentListLabels
|
||||
readonly index: IndexFormLabels
|
||||
readonly search: SearchFormLabels
|
||||
/** The project's automatic PDF indexing: the collection it fills, or null when it is off. */
|
||||
readonly autoIndex: { readonly current: string | null; readonly labels: AutoIndexFormLabels }
|
||||
}
|
||||
|
||||
export type KnowledgePage = ProjectPage<TransportCaller, I18n, Container, KnowledgePageData>
|
||||
|
||||
const indexFormLabels = (t: (key: string, values?: Record<string, string | number>) => string): IndexFormLabels => ({
|
||||
heading: t("upload.heading"),
|
||||
label: t("upload.label"),
|
||||
hint: t("upload.hint"),
|
||||
submit: t("upload.submit"),
|
||||
busy: t("upload.busy"),
|
||||
done: t("upload.done"),
|
||||
})
|
||||
|
||||
const documentStatusKey: Record<DocumentView["status"], string> = {
|
||||
queued: "documents.statusQueued",
|
||||
indexing: "documents.statusIndexing",
|
||||
indexed: "documents.statusIndexed",
|
||||
failed: "documents.statusFailed",
|
||||
cancelled: "documents.statusCancelled",
|
||||
}
|
||||
|
||||
export const knowledgePage: KnowledgePage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<KnowledgePageData> {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`knowledge.${key}`, values)
|
||||
if (segments.length > 0) throw notFound("no such page")
|
||||
|
||||
const [{ collections }, status, auto] = await Promise.all([
|
||||
caller.call(collectionList, slugs),
|
||||
caller.call(knowledgeStatus, slugs),
|
||||
caller.call(autoIndexGet, slugs),
|
||||
])
|
||||
const rows = await Promise.all(
|
||||
collections.map(async (collection: CollectionView): Promise<KnowledgeCollectionRow> => {
|
||||
const { documents } = await caller.call(documentList, { ...slugs, collectionName: collection.name })
|
||||
return { name: collection.name, documents }
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
intro: t("intro"),
|
||||
status: {
|
||||
enabled: status.enabled,
|
||||
labels: statusLabels(t, status),
|
||||
state: serviceStateLabel(t, status.service.state),
|
||||
reasons: status.service.reasons,
|
||||
...(status.service.refusal === undefined
|
||||
? {}
|
||||
: {
|
||||
refusal: {
|
||||
code: status.service.refusal,
|
||||
text: refusalLabels(t)[status.service.refusal],
|
||||
},
|
||||
}),
|
||||
},
|
||||
collections: rows,
|
||||
documents: {
|
||||
caption: t("documents.heading"),
|
||||
file: t("documents.file"),
|
||||
status: t("documents.status"),
|
||||
error: t("documents.error"),
|
||||
statuses: Object.fromEntries(
|
||||
Object.entries(documentStatusKey).map(([status, key]) => [status, t(key)]),
|
||||
) as DocumentListLabels["statuses"],
|
||||
failures: failureLabels(t),
|
||||
},
|
||||
index: indexFormLabels(t),
|
||||
search: searchFormLabels(t),
|
||||
autoIndex: {
|
||||
current: auto.collectionName,
|
||||
labels: {
|
||||
heading: t("autoIndex.heading"),
|
||||
hint: t("autoIndex.hint"),
|
||||
label: t("autoIndex.label"),
|
||||
off: t("autoIndex.off"),
|
||||
submit: t("autoIndex.submit"),
|
||||
busy: t("autoIndex.busy"),
|
||||
done: t("autoIndex.done"),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, locale, data }: ProjectPageViewProps<KnowledgePageData>): ReactElement {
|
||||
const { labels, enabled, state, reasons, refusal } = data.status
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.title} description={data.intro} />
|
||||
<PageSection title={labels.heading} variant="card">
|
||||
{refusal ? (
|
||||
<Alert variant="warning" role="status" data-state={refusal.code}>
|
||||
<AlertDescription>{refusal.text}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{enabled ? (
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: labels.state, value: state },
|
||||
...(reasons.length > 0
|
||||
? [
|
||||
{
|
||||
label: labels.reasons,
|
||||
value: (
|
||||
<ul>
|
||||
{reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: labels.indexing, value: labels.documents },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p role="status">{labels.flagOff}</p>
|
||||
)}
|
||||
</PageSection>
|
||||
{enabled && data.collections.length > 0 ? (
|
||||
<AutoIndexForm
|
||||
slugs={slugs}
|
||||
collections={data.collections.map((collection) => collection.name)}
|
||||
current={data.autoIndex.current}
|
||||
labels={data.autoIndex.labels}
|
||||
/>
|
||||
) : null}
|
||||
{enabled
|
||||
? data.collections.map((collection) => (
|
||||
<PageSection key={collection.name} title={collection.name}>
|
||||
<IndexForm slugs={slugs} collectionName={collection.name} labels={data.index} />
|
||||
<SearchForm slugs={slugs} collectionName={collection.name} labels={data.search} locale={locale} />
|
||||
<CollectionDocuments
|
||||
slugs={slugs}
|
||||
collectionName={collection.name}
|
||||
seed={collection.documents}
|
||||
labels={data.documents}
|
||||
/>
|
||||
</PageSection>
|
||||
))
|
||||
: null}
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// The enablement controls of the organization's settings page: the current state, the enable
|
||||
// button with its typed refusal reasons (plan, policy, quota), and disable — which preserves
|
||||
// every collection, document and the service's index.
|
||||
|
||||
import { describeError, useWrite } from "@conusai-kit/ui/data"
|
||||
import { PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface SettingsSectionLabels {
|
||||
readonly heading: string
|
||||
readonly state: string
|
||||
readonly stateOn: string
|
||||
readonly stateOff: string
|
||||
readonly backend: string
|
||||
readonly backendPlatform: string
|
||||
readonly enable: string
|
||||
readonly enabling: string
|
||||
readonly disable: string
|
||||
readonly disabling: string
|
||||
readonly enabledDone: string
|
||||
readonly disabledDone: string
|
||||
readonly planRequired: string
|
||||
readonly featureDisabled: string
|
||||
readonly quotaExhausted: string
|
||||
readonly upgrade: string
|
||||
readonly hint: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type View = { kind: "idle" } | { kind: "busy" } | { kind: "message"; text: string; alert: boolean }
|
||||
|
||||
export function SettingsSection({
|
||||
org,
|
||||
initial,
|
||||
labels,
|
||||
}: {
|
||||
org: string
|
||||
initial: { enabled: boolean; backend: string | null }
|
||||
labels: SettingsSectionLabels
|
||||
}): ReactElement {
|
||||
const enableAi = useWrite<{ org: string }, unknown>("knowledge.settings.enableDocumentAi")
|
||||
const disableAi = useWrite<{ org: string }, unknown>("knowledge.settings.disableDocumentAi")
|
||||
const [enabled, setEnabled] = useState(initial.enabled)
|
||||
const [view, setView] = useState<View>({ kind: "idle" })
|
||||
|
||||
const enable = async (): Promise<void> => {
|
||||
setView({ kind: "busy" })
|
||||
try {
|
||||
await enableAi.mutate({ org })
|
||||
setEnabled(true)
|
||||
setView({ kind: "message", text: labels.enabledDone, alert: false })
|
||||
} catch (error) {
|
||||
const code = (error as { problem?: { code?: string } } | null)?.problem?.code ?? "unknown"
|
||||
if (code === "plan_required") {
|
||||
setView({ kind: "message", text: `${labels.planRequired} ${labels.upgrade}`, alert: true })
|
||||
} else if (code === "quota_exhausted") {
|
||||
setView({ kind: "message", text: labels.quotaExhausted, alert: true })
|
||||
} else if (code === "feature_disabled") {
|
||||
setView({ kind: "message", text: labels.featureDisabled, alert: true })
|
||||
} else {
|
||||
setView({ kind: "message", text: describeError(error), alert: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disable = async (): Promise<void> => {
|
||||
setView({ kind: "busy" })
|
||||
try {
|
||||
await disableAi.mutate({ org })
|
||||
setEnabled(false)
|
||||
setView({ kind: "message", text: labels.disabledDone, alert: false })
|
||||
} catch (error) {
|
||||
setView({ kind: "message", text: describeError(error), alert: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection title={labels.heading}>
|
||||
<p>{labels.hint}</p>
|
||||
<dl>
|
||||
<dt>{labels.state}</dt>
|
||||
<dd>{enabled ? labels.stateOn : labels.stateOff}</dd>
|
||||
{initial.backend !== null ? (
|
||||
<>
|
||||
<dt>{labels.backend}</dt>
|
||||
<dd>{labels.backendPlatform}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{view.kind === "message" ? <p role={view.alert ? "alert" : "status"}>{view.text}</p> : null}
|
||||
{enabled ? (
|
||||
<Button type="button" disabled={view.kind === "busy"} onClick={() => void disable()}>
|
||||
{view.kind === "busy" ? labels.disabling : labels.disable}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" disabled={view.kind === "busy"} onClick={() => void enable()}>
|
||||
{view.kind === "busy" ? labels.enabling : labels.enable}
|
||||
</Button>
|
||||
)}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user