Files
interactive-story/frontend/src/pages/Play/panels/KnowledgePanel.jsx
T
JesseMarkowitzandClaude Opus 5 1ce9972760 M8: the browser becomes the storyteller
The interface was AI-DnD's with this product's features bolted into it. The
navigation read Home · Adventures · Scenarios · Settings · AI Chat; starting a
story meant first picking a *world*, and making a world meant a JSON stat-schema
form, a story-card table and an art picker. The play screen had a Branches tab.
The input had three modes. Sixteen of the sixteen controls on a two-turn story
had no accessible name — they were single glyphs with a tooltip.

All of that was measured in a real browser before anything was changed, and the
measurements are in planning/reports/M8-IMPLEMENTATION-REPORT.md §C. Almost
nothing underneath was wrong: the play loop, the history controls, the takes,
the Save Points, the state correction and the knowledge library all worked. What
was wrong was what a reader was asked to understand in order to use them.

So the shape now is one entry point and one screen:

  Campaigns -> Campaign -> Story
                           State · Knowledge · Context · Save Points · Settings

Everything that is not the story lives in a panel that starts closed. The
top navigation bar is hidden on the story screen entirely, because on that one
screen the story is the interface.

Play is one natural-language field. An action and a piece of quoted dialogue are
both just what the reader wrote, and B01/B02 confirmed against a real narrator
that the model reads the quotes without being told which kind of turn it is.
What survives from the old Story mode is a Story direction toggle, which is not
a fourth mode: it changes who is being spoken to, not what kind of action is
taken, and the box is visibly marked while it is on.

Branch, fork, node, merge and head appear nowhere a reader can see them. The
branch panel and the tree overlay are gone from the browser. The mechanism is
untouched — takes, divergence, retained futures and Save Points all still work,
and their endpoints are still tested. This is a decision about what a reader is
asked to understand, not a reduction of what the product can do.

The two defects worth the space:

A player action is stored with AI Dungeon's "> You " prefix. That was right when
the Do mode asked for a bare verb phrase. With one field the spec tells the
reader to write "I enter the tavern", and the result was "> You I enter the
tavern." — in the transcript, in the replayed history, and therefore in the
narration, where a small model imitates it and writes "You I thank her". M8's
own design surfaced it, so M8 fixed it: the prefix is added only when the reader
has not already written a subject. The ">" marker, which is what actually
identifies a player turn in the prompt, is unchanged in every case.

And a stale `.input-bar { display: flex }` in play.css overrode the new
composer, because that sheet is imported after the new one. The direction row
and the input row laid out side by side and the box was unusably narrow. Found
by opening the product in a browser, not by reading the CSS — which is the
argument for having done that first.

Failures now have the taxonomy the spec asked for rather than one toast: model,
generation, state, knowledge, server, each with the thing to do about it. A
failed turn leaves the reader's words in the box and says so. The classification
reads backend strings, so it is a fallback ladder rather than a lookup — an
unrecognised message still classifies, still shows the server's own words and
still offers Retry.

`Settings.model` could be empty with nothing saying so until the first turn
failed with a provider error. The header now reports Ollama in five states, and
an unconfigured or missing model offers the models actually installed on the
endpoint, from the connection test that already knew them. Nothing is chosen
automatically: an endpoint's first model may be an embedding model, which cannot
narrate at all.

Narrator prose is rendered as safe Markdown — headings, emphasis, lists,
blockquotes, code. The safety is structural rather than filtered: every node is
a React element built from parsed text, and there is no dangerouslySetInnerHTML
in the file. A sanitizer is not needed to make markup safe if markup is never
produced from input. Link schemes are checked with the URL parser rather than a
pattern, because the bypasses are all in the parsing. A remote image is a
placeholder naming the blocked address; the knowledge and context panels
deliberately do not use this renderer at all, because they exist to show a
reader exactly what is in their file.

Backend, and only what the browser could not otherwise reach:

  AdventureCreate.opening   a start action could only come from a Scenario, so
                            every campaign made in the new setup flow opened on
                            a blank page. Same node, same code path.
  canon_rules               campaign_canon has been the highest authority in a
                            campaign since M5, read by the prompt builder and
                            the state validator, and had no API at all — a
                            fixture had to write it with SQL.
  a 401 and a 429 message   the last user-facing text describing a hosted
                            deployment. One told the reader to check an API key
                            that has not existed since M2.

