This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"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.
|
||||
|
||||
import { type ProjectSlugs, uploadFile, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, 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
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
type State = { kind: "idle" } | { kind: "busy" } | { kind: "done" } | { kind: "failed"; detail: string }
|
||||
|
||||
export function IndexForm({
|
||||
slugs,
|
||||
collectionName,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collectionName: string
|
||||
labels: IndexFormLabels
|
||||
}): ReactElement {
|
||||
const transport = useTransport()
|
||||
const { call, describe } = transport
|
||||
const inputId = useId()
|
||||
const hintId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const index = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const file = new FormData(form).get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const stored = await uploadFile(transport, slugs, file)
|
||||
await call("knowledge.document.index", { ...slugs, collectionName, fileId: stored.fileId })
|
||||
form.reset()
|
||||
setState({ kind: "done" })
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${inputId}-heading`}>
|
||||
<h2 id={`${inputId}-heading`}>{labels.heading}</h2>
|
||||
<form onSubmit={(event) => void index(event)}>
|
||||
<label htmlFor={inputId}>{labels.label}</label>
|
||||
<input
|
||||
id={inputId}
|
||||
name="file"
|
||||
type="file"
|
||||
required
|
||||
aria-describedby={hintId}
|
||||
accept={INDEXABLE_CONTENT_TYPES.join(",")}
|
||||
/>
|
||||
<p id={hintId}>
|
||||
{labels.hint} · {Math.round(MAX_INDEX_BYTES / 1024)} KB.
|
||||
</p>
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
{state.kind === "done" ? <p role="status">{labels.done}</p> : null}
|
||||
{state.kind === "failed" ? <p role="alert">{labels.failed.replace("{detail}", state.detail)}</p> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { IndexFormLabels } from "./index-form.js"
|
||||
export { IndexForm } from "./index-form.js"
|
||||
export type { SearchFormLabels, StatusLabels } from "./labels.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,79 @@
|
||||
// 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.
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** 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 disclosure: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
export function searchFormLabels(t: Translate, failed: string): 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"),
|
||||
results: t("search.results", { count: "{count}" }),
|
||||
snippet: t("search.snippet"),
|
||||
source: t("search.source"),
|
||||
chunk: t("search.chunk", { chunk: "{chunk}" }),
|
||||
disclosure: t("search.disclosure"),
|
||||
failed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// The knowledge module's page, as the one server component the web shell mounts under
|
||||
// `/<org>/<workspace>/<project>/knowledge`. Every read is a procedure through the same caller the
|
||||
// API uses; 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, TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import type { ProjectPage } from "@conusai-kit/ui"
|
||||
import type { ReactElement } from "react"
|
||||
import {
|
||||
type CollectionView,
|
||||
collectionList,
|
||||
type DocumentView,
|
||||
documentList,
|
||||
knowledgeStatus,
|
||||
} from "../contract/index.js"
|
||||
import { IndexForm, type IndexFormLabels } from "./index-form.js"
|
||||
import { searchFormLabels, serviceStateLabel, statusLabels } from "./labels.js"
|
||||
import { SearchForm } from "./search-form.js"
|
||||
|
||||
export type KnowledgePage = ProjectPage<TransportCaller, I18n, Container>
|
||||
|
||||
const indexFormLabels = (
|
||||
t: (key: string, values?: Record<string, string | number>) => string,
|
||||
failed: 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"),
|
||||
failed,
|
||||
})
|
||||
|
||||
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 = async ({ slugs, caller, i18n }): Promise<ReactElement> => {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`knowledge.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
|
||||
const [{ collections }, status] = await Promise.all([
|
||||
caller.call(collectionList, slugs),
|
||||
caller.call(knowledgeStatus, slugs),
|
||||
])
|
||||
const documentsByName = new Map<string, DocumentView[]>()
|
||||
await Promise.all(
|
||||
collections.map(async (collection: CollectionView) => {
|
||||
const answer = await caller.call(documentList, { ...slugs, collectionName: collection.name })
|
||||
documentsByName.set(collection.name, answer.documents)
|
||||
}),
|
||||
)
|
||||
|
||||
const labels = statusLabels(t, status)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{t("title")}</h1>
|
||||
<p>{t("intro")}</p>
|
||||
<section aria-labelledby="knowledge-status">
|
||||
<h2 id="knowledge-status">{labels.heading}</h2>
|
||||
{status.enabled ? (
|
||||
<dl>
|
||||
<dt>{labels.state}</dt>
|
||||
<dd>{serviceStateLabel(t, status.service.state)}</dd>
|
||||
{status.service.reasons.length > 0 ? (
|
||||
<>
|
||||
<dt>{labels.reasons}</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{status.service.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>{labels.indexing}</dt>
|
||||
<dd>{labels.documents}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<p role="status">{labels.flagOff}</p>
|
||||
)}
|
||||
</section>
|
||||
{status.enabled
|
||||
? collections.map((collection) => (
|
||||
<section key={collection.name} aria-labelledby={`collection-${collection.name}`}>
|
||||
<h2 id={`collection-${collection.name}`}>{collection.name}</h2>
|
||||
<IndexForm slugs={slugs} collectionName={collection.name} labels={indexFormLabels(t, failed)} />
|
||||
<SearchForm slugs={slugs} collectionName={collection.name} labels={searchFormLabels(t, failed)} />
|
||||
<table>
|
||||
<caption>{t("documents.heading")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t("documents.file")}</th>
|
||||
<th scope="col">{t("documents.status")}</th>
|
||||
<th scope="col">{t("documents.error")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(documentsByName.get(collection.name) ?? []).map((document) => (
|
||||
<tr key={document.documentId}>
|
||||
<td>{document.filename}</td>
|
||||
<td>{t(documentStatusKey[document.status])}</td>
|
||||
<td>{document.error ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"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 { AiDisclosureBadge, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
|
||||
import { type FormEvent, type ReactElement, useId, useState } from "react"
|
||||
import type { SearchResult } from "../contract/index.js"
|
||||
import type { SearchFormLabels } from "./labels.js"
|
||||
|
||||
type State =
|
||||
| { kind: "idle" }
|
||||
| { kind: "busy" }
|
||||
| { kind: "done"; results: SearchResult[] }
|
||||
| { kind: "failed"; detail: string }
|
||||
|
||||
export function SearchForm({
|
||||
slugs,
|
||||
collectionName,
|
||||
labels,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
collectionName: string
|
||||
labels: SearchFormLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const inputId = useId()
|
||||
const [state, setState] = useState<State>({ kind: "idle" })
|
||||
|
||||
const search = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
const query = String(new FormData(event.currentTarget).get("query") ?? "").trim()
|
||||
if (query === "") return
|
||||
setState({ kind: "busy" })
|
||||
try {
|
||||
const answer = (await call("knowledge.search", { ...slugs, collectionName, query })) as {
|
||||
results: SearchResult[]
|
||||
}
|
||||
setState({ kind: "done", results: answer.results })
|
||||
} catch (error) {
|
||||
setState({ kind: "failed", detail: describe(error) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${inputId}-heading`}>
|
||||
<h2 id={`${inputId}-heading`}>{labels.heading}</h2>
|
||||
<AiDisclosureBadge label={labels.disclosure} />
|
||||
<form onSubmit={(event) => void search(event)}>
|
||||
<label htmlFor={inputId}>{labels.label}</label>
|
||||
<input id={inputId} name="query" type="search" placeholder={labels.placeholder} required maxLength={500} />
|
||||
<button type="submit" disabled={state.kind === "busy"}>
|
||||
{state.kind === "busy" ? labels.busy : labels.submit}
|
||||
</button>
|
||||
</form>
|
||||
{state.kind === "failed" ? <p role="alert">{labels.failed.replace("{detail}", state.detail)}</p> : null}
|
||||
{state.kind === "done" ? (
|
||||
state.results.length === 0 ? (
|
||||
<p>{labels.empty}</p>
|
||||
) : (
|
||||
<ol aria-label={labels.results.replace("{count}", String(state.results.length))}>
|
||||
{state.results.map((result, at) => (
|
||||
<li key={`${result.fileId}-${result.chunk}-${at}`}>
|
||||
<blockquote>{result.snippet}</blockquote>
|
||||
<p>
|
||||
{labels.source}: {result.filename} · {labels.chunk.replace("{chunk}", String(result.chunk))}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
// 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 vector.
|
||||
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { type ReactElement, useId, useState } from "react"
|
||||
|
||||
export interface SettingsSectionLabels {
|
||||
readonly heading: string
|
||||
readonly state: string
|
||||
readonly stateOn: string
|
||||
readonly stateOff: string
|
||||
readonly backend: string
|
||||
readonly backendVectors: string
|
||||
readonly source: string
|
||||
readonly sourceOperator: string
|
||||
readonly sourceManaged: string
|
||||
readonly endpoint: 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; source: string | null; url: string | null }
|
||||
labels: SettingsSectionLabels
|
||||
}): ReactElement {
|
||||
const { call, describe } = useTransport()
|
||||
const id = useId()
|
||||
const [enabled, setEnabled] = useState(initial.enabled)
|
||||
const [view, setView] = useState<View>({ kind: "idle" })
|
||||
|
||||
const enable = async (): Promise<void> => {
|
||||
setView({ kind: "busy" })
|
||||
try {
|
||||
await call("knowledge.settings.enableDocumentAi", { org })
|
||||
setEnabled(true)
|
||||
setView({ kind: "message", text: labels.enabledDone, alert: false })
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).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: describe(error), alert: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disable = async (): Promise<void> => {
|
||||
setView({ kind: "busy" })
|
||||
try {
|
||||
await call("knowledge.settings.disableDocumentAi", { org })
|
||||
setEnabled(false)
|
||||
setView({ kind: "message", text: labels.disabledDone, alert: false })
|
||||
} catch (error) {
|
||||
setView({ kind: "message", text: describe(error), alert: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby={`${id}-heading`}>
|
||||
<h2 id={`${id}-heading`}>{labels.heading}</h2>
|
||||
<p>{labels.hint}</p>
|
||||
<dl>
|
||||
<dt>{labels.state}</dt>
|
||||
<dd>{enabled ? labels.stateOn : labels.stateOff}</dd>
|
||||
{initial.backend !== null ? (
|
||||
<>
|
||||
<dt>{labels.backend}</dt>
|
||||
<dd>{initial.backend === "vectors" ? labels.backendVectors : initial.backend}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{initial.source !== null ? (
|
||||
<>
|
||||
<dt>{labels.source}</dt>
|
||||
<dd>{initial.source === "operator" ? labels.sourceOperator : labels.sourceManaged}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{initial.url !== null ? (
|
||||
<>
|
||||
<dt>{labels.endpoint}</dt>
|
||||
<dd>{initial.url}</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>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user