WikiN — project handbook
WikiN is the example's living documentation: the program's CAD/BIM standards, file-naming conventions, software versions, and routines. It is the handbook everyone on a building works from, and the source the proactive agent quotes when a chat question is factual ("which Revit family is used for interior doors?").
It is project-scoped: every WikiN page lives under /project/$projectKey, so you are
always reading one building's handbook. The UI is read-only browse + a per-article Edit
— there is no per-row permission model, because the handbook is shared by the whole program.
(The global surfaces — the home, Carola, universal
search, and the admin area — aren't project-scoped.)
Coming from Django? Think of WikiN as a Django flatpages/wiki app —
CategoryandArticlemodels served by a couple of "views". The twist: an article'sbodyis stored as rich text (ProseMirror/Tiptap JSON), with a separate plain-text projection (bodyText) that full-text search and the AI agent read. The "views" are server functions, and the agent retrieves answers from the table with Postgres FTS. See Coming from Django.
Where it lives
The logic lives in the @tikab-interactive/pulsn-apps package
(/wikin subpath); the example is a thin consumer.
| Layer | Where it lives |
|---|---|
| Logic (package) | @tikab-interactive/pulsn-apps/wikin — schema, handbook reads + collaborative edit + Swedish-FTS searchWiki |
| Transport | thin createServerFn wrappers in src/lib/wikin-server.ts; the agent's search via src/lib/knowledge-search.ts |
| UI | fusion-ui's Handbook element + WikiArticleView; the example wires the detail panel in src/components/wiki/WikiArticle.tsx |
| Routes (pages) | src/routes/_authed.project.$projectKey.wikin.tsx · src/routes/_authed.wikin.tsx (legacy → /home) |
Data model
-
wikin_category— a handbook section ("CAD/BIM-standarder", "Filhantering", "Mjukvara"…). Carriesname,slug,description,icon, andsortOrderfor the index. -
wikin_article— one document, joined to a category bycategoryIdand scoped to a building byprojectKey(its route key, e.g."EKEN-A"). Plustitle,slug,summary,discipline("Alla" | "A" | "K" | "V" | "E"),tags, andupdatedByName. -
The distinctive bit — two bodies. Each article stores both:
body(jsonb) — the rich-text ProseMirror/Tiptap document, rendered by fusion-ui'sWikiArticleView. This is what a human sees.bodyText(text) — a flat plain-text projection of that document. This is what full-text search indexes and what the agent quotes.
You render the rich JSON but you search the flat text — never the JSON, which is structural noise. On save,
updateWikinArticlewrites both at once (the editor derives the text viaeditor.getText()).
Pages
| URL | What it shows |
|---|---|
/project/$projectKey/wikin | the handbook index — sections, each listing its articles, with a client-side search box |
/project/$projectKey/wikin (article open) | clicking an article opens it in the right detail panel as a read-only WikiArticleView with an Edit button |
/wikin | legacy global route — has no building context, so it redirects to /home (pick a building first) |
Articles are loaded by bun run db:seed — the seed populates
each building's handbook (~100 rows) across the standard sections, so search and the agent
have real documentation to answer from.
How it loads
The index is a textbook loader → server function.
The loader calls getWikinIndex, which returns the whole handbook in one read — sections
(sorted) each with their articles — small enough that the page filters/searches client-side:
export const Route = createFileRoute("/_authed/project/$projectKey/wikin")({
loader: ({ params }) => getWikinIndex({ data: { projectKey: params.projectKey } }),
component: HandbookPage,
});The Handbook component reads Route.useLoaderData() (WikinSection[] for this building)
and a TextInput filters the already-loaded index in-memory — no extra round-trip.
Opening an article fetches its full rich-text body on demand — WikiArticlePanel calls
getWikinArticle({ data: { slug } }), which innerJoins the category and returns the
body JSON for WikiArticleView to render.
Asking the handbook (RAG / search)
This is WikiN's distinctive role. searchWiki is exposed to Carola as the
search_project_wiki tool, so she quotes the handbook when you ask her something factual. It
runs Postgres full-text search over the articles in searchWiki (knowledge-search.ts):
Swedish-stemmed, ts_rank-ordered, with an ILIKE-token fallback for recall. It is
air-gap safe — no embedder, no vector store, just to_tsvector/websearch_to_tsquery
in the database. See AI for the provider story.
const tsv = sql`to_tsvector('swedish', ${wikinArticle.title} || ' ' || ${wikinArticle.summary} || ' ' || ${wikinArticle.bodyText} || ' ' || ${wikinArticle.tags}::text)`;
const tsq = sql`websearch_to_tsquery('swedish', ${query})`;
const fts = await db
.select(cols)
.from(wikinArticle)
.innerJoin(wikinCategory, eq(wikinArticle.categoryId, wikinCategory.id))
.where(and(sql`${tsv} @@ ${tsq}`, scope))
.orderBy(desc(sql`ts_rank(${tsv}, ${tsq})`))
.limit(limit);Important — two gotchas.
- Search and the agent read
bodyText(the plain projection), neverbody(the rich JSON). Returning JSON would be noise the model can't quote cleanly.searchWikiis a plaindb-using function, so it lives in its own server-only module (knowledge-search.ts), not inwikin-server.ts. A route component that imported it would drag the Postgres client into the browser bundle and break hydration. The route-facing reads (getWikinIndex,getWikinArticle) arecreateServerFns, whose handlers the compiler strips from the client — so routes import those, never this.
Extending it
- A new handbook section → add a
wikin_categoryrow (set itssortOrder); empty sections are hidden from the index automatically. - Seed more articles → add rows to
wikin_articlewith acategoryIdand the building'sprojectKey; follow Building a feature. - Honour the body/bodyText pattern → whenever you write
body, write the derivedbodyTexttoo — search and the agent depend on it, and stale text means stale answers. - A new agent source → mirror
searchWikiinknowledge-search.tsand wire a tool, the way NyhetN does for news; keep it out of the route-imported modules.