No schema change and no migration: proved by building a database with a server
running the M7 commit's own code and opening it with this one.

The project had no frontend tests. It has 132 now, across ten files, running
in about six seconds — the enabled state of every history control, the take
selector, the confirmations, the panels, the five model states, the failure
taxonomy, the focus trap, accessibility, and that the reserved dictation control
never touches the microphone. Writing them found a real defect: the focus trap
filtered candidates with offsetParent, which is null inside the fixed-position
ancestor the dialog has and which jsdom never computes — it would have behaved
differently in the tests from the browser.

They do not replace the real-browser runs, and both kinds of evidence are in the
report. The browser suites drive the production build served by the real backend
with a real local narrator, including a genuine process restart.

A verification pass over all of it then found three more, each by driving the
product rather than reading it:

Stepping between alternate takes did nothing. The pager asked whether a take
lived on another line by comparing `target.branch_id !== action.branch_id`, and
`ActionOut` has never carried `branch_id` — so the comparison was permanently
`number !== undefined`, always true, and every step took the branch-switch path.
For two takes of an ordinary retry, which share a line until one is written
below, that meant switching to the line already being read: the same window came
back and nothing moved. D07 is a required v1 acceptance test. The fix needed no
new field — the variants list already carries every attempt's branch and marks
the live one.

The first regression test for that passed against the broken code, because its
fixture gave the action a `branch_id` the real payload never sends. That is the
exact failure M7's review was about, so the fixture was corrected, the tests were
re-run against the reverted code and failed for the right reason, and the
fixture now carries a docstring saying why the field must never come back.

And the knowledge panel pointed readers at an "embedding model" while the
setting is called "Model for meaning-based search" — a reader sent looking for a
field that does not exist by that name.

Campaign canon was measured rather than assumed. Editing it after play is a
configuration change: every turn already played keeps the canon it was actually
given, in its own context snapshot, and the accepted story, the state document
and the state audit log are byte-identical across an edit. It is not routed
through M5's state audit, because canon is not narrative state and doing so
would create the second representation the spec forbids. What the editor does
now is say so, once a campaign has moments.

`BROWSER-UX-SPEC.md` §38 asked for a "Show Hidden Story State" toggle. There is
no hidden story state — a secret lives in a narrator-only knowledge source and
never enters the state document. The section is rewritten to require what it
actually meant: ordinary surfaces must not carry narrator-only information,
advanced inspection must withhold it by default behind an explicit warned
choice, and no second store may be invented to give a toggle something to
reveal. The protection is stricter than before, not weaker.

Closeout. An independent review returned M8 IMPLEMENTATION: PASS subject to
evidence and documentation cleanup, and this commit carries that cleanup:

The report named two frontend bundles as the artifact behind its acceptance
evidence. The saved run logs settle it. index-Ii-lARp9.js, built at 18:53:02
from this tree, is the one final frozen artifact behind all 157 browser checks;
index-C6E5Uvtu.js is superseded — it predates the D09 fix and its acceptance
suite ended 54/55 on exactly that defect. No tracked file under backend/app or
frontend/src has a modification time after the freeze, so the whole final
campaign describes one build. §P sets the two side by side.

Finding 14 — the app budgets 16,384 prompt tokens while an Ollama that sees no
VRAM enforces 4,096 — is resolved operationally, with no application change.
The OpenAI-compatible endpoint this app speaks accepts num_ctx and ignores it,
and reloads the model at its own default, so a native call cannot prime it
either. A model derived with POST /api/create carries the parameter, is honoured
through the app's own OpenAI-compatible path, and appears in /v1/models — which
is the listing the Settings model picker already reads. Measured end to end.
The procedure is in DEVELOPMENT.md; nothing in the repository depends on any
particular derived model existing. Adding provider code to work around this was
declined deliberately: it would mean either a second native request path,
against ADR 011, or a parameter the endpoint provably ignores.

The §38 rewrite is ratified as a requirement clarification aligned with the
implemented architecture, and the spec gains the clause finding 3 was really
about: withheld material must be absent from the rendered DOM, not merely
collapsed in it.

The report's §U carries the M9 handoff — what a portable campaign has to include,
whether historical context snapshots belong in the bundle, what happens to
inherited story cards, and that a restored campaign may meet a different context
window than the one that wrote it. None of it is implemented here.

