Skip to content
Fusion

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 — Category and Article models served by a couple of "views". The twist: an article's body is 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.

LayerWhere it lives
Logic (package)@tikab-interactive/pulsn-apps/wikin — schema, handbook reads + collaborative edit + Swedish-FTS searchWiki
Transportthin createServerFn wrappers in src/lib/wikin-server.ts; the agent's search via src/lib/knowledge-search.ts
UIfusion-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

Loading diagram...
  • wikin_category — a handbook section ("CAD/BIM-standarder", "Filhantering", "Mjukvara"…). Carries name, slug, description, icon, and sortOrder for the index.

  • wikin_article — one document, joined to a category by categoryId and scoped to a building by projectKey (its route key, e.g. "EKEN-A"). Plus title, slug, summary, discipline ("Alla" | "A" | "K" | "V" | "E"), tags, and updatedByName.

  • The distinctive bit — two bodies. Each article stores both:

    • body (jsonb) — the rich-text ProseMirror/Tiptap document, rendered by fusion-ui's WikiArticleView. 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, updateWikinArticle writes both at once (the editor derives the text via editor.getText()).

Pages

URLWhat it shows
/project/$projectKey/wikinthe 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
/wikinlegacy 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:

example/src/routes/_authed.project.$projectKey.wikin.tsx
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.

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.

pulsn-apps/src/wikin/index.ts
	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.

  1. Search and the agent read bodyText (the plain projection), never body (the rich JSON). Returning JSON would be noise the model can't quote cleanly.
  2. searchWiki is a plain db-using function, so it lives in its own server-only module (knowledge-search.ts), not in wikin-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) are createServerFns, whose handlers the compiler strips from the client — so routes import those, never this.

Extending it

  • A new handbook section → add a wikin_category row (set its sortOrder); empty sections are hidden from the index automatically.
  • Seed more articles → add rows to wikin_article with a categoryId and the building's projectKey; follow Building a feature.
  • Honour the body/bodyText pattern → whenever you write body, write the derived bodyText too — search and the agent depend on it, and stale text means stale answers.
  • A new agent source → mirror searchWiki in knowledge-search.ts and wire a tool, the way NyhetN does for news; keep it out of the route-imported modules.