mnem/hugin-beta
Read-only mirror — pushes are disabled.
Hugin Beta
A multi-provider AI chat application built with SvelteKit, providing a unified interface to multiple AI providers with enterprise-grade authentication and real-time streaming responses.
Overview
Hugin Beta is an internal AI-agent web application designed to provide a democratic, secure, flexible, and user-friendly AI solution. The application supports multiple AI providers through a vendor-agnostic architecture, ensuring built-in privacy and seamless user experience.
Key Features
- Multi-Provider Support - Unified interface for OpenAI, Mistral AI, Ollama, and LiteLLM
- Real-Time Streaming - Server-Sent Events (SSE) for incremental AI responses
- Enterprise Authentication - Microsoft Entra ID integration with role-based and group-based access control
- Multi-Modal Input - Support for text, images, and document uploads
- Canvas - AI-assisted document editor with web search, manual editing, Mermaid diagram generation, and export to text and Word
- Transcription (Tale-til-notat) - Audio upload and transcription via an internal service, with group-gated sensitive use cases
- Datakilder (RAG) - Retrieval-augmented data sources, injected into agent instructions via an on-behalf-of proxy (actively under development)
- Conversation Persistence - Optional history with auto-generated titles, incognito mode, and at-rest encryption
- Agent Management - Create, publish, and share reusable chat configurations ("agents")
- Modern UI - Svelte 5 Runes for reactive state management with markdown and LaTeX rendering
Table of Contents
- Architecture
- Features
- Getting Started
- Development
- API Reference
- Type System
- Testing
- Deployment
- License
Architecture
System Overview
Hugin Beta follows an API-first architecture where all frontend capabilities are backed by corresponding backend APIs. The application is built as a SvelteKit monolith with clear separation between client and server code.
┌─────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Chat.svelte │ │ ChatState │ │ SSE Stream Consumer │ │
│ │ (UI) │◄─┤ (Svelte 5) │◄─┤ (PostChatMessage) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP/SSE
┌────────────────────────────▼────────────────────────────────────┐
│ Server Layer │
│ ┌─────────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ Auth Middleware │─►│ API Routes │─►│ Vendor Factory │ │
│ │ (Entra ID) │ │ (/api/chat) │ │ (ai-vendors.ts) │ │
│ └─────────────────┘ └──────────────┘ └─────────┬─────────┘ │
└───────────────────────────────────────────────────┼─────────────┘
│
┌───────────────────────────────────────────────────▼─────────────┐
│ AI Provider Layer │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ OpenAI │ │ Mistral AI│ │ Ollama │ │ LiteLLM │ │
│ │ Vendor │ │ Vendor │ │ Vendor │ │ Vendor │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
Vendor Abstraction
The application uses a plugin-based vendor pattern. All AI providers implement the IAIVendor interface:
interface IAIVendor {
createChatResponse(chatRequest: ChatRequest): Promise<ChatResponseObject>
createChatResponseStream(chatRequest: ChatRequest): Promise<ChatResponseStream>
}
Four vendors are implemented today: OpenAI, Mistral AI, Ollama, and LiteLLM (src/lib/server/{openai,mistral,ollama,litellm}/), each following the same three-file pattern:
| File | Purpose |
|---|---|
{vendor}-vendor.ts |
Implements IAIVendor interface |
{vendor}-mapping.ts |
Converts between internal types and vendor SDK types |
{vendor}-stream.ts |
Handles SSE streaming and event normalization |
Vendors are registered in src/lib/server/ai-vendors.ts and enabled/configured per-vendor via APP_CONFIG.VENDORS (src/lib/server/app-config/app-config.ts). vendorId values are uppercase ("OPENAI", "MISTRAL", "OLLAMA", "LITELLM"), matching the VENDORS keys in APP_CONFIG.
Data Flow:
ChatRequest → Mapping Layer → Vendor SDK → Vendor Response → Mapping Layer → ChatResponse
Authentication Flow
Production (Microsoft Entra ID):
- Azure App Service EasyAuth validates JWT token
- Claims passed via
X-MS-CLIENT-PRINCIPALheader (base64-encoded) src/lib/server/auth/get-authenticated-user.tsdecodes the header and parses claims with hand-written type guards — not Zod. A Zod schema (AuthenticatedPrincipalSchemainsrc/lib/types/authentication.ts) exists but is currently used only forz.inferto derive the TypeScript type, not for runtime validationAuthenticatedPrincipalobject created with userId, name, roles, and groups
Development (Mock Authentication):
- Enabled via
MOCK_AUTH="true"environment variable - Roles and groups configurable via environment variables
Streaming Architecture
Real-time AI responses use Server-Sent Events with the following event types:
| Event | Description |
|---|---|
response.config |
Chat configuration metadata |
response.started |
Response initiated with responseId |
response.output_text.delta |
Incremental text chunk |
response.searching |
Web search in progress |
response.annotations |
URL citations from web search |
response.done |
Completion with token usage statistics |
response.error |
Error information |
conversation.created |
New conversation identifier |
hugin_conversation.created |
Internal Hugin conversation id (huginConversationId), used for persisted history |
All events are validated using Zod discriminated unions (MuginSse in src/lib/types/streaming.ts) for type-safe handling — this is one of the few places in the codebase where Zod defines the primary type (see Type System).
Authorization
All access-control decisions go through named functions in src/lib/authorization.ts, taking the AuthenticatedPrincipal (and usually AppRoles/AppConfig) and returning a boolean:
| Function | Grants access when... |
|---|---|
canViewAllChatConfigs |
user has the ADMIN role |
canEditPredefinedConfig |
AGENT_MAINTAINER or ADMIN |
canPublishChatConfig |
AGENT_MAINTAINER or ADMIN |
canEditChatConfig |
new config (_id === ""), ADMIN, AGENT_MAINTAINER on a published config, or the private config's own creator |
canUpdateChatConfig |
same as above, plus validates the existing/incoming config _ids match before checking |
canPromptConfig |
ADMIN; a shared config; a private config owned by the user; or a published config matching the user's role/group via accessGroups ("all", "employee", "edu_employee", "student", or an explicit Entra group id) |
canUseCanvas |
EMPLOYEE or ADMIN |
canUseRagservice |
EMPLOYEE or ADMIN |
isStudentOnly |
STUDENT is the user's only role (a user who is both STUDENT and EDU_EMPLOYEE does not count) |
canUseHistory |
anyone except a student-only user — students are always forced into incognito, no history is ever stored |
canSeeSpotlight |
audience gate for Feature Spotlight announcements; uses the same accessGroups semantics as canPromptConfig |
Regular users can define their own chat configs and test them against /api/chat, but cannot create configs pointing at predefined agent/prompt IDs configured in a vendor — those are only usable via a predefined chat config in the database, created by a user with AGENT_MAINTAINER or ADMIN permissions. Transcription access is currently gated separately from this table — see Transcription.
Features
Canvas
Canvas is an AI-assisted document editor available at /canvas/document. It lets users create and refine markdown documents through natural language prompts, with optional web search for sourcing content.
How it works:
- User writes a prompt describing what they want (e.g. "Write a job application for a summer position at a campsite")
- The AI generates or modifies the document and streams the result back in real time
- User can continue refining via further prompts, or switch to manual edit mode to edit the raw markdown directly
- Sources from web search are automatically appended as a
## Kildersection with numbered links
Features:
- Real-time streaming response via SSE
- Toggle between rendered preview and raw markdown editing
- Web search toggle — enables live internet sourcing, with citations appended to the document
- Export to
.txtor.docx(with proper heading, bold, italic, bullet, and horizontal rule formatting) - Mermaid diagram generation and editing via a separate endpoint (
POST /api/canvas/mermaid) - Hardcoded to OpenAI
gpt-5.6-terra— no model selection needed
Access control:
Canvas is gated behind the CANVAS_ENABLED environment variable and requires the EMPLOYEE or ADMIN role. Both the page load and the API endpoint enforce this independently.
Relevant files:
| File | Purpose |
|---|---|
src/routes/canvas/+layout.server.ts |
Auth/feature-flag gate (shell) |
src/routes/canvas/+layout.svelte |
Shell frame + conditional tool tab strip |
src/routes/canvas/document/+page.svelte |
Document editor UI — editor, prompt bar, export |
src/routes/canvas/PromptBar.svelte |
Prompt input component |
src/routes/canvas/tools.ts |
Tool registry |
src/routes/api/canvas/+server.ts |
POST endpoint — streams AI response |
src/lib/types/canvas.ts |
Canvas request type (plain TypeScript) |
Transcription (Tale-til-notat)
Audio-to-text transcription via an internal service ("tale-til-notat"), available at /transcription. Users upload an audio/video file, the app proxies it to an internal Copyparty file server, triggers transcription, and polls/receives a callback with the result.
How it works:
- User selects a file (accepted:
.mp3,.mp4,.wav,.m4a,.ogg,.webm,.flac,.mkv,.avi,.wma) and a mode — "open" or "red" (sensitive use case) - The app creates a job, proxies the upload to Copyparty, and triggers the external transcription service with a callback URL
- The external service calls back
POST /api/transcription/callback(secret-gated viaTRANSCRIPTION_CALLBACK_SECRET) when done - The finished transcription can be downloaded as a
.docx
Access control: "Red" (sensitive) use cases are gated by Entra ID group membership — TRANSCRIPTION_GROUP_N_ID/TRANSCRIPTION_GROUP_N_LABEL env vars (dynamically scanned, N = 1, 2, 3, …) define the available groups, checked against the user's Entra groups.
Relevant files:
| File | Purpose |
|---|---|
src/routes/transcription/+page.svelte |
UI — job list, upload, mode toggle, download, delete |
src/routes/api/transcription/+server.ts |
Create/list/update transcription jobs |
src/routes/api/transcription/callback/+server.ts |
Secret-gated webhook called by the external service |
src/routes/api/transcription/[id]/download/+server.ts |
Download a finished transcription as .docx |
src/routes/api/transcription/upload/[...path]/+server.ts |
Proxies large audio uploads to Copyparty |
src/lib/server/transcription/job-store.ts |
In-memory job store — jobs do not survive a restart or work across instances |
src/lib/server/transcription/tale-til-notat.ts |
Calls the external transcription service |
src/lib/server/transcription/types.ts |
Zod schemas for job/callback payloads |
Datakilder (RAG)
Actively under development. Retrieval-augmented data sources, available at /ragservice. Lets EMPLOYEE/ADMIN users manage data stores and have their retrieval results injected into an agent's instructions at prompt time.
Requests are proxied to an external ragservice through an on-behalf-of (OBO) token exchange — Hugin's own Entra app registration (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET) exchanges the user's token for one scoped to the ragservice, rather than the user talking to it directly.
Access control: Gated by canUseRagservice (EMPLOYEE or ADMIN).
Relevant files:
| File | Purpose |
|---|---|
src/routes/ragservice/+page.svelte |
UI — data store management |
src/routes/api/obo/rag/[...path]/+server.ts |
OBO token exchange + proxy to the external ragservice |
src/lib/ragservice/components/ |
Data store, chunk, file upload, access, and settings components |
src/lib/ragservice/adapters/ragserviceApi.ts |
Client for the external ragservice API |
src/lib/server/ragservice/get-rag-token.ts |
OBO token exchange logic |
Conversation History & Encryption
Conversations can be persisted so users can revisit earlier chats, with an auto-generated title and optional at-rest encryption for message content and titles/summaries.
- History —
canUseHistorygates this: everyone except a student-only user (see Authorization) — student conversations are never stored, forcing incognito mode everywhere, client and server. - Incognito — any chat can opt out of persistence per-request (
store: false), regardless of role. - Encryption — optional; if
CONVERSATION_ENCRYPTION_KEYS/CONVERSATION_ENCRYPTION_ACTIVE_KEYare unset, messages/titles/summaries are stored in plaintext. When set, encryption is keyed by a free-form "key version" string so keys can be rotated per environment.
Relevant files:
| File | Purpose |
|---|---|
src/lib/conversationstore/server/conv_manager.ts |
Conversation persistence logic |
src/lib/conversationstore/server/message-encryption.ts |
At-rest encryption for messages/titles/summaries |
src/lib/conversationstore/server/adapters/ |
Storage adapters (MongoDB, in-memory mock) |
src/routes/api/conversations/+server.ts |
List the user's stored conversations |
src/routes/api/conversations/[_id]/+server.ts |
Fetch one conversation's history |
src/routes/api/conversations/[_id]/title/+server.ts |
Fire-and-forget auto-title generation (no-ops if already titled) |
Agent Management
The UI for creating, editing, and publishing the ChatConfigs referenced throughout Authorization — available at /agents.
Relevant files:
| File | Purpose |
|---|---|
src/routes/agents/+page.svelte |
List published/private agents |
src/routes/agents/[agentId]/+page.svelte |
Agent detail/edit view |
src/routes/agents/create/+page.svelte |
Agent creation flow |
Feature Spotlight
A generic "hey, look at this new feature" callout for announcing changes to users. It's a non-blocking box with an icon, header, body text, and an optional subtext line, always paired with a "Ikke vis denne igjen" (don't show again) checkbox and a close button. Dismissal is self-managed — the component checks localStorage on mount and simply doesn't render if the user has already opted out, so callers don't need to wire up any show/open state themselves.
Two components, split by concern: FeatureSpotlight.svelte is the presentational box itself — content, positioning, dismissal — and stays agnostic about where its active gate or its data comes from, so it can be used standalone with hardcoded props (all the examples below do exactly that). SpotlightHost.svelte is the app-specific wiring on top: it owns the static SPOTLIGHTS registry, resolves each entry's audience via canSeeSpotlight, and renders one FeatureSpotlight per eligible entry — mounted once, globally, in +layout.svelte. For the common case (see "Adding an announcement" below) you never touch FeatureSpotlight or canSeeSpotlight directly — just add an entry to SPOTLIGHTS.
Simple example (defaults to a top-center splash, the common case):
<script lang="ts">
import FeatureSpotlight from "$lib/components/FeatureSpotlight.svelte"
</script>
<FeatureSpotlight
id="canvas-feature-2026-08"
icon="auto_awesome"
header="Nytt: Kladdeboka"
text="Nå kan du bruke Kladdeboka til å jobbe med lengre tekster sammen med KI-assistenten."
subtext="Du finner den i menyen til venstre."
/>
id is required and must be unique per announcement — it's the key used to remember that this specific spotlight was dismissed. placement defaults to "top-center", so the example above needs nothing else to show up top-center; pass e.g. placement="bottom-right" for a corner toast instead.
Text formatting: text supports a small set of Markdown — a blank line starts a new paragraph, a single line break becomes a <br>, and **bold**/*italic* work as usual:
<FeatureSpotlight
id="canvas-feature-2026-08"
header="Nytt: Kladdeboka"
text={"Nå kan du bruke Kladdeboka til å jobbe med lengre tekster.\nStøtter **fet** og *kursiv* tekst.\n\nOg flere avsnitt om nødvendig."}
/>
This is rendered through its own small markdown-it instance (src/lib/formatting/simple-markdown-formatter.ts), separate from the one used for chat/canvas content — so changing this doesn't affect how AI responses render. header and subtext are plain text, not Markdown.
Pointing at real UI inline (e.g. referencing the button an announcement is about, mid-sentence): embed a small .spotlight-pill span directly in text. text is rendered with html: true, so this flows inline with the surrounding sentence instead of sitting in its own block:
<FeatureSpotlight
id="history-feature-2026-08"
header="Nytt: Historikk"
text={'Du finner tidligere samtaler under <span class="spotlight-pill"><span class="material-symbols-rounded">history</span>Samtaler</span> i toppmenyen.'}
/>
.spotlight-pill is deliberately styled unlike a real button (no pointer cursor, no hover state, a subtle background instead of the transparent-hover-highlight look real buttons have) — it's a reference chip saying "this is what to look for," not a clickable mimic that could confuse users into clicking it. text is standard Markdown throughout — this inline HTML isn't a separate mechanism layered on top, it's CommonMark's normal inline-HTML passthrough, just correctly left enabled.
Restricting the audience: not every announcement applies to every user (e.g. a conversation-history announcement is meaningless for student-only accounts, who never get history stored — see isStudentOnly in src/lib/authorization.ts). There's no dedicated prop for this — reuse the existing active gate with canSeeSpotlight, which shares the exact same accessGroups semantics as ChatConfig.accessGroups/canPromptConfig, so an announcement's audience is declared the same way an agent's audience is:
<script lang="ts">
import { canSeeSpotlight } from "$lib/authorization"
import type { RoleAccessGroups } from "$lib/types/chat"
const accessGroups: RoleAccessGroups[] = ["employee", "edu_employee"] // excludes "student"
</script>
<FeatureSpotlight
...
active={canSeeSpotlight(data.authenticatedUser, data.APP_CONFIG.APP_ROLES, accessGroups)}
/>
canSeeSpotlight always returns true for ADMIN, matching canPromptConfig's convention.
Dismissal behavior:
- Closing via the X button only hides the box for the current page load.
- Checking "Ikke vis denne igjen" before closing persists the dismissal to
localStorage(keyhugin_dismissed_spotlights, a JSON array of ids) so it won't be shown again on that browser.
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
— | Required. Unique key used to remember dismissal. |
header |
string |
— | Required. Title text. |
text |
string |
— | Required. Main body text. Supports a small set of Markdown — see "Text formatting" above. |
icon |
string |
undefined |
Optional Material Symbol name shown in a round badge. |
subtext |
string |
undefined |
Optional smaller line below the main text. |
active |
boolean |
true |
One-way gate — set to false to keep it hidden regardless of dismissal state. |
backdrop |
boolean |
false |
Adds a subtle darken + blur behind the box. Off by default since most spotlights are non-blocking toasts; turn on for a more attention-grabbing splash (e.g. placement="center"/"top-center"). Purely visual — the backdrop doesn't dismiss the box on click, matching the checkbox/close-button-only dismissal model. |
placement |
"center" | "top-center" | "top-right" | "bottom-right" | "bottom-center" | "bottom-left" | "top-left" |
"top-center" |
Fixed screen position. |
onDismiss |
() => void |
undefined |
Called whenever the box is closed, whether or not "don't show again" was checked. |
Not supported: pinning the box next to a specific element, and multi-step guided tours — only fixed screen-corner placement. An earlier anchor/anchorSide/anchorOffset implementation was removed: it required bind:this wiring at every call site (tightly coupling the announcement to whatever element it pointed at) and still didn't handle auto-flipping near a viewport edge or an arrow pointing at the anchor. If element-pointing or guided tours become a real need, a proper library (e.g. @floating-ui/dom for positioning, or a dedicated tour library) is a better foundation than reviving this DIY version — see git history for reference if useful.
Adding an announcement in this app: to launch or update an announcement in hugin-beta specifically, add or edit an entry in the SPOTLIGHTS array in src/lib/spotlights.ts — SpotlightHost.svelte (mounted once in src/routes/+layout.svelte) loops over it, resolves each entry's active prop via canSeeSpotlight, and renders one <FeatureSpotlight> per eligible entry. There's no <FeatureSpotlight> markup to touch for the common case:
export const SPOTLIGHTS: SpotlightDefinition[] = [
{
id: "unique-id-2026-08",
icon: "auto_awesome",
header: "…",
text: "…",
accessGroups: ["employee", "edu_employee"] // omit for everyone
}
]
As before, give an entry a new unique id whenever its copy changes, or users who dismissed the old copy won't see the update. Multiple entries can be active at once — each gets its own dismissal state — though nothing resolves visual overlap if two share the same fixed placement.
Relevant files:
| File | Purpose |
|---|---|
src/lib/components/FeatureSpotlight.svelte |
The component |
src/lib/spotlights.ts |
Static array of announcement definitions — the thing to edit to launch/update one |
src/lib/components/SpotlightHost.svelte |
Loops the array, resolves active per entry via canSeeSpotlight, mounted once in +layout.svelte |
src/lib/util/spotlight-util.ts |
localStorage read/write for tracking dismissed spotlight ids |
src/lib/formatting/simple-markdown-formatter.ts |
Renders text's Markdown subset (paragraphs, line breaks, bold, italic) |
Analytics
Pageviews are tracked with Plausible — cookieless, no cross-site tracking, no personal data in the payload.
The snippet lives in src/app.html behind a %plausible% placeholder, which src/hooks.server.ts fills in via transformPageChunk — with the snippet when PLAUSIBLE_SCRIPT_URL is set, and with nothing when it isn't. The variable is deliberately set on the prod app only: test/beta and prod run the same build from the same repo, so a hardcoded snippet would file betatester traffic under the prod site's numbers.
The URL Plausible hands you already has the site id baked into the filename (.../js/pa-<id>.js), so there is no data-domain attribute to keep in sync:
PLAUSIBLE_SCRIPT_URL="https://plausible.io/js/pa-<site-id>.js"
Because it is read through $env/dynamic/private, flipping it needs an app-setting change and a restart — not a rebuild.
Keeping it in app.html rather than <svelte:head> matters here: app.html is the shell SvelteKit returns on every response, so the script is plain parser-inserted HTML even on / and /agents/[agentId], which set ssr = false. Scripts in <svelte:head> are also subject to known client-side-navigation quirks that the shell sidesteps entirely.
Paths are reported as-is, which means /agents/<agentId> shows up per assistant. Agent ids are config ids rather than personal data, and the per-assistant breakdown is the useful part.
Relevant files:
| File | Purpose |
|---|---|
src/app.html |
Holds the %plausible% placeholder |
src/hooks.server.ts |
Replaces the placeholder with Plausible's snippet when the env var is set |
Getting Started
Prerequisites
- Node.js - v22 (matches CI, see
.github/workflows/publish-*.yml) - npm - Package manager (included with Node.js)
- API Keys - At least one of: Mistral API key, OpenAI API key, or local Ollama instance
Installation
# Clone the repository
git clone <your-repository-url>
cd hugin-beta
# Install dependencies
npm install
Environment Configuration
Create a .env file in the project root:
# AI Provider API Keys (at least one required)
MISTRAL_API_KEY_PROJECT_DEFAULT="your-mistral-api-key"
OPENAI_API_KEY_PROJECT_DEFAULT="your-openai-api-key"
# OLLAMA_HOST / LITELLM_BASE_URL enable the Ollama / LiteLLM vendors if set
# Mock Database Configuration
MOCK_DB="true" # Use in-memory database (required for local dev)
# Or production Database Configuration
MONGODB_CONNECTION_STRING="mongodb+srv://..." # Production MongoDB connection
MONGODB_DB_NAME="mugin" # Name of database
# Conversation encryption (optional - if unset, messages/titles/summaries are stored in plaintext)
# Covers conversation-messages (userInput/response) and conversations (title/summary).
# Key versions are free-form strings (a counter, a date, "prod-1" - whatever helps you track rotations).
# Generate a key with: node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
# Use a DIFFERENT keyset per environment (dev/prod) - each environment's own .env supplies its own.
CONVERSATION_ENCRYPTION_KEYS='{"2026-08":"<base64 of 32 random bytes>"}' # keyed by key version
CONVERSATION_ENCRYPTION_ACTIVE_KEY="2026-08" # which key version new writes are encrypted with
# Authentication
MOCK_AUTH="true" # Enable mock authentication for local development
MOCK_AUTH_ROLES="Employee,Admin" # Comma-separated role values
MOCK_AUTH_GROUPS="group-id-123" # Comma-separated group IDs
MOCK_AUTH_FORCE_401="false" # Simulate an unauthenticated request (local testing only)
MOCK_AUTH_FORCE_403="false" # Simulate an unauthorized request (local testing only)
# Application Roles
APP_ROLE_EMPLOYEE="Employee"
APP_ROLE_STUDENT="Student"
APP_ROLE_ADMIN="Admin"
APP_ROLE_AGENT_MAINTAINER="AgentMaintainer"
APP_ROLE_EDU_EMPLOYEE="eduemployee" # optional - defaults to "eduemployee" if unset
# Feature flags
CANVAS_ENABLED="true" # Enable the Canvas document editor
DEFAULT_AGENT_ID="" # Agent loaded by default on the home page
# Analytics - set on the prod app only (see the Analytics section)
PLAUSIBLE_SCRIPT_URL="" # Plausible script URL; unset = no analytics
# Transcription (Tale-til-notat) - see the Transcription feature section
TALE_TIL_NOTAT_URL="http://<ki-server>:<port>"
COPYPARTY_BASE_URL="http://<copyparty-host>/copyparty"
TRANSCRIPTION_CALLBACK_SECRET="<random-secret>"
# Sensitive ("red") use case groups - define as many as needed (TRANSCRIPTION_GROUP_2_ID, _3_ID, ...)
TRANSCRIPTION_GROUP_1_ID=""
TRANSCRIPTION_GROUP_1_LABEL=""
# Datakilder (RAG) - see the Datakilder feature section
RAGSERVICE_URL="https://ragservice.example.com"
RAGSERVICE_SCOPE="api://<scope>/.default"
RAGSERVICE_TOKEN="" # For local testing only - paste a token here
# Hugin's own Entra app registration, used for the on-behalf-of token exchange to ragservice
ENTRA_TENANT_ID=""
ENTRA_CLIENT_ID=""
ENTRA_CLIENT_SECRET=""
BODY_SIZE_LIMITgotcha:APP_CONFIG.BODY_SIZE_LIMIT_BYTESonly honours values ending inM(e.g."512M") — a raw byte count like"536870912"silently falls back to a 10 MB default.adapter-nodereads the raw env var separately for its own request limit, so the two can disagree if you set a raw byte count.
Development
Commands
| Command | Description |
|---|---|
npm run dev |
Start development server at http://localhost:5173 |
npm run dev -- --open |
Start dev server and open browser |
npm run build |
Build for production |
npm run preview |
Preview production build locally |
npm run test |
Run full test suite (types, lint, build, unit tests) |
npm run test:unit |
Run Vitest unit tests only |
npm run test:unit -- --watch |
Run tests in watch mode |
npm run check |
TypeScript + Svelte type checking |
npm run lint |
Run Biome linter |
npm run lint:fix |
Auto-fix linting issues |
Project Structure
src/
├── lib/ # Shared library code
│ ├── types/ # Domain types (mostly plain TS; Zod only at trust boundaries)
│ │ ├── AIVendor.ts # Core vendor interface
│ │ ├── chat.ts # Chat request/response types (+ ChatConfigSchema)
│ │ ├── chat-item.ts # Message types
│ │ ├── chat-item-content.ts # Content types (text, file, image)
│ │ ├── streaming.ts # SSE event types (Zod discriminated union)
│ │ ├── canvas.ts # Canvas request type
│ │ └── authentication.ts # Auth types
│ ├── validation/ # Boundary parse functions (plain TS guards → HTTPError)
│ ├── server/ # Server-only code
│ │ ├── ai-vendors.ts # Vendor factory
│ │ ├── openai/ # OpenAI implementation
│ │ ├── mistral/ # Mistral implementation
│ │ ├── ollama/ # Ollama implementation
│ │ ├── litellm/ # LiteLLM implementation
│ │ ├── ragservice/ # RAG on-behalf-of token exchange
│ │ ├── transcription/ # Transcription job store + external service client
│ │ ├── auth/ # Authentication handlers
│ │ ├── middleware/ # HTTP middleware
│ │ ├── app-config/ # APP_CONFIG - vendors, models, roles, feature flags
│ │ └── db/ # Database abstraction
│ ├── conversationstore/ # Conversation persistence + at-rest encryption
│ ├── ragservice/ # Datakilder UI components + API adapter (client-side)
│ ├── components/ # Svelte components
│ │ └── Chat/ # Chat UI components
│ ├── authorization.ts # Access-control functions (see Authorization section)
│ ├── spotlights.ts # Feature Spotlight announcement registry
│ └── streaming.ts # SSE utilities
├── routes/ # SvelteKit routes
│ ├── +layout.server.ts # Root auth middleware
│ ├── +page.svelte # Home page
│ ├── api/
│ │ ├── chat/+server.ts # Chat streaming endpoint
│ │ ├── canvas/+server.ts # Canvas streaming endpoint (+ mermaid/+server.ts)
│ │ ├── chatconfigs/ # Config CRUD endpoints
│ │ ├── conversations/ # Conversation history endpoints
│ │ ├── transcription/ # Transcription job + upload/download endpoints
│ │ └── obo/rag/ # On-behalf-of proxy to the external ragservice
│ ├── canvas/ # Canvas document editor
│ ├── transcription/ # Tale-til-notat UI
│ ├── ragservice/ # Datakilder UI
│ ├── agents/ # Agent management pages
│ └── admin/ # Admin pages
└── app.d.ts # Global type definitions
API Reference
POST /api/chat
Send a message and receive an AI response (streaming or non-streaming).
Request Body:
{
config: {
_id: string,
name: string,
description: string,
vendorId: "OPENAI" | "MISTRAL" | "OLLAMA" | "LITELLM",
project: string,
model?: string,
instructions?: string,
conversationId?: string
},
inputs: ChatInputItem[],
stream?: boolean,
store?: boolean
}
Response:
- Streaming:
ReadableStreamwithContent-Type: text/event-stream - Non-streaming:
ChatResponseObjectas JSON
POST /api/canvas
Submit a prompt (and optionally the current document) to the AI document editor. Returns a streaming SSE response.
Access: Requires CANVAS_ENABLED=true and EMPLOYEE or ADMIN role.
Request Body:
{
document: string, // Current document content (markdown). Empty string for new documents.
prompt: string, // Instruction for the AI (required, non-empty)
webSearch?: boolean // Enable web search (OpenAI vendor must be configured)
}
Response: ReadableStream with Content-Type: text/event-stream
Emits response.output_text.delta events with the updated document, and response.annotations events with URL citations when web search is used.
POST /api/canvas/mermaid
Generate or edit a Mermaid diagram from a prompt. Same access control and streaming response shape as /api/canvas; hardcoded to OpenAI gpt-5.6-terra.
GET / POST /api/chatconfigs
List, or create, chat configurations.
PUT /api/chatconfigs/[_id]
Update an existing chat configuration.
DELETE /api/chatconfigs/[_id]
Delete a chat configuration.
GET /api/conversations
List the authenticated user's stored conversations.
GET /api/conversations/[_id]
Fetch one conversation's full message history.
POST /api/conversations/[_id]/title
Fire-and-forget: auto-generate a title for a conversation. No-op if the conversation is already titled.
GET / POST / PATCH /api/transcription
List, create, or update transcription jobs. See Transcription.
POST /api/transcription/callback
Webhook called by the external tale-til-notat service when a job completes. Authenticated via a shared secret (TRANSCRIPTION_CALLBACK_SECRET) query param, not user auth.
GET /api/transcription/[id]/download
Download a finished transcription as a .docx file.
/api/transcription/upload/[...path]
Proxies large audio/video uploads to the internal Copyparty file server.
/api/obo/rag/[...path]
On-behalf-of token exchange, then proxies the request to the external ragservice. Gated by canUseRagservice. See Datakilder.
Type System
Domain types are plain TypeScript, not Zod schemas. Runtime validation with Zod is used only at a handful of external trust boundaries — API request bodies and the SSE event stream — where a discriminated-union validator genuinely earns its keep:
// Most domain types: plain TypeScript, no Zod
type ChatConfig = {
_id: string
name: string
vendorId: "OPENAI" | "MISTRAL" | "OLLAMA" | "LITELLM"
model?: string
// ...
}
// Zod used at a boundary - built from the plain type via schemaForType<T>(),
// not the other way around
const ChatConfigSchema = schemaForType<ChatConfig>()(z.object({ /* ... */ }))
// Boundary validation, in a route handler
const result = ChatConfigSchema.safeParse(requestBody)
Where Zod is used: ChatConfigSchema (chat.ts, validates incoming chat config bodies), MuginSse (streaming.ts, validates SSE events from vendors), and transcription job/callback payloads (types.ts). AuthenticatedPrincipalSchema (authentication.ts) exists but is used only for z.infer, not runtime validation — see Authentication Flow.
Everywhere else, validation is hand-written parse functions in src/lib/validation/ (e.g. parse-chat-config.ts, parse-canvas-request.ts, file-input.ts) that throw HTTPError(400) on invalid input.
Core Types
| Type | Description | Location |
|---|---|---|
ChatConfig |
Chat configuration (vendor, model, instructions) | chat.ts |
ChatRequest |
Request payload with config and inputs | chat.ts |
ChatResponseObject |
Complete response with outputs and usage | chat.ts |
ChatInputMessage |
User/system input message | chat-item.ts |
ChatOutputMessage |
Assistant output message | chat-item.ts |
MuginSse |
SSE event discriminated union (Zod) | streaming.ts |
AuthenticatedPrincipal |
User identity with roles/groups | authentication.ts |
Testing
The project uses Vitest with separate test environments:
| Test Type | Location | Environment |
|---|---|---|
| Client tests | src/**/*.svelte.{test,spec}.ts |
Browser |
| Server tests | tests/server/**/*.{test,spec}.ts |
Node.js |
# Run all tests
npm run test
# Run unit tests only
npm run test:unit
# Run tests in watch mode
npm run test:unit -- --watch
Code Quality
Before committing, ensure all checks pass:
npm run test # Runs: tsc → biome → build → vitest
TypeScript Configuration (tsconfig.json):
strict: true- All strict checks enablednoUncheckedIndexedAccess: true- Prevents array access bugsexactOptionalPropertyTypes: true- Catches undefined/null issues
Deployment
Build
npm run build
The build uses @sveltejs/adapter-node for Node.js deployment. npm run build runs Vite in --mode build, which loads .env.build (placeholder values) rather than your local .env — real values are supplied by the deployment environment, not baked in at build time.
Production Environment Variables
# Required
MONGODB_CONNECTION_STRING="mongodb+srv://..."
MONGODB_DB_NAME="mugin"
MISTRAL_API_KEY_PROJECT_DEFAULT="sk-..."
OPENAI_API_KEY_PROJECT_DEFAULT="sk-..."
# Authentication (Azure App Service)
MOCK_AUTH="false"
# Application Roles
APP_ROLE_EMPLOYEE="Employee"
APP_ROLE_STUDENT="Student"
APP_ROLE_ADMIN="Admin"
APP_ROLE_AGENT_MAINTAINER="AgentMaintainer"
# Feature flags
CANVAS_ENABLED="true"
# Analytics (prod app only)
PLAUSIBLE_SCRIPT_URL="https://plausible.io/js/pa-<site-id>.js"
Azure Deployment
- Configure Azure App Service with Node.js runtime
- Enable EasyAuth with Microsoft Entra ID
- Set environment variables in Application Settings
- Deploy using your preferred method (Azure CLI, GitHub Actions, etc.)
Technology Stack
| Category | Technology |
|---|---|
| Framework | SvelteKit 2.57, Svelte 5.55 |
| Language | TypeScript 5.9 |
| AI Providers | OpenAI, Mistral AI, Ollama, LiteLLM |
| Database | MongoDB (mongodb driver 7.x) |
| Validation | Zod 4.3 (boundary validation only — see Type System) |
| Linting | Biome |
| Testing | Vitest |
| Markdown | markdown-it, highlight.js, KaTeX |
| Documents | docx (Canvas/Transcription export), mermaid (diagram generation), pdf-lib |
| Logging | @vestfoldfylke/loglady (structured logging) |
License
This project is licensed under the MIT License - see the LICENSE file for details.
Built with ❤️ by the Mugin Team of Vestfold and Telemark fylkeskommuner.