Final: backend 950 passed / 14 skipped; frontend 132 passed; lint, production
build and Docker build clean; 157 browser checks across six suites, zero
failures. M8 is implemented, verified, reviewed and accepted (2026-09-06).
M9 has not been started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HdaXiFbscatQaLS7dJk6b
2026-09-06 23:31:45 -04:00

573 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The imported knowledge library.
*
* M7 built every behaviour here and M8 owns how it reads
* (`BROWSER-UX-SPEC.md` §46-52, §21-22 of the M8 brief). What changed is
* presentation and vocabulary, not capability: the same imports, the same
* classification, enable/disable, narrator-only, always-include, delete,
* inspect and rebuild.
*
* **Nothing here renders imported text as HTML.** Source text and passage text
* both go into a `<pre>` as React children, which React escapes — so a
* `<script>` in a file is visible characters, a `javascript:` URL is never an
* href, and a remote Markdown image reference creates no `<img>` and therefore
* makes no request (H06, H07, G09). The safe Markdown renderer the transcript
* uses is deliberately *not* used here: this panel exists to show a reader
* exactly what is in their file, and rendering is the opposite of that.
* `SECURITY-THREAT-MODEL.md` §14 names sanitized presentation text as the safer
* default; rendering no markup at all is one step safer still.
*
* ## The classification is explained, not iconified
*
* §48 is explicit that the three classes must not rely on unexplained icons,
* because the difference between them is the whole feature: it decides the
* words a passage is framed with in the prompt, the weight it carries in
* ranking, and which budget it competes in. The explanations are on the import
* form where the decision is made, not in a tooltip.
*
* ## Semantic status
*
* §22. Three states have to be told apart and the inherited line ran them
* together: searching by meaning is on; it is off because no embedding model is
* configured, which is a supported setup; or a model *is* configured and is not
* being used because M7 has no measured calibration for it, so retrieval
* degraded to keyword-only. The third is the one that reads as a mysterious
* failure if it is not explained, and it is the one M7's closeout added.
*
* No threshold is exposed. `0.58` is a measured property of one embedding
* model, not a preference, and a slider over it would invite a reader to
* recreate the defect M7's review found.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../../../api'
import { ConfirmDialog } from '../../../Dialog'
const CLASSES = [
{
value: 'canon',
label: 'Canon',
hint: 'Authoritative truth for this campaign. The narrator may not contradict it.',
},
{
value: 'reference',
label: 'Reference',
hint: 'Supporting information. It informs description but does not establish story truth.',
},
{
value: 'inspiration',
label: 'Inspiration',
hint: 'Creative and style influence only. Never a source of fact.',
},
]
const CLASS_LABEL = Object.fromEntries(CLASSES.map((c) => [c.value, c.label]))
function bytes(n) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`
return `${(n / 1024 / 1024).toFixed(2)} MB`
}
function when(iso) {
if (!iso) return ''
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString()
}
/** The three classes, said in full. Shown where the choice is made. */
function ClassLegend() {
return (
<dl className="class-legend">
{CLASSES.map((c) => (
<div key={c.value}>
<dt><span className={`class-tag class-${c.value}`}>{c.label}</span></dt>
<dd>{c.hint}</dd>
</div>
))}
</dl>
)
}
function SourceDetail({ advId, source, onError }) {
const [detail, setDetail] = useState(null)
const [chunks, setChunks] = useState(null)
const [showText, setShowText] = useState(false)
const [showChunks, setShowChunks] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
// The error reporter reaches this component through a ref rather than the
// effect's dependencies. It arrives as a fresh arrow on every render of the
// Play screen — which re-renders on every keystroke in the story box — so
// depending on it would refetch the source once per character typed. Found by
// M7's browser run; the ref keeps the *current* reporter without making it a
// reason to re-run.
const report = useRef(onError)
report.current = onError
useEffect(() => {
let stale = false
setDetail(null)
api.getKnowledgeSource(advId, source.id)
.then((r) => { if (!stale) setDetail(r) })
.catch((e) => { if (!stale) report.current(e.message) })
return () => { stale = true }
}, [advId, source.id])
const loadChunks = () => {
setShowChunks((open) => !open)
if (chunks === null) {
api.getKnowledgeChunks(advId, source.id)
.then(setChunks).catch((e) => report.current(e.message))
}
}
return (
<div className="knowledge-detail">
<dl className="knowledge-facts">
<div><dt>File</dt><dd>{source.original_filename}</dd></div>
<div><dt>Imported</dt><dd>{when(source.imported_at)}</dd></div>
<div><dt>Size</dt><dd>{bytes(source.byte_size)}</dd></div>
<div>
<dt>Passages</dt>
<dd>{source.chunk_count} · {source.embedded_count} searchable by meaning</dd>
</div>
</dl>
<div className="knowledge-detail-buttons">
<button type="button" onClick={() => setShowText((open) => !open)}>
{showText ? 'Hide the text' : 'Read the text'}
</button>
<button type="button" onClick={loadChunks}>
{showChunks ? 'Hide passages' : `Show the ${source.chunk_count} passages`}
</button>
</div>
{showText && (
detail
? <pre className="knowledge-text" data-testid="knowledge-source-text">{detail.content}</pre>
: <p className="dim">Loading…</p>
)}
{showChunks && (
chunks
? chunks.map((chunk) => (
<div key={chunk.id} className="knowledge-chunk">
<div className="dim">
passage {chunk.chunk_index + 1}
{chunk.heading_path && ` · ${chunk.heading_path}`}
{' '}· {chunk.token_count} tokens
{chunk.embedded ? ' · searchable by meaning' : ' · keyword search only'}
</div>
<pre className="knowledge-text">{chunk.text}</pre>
</div>
))
: <p className="dim">Loading…</p>
)}
{/* §49's advanced half: the content identity and the parser versions. Of
interest when comparing two copies of a file, and noise otherwise. */}
<details className="knowledge-advanced" open={showAdvanced}
onToggle={(e) => setShowAdvanced(e.currentTarget.open)}>
<summary>Technical details</summary>
<dl className="knowledge-facts">
<div><dt>Type</dt><dd>{source.media_type}</dd></div>
<div>
<dt>Versions</dt>
<dd>parser v{source.parser_version} · chunking v{source.chunking_version}</dd>
</div>
</dl>
{/* In full rather than shortened: it is here to be compared against
another copy, and half a digest compares nothing. */}
<p className="knowledge-hash">SHA-256: {source.content_hash}</p>
</details>
</div>
)
}
function SourceRow({ advId, source, open, onToggle, onChange, onDelete, onError }) {
const [confirming, setConfirming] = useState(false)
return (
<li
className={`knowledge-row ${source.enabled ? '' : 'disabled'}`}
data-source-id={source.id}
data-classification={source.classification}
>
<div className="knowledge-head">
<button
type="button"
className="knowledge-title"
aria-expanded={open}
onClick={onToggle}
>
<span aria-hidden="true">{open ? '▾' : '▸'}</span> {source.title}
</button>
<span className={`class-tag class-${source.classification}`}>
{CLASS_LABEL[source.classification]}
</span>
{!source.enabled && <span className="knowledge-badge">not in use</span>}
{source.visibility === 'hidden' && (
<span className="hidden-tag">narrator only</span>
)}
{source.always_include && <span className="knowledge-badge">always included</span>}
{source.index_state === 'failed' && (
<span className="knowledge-badge failed" title={source.index_detail}>
could not be indexed
</span>
)}
{source.embed_state === 'failed' && (
<span className="knowledge-badge failed" title={source.embed_detail}>
keyword search only
</span>
)}
</div>
<p className="knowledge-sub">
{/* A title defaults to the filename without its extension, so two files
differing only by type would otherwise look identical in the list. */}
{source.original_filename && source.original_filename !== source.title && (
<span>{source.original_filename} · </span>
)}
<span>imported {when(source.imported_at)}</span>
<span> · {source.chunk_count} passage{source.chunk_count === 1 ? '' : 's'}</span>
</p>
<div className="knowledge-controls">
<label>
<span className="sr-only">Classification for {source.title}</span>
<select
value={source.classification}
aria-label={`Classification for ${source.title}`}
onChange={(e) => onChange(source, { classification: e.target.value })}
>
{CLASSES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</label>
<label>
<input
type="checkbox"
checked={source.enabled}
onChange={(e) => onChange(source, { enabled: e.target.checked })}
/>
{' '}In use
</label>
<label>
<input
type="checkbox"
checked={source.visibility === 'hidden'}
onChange={(e) => onChange(source, {
visibility: e.target.checked ? 'hidden' : 'normal',
})}
/>
{' '}Narrator only
</label>
{/* Canon's alone. The server enforces it too — this only stops the
control offering something that would be silently ignored. */}
{source.classification === 'canon' && (
<label>
<input
type="checkbox"
checked={source.always_include}
onChange={(e) => onChange(source, { always_include: e.target.checked })}
/>
{' '}Always include
</label>
)}
<button type="button" className="danger" onClick={() => setConfirming(true)}>
Delete
</button>
</div>
{open && <SourceDetail advId={advId} source={source} onError={onError} />}
{confirming && (
<ConfirmDialog
title={`Delete “${source.title}”?`}
confirmLabel="Delete this source"
destructive
onCancel={() => setConfirming(false)}
onConfirm={() => { setConfirming(false); onDelete(source) }}
>
<p>
It stops being used from now on, and the story will no longer draw
on it.
</p>
<p>
Turns that already used it are unchanged — each one keeps its own
record of what the narrator was given.
</p>
<p className="dim">
If you only want to stop using it for a while, turn off “In use”
instead. That is reversible.
</p>
</ConfirmDialog>
)}
</li>
)
}
/** §22, in three cases rather than a boolean. */
function SemanticStatus({ status }) {
if (!status) return null
const model = status.embedding_model
if (status.semantic_enabled) {
return (
<p className="dim" data-testid="semantic-status" data-state="on">
Searching by keyword and by meaning.
{status.pending_embeddings > 0
&& ` ${status.pending_embeddings} passage(s) are still being prepared.`}
</p>
)
}
if (!model) {
return (
<p className="dim" data-testid="semantic-status" data-state="lexical-only">
Searching by keyword. Setting a <em>model for meaning-based search</em> in
Settings would let it search by meaning as well — keyword search alone is
a supported setup, and it is what finds names and invented terms.
</p>
)
}
// Configured but not calibrated: the M7 closeout case.
return (
<p className="notice-detail" data-testid="semantic-status" data-state="uncalibrated">
Searching by keyword only. <code>{model}</code>, the model set for
meaning-based search, has not been measured for this in this build, so it
is not used to decide what is relevant — borrowing another model’s
measurement would let unrelated material through. Keyword search is
unaffected.
{status.calibrated_models?.length > 0 && (
<> Measured so far: {status.calibrated_models.join(', ')}.</>
)}
</p>
)
}
const NO_OP = () => {}
function KnowledgePanel({ advId, refreshKey, onError, focusSourceId }) {
const [sources, setSources] = useState(null)
const [status, setStatus] = useState(null)
const [file, setFile] = useState(null)
const [classification, setClassification] = useState('canon')
const [title, setTitle] = useState('')
const [hidden, setHidden] = useState(false)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [duplicate, setDuplicate] = useState(null)
const [openId, setOpenId] = useState(null)
const load = useCallback(() => {
api.listKnowledge(advId).then(setSources).catch(() => setSources([]))
api.getKnowledgeStatus(advId).then(setStatus).catch(() => setStatus(null))
}, [advId])
useEffect(() => { load() }, [load, refreshKey])
// Opened from a retrieved passage in the Context panel (§57).
useEffect(() => {
if (focusSourceId != null) setOpenId(focusSourceId)
}, [focusSourceId])
const doImport = async (allowDuplicate = false) => {
if (!file) return
setBusy(true)
setNotice(null)
try {
await api.importKnowledge(advId, file, {
classification,
title,
visibility: hidden ? 'hidden' : 'normal',
allow_duplicate: allowDuplicate,
})
setFile(null)
setTitle('')
setDuplicate(null)
// A file input cannot be controlled, so it is cleared through the DOM.
// Without this, re-picking the same file after a failed import fires no
// change event and the button does nothing.
const input = document.getElementById('knowledge-file')
if (input) input.value = ''
setNotice('Imported.')
load()
} catch (err) {
if (err.conflict) setDuplicate(err.conflict)
setNotice(err.message)
} finally {
setBusy(false)
}
}
const change = async (source, data) => {
try {
const updated = await api.updateKnowledgeSource(advId, source.id, data)
setSources((prev) => prev.map((s) => (s.id === source.id ? updated : s)))
} catch (err) { onError?.(err.message) }
}
const remove = async (source) => {
try {
await api.deleteKnowledgeSource(advId, source.id)
setSources((prev) => prev.filter((s) => s.id !== source.id))
load()
} catch (err) { onError?.(err.message) }
}
const reindex = async () => {
setBusy(true)
setNotice(null)
try {
const out = await api.reindexKnowledge(advId)
setNotice(
`Rebuilt ${out.chunks} passage${out.chunks === 1 ? '' : 's'} across `
+ `${out.sources} source${out.sources === 1 ? '' : 's'}`
+ (out.semantic ? `, ${out.embedded} searchable by meaning.` : '.'),
)
load()
} catch (err) {
setNotice(err.message)
} finally { setBusy(false) }
}
return (
<div className="knowledge-panel">
<p className="dim">
Local <code>.txt</code> and <code>.md</code> files this campaign can draw
on. Nothing is uploaded anywhere, no link in a file is ever followed, and
text in a file is never treated as an instruction to the application.
</p>
<details className="knowledge-import-block" open={sources?.length === 0}>
<summary>Import a file</summary>
<div className="knowledge-import">
<div className="field">
<label htmlFor="knowledge-file">Choose a file</label>
<input
id="knowledge-file"
type="file"
accept=".txt,.md,text/plain,text/markdown"
onChange={(e) => { setFile(e.target.files?.[0] || null); setDuplicate(null) }}
/>
</div>
<fieldset className="knowledge-class-choice">
<legend>How should the story treat it?</legend>
{CLASSES.map((c) => (
<label key={c.value} className="class-choice">
<input
type="radio"
name="knowledge-class"
value={c.value}
checked={classification === c.value}
onChange={() => setClassification(c.value)}
/>
<span className={`class-tag class-${c.value}`}>{c.label}</span>
<span className="class-choice-hint">{c.hint}</span>
</label>
))}
</fieldset>
<div className="field">
<label htmlFor="knowledge-title">Title (optional)</label>
<input
id="knowledge-title"
type="text"
value={title}
placeholder="Defaults to the filename"
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<label className="knowledge-hidden-choice">
<input
type="checkbox"
checked={hidden}
onChange={(e) => setHidden(e.target.checked)}
/>
{' '}Narrator only — the narrator knows it, your character does not
</label>
<button
type="button"
className="primary"
id="knowledge-import"
disabled={!file || busy}
onClick={() => doImport(false)}
>
{busy ? 'Working…' : 'Import file'}
</button>
</div>
</details>
{notice && <div className="knowledge-notice" id="knowledge-notice">{notice}</div>}
{duplicate && (
<div className="knowledge-notice">
Already imported as “{duplicate.title}” ({CLASS_LABEL[duplicate.classification]}).{' '}
<button type="button" onClick={() => doImport(true)}>
Import a second copy anyway
</button>
</div>
)}
{status && (
<div className="knowledge-status">
<SemanticStatus status={status} />
{status.failed_index.length > 0 && (
<p className="notice-detail error">
{status.failed_index.length} file(s) could not be indexed and cannot
be found by search.
</p>
)}
{status.failed_embedding.length > 0 && (
<p className="notice-detail">
{status.failed_embedding.length} file(s) could not be prepared for
meaning-based search. Keyword search still finds them.
</p>
)}
</div>
)}
<div className="knowledge-list-head">
<h3>In this campaign {sources && `(${sources.length})`}</h3>
<button
type="button"
onClick={reindex}
disabled={busy}
title="Rebuild passages and search indexes from the stored text. Your story is untouched."
>
Rebuild search
</button>
</div>
{!sources && <p className="dim">Loading…</p>}
{sources && sources.length === 0 && (
<div className="knowledge-empty">
<p>Nothing imported yet.</p>
<p className="dim">
A setting bible, character notes, a research file, or a passage whose
style you want the prose to feel like.
</p>
<ClassLegend />
</div>
)}
{sources?.length > 0 && (
<ul className="knowledge-list">
{sources.map((source) => (
<SourceRow
key={source.id}
advId={advId}
source={source}
open={openId === source.id}
onToggle={() => setOpenId(openId === source.id ? null : source.id)}
onChange={change}
onDelete={remove}
onError={onError || NO_OP}
/>
))}
</ul>
)}
</div>
)
}
export { KnowledgePanel, ClassLegend, SemanticStatus, CLASSES, CLASS_LABEL }