diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa2cae..59dc550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added — AI Bibliotek Project +- Prototype for a shared library where Danish public authorities publish and "hjemtage" (take home) AI assistants — initially OpenWebUI-based — so local use cases can scale nationally +- Single-page mock adapted from the Dansk Viden til Dansk AI SPA: self-signup, katalog with search and facets (kommune, sprogmodel, rammeværk, datafølsomhed), assistant detail with modelkort/readme/vidensopskrift and versioned JSON export, plus a "del assistent" flow — `localStorage` backend, teal primary color +- Report (`index.md`), estimeringsnotat with phased estimate and a drift/driftsomkostninger note, and mocks listing + ### Added — Solceller i Universitetsparken hearing detail mock (deltag-aarhus) - Second interactive prototype for deltag.aarhus.dk under `docs/public/projects/deltag-aarhus/mocks/uniparken/` — Lokalplan nr. 1245 om et solcelleanlæg på Universitetsparkens fællesplæne, med samme funktionalitet som Vosnæs-prototypen (784 høringssvar, åben/afsluttet variant, alle modaler, kort, statistik) - Introduced a per-mock config layer (`mocks/js/config.js` + per-mock `window.DeltagMock.config` override) so plan number, deadlines, map center/clusters and active dataset are swappable without duplicating shared CSS/JS. The Vosnæs mock continues to use the file's defaults. diff --git a/CLAUDE.md b/CLAUDE.md index 93078cd..ce5d42b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ Taskfile.yml # Task automation (dev, build, lint | `opkraevningsoverblik` | Opkrævningsoverblik | No | | `roboway` | Roboway | No | | `dansk-viden-til-dansk-ai` | Dansk Viden til Dansk AI | No | +| `ai-bibliotek` | AI Bibliotek | No | ## Conventions diff --git a/docs/.vitepress/sidebar.mts b/docs/.vitepress/sidebar.mts index ef8e5e7..c07ede6 100644 --- a/docs/.vitepress/sidebar.mts +++ b/docs/.vitepress/sidebar.mts @@ -108,6 +108,17 @@ const danskVidenTilDanskAi: DefaultTheme.SidebarItem[] = [ }, ] +const aiBibliotek: DefaultTheme.SidebarItem[] = [ + { + text: 'AI Bibliotek', + items: [ + { text: 'Overview', link: '/projects/ai-bibliotek/' }, + { text: 'Estimeringsnotat', link: '/projects/ai-bibliotek/estimeringsnotat' }, + { text: 'Interactive Mocks', link: '/projects/ai-bibliotek/mocks' }, + ], + }, +] + const designSystem: DefaultTheme.SidebarItem[] = [ { text: 'Design System', @@ -134,6 +145,7 @@ export function sidebar(): DefaultTheme.Sidebar { '/projects/opkraevningsoverblik/': opkraevningsoverblik, '/projects/roboway/': roboway, '/projects/dansk-viden-til-dansk-ai/': danskVidenTilDanskAi, + '/projects/ai-bibliotek/': aiBibliotek, '/projects/design-system/': designSystem, } } diff --git a/docs/index.md b/docs/index.md index b030444..00307a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,4 +46,8 @@ features: details: Fælles offentlig service til indsamling og deling af danske publikationer som grundlag for dansk AI — med AI-katalogisering, syv rettighedsniveauer og delbare samlinger. link: /projects/dansk-viden-til-dansk-ai/ linkText: View project + - title: AI Bibliotek + details: Fælles bibliotek hvor danske myndigheder kan dele og hjemtage AI-assistenter — med katalog, søgning, modelkort, vidensopskrift og versioneret JSON-eksport, så lokale use cases kan skaleres nationalt. + link: /projects/ai-bibliotek/ + linkText: View project --- diff --git a/docs/projects/ai-bibliotek/estimeringsnotat.md b/docs/projects/ai-bibliotek/estimeringsnotat.md new file mode 100644 index 0000000..c6b0cb0 --- /dev/null +++ b/docs/projects/ai-bibliotek/estimeringsnotat.md @@ -0,0 +1,54 @@ +**Project:** AI Bibliotek · **Status:** Estimat (pitch) + +# AI Bibliotek — Estimeringsnotat + +**Dato:** maj 2026 +**Projekt:** AI Bibliotek — deling af AI-assistenter på tværs af offentlige myndigheder +**Fase:** Pitch / oplæg (uge 23) + +::: info Bemærkning +Notatet er bevidst holdt på et overordnet niveau. Det er et oplæg, ikke et tilbud — formålet er at give en størrelsesorden og efterlade manøvrerum i den videre proces. Estimaterne er grove og afhænger af de afklaringer, der er beskrevet i [hovednotatet](./). +::: + +--- + +## Overordnet estimat + +Estimatet er delt i faser, så projektet kan startes småt og udvides, efterhånden som koncept og behov afklares. + +| Fase | Indhold | Estimat | Tidshorisont | +|---|---|---|---| +| **Fase 0 — Afklaring** | Standard for assistent-format, governance, hvad er åbent vs. bag login, rettigheder til delt JSON | 60–120 t. | 3–5 uger | +| **Fase 1 — MVP (v0.1)** | Webapp, simpel brugerstyring, katalog med søgning/filtre, assistent-side, JSON-eksport, del/upload-flow, OpenWebUI-tag | 350–550 t. | 2–3 mdr. | +| **Fase 2 — Udvidelser** | Flere versioner i drift, AI-genererede tags, forbedret datagrundlags-opskrift, flere rammeværk | 250–450 t. | 2–3 mdr. | +| **Fase 3 — Økosystem (på sigt)** | Tools & skills, ratings, API, abonnement/advisering, testcases | 400–800 t. | Løbende | +| **Sum (fase 0–2)** | | **660–1.120 t.** | **ca. 5–8 mdr.** | + +Fase 3 er medtaget for fuldstændighedens skyld og er ikke en del af det anbefalede første skridt. + +### Anbefalet første skridt + +Fase 0 + Fase 1 leverer en brugbar MVP, der kan opfylde succeskriteriet: to AarhusAI-assistenter delt i biblioteket og inviterede OS2ai-testkommuner. Estimat **410–670 t.**, tidshorisont **ca. 3–4 måneder**. + +--- + +## Opmærksomhedspunkt: drift og driftsomkostninger + +Et bibliotek af denne type er ikke et "byg og glem"-projekt. Den væsentligste langsigtede omkostning er **drift**, ikke selve udviklingen. Følgende skal afklares tidligt: + +- **Hosting og ejerskab.** Hvem driver og betaler for platformen? OS2/OS2ai-fællesskab, en værtskommune, eller en central aktør? Driften skal have en fast ejer, ellers forfalder biblioteket. +- **Løbende vedligehold.** Sikkerhedsopdateringer, afhængigheder, brugerstyring og support koster typisk **15–25 % af udviklingsestimatet pr. år**. For fase 0–2 svarer det groft til **100–280 t./år**. +- **Indholdsmoderering og kvalitet.** Delte assistenter skal kunne reviewes — er JSON'en trygt at dele, er metadata korrekt, er datafølsomheden vurderet rigtigt? Det kræver en governance-proces og dermed tid, ikke kun teknik. +- **Versionering over tid.** Når en kommune opdaterer en delt assistent, skal abonnenter adviseres og gamle versioner håndteres. Det vokser med antallet af assistenter og kommuner. +- **Compute.** Selve assistenterne kører lokalt i den enkelte kommune — biblioteket hoster ikke modeller. Driftsomkostningen til AI-inferens ligger derfor hos den hjemtagende kommune, ikke hos biblioteket. Det holder bibliotekets egne driftsomkostninger lave og forudsigelige. + +**Konklusion:** udviklingsomkostningen er en engangsinvestering; driften er en løbende forpligtelse, der skal have et budget og en ejer fra dag ét. + +--- + +## Forudsætninger og forbehold + +- Estimaterne forudsætter genbrug af eksisterende ITKdev-fundament (auth, design, hosting-mønstre). +- De forudsætter, at OpenWebUIs eksportformat kan bruges som udgangspunkt for assistent-JSON. +- Større usikkerheder: standardisering på tværs af rammeværk, juridisk afklaring af deling af systemprompter/datagrundlag, og governance-modellen. +- Tallene er grove pitch-estimater og skal kvalificeres i fase 0. diff --git a/docs/projects/ai-bibliotek/index.md b/docs/projects/ai-bibliotek/index.md new file mode 100644 index 0000000..e1f2132 --- /dev/null +++ b/docs/projects/ai-bibliotek/index.md @@ -0,0 +1,129 @@ +**Project:** AI Bibliotek · **Status:** Prototype · **Date:** May 2026 + +# AI Bibliotek + +**Et fælles bibliotek hvor danske myndigheder kan dele og hjemtage AI-assistenter — så lokale use cases kan skaleres op på nationalt niveau.** + +--- + +## Baggrund + +I Storskalaprojektet udvikles AI-assistenter til konkrete kommunale opgaver — borgerservice, sagsbehandling, journalisering, mødereferater og meget mere. Assistenterne bygges i dag typisk lokalt i den enkelte kommune, ofte oven på OpenWebUI, og bliver sjældent delt på tværs. Det betyder, at den samme assistent reelt udvikles flere gange parallelt i forskellige kommuner. + +Samtidig efterspørger OS2ai-kommunerne en måde at genbruge hinandens arbejde på: hvis Aarhus har bygget en velfungerende FAQ-assistent til borgerservice, bør Odense kunne hjemtage den, tilpasse den lokalt og tage den i brug — uden at starte forfra. + +Et AI-bibliotek kan udstille assistenterne ét sted, så de kan fremsøges, vurderes og hjemtages. På sigt kan det samme bibliotek rumme tools, skills og hele økosystemet omkring assistenterne — og måske brede sig ud over kommunegrænserne til hele det offentlige Danmark. + +## Formål + +Prototypen undersøger spørgsmålet: **Hvordan kan et fælles bibliotek for delte AI-assistenter se ud i praksis — så en assistent udviklet i én kommune kan hjemtages og anvendes i en anden?** + +Biblioteket skal understøtte to bevægelser: + +- **Dele** — en kommune udstiller en assistent, den har udviklet lokalt, sammen med dens datagrundlag (i opskriftsform), modelkort og readme +- **Hjemtage** — en anden kommune fremsøger, vurderer og henter assistenten (eksporterer dens JSON) og bygger sit eget lokale datagrundlag efter opskriften + +## Hvad prototypen viser + +Prototypen er en single-page application, der bruger `localStorage` som backend og simulerer AI-understøttet metadata med en kort spinner. Den er et **visuelt og funktionelt diskussionsgrundlag** — teksten er illustrativ (lorem-agtig, men realistisk), netop fordi det er en visualisering af et koncept og ikke en færdig løsning. + +### Forsiden + +Hero med søgefelt, kort introduktion og statistik (antal assistenter, deltagende kommuner, antal sprogmodeller). En rail med "Senest opdateret" og en sektion der **teaser fremtidige muligheder** (tools, skills, ratings, API, abonnement, testcases) som "kommer snart". + +### Registrering og login + +Simpel brugerflade hvor en medarbejder kan oprette en konto (i første omgang opret sig selv) eller logge ind. Brugere gemmes i `localStorage`. Ingen reel auth — kun til demoformål. + +### Katalog og søgning + +Fritekstsøgning kombineret med facetter: **oprindelseskommune, sprogmodel, rammeværk** (i dag OpenWebUI) og **datafølsomhed** (almindelige personoplysninger / fortrolige / personfølsomme). Resultater vises som kort med badges. + +### Assistent-side + +Detaljevisning af en enkelt assistent med adskilte visninger: + +- **Beskrivelse** — assistentens formål +- **Modelkort** — hvilken sprogmodel, kontekstvindue, parametre og hvilke hensyn der er til assistenten +- **Readme** — praktisk dokumentation +- **Viden** — *i opskriftsform*: hvad den enkelte kommune selv skal levere af datagrundlag lokalt (de faktiske vidensfiler kan sjældent deles på tværs af kommuner) +- **JSON** — vælg blandt **flere versioner** af assistenten og **eksportér** den valgte version som en JSON-fil (datagrundlag svarende til OpenWebUIs eksportformat) + +Metadata i sidepanelet: oprindelseskommune, sprogmodel, rammeværk, datafølsomhed, dato for oprettelse og opdatering samt antal versioner. En **OpenWebUI-tag** markerer rammeværket, så biblioteket med tiden kan rumme assistenter til flere typer rammeværk. + +### Del assistent + +Et flow hvor en kommune udstiller en assistent: indsæt eller upload assistentens OWUI-JSON → systemet foreslår metadata (navn, beskrivelse, tags, sprogmodel) → kommunen gennemgår, vælger datafølsomhed og udgiver. Kvittering med permalink. + +### Mine assistenter & favoritter + +Personligt overblik over egne delte assistenter samt en favoritliste, begge gemt i `localStorage`. + +--- + +## Krav (v0.1) + +- Webapplikation med simpel brugerstyring (opret sig selv) +- En assistent består af — og skal kunne udvides uendeligt med — mindst: eksporterbar **JSON** (datagrundlag svarende til OpenWebUI), **viden/filer i opskriftsform**, **modelkort**, **readme** og en **beskrivelse** +- Metadata: sprogmodel, viden, dato for oprettelse/opdatering, oprindelseskommune samt om assistenten er beregnet til personfølsomme / fortrolige / almindelige personoplysninger +- Flere versioner af JSON på samme assistent +- Det skal være muligt at tilføje nye entiteter (fx tools og skills) på sigt +- En assistent skal kunne tagges med sit rammeværk (OpenWebUI i dag), så biblioteket senere kan rumme flere rammeværk +- AI-genererede tags (evt.) +- Forskellige filtrerings- og søgemuligheder + +--- + +## Afklarende spørgsmål + +Prototypen er et diskussionsgrundlag, ikke en implementeringsklar løsning. En række forhold skal afklares inden et reelt system bygges. + +### Hvad må udstilles åbent — og hvad skal bag login? + +I prototypen er **kataloget offentligt at browse**, mens det at **dele og eksportere** kræver login. Det er en antagelse, ikke en beslutning. Skal selve eksistensen af en assistent (navn, formål, kommune) være åben, mens JSON og modelkort er bag login? Eller skal hele biblioteket være lukket for ikke-myndigheder? + +### Hvad er der i JSON-filerne? + +Kan vi ukritisk dele indholdet af en OWUI-assistents JSON? Systemprompter kan indeholde interne formuleringer, henvisninger til konkrete sager eller forudsætninger, der ikke bør deles bredt. Der skal tages stilling til, hvad der reviewes inden deling. + +### Hvordan fungerer download og upload på tværs af forskellige løsninger? + +Biblioteket forudsætter, at en assistent eksporteret ét sted kan importeres et andet sted. Hvor ens skal afsender- og modtagermiljøet være? Hvad sker der, når en kommune kører en anden version af OpenWebUI — eller et helt andet rammeværk? + +### Er der en standard for assistenter? + +Findes der en standard (JSON, XML eller andet) for, hvordan en assistent beskrives og overdrages? OpenWebUIs eksportformat er udgangspunktet, men en bibliotek-standard på tværs af rammeværk vil kræve en aftalt struktur. + +### Datagrundlaget + +De faktiske vidensfiler kan sjældent deles på tværs af kommuner (interne data, persondata, rettigheder). Prototypen løser det ved at beskrive datagrundlaget **i opskriftsform**, så den enkelte kommune selv kan opbygge sit lokale grundlag. Er opskriftsformen tilstrækkelig, eller skal der mere standardisering til? + +--- + +## På sigt + +Følgende er ikke en del af v0.1, men biblioteket skal kunne rumme det. I prototypen er mulighederne teaset som "kommer snart": + +- Deling af **tools** og **skills** +- Mulighed for at **rate** assistenter, tools og skills +- **API** så man kan oprette og vedligeholde assistenter fra egen løsning +- Mulighed for at **abonnere** på en assistent og få besked ved ændringer (og dermed hente ny version) +- Deling af **testcases og resultater** på en assistent + +--- + +## Succeskriterie + +At der er delt **to AI-assistenter fra AarhusAI** i AI-biblioteket. Derudover inviteres andre OS2ai-kommuner til at være testkommuner — både til at dele lokalt udviklede assistenter og til at tage delte assistenter i anvendelse lokalt. + +--- + +## Drift og økonomi + +Se [Estimeringsnotat](./estimeringsnotat) for økonomisk estimat, tidshorisont og opmærksomhedspunkter om drift og driftsomkostninger. + +--- + +## Interaktiv prototype + +Åbn prototypen ↗ diff --git a/docs/projects/ai-bibliotek/mocks.md b/docs/projects/ai-bibliotek/mocks.md new file mode 100644 index 0000000..c755d91 --- /dev/null +++ b/docs/projects/ai-bibliotek/mocks.md @@ -0,0 +1,8 @@ +**Project:** AI Bibliotek + +# Interaktive Mocks + +--- + +**AI Bibliotek — prototype ↗** +Single-page prototype hvor danske myndigheder kan dele og hjemtage AI-assistenter. Katalog med søgning og facetter (kommune, sprogmodel, rammeværk, datafølsomhed), assistent-side med modelkort, readme, vidensopskrift og versioneret JSON-eksport, samt et flow til at dele en assistent. Bruger `localStorage` som backend. diff --git a/docs/public/projects/ai-bibliotek/mocks/css/styles.css b/docs/public/projects/ai-bibliotek/mocks/css/styles.css new file mode 100644 index 0000000..c6bf5b4 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/css/styles.css @@ -0,0 +1,1370 @@ +/* AI Bibliotek – prototype styles */ + +:root { + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-surface-2: #fbf9f5; + --color-border: #e3ddd1; + --color-border-strong: #c8bea9; + --color-line: #ddd5c4; + --color-ink: #11192a; + --color-text: #1c2433; + --color-text-muted: #5b6478; + --color-primary: #0f766e; + --color-primary-hover: #0c5e57; + --color-primary-ink: #ffffff; + --color-accent: #c89b3c; + --color-accent-soft: #f0e3c2; + --color-accent-deep: #8a6a1f; + --color-danger: #a4302a; + --color-warn: #b27a14; + --color-ok: #2f6b3a; + --color-focus: #2f6fb6; + + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 20px; + --shadow-sm: 0 1px 0 var(--color-line); + --shadow-md: 0 12px 32px -16px rgba(17, 25, 42, 0.18); + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + --gutter: clamp(20px, 4vw, 56px); + + --font-display: "Fraunces", "Source Serif Pro", "Iowan Old Style", Georgia, serif; + --font-sans: "Geist", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --dur-fast: 120ms; + --dur: 220ms; + + --container-max: 1180px; + --container-wide: 1600px; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.6; + color: var(--color-text); + background: var(--color-bg); + display: flex; + flex-direction: column; + min-height: 100vh; + font-feature-settings: "ss01", "ss02"; +} + +a { color: var(--color-primary); } +a:hover { color: var(--color-primary-hover); } + +h1, h2, h3, h4 { + font-family: var(--font-display); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.01em; + margin: 0 0 var(--space-3); + color: var(--color-ink); + font-variation-settings: "opsz" 36, "SOFT" 30; +} +h1 { font-size: clamp(2rem, 3vw, 2.6rem); letter-spacing: -0.02em; font-variation-settings: "opsz" 96, "SOFT" 40; } +h2 { font-size: 1.55rem; } +h3 { font-size: 1.2rem; } + +p { margin: 0 0 var(--space-3); } + +.container { + width: 100%; + max-width: var(--container-max); + margin: 0 auto; + padding: 0 var(--gutter); +} +.container-wide { + width: 100%; + max-width: var(--container-wide); + margin: 0 auto; + padding: 0 var(--gutter); +} + +main.container, +main.container-wide { + flex: 1 0 auto; + padding-top: clamp(24px, 4vw, 56px); + padding-bottom: clamp(32px, 5vw, 72px); +} + +/* Reusable display utilities */ +.eyebrow { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.16em; + word-spacing: 0.2em; + white-space: nowrap; + text-transform: uppercase; + color: var(--color-accent-deep); +} +.eyebrow::before { + content: ""; + display: inline-block; + width: 24px; + height: 1px; + background: var(--color-accent-deep); +} +.display { font-family: var(--font-display); font-weight: 500; color: var(--color-ink); letter-spacing: -0.01em; } +.serif { font-family: var(--font-display); } +.mono { font-family: var(--font-mono); } +.lede { font-size: 1.125rem; line-height: 1.6; color: var(--color-text); max-width: 60ch; } + +/* Page-enter motion — staggered fade-up on the first few children of the view */ +@keyframes fade-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +#view-root > * { + animation: fade-up var(--dur) var(--ease-out) both; +} +#view-root > *:nth-child(1) { animation-delay: 0ms; } +#view-root > *:nth-child(2) { animation-delay: 40ms; } +#view-root > *:nth-child(3) { animation-delay: 80ms; } +#view-root > *:nth-child(4) { animation-delay: 120ms; } +@media (prefers-reduced-motion: reduce) { + #view-root > * { animation: none; } +} + +/* Skip link */ +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--color-primary); + color: var(--color-primary-ink); + padding: var(--space-2) var(--space-3); +} +.skip-link:focus { left: var(--space-3); top: var(--space-3); z-index: 100; } + +/* Header */ +.site-header { + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); + position: sticky; + top: 32px; /* clear the shared mock banner */ + z-index: 20; +} +.header-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--space-5); + min-height: 64px; +} +.brand { + display: flex; + align-items: center; + gap: var(--space-3); + text-decoration: none; + color: var(--color-ink); +} +.brand-mark { + width: 32px; height: 32px; + background: var(--color-primary); + color: var(--color-primary-ink); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-family: var(--font-display); + font-weight: 600; + font-size: 1.1rem; + line-height: 1; +} +.brand-text { display: flex; flex-direction: column; line-height: 1.05; } +.brand-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.15rem; + letter-spacing: -0.01em; + color: var(--color-ink); + border-bottom: 1px solid var(--color-accent-deep); + padding-bottom: 2px; + align-self: flex-start; +} +.brand-sub { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-text-muted); + margin-top: 4px; +} + +.site-nav { + display: flex; + gap: var(--space-6); + justify-content: center; +} +.site-nav a { + text-decoration: none; + color: var(--color-text); + font-weight: 500; + font-size: 0.95rem; + padding: var(--space-2) 0; + position: relative; +} +.site-nav a::after { + content: ""; + position: absolute; + left: 50%; + bottom: 2px; + width: 100%; + height: 1px; + background: var(--color-ink); + transform: translateX(-50%) scaleX(0); + transform-origin: center; + transition: transform var(--dur) var(--ease-out); +} +.site-nav a:hover { color: var(--color-ink); } +.site-nav a:hover::after { transform: translateX(-50%) scaleX(1); } +.site-nav a.active { color: var(--color-ink); } +.site-nav a.active::after { + content: ""; + width: 4px; height: 4px; + border-radius: 50%; + background: var(--color-accent-deep); + position: absolute; + bottom: -4px; + left: 50%; + transform: translateX(-50%); +} + +.nav-toggle { + display: none; + width: 40px; height: 40px; + background: transparent; + border: 1px solid var(--color-line); + border-radius: var(--radius-sm); + cursor: pointer; + padding: 0; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; +} +.nav-toggle-bar { + display: block; + width: 18px; + height: 1.5px; + background: var(--color-ink); + transition: transform var(--dur) var(--ease-out), opacity var(--dur) var(--ease-out); +} +.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) { transform: translateY(5.5px) rotate(45deg); } +.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) { opacity: 0; } +.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) { transform: translateY(-5.5px) rotate(-45deg); } + +.user-area { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: 0.95rem; + justify-self: end; +} +.user-area .user-name { + color: var(--color-text); + padding: var(--space-1) 0; + font-weight: 500; +} + +@media (max-width: 1020px) { + .header-row { grid-template-columns: auto auto 1fr; } + .nav-toggle { display: inline-flex; order: 2; justify-self: end; } + .user-area { justify-self: end; } + .site-nav { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--color-surface); + border-bottom: 1px solid var(--color-line); + flex-direction: column; + gap: 0; + padding: var(--space-3) var(--gutter) var(--space-4); + display: none; + } + .site-nav.is-open { display: flex; } + .site-nav a { padding: var(--space-3) 0; border-bottom: 1px solid var(--color-line); } + .site-nav a:last-child { border-bottom: none; } + .site-nav a::after, .site-nav a.active::after { display: none; } +} + +/* Footer */ +.site-footer { + background: var(--color-surface); + border-top: 1px solid var(--color-line); + padding: var(--space-5) 0; + margin-top: var(--space-7); + font-size: 0.875rem; + color: var(--color-text-muted); +} +.footer-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} +.footer-meta { margin: 0; } +.footer-link { text-decoration: none; color: var(--color-text-muted); } +.footer-link:hover { color: var(--color-ink); } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid transparent; + background: var(--color-primary); + color: var(--color-primary-ink); + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; + line-height: 1.2; + transition: background 0.15s ease, transform 0.05s ease; +} +.btn:hover, a.btn:hover { background: var(--color-primary-hover); color: var(--color-primary-ink); } +.btn:active { transform: translateY(1px); } +.btn[disabled] { opacity: 0.5; cursor: not-allowed; } +.btn-secondary { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-border-strong); +} +.btn-secondary:hover, a.btn-secondary:hover { background: var(--color-surface-2); color: var(--color-text); } +.btn-ghost { background: transparent; color: var(--color-primary); } +.btn-ghost:hover, a.btn-ghost:hover { background: var(--color-surface-2); color: var(--color-primary); } +.btn-danger { background: var(--color-danger); } +.btn-danger:hover, a.btn-danger:hover { background: #842722; color: var(--color-primary-ink); } +.btn-sm { padding: var(--space-2) var(--space-3); font-size: 0.9rem; } +.btn-icon { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text); + width: 36px; height: 36px; + padding: 0; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} +.btn-icon:hover { background: var(--color-surface-2); } +.btn-icon.is-on { + background: #e3f0ee; + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* Icons */ +.icon { flex-shrink: 0; vertical-align: -3px; } +.btn .icon, .btn-icon .icon { vertical-align: 0; } + +/* Forms */ +.form-grid { display: grid; gap: var(--space-4); } +.form-grid.cols-2 { grid-template-columns: 1fr 1fr; } +@media (max-width: 700px) { .form-grid.cols-2 { grid-template-columns: 1fr; } } + +label.field { display: flex; flex-direction: column; gap: var(--space-1); font-size: 0.9rem; color: var(--color-text-muted); } +label.field .hint { font-size: 0.8rem; color: var(--color-text-muted); } +input[type="text"], input[type="email"], input[type="password"], input[type="number"], +input[type="search"], input[type="date"], input[type="url"], input[type="tel"], +textarea, select { + font: inherit; + padding: var(--space-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + width: 100%; + line-height: 1.4; +} +input[type="date"] { min-height: 45px; appearance: none; -webkit-appearance: none; } +input[type="date"]::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + filter: invert(0.3); +} +input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; } + +select { + appearance: none; + -webkit-appearance: none; + padding-right: calc(var(--space-3) + 24px); + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right var(--space-3) center; + background-size: 12px 12px; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; } +textarea { resize: vertical; min-height: 96px; } +input:focus, textarea:focus, select:focus { + outline: 2px solid var(--color-focus); + outline-offset: 1px; + border-color: var(--color-focus); +} + +/* Cards */ +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-5); + box-shadow: var(--shadow-sm); +} +.card h2, .card h3 { margin-top: 0; } +.card + .card { margin-top: var(--space-4); } +/* Inside the detail grid, cards sit side-by-side at >=900px — kill the + stacking margin so the aside aligns with the main card's top. The margin + comes back below the breakpoint where the grid collapses to one column. */ +@media (min-width: 900px) { + .detail-grid > .card + .card { margin-top: 0; } +} + +/* Hero — editorial */ +.hero { + padding: 0 0 var(--space-7); + max-width: none; +} +.hero .eyebrow { margin-bottom: var(--space-4); } +.hero-title { + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2.4rem, 7vw, 5.6rem); + line-height: 1.02; + letter-spacing: -0.025em; + color: var(--color-ink); + margin: 0 0 var(--space-5); + max-width: 22ch; + font-variation-settings: "opsz" 144, "SOFT" 50; +} +.hero-title em { + font-style: italic; + color: var(--color-accent-deep); + font-variation-settings: "opsz" 144, "SOFT" 100; +} +.hero .lede { margin-bottom: var(--space-6); } + +/* Home search — sits above the hero, full width */ +.home-search { + padding: 0 0 var(--space-6); + margin-bottom: var(--space-7); +} +.home-search .eyebrow { margin-bottom: var(--space-3); } +.home-search-form { + display: flex; + gap: var(--space-4); + align-items: center; + border-bottom: 1px solid var(--color-ink); + padding-bottom: var(--space-3); +} +.home-search-form input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + font-family: var(--font-display); + font-size: clamp(1.25rem, 2vw, 1.6rem); + font-weight: 400; + color: var(--color-ink); + padding: var(--space-2) 0; + border-radius: 0; + font-variation-settings: "opsz" 48, "SOFT" 30; +} +.home-search-form input::placeholder { color: var(--color-text-muted); font-style: italic; } +.home-search-form input:focus { outline: none; } +.home-search-form .btn { + border-radius: 999px; + padding: var(--space-3) var(--space-5); + flex-shrink: 0; +} + +.hero-stats { + display: flex; + gap: clamp(var(--space-5), 5vw, var(--space-7)); + margin: 0; + flex-wrap: wrap; + padding-top: var(--space-5); + border-top: 1px solid var(--color-line); +} +.hero-stats div { display: flex; flex-direction: column; gap: 2px; } +.hero-stats dt { + order: 2; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); +} +.hero-stats dd { + order: 1; + margin: 0; + font-family: var(--font-display); + font-weight: 500; + font-size: clamp(2rem, 3.5vw, 2.8rem); + line-height: 1; + color: var(--color-ink); + font-variant-numeric: lining-nums tabular-nums; + font-variation-settings: "opsz" 96, "SOFT" 30; +} + +/* Recent rail section */ +.recent-section { margin-bottom: var(--space-7); } +.section-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-5); + flex-wrap: wrap; +} +.section-link { + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text); +} +.section-link:hover { color: var(--color-primary); } + +.recent-rail-wrap { position: relative; } +.rail-nav-bar { display: none; } +.rail-nav { display: inline-flex; } + +.recent-rail { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--space-4); +} +@media (max-width: 1180px) { + .recent-rail { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .recent-rail .rail-card:nth-child(n+7) { display: none; } +} +@media (max-width: 900px) { + .recent-rail { + grid-template-columns: none; + grid-auto-flow: column; + grid-auto-columns: 80%; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + /* Bleed past the wide container's gutters on both sides so off-screen + cards fade out at the actual viewport edges. The first card's + leading padding inside the scroller keeps it aligned with the + eyebrow above at the resting position. */ + margin-inline: calc(var(--gutter) * -1); + padding: 0 var(--gutter) var(--space-3); + gap: var(--space-3); + scroll-padding-left: var(--gutter); + } + .recent-rail::-webkit-scrollbar { display: none; } + .recent-rail .rail-card { scroll-snap-align: start; } + .recent-rail .rail-card:nth-child(n+7) { display: flex; } + + .rail-nav-bar { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-3); + } + .rail-nav { + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-surface); + border: 1px solid var(--color-line); + color: var(--color-ink); + cursor: pointer; + } + .rail-nav:hover { background: var(--color-surface-2); } +} +.rail-card { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + padding: var(--space-5); + text-decoration: none; + color: var(--color-ink); + display: flex; + flex-direction: column; + gap: var(--space-2); + transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out); +} +.rail-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} +.rail-meta { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); +} +.rail-title { + font-family: var(--font-display); + font-weight: 500; + font-size: 1.15rem; + line-height: 1.2; + color: var(--color-ink); +} +.rail-summary { + font-size: 0.92rem; + line-height: 1.55; + color: var(--color-text); +} + +@media (max-width: 700px) { + .recent-rail { grid-auto-columns: 80%; } +} + +/* "Sådan virker det" card */ +.how-it-works .eyebrow { margin-bottom: var(--space-4); } +.steps-list { + list-style: none; + counter-reset: step; + margin: 0; + padding: 0; + display: grid; + gap: 0; +} +.steps-list li { + counter-increment: step; + display: grid; + grid-template-columns: clamp(48px, 5vw, 72px) minmax(0, 60ch); + gap: var(--space-4); + align-items: start; + padding: var(--space-4) 0; + border-bottom: 1px solid var(--color-line); + line-height: 1.55; +} +.steps-list li:first-child { padding-top: 0; } +.steps-list li:last-child { border-bottom: none; padding-bottom: 0; } +.steps-list li::before { + content: counter(step, decimal-leading-zero); + font-family: var(--font-display); + font-weight: 500; + font-size: 1.5rem; + line-height: 1.55; + color: var(--color-accent-deep); + font-variant-numeric: lining-nums tabular-nums; + font-variation-settings: "opsz" 48, "SOFT" 30; +} +.steps-list .step-body { display: block; min-width: 0; } + +/* Search layout */ +.search-layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: var(--space-6); + align-items: start; +} +@media (min-width: 1280px) { + .search-layout { grid-template-columns: 260px minmax(0, 1fr) 280px; } +} +@media (max-width: 900px) { + .search-layout { grid-template-columns: 1fr; gap: var(--space-4); } +} +.facet-group { + margin: 0; + border-bottom: 1px solid var(--color-line); +} +.facet-group:last-of-type { border-bottom: none; } +.facet-group > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-3) 0; + user-select: none; +} +.facet-group > summary::-webkit-details-marker { display: none; } +.facet-group h4 { + margin: 0; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-text-muted); + font-weight: 600; + font-family: var(--font-sans); +} +.facet-chevron { + width: 8px; + height: 8px; + border-right: 1.5px solid var(--color-text-muted); + border-bottom: 1.5px solid var(--color-text-muted); + transform: rotate(45deg); + transition: transform var(--dur) var(--ease-out); + margin-right: 4px; +} +.facet-group[open] > summary .facet-chevron { transform: rotate(-135deg); margin-top: 4px; } +.facet-group > summary:hover h4 { color: var(--color-ink); } +.facet-group ul { list-style: none; margin: 0; padding: 0 0 var(--space-3); } +.facet-group li label { + display: flex; align-items: center; gap: var(--space-2); + padding: var(--space-1) 0; + font-size: 0.92rem; + cursor: pointer; +} +.facet-group .count { color: var(--color-text-muted); font-size: 0.85rem; margin-left: auto; } + +.facet-aside, .context-aside { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + padding: var(--space-5); +} +.facet-aside { position: sticky; top: 116px; } +/* The context rail is the sticky container; its child asides flow inside it. */ +.context-rail { position: sticky; top: 116px; display: grid; gap: var(--space-4); } +@media (max-width: 900px) { + .facet-aside, .context-rail { position: static; } +} + +.context-aside h3 { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-text-muted); + font-family: var(--font-sans); + font-weight: 600; + margin-top: 0; + margin-bottom: var(--space-3); +} +.context-empty { font-size: 0.9rem; color: var(--color-text-muted); margin: 0; } + +.active-filters { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--color-surface-2); + border: 1px solid var(--color-line); + border-radius: 999px; + font-size: 0.85rem; + cursor: pointer; + font: inherit; + font-size: 0.85rem; + color: var(--color-ink); +} +.filter-chip:hover { background: #f1ebda; } +.filter-chip-x { + display: inline-block; + width: 14px; + height: 14px; + line-height: 12px; + text-align: center; + border-radius: 50%; + background: var(--color-line); + color: var(--color-ink); + font-size: 12px; +} + +.recent-searches { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; } +.recent-searches a { + display: block; + padding: 4px 0; + text-decoration: none; + color: var(--color-text); + font-size: 0.92rem; +} +.recent-searches a:hover { color: var(--color-primary); } + +.results-header { + display: flex; align-items: baseline; justify-content: space-between; + margin-bottom: var(--space-4); + gap: var(--space-4); + flex-wrap: wrap; +} +.results-header .count { color: var(--color-text-muted); } +.results-list { display: grid; gap: var(--space-4); } + +/* Publication card */ +.pub-card { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-lg); + padding: var(--space-5) var(--space-6); + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-5); + transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out); +} +.pub-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} +.pub-card .meta { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: var(--space-2); +} +.pub-card h3 { + margin: 0 0 var(--space-3); + font-family: var(--font-display); + font-weight: 500; + font-size: 1.35rem; + line-height: 1.2; + font-variation-settings: "opsz" 36, "SOFT" 30; +} +.pub-card h3 a { text-decoration: none; color: var(--color-ink); } +.pub-card h3 a:hover { color: var(--color-primary); } +.pub-card .summary { + margin: 0 0 var(--space-4); + max-width: 72ch; + color: var(--color-text); +} +.badges { display: flex; gap: var(--space-2); flex-wrap: wrap; } +.pub-card .badges { gap: var(--space-3); } +.pub-card .actions { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-end; } +@media (max-width: 700px) { + .pub-card { grid-template-columns: 1fr; padding: var(--space-4) var(--space-5); } + .pub-card .actions { flex-direction: row; align-items: center; } +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 3px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid transparent; + white-space: nowrap; + line-height: 1.4; +} +.badge-rights { background: var(--color-surface-2); border-color: var(--color-border-strong); color: var(--color-text); } +.badge-rights[data-level="1"] { background: #f1ece2; color: #5b3e1a; border-color: #d9c89a; } +.badge-rights[data-level="2"], .badge-rights[data-level="3"] { background: #eef2f7; color: #163057; border-color: #b4c4dc; } +.badge-rights[data-level="4"], .badge-rights[data-level="5"] { background: #e6efe4; color: #2f6b3a; border-color: #bdd5b6; } +.badge-rights[data-level="6"], .badge-rights[data-level="7"] { background: #dfeadd; color: #2f6b3a; border-color: #9bc394; } + +.badge-risk::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; border-radius: 50%; + background: currentColor; +} +.badge-risk[data-risk="green"] { color: var(--color-ok); background: #e6efe4; border-color: #bdd5b6; } +.badge-risk[data-risk="yellow"] { color: var(--color-warn); background: #f7eed3; border-color: #e1cd86; } +.badge-risk[data-risk="red"] { color: var(--color-danger); background: #f6e0de; border-color: #e3a9a5; } + +.badge-doctype { + background: transparent; + border-color: transparent; + padding: 3px 0; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.7rem; +} +.badge-doctype::before { + content: ""; + display: inline-block; + width: 4px; height: 4px; border-radius: 50%; + background: var(--color-accent-deep); + margin-right: 2px; +} + +/* Framework badge (rammeværk, e.g. OpenWebUI). Reuses the teal accent. */ +.badge-framework { + background: var(--color-surface-2); + border-color: var(--color-primary); + color: var(--color-primary); +} + +/* Data sensitivity badge (datafølsomhed). Mirrors the old risk colour scale: + almindelige → green, fortrolige → yellow, personfoelsomme → red. */ +.badge-sensitivity::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; border-radius: 50%; + background: currentColor; +} +.badge-sensitivity[data-level="almindelige"] { color: var(--color-ok); background: #e6efe4; border-color: #bdd5b6; } +.badge-sensitivity[data-level="fortrolige"] { color: var(--color-warn); background: #f7eed3; border-color: #e1cd86; } +.badge-sensitivity[data-level="personfoelsomme"] { color: var(--color-danger); background: #f6e0de; border-color: #e3a9a5; } + +/* "Kommer snart" teaser chips on the home page. */ +.coming-soon-grid { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); +} +.coming-soon-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 6px 12px; + border-radius: 999px; + border: 1px dashed var(--color-border-strong); + background: var(--color-surface-2); + color: var(--color-text-muted); + font-size: 0.82rem; + cursor: not-allowed; + opacity: 0.75; +} +.coming-soon-chip .cs-tag { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-primary); +} + +/* Breadcrumb above detail/grid layouts */ +.breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.85rem; + color: var(--color-text-muted); + margin-bottom: var(--space-4); +} +.breadcrumb a { text-decoration: none; color: var(--color-text); } +.breadcrumb a:hover { color: var(--color-primary); } +.breadcrumb-sep { color: var(--color-line); } + +/* Detail page */ +.detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: var(--space-6); + align-items: start; +} +@media (min-width: 1280px) { + .detail-grid { + grid-template-columns: minmax(0, 1fr) 280px 240px; + gap: clamp(var(--space-5), 3vw, var(--space-7)); + } +} +@media (max-width: 1023px) { + .detail-grid { + grid-template-columns: 1fr; + gap: var(--space-5); + } +} + +.detail-main h1 { + font-size: clamp(2rem, 4vw, 3rem); + letter-spacing: -0.02em; + font-variation-settings: "opsz" 96, "SOFT" 50; + line-height: 1.05; + margin-bottom: var(--space-3); +} +.detail-main .detail-subtitle { + font-family: var(--font-display); + font-style: italic; + font-size: 1.15rem; + color: var(--color-text-muted); + margin: 0 0 var(--space-5); + font-variation-settings: "opsz" 36, "SOFT" 100; +} +.detail-main .resume-block { font-size: 1.05rem; line-height: 1.65; } +.detail-main h2 { margin-top: var(--space-6); } +.detail-main h3 { margin-top: var(--space-5); } + +.detail-meta-aside, +.detail-actions-aside { + position: sticky; + top: 84px; +} +.detail-meta-aside .eyebrow { margin-bottom: var(--space-3); display: flex; } + +/* 1024–1279px: two columns. The right column stacks actions then metadata. */ +@media (min-width: 1024px) and (max-width: 1279px) { + .detail-grid { + grid-template-columns: minmax(0, 1fr) 320px; + grid-template-rows: auto 1fr; + } + .detail-main { grid-column: 1; grid-row: 1 / span 2; } + .detail-actions-aside { grid-column: 2; grid-row: 1; position: sticky; top: 116px; } + .detail-meta-aside { grid-column: 2; grid-row: 2; position: static; } +} + +/* < 1024px: single column. Order: report → actions → metadata. */ +@media (max-width: 1023px) { + .detail-meta-aside, + .detail-actions-aside { position: static; } + .detail-grid { + grid-template-columns: 1fr; + grid-template-areas: "main" "actions" "meta"; + } + .detail-main { grid-area: main; } + .detail-actions-aside { grid-area: actions; } + .detail-meta-aside { grid-area: meta; } +} + +.detail-actions { display: grid; gap: var(--space-2); } +.detail-actions .btn { width: 100%; justify-content: center; } +@media (min-width: 1280px) { + /* Narrow rail — let buttons hug content for cleaner reading */ + .detail-actions .btn { justify-content: flex-start; padding-left: var(--space-4); padding-right: var(--space-4); } +} + +.detail-meta { + margin: 0; + display: grid; + gap: 0; +} +.detail-meta dt { + color: var(--color-text-muted); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 600; + margin: 0; + padding-top: var(--space-3); +} +.detail-meta dd { + margin: 0; + padding: 2px 0 var(--space-3); + border-bottom: 1px solid var(--color-line); + font-size: 0.95rem; + color: var(--color-ink); +} +.detail-meta dd:last-child { border-bottom: none; } + +/* Upload */ +.dropzone { + border: 2px dashed var(--color-border-strong); + border-radius: var(--radius-lg); + padding: var(--space-7); + text-align: center; + background: var(--color-surface); + cursor: pointer; +} +.dropzone.is-dragover { border-color: var(--color-primary); background: var(--color-surface-2); } +.dropzone p { margin: var(--space-2) 0; } + +.spinner { + width: 36px; height: 36px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto var(--space-3); +} +@keyframes spin { to { transform: rotate(360deg); } } + +.rights-list { display: grid; gap: var(--space-2); } +.rights-list label { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + background: var(--color-surface); +} +.rights-list label:hover { border-color: var(--color-border-strong); } +.rights-list label:has(input:checked) { border-color: var(--color-primary); background: var(--color-surface-2); } +.rights-list .rl-title { font-weight: 600; } +.rights-list .rl-desc { color: var(--color-text-muted); font-size: 0.9rem; } + +.check-list { display: grid; gap: var(--space-2); } +.check-list label { + display: flex; align-items: flex-start; gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +/* Generic page header for non-hero views */ +.page-head { margin-bottom: var(--space-5); max-width: 70ch; } + +/* Alert / notice block */ +.alert { + display: flex; + gap: var(--space-3); + align-items: flex-start; + padding: var(--space-4) var(--space-5); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); +} +.alert p { margin: 0; line-height: 1.5; } +.alert-mark { + display: inline-block; + width: 22px; + height: 22px; + border-radius: 50%; + flex-shrink: 0; + position: relative; + margin-top: 2px; +} +.alert-mark::before { + content: "!"; + font-family: var(--font-display); + font-weight: 600; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; +} +.alert-rights { + background: var(--color-accent-soft); + border: 1px solid var(--color-accent-deep); + color: var(--color-ink); +} +.alert-rights .alert-mark { background: var(--color-accent-deep); color: #fff; } + +/* Upload page: sidebar steps + step card */ +.upload-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: var(--space-6); + align-items: start; +} +@media (max-width: 1023px) { + .upload-layout { grid-template-columns: 1fr; gap: var(--space-4); } +} +.upload-step { max-width: 880px; min-width: 0; } + +.step-rail { + display: grid; + gap: var(--space-3); + position: sticky; + top: 84px; +} +@media (max-width: 1023px) { + .step-rail { + position: static; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: var(--space-2); + } +} +.step-item { + display: grid; + grid-template-columns: 36px 1fr; + gap: var(--space-3); + padding: var(--space-3) var(--space-3); + border-left: 2px solid var(--color-line); + background: transparent; + color: var(--color-text-muted); +} +@media (max-width: 1023px) { + .step-item { + grid-template-columns: 28px 1fr; + padding: var(--space-2) var(--space-3); + border-left: none; + border-top: 2px solid var(--color-line); + background: var(--color-surface); + border-radius: var(--radius-sm); + } +} +.step-item .step-num { + font-family: var(--font-display); + font-weight: 500; + font-size: 1.1rem; + color: var(--color-text-muted); + font-variant-numeric: lining-nums tabular-nums; + line-height: 1.4; +} +.step-item .step-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.step-item .step-title { font-weight: 600; font-size: 0.95rem; color: var(--color-text); } +.step-item .step-hint { font-size: 0.8rem; color: var(--color-text-muted); } +@media (max-width: 1023px) { + .step-item .step-hint { display: none; } +} + +.step-active { + border-left-color: var(--color-accent-deep); + background: var(--color-surface); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} +@media (max-width: 1023px) { + .step-active { border-top-color: var(--color-accent-deep); } +} +.step-active .step-num { color: var(--color-accent-deep); } +.step-active .step-title { color: var(--color-ink); } + +.step-done .step-num { color: var(--color-ok); display: inline-flex; align-items: center; } +.step-done .step-title { color: var(--color-text-muted); } + +/* Empty states */ +.empty { + text-align: center; + padding: var(--space-7) var(--space-5); + color: var(--color-text-muted); +} +.empty h2 { color: var(--color-text); } + +/* Toast */ +.toast-region { + position: fixed; + bottom: var(--space-5); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: var(--space-2); + z-index: 100; +} +.toast { + background: var(--color-text); + color: #fff; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + font-size: 0.95rem; + animation: toast-in 0.2s ease-out; +} +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Auth forms */ +.auth-card { max-width: 480px; margin: 0 auto; } +.auth-tabs { + display: flex; + border-bottom: 1px solid var(--color-border); + margin-bottom: var(--space-5); +} +.auth-tabs button { + flex: 1; + background: transparent; + border: none; + padding: var(--space-3); + font: inherit; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; +} +.auth-tabs button.is-active { color: var(--color-primary); border-bottom-color: var(--color-primary); } + +/* Demo users on the login screen */ +.demo-users { + margin-top: var(--space-5); + padding-top: var(--space-4); + border-top: 1px dashed var(--color-border-strong); +} +.demo-users-head { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.14em; + font-weight: 600; + color: var(--color-text-muted); + margin: 0 0 var(--space-3); +} +.demo-user { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + text-align: left; + padding: var(--space-3); + margin-bottom: var(--space-2); + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + font: inherit; +} +.demo-user:hover { border-color: var(--color-primary); } +.demo-user-name { font-weight: 600; color: var(--color-ink); font-size: 0.95rem; } +.demo-user-cred { font-family: var(--font-mono); font-size: 0.85rem; color: var(--color-text-muted); } +.demo-users-foot { margin: var(--space-2) 0 0; } + +/* Tag chips */ +.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.chip { + display: inline-flex; + align-items: center; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: 2px var(--space-2); + border-radius: 999px; + font-size: 0.85rem; +} + +/* Collection sharing notice */ +.share-notice { + background: var(--color-accent-soft); + border: 1px solid var(--color-accent); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-size: 0.9rem; + margin-bottom: var(--space-4); +} + +/* Modal */ +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(20, 28, 48, 0.45); + display: flex; align-items: center; justify-content: center; + z-index: 50; + padding: var(--space-4); +} +.modal { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: var(--space-5); + max-width: 480px; + width: 100%; + box-shadow: var(--shadow-md); +} +.modal h3 { margin-top: 0; } +.modal-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); } + +/* Inline utilities */ +.row { display: flex; gap: var(--space-3); align-items: center; } +.row.wrap { flex-wrap: wrap; } +.mt-3 { margin-top: var(--space-3); } +.mt-4 { margin-top: var(--space-4); } +.mt-5 { margin-top: var(--space-5); } +.mb-3 { margin-bottom: var(--space-3); } +.mb-4 { margin-bottom: var(--space-4); } +.muted { color: var(--color-text-muted); } +.right { display: flex; justify-content: flex-end; gap: var(--space-2); } +.divider { height: 1px; background: var(--color-border); margin: var(--space-5) 0; border: 0; } +.text-sm { font-size: 0.9rem; } +.no-wrap { white-space: nowrap; } diff --git a/docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js b/docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js new file mode 100644 index 0000000..f1db26b --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/data/seed-assistants.js @@ -0,0 +1,570 @@ +/* Seeded fake AI assistants. + Exposed as window.SEED_ASSISTANTS so the app works when opened + via file:// where fetch() of local JSON is often blocked. + + Each assistant has one or more versions; every version carries an + OpenWebUI-shaped JSON object that can be exported/downloaded. +*/ +window.SEED_ASSISTANTS = [ + { + id: "seed-001", + name: "Borgerservice FAQ-assistent", + tagline: "Svarer borgere på de hyppigste spørgsmål i borgerservice", + description: + "En assistent der besvarer borgernes hyppigste spørgsmål om flytning, pas, kørekort, sundhedskort og ventetider. Bygger på kommunens egne vejledninger og henviser altid til den rette selvbetjeningsløsning. Tænkt som førstelinjehjælp, der aflaster telefonerne i borgerservice.", + originKommune: "Aarhus Kommune", + languageModel: "llama3.1:70b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["borgerservice", "faq", "selvbetjening", "førstelinje"], + aiTags: ["selvbetjening"], + createdAt: "2025-01-14", + updatedAt: "2025-04-02", + readme: + "# Borgerservice FAQ-assistent\n\nDenne assistent er udviklet af AarhusAI til at besvare borgernes hyppigste henvendelser.\n\nAssistenten er trænet til at:\n- svare i et letforståeligt sprog\n- altid henvise til den korrekte selvbetjeningsløsning\n- afvise spørgsmål den ikke har dækning for, frem for at gætte\n\nDen erstatter ikke sagsbehandling, men hjælper borgeren videre til rette sted.", + modelCard: + "Sprogmodel: Llama 3.1 70B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.3 (lav, for konsistente svar).\n\nHensyn:\n- Assistenten må ikke træffe afgørelser eller love sagsbehandlingstider.\n- Svar bør altid ledsages af et link til officiel selvbetjening.\n- Modellen kan hallucinere ved spørgsmål uden for vidensgrundlaget; system-prompten beder den henvise til en medarbejder i tvivlstilfælde.", + knowledgeRecipe: + "1. Eksportér alle aktive FAQ-artikler fra kommunens hjemmeside som PDF eller Markdown.\n2. Saml gældende vejledninger om flytning, pas, kørekort og sundhedskort i én mappe.\n3. Tilføj en oversigt over lokale åbningstider og kontaktinformation.\n4. Upload filerne som en Knowledge-collection i OpenWebUI med navnet \"Borgerservice\".\n5. Opdatér samlingen kvartalsvist, så svarene følger gældende regler.", + versions: [ + { + version: "1.3.0", + releasedAt: "2025-04-02", + notes: "Tilføjet håndtering af spørgsmål om sundhedskort og opdateret system-prompt.", + json: { + id: "borgerservice-faq", + name: "Borgerservice FAQ-assistent", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Besvarer hyppige borgerhenvendelser i borgerservice.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "borgerservice" }, { name: "faq" }] + }, + params: { + system: "Du er en hjælpsom assistent for borgerservice i Aarhus Kommune. Svar kort og letforståeligt, og henvis altid til den rette selvbetjeningsløsning. Er du i tvivl, så bed borgeren kontakte en medarbejder.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Borgerservice", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + }, + { + version: "1.0.0", + releasedAt: "2025-01-14", + notes: "Første version delt med kommunenetværket.", + json: { + id: "borgerservice-faq", + name: "Borgerservice FAQ-assistent", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Besvarer hyppige borgerhenvendelser.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "borgerservice" }] + }, + params: { + system: "Du er en hjælpsom assistent for borgerservice. Henvis til selvbetjening hvor muligt.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Borgerservice", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-002", + name: "Sagsbehandler-medhjælper til byggesager", + tagline: "Hjælper byggesagsbehandlere med at finde regler og fortilfælde", + description: + "En assistent til byggesagsbehandlere, der hurtigt finder relevante bestemmelser i bygningsreglementet, lokalplaner og kommunens egne vejledninger. Den opsummerer komplekse sager og foreslår, hvilke forhold der skal vurderes. Mennesket træffer altid den endelige afgørelse.", + originKommune: "Aarhus Kommune", + languageModel: "gpt-4o", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["byggesag", "sagsbehandling", "bygningsreglement", "lokalplan"], + aiTags: ["fortilfælde"], + createdAt: "2025-02-20", + updatedAt: "2025-05-09", + readme: + "# Sagsbehandler-medhjælper til byggesager\n\nUnderstøtter byggesagsbehandlere i at navigere i bygningsreglement, lokalplaner og praksis.\n\nAssistenten:\n- finder og citerer relevante bestemmelser\n- opsummerer indkomne ansøgninger\n- peger på forhold, der kræver nærmere vurdering\n\nAfgørelser træffes altid af en sagsbehandler. Assistenten er et opslagsværktøj, ikke en beslutningsmaskine.", + modelCard: + "Sprogmodel: GPT-4o (kan køres mod lokal eller hostet endpoint via OWUI).\nKontekstvindue: 16384 tokens.\nTemperatur: 0.2.\n\nHensyn:\n- Sager kan indeholde fortrolige oplysninger; assistenten må kun anvendes i godkendt miljø.\n- Citater skal verificeres mod den autoritative kilde inden brug i afgørelser.\n- Vidensgrundlaget skal holdes opdateret ved ændringer i bygningsreglementet.", + knowledgeRecipe: + "1. Eksportér gældende bygningsreglement og relevante BR-vejledninger som PDF.\n2. Saml kommunens egne byggesagsvejledninger og standardsvar.\n3. Tilføj de lokalplaner, der oftest er i spil i jeres kommune.\n4. Upload som Knowledge-collection \"Byggesag\" i OpenWebUI.\n5. Markér dokumenter med ikrafttrædelsesdato, så assistenten kan skelne mellem gældende og historisk praksis.", + versions: [ + { + version: "2.1.0", + releasedAt: "2025-05-09", + notes: "Udvidet kontekstvindue og bedre citatformat.", + json: { + id: "byggesag-medhjaelper", + name: "Sagsbehandler-medhjælper til byggesager", + object: "model", + base_model_id: "gpt-4o", + meta: { + description: "Opslag i bygningsreglement og lokalplaner for byggesagsbehandlere.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "byggesag" }, { name: "sagsbehandling" }] + }, + params: { + system: "Du er en faglig medhjælper for byggesagsbehandlere i Aarhus Kommune. Find og citér relevante bestemmelser præcist med kildehenvisning. Træf aldrig afgørelser — peg på forhold der skal vurderes af en sagsbehandler.", + temperature: 0.2, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [ + { name: "Byggesag", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + }, + { + version: "2.0.0", + releasedAt: "2025-02-20", + notes: "Skiftet basismodel til GPT-4o.", + json: { + id: "byggesag-medhjaelper", + name: "Sagsbehandler-medhjælper til byggesager", + object: "model", + base_model_id: "gpt-4o", + meta: { + description: "Opslag i bygningsreglement for byggesagsbehandlere.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "byggesag" }] + }, + params: { + system: "Du er en faglig medhjælper for byggesagsbehandlere. Citér kilder og træf ingen afgørelser.", + temperature: 0.2, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Byggesag", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-003", + name: "Referat-opsummering til møder", + tagline: "Laver strukturerede referater og handlepunkter fra mødenoter", + description: + "Indsæt rå mødenoter eller en transskription, og assistenten leverer et struktureret referat med beslutninger, handlepunkter og ansvarlige. Velegnet til udvalgsmøder, projektmøder og personalemøder.", + originKommune: "Odense Kommune", + languageModel: "mistral-large", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["referat", "møder", "opsummering", "produktivitet"], + aiTags: ["handlepunkter"], + createdAt: "2025-03-01", + updatedAt: "2025-03-28", + readme: + "# Referat-opsummering\n\nForvandler rå mødenoter til et struktureret referat.\n\nOutput indeholder fast:\n- Deltagere (hvis nævnt)\n- Beslutninger\n- Handlepunkter med ansvarlig og frist\n- Åbne spørgsmål\n\nKontrollér altid referatet inden udsendelse — assistenten kan misforstå forkortelser og uformelt sprog.", + modelCard: + "Sprogmodel: Mistral Large (lokal via OWUI).\nKontekstvindue: 32768 tokens (rummer lange transskriptioner).\nTemperatur: 0.4.\n\nHensyn:\n- Mødenoter kan indeholde personoplysninger om medarbejdere; brug kun i godkendt miljø.\n- Assistenten gætter ikke på navne den ikke er givet.\n- Lange møder bør deles op, hvis de overstiger kontekstvinduet.", + knowledgeRecipe: + "Denne assistent kræver ikke et fast vidensgrundlag — den arbejder på den tekst, du indsætter.\n\n1. Indsæt rå noter eller transskription i chatten.\n2. (Valgfrit) Tilføj en skabelon for jeres referatformat som Knowledge-fil, hvis I ønsker et bestemt layout.\n3. Bed eventuelt om referat på letlæst dansk til bred udsendelse.", + versions: [ + { + version: "1.1.0", + releasedAt: "2025-03-28", + notes: "Tilføjet fast sektion for åbne spørgsmål.", + json: { + id: "referat-opsummering", + name: "Referat-opsummering til møder", + object: "model", + base_model_id: "mistral-large", + meta: { + description: "Laver strukturerede referater fra mødenoter.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "referat" }, { name: "møder" }] + }, + params: { + system: "Du opsummerer mødenoter til et struktureret referat med deltagere, beslutninger, handlepunkter (ansvarlig + frist) og åbne spørgsmål. Gæt ikke på navne eller beslutninger der ikke fremgår.", + temperature: 0.4, + top_p: 0.9, + num_ctx: 32768 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-004", + name: "Journaliseringshjælp", + tagline: "Foreslår journalnummer, titel og emneord ved journalisering", + description: + "Hjælper medarbejdere med at journalisere korrekt i ESDH. Ud fra et dokuments indhold foreslår assistenten sagstitel, relevant journalplan-kode og emneord, så journaliseringen bliver ensartet på tværs af afdelinger.", + originKommune: "Aalborg Kommune", + languageModel: "gemma2:27b", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["journalisering", "esdh", "arkiv", "sagsstyring"], + aiTags: ["journalplan"], + createdAt: "2025-01-30", + updatedAt: "2025-04-18", + readme: + "# Journaliseringshjælp\n\nGiver forslag til journalisering ud fra et dokuments indhold.\n\nAssistenten foreslår:\n- sagstitel\n- journalplan-kode\n- emneord\n\nForslagene skal godkendes af medarbejderen inden journalisering. Den endelige journalplan er kommunens ansvar.", + modelCard: + "Sprogmodel: Gemma 2 27B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.2.\n\nHensyn:\n- Dokumenter kan være fortrolige; kør kun i godkendt lokalt miljø.\n- Journalplan-koder skal matche kommunens egen plan — derfor leveres planen som lokalt vidensgrundlag.", + knowledgeRecipe: + "1. Eksportér kommunens journalplan (KL-emnesystematik eller lokal variant) som struktureret fil (CSV/Markdown).\n2. Tilføj eksempler på korrekt journaliserede sager som referencer.\n3. Upload som Knowledge-collection \"Journalplan\" i OpenWebUI.\n4. Opdatér ved ændringer i journalplanen.", + versions: [ + { + version: "1.2.0", + releasedAt: "2025-04-18", + notes: "Bedre forslag til emneord og kortere sagstitler.", + json: { + id: "journaliseringshjaelp", + name: "Journaliseringshjælp", + object: "model", + base_model_id: "gemma2:27b", + meta: { + description: "Foreslår sagstitel, journalplan-kode og emneord.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "journalisering" }, { name: "esdh" }] + }, + params: { + system: "Du foreslår journalisering: sagstitel, journalplan-kode og emneord ud fra dokumentets indhold. Brug kun koder fra den vedlagte journalplan. Forslag skal godkendes af en medarbejder.", + temperature: 0.2, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Journalplan", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-005", + name: "Aktindsigt-screening", + tagline: "Markerer mulige undtagelser i dokumenter til aktindsigt", + description: + "Gennemgår dokumenter forud for aktindsigt og markerer passager, der kan være omfattet af undtagelser i offentlighedsloven — fx personoplysninger, interne arbejdsdokumenter eller forretningshemmeligheder. Et menneske foretager den endelige vurdering og overstregning.", + originKommune: "Vejle Kommune", + languageModel: "llama3.1:70b", + framework: "openwebui", + dataSensitivity: "personfoelsomme", + approvedFor: ["almindelige", "fortrolige", "personfoelsomme"], + tags: ["aktindsigt", "offentlighedsloven", "screening", "gdpr"], + aiTags: ["undtagelser"], + createdAt: "2025-02-11", + updatedAt: "2025-05-15", + readme: + "# Aktindsigt-screening\n\nStøtter sagsbehandlere i at forberede aktindsigt.\n\nAssistenten markerer mulige undtagelser, men:\n- foretager ikke selv overstregning\n- erstatter ikke den juridiske vurdering\n- skal altid efterprøves af en medarbejder\n\nFormålet er at spare tid på den indledende gennemgang.", + modelCard: + "Sprogmodel: Llama 3.1 70B (lokal via OWUI).\nKontekstvindue: 16384 tokens.\nTemperatur: 0.1 (meget lav — forsigtige, konservative forslag).\n\nHensyn:\n- Dokumenterne kan indeholde personfølsomme oplysninger; må kun køres i fuldt isoleret, godkendt miljø.\n- Assistenten skal hellere markere for meget end for lidt; den endelige afgørelse er altid menneskelig.\n- Falske negativer er en risiko — brug aldrig outputtet ukritisk.", + knowledgeRecipe: + "1. Saml kommunens interne vejledning om aktindsigt og relevante afgørelser.\n2. Tilføj en oversigt over typiske undtagelsesgrunde med eksempler.\n3. Upload som Knowledge-collection \"Aktindsigt\".\n4. Det konkrete dokument til screening indsættes i chatten — det skal IKKE gemmes i vidensgrundlaget.", + versions: [ + { + version: "1.4.0", + releasedAt: "2025-05-15", + notes: "Mere konservativ markering og henvisning til paragraf.", + json: { + id: "aktindsigt-screening", + name: "Aktindsigt-screening", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Markerer mulige undtagelser i dokumenter til aktindsigt.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "aktindsigt" }, { name: "offentlighedsloven" }] + }, + params: { + system: "Du screener dokumenter til aktindsigt. Markér passager der kan være omfattet af undtagelser i offentlighedsloven, og henvis til den mulige paragraf. Foretag aldrig selv overstregning; en medarbejder træffer den endelige afgørelse.", + temperature: 0.1, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [ + { name: "Aktindsigt", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-006", + name: "Jobcenter-vejleder", + tagline: "Vejleder om beskæftigelsesindsatser og krav til ledige", + description: + "Hjælper jobcentermedarbejdere og vejledere med at finde de rette beskæftigelsestilbud, frister og rådighedskrav. Assistenten forklarer reglerne i et klart sprog og henviser til den relevante lovgivning.", + originKommune: "Randers Kommune", + languageModel: "mistral-large", + framework: "openwebui", + dataSensitivity: "fortrolige", + approvedFor: ["almindelige", "fortrolige"], + tags: ["jobcenter", "beskæftigelse", "vejledning", "lab-loven"], + aiTags: ["rådighed"], + createdAt: "2025-03-12", + updatedAt: "2025-04-25", + readme: + "# Jobcenter-vejleder\n\nGiver overblik over beskæftigelsesindsatser, frister og rådighedskrav.\n\nAssistenten:\n- forklarer regler i klart sprog\n- henviser til relevant lovgivning (fx LAB-loven)\n- foreslår relevante tilbud ud fra en situationsbeskrivelse\n\nDen erstatter ikke en konkret afgørelse.", + modelCard: + "Sprogmodel: Mistral Large (lokal via OWUI).\nKontekstvindue: 16384 tokens.\nTemperatur: 0.3.\n\nHensyn:\n- Borgersager kan indeholde fortrolige oplysninger; anonymisér før indsættelse hvor muligt.\n- Lovgrundlaget ændres ofte — hold vidensgrundlaget opdateret.", + knowledgeRecipe: + "1. Eksportér gældende vejledninger om beskæftigelsesindsatsen (LAB) og rådighedskrav.\n2. Tilføj kommunens egne tilbudskataloger og kontaktoplysninger.\n3. Upload som Knowledge-collection \"Beskæftigelse\".\n4. Opdatér ved lovændringer.", + versions: [ + { + version: "1.0.0", + releasedAt: "2025-04-25", + notes: "Første offentlige version.", + json: { + id: "jobcenter-vejleder", + name: "Jobcenter-vejleder", + object: "model", + base_model_id: "mistral-large", + meta: { + description: "Vejleder om beskæftigelsesindsatser og rådighedskrav.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "jobcenter" }, { name: "beskæftigelse" }] + }, + params: { + system: "Du vejleder jobcentermedarbejdere om beskæftigelsesindsatser, frister og rådighedskrav. Forklar reglerne klart og henvis til lovgrundlaget. Du træffer ikke afgørelser.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [ + { name: "Beskæftigelse", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-007", + name: "Forældrekommunikation i dagtilbud", + tagline: "Hjælper pædagoger med klare beskeder til forældre", + description: + "Hjælper personale i dagtilbud med at formulere venlige og klare beskeder til forældre — om udflugter, sygdom, arrangementer og praktiske forhold. Kan oversætte beskeder til letlæst dansk og flere sprog.", + originKommune: "Esbjerg Kommune", + languageModel: "gemma2:27b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["dagtilbud", "kommunikation", "forældre", "skole"], + aiTags: ["letlæst"], + createdAt: "2025-02-28", + updatedAt: "2025-03-30", + readme: + "# Forældrekommunikation i dagtilbud\n\nStøtter personalet i at skrive klare beskeder til forældre.\n\nAssistenten:\n- formulerer venlige, korte beskeder\n- kan oversætte til letlæst dansk\n- kan oversætte til udvalgte sprog\n\nUndgå at indsætte oplysninger om enkelte børn — hold beskederne generelle.", + modelCard: + "Sprogmodel: Gemma 2 27B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.6 (lidt højere for naturligt sprog).\n\nHensyn:\n- Beskeder bør ikke indeholde personhenførbare oplysninger om enkelte børn.\n- Oversættelser bør kontrolleres ved vigtige beskeder.", + knowledgeRecipe: + "1. (Valgfrit) Saml institutionens standardbeskeder og tone-of-voice som Knowledge-fil.\n2. Upload som collection \"Dagtilbud\" hvis I vil have ensartet sprog.\n3. Ellers fungerer assistenten direkte på den tekst, du indsætter.", + versions: [ + { + version: "1.0.0", + releasedAt: "2025-03-30", + notes: "Første version med oversættelse til letlæst dansk.", + json: { + id: "dagtilbud-foraeldrekommunikation", + name: "Forældrekommunikation i dagtilbud", + object: "model", + base_model_id: "gemma2:27b", + meta: { + description: "Hjælper pædagoger med klare beskeder til forældre.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "dagtilbud" }, { name: "kommunikation" }] + }, + params: { + system: "Du hjælper personale i dagtilbud med at skrive venlige, korte og klare beskeder til forældre. Undgå personhenførbare oplysninger om enkelte børn. Tilbyd at oversætte til letlæst dansk.", + temperature: 0.6, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-008", + name: "Oversæt til letlæst dansk", + tagline: "Gør komplekse myndighedstekster lette at forstå", + description: + "Omskriver komplekse myndighedstekster — afgørelser, breve og vejledninger — til letlæst dansk uden at ændre indholdet. Velegnet til breve, der skal forstås af alle borgere.", + originKommune: "Gentofte Kommune", + languageModel: "gpt-4o", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["letlæst", "oversættelse", "tilgængelighed", "kommunikation"], + aiTags: ["sprogforenkling"], + createdAt: "2025-01-08", + updatedAt: "2025-04-10", + readme: + "# Oversæt til letlæst dansk\n\nOmskriver tekster til letlæst dansk uden at ændre betydningen.\n\nAssistenten:\n- bruger korte sætninger og almindelige ord\n- bevarer det faglige indhold\n- markerer hvis et begreb ikke kan forenkles uden at miste mening\n\nKontrollér altid den omskrevne tekst, særligt ved afgørelser.", + modelCard: + "Sprogmodel: GPT-4o.\nKontekstvindue: 16384 tokens.\nTemperatur: 0.3.\n\nHensyn:\n- Forenkling må ikke ændre den juridiske betydning af en afgørelse.\n- Personoplysninger i indsat tekst behandles fortroligt og bør anonymiseres hvor muligt.", + knowledgeRecipe: + "Kræver ikke fast vidensgrundlag.\n\n1. Indsæt teksten der skal forenkles.\n2. (Valgfrit) Tilføj kommunens sprogpolitik som Knowledge-fil, så stilen bliver ensartet.", + versions: [ + { + version: "1.2.0", + releasedAt: "2025-04-10", + notes: "Bedre bevarelse af juridisk betydning.", + json: { + id: "letlaest-dansk", + name: "Oversæt til letlæst dansk", + object: "model", + base_model_id: "gpt-4o", + meta: { + description: "Omskriver myndighedstekster til letlæst dansk.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "letlæst" }, { name: "tilgængelighed" }] + }, + params: { + system: "Du omskriver myndighedstekster til letlæst dansk med korte sætninger og almindelige ord, uden at ændre betydningen. Markér hvis et begreb ikke kan forenkles uden tab af mening.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 16384 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-009", + name: "Mødebooking-assistent", + tagline: "Finder ledige tider og udkast til mødeindkaldelser", + description: + "Hjælper medarbejdere med at planlægge møder: foreslår mødetidspunkter ud fra beskrevne begrænsninger og skriver et udkast til en mødeindkaldelse med dagsorden. Integrerer ikke med kalenderen — den arbejder på de oplysninger, du giver.", + originKommune: "Odense Kommune", + languageModel: "gemma2:27b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["møder", "planlægning", "produktivitet", "kommunikation"], + aiTags: ["dagsorden"], + createdAt: "2025-03-18", + updatedAt: "2025-03-18", + readme: + "# Mødebooking-assistent\n\nHjælper med at planlægge møder og skrive indkaldelser.\n\nAssistenten:\n- foreslår mødetidspunkter ud fra dine begrænsninger\n- skriver et udkast til indkaldelse med dagsorden\n\nDen har ikke adgang til din kalender — du oplyser selv ledige tider.", + modelCard: + "Sprogmodel: Gemma 2 27B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.5.\n\nHensyn:\n- Ingen kalenderintegration; assistenten arbejder kun på de oplysninger, du indtaster.\n- Undgå at indsætte fortrolige deltageroplysninger.", + knowledgeRecipe: + "Kræver ikke fast vidensgrundlag.\n\n1. Beskriv deltagere, varighed og dine ledige tider.\n2. (Valgfrit) Tilføj en standard dagsorden-skabelon som Knowledge-fil.", + versions: [ + { + version: "0.9.0", + releasedAt: "2025-03-18", + notes: "Beta — deles til feedback fra netværket.", + json: { + id: "moedebooking", + name: "Mødebooking-assistent", + object: "model", + base_model_id: "gemma2:27b", + meta: { + description: "Foreslår mødetider og skriver indkaldelser.", + capabilities: { vision: false, citations: false }, + tags: [{ name: "møder" }, { name: "planlægning" }] + }, + params: { + system: "Du hjælper med at planlægge møder. Foreslå tidspunkter ud fra de oplyste begrænsninger og skriv et udkast til en mødeindkaldelse med dagsorden. Du har ingen kalenderadgang.", + temperature: 0.5, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [] + } + } + ], + uploadedBy: null, + source: "seed" + }, + + { + id: "seed-010", + name: "Tilskuds- og puljevejleder", + tagline: "Hjælper foreninger og borgere med at finde rette pulje", + description: + "Vejleder borgere og foreninger om kommunens tilskuds- og puljemuligheder. Ud fra en beskrivelse af et projekt foreslår assistenten relevante puljer, ansøgningsfrister og krav til ansøgningen.", + originKommune: "Aalborg Kommune", + languageModel: "llama3.1:70b", + framework: "openwebui", + dataSensitivity: "almindelige", + approvedFor: ["almindelige"], + tags: ["tilskud", "puljer", "foreninger", "borgerservice"], + aiTags: ["ansøgningsfrist"], + createdAt: "2025-04-01", + updatedAt: "2025-05-02", + readme: + "# Tilskuds- og puljevejleder\n\nHjælper borgere og foreninger med at finde den rette pulje.\n\nAssistenten:\n- foreslår relevante puljer ud fra projektbeskrivelsen\n- oplyser frister og krav\n- henviser til ansøgningsportalen\n\nDen behandler ikke ansøgninger og kan ikke love bevilling.", + modelCard: + "Sprogmodel: Llama 3.1 70B (lokal via OWUI).\nKontekstvindue: 8192 tokens.\nTemperatur: 0.3.\n\nHensyn:\n- Puljer og frister ændrer sig løbende — hold vidensgrundlaget opdateret.\n- Assistenten må ikke love bevilling eller foregribe en vurdering.", + knowledgeRecipe: + "1. Saml en oversigt over kommunens aktuelle puljer med formål, frister og krav.\n2. Tilføj links til de relevante ansøgningsportaler.\n3. Upload som Knowledge-collection \"Puljer\".\n4. Opdatér mindst hvert kvartal, da frister skifter.", + versions: [ + { + version: "1.0.0", + releasedAt: "2025-05-02", + notes: "Første offentlige version.", + json: { + id: "puljevejleder", + name: "Tilskuds- og puljevejleder", + object: "model", + base_model_id: "llama3.1:70b", + meta: { + description: "Vejleder om kommunens tilskuds- og puljemuligheder.", + capabilities: { vision: false, citations: true }, + tags: [{ name: "tilskud" }, { name: "puljer" }] + }, + params: { + system: "Du vejleder borgere og foreninger om kommunens puljer. Foreslå relevante puljer ud fra projektbeskrivelsen, og oplys frister og krav. Lov aldrig bevilling.", + temperature: 0.3, + top_p: 0.9, + num_ctx: 8192 + }, + knowledge: [ + { name: "Puljer", type: "collection", note: "Leveres lokalt af den enkelte kommune" } + ] + } + } + ], + uploadedBy: null, + source: "seed" + } +]; diff --git a/docs/public/projects/ai-bibliotek/mocks/index.html b/docs/public/projects/ai-bibliotek/mocks/index.html new file mode 100644 index 0000000..846f366 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/index.html @@ -0,0 +1,61 @@ + + +
+ + +elements, preserving single line + breaks inside a paragraph via white-space:pre-wrap. */ +function multiline(text) { + const wrap = el("div", { class: "resume-block" }); + const blocks = String(text || "").split(/\n{2,}/); + blocks.forEach(b => { + if (!b.trim()) return; + wrap.appendChild(el("p", { style: "white-space:pre-wrap;", text: b })); + }); + if (!wrap.childNodes.length) wrap.appendChild(el("p", { class: "muted" }, "Ingen tekst.")); + return wrap; +} + +/* Trigger a real download of the version's JSON via a Blob + object URL. */ +function downloadVersion(assistant, version) { + const data = JSON.stringify(version.json, null, 2); + const blob = new Blob([data], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const slug = (version.json?.id || assistant.id || "assistant").replace(/[^a-z0-9-]+/gi, "-"); + const a = document.createElement("a"); + a.href = url; + a.download = `${slug}-v${version.version}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(`Eksporterede ${slug}-v${version.version}.json`); +} + +function appendMeta(dl, label, value) { + if (!value) return; + dl.appendChild(el("dt", {}, label)); + dl.appendChild(el("dd", { text: value })); +} + +async function openAddToCollection(assistant) { + const { showAddToCollectionModal } = await import("./_collection-modal.js"); + showAddToCollectionModal(assistant); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js b/docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js new file mode 100644 index 0000000..9062f3c --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/favorites.js @@ -0,0 +1,35 @@ +import { el, clear, navigate } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { getAssistant } from "../catalog.js"; +import { renderAssistantCard } from "./_assistant-card.js"; + +export function render(root) { + const user = auth.currentUser(); + if (!user) { navigate("#/login"); return; } + clear(root); + + function paint() { + clear(root); + root.appendChild(el("h1", {}, "Mine favoritter")); + const favs = store.favoritesForUser(user.id); + const assistants = favs.map(f => getAssistant(f.assistantId)).filter(Boolean); + + if (!assistants.length) { + root.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Du har ingen favoritter endnu"), + el("p", { class: "muted" }, "Marker en assistent med hjertet for at gemme den her."), + el("a", { class: "btn", href: "#/search" }, "Find assistenter") + ])); + return; + } + + const list = el("div", { class: "results-list" }); + assistants.forEach(a => list.appendChild(renderAssistantCard(a, { + onFavoriteChange: (isFav) => { if (!isFav) paint(); } + }))); + root.appendChild(list); + } + + paint(); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/home.js b/docs/public/projects/ai-bibliotek/mocks/js/views/home.js new file mode 100644 index 0000000..bba2e48 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/home.js @@ -0,0 +1,153 @@ +import { el, clear, navigate, escapeHtml } from "../util.js"; +import { getAllAssistants } from "../catalog.js"; +import { iconHtml } from "../icons.js"; + +/* Future features teased on the home page but not wired to real routes. */ +const COMING_SOON = [ + "Deling af tools", + "Deling af skills", + "Ratings", + "API", + "Abonnér på ændringer", + "Testcases" +]; + +export function render(root) { + clear(root); + + const all = getAllAssistants(); + const kommuneCount = new Set(all.map(a => a.originKommune)).size; + const modelCount = new Set(all.map(a => a.languageModel)).size; + + const searchBlock = el("section", { class: "home-search", "aria-label": "Søg i kataloget" }, [ + el("p", { class: "eyebrow" }, "Find en assistent"), + el("form", { + class: "home-search-form", + role: "search", + onsubmit: (e) => { + e.preventDefault(); + const q = new FormData(e.target).get("q") || ""; + navigate(`#/search?q=${encodeURIComponent(q.trim())}`); + } + }, [ + el("input", { + type: "search", + name: "q", + placeholder: "Søg efter borgerservice, referat, journalisering…", + "aria-label": "Søg" + }), + el("button", { class: "btn", type: "submit" }, "Søg") + ]) + ]); + + const hero = el("section", { class: "hero" }, [ + el("p", { class: "eyebrow" }, "Del & hjemtag · dansk offentlig AI"), + el("h1", { class: "hero-title" }, [ + "Et fælles bibliotek over ", + el("em", {}, "kommunale"), + " AI-assistenter." + ]), + el("p", { class: "lede" }, + "Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt."), + + el("dl", { class: "hero-stats" }, [ + el("div", {}, [ + el("dt", {}, "Assistenter"), + el("dd", {}, String(all.length)) + ]), + el("div", {}, [ + el("dt", {}, "Kommuner"), + el("dd", {}, String(kommuneCount)) + ]), + el("div", {}, [ + el("dt", {}, "Sprogmodeller"), + el("dd", {}, String(modelCount)) + ]) + ]) + ]); + + const recent = [...all] + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)) + .slice(0, 8); + + const railEl = el("div", { class: "recent-rail" }, + recent.map(a => renderRailCard(a)) + ); + + function scrollRail(direction) { + const firstCard = railEl.querySelector(".rail-card"); + const step = firstCard ? firstCard.getBoundingClientRect().width + 16 : 320; + railEl.scrollBy({ left: direction * step, behavior: "smooth" }); + } + + const prevBtn = el("button", { + class: "rail-nav rail-nav-prev", + type: "button", + "aria-label": "Forrige", + onclick: () => scrollRail(-1), + html: iconHtml("arrowLeft", { size: 18 }) + }); + const nextBtn = el("button", { + class: "rail-nav rail-nav-next", + type: "button", + "aria-label": "Næste", + onclick: () => scrollRail(1), + html: iconHtml("arrowRight", { size: 18 }) + }); + + const rail = el("section", { class: "recent-section" }, [ + el("div", { class: "section-head" }, [ + el("p", { class: "eyebrow" }, "Senest opdateret"), + el("a", { class: "section-link", href: "#/search" }, "Se hele kataloget →") + ]), + el("div", { class: "recent-rail-wrap" }, [ + railEl, + el("div", { class: "rail-nav-bar" }, [prevBtn, nextBtn]) + ]) + ]); + + const aboutSection = el("section", { class: "card mt-5 how-it-works" }, [ + el("p", { class: "eyebrow" }, "Sådan virker det"), + el("ol", { class: "steps-list" }, [ + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Opret bruger"), " og log ind som repræsentant for din myndighed."])), + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Find en assistent"), " – søg og filtrér på kommune, sprogmodel og datafølsomhed."])), + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Hjemtag"), " – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere."])), + el("li", {}, el("span", { class: "step-body" }, [el("strong", {}, "Tilpas lokalt"), " – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug."])) + ]) + ]); + + const comingSoon = el("section", { class: "card mt-5" }, [ + el("p", { class: "eyebrow" }, "Kommer snart"), + el("p", { class: "muted", style: "margin:4px 0 0;" }, + "Funktioner vi arbejder på til kommende versioner. De er ikke aktive endnu."), + el("div", { class: "coming-soon-grid", "aria-label": "Kommende funktioner" }, + COMING_SOON.map(label => el("span", { + class: "coming-soon-chip", + "aria-disabled": "true", + title: "Kommer snart" + }, [label, el("span", { class: "cs-tag" }, "snart")])) + ) + ]); + + root.appendChild(hero); + root.appendChild(searchBlock); + root.appendChild(rail); + root.appendChild(aboutSection); + root.appendChild(comingSoon); +} + +function renderRailCard(a) { + return el("a", { + class: "rail-card", + href: `#/assistant/${a.id}` + }, [ + el("span", { class: "rail-meta" }, `${a.originKommune} · ${a.languageModel}`), + el("span", { class: "rail-title", html: escapeHtml(a.name) }), + el("span", { class: "rail-summary", text: trim(a.description, 110) }) + ]); +} + +function trim(s, n) { + if (!s) return ""; + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/login.js b/docs/public/projects/ai-bibliotek/mocks/js/views/login.js new file mode 100644 index 0000000..93a3c69 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/login.js @@ -0,0 +1,122 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth, DEMO_USERS } from "../auth.js"; + +export function render(root, query = {}) { + clear(root); + + let mode = query.mode === "register" ? "register" : "login"; + + const card = el("section", { class: "card auth-card" }); + + function paint() { + clear(card); + const tabs = el("div", { class: "auth-tabs" }, [ + el("button", { + class: mode === "login" ? "is-active" : "", + onclick: () => { mode = "login"; paint(); } + }, "Log ind"), + el("button", { + class: mode === "register" ? "is-active" : "", + onclick: () => { mode = "register"; paint(); } + }, "Opret bruger") + ]); + card.appendChild(tabs); + + if (mode === "login") { + const form = loginForm(); + card.appendChild(form); + card.appendChild(demoUsersBox(form)); + } else { + card.appendChild(registerForm()); + } + } + + paint(); + root.appendChild(card); +} + +function loginForm() { + return el("form", { + class: "form-grid", + onsubmit: (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target)); + try { + const user = auth.login(data); + toast(`Velkommen tilbage, ${user.name}`); + navigate("#/"); + } catch (err) { + toast(err.message); + } + } + }, [ + el("label", { class: "field" }, [ + "E-mail", + el("input", { type: "email", name: "email", required: true, autocomplete: "email" }) + ]), + el("label", { class: "field" }, [ + "Adgangskode", + el("input", { type: "password", name: "password", required: true, autocomplete: "current-password" }) + ]), + el("button", { class: "btn", type: "submit" }, "Log ind") + ]); +} + +/* Prototype-only: ready-made logins. Clicking one fills the form fields. */ +function demoUsersBox(form) { + const fill = (email, password) => { + form.querySelector('input[name="email"]').value = email; + form.querySelector('input[name="password"]').value = password; + }; + + return el("div", { class: "demo-users" }, [ + el("p", { class: "demo-users-head" }, "Prototype – prøv med en testbruger"), + ...DEMO_USERS.map(d => + el("button", { + type: "button", + class: "demo-user", + onclick: () => fill(d.email, d.password) + }, [ + el("span", { class: "demo-user-name" }, `${d.name} · ${d.organization}`), + el("span", { class: "demo-user-cred" }, `${d.email} / ${d.password}`) + ]) + ), + el("p", { class: "hint demo-users-foot" }, "Klik en bruger for at udfylde felterne.") + ]); +} + +function registerForm() { + return el("form", { + class: "form-grid", + onsubmit: (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target)); + try { + const user = auth.register(data); + toast(`Velkommen, ${user.name}`); + navigate("#/"); + } catch (err) { + toast(err.message); + } + } + }, [ + el("label", { class: "field" }, [ + "Navn", + el("input", { type: "text", name: "name", required: true, autocomplete: "name" }) + ]), + el("label", { class: "field" }, [ + "Myndighed eller organisation", + el("input", { type: "text", name: "organization", placeholder: "f.eks. Aarhus Kommune", autocomplete: "organization" }) + ]), + el("label", { class: "field" }, [ + "E-mail", + el("input", { type: "email", name: "email", required: true, autocomplete: "email" }) + ]), + el("label", { class: "field" }, [ + "Adgangskode", + el("input", { type: "password", name: "password", required: true, minlength: 4, autocomplete: "new-password" }), + el("span", { class: "hint" }, "Mindst 4 tegn. Prototype – brug ikke en rigtig adgangskode.") + ]), + el("button", { class: "btn", type: "submit" }, "Opret bruger") + ]); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/search.js b/docs/public/projects/ai-bibliotek/mocks/js/views/search.js new file mode 100644 index 0000000..2a6c7ad --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/search.js @@ -0,0 +1,246 @@ +import { el, clear } from "../util.js"; +import { searchAssistants, buildFacets, getAllAssistants, FRAMEWORK_LABEL } from "../catalog.js"; +import { renderAssistantCard } from "./_assistant-card.js"; + +const SENSITIVITY_LABEL = { + almindelige: "Almindelige personoplysninger", + fortrolige: "Fortrolige data", + personfoelsomme: "Personfølsomme data" +}; + +const RECENT_KEY = "ab:recent-searches"; +const FACET_OPEN_KEY = "ab:facet-open"; +const FACET_DEFAULT_OPEN = new Set(["Kommune", "Sprogmodel"]); + +function loadFacetOpen() { + try { return new Set(JSON.parse(localStorage.getItem(FACET_OPEN_KEY) || "null") || FACET_DEFAULT_OPEN); } + catch { return new Set(FACET_DEFAULT_OPEN); } +} +function persistFacetOpen(set) { + localStorage.setItem(FACET_OPEN_KEY, JSON.stringify([...set])); +} + +function loadRecentSearches() { + try { return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); } catch { return []; } +} +function pushRecentSearch(q) { + if (!q) return; + const list = loadRecentSearches().filter(x => x !== q); + list.unshift(q); + localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, 6))); +} + +export function render(root, query = {}) { + clear(root); + + const state = { + q: query.q || "", + kommuner: new Set(), + languageModels: new Set(), + frameworks: new Set(), + sensitivities: new Set() + }; + + if (state.q) pushRecentSearch(state.q); + + const openFacets = loadFacetOpen(); + const layout = el("div", { class: "search-layout" }); + const aside = el("aside", { class: "facet-aside", "aria-label": "Filtre" }); + const main = el("div", { style: "min-width: 0;" }); + const context = el("div", { class: "context-rail" }); + layout.appendChild(aside); + layout.appendChild(main); + layout.appendChild(context); + root.appendChild(layout); + + function update() { + const results = searchAssistants(state); + const facets = buildFacets(getAllAssistants()); + + renderAside(); + renderMain(results); + renderContext(results); + syncUrl(); + + function renderAside() { + clear(aside); + aside.appendChild(el("h3", { style: "margin-top:0;" }, "Filtre")); + + aside.appendChild(facetGroup("Kommune", facets.kommuner, state.kommuner)); + aside.appendChild(facetGroup("Sprogmodel", facets.languageModels, state.languageModels)); + aside.appendChild(facetGroup("Rammeværk", facets.frameworks, state.frameworks, (k) => FRAMEWORK_LABEL[k] || k)); + aside.appendChild(facetGroup("Datafølsomhed", facets.sensitivities, state.sensitivities, (k) => SENSITIVITY_LABEL[k] || k)); + + if (anyFilter()) { + aside.appendChild(el("button", { + class: "btn btn-secondary btn-sm mt-3", + onclick: () => { + ["kommuner","languageModels","frameworks","sensitivities"].forEach(k => state[k].clear()); + update(); + } + }, "Nulstil filtre")); + } + } + + function renderMain(results) { + clear(main); + const formChildren = [ + el("input", { type: "search", name: "q", value: state.q, placeholder: "Søg…", "aria-label": "Søg" }) + ]; + if (state.q) { + formChildren.push(el("button", { + class: "btn btn-secondary", + type: "button", + onclick: () => { state.q = ""; update(); } + }, "Nulstil")); + } + formChildren.push(el("button", { class: "btn", type: "submit" }, "Søg")); + + const header = el("div", { class: "results-header" }, [ + el("form", { + role: "search", + style: "flex:1; display:flex; gap:8px; max-width:560px;", + onsubmit: (e) => { + e.preventDefault(); + state.q = (new FormData(e.target).get("q") || "").trim(); + if (state.q) pushRecentSearch(state.q); + update(); + } + }, formChildren), + el("span", { class: "count" }, `${results.length} resultat${results.length === 1 ? "" : "er"}`) + ]); + main.appendChild(header); + + if (!results.length) { + main.appendChild(el("div", { class: "empty card" }, [ + el("h2", {}, "Ingen assistenter matcher"), + el("p", { class: "muted" }, "Prøv at slække på filtrene eller skriv en bredere søgning.") + ])); + return; + } + + const list = el("div", { class: "results-list" }); + results.forEach(a => list.appendChild(renderAssistantCard(a))); + main.appendChild(list); + } + + function renderContext(results) { + clear(context); + + // Active filters + const filtersCard = el("aside", { class: "context-aside" }); + filtersCard.appendChild(el("h3", {}, "Aktive filtre")); + const chips = collectActiveChips(); + if (chips.length) { + const wrap = el("div", { class: "active-filters" }, chips); + filtersCard.appendChild(wrap); + } else { + filtersCard.appendChild(el("p", { class: "context-empty" }, "Ingen filtre aktive.")); + } + context.appendChild(filtersCard); + + // Recent searches + const recent = loadRecentSearches(); + if (recent.length) { + const recentCard = el("aside", { class: "context-aside" }); + recentCard.appendChild(el("h3", {}, "Seneste søgninger")); + const ul = el("ul", { class: "recent-searches" }); + recent.forEach(q => { + ul.appendChild(el("li", {}, el("a", { + href: "#", + onclick: (e) => { e.preventDefault(); state.q = q; update(); } + }, q))); + }); + recentCard.appendChild(ul); + context.appendChild(recentCard); + } + + // "Klar til hjemtagning" callout — all catalog assistants can be exported. + if (results.length) { + const callout = el("aside", { class: "context-aside" }); + callout.appendChild(el("h3", {}, "Klar til hjemtagning")); + callout.appendChild(el("p", { class: "context-empty" }, `Alle ${results.length} resultat${results.length === 1 ? "" : "er"} kan eksporteres som OpenWebUI-JSON og køres lokalt.`)); + context.appendChild(callout); + } + } + + function collectActiveChips() { + const chips = []; + if (state.q) { + chips.push(makeChip(`"${state.q}"`, () => { state.q = ""; update(); })); + } + state.kommuner.forEach(v => chips.push(makeChip(v, () => { state.kommuner.delete(v); update(); }))); + state.languageModels.forEach(v => chips.push(makeChip(v, () => { state.languageModels.delete(v); update(); }))); + state.frameworks.forEach(v => chips.push(makeChip(FRAMEWORK_LABEL[v] || v, () => { state.frameworks.delete(v); update(); }))); + state.sensitivities.forEach(v => chips.push(makeChip(SENSITIVITY_LABEL[v] || v, () => { state.sensitivities.delete(v); update(); }))); + return chips; + } + } + + function makeChip(label, onRemove) { + return el("button", { + class: "filter-chip", + type: "button", + onclick: onRemove, + "aria-label": `Fjern filter: ${label}` + }, [label, el("span", { class: "filter-chip-x", "aria-hidden": "true" }, "×")]); + } + + function facetGroup(label, counts, selectedSet, labelFn) { + let entries = [...counts.entries()]; + entries.sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), "da")); + + const ul = el("ul"); + for (const [key, count] of entries) { + const value = key; + const checked = selectedSet.has(value); + ul.appendChild(el("li", {}, el("label", {}, [ + el("input", { + type: "checkbox", + checked, + onchange: (e) => { + if (e.target.checked) selectedSet.add(value); + else selectedSet.delete(value); + update(); + } + }), + el("span", {}, labelFn ? labelFn(key) : key), + el("span", { class: "count" }, String(count)) + ]))); + } + + // Force-open if the group has an active filter, otherwise respect user preference. + const isOpen = selectedSet.size > 0 || openFacets.has(label); + const summary = el("summary", {}, [ + el("h4", {}, label), + el("span", { class: "facet-chevron", "aria-hidden": "true" }) + ]); + const details = el("details", { + class: "facet-group", + open: isOpen, + ontoggle: (e) => { + if (e.target.open) openFacets.add(label); + else openFacets.delete(label); + persistFacetOpen(openFacets); + } + }, [summary, ul]); + + return details; + } + + function anyFilter() { + return state.kommuner.size || state.languageModels.size + || state.frameworks.size || state.sensitivities.size; + } + + function syncUrl() { + const params = []; + if (state.q) params.push(`q=${encodeURIComponent(state.q)}`); + const hash = "#/search" + (params.length ? `?${params.join("&")}` : ""); + if (window.location.hash !== hash) { + history.replaceState(null, "", hash); + } + } + + update(); +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/upload.js b/docs/public/projects/ai-bibliotek/mocks/js/views/upload.js new file mode 100644 index 0000000..d73ea22 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/upload.js @@ -0,0 +1,324 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth } from "../auth.js"; +import { store, uid } from "../store.js"; +import { DATA_SENSITIVITY } from "../catalog.js"; +import { iconHtml, icon as iconNode } from "../icons.js"; + +export function render(root) { + const user = auth.currentUser(); + if (!user) { + navigate("#/login"); + return; + } + clear(root); + + // state.parsed holds the parsed OWUI JSON; state.draft holds suggested metadata. + const state = { step: 1, rawJson: null, parsed: null, draft: null, published: null }; + + root.appendChild(el("header", { class: "page-head" }, [ + el("h1", { style: "margin:0 0 8px;" }, "Del assistent"), + el("p", { class: "lede", style: "margin:0;" }, + "Del en AI-assistent fra din myndighed med de andre kommuner. Indsæt assistentens OpenWebUI-JSON — AI foreslår metadata, og du bekræfter datagrundlaget.") + ])); + + root.appendChild(el("aside", { + class: "alert alert-rights", + role: "note", + "aria-label": "Ansvar" + }, [ + el("span", { class: "alert-mark", "aria-hidden": "true" }), + el("p", {}, + "Du har som deler ansvaret for, at assistenten kan deles, og at det beskrevne datagrundlag ikke indeholder data, der ikke må deles. Selve videns- og datafiler deles ikke — kun konfigurationen og en opskrift på datagrundlaget.") + ])); + + const layout = el("div", { class: "upload-layout" }); + const stepNav = el("nav", { class: "step-rail", "aria-label": "Trin i deling" }); + const container = el("div", { class: "upload-step" }); + layout.appendChild(stepNav); + layout.appendChild(container); + root.appendChild(layout); + + paint(); + + function paint() { + renderStepRail(); + clear(container); + if (state.step === 1) container.appendChild(renderPick()); + else if (state.step === 2) container.appendChild(renderReview()); + else container.appendChild(renderReceipt()); + } + + function renderStepRail() { + clear(stepNav); + const stepDefs = [ + { n: 1, title: "Indsæt JSON", hint: "Vælg fil eller indsæt OWUI-JSON" }, + { n: 2, title: "Gennemgang", hint: "Tjek AI-forslag og datafølsomhed" }, + { n: 3, title: "Kvittering", hint: "Permalink til din assistent" } + ]; + stepDefs.forEach(({ n, title, hint }) => { + const status = state.step === n ? "active" : state.step > n ? "done" : "todo"; + stepNav.appendChild(el("div", { class: `step-item step-${status}` }, [ + el("span", { class: "step-num" }, status === "done" ? iconNode("check") : String(n).padStart(2, "0")), + el("div", { class: "step-text" }, [ + el("span", { class: "step-title" }, title), + el("span", { class: "step-hint" }, hint) + ]) + ])); + }); + } + + function renderPick() { + const card = el("section", { class: "card" }); + + const input = el("input", { + type: "file", + accept: "application/json,.json", + style: "display:none;", + onchange: (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => acceptJson(String(reader.result)); + reader.readAsText(file); + } + }); + + const drop = el("div", { + class: "dropzone", + tabindex: "0", + role: "button", + "aria-label": "Vælg JSON-fil eller træk hertil", + onclick: () => input.click(), + onkeydown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); } }, + ondragover: (e) => { e.preventDefault(); drop.classList.add("is-dragover"); }, + ondragleave: () => drop.classList.remove("is-dragover"), + ondrop: (e) => { + e.preventDefault(); + drop.classList.remove("is-dragover"); + const f = e.dataTransfer?.files?.[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = () => acceptJson(String(reader.result)); + reader.readAsText(f); + } + }, [ + el("p", { html: iconHtml("document", { size: 36, stroke: 1.5 }) }), + el("p", { style: "font-weight:600;" }, "Træk OpenWebUI-JSON hertil"), + el("p", { class: "muted" }, "eller klik for at vælge en .json-fil"), + input + ]); + card.appendChild(drop); + + card.appendChild(el("hr", { class: "divider" })); + + const form = el("form", { + onsubmit: (e) => { + e.preventDefault(); + const raw = new FormData(e.target).get("json") || ""; + acceptJson(String(raw)); + } + }, [ + el("label", { class: "field" }, [ + "…eller indsæt JSON direkte", + el("textarea", { + name: "json", + rows: "8", + placeholder: '{\n "id": "min-assistent",\n "name": "Min assistent",\n "base_model_id": "llama3.1:70b",\n ...\n}' + }) + ]), + el("div", { class: "right" }, [ + el("button", { type: "submit", class: "btn" }, "Analysér JSON") + ]) + ]); + card.appendChild(form); + + return card; + } + + function acceptJson(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + toast("Kunne ikke læse JSON. Tjek at formatet er gyldigt."); + return; + } + state.rawJson = raw; + state.parsed = parsed; + state.step = 2; + state.draft = null; + paint(); + // Simulate brief AI processing of the parsed config. + setTimeout(() => { + state.draft = suggestMetadata(parsed); + paint(); + }, 1400); + } + + function renderReview() { + const card = el("section", { class: "card" }); + if (!state.draft) { + card.appendChild(el("div", { style: "text-align:center; padding: 32px 0;" }, [ + el("div", { class: "spinner" }), + el("p", { style: "font-weight:600;" }, "AI analyserer assistenten…"), + el("p", { class: "muted" }, "Læser konfigurationen og foreslår metadata.") + ])); + return card; + } + + const d = state.draft; + card.appendChild(el("h2", {}, "Gennemgå AI-forslag")); + card.appendChild(el("p", { class: "muted" }, "AI har foreslået metadata ud fra den indsatte konfiguration. Ret efter behov, og vælg datafølsomhed før deling.")); + + const form = el("form", { + onsubmit: (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const assistant = buildAssistant(d, fd, state.parsed); + store.addUpload(assistant); + state.published = assistant; + state.step = 3; + paint(); + toast("Assistent delt"); + } + }); + + form.appendChild(el("div", { class: "form-grid" }, [ + el("label", { class: "field" }, ["Navn", el("input", { type: "text", name: "name", value: d.name, required: true })]), + el("label", { class: "field" }, ["Tagline (kort beskrivelse)", el("input", { type: "text", name: "tagline", value: d.tagline || "" })]), + el("label", { class: "field" }, ["Beskrivelse", + el("textarea", { name: "description", required: true }, d.description) + ]), + el("div", { class: "form-grid cols-2" }, [ + el("label", { class: "field" }, ["Oprindelseskommune", el("input", { type: "text", name: "originKommune", value: d.originKommune, required: true })]), + el("label", { class: "field" }, ["Sprogmodel", el("input", { type: "text", name: "languageModel", value: d.languageModel, required: true })]) + ]), + el("label", { class: "field" }, [ + "Tags (komma-separeret)", + el("input", { type: "text", name: "tags", value: (d.tags || []).join(", ") }) + ]), + el("label", { class: "field" }, ["Vidensopskrift (hvad kommunen selv skal levere)", + el("textarea", { name: "knowledgeRecipe", rows: "4" }, d.knowledgeRecipe || "") + ]) + ])); + + form.appendChild(el("hr", { class: "divider" })); + form.appendChild(el("h3", {}, "Datafølsomhed")); + form.appendChild(el("p", { class: "muted" }, "Vælg hvilke data assistenten er beregnet til. Det styrer, hvordan assistenten må anvendes.")); + + const rl = el("div", { class: "rights-list" }); + DATA_SENSITIVITY.forEach(s => { + rl.appendChild(el("label", {}, [ + el("input", { type: "radio", name: "dataSensitivity", value: s.level, required: true, checked: s.level === d.dataSensitivity }), + el("div", {}, [ + el("div", { class: "rl-title" }, s.title), + el("div", { class: "rl-desc" }, s.description) + ]) + ])); + }); + form.appendChild(rl); + + form.appendChild(el("div", { class: "right mt-5" }, [ + el("button", { + type: "button", + class: "btn btn-secondary", + onclick: () => { state.step = 1; state.parsed = null; state.draft = null; paint(); }, + html: `${iconHtml("arrowLeft")}Indsæt anden JSON` + }), + el("button", { type: "submit", class: "btn" }, "Del assistent") + ])); + card.appendChild(form); + return card; + } + + function renderReceipt() { + const a = state.published; + const card = el("section", { class: "card" }); + card.appendChild(el("h2", { + html: `${iconHtml("check", { size: 22 })} Assistent delt`, + style: "display:flex; align-items:center; gap:8px; color: var(--color-ok);" + })); + card.appendChild(el("p", {}, `"${a.name}" er nu tilgængelig i biblioteket og kan hjemtages af andre kommuner.`)); + card.appendChild(el("div", { class: "muted text-sm mb-4" }, `Permalink: ${window.location.origin}${window.location.pathname}#/assistant/${a.id}`)); + card.appendChild(el("div", { class: "row wrap" }, [ + el("a", { class: "btn", href: `#/assistant/${a.id}` }, "Gå til assistent"), + el("button", { + class: "btn btn-secondary", + onclick: () => { + state.step = 1; state.parsed = null; state.rawJson = null; state.draft = null; state.published = null; paint(); + } + }, "Del en til"), + el("a", { class: "btn btn-ghost", href: "#/search" }, "Tilbage til kataloget") + ])); + return card; + } +} + +function buildAssistant(draft, fd, parsed) { + const now = new Date().toISOString().slice(0, 10); + return { + id: uid("asst"), + name: (fd.get("name") || "").trim(), + tagline: (fd.get("tagline") || "").trim(), + description: (fd.get("description") || "").trim(), + originKommune: (fd.get("originKommune") || "").trim(), + languageModel: (fd.get("languageModel") || "").trim(), + framework: "openwebui", + dataSensitivity: fd.get("dataSensitivity") || "almindelige", + approvedFor: [fd.get("dataSensitivity") || "almindelige"], + tags: splitList(fd.get("tags")), + aiTags: draft.aiTags || [], + readme: draft.readme || "", + modelCard: draft.modelCard || "", + knowledgeRecipe: (fd.get("knowledgeRecipe") || "").trim(), + versions: [ + { + version: "1.0.0", + releasedAt: new Date().toISOString(), + notes: "Første version delt via AI Bibliotek.", + json: parsed + } + ], + createdAt: now, + updatedAt: now, + uploadedBy: auth.currentUser()?.id ?? null, + source: "user" + }; +} + +function splitList(s) { + if (!s) return []; + return String(s).split(",").map(x => x.trim()).filter(Boolean); +} + +/* Suggest metadata from a parsed OpenWebUI-shaped JSON object. + This stands in for an AI extraction step. */ +function suggestMetadata(parsed) { + const user = auth.currentUser(); + const originKommune = user?.organization || "Min Kommune"; + + const name = parsed?.name || "Ny assistent"; + const description = parsed?.meta?.description + || parsed?.params?.system + || "Beskrivelse mangler — tilføj en kort forklaring af hvad assistenten gør."; + const languageModel = parsed?.base_model_id || parsed?.model || "ukendt model"; + const tags = (parsed?.meta?.tags || []) + .map(t => (typeof t === "string" ? t : t?.name)) + .filter(Boolean); + + return { + name, + tagline: "", + description, + originKommune, + languageModel, + tags: tags.length ? tags : ["assistent"], + aiTags: tags.slice(0, 2), + dataSensitivity: "almindelige", + readme: `# ${name}\n\n${description}`, + modelCard: `Sprogmodel: ${languageModel}.\nKontekstvindue: ${parsed?.params?.num_ctx || "ukendt"}.\nTemperatur: ${parsed?.params?.temperature ?? "ukendt"}.\n\nHensyn: Gennemgå system-prompten og tilpas den til din kommune før brug.`, + knowledgeRecipe: (parsed?.knowledge?.length) + ? parsed.knowledge.map((k, i) => `${i + 1}. Levér lokalt: ${k.name}${k.note ? ` (${k.note})` : ""}.`).join("\n") + : "Beskriv her hvilke videns- og datafiler den enkelte kommune selv skal levere." + }; +} diff --git a/docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js b/docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js new file mode 100644 index 0000000..b197820 --- /dev/null +++ b/docs/public/projects/ai-bibliotek/mocks/js/views/uploads.js @@ -0,0 +1,44 @@ +import { el, clear, navigate, toast } from "../util.js"; +import { auth } from "../auth.js"; +import { store } from "../store.js"; +import { renderAssistantCard } from "./_assistant-card.js"; + +export function render(root) { + const user = auth.currentUser(); + if (!user) { navigate("#/login"); return; } + + function paint() { + clear(root); + root.appendChild(el("h1", {}, "Mine assistenter")); + root.appendChild(el("p", { class: "muted" }, + "Assistenter du har delt til biblioteket. Du kan trække en assistent tilbage — den fjernes også fra andres favoritter og samlinger.")); + + const mine = store.uploadsByUser(user.id) + .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + if (!mine.length) { + root.appendChild(el("div", { class: "empty card mt-4" }, [ + el("h2", {}, "Du har ikke delt nogen assistenter endnu"), + el("p", { class: "muted" }, "Del en assistent fra din myndighed for at gøre den tilgængelig for andre kommuner."), + el("a", { class: "btn", href: "#/upload" }, "Del assistent") + ])); + return; + } + + const list = el("div", { class: "results-list mt-4" }); + mine.forEach(a => list.appendChild(renderAssistantCard(a, { + extraAction: el("button", { + class: "btn btn-danger btn-sm", + onclick: () => { + if (!confirm(`Slet assistenten "${a.name}"?\n\nDen fjernes fra biblioteket og alle samlinger.`)) return; + store.deleteUpload(a.id); + toast("Assistent slettet"); + paint(); + } + }, "Slet") + }))); + root.appendChild(list); + } + + paint(); +}