Pablan, as it stands

Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.svelte-kit
build
test-results
playwright-report
.vscode
Dockerfile
+28
View File
@@ -0,0 +1,28 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
test-results
# Paraglide compiles messages into here on every build.
src/lib/paraglide
+4
View File
@@ -0,0 +1,4 @@
engine-strict=true
# openapi-typescript declares peer typescript ^5.x but works with our TS 6
# (verified by `make types`). Remove once its peer range includes ^6.
legacy-peer-deps=true
+17
View File
@@ -0,0 +1,17 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
# Generated (make types)
src/lib/api/schema.d.ts
# Generated by the inlang tooling, not ours to format.
project.inlang/.meta.json
project.inlang/README.md
src/lib/paraglide
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"svelte.svelte-vscode",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
# adapter-node bundles all runtime dependencies into build/;
# package.json is only needed for "type": "module".
COPY --from=build /app/build ./build
COPY package.json ./
ENV PORT=3000
EXPOSE 3000
CMD ["node", "build"]
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.3 create --template minimal --types ts --add prettier eslint playwright tailwindcss="plugins:none" --install npm frontend
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+129
View File
@@ -0,0 +1,129 @@
import { expect, test } from '@playwright/test';
import { login, DEV_PASSWORD } from './helpers';
// Uses max@pablan.dev so a failure here cannot lock the other specs out of
// pablo@pablan.dev. The password is changed back at the end (zero residue).
const NEW_PASSWORD = 'ben-neues-geheimnis';
test('a user changes their own password and stays signed in', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill(DEV_PASSWORD);
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Repeat new password').fill(NEW_PASSWORD);
await page.getByTestId('submit-password').click();
await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 });
// This session survived the change — no redirect to the login page.
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
await expect(page).toHaveURL('/');
// The new password is the one that works now.
await page.request.post('/api/auth/logout');
const stale = await page.request.post('/api/auth/login', {
data: { email: 'max@pablan.dev', password: DEV_PASSWORD }
});
expect(stale.status()).toBe(401);
// Change it back, so the suite can run again.
await login(page, 'max@pablan.dev', NEW_PASSWORD);
await page.getByTestId('user-menu').click();
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill(NEW_PASSWORD);
await page.getByLabel('New password', { exact: true }).fill(DEV_PASSWORD);
await page.getByLabel('Repeat new password').fill(DEV_PASSWORD);
await page.getByTestId('submit-password').click();
await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 });
await page.request.post('/api/auth/logout');
});
test('the wrong current password is rejected', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill('definitely-wrong');
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Repeat new password').fill(NEW_PASSWORD);
await page.getByTestId('submit-password').click();
await expect(page.getByRole('alert')).toContainText('current password');
// The old password still works.
await page.request.post('/api/auth/logout');
const still = await page.request.post('/api/auth/login', {
data: { email: 'max@pablan.dev', password: DEV_PASSWORD }
});
expect(still.status()).toBe(200);
await page.request.post('/api/auth/logout');
});
test('the theme survives a reload without flashing the other one', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
await page.getByTestId('settings-dialog').waitFor();
await page.getByTestId('theme-switch').getByRole('button', { name: 'Light' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// The boot script applies it before hydration, so it is already correct
// on the very first frame after a reload.
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.locator('body[data-hydrated]').waitFor();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// Back to following the OS, so the next spec starts clean.
await page.getByTestId('user-menu').click();
await page.getByTestId('theme-switch').getByRole('button', { name: 'System' }).click();
await expect(page.locator('html')).not.toHaveAttribute('data-theme', /.*/);
await page.request.post('/api/auth/logout');
});
test('the language switch flips the interface in place, with no reload', async ({ page }) => {
await login(page, 'max@pablan.dev');
await page.getByTestId('user-menu').click();
const dialog = page.getByTestId('settings-dialog');
const locales = page.getByTestId('locale-switch');
// Start from English, whatever a previous run left behind.
await locales.getByRole('button', { name: 'English' }).click();
await expect(dialog).toContainText('Settings');
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
// A marker on window survives a re-render but not a reload, and a typed
// value survives neither if the DOM is rebuilt: together they are the
// assertion that "no reload artifacts" actually holds.
await page.evaluate(() => ((window as unknown as Record<string, string>).__i18nMarker = 'alive'));
await page.getByTestId('change-password').click();
await page.getByLabel('Current password').fill('typed-before-switch');
await locales.getByRole('button', { name: 'Deutsch' }).click();
await expect(dialog).toContainText('Einstellungen');
await expect(dialog).toContainText('Passwort ändern');
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
expect(
await page.evaluate(() => (window as unknown as Record<string, string>).__i18nMarker)
).toBe('alive');
await expect(page.getByLabel('Aktuelles Passwort')).toHaveValue('typed-before-switch');
// The choice is on the account, not just in the tab.
await expect
.poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale)
.toBe('de');
// Zero residue: back to following the browser.
await locales.getByRole('button', { name: 'Automatisch' }).click();
await expect
.poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale)
.toBe(null);
await page.request.post('/api/auth/logout');
});
+217
View File
@@ -0,0 +1,217 @@
import { expect, test } from '@playwright/test';
import { login, logout, openAdmin, openAdminTab, openDocuments } from './helpers';
// Admin creates a department and a user; the new user logs in and only
// sees what their (new, grantless) department allows. Plus the LLM panel
// against the real endpoints.
test.setTimeout(120_000);
const RUN = Date.now();
const DEPARTMENT = `QS-${RUN}`;
const EMAIL = `quinn-${RUN}@pablan.dev`;
test('admin creates department + user; the new user is correctly scoped', async ({ page }) => {
await login(page, 'florian@pablan.dev', 'pablan-dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
// Department first, so the user form can pick it. Creating is a dialog
// for both, so each starts with its trigger.
await openAdminTab(page, 'people');
await page.getByTestId('new-department').click();
await page.locator('#new-department-name').fill(DEPARTMENT);
await page.getByTestId('create-department').click();
await expect(page.getByTestId('department-list')).toContainText(DEPARTMENT);
await page.getByTestId('new-user').click();
await page.locator('#new-email').fill(EMAIL);
await page.locator('#new-name').fill('Quinn Neu');
await page.locator('#new-department').selectOption({ label: DEPARTMENT });
await page.locator('#new-password').fill('quinn-secret-1');
await page.getByTestId('create-user').click();
await expect(page.getByTestId('user-table')).toContainText(EMAIL);
// LLM endpoint panel against the real endpoints — its own tab now.
await openAdminTab(page, 'llm');
await page.getByTestId('llm-test').click();
await expect(page.getByTestId('llm-results')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('llm-results').getByText('ok')).toHaveCount(3);
await logout(page);
// The new user sees public documents, but no other department's ones.
await login(page, EMAIL, 'quinn-secret-1');
await openDocuments(page);
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('document-list')).toContainText('Reklamationsprozess', {
timeout: 10_000
});
await expect(page.getByTestId('document-list')).not.toContainText('Wartungsplan CNC-Fräse');
await expect(page.getByTestId('document-list')).not.toContainText('CRM-Pflege');
// No admin nav for members.
await expect(page.getByRole('link', { name: 'Admin', exact: true })).toHaveCount(0);
// Cleanup so the spec is re-runnable.
await logout(page);
await login(page, 'florian@pablan.dev', 'pablan-dev');
const users = (await (await page.request.get('/api/admin/users')).json()).items;
const created = users.find((u: { email: string }) => u.email === EMAIL);
if (created) await page.request.delete(`/api/admin/users/${created.id}`);
const departments = await (await page.request.get('/api/departments')).json();
const dept = departments.find((d: { name: string }) => d.name === DEPARTMENT);
if (dept) await page.request.delete(`/api/admin/departments/${dept.id}`);
await page.request.post('/api/auth/logout');
});
test('an admin changes an LLM endpoint and resets it to the .env value', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
await openAdminTab(page, 'llm');
const settings = page.getByTestId('llm-settings');
// Bootstrapped from .env, so every field starts out attributed to it.
await expect(settings).toContainText('from .env');
const envUrl = await page.locator('#chat-url').inputValue();
expect(envUrl).not.toEqual('');
// A bogus endpoint must fail the test, and Save stays locked behind it.
await page.locator('#chat-url').fill('http://definitely.invalid/v1');
await page.getByTestId('test-chat').click();
await expect(settings).toContainText('endpoint failed', { timeout: 30_000 });
await expect(page.getByTestId('save-chat')).toBeDisabled();
// The real endpoint passes, so it can be stored, and applies at once.
await page.locator('#chat-url').fill(envUrl);
await page.getByTestId('test-chat').click();
await expect(settings).toContainText('ok ·', { timeout: 30_000 });
await page.locator('#chat-url').fill(`${envUrl}/`);
await page.getByTestId('save-chat').click();
await expect(settings).toContainText('changed here', { timeout: 15_000 });
// The endpoint reports what it serves, so the model becomes a dropdown.
await page.getByTestId('discover-chat').click();
await expect(
page.getByTestId('model-select-chat').or(page.getByTestId('no-model-list-chat'))
).toBeVisible({ timeout: 30_000 });
// Reset to .env, so the run leaves no configuration behind (zero residue).
await settings.getByLabel('Reset to .env').first().click();
await expect(settings.getByText('changed here')).toHaveCount(0, { timeout: 15_000 });
await expect(page.locator('#chat-url')).toHaveValue(envUrl);
await logout(page);
});
test('an admin forks a template, edits the fork and deletes it', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
await openAdminTab(page, 'templates');
const templates = page.getByTestId('template-list');
const firstRow = templates.locator('li').first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
await firstRow.getByLabel('Duplicate').click();
// The fork opens in the form builder; the raw YAML is behind its toggle,
// which is where a broken template can be typed at all.
await page.getByTestId('builder-show-yaml').click();
const editor = page.getByTestId('template-editor');
await expect(editor).toBeVisible({ timeout: 15_000 });
const original = await editor.inputValue();
await editor.fill('id: broken\nname: nope');
await page.getByTestId('save-template').click();
await expect(page.getByTestId('template-error')).toBeVisible();
// Valid YAML saves, and the fork appears in the list.
await editor.fill(original);
await page.getByTestId('save-template').click();
await expect(templates).toContainText('(2)', { timeout: 15_000 });
// Remove it again (zero residue). Destructive actions ask in the app's own
// modal, not the browser's.
const fork = templates.locator('li').filter({ hasText: '(2)' }).first();
await fork.getByLabel('Delete').click();
await page.getByTestId('confirm-accept').click();
await expect(templates.getByText('(2)')).toHaveCount(0, { timeout: 15_000 });
await logout(page);
});
test('an admin adds a template from the catalog and removes it again', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
await openAdminTab(page, 'templates');
await page.getByTestId('toggle-catalog').click();
// A blueprint can be read before it is added — that is what "View" is for.
// Deliberately one the starter set does NOT install: adding a blueprint
// that is already there would make the delete below ambiguous.
const catalog = page.getByTestId('template-catalog');
const blueprint = catalog.locator('li').filter({ hasText: 'Anlage' }).first();
await expect(blueprint).toBeVisible({ timeout: 15_000 });
await blueprint.getByLabel('View').click();
const editor = page.getByTestId('template-editor');
await expect(editor).toBeVisible({ timeout: 15_000 });
// Nothing on disk is editable — it has no row to save to yet.
await expect(editor).toHaveAttribute('readonly', '');
// Adding drops it into the instance and opens it for adapting straight
// away, which is what an admin does next — so the row is NOT on screen
// afterwards, and the instance's own list is what proves the add. (The
// panel around the list holds the open editor too, so asserting text on it
// would match the blueprint YAML and prove nothing.)
await page.getByTestId('add-template').click();
const listed = async () =>
(await (await page.request.get('/api/templates')).json()).find((entry: { name: string }) =>
entry.name.includes('Anlage')
);
await expect.poll(listed, { timeout: 15_000 }).toBeTruthy();
// Zero residue. Through the API: the UI is sitting in the editor it just
// opened, and this spec is about the catalog, not about leaving a form.
const added = await listed();
expect((await page.request.delete(`/api/templates/${added.id}`)).status()).toBe(204);
await logout(page);
});
test('an admin edits a user in the modal and pages the list', async ({ page }) => {
await login(page, 'florian@pablan.dev');
await openAdmin(page);
await page.locator('body[data-hydrated]').waitFor();
// Address the row by who it is, never by position: this test changes
// data, and after the search below `.first()` is a different person.
await openAdminTab(page, 'people');
const row = page.getByTestId('user-table').locator('tr').filter({ hasText: 'max@pablan.dev' });
const dialog = page.getByTestId('user-dialog');
// Editing is a dialog, not an expanded row.
await row.getByTestId('edit-user').click();
await expect(dialog).toBeVisible({ timeout: 15_000 });
const original = await page.locator('#edit-name').inputValue();
await page.locator('#edit-name').fill(`${original} (e2e)`);
await page.getByTestId('save-user').click();
await expect(dialog).toHaveCount(0, { timeout: 15_000 });
await expect(row).toContainText(`${original} (e2e)`);
// Search narrows the list server-side.
await page.getByTestId('user-search').fill('pablo');
await expect
.poll(async () => page.getByTestId('user-table').locator('tr').count(), { timeout: 15_000 })
.toBe(1);
await page.getByTestId('user-search').fill('');
await expect(row).toBeVisible({ timeout: 15_000 });
// Zero residue: put the name back on the same row it came from.
await row.getByTestId('edit-user').click();
await expect(dialog).toBeVisible({ timeout: 15_000 });
await page.locator('#edit-name').fill(original);
await page.getByTestId('save-user').click();
await expect(dialog).toHaveCount(0, { timeout: 15_000 });
await expect(row).toContainText(original);
await logout(page);
});
+107
View File
@@ -0,0 +1,107 @@
import { expect, test, type Page } from '@playwright/test';
import { login, logout } from './helpers';
// Uses the seeded dev users (make seed): pablo@pablan.dev / pablan-dev.
async function gotoHydrated(page: Page, path: string) {
await page.goto(path);
await page.locator('body[data-hydrated]').waitFor();
}
async function signInOnCurrentPage(page: Page, email: string) {
// Fills the login form already on screen — NO page.goto. A full
// navigation would reset the module singletons and hide the very leak
// this exercises. Wait for hydration first, so the client submit handler
// (which does the full-reload navigation) is wired up. Selectors are by
// attribute, not label: the logged-out login page is rendered in
// whatever language the previous user left, which is part of the point.
await page.locator('body[data-hydrated]').waitFor();
await page.locator('input[name="email"]').fill(email);
await page.locator('input[name="password"]').fill('pablan-dev');
await page.locator('input[name="password"]').press('Enter');
await expect(page).toHaveURL('/');
await page.locator('body[data-hydrated]').waitFor();
}
test('redirects anonymous visitors to the login page', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
test('shows an error for wrong credentials', async ({ page }) => {
await gotoHydrated(page, '/login');
await page.getByLabel('Email').fill('pablo@pablan.dev');
await page.getByLabel('Password').fill('definitely-wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toHaveText('Email or password is incorrect.');
await expect(page).toHaveURL(/\/login$/);
});
test('login and logout round-trip', async ({ page }) => {
await gotoHydrated(page, '/login');
await page.getByLabel('Email').fill('pablo@pablan.dev');
await page.getByLabel('Password').fill('pablan-dev');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/');
await expect(page.getByTestId('sidebar').getByText('Pablo')).toBeVisible();
// The landing greets by first name and invites capture.
await expect(page.getByRole('heading', { name: /Pablo/ })).toBeVisible();
await logout(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
});
// Regression for the P0 session-state leak: switching users in one browser
// context must not carry the previous user's conversation titles (an
// information disclosure) or their interface language across the boundary.
// Deliberately drives the real in-app flow — logout button, then the login
// form on the page it lands on — with no page.goto between users, which is
// what masked this in the other specs.
const MARKER = 'Zebrafrage-Session-P0-Test';
let leakConversationId: string | null = null;
test.afterEach(async ({ page }) => {
// End whatever session is current (max's UI login is never logged out by
// the test itself), then re-auth as pablo to undo his residue.
await page.request.post('/api/auth/logout');
await page.request.post('/api/auth/login', {
data: { email: 'pablo@pablan.dev', password: 'pablan-dev' }
});
await page.request.put('/api/account/locale', { data: { locale: null } });
if (leakConversationId) {
await page.request.delete(`/api/conversations/${leakConversationId}`);
leakConversationId = null;
}
await page.request.post('/api/auth/logout');
});
test('a different user does not inherit the previous session state', async ({ page }) => {
// Pablo: German interface and one conversation with a recognizable title.
await login(page, 'pablo@pablan.dev');
await page.request.put('/api/account/locale', { data: { locale: 'de' } });
const created = await page.request.post('/api/conversations', { data: { mode: 'query' } });
leakConversationId = (await created.json()).id;
await page.request.post(`/api/conversations/${leakConversationId}/messages`, {
data: { content: MARKER }
});
// Reload (pablo → pablo) so the German setting and the new conversation
// are on screen before the switch.
await page.goto('/');
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('sidebar')).toContainText(MARKER);
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
// Switch to Max through the UI, no page.goto.
await logout(page);
await page.locator('body[data-hydrated]').waitFor();
await signInOnCurrentPage(page, 'max@pablan.dev');
// Max sees only his own world.
await expect(page.getByTestId('sidebar')).not.toContainText(MARKER);
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
+91
View File
@@ -0,0 +1,91 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs } from './helpers';
// Writing-first capture as a USER sees it: pick a documentation type, write in
// the editor, get a refined version of the section at the cursor, accept it,
// read the diff before saving, and publish. The refine step drives the real
// LLM, so it can take a few seconds.
test.describe.configure({ mode: 'serial' });
test.setTimeout(180_000);
// Template names are matched in German on purpose: a template is product
// CONTENT in the instance's own language (PABLAN_DEFAULT_LOCALE), not UI copy
// that follows the reader's language setting.
/** Draft documents this spec created, so cleanup removes only its own. */
let created: string[] = [];
async function newDraft(page: Page, templateText: string): Promise<string> {
await page.goto('/documents/new');
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('capture-template').filter({ hasText: templateText }).first().click();
await expect(page).toHaveURL(/\/documents\/[0-9a-f-]{36}\/edit$/);
const id = page.url().split('/')[4];
created.push(id);
await page.locator('.cm-content').waitFor();
return id;
}
test.afterEach(async ({ page }) => {
for (const id of created) {
await page.request.delete(`/api/documents/${id}`);
}
created = [];
await page.request.post('/api/auth/logout');
});
test('picking a type opens the editor on the template skeleton', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
await newDraft(page, 'Über dich');
// The editor opens on the skeleton headings, not an empty box, and nothing
// else: the suggestion is a block inside the text that appears when one
// streams, so before any typing there is no widget and nothing to accept.
await expect(page.getByTestId('editor-source')).toContainText('##');
await expect(page.getByTestId('editor-suggestion')).toHaveCount(0);
await expect(page.getByTestId('editor-accept')).toHaveCount(0);
});
test('a typing pause yields a section suggestion that overwrites on accept', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
await newDraft(page, 'Über dich');
// Write rough notes under the first heading.
await page.locator('.cm-content').click();
await page.keyboard.press('Control+Home');
await page.keyboard.press('ArrowDown');
await page.keyboard.type(
'also der neue kollege wartet die cnc maschinen und ist ansprechpartner für den einkauf.'
);
// After the pause the model streams a refined version of the section.
await expect(page.getByTestId('editor-accept')).toBeVisible({ timeout: 40_000 });
await page.getByTestId('editor-accept').click();
// Accepting consumes the suggestion (the pane returns to its empty state)
// and replaces the rough notes — the lowercase draft phrasing is gone.
await expect(page.getByTestId('editor-accept')).toHaveCount(0);
await expect(page.getByTestId('editor-source')).not.toContainText('also der neue kollege');
// Saving shows what changed first, VSCode-style, and asks again.
await page.getByTestId('editor-open-save').click();
await expect(page.getByTestId('editor-diff')).toBeVisible();
});
test('a draft is published from the editor in one step', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
const id = await newDraft(page, 'Notiz');
await page.locator('.cm-content').click();
await page.keyboard.type('\nEin kurzer, brauchbarer Inhalt zum Veröffentlichen.');
await page.getByTestId('editor-open-save').click();
await page.getByTestId('editor-publish').click();
// Published, and the author is offered a colleague to check it.
await expect(page.getByTestId('capture-success')).toBeVisible();
await page.getByTestId('success-view').click();
await expect(page).toHaveURL(new RegExp(`/documents/${id}$`));
await expect(page.getByTestId('document-status')).toHaveAttribute('data-status', 'published');
});
+246
View File
@@ -0,0 +1,246 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs, openChat, waitForTurnEnd } from './helpers';
// Runs against the dev stack with the REAL LLM endpoints and the seeded,
// indexed corpus (make seed + backend running). Streaming answers from the
// local model take seconds — generous timeouts on stream assertions.
test.describe.configure({ mode: 'serial' });
test.setTimeout(120_000);
async function login(page: Page) {
await loginAs(page, 'pablo@pablan.dev');
}
async function deleteNewestConversation(page: Page) {
const list = await (await page.request.get('/api/conversations')).json();
if (list.length > 0) {
await page.request.delete(`/api/conversations/${list[0].id}`);
}
}
// Zero-residue: every test cleans up its conversation; the session goes here.
test.afterEach(async ({ page }) => {
await page.request.post('/api/auth/logout');
});
test('streams an answer with citations from the corpus', async ({ page }) => {
await login(page);
await openChat(page);
await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('send-button').click();
// Retrieval progress is reported before the answer arrives.
await expect(page.getByTestId('retrieval-status')).toBeVisible({ timeout: 30_000 });
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('sources').last()).toContainText(
'Urlaubsanträge und Abwesenheiten',
{ timeout: 30_000 }
);
// Wait for the stream to finish, then check we got a real answer.
await waitForTurnEnd(page);
const answer = (await assistant.innerText()).trim();
expect(answer.length).toBeGreaterThan(40);
// Transient: the status line disappears once the turn ends.
await expect(page.getByTestId('retrieval-status')).toHaveCount(0);
// One badge per cited document, however many of its sections matched.
// Retrieval works on chunks, so this is exactly where duplicates appear.
const titles = await page
.getByTestId('sources')
.last()
.getByTestId('source-badge')
.allInnerTexts();
const documentNames = titles.map((text) => text.replace(/\d+ sections$/, '').trim());
expect(new Set(documentNames).size).toBe(documentNames.length);
await deleteNewestConversation(page);
});
test('a citation shows its excerpt on hover and opens the document beside the chat', async ({
page
}) => {
await login(page);
await openChat(page);
await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('sources').last()).toBeVisible({ timeout: 45_000 });
await waitForTurnEnd(page);
const badge = page.getByTestId('source-badge').first();
await badge.hover();
// The popover carries the cited passage as plain text.
const tooltip = page.getByTestId('tooltip-content');
await expect(tooltip).toBeVisible({ timeout: 10_000 });
expect((await tooltip.innerText()).trim().length).toBeGreaterThan(20);
await badge.click();
const panel = page.getByTestId('document-panel');
await expect(panel).toBeVisible();
await expect(panel).toContainText('Urlaub', { timeout: 15_000 });
await panel.getByLabel('Close document panel').click();
await expect(panel).toHaveCount(0);
await deleteNewestConversation(page);
});
test('an undocumented question offers to capture the knowledge', async ({ page }) => {
await login(page);
await openChat(page);
// One of the eval-calibrated no-answer queries (tests/fixtures/
// golden_queries.yaml), so this reliably takes the low-confidence path.
await page.getByTestId('chat-input').fill('Welche Gerichte gibt es in der Kantine für Veganer?');
await page.getByTestId('send-button').click();
await waitForTurnEnd(page);
// The answer stands on general knowledge, but is labelled source-free
// and offers to close the gap by writing it down.
await expect(page.getByTestId('no-sources-note')).toBeVisible();
// The link carries the conversation, so the draft starts with the question
// that exposed the gap as background.
await expect(page.getByTestId('capture-gap')).toHaveAttribute(
'href',
/\/documents\/new\?conversation=/
);
await page.getByTestId('capture-gap').click();
await expect(page).toHaveURL(/\/documents\/new\?conversation=/);
// Only the query conversation was created; clean it up.
await deleteNewestConversation(page);
});
test('stop button aborts the stream and the partial answer survives reload', async ({ page }) => {
await login(page);
await openChat(page);
await page
.getByTestId('chat-input')
.fill('Erkläre ausführlich und Schritt für Schritt den kompletten Reklamationsprozess.');
await page.getByTestId('send-button').click();
// Wait until answer TOKENS have arrived (the .markdown body, not the
// sources badges which render first), then stop mid-stream.
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toBeVisible({ timeout: 30_000 });
const answerBody = assistant.locator('.markdown');
await expect
.poll(async () => (await answerBody.innerText()).trim().length, { timeout: 40_000 })
.toBeGreaterThan(15);
const stop = page.getByTestId('stop-button');
if (await stop.isVisible()) {
await stop.click();
}
await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 90_000 });
// The (partial) assistant message is persisted server-side.
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
// Sidebar rows are links; the only button in a row is "delete".
await page.getByTestId('conversation-list').getByRole('link').first().click();
const restored = page.getByTestId('assistant-message').last();
await expect(restored).toBeVisible({ timeout: 15_000 });
expect((await restored.innerText()).trim().length).toBeGreaterThan(10);
await deleteNewestConversation(page);
});
test('deleting a conversation removes it from the list', async ({ page }) => {
await login(page);
// Create a dedicated conversation via the API so the test never deletes
// pre-existing user data (and doesn't depend on earlier tests' residue).
await page.request.post('/api/conversations', { data: { mode: 'query' } });
await openChat(page);
const list = page.getByTestId('conversation-list');
await expect.poll(async () => list.locator('li').count(), { timeout: 15_000 }).toBeGreaterThan(0);
const before = await list.locator('li').count();
// Newest first: the untitled conversation we just created. Deleting asks
// first — it is destructive and the row carries no undo.
const ownRow = list.locator('li').filter({ hasText: 'New conversation' }).first();
await ownRow.hover();
await ownRow.getByLabel('Delete conversation').click();
await page.getByTestId('confirm-accept').click();
await expect.poll(async () => list.locator('li').count()).toBe(before - 1);
});
test('Pablan answers questions about itself, in the language they were asked', async ({ page }) => {
await login(page);
await openChat(page);
// Deictic phrasing ("this app") and English, against German help pages:
// the built-in documentation has to carry both the self-reference and
// survive the language switch.
await page.getByTestId('chat-input').fill('How does this app work?');
await page.getByTestId('send-button').click();
await waitForTurnEnd(page);
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toContainText('Pablan');
await expect(page.getByTestId('sources').last()).toContainText('Pablan:', {
timeout: 15_000
});
// It was answered FROM the knowledge base, not from general knowledge.
await expect(page.getByTestId('no-sources-note')).toHaveCount(0);
await deleteNewestConversation(page);
});
test('the first message moves the URL to the conversation, and Back leaves it', async ({
page
}) => {
await login(page);
await openChat(page);
await expect(page).toHaveURL('/chat');
await page.getByTestId('chat-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('send-button').click();
// The conversation gets its own address as soon as it exists, while the
// answer is still streaming.
await expect(page).toHaveURL(/\/chat\/[0-9a-f-]{36}$/, { timeout: 30_000 });
const conversationUrl = page.url();
await waitForTurnEnd(page);
// The stream survived the navigation: the state outlives the route.
expect(
(await page.getByTestId('assistant-message').last().innerText()).trim().length
).toBeGreaterThan(20);
// replaceState, so Back never lands on an orphaned empty composer.
await page.goBack();
await expect(page).not.toHaveURL(conversationUrl);
await deleteNewestConversation(page);
});
test('switching conversations abandons the previous view, and a foreign id is 404', async ({
page
}) => {
await login(page);
// Two conversations, created via the API so nothing depends on ordering.
const first = await (
await page.request.post('/api/conversations', { data: { mode: 'query' } })
).json();
const second = await (
await page.request.post('/api/conversations', { data: { mode: 'query' } })
).json();
await page.goto(`/chat/${first.id}`);
await page.locator('body[data-hydrated]').waitFor();
await page.goto(`/chat/${second.id}`);
await page.locator('body[data-hydrated]').waitFor();
// Nothing bled across: the second conversation is empty.
await expect(page.getByTestId('assistant-message')).toHaveCount(0);
// An id that is not yours is indistinguishable from one that does not
// exist — existence must not leak.
await page.goto('/chat/00000000-0000-4000-8000-000000000000');
await expect(page.locator('body')).toContainText('404');
await page.request.delete(`/api/conversations/${first.id}`);
await page.request.delete(`/api/conversations/${second.id}`);
});
+183
View File
@@ -0,0 +1,183 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs } from './helpers';
// The full knowledge loop against the REAL model: author a document in the
// writing editor (content set via the API for speed, the way the editor saves
// it), publish it in the UI, then retrieve the new knowledge via chat.
// Uses the seeded dev stack (make seed).
test.setTimeout(240_000);
async function login(page: Page) {
await loginAs(page, 'pablo@pablan.dev');
}
// Zero-residue: each test's login session goes here.
test.afterEach(async ({ page }) => {
await page.request.post('/api/auth/logout');
});
test('author → publish → ask-about-it, entirely in the product', async ({ page }) => {
await login(page);
// --- create a draft from a template (API, browser cookies shared) -----
const templates = await (await page.request.get('/api/templates')).json();
// German: a template is product content in the instance's language.
const blueprint = templates.find((t: { name: string }) => t.name.startsWith('Ablauf'));
expect(blueprint).toBeTruthy();
const draft = await (
await page.request.post('/api/documents', { data: { template_id: blueprint.id } })
).json();
const documentId = draft.id;
expect(draft.status).toBe('draft');
// Write the document. Done via the API (the editor autosaves the same way)
// so this loop does not depend on the LLM suggestion — capture.spec covers
// that path.
await page.request.patch(`/api/documents/${documentId}`, {
data: {
content_md:
'## Rolle und Aufgaben\n\nInstandhaltung der CNC-Maschinen in Halle 1.\n\n' +
'## Ansprechpartner\n\nDie interne Notfallnummer der Instandhaltung ist die 4455.'
}
});
// --- publish it in the UI --------------------------------------------
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('publish-button').click();
await expect(page.getByTestId('document-status')).toHaveAttribute('data-status', 'published', {
timeout: 15_000
});
// --- the new knowledge is retrievable via chat ------------------------
await page.waitForTimeout(5_000); // let the index job embed the chunks
await page.getByTestId('sidebar-new-conversation').click();
await page
.getByTestId('chat-input')
.fill('Wie lautet die interne Notfallnummer der Instandhaltung?');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('sources').last()).toContainText('Ablauf', {
timeout: 45_000
});
await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 });
expect(await page.getByTestId('assistant-message').last().innerText()).toContain('4455');
// --- cleanup so the spec is re-runnable -------------------------------
await page.request.delete(`/api/documents/${documentId}`);
// The chat question above auto-created a query conversation (newest first).
const conversations = await (await page.request.get('/api/conversations')).json();
if (conversations.length > 0) {
await page.request.delete(`/api/conversations/${conversations[0].id}`);
}
});
// Edit rights are deliberately NOT shown here — editing starts by opening
// the document, so the list only answers "why can I see this?".
test('the list marks what needs attention and filters by access', async ({ page }) => {
await login(page);
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
const list = page.getByTestId('document-list');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 });
// Rows are quiet unless something is off: the document somebody asked this
// user to check is flagged, and the normal case (published, yours, public)
// carries no badge.
await expect(page.getByTestId('open-review-badge').first()).toBeVisible();
// Built-in help pages read as product content, not as someone's document.
await expect(list).toContainText('Built-in');
// Filtering by access is client-side over the same rows: a
// department-only document disappears under "Public" and comes back.
const before = await list.locator('li').count();
// The narrowing controls live behind the filter toggle; only the filters
// that are ON stay visible, as removable chips.
await page.getByTestId('filters-toggle').click();
await page.getByTestId('access-filter').getByRole('button', { name: 'Public' }).click();
await expect(list).not.toContainText('Wartungsplan CNC-Fräse');
expect(await list.locator('li').count()).toBeLessThan(before);
await page.getByTestId('access-filter').getByRole('button', { name: 'All' }).click();
await expect(list).toContainText('Wartungsplan CNC-Fräse');
// A filter that is on stays visible after the panel is closed, and can be
// dropped from there — a quietly filtered list would lie about what exists.
await page.getByTestId('access-filter').getByRole('button', { name: 'Public' }).click();
await page.getByTestId('filters-toggle').click();
await expect(page.getByTestId('active-filters')).toContainText('Public');
await page.getByTestId('active-filters').getByRole('button').first().click();
await expect(list).toContainText('Wartungsplan CNC-Fräse');
});
test('a built-in help page cannot be edited or deleted', async ({ page }) => {
await login(page);
const found = (await (await page.request.get('/api/documents?search=Überblick')).json()).items;
const builtin = found.find((d: { is_builtin: boolean }) => d.is_builtin);
expect(builtin).toBeTruthy();
await page.goto(`/documents/${builtin.id}`);
await page.locator('body[data-hydrated]').waitFor();
const actions = page.getByRole('main');
await expect(actions.getByLabel('Edit')).toHaveCount(0);
await expect(actions.getByLabel('Delete')).toHaveCount(0);
// The API refuses the edit as well, not just the UI.
const patched = await page.request.patch(`/api/documents/${builtin.id}`, {
data: { title: 'Hijacked' }
});
expect(patched.status()).toBe(409);
});
test('search finds a document by meaning, not just by title', async ({ page }) => {
await login(page);
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
// Wording that appears nowhere in the title — only hybrid retrieval finds it.
await page.getByTestId('document-search').fill('Wie oft wird die Fräse gewartet');
const list = page.getByTestId('document-list');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 20_000 });
// Hits name the section they matched.
await expect(list.locator('li').first()).toContainText('Wartungsplan');
});
test('the list can be sorted and paged, and the sort sticks', async ({ page }) => {
await login(page);
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
const list = page.getByTestId('document-list');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 });
// Grid is the only layout for now.
await expect(list).toHaveAttribute('data-layout', 'grid');
// Sorting is server-side, so the set stays the same size.
await page.getByTestId('filters-toggle').click();
await page.getByTestId('sort-select').selectOption('created');
await expect(list).toContainText('Wartungsplan CNC-Fräse', { timeout: 15_000 });
// The sort choice is per device and survives a reload (the filter panel
// itself does not — it opens closed, which is the point of it).
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('filters-toggle').click();
await expect(page.getByTestId('sort-select')).toHaveValue('created', { timeout: 15_000 });
// Paging: force a small page so the pager appears regardless of corpus size.
const small = await (await page.request.get('/api/documents?per_page=2&page=1')).json();
expect(small.items.length).toBeLessThanOrEqual(2);
expect(small.total).toBeGreaterThan(2);
const second = await (await page.request.get('/api/documents?per_page=2&page=2')).json();
const firstIds = small.items.map((d: { id: string }) => d.id);
const secondIds = second.items.map((d: { id: string }) => d.id);
expect(firstIds.some((id: string) => secondIds.includes(id))).toBe(false);
// Zero residue: back to the default sort (the panel is still open).
await page.getByTestId('sort-select').selectOption('updated');
await expect(page.getByTestId('sort-select')).toHaveValue('updated');
});
+3
View File
@@ -0,0 +1,3 @@
import { globalSetup } from './residue';
export default globalSetup;
+3
View File
@@ -0,0 +1,3 @@
import { globalTeardown } from './residue';
export default globalTeardown;
+71
View File
@@ -0,0 +1,71 @@
// Shared navigation helpers. Every spec used to inline these; they live
// here so a change to the app shell is a one-file fix.
import { expect, type Page } from '@playwright/test';
export const DEV_PASSWORD = 'pablan-dev';
export async function login(page: Page, email: string, password = DEV_PASSWORD) {
await page.goto('/login');
await page.locator('body[data-hydrated]').waitFor();
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/');
// The specs assert English copy and pin it with Accept-Language, which is
// the strategy for a visitor with no ACCOUNT preference. A language picked
// in the settings dialog outranks it (that is the product rule), so a dev
// stack where somebody switched to German would fail every spec that reads
// a label. Hand the account back to "follow the browser" on the way in.
await page.request.put('/api/account/locale', { data: { locale: null } });
// Wait for the landing page to hydrate: filling an input before Svelte
// binds it leaves the state empty and submit buttons disabled.
await page.locator('body[data-hydrated]').waitFor();
}
/** Log out through the sidebar user menu. */
export async function logout(page: Page) {
// Login/logout are full document reloads (session state must not survive
// the boundary), so the page may be freshly server-rendered and not yet
// interactive when a test reaches here; a click before hydration does
// nothing. A real user is far slower than hydration.
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('user-menu').click();
await page.getByTestId('logout').click();
await expect(page).toHaveURL(/\/login$/);
}
export async function openChat(page: Page) {
// By testid: an untitled conversation row carries the same label.
await page.getByTestId('sidebar-new-conversation').click();
await expect(page).toHaveURL(/\/chat/);
}
export async function openDocuments(page: Page) {
await page.getByTestId('sidebar').getByRole('link', { name: 'Documents' }).click();
await expect(page).toHaveURL(/\/documents$/);
}
export async function openAdmin(page: Page) {
await page.getByTestId('sidebar').getByRole('link', { name: 'Admin' }).click();
await expect(page).toHaveURL(/\/admin$/);
}
/** The admin page is four unrelated jobs behind four tabs; every spec has to
* say which one it is doing.
*
* By testid, not by label: the account's own language setting outranks the
* Accept-Language this suite pins (that is the product rule — a language a
* person chose follows them), and the admin used here has picked one. */
export async function openAdminTab(page: Page, tab: 'people' | 'templates' | 'llm' | 'prompts') {
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId(`tab-${tab}`).click();
}
/** Call right after triggering a turn: waits for it to actually start (the
* stop button appears) and then to finish. Waiting on the send button alone
* races, because it is still visible for a moment after the click. */
export async function waitForTurnEnd(page: Page) {
await expect(page.getByTestId('stop-button')).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 120_000 });
}
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { login, logout } from './helpers';
// The landing is the front door: one input that starts a real conversation,
// and three chips.
test.setTimeout(120_000);
test.afterEach(async ({ page }) => {
await page.request.post('/api/auth/logout');
});
test('the landing input starts a conversation and streams an answer', async ({ page }) => {
await login(page, 'pablo@pablan.dev');
await page.getByTestId('landing-input').fill('Wie beantrage ich Urlaub?');
await page.getByTestId('landing-send').click();
// Hand-off to the chat page, which sends the question straight away.
await expect(page).toHaveURL(/\/chat/);
const assistant = page.getByTestId('assistant-message').last();
await expect(assistant).toBeVisible({ timeout: 45_000 });
// The turn is over once the stop button is gone.
await expect(page.getByTestId('stop-button')).toHaveCount(0, { timeout: 90_000 });
expect((await assistant.innerText()).trim().length).toBeGreaterThan(40);
// The conversation shows up in the sidebar.
await expect(page.getByTestId('conversation-list')).toContainText('Urlaub');
// Reloading reopens the same conversation instead of re-asking: the
// hand-off parameter is replaced by the conversation's own route.
const list = await (await page.request.get('/api/conversations')).json();
const created = list.find((c: { title: string }) => c.title?.includes('Urlaub'));
expect(created).toBeTruthy();
await expect(page).toHaveURL(`/chat/${created.id}`);
await page.reload();
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('assistant-message')).toHaveCount(1);
const after = await (await page.request.get(`/api/conversations/${created.id}`)).json();
expect(after.messages).toHaveLength(2); // still one question, one answer
await page.request.delete(`/api/conversations/${created.id}`);
});
test('the chips lead to capture and documents', async ({ page }) => {
await login(page, 'pablo@pablan.dev');
await page.getByTestId('chip-find').click();
await expect(page).toHaveURL(/\/documents$/);
await page.goBack();
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('chip-capture').click();
await expect(page).toHaveURL(/\/documents\/new$/);
await expect(page.getByTestId('capture-template').first()).toBeVisible({ timeout: 15_000 });
await logout(page);
});
+67
View File
@@ -0,0 +1,67 @@
import { expect, test, type Page } from '@playwright/test';
import { login as loginAs, logout } from './helpers';
// Permission boundary in the browser, against the restricted corpus
// document "Wissenssicherung: Werner Krause" (visibility: restricted,
// granted to Engineering only). Pablo (Engineering) sees it, Max (Sales)
// must not — in the list, in the detail view, and in chat sources.
test.setTimeout(120_000);
async function login(page: Page, email: string) {
await loginAs(page, email);
}
test('restricted corpus document stays invisible across list, detail and chat', async ({
page
}) => {
// Pablo (Engineering, has the grant) can see it — and we grab the id.
await login(page, 'pablo@pablan.dev');
const fromApi = (await (await page.request.get('/api/documents?search=Wissenssicherung')).json())
.items;
expect(fromApi.length).toBeGreaterThan(0);
const restrictedId = fromApi[0].id;
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('document-search').fill('Wissenssicherung');
await expect(page.getByTestId('document-list')).toContainText('Wissenssicherung: Werner Krause', {
timeout: 10_000
});
await logout(page);
// Max (Sales): list is empty, detail 404s, chat cites nothing restricted.
await login(page, 'max@pablan.dev');
await page.goto('/documents');
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('document-search').fill('Wissenssicherung Werner Krause');
// Search ranks by meaning, so it returns related documents rather than
// nothing — what matters is that the restricted one is never among them.
await expect(
page.getByTestId('document-list').or(page.getByText('No documents match'))
).toBeVisible({
timeout: 15_000
});
await expect(page.locator('body')).not.toContainText('Wissenssicherung: Werner Krause');
await page.goto(`/documents/${restrictedId}`);
await expect(page.getByText('Document not found')).toBeVisible();
await page.getByTestId('sidebar-new-conversation').click();
await page
.getByTestId('chat-input')
.fill('Welcher Servotec-Techniker kennt die F-350 am besten?');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('send-button')).toBeVisible({ timeout: 60_000 });
const sources = page.getByTestId('sources');
if ((await sources.count()) > 0) {
await expect(sources.last()).not.toContainText('Wissenssicherung');
}
// Cleanup: ben's chat question created a conversation; drop it + session.
const conversations = await (await page.request.get('/api/conversations')).json();
if (conversations.length > 0) {
await page.request.delete(`/api/conversations/${conversations[0].id}`);
}
await page.request.post('/api/auth/logout');
});
+58
View File
@@ -0,0 +1,58 @@
// Zero-residue guard: the e2e suite must leave the dev database exactly as
// it found it. globalSetup snapshots row counts, globalTeardown re-counts
// and fails the run on any difference.
//
// Documented exception: done/failed rows in `jobs` are execution history
// (the queue's audit trail); only unprocessed jobs count as residue.
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const TABLES = [
'users',
'departments',
'templates',
'conversations',
'messages',
'documents',
'chunks',
'doc_permissions',
'auth_sessions',
'llm_settings'
];
const SNAPSHOT = path.join(import.meta.dirname, '..', 'test-results', 'row-counts.json');
function query(sql: string): string {
return execSync(`docker exec pablan-dev-postgres-1 psql -tA -U pablan -d pablan -c "${sql}"`)
.toString()
.trim();
}
export function snapshotCounts(): Record<string, number> {
const counts: Record<string, number> = {};
for (const table of TABLES) {
counts[table] = Number(query(`SELECT count(*) FROM ${table}`));
}
counts['jobs (unprocessed)'] = Number(
query(`SELECT count(*) FROM jobs WHERE status IN ('pending','running')`)
);
return counts;
}
export async function globalSetup(): Promise<void> {
fs.mkdirSync(path.dirname(SNAPSHOT), { recursive: true });
fs.writeFileSync(SNAPSHOT, JSON.stringify(snapshotCounts(), null, 2));
}
export async function globalTeardown(): Promise<void> {
const before = JSON.parse(fs.readFileSync(SNAPSHOT, 'utf-8')) as Record<string, number>;
const after = snapshotCounts();
const diffs = Object.keys(after)
.filter((key) => before[key] !== after[key])
.map((key) => ` ${key}: ${before[key]} -> ${after[key]}`);
if (diffs.length > 0) {
throw new Error(`e2e suite left residue in the database:\n${diffs.join('\n')}`);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from '@playwright/test';
import { login as loginAs, logout } from './helpers';
// Asking a colleague to check something, end to end and without the model:
// the author asks, the document is marked as unsettled for everyone, the
// colleague answers, and the mark goes away with the answer.
test.setTimeout(120_000);
test('a question travels with the document until the colleague answers it', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
// A published document everyone may read, so Max is a candidate reviewer.
const created = await (
await page.request.post('/api/documents', {
data: { title: 'Notfallnummern Halle 1', visibility: 'public' }
})
).json();
const documentId = created.id;
await page.request.patch(`/api/documents/${documentId}`, {
data: { content_md: '## Notfall\n\nDie Nummer der Instandhaltung ist die 4455.' }
});
await page.request.post(`/api/documents/${documentId}/publish`);
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await page.getByTestId('document-menu').click();
await page.getByTestId('document-ask-review').click();
await page.getByTestId('reviewer-select').selectOption({ label: 'Max' });
await page.getByTestId('review-question').fill('Stimmt die 4455 noch?');
await page.getByTestId('review-ask-send').click();
// The question is on the document, and it says who is waiting on whom.
await expect(page.getByTestId('open-reviews')).toContainText('Stimmt die 4455 noch?');
await expect(page.getByTestId('open-reviews')).toContainText('Max');
// The author cannot answer their own question, only drop it.
await expect(page.getByTestId('review-confirm')).toHaveCount(0);
await expect(page.getByTestId('review-close')).toBeVisible();
await logout(page);
// Max was asked: it is in his queue, he may edit it, and he answers.
await loginAs(page, 'max@pablan.dev');
await page.goto('/documents?review=1');
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('document-list')).toContainText('Notfallnummern Halle 1');
await expect(page.getByTestId('open-review-badge').first()).toBeVisible();
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('document-edit')).toBeVisible();
await page.getByTestId('review-confirm').click();
// Answered: no open question left, and the record says who checked it.
await expect(page.getByTestId('open-reviews')).toHaveCount(0);
await expect(page.getByTestId('answered-reviews')).toContainText('Max');
// The grant went with the answer — Max may read it, not change it.
await expect(page.getByTestId('document-edit')).toHaveCount(0);
await logout(page);
await loginAs(page, 'pablo@pablan.dev');
await page.request.delete(`/api/documents/${documentId}`);
await page.request.post('/api/auth/logout');
});
test('answering on a draft hands it back instead of dropping you on a 404', async ({ page }) => {
await loginAs(page, 'pablo@pablan.dev');
const created = await (
await page.request.post('/api/documents', {
data: { title: 'Entwurf zum Gegenlesen', visibility: 'public' }
})
).json();
const documentId = created.id;
await page.request.patch(`/api/documents/${documentId}`, {
data: { content_md: '## Stand\n\nNoch nicht fertig.' }
});
const max = (
await (await page.request.get(`/api/documents/${documentId}/reviewers`)).json()
).find((candidate: { name: string }) => candidate.name === 'Max');
await page.request.post(`/api/documents/${documentId}/reviews`, {
data: { reviewer_id: max.id, question: 'Passt der Stand so?' }
});
await logout(page);
// Max only sees the draft because he was asked, and the page says so.
await loginAs(page, 'max@pablan.dev');
await page.goto(`/documents/${documentId}`);
await page.locator('body[data-hydrated]').waitFor();
await expect(page.getByTestId('open-reviews')).toContainText('Passt der Stand so?');
// He can see who may read it, and change neither that nor the publish state.
await page.getByTestId('access-chip').click();
await expect(page.getByTestId('access-controls')).toBeVisible();
await expect(page.getByTestId('visibility-select')).toHaveCount(0);
await expect(page.getByTestId('share-save')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.getByTestId('publish-button')).toHaveCount(0);
// Answering ends that access, so the page thanks him instead of 404ing.
await page.getByTestId('review-confirm').click();
await expect(page.getByTestId('review-done')).toBeVisible();
await logout(page);
await loginAs(page, 'pablo@pablan.dev');
await page.request.delete(`/api/documents/${documentId}`);
await page.request.post('/api/auth/logout');
});
+41
View File
@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig, includeIgnoreFile } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);
+501
View File
@@ -0,0 +1,501 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"settings_title": "Einstellungen",
"settings_section_appearance": "Darstellung",
"settings_section_language": "Sprache",
"settings_section_security": "Sicherheit",
"settings_theme_system": "System",
"settings_theme_light": "Hell",
"settings_theme_dark": "Dunkel",
"settings_locale_automatic": "Automatisch",
"settings_locale_hint": "Automatisch folgt deinem Browser. Deine Auswahl wird in deinem Konto gespeichert und gilt auf jedem Gerät.",
"settings_password_change": "Passwort ändern",
"settings_password_current": "Aktuelles Passwort",
"settings_password_new": "Neues Passwort",
"settings_password_repeat": "Neues Passwort wiederholen",
"settings_password_other_devices": "Deine anderen Geräte werden abgemeldet, dieses bleibt angemeldet.",
"settings_password_changed": "Passwort geändert. Andere Geräte wurden abgemeldet.",
"settings_password_mismatch": "Die neuen Passwörter stimmen nicht überein.",
"settings_password_wrong_current": "Das ist nicht dein aktuelles Passwort.",
"settings_password_failed": "Passwort konnte nicht geändert werden. Bitte versuch es erneut.",
"settings_administration": "Administration",
"settings_logout": "Abmelden",
"common_cancel": "Abbrechen",
"common_delete": "Löschen",
"login_page_title": "Anmelden",
"login_subtitle": "Melde dich mit deinem Firmenkonto an.",
"login_email": "E-Mail",
"login_password": "Passwort",
"login_submit": "Anmelden",
"login_submitting": "Wird angemeldet …",
"login_error_credentials": "E-Mail oder Passwort ist falsch.",
"login_error_generic": "Anmeldung fehlgeschlagen. Bitte versuch es später erneut.",
"nav_new_conversation": "Neues Gespräch",
"nav_documents": "Dokumente",
"nav_administration": "Administration",
"nav_settings": "Einstellungen",
"nav_sidebar_expand": "Seitenleiste ausklappen",
"nav_sidebar_collapse": "Seitenleiste einklappen",
"nav_conversation_untitled": "Neues Gespräch",
"nav_conversation_delete": "Gespräch löschen",
"landing_greeting_morning": "Guten Morgen, {name}",
"landing_greeting_afternoon": "Guten Tag, {name}",
"landing_greeting_evening": "Guten Abend, {name}",
"landing_prompt": "Was möchtest du heute festhalten?",
"landing_input_placeholder": "Stell eine Frage oder beschreib, was du weißt …",
"landing_input_note": "Antworten nennen die Dokumente, aus denen sie stammen.",
"landing_ask": "Fragen",
"landing_chip_capture": "Wissen festhalten",
"landing_chip_find": "Dokument finden",
"landing_setup_title": "Pablan einrichten",
"landing_setup_departments": "Abteilungen anlegen",
"landing_setup_departments_hint": "Sie entscheiden, wer was sieht.",
"landing_setup_invite": "Kolleginnen und Kollegen einladen",
"landing_setup_first_capture": "Die erste geführte Dokumentation starten",
"documents_page_title": "Dokumente",
"documents_search_placeholder": "Wissensbasis durchsuchen …",
"documents_filter_all_statuses": "Alle Status",
"documents_filter_all_departments": "Alle Abteilungen",
"documents_filter_disabled_hint": "Filter gelten beim Blättern, nicht für Suchergebnisse.",
"documents_sort_disabled_hint": "Suchergebnisse sind nach Relevanz sortiert.",
"documents_sort_updated": "Zuletzt geändert",
"documents_sort_created": "Neueste zuerst",
"documents_access_all": "Alle",
"documents_access_mine": "Meine",
"documents_access_department": "Abteilung",
"documents_access_public": "Öffentlich",
"documents_access_granted": "Freigegeben",
"documents_access_label_author": "Von dir",
"documents_access_label_department": "Abteilung",
"documents_access_label_public": "Öffentlich",
"documents_access_label_granted": "Freigegeben",
"documents_access_hint_author": "Du hast dieses Dokument erstellt.",
"documents_access_hint_department": "Mit deiner Abteilung geteilt.",
"documents_access_hint_public": "Für alle im Unternehmen sichtbar.",
"documents_access_hint_granted": "Deine Abteilung hat Zugriff erhalten.",
"documents_status_draft": "Entwurf",
"documents_status_published": "Veröffentlicht",
"documents_status_archived": "Archiviert",
"documents_badge_builtin": "Mitgeliefert",
"documents_no_department": "Keine Abteilung",
"documents_loading": "Wird geladen …",
"documents_empty": "Keine Dokumente passen zu den aktuellen Filtern.",
"documents_updated_at": "Geändert {date}",
"documents_created_at": "Erstellt {date}",
"history_title": "Verlauf",
"history_empty": "Noch keine Änderungen aufgezeichnet.",
"history_by": "von {actor}",
"history_actor_unknown": "Unbekannt",
"history_view_changes": "Änderungen ansehen",
"history_diff_title": "Änderungen in dieser Version",
"history_restore": "Diese Version wiederherstellen",
"history_action_created": "Erstellt",
"history_action_edited": "Bearbeitet",
"history_action_archived": "Archiviert",
"history_action_visibility_changed": "Sichtbarkeit geändert",
"nav_people": "Kolleg:innen",
"people_title": "Kolleg:innen",
"people_subtitle": "Finde heraus, wer im Team was macht.",
"people_no_department": "Keine Abteilung",
"people_role_admin": "Admin",
"people_not_found": "Diese Person gibt es nicht.",
"people_back": "Zurück zum Verzeichnis",
"profile_edit_action": "Profil bearbeiten",
"profile_edit_title": "Mein Profil",
"profile_edit_subtitle": "Halt fest, was du weißt, damit Kolleg:innen es finden.",
"profile_view_public": "Öffentliches Profil ansehen",
"settings_edit_profile": "Profil bearbeiten",
"document_delete_confirm": "Dieses Dokument wird dauerhaft gelöscht.",
"conversation_delete_confirm": "Dieses Gespräch wird dauerhaft gelöscht.",
"sharing_shared_with": "Geteilt mit",
"sharing_manage": "Teilen verwalten",
"sharing_share": "Mit Abteilungen teilen",
"sharing_dialog_title": "Mit Abteilungen teilen",
"sharing_dialog_hint": "Diese Abteilungen dürfen das Dokument zusätzlich lesen.",
"sharing_none_shareable": "Es gibt keine weiteren Abteilungen.",
"sharing_save": "Speichern",
"sharing_save_failed": "Speichern fehlgeschlagen.",
"sharing_lockout_warning": "Nach dieser Änderung verlierst du selbst den Zugriff auf dieses Dokument.",
"sharing_lockout_confirm": "Trotzdem speichern",
"admin_prompts_title": "System-Prompts",
"admin_prompts_hint": "Diese Anweisungen steuern, wie das Modell antwortet und Texte verbessert. Änderungen greifen sofort, ohne Neustart.",
"admin_prompt_default": "Standard",
"admin_prompt_changed": "Geändert",
"admin_prompt_save": "Speichern",
"admin_prompt_saved": "Gespeichert",
"admin_prompt_reset": "Zurücksetzen",
"admin_prompt_query_system": "Chat-Assistent (System)",
"admin_prompt_query_no_sources": "Chat: keine Treffer",
"admin_prompt_refine_persona": "Textverbesserung: Persona",
"admin_prompt_refine_rules": "Textverbesserung: Regeln",
"admin_prompt_grounding_framing": "Textverbesserung: Wissensbezug",
"admin_prompt_topic_summary": "Themen-Zusammenfassung",
"admin_prompt_title": "Titelvorschlag",
"landing_review_open": "Jetzt prüfen",
"landing_review_pending": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Ein Dokument wartet auf deine Prüfung.",
"countPlural=other": "{count} Dokumente warten auf deine Prüfung."
}
}
],
"documents_pager_previous": "Zurück",
"documents_pager_next": "Weiter",
"documents_pager_status": "Seite {page} von {pages}, {total} Dokumente",
"document_fallback_title": "Dokument",
"document_not_found_title": "Dokument nicht gefunden",
"document_not_found_body": "Es existiert nicht, oder du hast keinen Zugriff darauf.",
"document_back_to_list": "Zurück zu den Dokumenten",
"document_action_archive": "Archivieren",
"document_action_republish": "Wieder veröffentlichen",
"document_action_edit": "Bearbeiten",
"document_action_delete": "Löschen",
"document_builtin_note": "Teil von Pablan. Diese Seite gehört zum Produkt und wird mit ihm aktualisiert.",
"document_status_line": "Status: {status}",
"document_visibility_line": "Sichtbarkeit: {visibility}",
"document_can_edit": "Du kannst das bearbeiten",
"document_read_only": "Nur lesbar",
"document_visibility_public": "Öffentlich, ganzes Unternehmen",
"document_visibility_department": "Nur Abteilung",
"document_visibility_restricted": "Eingeschränkt, nur mit Freigabe",
"document_save_failed": "Speichern fehlgeschlagen. Bitte versuch es erneut.",
"chat_page_title": "Chat",
"chat_capture_button": "Wissen festhalten",
"chat_status_searching": "Durchsuche die Wissensbasis …",
"chat_status_results": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Eine relevante Stelle gefunden",
"countPlural=other": "{count} relevante Stellen gefunden"
}
}
],
"chat_status_no_answer": "Dazu ist noch nichts dokumentiert",
"chat_status_queued": "Das Modell ist gerade ausgelastet",
"chat_status_answering": "Erstelle Antwort …",
"chat_empty_query": "Stell eine Frage zur Wissensbasis deines Unternehmens.",
"chat_input_placeholder": "Stell eine Frage …",
"chat_stop": "Erzeugung stoppen",
"chat_send": "Senden",
"chat_no_sources_note": "Ohne die Wissensbasis beantwortet, dazu ist noch nichts dokumentiert.",
"chat_capture_gap": "Dieses Wissen festhalten",
"chat_context_label": "Kontext",
"chat_context_title": "Worauf diese Antwort fußt:",
"chat_context_used": "verwendet",
"chat_context_unused": "zu unsicher",
"panel_open_full_page": "Ganze Seite öffnen",
"panel_close": "Dokumentenpanel schließen",
"admin_page_title": "Administration",
"admin_users_title": "Benutzer",
"admin_users_email": "E-Mail",
"admin_users_name": "Name",
"admin_users_role": "Rolle",
"admin_users_department": "Abteilung",
"admin_users_actions": "Aktionen",
"admin_users_reset_password": "Passwort zurücksetzen",
"admin_users_delete": "Löschen",
"admin_users_no_department": "Keine Abteilung",
"admin_users_password": "Passwort",
"admin_users_create": "Benutzer anlegen",
"admin_users_create_failed": "Benutzer konnte nicht angelegt werden.",
"admin_users_reset_failed": "Zurücksetzen fehlgeschlagen (mindestens 8 Zeichen).",
"admin_users_delete_confirm": "{email} löschen? Die Dokumente bleiben bestehen, ohne Autor.",
"admin_departments_title": "Abteilungen",
"admin_departments_new": "Neue Abteilung",
"admin_departments_create": "Anlegen",
"admin_departments_create_failed": "Abteilung konnte nicht angelegt werden.",
"admin_departments_delete_confirm": "Abteilung \"{name}\" löschen? Mitglieder und Dokumente bleiben ohne sie bestehen.",
"admin_templates_title": "Dokumentationsvorlagen",
"admin_llm_title": "Sprachmodell-Endpunkte",
"admin_llm_test": "Endpunkte testen",
"admin_llm_testing": "Wird getestet …",
"admin_llm_intro": "Diese Werte wurden beim ersten Start aus .env übernommen. Seitdem liegen sie in der Datenbank, spätere Änderungen an .env wirken nicht mehr. Speichern greift sofort, ohne Neustart.",
"admin_llm_base_url": "Basis-URL",
"admin_llm_model": "Modell",
"admin_llm_api_key": "API-Schlüssel",
"admin_llm_api_key_unset": "nicht gesetzt",
"admin_llm_api_key_note": "Ein leeres Schlüsselfeld behält den gespeicherten Schlüssel.",
"admin_llm_source_env": "aus .env",
"admin_llm_source_ui": "hier geändert",
"admin_llm_reset_field": "Auf den .env-Wert zurücksetzen",
"admin_llm_check_models": "Modelle abfragen",
"admin_llm_checking": "Wird geprüft …",
"admin_llm_enter_manually": "Selbst eintragen",
"admin_llm_no_model_list": "Dieser Endpunkt veröffentlicht keine Modellliste. Trag die Modell-ID selbst ein.",
"admin_llm_test_role": "Testen",
"admin_llm_testing_role": "Wird getestet …",
"admin_llm_save": "Speichern",
"admin_llm_save_failed": "Endpunkt konnte nicht gespeichert werden. Bitte versuch es erneut.",
"admin_llm_endpoint_failed": "Endpunkt fehlgeschlagen",
"admin_template_edit": "Bearbeiten",
"admin_template_duplicate": "Duplizieren",
"admin_template_delete": "Löschen",
"admin_template_view": "Ansehen",
"admin_template_delete_confirm": "Vorlage \"{name}\" löschen?",
"admin_template_save": "Vorlage speichern",
"admin_template_saving": "Wird gespeichert …",
"admin_template_save_failed": "Vorlage konnte nicht gespeichert werden.",
"admin_template_add": "Zu dieser Instanz hinzufügen",
"admin_template_adding": "Wird hinzugefügt …",
"admin_template_add_failed": "Vorlage konnte nicht hinzugefügt werden.",
"admin_template_version_hint": "Denk daran, die Version zu erhöhen, wenn sich die Vorlage ändert.",
"admin_template_blueprint_hint": "Eine Vorlage aus dem Katalog. Füg sie hinzu, um sie bearbeiten zu können.",
"admin_template_empty": "Noch keine Dokumentationsvorlagen. Füg unten eine aus dem Katalog hinzu.",
"admin_catalog_title": "Vorlagenkatalog",
"admin_catalog_available": "{count} zum Hinzufügen verfügbar",
"admin_catalog_added": "Hinzugefügt",
"admin_catalog_add": "Hinzufügen",
"admin_template_new": "Neue Vorlage",
"admin_builder_new_heading": "Neue Dokumentationsvorlage",
"admin_builder_show_yaml": "YAML anzeigen",
"admin_builder_show_form": "Formular anzeigen",
"admin_builder_name": "Name",
"admin_builder_version": "Version",
"admin_builder_description": "Kurzbeschreibung",
"admin_builder_description_placeholder": "Wofür ist diese Vorlage gedacht?",
"admin_builder_persona": "Schreibstil fürs Modell",
"admin_builder_persona_placeholder": "Beschreib, wie das Modell den Abschnitt schreiben soll: Ton, Detailgrad, was es vermeiden soll.",
"admin_builder_sections": "Abschnitte",
"admin_builder_sections_hint": "Jede Überschrift wird ein Abschnitt im Dokument. Der Hinweis steuert, was das Modell in diesem Abschnitt herausarbeitet.",
"admin_builder_sections_empty": "Noch keine Abschnitte. Füg den ersten hinzu.",
"admin_builder_section_heading_placeholder": "Überschrift, z. B. Ablauf",
"admin_builder_section_hint_placeholder": "Was gehört in diesen Abschnitt?",
"admin_builder_section_add": "Abschnitt hinzufügen",
"admin_builder_section_up": "Nach oben",
"admin_builder_section_down": "Nach unten",
"admin_builder_section_remove": "Abschnitt entfernen",
"admin_builder_title": "Titelvorschlag",
"admin_builder_title_tokens": "Platzhalter:",
"admin_builder_locale": "Sprache des Inhalts",
"admin_builder_locale_unset": "Ohne Festlegung",
"admin_builder_locale_de": "Deutsch",
"admin_builder_locale_en": "Englisch",
"admin_builder_visibility": "Sichtbarkeit nach Freigabe",
"admin_builder_temperature": "Kreativität",
"admin_builder_temperature_hint": "0 = nüchtern, 1 = frei",
"admin_builder_min_class": "Mindestmodell",
"admin_builder_min_class_placeholder": "optional, z. B. 12b",
"admin_builder_error_name": "Gib der Vorlage einen Namen.",
"admin_builder_error_sections": "Füg mindestens einen Abschnitt mit Überschrift hinzu.",
"common_close": "Schließen",
"admin_llm_reset_field_short": "Auf .env zurücksetzen",
"admin_users_edit": "Bearbeiten",
"admin_users_save": "Speichern",
"admin_users_update_failed": "Änderung konnte nicht gespeichert werden.",
"admin_users_email_placeholder": "vorname@firma.de",
"admin_users_name_placeholder": "Vor- und Nachname",
"admin_users_password_placeholder": "mindestens 8 Zeichen",
"admin_departments_rename": "Umbenennen",
"admin_departments_rename_failed": "Abteilung konnte nicht umbenannt werden.",
"admin_departments_placeholder": "z. B. Instandhaltung",
"admin_llm_base_url_placeholder": "https://api.example.com/v1",
"admin_llm_model_placeholder": "Modell-ID",
"admin_template_name_placeholder": "Vorlagenname",
"admin_users_edit_title": "Benutzer bearbeiten",
"admin_users_search_placeholder": "Nach Name oder E-Mail suchen …",
"admin_pager_status": "Seite {page} von {pages}, {total} Benutzer",
"admin_users_empty": "Keine Benutzer gefunden.",
"admin_department_edit_title": "Abteilung umbenennen",
"admin_users_create_title": "Neuen Benutzer anlegen",
"admin_users_new": "Benutzer anlegen",
"admin_departments_create_title": "Neue Abteilung anlegen",
"admin_departments_new_button": "Abteilung anlegen",
"chat_error_start_conversation": "Das Gespräch konnte nicht gestartet werden. Bitte versuche es erneut.",
"chat_error_connection_lost": "Verbindung unterbrochen. Bitte versuche es erneut.",
"capture_title": "Wissen festhalten",
"capture_subtitle": "Wähle eine Dokumentationsart, dann schreib los. Das Modell schlägt dir beim Schreiben reifere Formulierungen vor.",
"capture_start_failed": "Das Dokument konnte nicht angelegt werden.",
"editor_save": "Speichern",
"editor_accept": "Übernehmen",
"editor_dismiss": "Verwerfen",
"editor_grounding_label": "Grundlage",
"editor_suggestion_title": "Vorschlag",
"admin_catalog_sections": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Ein Abschnitt",
"countPlural=other": "{count} Abschnitte"
}
}
],
"capture_matches_title": "Passt zu deinem Gespräch",
"capture_templates_title": "Neu dokumentieren",
"capture_success_title": "Dein Wissen ist dokumentiert.",
"capture_success_body": "Du kannst es dir ansehen oder jemandem mit Zugriff zur Prüfung geben.",
"capture_success_view": "Ansehen",
"documents_export": "Exportieren",
"llm_error_unreachable": "Das Sprachmodell ist nicht erreichbar. Prüf, ob der Endpunkt läuft, oder versuch es später noch einmal.",
"llm_error_busy": "Das Sprachmodell ist gerade ausgelastet. Warte einen Moment und versuch es erneut.",
"llm_error_misconfigured": "Der Zugang zum Sprachmodell stimmt nicht. Bitte gib einer Administratorin Bescheid.",
"llm_error_failed": "Das Sprachmodell hat nicht geantwortet. Bitte versuch es erneut.",
"llm_status_slow": "Das Modell braucht länger als sonst. Wahrscheinlich ist der Endpunkt gerade ausgelastet.",
"error_generic": "Etwas ist schiefgelaufen. Bitte versuch es erneut.",
"profile_capture_title": "Halt fest, was du weißt",
"profile_capture_hint": "Ein kurzes Dokument über deine Rolle, deine Spezialgebiete und wofür man dich fragen kann. Die Vorlage stellt die Fragen, du antwortest in deinen Worten.",
"profile_capture_cta": "Dokument über dich anlegen",
"chat_fallback_note": "Ohne Modell wurde klassisch im Volltext gesucht. Du siehst die Fundstellen direkt und kannst sie selbst öffnen.",
"chat_fallback_empty": "Die Volltextsuche hat zu deinen Wörtern nichts gefunden. Formulier es mit anderen Begriffen, oder versuch es erneut, wenn das Modell wieder läuft.",
"admin_users_reset_title": "Passwort zurücksetzen",
"error_email_taken": "Diese E-Mail-Adresse wird bereits verwendet.",
"error_name_taken": "Diesen Namen gibt es schon.",
"error_self_modification": "Das kannst du an deinem eigenen Konto nicht ändern.",
"error_department_in_use": "Diese Abteilung wird noch verwendet. Beim Löschen gehen ihre Freigaben verloren.",
"profile_document_title": "Dein Dokument",
"profile_document_hint": "Das ist dein Dokument über dich. Du kannst es jederzeit weiterschreiben.",
"profile_document_open": "Ansehen",
"profile_document_edit": "Bearbeiten",
"documents_filter_my_reviews": "Zur Prüfung bei mir",
"documents_badge_open_reviews": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Frage offen",
"countPlural=other": "{count} Fragen offen"
}
}
],
"document_publish": "Veröffentlichen",
"document_draft_title": "Noch ein Entwurf",
"document_draft_hint": "Nur du siehst diesen Text. Veröffentlicht wird er für alle sichtbar, die Zugriff haben, und im Chat gefunden.",
"document_draft_hint_reviewer": "Du wurdest gebeten, diesen Entwurf zu prüfen. Außer dir sieht ihn nur die Person, die ihn schreibt.",
"document_review_ask": "Um Prüfung bitten",
"document_review_asked_by": "{name} fragt:",
"document_review_asked_plain": "{name} bittet um eine Prüfung.",
"document_review_waiting_on": "Wartet auf {name}, gefragt am {date}",
"document_review_confirm": "Stimmt so",
"document_review_fix": "Korrigieren",
"document_review_close": "Frage schließen",
"document_review_answered": "Geprüft von {name} am {date}",
"document_review_failed": "Das hat nicht geklappt. Bitte versuch es erneut.",
"review_ask_hint": "Such jemanden aus, der es beurteilen kann, und schreib dazu, worum es geht. Bis zur Antwort ist das Dokument überall als ungeprüft markiert.",
"review_ask_reviewer": "Wer soll es prüfen?",
"review_ask_question": "Worum geht es? (optional)",
"review_ask_question_placeholder": "z. B. Stimmt das so mit den 14 Urlaubstagen?",
"review_ask_send": "Frage senden",
"review_ask_sent": "{name} wurde gefragt.",
"review_ask_none": "Niemand sonst hat Zugriff auf dieses Dokument.",
"review_ask_failed": "Die Anfrage ist nicht angekommen. Bitte versuch es erneut.",
"editor_save_title": "Änderungen speichern",
"editor_save_hint": "Das hast du geändert, seit du zuletzt gespeichert hast.",
"editor_save_and_publish": "Speichern und veröffentlichen",
"editor_no_changes": "Seit dem letzten Speichern hat sich nichts geändert.",
"editor_unsaved": "Nicht gespeichert",
"capture_success_ask": "Prüfen lassen",
"landing_drafts_title": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Ein Entwurf von dir",
"countPlural=other": "{count} Entwürfe von dir"
}
}
],
"landing_drafts_hint": "Noch nicht veröffentlicht, niemand sonst findet sie.",
"landing_drafts_publish": "Veröffentlichen",
"landing_drafts_all": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Alle ansehen",
"countPlural=other": "Alle {count} ansehen"
}
}
],
"chat_source_review_pending": "Zu diesem Dokument ist eine Frage offen. Der Inhalt ist eventuell nicht mehr aktuell.",
"panel_missing": "Das Dokument gibt es nicht mehr, oder du hast keinen Zugriff darauf.",
"panel_loading": "Wird geladen …",
"common_back": "Zurück",
"documents_visibility_public": "öffentlich",
"documents_visibility_department": "Abteilung",
"documents_visibility_restricted": "eingeschränkt",
"history_action_published": "Veröffentlicht",
"history_action_review_requested": "Um Prüfung gebeten",
"history_action_review_resolved": "Geprüft",
"chat_source_sections": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "1 Stelle",
"countPlural=other": "{count} Stellen"
}
}
],
"visibility_label": "Sichtbar für",
"visibility_save_failed": "Die Sichtbarkeit konnte nicht geändert werden.",
"editor_title_label": "Titel",
"editor_title_suggest": "Titel vorschlagen lassen",
"editor_title_suggest_failed": "Es kam kein Vorschlag zurück. Bitte versuch es erneut.",
"document_review_thanks_title": "Danke, geprüft.",
"document_review_thanks_body": "Der Entwurf gehört wieder der Person, die ihn schreibt. Sobald sie ihn veröffentlicht, findest du ihn über die Suche.",
"documents_access_label_review": "Zur Prüfung",
"documents_access_hint_review": "Du siehst das, weil du um eine Prüfung gebeten wurdest. Mit deiner Antwort endet der Zugriff.",
"common_more": "Mehr",
"document_draft_chip": "Entwurf",
"access_popover_title": "Wer sieht das?",
"access_extra_departments": "Zusätzlich geteilt mit",
"access_no_extra_departments": "Mit keiner weiteren Abteilung geteilt.",
"access_plus_departments": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "+1 Abteilung",
"countPlural=other": "+{count} Abteilungen"
}
}
],
"history_show_all": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Einen Eintrag anzeigen",
"countPlural=other": "Alle {count} Einträge anzeigen"
}
}
],
"history_show_less": "Weniger anzeigen",
"documents_filters": "Filter",
"documents_access_filter_label": "Warum sichtbar:",
"documents_export_hint": "Alle lesbaren Dokumente als Markdown-ZIP herunterladen",
"admin_tab_people": "Benutzer und Abteilungen",
"admin_page_subtitle": "Wer arbeitet mit, womit wird geschrieben, und woran hängt das Sprachmodell.",
"people_search_placeholder": "Name oder Abteilung …",
"people_none_found": "Niemand gefunden.",
"profile_my_documents": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Ein Dokument von dir",
"countPlural=other": "{count} Dokumente von dir"
}
}
],
"chat_empty_hint": "Die Antwort kommt aus euren eigenen Dokumenten und nennt die Stellen, auf die sie sich stützt.",
"chat_sources_label": "Quellen:",
"editor_back": "Zurück",
"admin_prompt_hint_query_system": "Die Grundhaltung des Assistenten im Chat: wie er antwortet und wie er mit den gefundenen Stellen umgeht.",
"admin_prompt_hint_query_no_sources": "Was der Assistent sagt, wenn die Suche nichts Belastbares findet.",
"admin_prompt_hint_refine_persona": "Wer beim Schreiben mitformuliert: Rolle und Tonfall der Textvorschläge im Editor.",
"admin_prompt_hint_refine_rules": "Die Regeln für einen Vorschlag: was er darf, was er nicht erfinden soll.",
"admin_prompt_hint_grounding_framing": "Wie bereits dokumentiertes Wissen in einen Textvorschlag eingebettet wird.",
"admin_prompt_hint_topic_summary": "Fasst ein Gespräch in einem Satz zusammen, um passende Dokumente zu finden.",
"admin_prompt_hint_title": "Schlägt aus dem Inhalt eines Dokuments einen Titel vor.",
"editor_suggestions_paused": "Vorschläge pausiert.",
"editor_suggestions_retry": "Jetzt erneut versuchen",
"editor_saved_at": "Gespeichert um {time}",
"editor_untouched_draft": "Neuer Entwurf. Wenn du nichts schreibst, wird er beim Verlassen verworfen.",
"editor_draft_exists": "Entwurf, nur für dich sichtbar."
}
+501
View File
@@ -0,0 +1,501 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"settings_title": "Settings",
"settings_section_appearance": "Appearance",
"settings_section_language": "Language",
"settings_section_security": "Security",
"settings_theme_system": "System",
"settings_theme_light": "Light",
"settings_theme_dark": "Dark",
"settings_locale_automatic": "Automatic",
"settings_locale_hint": "Automatic follows your browser. Your choice is saved to your account and applies on every device.",
"settings_password_change": "Change password",
"settings_password_current": "Current password",
"settings_password_new": "New password",
"settings_password_repeat": "Repeat new password",
"settings_password_other_devices": "Your other devices will be signed out, this one stays.",
"settings_password_changed": "Password changed. Other devices were signed out.",
"settings_password_mismatch": "The new passwords do not match.",
"settings_password_wrong_current": "That is not your current password.",
"settings_password_failed": "Could not change the password. Please try again.",
"settings_administration": "Administration",
"settings_logout": "Log out",
"common_cancel": "Cancel",
"common_delete": "Delete",
"login_page_title": "Sign in",
"login_subtitle": "Sign in with your company account.",
"login_email": "Email",
"login_password": "Password",
"login_submit": "Sign in",
"login_submitting": "Signing in …",
"login_error_credentials": "Email or password is incorrect.",
"login_error_generic": "Login failed. Please try again later.",
"nav_new_conversation": "New conversation",
"nav_documents": "Documents",
"nav_administration": "Administration",
"nav_settings": "Settings",
"nav_sidebar_expand": "Expand sidebar",
"nav_sidebar_collapse": "Collapse sidebar",
"nav_conversation_untitled": "New conversation",
"nav_conversation_delete": "Delete conversation",
"landing_greeting_morning": "Good morning, {name}",
"landing_greeting_afternoon": "Good afternoon, {name}",
"landing_greeting_evening": "Good evening, {name}",
"landing_prompt": "What would you like to capture today?",
"landing_input_placeholder": "Ask a question, or describe what you know…",
"landing_input_note": "Answers cite the documents they come from.",
"landing_ask": "Ask",
"landing_chip_capture": "Capture knowledge",
"landing_chip_find": "Find a document",
"landing_setup_title": "Set up Pablan",
"landing_setup_departments": "Create departments",
"landing_setup_departments_hint": "They decide who sees what.",
"landing_setup_invite": "Invite your colleagues",
"landing_setup_first_capture": "Run the first guided documentation",
"documents_page_title": "Documents",
"documents_search_placeholder": "Search the knowledge base…",
"documents_filter_all_statuses": "All statuses",
"documents_filter_all_departments": "All departments",
"documents_filter_disabled_hint": "Filters apply when browsing, not to search results.",
"documents_sort_disabled_hint": "Search results are ranked by relevance.",
"documents_sort_updated": "Recently changed",
"documents_sort_created": "Newest",
"documents_access_all": "All",
"documents_access_mine": "Mine",
"documents_access_department": "Department",
"documents_access_public": "Public",
"documents_access_granted": "Granted",
"documents_access_label_author": "Yours",
"documents_access_label_department": "Department",
"documents_access_label_public": "Public",
"documents_access_label_granted": "Granted",
"documents_access_hint_author": "You created this document.",
"documents_access_hint_department": "Shared with your department.",
"documents_access_hint_public": "Visible to everyone in the company.",
"documents_access_hint_granted": "Your department was granted access.",
"documents_status_draft": "Draft",
"documents_status_published": "Published",
"documents_status_archived": "Archived",
"documents_badge_builtin": "Built-in",
"documents_no_department": "No department",
"documents_loading": "Loading…",
"documents_empty": "No documents match the current filters.",
"documents_updated_at": "Updated {date}",
"documents_created_at": "Created {date}",
"history_title": "History",
"history_empty": "No changes recorded yet.",
"history_by": "by {actor}",
"history_actor_unknown": "Unknown",
"history_view_changes": "View changes",
"history_diff_title": "What this version changed",
"history_restore": "Restore this version",
"history_action_created": "Created",
"history_action_edited": "Edited",
"history_action_archived": "Archived",
"history_action_visibility_changed": "Visibility changed",
"nav_people": "People",
"people_title": "People",
"people_subtitle": "Find out who does what on the team.",
"people_no_department": "No department",
"people_role_admin": "Admin",
"people_not_found": "This person does not exist.",
"people_back": "Back to the directory",
"profile_edit_action": "Edit profile",
"profile_edit_title": "My profile",
"profile_edit_subtitle": "Write down what you know, so colleagues can find it.",
"profile_view_public": "View public profile",
"settings_edit_profile": "Edit profile",
"document_delete_confirm": "This document will be permanently deleted.",
"conversation_delete_confirm": "This conversation will be permanently deleted.",
"sharing_shared_with": "Shared with",
"sharing_manage": "Manage sharing",
"sharing_share": "Share with departments",
"sharing_dialog_title": "Share with departments",
"sharing_dialog_hint": "These departments may also read the document.",
"sharing_none_shareable": "There are no other departments.",
"sharing_save": "Save",
"sharing_save_failed": "Saving failed.",
"sharing_lockout_warning": "You will lose your own access to this document after this change.",
"sharing_lockout_confirm": "Save anyway",
"admin_prompts_title": "System prompts",
"admin_prompts_hint": "These instructions shape how the model answers and refines text. Changes take effect immediately, without a restart.",
"admin_prompt_default": "Default",
"admin_prompt_changed": "Changed",
"admin_prompt_save": "Save",
"admin_prompt_saved": "Saved",
"admin_prompt_reset": "Reset",
"admin_prompt_query_system": "Chat assistant (system)",
"admin_prompt_query_no_sources": "Chat: no matches",
"admin_prompt_refine_persona": "Refinement: persona",
"admin_prompt_refine_rules": "Refinement: rules",
"admin_prompt_grounding_framing": "Refinement: grounding framing",
"admin_prompt_topic_summary": "Topic summary",
"admin_prompt_title": "Title suggestion",
"landing_review_open": "Review now",
"landing_review_pending": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "One document is waiting for your review.",
"countPlural=other": "{count} documents are waiting for your review."
}
}
],
"documents_pager_previous": "Previous",
"documents_pager_next": "Next",
"documents_pager_status": "Page {page} of {pages}, {total} documents",
"document_fallback_title": "Document",
"document_not_found_title": "Document not found",
"document_not_found_body": "It may not exist, or you do not have access to it.",
"document_back_to_list": "Back to documents",
"document_action_archive": "Archive",
"document_action_republish": "Republish",
"document_action_edit": "Edit",
"document_action_delete": "Delete",
"document_builtin_note": "Part of Pablan. This page ships with the product and updates with it.",
"document_status_line": "Status: {status}",
"document_visibility_line": "Visibility: {visibility}",
"document_can_edit": "You can edit this",
"document_read_only": "Read only",
"document_visibility_public": "Public, whole company",
"document_visibility_department": "Department only",
"document_visibility_restricted": "Restricted, explicit grants",
"document_save_failed": "Saving failed. Please try again.",
"chat_page_title": "Chat",
"chat_capture_button": "Capture knowledge",
"chat_status_searching": "Searching the knowledge base…",
"chat_status_results": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "1 relevant passage found",
"countPlural=other": "{count} relevant passages found"
}
}
],
"chat_status_no_answer": "Nothing documented on this yet",
"chat_status_queued": "The model is busy right now",
"chat_status_answering": "Writing answer…",
"chat_empty_query": "Ask a question about your company's knowledge base.",
"chat_input_placeholder": "Ask a question…",
"chat_stop": "Stop generating",
"chat_send": "Send",
"chat_no_sources_note": "Answered without the knowledge base, nothing documented on this yet.",
"chat_capture_gap": "Capture this knowledge",
"chat_context_label": "Context",
"chat_context_title": "What this answer is based on:",
"chat_context_used": "used",
"chat_context_unused": "too weak",
"panel_open_full_page": "Open full page",
"panel_close": "Close document panel",
"admin_page_title": "Administration",
"admin_users_title": "Users",
"admin_users_email": "Email",
"admin_users_name": "Name",
"admin_users_role": "Role",
"admin_users_department": "Department",
"admin_users_actions": "Actions",
"admin_users_reset_password": "Reset password",
"admin_users_delete": "Delete",
"admin_users_no_department": "No department",
"admin_users_password": "Password",
"admin_users_create": "Create user",
"admin_users_create_failed": "Creating the user failed.",
"admin_users_reset_failed": "Password reset failed (min. 8 characters).",
"admin_users_delete_confirm": "Delete {email}? Their documents survive without an author.",
"admin_departments_title": "Departments",
"admin_departments_new": "New department",
"admin_departments_create": "Create",
"admin_departments_create_failed": "Creating the department failed.",
"admin_departments_delete_confirm": "Delete department \"{name}\"? Members and documents keep existing without it.",
"admin_templates_title": "Capture templates",
"admin_llm_title": "LLM endpoints",
"admin_llm_test": "Test endpoints",
"admin_llm_testing": "Testing…",
"admin_llm_intro": "These values were taken from .env when this instance was first started. From then on they live in the database, so editing .env afterwards changes nothing. Saving applies immediately, no restart.",
"admin_llm_base_url": "Base URL",
"admin_llm_model": "Model",
"admin_llm_api_key": "API key",
"admin_llm_api_key_unset": "not set",
"admin_llm_api_key_note": "An empty API key field keeps the stored key.",
"admin_llm_source_env": "from .env",
"admin_llm_source_ui": "changed here",
"admin_llm_reset_field": "Put this field back to the .env value",
"admin_llm_check_models": "Check models",
"admin_llm_checking": "Checking…",
"admin_llm_enter_manually": "Enter manually",
"admin_llm_no_model_list": "This endpoint does not publish a model list. Type the model id yourself.",
"admin_llm_test_role": "Test",
"admin_llm_testing_role": "Testing…",
"admin_llm_save": "Save",
"admin_llm_save_failed": "Could not save the endpoint. Please try again.",
"admin_llm_endpoint_failed": "endpoint failed",
"admin_template_edit": "Edit",
"admin_template_duplicate": "Duplicate",
"admin_template_delete": "Delete",
"admin_template_view": "View",
"admin_template_delete_confirm": "Delete the template \"{name}\"?",
"admin_template_save": "Save template",
"admin_template_saving": "Saving…",
"admin_template_save_failed": "Could not save the template.",
"admin_template_add": "Add to this instance",
"admin_template_adding": "Adding…",
"admin_template_add_failed": "Could not add the template.",
"admin_template_version_hint": "Remember to raise the version when the template changes.",
"admin_template_blueprint_hint": "A blueprint. Add it to this instance to make it editable.",
"admin_template_empty": "No capture templates yet, add one from the catalog below.",
"admin_catalog_title": "Template catalog",
"admin_catalog_available": "{count} available to add",
"admin_catalog_added": "Added",
"admin_catalog_add": "Add",
"admin_template_new": "New template",
"admin_builder_new_heading": "New documentation template",
"admin_builder_show_yaml": "Show YAML",
"admin_builder_show_form": "Show form",
"admin_builder_name": "Name",
"admin_builder_version": "Version",
"admin_builder_description": "Short description",
"admin_builder_description_placeholder": "What is this template for?",
"admin_builder_persona": "Writing style for the model",
"admin_builder_persona_placeholder": "Describe how the model should write the section: tone, level of detail, what to avoid.",
"admin_builder_sections": "Sections",
"admin_builder_sections_hint": "Each heading becomes a section in the document. The hint steers what the model draws out in that section.",
"admin_builder_sections_empty": "No sections yet. Add the first one.",
"admin_builder_section_heading_placeholder": "Heading, e.g. Procedure",
"admin_builder_section_hint_placeholder": "What belongs in this section?",
"admin_builder_section_add": "Add section",
"admin_builder_section_up": "Move up",
"admin_builder_section_down": "Move down",
"admin_builder_section_remove": "Remove section",
"admin_builder_title": "Suggested title",
"admin_builder_title_tokens": "Placeholders:",
"admin_builder_locale": "Content language",
"admin_builder_locale_unset": "No fixed language",
"admin_builder_locale_de": "German",
"admin_builder_locale_en": "English",
"admin_builder_visibility": "Visibility after approval",
"admin_builder_temperature": "Creativity",
"admin_builder_temperature_hint": "0 = sober, 1 = free",
"admin_builder_min_class": "Minimum model",
"admin_builder_min_class_placeholder": "optional, e.g. 12b",
"admin_builder_error_name": "Give the template a name.",
"admin_builder_error_sections": "Add at least one section with a heading.",
"common_close": "Close",
"admin_llm_reset_field_short": "Reset to .env",
"admin_users_edit": "Edit",
"admin_users_save": "Save",
"admin_users_update_failed": "Could not save the change.",
"admin_users_email_placeholder": "firstname@company.com",
"admin_users_name_placeholder": "First and last name",
"admin_users_password_placeholder": "at least 8 characters",
"admin_departments_rename": "Rename",
"admin_departments_rename_failed": "Could not rename the department.",
"admin_departments_placeholder": "e.g. Maintenance",
"admin_llm_base_url_placeholder": "https://api.example.com/v1",
"admin_llm_model_placeholder": "model id",
"admin_template_name_placeholder": "Template name",
"admin_users_edit_title": "Edit user",
"admin_users_search_placeholder": "Search by name or email…",
"admin_pager_status": "Page {page} of {pages}, {total} users",
"admin_users_empty": "No users found.",
"admin_department_edit_title": "Rename department",
"admin_users_create_title": "Create a new user",
"admin_users_new": "Create user",
"admin_departments_create_title": "Create a new department",
"admin_departments_new_button": "Create department",
"chat_error_start_conversation": "The conversation could not be started. Please try again.",
"chat_error_connection_lost": "Connection lost. Please try again.",
"capture_title": "Capture knowledge",
"capture_subtitle": "Pick a documentation type, then start writing. The model suggests more polished wording as you go.",
"capture_start_failed": "Could not create the document.",
"editor_save": "Save",
"editor_accept": "Accept",
"editor_dismiss": "Dismiss",
"editor_grounding_label": "Grounding",
"editor_suggestion_title": "Suggestion",
"admin_catalog_sections": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "1 section",
"countPlural=other": "{count} sections"
}
}
],
"capture_matches_title": "Matches your conversation",
"capture_templates_title": "Document something new",
"capture_success_title": "Your knowledge is documented.",
"capture_success_body": "View it, or hand it to someone with access for review.",
"capture_success_view": "View it",
"documents_export": "Export",
"llm_error_unreachable": "The language model cannot be reached. Check that the endpoint is running, or try again later.",
"llm_error_busy": "The language model is busy right now. Wait a moment and try again.",
"llm_error_misconfigured": "Access to the language model is not set up correctly. Please tell an administrator.",
"llm_error_failed": "The language model did not answer. Please try again.",
"llm_status_slow": "The model is taking longer than usual. The endpoint is probably busy.",
"error_generic": "Something went wrong. Please try again.",
"profile_capture_title": "Write down what you know",
"profile_capture_hint": "A short document about your role, your specialities and what people can ask you about. The template asks the questions, you answer in your own words.",
"profile_capture_cta": "Start a document about you",
"chat_fallback_note": "Without a model this was a plain full-text search. You see the matches directly and can open them yourself.",
"chat_fallback_empty": "The full-text search found nothing for your words. Try other terms, or ask again once the model is back.",
"admin_users_reset_title": "Reset password",
"error_email_taken": "That email address is already in use.",
"error_name_taken": "That name already exists.",
"error_self_modification": "You cannot change that on your own account.",
"error_department_in_use": "This department is still in use. Deleting it drops the access its grants gave.",
"profile_document_title": "Your document",
"profile_document_hint": "This is your document about yourself. You can keep writing it whenever you like.",
"profile_document_open": "View",
"profile_document_edit": "Edit",
"documents_filter_my_reviews": "Waiting for my check",
"documents_badge_open_reviews": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Question open",
"countPlural=other": "{count} questions open"
}
}
],
"document_publish": "Publish",
"document_draft_title": "Still a draft",
"document_draft_hint": "Only you can see this text. Published, it becomes visible to everyone with access and findable in chat.",
"document_draft_hint_reviewer": "You were asked to check this draft. Apart from you, only the person writing it can see it.",
"document_review_ask": "Ask for a check",
"document_review_asked_by": "{name} asks:",
"document_review_asked_plain": "{name} asked for a check.",
"document_review_waiting_on": "Waiting for {name}, asked on {date}",
"document_review_confirm": "That's correct",
"document_review_fix": "Fix it",
"document_review_close": "Close question",
"document_review_answered": "Checked by {name} on {date}",
"document_review_failed": "That did not work. Please try again.",
"review_ask_hint": "Pick someone who can judge it and say what it is about. Until they answer, the document is marked as unchecked everywhere.",
"review_ask_reviewer": "Who should check it?",
"review_ask_question": "What is it about? (optional)",
"review_ask_question_placeholder": "e.g. Are the 14 holiday days still right?",
"review_ask_send": "Send question",
"review_ask_sent": "{name} has been asked.",
"review_ask_none": "Nobody else has access to this document.",
"review_ask_failed": "The request did not go through. Please try again.",
"editor_save_title": "Save changes",
"editor_save_hint": "This is what you changed since you last saved.",
"editor_save_and_publish": "Save and publish",
"editor_no_changes": "Nothing has changed since the last save.",
"editor_unsaved": "Not saved",
"capture_success_ask": "Have it checked",
"landing_drafts_title": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "One draft of yours",
"countPlural=other": "{count} drafts of yours"
}
}
],
"landing_drafts_hint": "Not published yet, nobody else can find them.",
"landing_drafts_publish": "Publish",
"landing_drafts_all": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "See all",
"countPlural=other": "See all {count}"
}
}
],
"chat_source_review_pending": "There is an open question about this document. The content may be out of date.",
"panel_missing": "This document no longer exists, or you do not have access to it.",
"panel_loading": "Loading…",
"common_back": "Back",
"documents_visibility_public": "public",
"documents_visibility_department": "department",
"documents_visibility_restricted": "restricted",
"history_action_published": "Published",
"history_action_review_requested": "Asked for a check",
"history_action_review_resolved": "Checked",
"chat_source_sections": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "1 section",
"countPlural=other": "{count} sections"
}
}
],
"visibility_label": "Visible to",
"visibility_save_failed": "The visibility could not be changed.",
"editor_title_label": "Title",
"editor_title_suggest": "Suggest a title",
"editor_title_suggest_failed": "No suggestion came back. Please try again.",
"document_review_thanks_title": "Thanks, checked.",
"document_review_thanks_body": "The draft belongs to the person writing it again. Once they publish it, you will find it through search.",
"documents_access_label_review": "For your check",
"documents_access_hint_review": "You can see this because you were asked to check it. Your answer ends the access.",
"common_more": "More",
"document_draft_chip": "Draft",
"access_popover_title": "Who can see this?",
"access_extra_departments": "Also shared with",
"access_no_extra_departments": "Not shared with any other department.",
"access_plus_departments": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "+1 department",
"countPlural=other": "+{count} departments"
}
}
],
"history_show_all": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Show one entry",
"countPlural=other": "Show all {count} entries"
}
}
],
"history_show_less": "Show less",
"documents_filters": "Filters",
"documents_access_filter_label": "Why visible:",
"documents_export_hint": "Download every readable document as a Markdown ZIP",
"admin_tab_people": "Users and departments",
"admin_page_subtitle": "Who works here, what they write with, and what the language model runs on.",
"people_search_placeholder": "Name or department …",
"people_none_found": "Nobody found.",
"profile_my_documents": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "One document you wrote",
"countPlural=other": "{count} documents you wrote"
}
}
],
"chat_empty_hint": "Answers come out of your own documents and name the passages they lean on.",
"chat_sources_label": "Sources:",
"editor_back": "Back",
"admin_prompt_hint_query_system": "How the assistant answers in chat, and how it treats the passages it found.",
"admin_prompt_hint_query_no_sources": "What the assistant says when the search finds nothing solid.",
"admin_prompt_hint_refine_persona": "Who writes along in the editor: the role and tone of a suggestion.",
"admin_prompt_hint_refine_rules": "The rules for a suggestion: what it may do, and what it must not invent.",
"admin_prompt_hint_grounding_framing": "How already documented knowledge is framed inside a suggestion.",
"admin_prompt_hint_topic_summary": "Sums a conversation up in one sentence, to find matching documents.",
"admin_prompt_hint_title": "Suggests a title from a document's content.",
"editor_suggestions_paused": "Suggestions paused.",
"editor_suggestions_retry": "Try again now",
"editor_saved_at": "Saved at {time}",
"editor_untouched_draft": "New draft. Leave without writing anything and it is discarded.",
"editor_draft_exists": "Draft, visible only to you."
}
+5163
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "npm run messages && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "npm run messages && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "npm run messages && npm run lint:files",
"lint:files": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:e2e": "playwright install chromium && playwright test",
"test": "npm run test:e2e",
"messages": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide --strategy custom-userPreference cookie preferredLanguage baseLocale"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@inlang/paraglide-js": "^2.22.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"openapi-typescript": "^7.13.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16"
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/language": "^6.12.4",
"@codemirror/merge": "^6.12.2",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0",
"bits-ui": "^2.18.1",
"dompurify": "^3.4.12",
"katex": "^0.18.1",
"marked": "^18.0.6",
"marked-katex-extension": "^5.1.10",
"openapi-fetch": "^0.17.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from '@playwright/test';
// E2E runs against the dev stack: postgres + backend must be up and seeded
// (make dev / make seed); the vite dev server is reused or started here.
export default defineConfig({
testDir: 'e2e',
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
// One worker: every spec drives the same seeded user against one local
// LLM, so parallel specs delete each other's in-flight conversations
// (and queue behind the same model anyway).
workers: 1,
use: {
baseURL: 'http://localhost:5173',
// The specs assert English copy, so the language the interface picks
// has to be pinned rather than inherited from whatever the machine
// running the suite happens to send. Accept-Language is the strategy
// that applies to a visitor with no account preference and no cookie.
// A seeded user who HAS picked a language keeps it (that is the product
// rule), so anything that runs as such a user selects by testid.
locale: 'en-US',
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' }
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true
}
});
+12
View File
@@ -0,0 +1,12 @@
/** @type {import("prettier").Config} */
const config = {
useTabs: true,
singleQuote: true,
trailingComma: 'none',
printWidth: 100,
plugins: ['prettier-plugin-svelte', 'prettier-plugin-tailwindcss'],
overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }],
tailwindStylesheet: './src/app.css'
};
export default config;
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "de",
"locales": ["de", "en"],
"modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js"],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Gate on the two message rules that cannot be caught at runtime.
1. **Every locale carries every key.** Paraglide falls back to the base
locale for a missing message, which means an untranslated string ships
silently as German inside an English interface. A missing key is a build
failure here instead.
2. **No em or en dashes in UI copy.** They are hard to type, inconsistent
across the app when hand-written, and in German they collide with the
Gedankenstrich convention. Commas, colons or a second sentence do the
job. Prose in docs/ and comments is unaffected: this only reads the
message files.
Run by `make lint`, so both rules hold for every route the migration
touches rather than only where someone remembered.
"""
import json
import sys
from pathlib import Path
MESSAGES = Path(__file__).resolve().parents[1] / "messages"
BASE_LOCALE = "de"
DASHES = {"": "em dash", "": "en dash"}
def load(path: Path) -> dict[str, object]:
data = json.loads(path.read_text(encoding="utf-8"))
return {key: value for key, value in data.items() if not key.startswith("$")}
def strings_in(value: object) -> list[str]:
"""Every translatable string inside a message.
A message is either a plain string or a list of variants, each with a
`match` object mapping a selector to a string (see docs/i18n.md,
pluralization). Declarations and selectors are machinery, not copy, so
only the match values are checked for dashes.
"""
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [
text
for variant in value
if isinstance(variant, dict)
for text in variant.get("match", {}).values()
if isinstance(text, str)
]
return []
def main() -> int:
files = sorted(MESSAGES.glob("*.json"))
if not files:
print(f"check-messages: no message files in {MESSAGES}", file=sys.stderr)
return 1
catalogs = {path.stem: load(path) for path in files}
if BASE_LOCALE not in catalogs:
print(f"check-messages: missing base locale {BASE_LOCALE}.json", file=sys.stderr)
return 1
problems: list[str] = []
base_keys = set(catalogs[BASE_LOCALE])
for locale, catalog in sorted(catalogs.items()):
if locale == BASE_LOCALE:
continue
for key in sorted(base_keys - set(catalog)):
problems.append(
f"{locale}.json: missing message '{key}' "
f"(present in {BASE_LOCALE}.json): a missing translation "
f"would silently ship as {BASE_LOCALE}"
)
for key in sorted(set(catalog) - base_keys):
problems.append(
f"{locale}.json: message '{key}' has no counterpart in "
f"{BASE_LOCALE}.json: the source language defines the set"
)
for locale, catalog in sorted(catalogs.items()):
for key, value in sorted(catalog.items()):
for char, name in DASHES.items():
if any(char in text for text in strings_in(value)):
problems.append(
f"{locale}.json: message '{key}' contains an {name} "
f"({char}): use a comma, a colon, or two sentences"
)
if problems:
print("check-messages: FAILED", file=sys.stderr)
for problem in problems:
print(f" {problem}", file=sys.stderr)
return 1
total = len(base_keys)
locales = ", ".join(sorted(catalogs))
print(f"check-messages: {total} messages complete in {locales}, no dashes")
return 0
if __name__ == "__main__":
sys.exit(main())
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""WCAG AA gate for the design tokens — runs as part of `make lint`.
Parses the light-dark() token definitions straight out of
frontend/src/app.css, so palette edits are validated without keeping a
copy of the values in sync here. Text pairings must reach 4.5:1, the
focus ring 3:1, in BOTH modes. Exits non-zero on any violation.
"""
import math
import re
import sys
from pathlib import Path
APP_CSS = Path(__file__).resolve().parents[1] / "src" / "app.css"
# Short names used in the pair lists → --pb-* token names.
ALIASES = {
"raised": "surface-raised",
"sunken": "surface-sunken",
}
# (foreground, background) — needs >= 4.5
TEXT_PAIRS = [
("ink", "surface"),
("ink", "raised"),
("ink", "sunken"),
("ink-muted", "surface"),
("ink-muted", "raised"),
("ink-muted", "sunken"),
("primary-fg", "primary"),
("primary-fg", "primary-hover"),
("secondary-fg", "secondary"),
("secondary-fg", "secondary-hover"),
("accent-fg", "accent"),
("accent-fg", "accent-hover"),
("secondary", "surface"),
("secondary", "sunken"),
# primary is a button surface (warm near-black), never body text, so it is
# only checked as a background — see the primary-fg pairings above.
("success", "surface"),
("warning", "surface"),
("danger", "surface"),
("success", "success-muted"),
("warning", "warning-muted"),
("danger", "danger-muted"),
("success-fg", "success"),
("warning-fg", "warning"),
("danger-fg", "danger"),
("danger-fg", "danger-hover"),
]
# Focus indicator vs adjacent surface — needs >= 3.0
RING_PAIRS = [("secondary", "surface"), ("secondary", "raised")]
COLOR = r"(#[0-9a-fA-F]{6}|oklch\([^)]*\))"
def parse_palettes() -> tuple[dict[str, str], dict[str, str]]:
css = APP_CSS.read_text()
light: dict[str, str] = {}
dark: dict[str, str] = {}
pattern = rf"--pb-([a-z-]+):\s*light-dark\(\s*{COLOR}\s*,\s*{COLOR}\s*\)"
for name, light_value, dark_value in re.findall(pattern, css):
light[name] = light_value
dark[name] = dark_value
return light, dark
def _hex_luminance(hex_color: str) -> float:
hex_color = hex_color.lstrip("#")
r, g, b = (int(hex_color[i : i + 2], 16) / 255 for i in (0, 2, 4))
def linear(channel: float) -> float:
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
def _oklch_luminance(value: str) -> float:
"""oklch(L C H) → relative luminance.
Goes OKLab → LMS → linear sRGB, which is already the space WCAG's
luminance formula wants, so no gamma round-trip is needed. Out-of-gamut
channels are clamped, the same way a browser renders them.
"""
body = value[value.index("(") + 1 : value.rindex(")")]
parts = body.replace("/", " ").split()
lightness = float(parts[0].rstrip("%")) / (100 if "%" in parts[0] else 1)
chroma = float(parts[1])
hue = math.radians(float(parts[2]))
a = chroma * math.cos(hue)
b = chroma * math.sin(hue)
l_ = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
m_ = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
s_ = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3
red = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
green = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
blue = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
red, green, blue = (min(1.0, max(0.0, channel)) for channel in (red, green, blue))
return 0.2126 * red + 0.7152 * green + 0.0722 * blue
def luminance(color: str) -> float:
if color.startswith("oklch"):
return _oklch_luminance(color)
return _hex_luminance(color)
def ratio(a: str, b: str) -> float:
la, lb = luminance(a), luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def main() -> int:
light, dark = parse_palettes()
if not light:
print(f"contrast-check: no light-dark() tokens found in {APP_CSS}")
return 1
failures = 0
checked = 0
for mode, palette in (("light", light), ("dark", dark)):
for pairs, minimum, kind in (
(TEXT_PAIRS, 4.5, "text"),
(RING_PAIRS, 3.0, "ring"),
):
for fg, bg in pairs:
fg_hex = palette[ALIASES.get(fg, fg)]
bg_hex = palette[ALIASES.get(bg, bg)]
r = ratio(fg_hex, bg_hex)
checked += 1
if r < minimum:
failures += 1
print(f"FAIL [{mode}] {fg} on {bg}: {r:.2f} < {minimum} ({kind})")
if failures:
print(f"contrast-check: {failures} of {checked} pairings violate WCAG AA")
return 1
print(f"contrast-check: {checked} token pairings pass WCAG AA")
return 0
if __name__ == "__main__":
sys.exit(main())
+193
View File
@@ -0,0 +1,193 @@
@import 'tailwindcss';
/*
* Design tokens — the ONLY place colors are defined (see CLAUDE.md).
* Every value carries light and dark via light-dark(); the active mode
* follows the OS preference and can be forced with data-theme on <html>.
* All pairings are WCAG-AA-checked by contrast-check.py in make lint.
*/
:root {
/* Dark is the product's default look — near-black with a warm cast, so the
* yellow/orange/red ramp sits on it without glaring; light stays fully
* supported and is selectable via data-theme. */
color-scheme: dark;
/* Pablan brand palette — a warm ramp: yellow (accent), orange (secondary),
* red (danger, and the end of the brand gradient). Dark values are authored
* in oklch, light mode keeps darkened variants of the same hues so text
* pairings still reach AA on white — a light-mode yellow reads as amber by
* necessity, not by accident. */
/* primary is the QUIET button surface — neutral, not a loud fill. The
* brand's saturation is spent only on links (secondary) and CTAs
* (accent); chrome stays a warm black and white. */
--pb-primary: light-dark(#1f1c18, oklch(24% 0.008 70));
--pb-primary-hover: light-dark(#2f2a24, oklch(30% 0.008 70));
--pb-primary-fg: light-dark(#ffffff, oklch(96% 0.005 90));
/* orange: links, interactive text, the focus ring */
--pb-secondary: light-dark(#b04a00, oklch(74% 0.165 55));
--pb-secondary-hover: light-dark(#8f3b00, oklch(80% 0.14 55));
--pb-secondary-fg: light-dark(#ffffff, oklch(15% 0.05 55));
/* yellow: highlights, active states, CTAs — never body text or large surfaces */
--pb-accent: light-dark(#b8770a, oklch(89% 0.185 95));
--pb-accent-hover: light-dark(#c9840f, oklch(93% 0.16 95));
--pb-accent-fg: light-dark(#1a1200, oklch(16% 0.06 95));
/* surfaces — near-neutral with a faint warm cast, so the yellow/orange
* accents are the only real colour on screen */
--pb-surface: light-dark(#fafaf9, oklch(7% 0.006 70));
--pb-surface-raised: light-dark(#ffffff, oklch(14% 0.006 70));
/* the recessed/interactive tint: darker on white, lifted on black */
--pb-surface-sunken: light-dark(#f2f0ed, oklch(19% 0.008 70));
/* text */
--pb-ink: light-dark(#1a1815, oklch(95% 0.005 90));
--pb-ink-muted: light-dark(#63605a, oklch(70% 0.01 80));
/* borders */
--pb-border: light-dark(#e5e1db, oklch(22% 0.008 70));
--pb-border-strong: light-dark(#c5bfb6, oklch(32% 0.01 70));
/* states — success is the one deliberately cool colour: the brand ramp is
* entirely warm, so green is the only thing left that reads as "good" at a
* glance. Warning borrows the family amber, danger IS the brand red. */
--pb-success: light-dark(#16793e, oklch(78% 0.16 150));
--pb-success-fg: light-dark(#ffffff, oklch(14% 0.05 150));
--pb-success-muted: light-dark(#e3f6ea, oklch(24% 0.07 150));
--pb-warning: light-dark(#8a6400, oklch(78% 0.14 75));
--pb-warning-fg: light-dark(#ffffff, oklch(14% 0.05 75));
--pb-warning-muted: light-dark(#faf0d3, oklch(24% 0.07 75));
--pb-danger: light-dark(#c0261b, oklch(68% 0.2 27));
--pb-danger-hover: light-dark(#a11f15, oklch(75% 0.17 27));
--pb-danger-fg: light-dark(#ffffff, oklch(14% 0.06 27));
--pb-danger-muted: light-dark(#fbe9e7, oklch(24% 0.09 27));
}
:root[data-theme='light'] {
color-scheme: light;
}
:root[data-theme='dark'] {
color-scheme: dark;
}
/* Drop Tailwind's default palette: only semantic tokens compile to utilities,
* so a stray `bg-red-500` fails the build instead of shipping. */
@theme {
--color-*: initial;
}
@theme inline {
--color-primary: var(--pb-primary);
--color-primary-hover: var(--pb-primary-hover);
--color-primary-fg: var(--pb-primary-fg);
--color-secondary: var(--pb-secondary);
--color-secondary-hover: var(--pb-secondary-hover);
--color-secondary-fg: var(--pb-secondary-fg);
--color-accent: var(--pb-accent);
--color-accent-hover: var(--pb-accent-hover);
--color-accent-fg: var(--pb-accent-fg);
--color-surface: var(--pb-surface);
--color-surface-raised: var(--pb-surface-raised);
--color-surface-sunken: var(--pb-surface-sunken);
--color-ink: var(--pb-ink);
--color-ink-muted: var(--pb-ink-muted);
--color-border: var(--pb-border);
--color-border-strong: var(--pb-border-strong);
--color-success: var(--pb-success);
--color-success-fg: var(--pb-success-fg);
--color-success-muted: var(--pb-success-muted);
--color-warning: var(--pb-warning);
--color-warning-fg: var(--pb-warning-fg);
--color-warning-muted: var(--pb-warning-muted);
--color-danger: var(--pb-danger);
--color-danger-hover: var(--pb-danger-hover);
--color-danger-fg: var(--pb-danger-fg);
--color-danger-muted: var(--pb-danger-muted);
--color-ring: var(--pb-secondary);
/* Dropping the default palette removed these keywords too. */
--color-transparent: transparent;
--color-current: currentColor;
}
body {
background-color: var(--pb-surface);
color: var(--pb-ink);
}
/*
* Brand treatments — the warm sweep of the wordmark and the glow behind hero
* elements. Defined once here so components never name colors (CLAUDE.md
* design tokens).
*/
.brand-gradient-text {
/* The brand ramp: yellow → orange → red. In light mode the tokens are
* already the darkened variants, so the wordmark stays legible on white. */
background-image: linear-gradient(
135deg,
var(--pb-accent) 0%,
var(--pb-secondary) 55%,
var(--pb-danger) 100%
);
background-clip: text;
color: transparent;
}
/* Soft radial brand light behind a hero. The element needs `relative`. */
.brand-glow {
position: relative;
}
.brand-glow::before {
content: '';
position: absolute;
inset: -30% -15% 30% -15%;
background:
radial-gradient(
45% 55% at 30% 40%,
color-mix(in oklab, var(--pb-secondary) 22%, transparent),
transparent 70%
),
radial-gradient(
45% 55% at 70% 45%,
color-mix(in oklab, var(--pb-accent) 18%, transparent),
transparent 70%
);
filter: blur(60px);
pointer-events: none;
z-index: 0;
}
.brand-glow > * {
position: relative;
z-index: 1;
}
/* CTA lights — a warm halo under the primary call to action. */
.glow-accent {
box-shadow: 0 0 40px color-mix(in oklab, var(--pb-accent) 35%, transparent);
}
.glow-accent:hover {
box-shadow: 0 0 48px color-mix(in oklab, var(--pb-accent) 50%, transparent);
}
.glow-secondary {
box-shadow: 0 0 40px color-mix(in oklab, var(--pb-secondary) 35%, transparent);
}
/*
* Sidebar collapse.
*
* Driven by data-sidebar on <html>, set by the pre-paint boot script in
* app.html — NOT by {#if collapsed} in the component. Svelte only knows the
* stored value once it hydrates, so a conditional render showed the expanded
* sidebar for the first frames of every reload. CSS applies to the
* server-rendered markup immediately.
*/
.sidebar {
width: 15rem;
}
:root[data-sidebar='collapsed'] .sidebar {
width: 3.5rem;
}
:root[data-sidebar='collapsed'] .sidebar-label {
display: none;
}
:root[data-sidebar='collapsed'] .sidebar-row {
justify-content: center;
}
+17
View File
@@ -0,0 +1,17 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { components } from '$lib/api/schema';
declare global {
namespace App {
// interface Error {}
interface Locals {
user: components['schemas']['UserOut'] | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="%lang%" dir="%dir%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<script>
// Apply stored per-device UI state before first paint. Inline and
// synchronous on purpose: any later — including hydration — and the
// page visibly flashes the other state. Everything switched from
// here is styled by CSS off these attributes, never by {#if}, so
// the server-rendered markup is already correct on frame one.
try {
var t = localStorage.getItem('pablan.theme');
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
if (localStorage.getItem('pablan.sidebar.collapsed') === 'true')
document.documentElement.setAttribute('data-sidebar', 'collapsed');
} catch (e) {
// Private mode without storage: the defaults apply.
}
</script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
import { apiFetch } from '$lib/server/api';
import { rememberRequestLocale } from '$lib/i18n/strategy.server';
import { paraglideMiddleware } from '$lib/paraglide/server';
import { getTextDirection } from '$lib/paraglide/runtime';
// The only auth logic in the frontend: forward the session cookie to the
// backend and expose the result as locals.user.
const auth: Handle = async ({ event, resolve }) => {
event.locals.user = null;
if (event.cookies.get('pablan_session')) {
try {
const response = await apiFetch(event.fetch, event.cookies, '/api/auth/me');
if (response.ok) {
event.locals.user = await response.json();
}
} catch {
// Backend unreachable: treat as logged out instead of failing the page.
event.locals.user = null;
}
}
// Hand the account's language to the custom locale strategy, which only
// sees the request. Runs before the i18n handle, hence the order below.
rememberRequestLocale(event.request, event.locals.user?.locale ?? null);
return resolve(event);
};
// Resolves the locale through the configured strategy chain and stamps the
// result into the document, so the very first server-rendered byte already
// carries the right language.
const i18n: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
event.request = localizedRequest;
return resolve(event, {
transformPageChunk: ({ html }) =>
html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale))
});
});
// auth first: it is what tells the locale strategy which user is asking.
export const handle: Handle = sequence(auth, i18n);
+11
View File
@@ -0,0 +1,11 @@
import type { Reroute } from '@sveltejs/kit';
import { deLocalizeUrl } from '$lib/paraglide/runtime';
/** Strip any locale prefix before the router matches a route.
*
* Pablan does not use the `url` strategy, so today this is a passthrough.
* It stays because it is the hook that has to exist the moment localized
* paths are ever turned on, and discovering that later means debugging
* every route at once.
*/
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
@@ -0,0 +1,203 @@
<script lang="ts">
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Building2 from '@lucide/svelte/icons/building-2';
import Plus from '@lucide/svelte/icons/plus';
import { api } from '$lib/api/client';
import { apiErrorCode, errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import IconAction from '$lib/components/IconAction.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormField from '$lib/components/FormField.svelte';
import Input from '$lib/components/Input.svelte';
import { m } from '$lib/paraglide/messages';
type Department = components['schemas']['DepartmentOut'];
// The page owns the list because the user manager reads it too; every
// change here reports back so both stay on the same set.
let {
departments,
onChanged
}: { departments: Department[]; onChanged: () => Promise<void> | void } = $props();
let error = $state<string | null>(null);
let creating = $state(false);
let editing = $state<Department | null>(null);
let draft = $state('');
let confirmRequest = $state<{ title: string; message: string; run: () => void } | null>(null);
function startCreate() {
error = null;
draft = '';
creating = true;
}
function startEdit(department: Department) {
error = null;
draft = department.name;
editing = department;
}
async function create(event: SubmitEvent) {
event.preventDefault();
error = null;
const { data, error: apiError } = await api.POST('/api/admin/departments', {
body: { name: draft }
});
if (!data) {
error = errorMessage(apiErrorCode(apiError), m.admin_departments_create_failed());
return;
}
creating = false;
await onChanged();
}
async function save(department: Department) {
error = null;
const { error: apiError } = await api.PATCH('/api/admin/departments/{department_id}', {
params: { path: { department_id: department.id } },
body: { name: draft }
});
if (apiError) {
error = errorMessage(apiErrorCode(apiError), m.admin_departments_rename_failed());
return;
}
editing = null;
await onChanged();
}
function remove(department: Department) {
confirmRequest = {
title: m.common_delete(),
message: m.admin_departments_delete_confirm({ name: department.name }),
run: async () => {
// The modal is the admin's acknowledgement, so pass confirm — the
// backend otherwise refuses to silently drop a department still in
// use (its shared-access grants CASCADE away).
await api.DELETE('/api/admin/departments/{department_id}', {
params: { path: { department_id: department.id }, query: { confirm: true } }
});
await onChanged();
}
};
}
</script>
<Card>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 class="flex items-center gap-2 text-lg font-semibold">
<Building2 size={18} class="text-ink-muted" />
{m.admin_departments_title()}
</h2>
<Button size="sm" onclick={startCreate} data-testid="new-department">
<Plus size={15} />
{m.admin_departments_new_button()}
</Button>
</div>
{#if error && !creating && !editing}
<p role="alert" class="mb-2 text-sm text-danger">{error}</p>
{/if}
<ul class="flex flex-col gap-1" data-testid="department-list">
{#each departments as department (department.id)}
<li
class="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-surface-sunken"
>
<span class="text-sm">{department.name}</span>
<span class="inline-flex items-center gap-1 whitespace-nowrap">
<IconAction
label={m.admin_departments_rename()}
icon={Pencil}
size="sm"
onclick={() => startEdit(department)}
testid="rename-department"
/>
<IconAction
label={m.admin_users_delete()}
icon={Trash2}
size="sm"
variant="danger"
onclick={() => remove(department)}
/>
</span>
</li>
{/each}
</ul>
</Card>
<Dialog
open={creating}
onOpenChange={(open) => {
if (!open) creating = false;
}}
title={m.admin_departments_create_title()}
data-testid="create-department-dialog"
>
<form class="flex flex-col gap-3" onsubmit={create}>
<FormField label={m.admin_departments_new()} for="new-department-name">
<Input
id="new-department-name"
required
placeholder={m.admin_departments_placeholder()}
bind:value={draft}
/>
</FormField>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button type="submit" size="sm" data-testid="create-department">
{m.admin_departments_create()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (creating = false)}>
{m.common_cancel()}
</Button>
</div>
</form>
</Dialog>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) editing = null;
}}
title={m.admin_department_edit_title()}
data-testid="department-dialog"
>
{#if editing}
{@const department = editing}
<div class="flex flex-col gap-3">
<FormField label={m.admin_departments_title()} for="edit-department-name">
<Input
id="edit-department-name"
placeholder={m.admin_departments_placeholder()}
bind:value={draft}
/>
</FormField>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => save(department)} data-testid="save-department">
{m.admin_users_save()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (editing = null)}>
{m.common_cancel()}
</Button>
</div>
</div>
{/if}
</Dialog>
<ConfirmDialog
open={confirmRequest !== null}
title={confirmRequest?.title ?? ''}
message={confirmRequest?.message ?? ''}
onConfirm={() => confirmRequest?.run()}
onClose={() => (confirmRequest = null)}
/>
@@ -0,0 +1,59 @@
<script lang="ts">
import { api } from '$lib/api/client';
import { errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import { m } from '$lib/paraglide/messages';
// First-line support: ping all three model roles and show what came back.
// A failure gets both halves, because they answer different questions:
// what it means for the product (the phrased reason), and what to fix
// (the sanitized technical detail).
type Status = components['schemas']['LLMTestResponse'];
let status = $state<Status | null>(null);
let busy = $state(false);
async function test() {
busy = true;
const { data } = await api.POST('/api/admin/llm/test');
status = data ?? null;
busy = false;
}
</script>
<div class="mt-4 border-t border-border pt-4">
<Button size="sm" onclick={test} disabled={busy} data-testid="llm-test">
{busy ? m.admin_llm_testing() : m.admin_llm_test()}
</Button>
{#if status}
<ul class="mt-3 flex flex-col gap-1.5 text-sm" data-testid="llm-results">
{#each status.roles as role (role.role)}
<li class="flex flex-wrap items-center gap-2">
<span
class="rounded-md px-2 py-0.5 text-xs font-medium {role.ok
? 'bg-success-muted text-success'
: 'bg-danger-muted text-danger'}"
>
{role.ok ? 'ok' : 'error'}
</span>
<span class="font-medium">{role.role}</span>
<span class="text-ink-muted">
{role.model} · {role.base_url}
{#if role.latency_ms !== null}
· {role.latency_ms} ms
{/if}
</span>
{#if !role.ok}
<span class="w-full text-xs text-ink-muted">
{errorMessage(role.code)}
{#if role.error}
<code class="ml-1">{role.error}</code>
{/if}
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Search from '@lucide/svelte/icons/search';
import { api } from '$lib/api/client';
import { errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import Badge from '$lib/components/Badge.svelte';
import Button from '$lib/components/Button.svelte';
import FormField from '$lib/components/FormField.svelte';
import Input from '$lib/components/Input.svelte';
import Select from '$lib/components/Select.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { m } from '$lib/paraglide/messages';
type Setting = components['schemas']['LLMSettingOut'];
type Role = Setting['role'];
type Status = components['schemas']['LLMRoleStatus'];
type Draft = { base_url: string; model: string; api_key: string };
type Discovery = { models: string[]; supported: boolean; loading: boolean };
let settings = $state<Setting[]>([]);
// Per-role form state, seeded with the stored values so the fields show
// what is actually configured rather than an empty box over a placeholder.
let draft = $state<Record<string, Draft>>({});
let tested = $state<Record<string, Status | undefined>>({});
let discovered = $state<Record<string, Discovery | undefined>>({});
let busy = $state<string | null>(null);
let error = $state<string | null>(null);
async function refresh() {
const { data } = await api.GET('/api/admin/llm/settings');
settings = data ?? [];
for (const setting of settings) {
draft[setting.role] = {
base_url: setting.base_url,
model: setting.model,
// Never round-trips: the key is write-only, so the field stays
// empty and an empty field means "keep the stored one".
api_key: ''
};
}
}
$effect(() => void refresh());
function candidate(role: Role) {
const form = draft[role];
return {
role,
base_url: form.base_url || undefined,
model: form.model || undefined,
api_key: form.api_key || undefined
};
}
/** Validate before storing: a wrong endpoint should fail here, not on
* the next user question. */
async function test(role: Role) {
busy = role;
error = null;
const { data } = await api.POST('/api/admin/llm/test', { body: candidate(role) });
tested[role] = data?.roles?.[0];
busy = null;
}
/** Ask the endpoint what it serves. Runs server-side, so the key never
* leaves the backend. */
async function discover(role: Role) {
const form = draft[role];
discovered[role] = { models: [], supported: true, loading: true };
const { data } = await api.POST('/api/admin/llm/models/{role}', {
params: { path: { role } },
body: { base_url: form.base_url || undefined, api_key: form.api_key || undefined }
});
discovered[role] = {
models: data?.models ?? [],
supported: data?.supported ?? false,
loading: false
};
}
async function save(role: Role) {
busy = role;
error = null;
const form = draft[role];
const stored = settings.find((setting) => setting.role === role);
// Only send what actually changed. Submitting every field would mark
// the untouched ones as hand-edited, so fixing a URL would quietly
// claim the model no longer comes from .env.
const { error: failed } = await api.PUT('/api/admin/llm/settings/{role}', {
params: { path: { role } },
body: {
base_url: form.base_url === stored?.base_url ? undefined : form.base_url,
model: form.model === stored?.model ? undefined : form.model,
// Empty means "keep the stored key", not "delete it".
api_key: form.api_key || undefined
}
});
busy = null;
if (failed) {
error = m.admin_llm_save_failed();
return;
}
tested[role] = undefined;
await refresh();
}
/** Put one field back to what .env says. Per field, because an admin who
* fixed the model has no reason to lose their URL. */
async function reset(role: Role, field: 'base_url' | 'model' | 'api_key') {
busy = role;
await api.PUT('/api/admin/llm/settings/{role}', {
params: { path: { role } },
body: { [`reset_${field}`]: true }
});
busy = null;
tested[role] = undefined;
await refresh();
}
function sourceLabel(fromEnv: boolean): string {
return fromEnv ? m.admin_llm_source_env() : m.admin_llm_source_ui();
}
</script>
{#snippet source(setting: Setting, field: 'base_url' | 'model' | 'api_key', fromEnv: boolean)}
<span class="flex items-center gap-1 text-xs font-normal text-ink-muted">
{sourceLabel(fromEnv)}
{#if !fromEnv}
<!-- Long sentence as the tooltip, short name as the accessible
label: a screen reader announcing a whole sentence for a small
icon button is noise. -->
<Tooltip
text={m.admin_llm_reset_field()}
label={m.admin_llm_reset_field_short()}
onclick={() => reset(setting.role, field)}
>
<span class="text-ink-muted transition-colors hover:text-ink">
<RotateCcw size={12} />
</span>
</Tooltip>
{/if}
</span>
{/snippet}
<div class="flex flex-col gap-4" data-testid="llm-settings">
<p class="text-xs text-ink-muted">
{m.admin_llm_intro()}
</p>
{#each settings as setting (setting.role)}
<div class="rounded-xl border border-border p-4">
<div class="mb-3 flex flex-wrap items-center gap-2">
<span class="font-medium">{setting.role}</span>
{#if tested[setting.role]}
<Badge variant={tested[setting.role]?.ok ? 'success' : 'danger'}>
{tested[setting.role]?.ok
? `ok · ${tested[setting.role]?.latency_ms} ms`
: m.admin_llm_endpoint_failed()}
</Badge>
{#if !tested[setting.role]?.ok}
<!-- Why it failed, in words, plus the sanitized technical detail
the admin needs to fix it. -->
<span class="text-xs text-ink-muted">
{errorMessage(tested[setting.role]?.code)}
{#if tested[setting.role]?.error}
<code class="ml-1">{tested[setting.role]?.error}</code>
{/if}
</span>
{/if}
{/if}
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="min-w-0">
<FormField label={m.admin_llm_base_url()} for="{setting.role}-url">
{#snippet hint()}{@render source(
setting,
'base_url',
setting.base_url_from_env
)}{/snippet}
<Input
id="{setting.role}-url"
placeholder={m.admin_llm_base_url_placeholder()}
bind:value={draft[setting.role].base_url}
/>
</FormField>
</div>
<div class="min-w-0">
<FormField label={m.admin_llm_model()} for="{setting.role}-model">
{#snippet hint()}{@render source(setting, 'model', setting.model_from_env)}{/snippet}
<div class="flex items-center gap-1">
{#if discovered[setting.role]?.models?.length}
<!-- A dropdown once we know what the endpoint serves; the
free-text field stays reachable via "Enter manually". -->
<Select
id="{setting.role}-model"
class="min-w-0 flex-1"
bind:value={draft[setting.role].model}
data-testid="model-select-{setting.role}"
>
{#each discovered[setting.role]?.models ?? [] as name (name)}
<option value={name}>{name}</option>
{/each}
</Select>
<Button
variant="ghost"
size="sm"
class="shrink-0 whitespace-nowrap"
onclick={() => (discovered[setting.role] = undefined)}
>
{m.admin_llm_enter_manually()}
</Button>
{:else}
<div class="min-w-0 flex-1">
<Input
id="{setting.role}-model"
placeholder={m.admin_llm_model_placeholder()}
bind:value={draft[setting.role].model}
/>
</div>
<Button
variant="ghost"
size="sm"
class="shrink-0 whitespace-nowrap"
disabled={discovered[setting.role]?.loading}
onclick={() => discover(setting.role)}
data-testid="discover-{setting.role}"
>
<Search size={14} />
{discovered[setting.role]?.loading
? m.admin_llm_checking()
: m.admin_llm_check_models()}
</Button>
{/if}
</div>
</FormField>
{#if discovered[setting.role] && !discovered[setting.role]?.loading && !discovered[setting.role]?.supported}
<p class="mt-1 text-xs text-ink-muted" data-testid="no-model-list-{setting.role}">
{m.admin_llm_no_model_list()}
</p>
{/if}
</div>
<div class="min-w-0 md:col-span-2">
<FormField label={m.admin_llm_api_key()} for="{setting.role}-key">
{#snippet hint()}{@render source(
setting,
'api_key',
setting.api_key_from_env
)}{/snippet}
<Input
id="{setting.role}-key"
type="password"
placeholder={setting.api_key_set ? '••••••••' : m.admin_llm_api_key_unset()}
bind:value={draft[setting.role].api_key}
/>
</FormField>
</div>
</div>
{#if tested[setting.role]?.error}
<p class="mt-2 text-xs text-danger">{tested[setting.role]?.error}</p>
{/if}
<div class="mt-3 flex flex-wrap gap-2">
<Button
variant="ghost"
size="sm"
disabled={busy !== null}
onclick={() => test(setting.role)}
data-testid="test-{setting.role}"
>
{busy === setting.role ? m.admin_llm_testing_role() : m.admin_llm_test_role()}
</Button>
<Button
size="sm"
disabled={busy !== null || !tested[setting.role]?.ok}
onclick={() => save(setting.role)}
data-testid="save-{setting.role}"
>
{m.admin_llm_save()}
</Button>
</div>
<p class="mt-2 text-xs text-ink-muted">{m.admin_llm_api_key_note()}</p>
</div>
{/each}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</div>
@@ -0,0 +1,155 @@
<script lang="ts">
import Check from '@lucide/svelte/icons/check';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import { m } from '$lib/paraglide/messages';
// Seven prompts in a column of identical grey boxes is a wall nobody reads.
// Each one is a card instead: what it is for, whether it still says what
// shipped, and — only when it has been touched — the buttons to keep or
// undo the change. The text itself is the widest thing on the page,
// monospaced and free to grow, because that is what is being edited.
type PromptSettingOut = components['schemas']['PromptSettingOut'];
let prompts = $state<PromptSettingOut[]>([]);
// The editable copy per prompt, plus per-prompt UI flags.
let draft = $state<Record<string, string>>({});
let busy = $state<Record<string, boolean>>({});
let saved = $state<Record<string, boolean>>({});
// Labels live in i18n keyed by prompt id — the backend never sends UI copy.
const labels = $derived<Record<string, string>>({
query_system: m.admin_prompt_query_system(),
query_no_sources: m.admin_prompt_query_no_sources(),
refine_persona: m.admin_prompt_refine_persona(),
refine_rules: m.admin_prompt_refine_rules(),
grounding_framing: m.admin_prompt_grounding_framing(),
topic_summary: m.admin_prompt_topic_summary(),
title: m.admin_prompt_title()
});
// What each prompt actually does, in one line — the label alone ("Chat:
// keine Treffer") does not tell an admin when it is used.
const hints = $derived<Record<string, string>>({
query_system: m.admin_prompt_hint_query_system(),
query_no_sources: m.admin_prompt_hint_query_no_sources(),
refine_persona: m.admin_prompt_hint_refine_persona(),
refine_rules: m.admin_prompt_hint_refine_rules(),
grounding_framing: m.admin_prompt_hint_grounding_framing(),
topic_summary: m.admin_prompt_hint_topic_summary(),
title: m.admin_prompt_hint_title()
});
function seed(list: PromptSettingOut[]) {
prompts = list;
draft = Object.fromEntries(list.map((prompt) => [prompt.key, prompt.content]));
}
async function refresh() {
const { data } = await api.GET('/api/admin/prompts');
if (data) seed(data);
}
$effect(() => {
void refresh();
});
function apply(updated: PromptSettingOut) {
prompts = prompts.map((prompt) => (prompt.key === updated.key ? updated : prompt));
}
async function save(key: string) {
busy[key] = true;
saved[key] = false;
const { data } = await api.PUT('/api/admin/prompts/{key}', {
params: { path: { key } },
body: { content: draft[key] }
});
busy[key] = false;
if (data) {
apply(data);
saved[key] = true;
}
}
async function reset(key: string) {
busy[key] = true;
saved[key] = false;
const { data } = await api.PUT('/api/admin/prompts/{key}', {
params: { path: { key } },
body: { reset: true }
});
busy[key] = false;
if (data) {
apply(data);
draft[key] = data.content;
}
}
</script>
<p class="mb-4 max-w-prose text-sm text-ink-muted">{m.admin_prompts_hint()}</p>
<div class="flex flex-col gap-3">
{#each prompts as prompt (prompt.key)}
{@const dirty = draft[prompt.key] !== prompt.content}
<div
class="rounded-xl border bg-surface-raised p-4 transition-colors {dirty
? 'border-accent/60'
: 'border-border'}"
data-testid="prompt-{prompt.key}"
>
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<div class="min-w-0">
<p class="font-medium">{labels[prompt.key] ?? prompt.key}</p>
<p class="mt-0.5 text-xs text-ink-muted">{hints[prompt.key] ?? ''}</p>
</div>
<!-- Only a prompt that no longer matches what shipped needs saying. -->
{#if !prompt.is_default}
<span class="shrink-0 text-xs text-secondary">{m.admin_prompt_changed()}</span>
{/if}
</div>
<textarea
bind:value={draft[prompt.key]}
rows="5"
spellcheck="false"
class="mt-3 w-full resize-y rounded-lg border border-border bg-surface p-3 font-mono text-xs leading-relaxed text-ink transition-colors focus:border-border-strong focus:outline-none"
></textarea>
<div class="mt-2 flex flex-wrap items-center gap-2">
{#if dirty}
<Button size="sm" onclick={() => save(prompt.key)} disabled={busy[prompt.key]}>
{m.admin_prompt_save()}
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => (draft[prompt.key] = prompt.content)}
disabled={busy[prompt.key]}
>
{m.common_cancel()}
</Button>
{:else if saved[prompt.key]}
<span class="flex items-center gap-1 text-xs text-success">
<Check size={13} />
{m.admin_prompt_saved()}
</span>
{/if}
{#if !prompt.is_default}
<Button
variant="ghost"
size="sm"
class="ml-auto"
onclick={() => reset(prompt.key)}
disabled={busy[prompt.key]}
>
<RotateCcw size={14} />
{m.admin_prompt_reset()}
</Button>
{/if}
</div>
</div>
{/each}
</div>
@@ -0,0 +1,235 @@
<script lang="ts">
import Code from '@lucide/svelte/icons/code';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import TemplateSections from '$lib/admin/TemplateSections.svelte';
import FormField from '$lib/components/FormField.svelte';
import Input from '$lib/components/Input.svelte';
import Select from '$lib/components/Select.svelte';
import { m } from '$lib/paraglide/messages';
import { untrack } from 'svelte';
type Detail = components['schemas']['TemplateDetail'];
type Config = components['schemas']['AuthoringTemplate'];
type Props = {
/** The template being edited, or null to build a new one. */
template: Detail | null;
/** Saved successfully — the caller refreshes and returns to the list. */
onSaved: (detail: Detail) => void;
onCancel: () => void;
/** Present only for an existing template: switch to the raw YAML editor.
* A new template has no serialized YAML yet, so it stays form-only. */
onShowYaml?: () => void;
};
let { template, onSaved, onCancel, onShowYaml }: Props = $props();
// The stored config IS an AuthoringTemplate; the detail types it loosely
// (JSONB), so we read it back through the generated shape. Read once to
// seed the form — the parent keys this component per target, so a fresh
// instance mounts whenever the edited template changes.
const cfg = untrack(() => template?.config) as Config | undefined;
let name = $state(cfg?.name ?? '');
let version = $state(cfg?.version ?? '1.0');
let description = $state(cfg?.description ?? '');
let persona = $state(cfg?.persona ?? '');
let titleTemplate = $state(cfg?.title_template ?? '{{user.name}} ({{date}})');
// '' stands in for null: "no fixed language".
let locale = $state<'de' | 'en' | ''>(cfg?.locale ?? '');
let visibility = $state<Config['metadata']['visibility']>(
cfg?.metadata?.visibility ?? 'department'
);
let temperature = $state(cfg?.model?.temperature ?? 0.4);
let minClass = $state(cfg?.model?.min_class_hint ?? '');
let sections = $state((cfg?.sections ?? []).map((s) => ({ heading: s.heading, hint: s.hint })));
let busy = $state(false);
let error = $state<string | null>(null);
let nameError = $state<string | null>(null);
let sectionError = $state<string | null>(null);
// The literal title placeholders, held as data so Svelte does not read the
// double braces as an expression.
const titleTokens = ['{{user.name}}', '{{date}}'];
function slugify(value: string): string {
const slug = value
.toLowerCase()
.replaceAll('ä', 'ae')
.replaceAll('ö', 'oe')
.replaceAll('ü', 'ue')
.replaceAll('ß', 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || 'vorlage';
}
// The skeleton is the document the editor opens with — one level-2 heading
// per section, in order. Deriving it here keeps the headings and the
// section hints from ever drifting apart.
function buildSkeleton(): string {
const headings = sections
.map((s) => s.heading.trim())
.filter(Boolean)
.map((h) => `## ${h}`);
return headings.length ? headings.join('\n\n') + '\n' : '';
}
async function save() {
nameError = sectionError = error = null;
if (!name.trim()) {
nameError = m.admin_builder_error_name();
return;
}
const kept = sections
.map((s) => ({ heading: s.heading.trim(), hint: s.hint.trim() }))
.filter((s) => s.heading);
if (kept.length === 0) {
sectionError = m.admin_builder_error_sections();
return;
}
const config: Config = {
id: cfg?.id ?? slugify(name),
name: name.trim(),
version: version.trim() || '1.0',
kind: 'authoring',
locale: locale === '' ? null : locale,
description: description.trim(),
model: { temperature: Number(temperature), min_class_hint: minClass.trim() || null },
persona: persona.trim(),
skeleton: buildSkeleton(),
sections: kept,
title_template: titleTemplate.trim(),
metadata: { visibility }
};
busy = true;
const { data, error: failed } = await api.POST('/api/templates/build', {
body: { template_id: template?.id ?? null, config }
});
busy = false;
if (failed || !data) {
error = (failed as { detail?: string })?.detail ?? m.admin_template_save_failed();
return;
}
onSaved(data);
}
</script>
<div class="flex flex-col gap-5" data-testid="template-builder">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="min-w-0 font-medium">
{template ? template.name : m.admin_builder_new_heading()}
</span>
{#if onShowYaml}
<Button variant="ghost" size="sm" onclick={onShowYaml} data-testid="builder-show-yaml">
<Code size={14} />
{m.admin_builder_show_yaml()}
</Button>
{/if}
</div>
<!-- Basics -->
<div class="grid gap-4 sm:grid-cols-2">
<FormField label={m.admin_builder_name()} for="tpl-name" error={nameError}>
<Input
id="tpl-name"
bind:value={name}
placeholder={m.admin_template_name_placeholder()}
data-testid="builder-name"
/>
</FormField>
<FormField label={m.admin_builder_version()} for="tpl-version">
{#snippet hint()}
<span class="text-xs text-ink-muted">{m.admin_template_version_hint()}</span>
{/snippet}
<Input id="tpl-version" bind:value={version} class="w-24" />
</FormField>
</div>
<FormField label={m.admin_builder_description()} for="tpl-desc">
<Input
id="tpl-desc"
bind:value={description}
placeholder={m.admin_builder_description_placeholder()}
/>
</FormField>
<FormField label={m.admin_builder_persona()} for="tpl-persona">
<textarea
id="tpl-persona"
class="min-h-24 w-full resize-y rounded-xl border border-border bg-surface-raised px-3 py-2 text-sm transition-colors focus:border-border-strong focus:outline-none"
bind:value={persona}
placeholder={m.admin_builder_persona_placeholder()}
data-testid="builder-persona"></textarea>
</FormField>
<TemplateSections bind:sections error={sectionError} />
<!-- Title, language, visibility, model. -->
<FormField label={m.admin_builder_title()} for="tpl-title">
<Input id="tpl-title" bind:value={titleTemplate} data-testid="builder-title" />
</FormField>
<p class="-mt-3 flex flex-wrap items-center gap-1.5 text-xs text-ink-muted">
{m.admin_builder_title_tokens()}
{#each titleTokens as token (token)}
<code class="rounded bg-surface-sunken px-1 py-0.5">{token}</code>
{/each}
</p>
<div class="grid gap-4 sm:grid-cols-2">
<FormField label={m.admin_builder_locale()} for="tpl-locale">
<Select id="tpl-locale" bind:value={locale}>
<option value="">{m.admin_builder_locale_unset()}</option>
<option value="de">{m.admin_builder_locale_de()}</option>
<option value="en">{m.admin_builder_locale_en()}</option>
</Select>
</FormField>
<FormField label={m.admin_builder_visibility()} for="tpl-visibility">
<Select id="tpl-visibility" bind:value={visibility}>
<option value="public">{m.document_visibility_public()}</option>
<option value="department">{m.document_visibility_department()}</option>
<option value="restricted">{m.document_visibility_restricted()}</option>
</Select>
</FormField>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<FormField label={m.admin_builder_temperature()} for="tpl-temp">
{#snippet hint()}
<span class="text-xs text-ink-muted">{m.admin_builder_temperature_hint()}</span>
{/snippet}
<Input
id="tpl-temp"
type="number"
min="0"
max="1"
step="0.1"
bind:value={temperature}
class="w-24"
/>
</FormField>
<FormField label={m.admin_builder_min_class()} for="tpl-minclass">
<Input
id="tpl-minclass"
bind:value={minClass}
placeholder={m.admin_builder_min_class_placeholder()}
/>
</FormField>
</div>
{#if error}
<p role="alert" class="text-sm text-danger" data-testid="builder-error">{error}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" disabled={busy} onclick={save} data-testid="builder-save">
{busy ? m.admin_template_saving() : m.admin_template_save()}
</Button>
<Button variant="ghost" size="sm" onclick={onCancel}>{m.common_cancel()}</Button>
</div>
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import Eye from '@lucide/svelte/icons/eye';
import Plus from '@lucide/svelte/icons/plus';
import type { components } from '$lib/api/schema';
import Badge from '$lib/components/Badge.svelte';
import Button from '$lib/components/Button.svelte';
import IconAction from '$lib/components/IconAction.svelte';
import { m } from '$lib/paraglide/messages';
// The blueprints that ship with the product, inert until an admin adds one.
// Collapsed by default so the list above it stays the answer to "what can my
// colleagues use right now?".
type CatalogSummary = components['schemas']['CatalogSummary'];
let {
catalog,
busy,
onPreview,
onAdd
}: {
catalog: CatalogSummary[];
busy: boolean;
onPreview: (entry: CatalogSummary) => void;
onAdd: (catalogId: string) => void;
} = $props();
let open = $state(false);
const available = $derived(catalog.filter((entry) => !entry.added));
</script>
<div class="rounded-xl border border-border" data-testid="template-catalog">
<button
class="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left"
onclick={() => (open = !open)}
data-testid="toggle-catalog"
>
<span class="text-sm font-medium">{m.admin_catalog_title()}</span>
<span class="text-xs whitespace-nowrap text-ink-muted">
{m.admin_catalog_available({ count: available.length })}
</span>
</button>
{#if open}
<ul class="flex flex-col gap-2 border-t border-border p-3">
{#each catalog as entry (entry.id)}
<li class="flex items-center gap-3 rounded-lg px-2 py-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="text-sm font-medium break-words">{entry.name}</span>
<span class="text-xs whitespace-nowrap text-ink-muted">
{m.admin_catalog_sections({ count: entry.sections })}
</span>
{#if entry.added}
<Badge variant="neutral">{m.admin_catalog_added()}</Badge>
{/if}
</div>
<p class="mt-0.5 text-xs text-ink-muted">{entry.description}</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<IconAction
icon={Eye}
label={m.admin_template_view()}
size="sm"
onclick={() => onPreview(entry)}
/>
{#if !entry.added}
<Button size="sm" variant="ghost" disabled={busy} onclick={() => onAdd(entry.id)}>
<Plus size={14} />
{m.admin_catalog_add()}
</Button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,243 @@
<script lang="ts">
import Copy from '@lucide/svelte/icons/copy';
import Pencil from '@lucide/svelte/icons/pencil';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import { api } from '$lib/api/client';
import { apiErrorCode, apiErrorDetail, errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import TemplateBuilder from '$lib/admin/TemplateBuilder.svelte';
import TemplateCatalog from '$lib/admin/TemplateCatalog.svelte';
import TemplateYaml from '$lib/admin/TemplateYaml.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import IconAction from '$lib/components/IconAction.svelte';
import { m } from '$lib/paraglide/messages';
// What this instance offers, and what it could offer. One surface with
// three faces: the list, the form builder that is the primary way to write
// a template, and raw YAML as the advanced escape hatch.
type Summary = components['schemas']['TemplateSummary'];
type Detail = components['schemas']['TemplateDetail'];
type CatalogSummary = components['schemas']['CatalogSummary'];
let templates = $state<Summary[]>([]);
let catalog = $state<CatalogSummary[]>([]);
let editing = $state<Detail | null>(null);
/** Building a brand-new template (no row yet). */
let creating = $state(false);
/** Raw YAML instead of the form; only for a template that has a row. */
let yamlMode = $state(false);
/** A blueprint being previewed. Read-only: it has no row to save to. */
let previewing = $state<CatalogSummary | null>(null);
let source = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
let confirmRequest = $state<{ message: string; run: () => void } | null>(null);
const showBuilder = $derived(creating || (editing !== null && !yamlMode));
const showYaml = $derived(previewing !== null || (editing !== null && yamlMode));
function reset() {
editing = null;
creating = false;
previewing = null;
yamlMode = false;
error = null;
}
async function refresh() {
const [own, shipped] = await Promise.all([
api.GET('/api/templates'),
api.GET('/api/templates/catalog')
]);
templates = own.data ?? [];
catalog = shipped.data ?? [];
}
$effect(() => void refresh());
function startCreate() {
reset();
creating = true;
}
async function edit(id: string) {
reset();
const { data } = await api.GET('/api/templates/{template_id}', {
params: { path: { template_id: id } }
});
if (!data) return;
editing = data;
source = data.yaml;
}
async function preview(entry: CatalogSummary) {
reset();
const { data } = await api.GET('/api/templates/catalog/{catalog_id}', {
params: { path: { catalog_id: entry.id } }
});
if (!data) return;
previewing = entry;
source = data.yaml;
}
/** A schema violation is the ONE failure whose English detail helps: it
* names the offending field, and an admin editing YAML is the reader. */
function failureMessage(failed: unknown, fallback: string): string {
const code = apiErrorCode(failed);
if (code === 'invalid_template') return apiErrorDetail(failed) ?? fallback;
return errorMessage(code, fallback);
}
async function save() {
if (!editing) return;
busy = true;
error = null;
const { data, error: failed } = await api.PUT('/api/templates/{template_id}', {
params: { path: { template_id: editing.id } },
body: { yaml: source }
});
busy = false;
if (failed || !data) {
error = failureMessage(failed, m.admin_template_save_failed());
return;
}
reset();
await refresh();
}
async function add(catalogId: string) {
busy = true;
error = null;
const { data, error: failed } = await api.POST('/api/templates/catalog/{catalog_id}', {
params: { path: { catalog_id: catalogId } }
});
busy = false;
if (failed || !data) {
error = failureMessage(failed, m.admin_template_add_failed());
return;
}
previewing = null;
await refresh();
// Straight into the editor: adding is almost always the first half of
// "add and adapt to how we actually do it here".
await edit(data.id);
}
async function duplicate(id: string) {
busy = true;
const { data } = await api.POST('/api/templates/{template_id}/duplicate', {
params: { path: { template_id: id } }
});
busy = false;
await refresh();
if (data) await edit(data.id);
}
function remove(id: string, name: string) {
confirmRequest = {
message: m.admin_template_delete_confirm({ name }),
run: async () => {
await api.DELETE('/api/templates/{template_id}', {
params: { path: { template_id: id } }
});
await refresh();
}
};
}
</script>
<ConfirmDialog
open={confirmRequest !== null}
title={m.common_delete()}
message={confirmRequest?.message}
onConfirm={() => confirmRequest?.run()}
onClose={() => (confirmRequest = null)}
/>
<div data-testid="template-list">
{#if showBuilder}
<!-- Key on the target so the form seeds fresh when the edited template
(or new-vs-edit) changes, rather than keeping the first values. -->
{#key editing?.id ?? 'new'}
<TemplateBuilder
template={editing}
onSaved={async () => {
reset();
await refresh();
}}
onCancel={reset}
onShowYaml={editing ? () => (yamlMode = true) : undefined}
/>
{/key}
{:else if showYaml}
{@const blueprint = previewing}
<TemplateYaml
title={editing?.name ?? blueprint?.name ?? ''}
bind:yaml={source}
readonly={blueprint !== null}
{busy}
{error}
onCommit={() => (blueprint ? add(blueprint.id) : save())}
onCancel={reset}
onShowForm={editing ? () => (yamlMode = false) : undefined}
/>
{:else}
<div class="flex flex-col gap-4">
<div class="flex justify-end">
<Button size="sm" onclick={startCreate} data-testid="new-template">
<Plus size={14} />
{m.admin_template_new()}
</Button>
</div>
<ul class="flex flex-col gap-2">
{#each templates as template (template.id)}
<li class="flex items-center gap-3 rounded-xl border border-border px-4 py-3">
<div class="min-w-0 flex-1">
<!-- The badges must never break mid-word, so the name gets the
flexible column and everything else stays nowrap. -->
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="font-medium break-words">{template.name}</span>
<span class="text-xs whitespace-nowrap text-ink-muted">v{template.version}</span>
</div>
{#if template.description}
<p class="mt-0.5 text-xs text-ink-muted">{template.description}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
<IconAction
icon={Pencil}
label={m.admin_template_edit()}
size="sm"
onclick={() => edit(template.id)}
/>
<IconAction
icon={Copy}
label={m.admin_template_duplicate()}
size="sm"
onclick={() => duplicate(template.id)}
/>
<IconAction
icon={Trash2}
label={m.admin_template_delete()}
size="sm"
variant="danger"
onclick={() => remove(template.id, template.name)}
/>
</div>
</li>
{/each}
{#if templates.length === 0}
<li
class="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-ink-muted"
>
{m.admin_template_empty()}
</li>
{/if}
</ul>
<TemplateCatalog {catalog} {busy} onPreview={preview} onAdd={add} />
</div>
{/if}
</div>
@@ -0,0 +1,114 @@
<script lang="ts">
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import ChevronUp from '@lucide/svelte/icons/chevron-up';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import { m } from '$lib/paraglide/messages';
// The skeleton a document starts from: a heading becomes a document heading,
// its hint steers the refinement model under that heading. Order matters, so
// it is editable — with buttons rather than drag: dragging is not
// keyboard-accessible and does not work on touch.
export type Section = { heading: string; hint: string };
let { sections = $bindable(), error }: { sections: Section[]; error: string | null } = $props();
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= sections.length) return;
[sections[index], sections[target]] = [sections[target], sections[index]];
}
</script>
{#snippet iconButton(
label: string,
onclick: () => void,
disabled: boolean,
danger: boolean,
children: import('svelte').Snippet
)}
<button
type="button"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-ink-muted transition-colors disabled:opacity-30 {danger
? 'hover:bg-danger-muted hover:text-danger'
: 'hover:bg-surface-sunken hover:text-ink'}"
{onclick}
{disabled}
aria-label={label}
>
{@render children()}
</button>
{/snippet}
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-ink">{m.admin_builder_sections()}</span>
<p class="text-xs text-ink-muted">{m.admin_builder_sections_hint()}</p>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex flex-col gap-2" data-testid="builder-sections">
{#each sections as section, index (section)}
<div class="flex flex-col gap-2 rounded-xl border border-border p-3">
<div class="flex items-center gap-2">
<Input
bind:value={section.heading}
placeholder={m.admin_builder_section_heading_placeholder()}
data-testid="builder-section-heading"
/>
<div class="flex shrink-0 items-center gap-0.5">
{#snippet up()}<ChevronUp size={15} />{/snippet}
{#snippet down()}<ChevronDown size={15} />{/snippet}
{#snippet remove()}<X size={15} />{/snippet}
{@render iconButton(
m.admin_builder_section_up(),
() => move(index, -1),
index === 0,
false,
up
)}
{@render iconButton(
m.admin_builder_section_down(),
() => move(index, 1),
index === sections.length - 1,
false,
down
)}
{@render iconButton(
m.admin_builder_section_remove(),
() => sections.splice(index, 1),
false,
true,
remove
)}
</div>
</div>
<textarea
class="min-h-16 w-full resize-y rounded-lg border border-border bg-surface-raised px-3 py-2 text-sm transition-colors focus:border-border-strong focus:outline-none"
bind:value={section.hint}
placeholder={m.admin_builder_section_hint_placeholder()}></textarea>
</div>
{/each}
{#if sections.length === 0}
<p
class="rounded-xl border border-dashed border-border px-4 py-5 text-center text-sm text-ink-muted"
>
{m.admin_builder_sections_empty()}
</p>
{/if}
</div>
<div>
<Button
variant="ghost"
size="sm"
onclick={() => sections.push({ heading: '', hint: '' })}
data-testid="builder-add-section"
>
<Plus size={14} />
{m.admin_builder_section_add()}
</Button>
</div>
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import { m } from '$lib/paraglide/messages';
// The advanced escape hatch: a template as raw YAML. Read-only when the
// source is a shipped blueprint, because a blueprint has no row to save to
// until it is added.
let {
title,
yaml = $bindable(),
readonly = false,
busy,
error,
onCommit,
onCancel,
onShowForm
}: {
title: string;
yaml: string;
readonly?: boolean;
busy: boolean;
error: string | null;
/** Save the edited YAML, or add the previewed blueprint. */
onCommit: () => void;
onCancel: () => void;
/** Back to the form builder; absent while previewing a blueprint. */
onShowForm?: () => void;
} = $props();
</script>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="min-w-0 font-medium">{title}</span>
{#if onShowForm}
<Button variant="ghost" size="sm" onclick={onShowForm} data-testid="show-form">
{m.admin_builder_show_form()}
</Button>
{:else}
<span class="text-xs text-ink-muted">{m.admin_template_blueprint_hint()}</span>
{/if}
</div>
<textarea
class="h-96 w-full resize-none rounded-xl border border-border bg-surface px-3 py-2 font-mono text-xs transition-colors focus:border-border-strong focus:outline-none"
bind:value={yaml}
{readonly}
spellcheck="false"
data-testid="template-editor"></textarea>
{#if error}
<p role="alert" class="text-sm text-danger" data-testid="template-error">{error}</p>
{/if}
<div class="flex gap-2">
{#if readonly}
<Button size="sm" disabled={busy} onclick={onCommit} data-testid="add-template">
{busy ? m.admin_template_adding() : m.admin_template_add()}
</Button>
{:else}
<Button size="sm" disabled={busy} onclick={onCommit} data-testid="save-template">
{busy ? m.admin_template_saving() : m.admin_template_save()}
</Button>
{/if}
<Button variant="ghost" size="sm" onclick={onCancel}>{m.common_cancel()}</Button>
</div>
</div>
+412
View File
@@ -0,0 +1,412 @@
<script lang="ts">
import KeyRound from '@lucide/svelte/icons/key-round';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import UserPlus from '@lucide/svelte/icons/user-plus';
import { api } from '$lib/api/client';
import { apiErrorCode, errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import IconAction from '$lib/components/IconAction.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormField from '$lib/components/FormField.svelte';
import Input from '$lib/components/Input.svelte';
import Select from '$lib/components/Select.svelte';
import { m } from '$lib/paraglide/messages';
type AdminUser = components['schemas']['AdminUserOut'];
type Department = components['schemas']['DepartmentOut'];
// Departments come from the page: this list only reads them (for the
// select and the name column), the department manager owns them.
let { departments }: { departments: Department[] } = $props();
const departmentNames = $derived(new Map(departments.map((d) => [d.id, d.name])));
let users = $state<AdminUser[]>([]);
let total = $state(0);
let perPage = $state(25);
let page = $state(1);
let search = $state('');
let error = $state<string | null>(null);
const pages = $derived(Math.max(1, Math.ceil(total / perPage)));
async function load() {
const { data } = await api.GET('/api/admin/users', {
params: { query: { search: search.trim() || undefined, page } }
});
users = data?.items ?? [];
total = data?.total ?? 0;
perPage = data?.per_page ?? perPage;
}
$effect(() => {
void load();
});
/** Any change to what is listed starts over at page one. */
function reload() {
page = 1;
void load();
}
let searchDebounce: ReturnType<typeof setTimeout> | undefined;
function onSearch() {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(reload, 250);
}
function goToPage(next: number) {
page = Math.min(Math.max(1, next), pages);
void load();
}
// One row at a time: an admin correcting an address is doing one thing, and
// a table full of open inputs invites half-finished edits nobody remembers
// making. Creating and editing share the same six fields, so they share a
// draft rather than growing two shapes for the same person.
const emptyDraft = { email: '', name: '', role: 'member', department: '', password: '' };
let draft = $state({ ...emptyDraft });
let editing = $state<AdminUser | null>(null);
let creating = $state(false);
let resetting = $state<AdminUser | null>(null);
let newPassword = $state('');
let confirmRequest = $state<{ title: string; message: string; run: () => void } | null>(null);
function startCreate() {
error = null;
draft = { ...emptyDraft };
creating = true;
}
function startEdit(user: AdminUser) {
error = null;
draft = {
email: user.email,
name: user.name,
role: user.role,
department: user.department_id ?? '',
password: ''
};
editing = user;
}
async function create(event: SubmitEvent) {
event.preventDefault();
error = null;
const { data, error: apiError } = await api.POST('/api/admin/users', {
body: {
email: draft.email,
name: draft.name,
role: draft.role as 'member' | 'admin',
department_id: draft.department || null,
password: draft.password
}
});
if (!data) {
error = errorMessage(apiErrorCode(apiError), m.admin_users_create_failed());
return;
}
creating = false;
reload();
}
async function save(user: AdminUser) {
error = null;
const { error: apiError } = await api.PATCH('/api/admin/users/{user_id}', {
params: { path: { user_id: user.id } },
body: {
email: draft.email,
name: draft.name,
role: draft.role as 'member' | 'admin',
// The two ways to say "no department" are different requests:
// omitting it changes nothing, clearing it is explicit.
department_id: draft.department || undefined,
clear_department: draft.department ? undefined : true
}
});
if (apiError) {
error = errorMessage(apiErrorCode(apiError), m.admin_users_update_failed());
return;
}
editing = null;
await load();
}
function startReset(user: AdminUser) {
error = null;
newPassword = '';
resetting = user;
}
async function resetPassword(event: SubmitEvent) {
event.preventDefault();
if (!resetting) return;
error = null;
const { data, error: apiError } = await api.PATCH('/api/admin/users/{user_id}', {
params: { path: { user_id: resetting.id } },
body: { password: newPassword }
});
if (!data) {
error = errorMessage(apiErrorCode(apiError), m.admin_users_reset_failed());
return;
}
resetting = null;
}
function remove(user: AdminUser) {
confirmRequest = {
title: m.common_delete(),
message: m.admin_users_delete_confirm({ email: user.email }),
run: async () => {
await api.DELETE('/api/admin/users/{user_id}', {
params: { path: { user_id: user.id } }
});
await load();
}
};
}
</script>
<Card>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold">{m.admin_users_title()}</h2>
<div class="flex flex-wrap items-center gap-2">
<div class="w-72">
<Input
placeholder={m.admin_users_search_placeholder()}
bind:value={search}
oninput={onSearch}
data-testid="user-search"
/>
</div>
<Button size="sm" onclick={startCreate} data-testid="new-user">
<UserPlus size={15} />
{m.admin_users_new()}
</Button>
</div>
</div>
{#if error && !creating && !editing && !resetting}
<p role="alert" class="mb-2 text-sm text-danger">{error}</p>
{/if}
<!-- Cells carry their own padding: without it the columns run into each
other and "florian@pablan.dev" reads as one word with "Florian". -->
<div class="overflow-x-auto">
<table class="w-full min-w-xl text-sm">
<thead>
<tr class="border-b border-border text-left text-xs text-ink-muted">
<th class="py-1.5 pr-4 font-medium">{m.admin_users_email()}</th>
<th class="py-1.5 pr-4 font-medium">{m.admin_users_name()}</th>
<th class="py-1.5 pr-4 font-medium">{m.admin_users_role()}</th>
<th class="py-1.5 pr-4 font-medium">{m.admin_users_department()}</th>
<th class="py-1.5"><span class="sr-only">{m.admin_users_actions()}</span></th>
</tr>
</thead>
<tbody data-testid="user-table">
{#each users as user (user.id)}
<tr class="border-b border-border">
<td class="py-2 pr-4">{user.email}</td>
<td class="py-2 pr-4">{user.name}</td>
<td class="py-2 pr-4">{user.role}</td>
<td class="py-2 pr-4">{departmentNames.get(user.department_id ?? '') ?? '—'}</td>
<td class="py-2 text-right whitespace-nowrap">
<span class="inline-flex items-center gap-1">
<IconAction
label={m.admin_users_edit()}
icon={Pencil}
size="sm"
onclick={() => startEdit(user)}
testid="edit-user"
/>
<IconAction
label={m.admin_users_reset_password()}
icon={KeyRound}
size="sm"
onclick={() => startReset(user)}
/>
<IconAction
label={m.admin_users_delete()}
icon={Trash2}
size="sm"
variant="danger"
onclick={() => remove(user)}
/>
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if users.length === 0}
<p class="py-4 text-sm text-ink-muted">{m.admin_users_empty()}</p>
{/if}
{#if pages > 1}
<div class="mt-3 flex items-center justify-center gap-3" data-testid="user-pager">
<Button variant="ghost" size="sm" disabled={page <= 1} onclick={() => goToPage(page - 1)}>
{m.documents_pager_previous()}
</Button>
<span class="text-xs text-ink-muted">
{m.admin_pager_status({ page, pages, total })}
</span>
<Button variant="ghost" size="sm" disabled={page >= pages} onclick={() => goToPage(page + 1)}>
{m.documents_pager_next()}
</Button>
</div>
{/if}
</Card>
<!-- Editing lives in a modal rather than an expanded row: the table's columns
are sized for reading, an edit needs room, and a dialog makes "I am
changing this person" unmistakable. -->
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) editing = null;
}}
title={m.admin_users_edit_title()}
data-testid="user-dialog"
>
{#if editing}
{@const user = editing}
<div class="flex flex-col gap-3">
{@render personFields('edit')}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button size="sm" onclick={() => save(user)} data-testid="save-user">
{m.admin_users_save()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (editing = null)}>
{m.common_cancel()}
</Button>
</div>
</div>
{/if}
</Dialog>
<Dialog
open={creating}
onOpenChange={(open) => {
if (!open) creating = false;
}}
title={m.admin_users_create_title()}
data-testid="create-user-dialog"
>
<form class="flex flex-col gap-3" onsubmit={create}>
{@render personFields('new')}
<FormField label={m.admin_users_password()} for="new-password">
<Input
id="new-password"
type="password"
required
minlength={8}
placeholder={m.admin_users_password_placeholder()}
bind:value={draft.password}
/>
</FormField>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button type="submit" size="sm" data-testid="create-user">
{m.admin_users_create()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (creating = false)}>
{m.common_cancel()}
</Button>
</div>
</form>
</Dialog>
<!-- A password is set, never shown: the same modal shape as every other admin
action, instead of the browser's unstyled prompt(). -->
<Dialog
open={resetting !== null}
onOpenChange={(open) => {
if (!open) resetting = null;
}}
title={m.admin_users_reset_title()}
description={resetting?.email}
data-testid="reset-password-dialog"
>
<form class="flex flex-col gap-3" onsubmit={resetPassword}>
<FormField label={m.admin_users_password()} for="reset-password">
<Input
id="reset-password"
type="password"
required
minlength={8}
placeholder={m.admin_users_password_placeholder()}
bind:value={newPassword}
/>
</FormField>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex gap-2">
<Button type="submit" size="sm" data-testid="save-password">
{m.admin_users_save()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (resetting = null)}>
{m.common_cancel()}
</Button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={confirmRequest !== null}
title={confirmRequest?.title ?? ''}
message={confirmRequest?.message ?? ''}
onConfirm={() => confirmRequest?.run()}
onClose={() => (confirmRequest = null)}
/>
{#snippet personFields(prefix: string)}
<FormField label={m.admin_users_email()} for="{prefix}-email">
<Input
id="{prefix}-email"
type="email"
required
placeholder={m.admin_users_email_placeholder()}
bind:value={draft.email}
/>
</FormField>
<FormField label={m.admin_users_name()} for="{prefix}-name">
<Input
id="{prefix}-name"
required
placeholder={m.admin_users_name_placeholder()}
bind:value={draft.name}
/>
</FormField>
<div class="flex flex-wrap gap-3">
<div class="min-w-36 flex-1">
<FormField label={m.admin_users_role()} for="{prefix}-role">
<Select id="{prefix}-role" bind:value={draft.role}>
<option value="member">member</option>
<option value="admin">admin</option>
</Select>
</FormField>
</div>
<div class="min-w-48 flex-1">
<FormField label={m.admin_users_department()} for="{prefix}-department">
<Select id="{prefix}-department" bind:value={draft.department}>
<option value="">{m.admin_users_no_department()}</option>
{#each departments as department (department.id)}
<option value={department.id}>{department.name}</option>
{/each}
</Select>
</FormField>
</div>
</div>
{/snippet}
+11
View File
@@ -0,0 +1,11 @@
import createClient from 'openapi-fetch';
import type { paths } from './schema';
export function createApi(fetchFn: typeof globalThis.fetch = globalThis.fetch) {
return createClient<paths>({ fetch: fetchFn });
}
export type ApiClient = ReturnType<typeof createApi>;
export const api = createApi();
+63
View File
@@ -0,0 +1,63 @@
// Backend error codes phrased for the user.
//
// The API answers with `{detail, code}` and SSE error frames carry a bare
// `code` — the backend never renders UI language (CLAUDE.md). `detail` is for
// developers; everything a user reads is written here.
import { m } from '$lib/paraglide/messages';
/** The `code` from an API error body, if it carries one. */
export function apiErrorCode(error: unknown): string | undefined {
if (typeof error === 'object' && error !== null && 'code' in error) {
const code = (error as { code: unknown }).code;
if (typeof code === 'string') return code;
}
return undefined;
}
/**
* The `detail` from an API error body.
*
* Developer-facing English, so it is NOT for the user — with one exception the
* caller must justify: a validation failure whose detail names the offending
* field is the only thing that helps, and no code could carry it.
*/
export function apiErrorDetail(error: unknown): string | undefined {
if (typeof error === 'object' && error !== null && 'detail' in error) {
const detail = (error as { detail: unknown }).detail;
if (typeof detail === 'string') return detail;
}
return undefined;
}
/**
* A sentence for `code`, falling back to the caller's own wording.
*
* `fallback` is the action-specific message ("the user could not be created"),
* used when the code is one we have no better sentence for. Without one, a
* generic sentence is still better than a blank.
*/
export function errorMessage(code: string | null | undefined, fallback?: string): string {
// Written out rather than looked up by a built key: Paraglide is a
// compiler and can only check and tree-shake literal keys (docs/i18n.md).
switch (code) {
case 'llm_unreachable':
return m.llm_error_unreachable();
case 'llm_busy':
return m.llm_error_busy();
case 'llm_misconfigured':
return m.llm_error_misconfigured();
case 'llm_failed':
return m.llm_error_failed();
case 'email_taken':
return m.error_email_taken();
case 'name_taken':
return m.error_name_taken();
case 'self_modification':
return m.error_self_modification();
case 'department_in_use':
return m.error_department_in_use();
default:
return fallback ?? m.error_generic();
}
}
+55
View File
@@ -0,0 +1,55 @@
// SSE consumer for section refinement: POST the document + cursor line, get
// back which line range the suggestion replaces, then the refined section
// streamed token by token. Same fetch + ReadableStream pattern as the chat
// turn stream (EventSource cannot POST); the AbortSignal cancels the moment
// the user resumes typing.
import { parseFrame } from '$lib/api/stream';
export type GroundingReference = { title: string; heading_path: string };
export type RefineEvent =
// The exact 1-based inclusive line range the accepted suggestion overwrites.
| { type: 'section'; start_line: number; end_line: number }
// What the suggestion is grounding on (the author's own readable material),
// for the "?" inspector; only sent when the section matched something.
| { type: 'grounding'; references: GroundingReference[] }
| { type: 'token'; text: string }
| { type: 'error'; code: string }
| { type: 'done' };
export async function* streamRefine(
documentId: string,
contentMd: string,
cursorLine: number,
signal: AbortSignal
): AsyncGenerator<RefineEvent> {
const response = await fetch(`/api/documents/${documentId}/refine`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content_md: contentMd, cursor_line: cursorLine }),
signal
});
if (!response.ok || !response.body) {
throw new Error(`refine request failed (${response.status})`);
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
let frameEnd;
while ((frameEnd = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, frameEnd);
buffer = buffer.slice(frameEnd + 2);
const event = parseFrame<RefineEvent>(frame);
if (event) yield event;
}
}
} finally {
reader.releaseLock();
}
}
+3581
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
// SSE consumer for conversation turns: fetch + ReadableStream (NOT
// EventSource — it cannot POST), with an AbortSignal wired to the stop
// button.
export type SourceChunk = {
document_id: string;
title: string;
heading_path: string;
excerpt: string;
// Whether the passage actually grounded the answer, or was only retrieved
// and dropped as too weak (a no-answer turn). Absent on old messages, which
// were all used — treat undefined as true.
used?: boolean;
// The cited document has an unanswered request to check it: readable, but
// not settled. Marked wherever the source appears.
review_pending?: boolean;
};
export type StreamEvent =
| { type: 'token'; text: string }
| { type: 'sources'; chunks: SourceChunk[] }
// Query progress, metadata only: phase is
// searching | results | no_answer | answering; count is documents found.
| { type: 'state'; phase: string; count: number | null }
| { type: 'error'; code: string }
// No model was reachable: `sources` is a plain full-text result list for
// the user to open, and no answer follows. `code` says why.
| { type: 'fallback'; code: string }
| { type: 'done'; message_id: string | null };
export async function* streamMessage(
conversationId: string,
content: string,
signal: AbortSignal
): AsyncGenerator<StreamEvent> {
yield* streamTurn(
`/api/conversations/${conversationId}/messages`,
JSON.stringify({ content }),
signal
);
}
async function* streamTurn(
url: string,
body: string | null,
signal: AbortSignal
): AsyncGenerator<StreamEvent> {
const response = await fetch(url, {
method: 'POST',
headers: body ? { 'content-type': 'application/json' } : undefined,
body,
signal
});
if (!response.ok || !response.body) {
throw new Error(`stream request failed (${response.status})`);
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
let frameEnd;
while ((frameEnd = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, frameEnd);
buffer = buffer.slice(frameEnd + 2);
const event = parseFrame<StreamEvent>(frame);
if (event) yield event;
}
}
} finally {
reader.releaseLock();
}
}
/** Parse one `event:`/`data:` SSE frame into a typed object, or null. Shared
* shape used by the chat turn stream and the section-refinement stream. */
export function parseFrame<T>(frame: string): T | null {
let eventName = '';
let data = '';
for (const line of frame.split('\n')) {
if (line.startsWith('event: ')) {
eventName = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
data += line.slice(6);
}
}
if (!eventName || !data) return null;
return { type: eventName, ...JSON.parse(data) } as T;
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="400" height="400" role="img" aria-label="Pablan network P">
<path d="M30,84 L30,18 L64,18 L64,48 L30,48" stroke="#d97a00" stroke-width="3" fill="none"></path>
<circle cx="30" cy="84" r="8" fill="#c0261b"></circle>
<circle cx="30" cy="48" r="6" fill="#d75a00"></circle>
<circle cx="64" cy="48" r="6" fill="#d97a00"></circle>
<circle cx="64" cy="18" r="6" fill="#d99400"></circle>
<circle cx="30" cy="18" r="7" fill="#e0a800"></circle>
</svg>

After

Width:  |  Height:  |  Size: 522 B

+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import ClipboardList from '@lucide/svelte/icons/clipboard-list';
import Info from '@lucide/svelte/icons/info';
import type { SourceChunk } from '$lib/api/stream';
import type { ChatMessage } from '$lib/chat/state.svelte';
import ContextInspector from '$lib/chat/ContextInspector.svelte';
import FallbackResults from '$lib/chat/FallbackResults.svelte';
import SourceBadge from '$lib/chat/SourceBadge.svelte';
import { groupSources } from '$lib/chat/sources';
import Markdown from '$lib/components/Markdown.svelte';
import { m } from '$lib/paraglide/messages';
// One reply, in whichever state it is in: still streaming, answered and
// cited, answered without cover, or not answered at all because no model
// could be reached. Each of those tells the reader something different
// about how much to trust what they see.
let {
message,
statusLine,
slow,
captureHref,
onOpenSource
}: {
message: ChatMessage;
/** Retrieval progress, only for the turn currently streaming. */
statusLine: string | null;
slow: boolean;
captureHref: string;
onOpenSource: (source: SourceChunk) => void;
} = $props();
const cited = $derived(message.sources.filter((source) => source.used !== false));
</script>
<div class="flex max-w-prose flex-col gap-2 self-start">
<div class="rounded-xl bg-surface px-3 py-2 text-sm" data-testid="assistant-message">
{#if message.streaming && statusLine}
<p class="mb-1 animate-pulse text-xs text-ink-muted" data-testid="retrieval-status">
{statusLine}
</p>
{/if}
{#if message.streaming && slow}
<p class="mb-1 text-xs text-warning" data-testid="slow-status">{m.llm_status_slow()}</p>
{/if}
<!-- Render the sanitized Markdown LIVE as it streams, so the reply is
unveiled already-formatted rather than snapping from plain text to
Markdown when it settles. The blinking cursor marks that more is
still coming. -->
<Markdown content={message.content} />
{#if message.streaming}
<span class="ml-0.5 animate-pulse text-secondary"></span>
{/if}
{#if message.fallback}
<FallbackResults code={message.fallback} sources={message.sources} onOpen={onOpenSource} />
{:else}
{#if cited.length > 0}
<!-- One badge per document, not per chunk: two matching sections of
one document are one source. Only passages that grounded the
answer become badges; the "?" inspector shows the full set. -->
<div class="mt-2 flex flex-wrap items-center gap-1" data-testid="sources">
<span class="mr-0.5 text-xs text-ink-muted">{m.chat_sources_label()}</span>
{#each groupSources(cited) as source (source.document_id)}
<SourceBadge {source} onOpen={onOpenSource} />
{/each}
</div>
{/if}
{#if !message.streaming && message.sources.length > 0}
<!-- What the model was working from: every retrieved passage, marking
which grounded the answer (this explains a no-answer too). -->
<div class="mt-1.5">
<ContextInspector sources={message.sources} />
</div>
{/if}
{/if}
{#if message.noAnswer && !message.streaming}
<!-- The answer stands, but it is not backed by the knowledge base —
say so where it applies. -->
<p
class="mt-2 flex items-center gap-1.5 border-t border-border pt-2 text-xs text-ink-muted"
data-testid="no-sources-note"
>
<Info size={12} class="shrink-0" />
{m.chat_no_sources_note()}
</p>
{/if}
</div>
{#if message.noAnswer && !message.streaming}
<!-- A gap in the knowledge base: offer to write it down. -->
<!-- captureHref IS resolved; the rule cannot see through the query string
the conversation id is passed as. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={captureHref}
class="flex w-fit cursor-pointer items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-xs text-ink-muted transition-colors hover:border-accent hover:text-ink"
data-testid="capture-gap"
>
<ClipboardList size={13} />
{m.chat_capture_gap()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/if}
</div>
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts">
import ClipboardList from '@lucide/svelte/icons/clipboard-list';
import { afterNavigate, goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { SourceChunk } from '$lib/api/stream';
import AssistantTurn from '$lib/chat/AssistantTurn.svelte';
import Composer from '$lib/chat/Composer.svelte';
import DocumentPanel from '$lib/chat/DocumentPanel.svelte';
import { chatState as chat } from '$lib/chat/state.svelte';
import { m } from '$lib/paraglide/messages';
import { untrack } from 'svelte';
type Props = {
/** The conversation this view shows, or null for a fresh composer. */
conversationId: string | null;
/** Asked once, on mount — the landing page hand-off. */
initialQuestion?: string;
};
let { conversationId, initialQuestion = '' }: Props = $props();
let draft = $state('');
let scroller = $state<HTMLElement | null>(null);
let openSource = $state<SourceChunk | null>(null);
let inputEl = $state<HTMLTextAreaElement | null>(null);
// Keep the composer focused across the /chat -> /chat/[id] navigation that
// the first message triggers: that swaps the page component, so the old
// textarea is destroyed and `keepFocus` cannot help. Re-focus once the new
// view has mounted, so the next message can be typed straight away.
afterNavigate(() => inputEl?.focus());
const splitOpen = $derived(openSource !== null);
// Capturing from a chat carries the conversation, so the picker can suggest
// matching documents and the draft keeps the chat as background context.
const captureHref = $derived(
resolve('/documents/new') + (chat.activeId ? `?conversation=${chat.activeId}` : '')
);
// The transient status line above a streaming reply. The backend sends a
// phase and counts; the sentence is written here, because the backend never
// renders UI-language strings.
const statusLine = $derived.by(() => {
const progress = chat.retrieval;
if (!progress) return null;
if (progress.phase === 'searching') return m.chat_status_searching();
if (progress.phase === 'results') {
return m.chat_status_results({ count: progress.count ?? 0 });
}
if (progress.phase === 'no_answer') return m.chat_status_no_answer();
// Every slot the endpoint has is taken by someone else's turn.
if (progress.phase === 'queued') return m.chat_status_queued();
if (progress.phase === 'answering') return m.chat_status_answering();
return null;
});
// Entering chat refreshes the shared list the sidebar renders — it may have
// gone stale since the app shell mounted.
$effect(() => {
void chat.loadConversations();
});
// The route owns which conversation is shown: /chat/[id] for an existing
// one, /chat for a fresh composer. Reacting to the param rather than to
// clicks is what makes back/forward and a pasted link behave, and it is the
// single place a switch can abort the previous stream.
$effect(() => {
const id = conversationId;
untrack(() => {
if (id === chat.activeId) return;
// Switching away mid-answer: the old stream must not keep writing into
// a view that now belongs to another conversation.
chat.stop();
openSource = null;
if (id) {
void chat.open(id);
} else {
chat.startNew();
}
});
});
// Give a conversation its own address as soon as it has one, without
// interrupting the answer already streaming into it.
$effect(() => {
const active = chat.activeId;
if (!active || active === conversationId) return;
void goto(resolve(`/chat/${active}`), {
replaceState: true,
noScroll: true,
keepFocus: true
});
});
// The landing hand-off, consumed exactly once.
let handedOff = false;
$effect(() => {
const question = initialQuestion;
untrack(() => {
if (question && !handedOff) {
handedOff = true;
// Not awaited: streaming takes seconds, and navigation must stay
// responsive while the answer arrives.
void chat.send(question);
}
});
});
// Follow the stream: scroll down whenever the last message grows.
$effect(() => {
const last = chat.messages.at(-1);
void last?.content;
if (scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
});
async function send() {
const content = draft.trim();
if (!content || chat.streaming) return;
draft = '';
await chat.send(content);
}
</script>
<!-- A split view uses the whole window; a single column stays readable.
Below lg there is no room for two columns, so the panels stack. -->
<div
class="mx-auto flex min-h-0 w-full flex-1 flex-col gap-4 lg:flex-row {splitOpen
? 'max-w-none'
: 'max-w-5xl'}"
>
<section
class="relative flex min-h-0 min-w-0 flex-1 flex-col rounded-2xl border border-border bg-surface-raised"
class:hidden={openSource !== null}
class:lg:flex={openSource !== null}
>
<div class="flex items-center justify-end gap-2 border-b border-border px-4 py-2">
<!-- captureHref IS resolved; the rule cannot see through the query
string the conversation id is passed as. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={captureHref}
class="flex items-center gap-2 rounded-full px-3 py-1.5 text-sm text-ink-muted transition-colors hover:text-ink"
data-testid="chat-capture-link"
>
<ClipboardList size={16} />
{m.chat_capture_button()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
<div bind:this={scroller} class="flex-1 overflow-y-auto p-4">
{#if chat.messages.length === 0}
<!-- The one promise worth making before the first question: the
answer comes out of your own documents, and says which. -->
<div class="mx-auto mt-10 flex max-w-sm flex-col items-center gap-1.5 text-center">
<p class="text-sm font-medium">{m.chat_empty_query()}</p>
<p class="text-xs text-ink-muted">{m.chat_empty_hint()}</p>
</div>
{/if}
<div class="flex flex-col gap-3">
{#each chat.messages as message, index (index)}
{#if message.role === 'user'}
<div
class="max-w-prose self-end rounded-xl bg-primary px-3 py-2 text-sm text-primary-fg"
>
{message.content}
</div>
{:else}
<AssistantTurn
{message}
{statusLine}
{captureHref}
slow={chat.slow}
onOpenSource={(source) => (openSource = source)}
/>
{/if}
{/each}
</div>
</div>
{#if chat.error}
<p role="alert" class="border-t border-border px-4 py-2 text-sm text-danger">
{chat.error}
</p>
{/if}
<Composer
bind:draft
bind:input={inputEl}
streaming={chat.streaming}
onSubmit={send}
onStop={() => chat.stop()}
/>
</section>
{#if openSource}
<DocumentPanel
documentId={openSource.document_id}
headingPath={openSource.heading_path}
onClose={() => (openSource = null)}
/>
{/if}
</div>
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import Square from '@lucide/svelte/icons/square';
import Button from '$lib/components/Button.svelte';
import { m } from '$lib/paraglide/messages';
// Ask, or stop. The same button position does both, so the answer can be
// interrupted where it was started.
let {
draft = $bindable(),
streaming,
input = $bindable(),
onSubmit,
onStop
}: {
draft: string;
streaming: boolean;
input: HTMLTextAreaElement | null;
onSubmit: () => void;
onStop: () => void;
} = $props();
function submit(event: SubmitEvent) {
event.preventDefault();
onSubmit();
}
function onKeydown(event: KeyboardEvent) {
// Enter sends, Shift+Enter breaks the line: a question is usually one
// line, and a chat that needs a mouse to send is slower than talking.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
(event.currentTarget as HTMLElement).closest('form')?.requestSubmit();
}
}
</script>
<div class="p-3">
<form
class="rounded-2xl border border-border bg-surface-sunken p-2.5 transition-colors focus-within:border-border-strong"
onsubmit={submit}
>
<textarea
bind:this={input}
class="max-h-40 min-h-10 w-full resize-none bg-transparent px-1.5 py-1 text-sm placeholder:text-ink-muted focus:outline-none"
placeholder={m.chat_input_placeholder()}
rows="1"
bind:value={draft}
onkeydown={onKeydown}
data-testid="chat-input"></textarea>
<div class="flex justify-end px-0.5">
{#if streaming}
<Button
size="icon"
type="button"
aria-label={m.chat_stop()}
onclick={onStop}
data-testid="stop-button"
>
<Square size={16} />
</Button>
{:else}
<Button
type="submit"
size="icon"
aria-label={m.chat_send()}
disabled={!draft.trim()}
data-testid="send-button"
>
<ArrowUp size={18} />
</Button>
{/if}
</div>
</form>
</div>
@@ -0,0 +1,48 @@
<script lang="ts">
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import HelpCircle from '@lucide/svelte/icons/circle-help';
import type { SourceChunk } from '$lib/api/stream';
import Popover from '$lib/components/Popover.svelte';
import { m } from '$lib/paraglide/messages';
// Every passage retrieval surfaced for this turn — the ones that grounded
// the answer (`used`) and the ones that were too weak (a no-answer turn).
let { sources }: { sources: SourceChunk[] } = $props();
</script>
{#if sources.length > 0}
<Popover triggerLabel={m.chat_context_label()} contentClass="max-w-md">
{#snippet trigger()}
<span
class="flex items-center gap-1 px-1.5 py-0.5 text-xs text-ink-muted transition-colors hover:text-ink"
data-testid="context-inspector"
>
<HelpCircle size={13} />
{m.chat_context_label()}
</span>
{/snippet}
<p class="mb-2 text-xs font-medium text-ink-muted">{m.chat_context_title()}</p>
<ul class="flex max-h-72 flex-col gap-2 overflow-y-auto">
{#each sources as source, index (source.document_id + source.heading_path + index)}
<li class="border-t border-border pt-2 first:border-t-0 first:pt-0">
<div class="flex items-start justify-between gap-2">
<span class="flex min-w-0 items-center gap-1 truncate font-medium">
{#if source.review_pending}
<AlertTriangle size={12} class="shrink-0 text-warning" />
{/if}
{source.heading_path || source.title}
</span>
<span
class="shrink-0 text-xs {source.used === false ? 'text-ink-muted' : 'text-success'}"
>
{source.used === false ? m.chat_context_unused() : m.chat_context_used()}
</span>
</div>
{#if source.excerpt}
<p class="mt-0.5 line-clamp-3 text-xs text-ink-muted">{source.excerpt}</p>
{/if}
</li>
{/each}
</ul>
</Popover>
{/if}
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import X from '@lucide/svelte/icons/x';
import ExternalLink from '@lucide/svelte/icons/external-link';
import { resolve } from '$app/paths';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import { m } from '$lib/paraglide/messages';
import Badge from '$lib/components/Badge.svelte';
import Markdown from '$lib/components/Markdown.svelte';
import { bodyWithoutTitle, statusLabel, visibilityLabel } from '$lib/documents/presentation';
type DocumentDetail = components['schemas']['DocumentDetail'];
type Props = {
documentId: string;
/** Cited section, e.g. "Wartung Intervalle" — scrolled to if found. */
headingPath?: string;
onClose: () => void;
};
let { documentId, headingPath = '', onClose }: Props = $props();
let document = $state<DocumentDetail | null>(null);
let missing = $state(false);
let body = $state<HTMLElement | null>(null);
// Must match rag/chunking.py HEADING_PATH_SEPARATOR.
const HEADING_SEPARATOR = ' ';
$effect(() => {
const id = documentId;
document = null;
missing = false;
void (async () => {
const { data, response } = await api.GET('/api/documents/{document_id}', {
params: { path: { document_id: id } }
});
if (!data) {
missing = response.status === 404;
return;
}
document = data;
})();
});
// Once the Markdown is in the DOM, jump to the cited section. Best
// effort: an unmatched heading just leaves the panel at the top.
$effect(() => {
void document?.content_md;
const target = headingPath.split(HEADING_SEPARATOR).at(-1)?.trim();
if (!body || !target) return;
queueMicrotask(() => {
const heading = [...(body?.querySelectorAll('h1, h2, h3, h4') ?? [])].find(
(element) => element.textContent?.trim() === target
);
heading?.scrollIntoView({ block: 'start' });
});
});
</script>
<aside
class="flex min-h-0 w-full min-w-0 flex-col rounded-2xl border border-border bg-surface-raised lg:w-[45%] lg:shrink-0"
data-testid="document-panel"
>
<div class="flex items-start justify-between gap-2 border-b border-border px-4 py-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{document?.title ?? m.document_fallback_title()}
</p>
{#if headingPath}
<p class="truncate text-xs text-ink-muted">{headingPath}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
{#if document}
<a
href={resolve(`/documents/${document.id}`)}
class="rounded-md p-1 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
aria-label={m.panel_open_full_page()}
>
<ExternalLink size={16} />
</a>
{/if}
<button
class="cursor-pointer rounded-md p-1 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
onclick={onClose}
aria-label={m.panel_close()}
>
<X size={16} />
</button>
</div>
</div>
<div bind:this={body} class="min-h-0 flex-1 overflow-y-auto p-4">
{#if missing}
<p class="text-sm text-ink-muted">{m.panel_missing()}</p>
{:else if document}
<div class="mb-3 flex flex-wrap items-center gap-1">
<Badge>{statusLabel(document.status)}</Badge>
<Badge>{visibilityLabel(document.visibility)}</Badge>
</div>
{#if document.open_reviews > 0}
<!-- The same warning the chat badge carries, restated where the text
is actually read. -->
<p
class="mb-3 flex items-start gap-1.5 rounded-lg bg-warning-muted px-3 py-2 text-xs text-warning"
data-testid="panel-review-pending"
>
<AlertTriangle size={13} class="mt-0.5 shrink-0" />
{m.chat_source_review_pending()}
</p>
{/if}
<Markdown content={bodyWithoutTitle(document.content_md, document.title)} />
{:else}
<p class="text-sm text-ink-muted">{m.panel_loading()}</p>
{/if}
</div>
</aside>
@@ -0,0 +1,60 @@
<script lang="ts">
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import FileText from '@lucide/svelte/icons/file-text';
import Info from '@lucide/svelte/icons/info';
import { errorMessage } from '$lib/api/errors';
import type { SourceChunk } from '$lib/api/stream';
import { groupSources } from '$lib/chat/sources';
import { m } from '$lib/paraglide/messages';
// No model answered this turn: what retrieval found IS the reply, as a list
// the reader opens themselves. Said plainly, because a search without a
// model finds only what is written literally.
let {
code,
sources,
onOpen
}: { code: string; sources: SourceChunk[]; onOpen: (source: SourceChunk) => void } = $props();
</script>
<p class="flex items-start gap-1.5 text-xs text-warning" data-testid="fallback-note">
<Info size={12} class="mt-0.5 shrink-0" />
<span>{errorMessage(code)} {m.chat_fallback_note()}</span>
</p>
{#if sources.length === 0}
<p class="mt-2 text-sm text-ink-muted">{m.chat_fallback_empty()}</p>
{:else}
<ul class="mt-2 flex flex-col gap-1" data-testid="fallback-results">
{#each groupSources(sources) as source (source.document_id)}
<li>
<button
type="button"
class="w-full cursor-pointer rounded-lg border border-border px-3 py-2 text-left transition-colors hover:border-border-strong"
onclick={() => onOpen(source.chunks[0])}
>
<span class="flex items-center gap-1.5 text-sm font-medium">
{#if source.reviewPending}
<AlertTriangle size={13} class="shrink-0 text-warning" />
{:else}
<FileText size={13} class="shrink-0 text-accent" />
{/if}
{source.title}
</span>
{#if source.reviewPending}
<span class="mt-0.5 block text-xs text-warning">
{m.chat_source_review_pending()}
</span>
{/if}
{#if source.chunks[0].heading_path}
<span class="mt-0.5 block text-xs text-ink-muted">
{source.chunks[0].heading_path}
</span>
{/if}
<!-- Plain text: an excerpt is document content, unrendered. -->
<span class="mt-1 block text-xs text-ink-muted">{source.chunks[0].excerpt}</span>
</button>
</li>
{/each}
</ul>
{/if}
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import FileText from '@lucide/svelte/icons/file-text';
import type { SourceChunk } from '$lib/api/stream';
import type { GroupedSource } from '$lib/chat/sources';
import Tooltip from '$lib/components/Tooltip.svelte';
import { m } from '$lib/paraglide/messages';
type Props = {
source: GroupedSource;
onOpen: (source: SourceChunk) => void;
};
let { source, onOpen }: Props = $props();
const matches = $derived(source.chunks.length);
</script>
<!-- Opening jumps to the best-matching section, which is the first chunk:
retrieval returns them in relevance order. -->
<Tooltip side="top" onclick={() => onOpen(source.chunks[0])} data-testid="source-badge">
{#snippet content()}
{#if source.reviewPending}
<span class="mb-2 block font-medium text-warning">{m.chat_source_review_pending()}</span>
{/if}
<!-- Plain text only: excerpts are document content and stay unrendered. -->
{#each source.chunks as chunk, index (chunk.heading_path + index)}
<span class="mt-2 block first:mt-0">
{#if chunk.heading_path}
<span class="block font-medium text-ink-muted">{chunk.heading_path}</span>
{/if}
<span class="block">{chunk.excerpt || 'No preview available.'}</span>
</span>
{/each}
{/snippet}
<!-- A cited document with an open question is marked here, where the answer
is read: the text may be out of date and the reader has to know. -->
<span
class="inline-flex items-center gap-1 rounded-full border bg-surface-sunken px-2 py-0.5 text-xs transition-colors hover:text-ink {source.reviewPending
? 'border-warning/60 text-warning'
: 'border-border text-ink-muted hover:border-border-strong'}"
>
{#if source.reviewPending}
<AlertTriangle size={12} data-testid="source-review-pending" />
{:else}
<FileText size={12} />
{/if}
{source.title}
{#if matches > 1}
<span class="text-ink-muted opacity-70" data-testid="source-match-count">
{m.chat_source_sections({ count: matches })}
</span>
{/if}
</span>
</Tooltip>
@@ -0,0 +1,29 @@
// Recent conversations are shown in the sidebar (every page) and driven by
// the chat page, so the list lives in one module-scope store rather than in
// ChatState. A layout `load` would refetch on every navigation and could not
// be refreshed right after a turn finishes.
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
export type ConversationSummary = components['schemas']['ConversationSummary'];
class ConversationStore {
items = $state<ConversationSummary[]>([]);
loaded = $state(false);
async load(): Promise<void> {
const { data } = await api.GET('/api/conversations');
this.items = data ?? [];
this.loaded = true;
}
async remove(id: string): Promise<void> {
await api.DELETE('/api/conversations/{conversation_id}', {
params: { path: { conversation_id: id } }
});
await this.load();
}
}
export const conversationStore = new ConversationStore();
+38
View File
@@ -0,0 +1,38 @@
import type { SourceChunk } from '$lib/api/stream';
/** One cited document, with every chunk of it that matched.
*
* Retrieval works on chunks, so a document whose introduction and whose
* appendix both match arrives as two sources. Showing that as two identical
* badges reads as two documents. Deduplication is presentation only: the
* SSE payload and the persisted `messages.meta` stay chunk-level, because
* the individual heading paths are what the popover lists.
*/
export type GroupedSource = {
document_id: string;
title: string;
/** In arrival order, which is relevance order. */
chunks: SourceChunk[];
/** The document has an unanswered request to check it — a property of the
* document, so any chunk carrying it marks the whole source. */
reviewPending: boolean;
};
export function groupSources(sources: SourceChunk[]): GroupedSource[] {
const byDocument = new Map<string, GroupedSource>();
for (const source of sources) {
const existing = byDocument.get(source.document_id);
if (existing) {
existing.chunks.push(source);
existing.reviewPending ||= source.review_pending === true;
continue;
}
byDocument.set(source.document_id, {
document_id: source.document_id,
title: source.title,
chunks: [source],
reviewPending: source.review_pending === true
});
}
return [...byDocument.values()];
}
+219
View File
@@ -0,0 +1,219 @@
// Chat state as a Svelte 5 runes class (no legacy stores).
//
// Query (RAG Q&A) only — capture is no longer a conversation; it writes a
// Document directly (see $lib/documents/WritingEditor.svelte).
import { api } from '$lib/api/client';
import { errorMessage } from '$lib/api/errors';
import { streamMessage, type SourceChunk, type StreamEvent } from '$lib/api/stream';
import { conversationStore } from '$lib/chat/conversations.svelte';
import { m } from '$lib/paraglide/messages';
export type { ConversationSummary } from '$lib/chat/conversations.svelte';
export type ChatMessage = {
role: 'user' | 'assistant';
content: string;
sources: SourceChunk[];
streaming: boolean;
/** The streamed tokens, kept separate so the live view can fade each one
* in (see StreamingText); `content` stays the source for the final render. */
tokens: string[];
/** Retrieval found nothing solid — offer to capture the knowledge. */
noAnswer: boolean;
/** The question that hit the gap (kept for a future retrieval-aware entry). */
gapQuestion: string;
/** No model answered this turn: the `llm_*` code that caused it, and
* `sources` is a plain full-text hit list instead of citations. */
fallback: string | null;
};
/** Transient query-mode progress; cleared when the turn ends. */
export type RetrievalProgress = { phase: string; count: number | null };
/** How long a turn may stay silent before we say so. Long enough that a
* normal local model never trips it, short enough to beat impatience. */
const SLOW_TURN_MS = 12_000;
export class ChatState {
activeId = $state<string | null>(null);
retrieval = $state<RetrievalProgress | null>(null);
messages = $state<ChatMessage[]>([]);
streaming = $state(false);
error = $state<string | null>(null);
/** Nothing has come back for a while: a busy endpoint queues the request
* instead of refusing it, so silence is the only symptom the user gets. */
slow = $state(false);
#abort: AbortController | null = null;
#slowTimer: ReturnType<typeof setTimeout> | null = null;
/** Shared with the sidebar — same list, one fetch. */
get conversations() {
return conversationStore.items;
}
async loadConversations(): Promise<void> {
await conversationStore.load();
}
async open(id: string): Promise<void> {
this.error = null;
this.activeId = id;
this.retrieval = null;
const { data } = await api.GET('/api/conversations/{conversation_id}', {
params: { path: { conversation_id: id } }
});
this.messages = (data?.messages ?? [])
.filter((message) => message.role === 'user' || message.role === 'assistant')
.map((message) => ({
role: message.role as 'user' | 'assistant',
content: message.content,
sources: message.sources ?? [],
streaming: false,
tokens: [],
noAnswer: false,
gapQuestion: '',
fallback: message.fallback ?? null
}));
}
startNew(): void {
this.activeId = null;
this.retrieval = null;
this.messages = [];
this.error = null;
}
async remove(id: string): Promise<void> {
if (this.activeId === id) {
this.startNew();
}
await conversationStore.remove(id);
}
#startSlowTimer(): void {
this.#clearSlowTimer();
this.slow = false;
this.#slowTimer = setTimeout(() => (this.slow = true), SLOW_TURN_MS);
}
#clearSlowTimer(): void {
if (this.#slowTimer !== null) clearTimeout(this.#slowTimer);
this.#slowTimer = null;
this.slow = false;
}
async send(content: string): Promise<void> {
if (this.streaming) return;
// Claim the turn before awaiting anything: creating the conversation
// takes a round trip, and a second call in that window would create
// a second conversation (double-click, or two navigation callbacks).
this.streaming = true;
this.error = null;
let conversationId = this.activeId;
if (!conversationId) {
const { data } = await api.POST('/api/conversations', {
body: { mode: 'query' }
});
if (!data) {
this.error = m.chat_error_start_conversation();
this.streaming = false;
return;
}
conversationId = data.id;
this.activeId = data.id;
}
this.messages.push({
role: 'user',
content,
sources: [],
streaming: false,
tokens: [],
noAnswer: false,
gapQuestion: '',
fallback: null
});
await this.#stream((signal) => streamMessage(conversationId, content, signal), content);
}
/** One streaming turn: append an assistant bubble and drain the events. */
async #stream(
open: (signal: AbortSignal) => AsyncGenerator<StreamEvent>,
question = ''
): Promise<void> {
this.messages.push({
role: 'assistant',
content: '',
sources: [],
streaming: true,
tokens: [],
noAnswer: false,
gapQuestion: '',
fallback: null
});
const assistant = this.messages[this.messages.length - 1];
this.streaming = true;
this.#abort = new AbortController();
this.#startSlowTimer();
try {
for await (const event of open(this.#abort.signal)) {
if (event.type === 'token') {
this.#clearSlowTimer();
assistant.content += event.text;
assistant.tokens.push(event.text);
} else if (event.type === 'sources') {
assistant.sources = event.chunks;
} else if (event.type === 'state') {
this.retrieval = { phase: event.phase, count: event.count };
if (event.phase === 'no_answer') {
assistant.noAnswer = true;
assistant.gapQuestion = question;
}
} else if (event.type === 'error') {
this.error = errorMessage(event.code);
} else if (event.type === 'fallback') {
// Not an error: the turn ends as a plain search the user reads.
assistant.fallback = event.code;
}
}
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
if (!aborted) {
this.error = m.chat_error_connection_lost();
}
} finally {
this.#clearSlowTimer();
// A turn that failed or was stopped before the first token leaves an
// empty speech bubble behind, which reads as a broken reply. A
// fallback turn is empty ON PURPOSE — its reply is the source list.
if (
!assistant.content &&
!assistant.fallback &&
this.messages[this.messages.length - 1] === assistant
) {
this.messages.pop();
}
assistant.streaming = false;
this.streaming = false;
this.retrieval = null;
this.#abort = null;
void this.loadConversations();
}
}
stop(): void {
this.#abort?.abort();
}
}
/** One instance for the whole app, like `conversationStore`.
*
* The first message on /chat creates a conversation and the URL moves to
* /chat/[id], which unmounts one page component and mounts another. A
* per-component state would take the in-flight stream and the messages
* already on screen down with it, so the state outlives the route.
*/
export const chatState = new ChatState();
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'accent';
title?: string;
class?: string;
children: Snippet;
[key: string]: unknown;
};
let { variant = 'neutral', title, class: className = '', children, ...rest }: Props = $props();
const variantClasses: Record<NonNullable<Props['variant']>, string> = {
neutral: 'bg-surface-sunken text-ink-muted',
info: 'bg-surface-sunken text-secondary',
success: 'bg-success-muted text-success',
warning: 'bg-warning-muted text-warning',
danger: 'bg-danger-muted text-danger',
accent: 'bg-accent text-accent-fg'
};
</script>
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {variantClasses[
variant
]} {className}"
{title}
{...rest}
>
{@render children()}
</span>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { Button } from 'bits-ui';
type Props = Button.RootProps & {
variant?: 'primary' | 'secondary' | 'accent' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'icon';
};
let {
variant = 'primary',
size = 'md',
class: className = '',
children,
...rest
}: Props = $props();
const variantClasses: Record<NonNullable<Props['variant']>, string> = {
primary: 'bg-primary text-primary-fg hover:bg-primary-hover',
secondary: 'bg-secondary text-secondary-fg hover:bg-secondary-hover',
accent: 'bg-accent text-accent-fg hover:bg-accent-hover',
ghost: 'bg-transparent text-ink hover:bg-surface-sunken',
danger: 'bg-danger text-danger-fg hover:bg-danger-hover'
};
const sizeClasses: Record<NonNullable<Props['size']>, string> = {
sm: 'px-3 py-1.5 text-sm rounded-full',
md: 'px-4 py-2 text-sm rounded-full',
// Square-ish circle for icon-only actions — pass an aria-label.
// `shrink-0` because a circle is the whole point: as a flex item next
// to a line of text it would otherwise squash into an oval on a
// narrow window.
icon: 'h-9 w-9 shrink-0 rounded-full'
};
</script>
<Button.Root
class="inline-flex cursor-pointer items-center justify-center gap-2 font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 {variantClasses[
variant
]} {sizeClasses[size]} {className}"
{...rest}
>
{@render children?.()}
</Button.Root>
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Props = {
class?: string;
children: Snippet;
};
let { class: className = '', children }: Props = $props();
</script>
<div class="rounded-xl border border-border bg-surface-raised p-6 shadow-sm {className}">
{@render children()}
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import { m } from '$lib/paraglide/messages';
// A destructive-action confirmation in the app's own modal, so delete flows
// look consistent instead of falling back to the browser's confirm().
// Driven by the caller: `open` reflects a pending target, `onClose` clears it
// (cancel, escape, overlay or the X), `onConfirm` runs the action.
let {
open = false,
title,
message,
confirmLabel,
onConfirm,
onClose
}: {
open?: boolean;
title: string;
message?: string;
confirmLabel?: string;
onConfirm: () => void;
onClose?: () => void;
} = $props();
</script>
<Dialog
{open}
onOpenChange={(next) => {
if (!next) onClose?.();
}}
{title}
description={message}
data-testid="confirm-dialog"
>
<div class="mt-2 flex justify-end gap-2">
<Button variant="ghost" size="sm" onclick={() => onClose?.()}>
{m.common_cancel()}
</Button>
<Button
variant="danger"
size="sm"
data-testid="confirm-accept"
onclick={() => {
onConfirm();
onClose?.();
}}
>
{confirmLabel ?? m.common_delete()}
</Button>
</div>
</Dialog>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { Dialog } from 'bits-ui';
import X from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import { m } from '$lib/paraglide/messages';
type Props = {
open?: boolean;
/** For callers whose open state is derived from something else (an
* "editing this row" object, say) and cannot be two-way bound. */
onOpenChange?: (open: boolean) => void;
title: string;
description?: string;
children: Snippet;
contentClass?: string;
'data-testid'?: string;
};
let {
open = $bindable(false),
onOpenChange,
title,
description,
children,
contentClass = '',
'data-testid': testId
}: Props = $props();
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-50 bg-surface-sunken/70 backdrop-blur-sm" />
<Dialog.Content
class="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-surface-raised p-6 shadow-lg {contentClass}"
data-testid={testId}
>
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<Dialog.Title class="text-lg font-semibold">{title}</Dialog.Title>
{#if description}
<Dialog.Description class="mt-1 text-sm text-ink-muted">
{description}
</Dialog.Description>
{/if}
</div>
<Dialog.Close
class="cursor-pointer rounded-md p-1 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label={m.common_close()}
>
<X size={18} />
</Dialog.Close>
</div>
{@render children()}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Label } from 'bits-ui';
import type { Snippet } from 'svelte';
type Props = {
label: string;
for: string;
error?: string | null;
/** Rendered on the label line, right-aligned — for provenance or a
* per-field action that would clutter the field itself. */
hint?: Snippet;
children: Snippet;
};
let { label, for: htmlFor, error = null, hint, children }: Props = $props();
</script>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<Label.Root for={htmlFor} class="text-sm font-medium text-ink">{label}</Label.Root>
{#if hint}{@render hint()}{/if}
</div>
{@render children()}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Component } from 'svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
// An icon-only action: a round hover target with the label as its tooltip
// AND its accessible name. The pattern appeared per table and per header
// before; one component keeps hit area, hover colour and naming identical
// wherever a row or a title offers something to do.
type Props = {
icon: Component<{ size?: number }>;
label: string;
onclick: () => void;
/** danger tints the hover state — deleting should not look like editing. */
variant?: 'neutral' | 'danger';
size?: 'sm' | 'md';
testid?: string;
};
let { icon: Icon, label, onclick, variant = 'neutral', size = 'md', testid }: Props = $props();
const box = $derived(size === 'sm' ? 'h-8 w-8' : 'h-9 w-9');
const glyph = $derived(size === 'sm' ? 15 : 17);
const hover = $derived(
variant === 'danger'
? 'hover:bg-danger-muted hover:text-danger'
: 'hover:bg-surface-sunken hover:text-ink'
);
</script>
<Tooltip text={label} {label} {onclick}>
<span
class="flex items-center justify-center rounded-full text-ink-muted transition-colors {box} {hover}"
data-testid={testid}
>
<Icon size={glyph} />
</span>
</Tooltip>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
type Props = HTMLInputAttributes;
let { value = $bindable(''), class: className = '', ...rest }: Props = $props();
</script>
<!--
Focus lifts the border rather than drawing an outer ring: the ring gets
clipped wherever an input sits inside a scroll container, and this matches
the composer cards on the landing and chat pages.
-->
<input
bind:value
class="w-full rounded-full border border-border bg-surface-raised px-4 py-2 text-sm text-ink transition-colors placeholder:text-ink-muted focus:border-border-strong focus:outline-none disabled:opacity-50 {className}"
{...rest}
/>
@@ -0,0 +1,87 @@
<script lang="ts">
import { browser } from '$app/environment';
import { renderMarkdown } from '$lib/markdown';
let { content }: { content: string } = $props();
// All model and document output is untrusted (a stored-XSS vector):
// render exclusively through the sanitizing renderer (marked + KaTeX +
// DOMPurify, `$lib/markdown`). During SSR there is no DOM for DOMPurify, so
// we fall back to plain text and the browser re-renders after hydration.
const html = $derived(browser ? renderMarkdown(content) : '');
</script>
{#if browser}
<!-- The ONLY sanctioned {@html}: everything went through DOMPurify. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="markdown">{@html html}</div>
{:else}
<div class="markdown whitespace-pre-wrap">{content}</div>
{/if}
<style>
.markdown :global(p) {
margin: 0.5rem 0;
}
.markdown :global(p:first-child) {
margin-top: 0;
}
.markdown :global(p:last-child) {
margin-bottom: 0;
}
.markdown :global(ul),
.markdown :global(ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.markdown :global(ul) {
list-style: disc;
}
.markdown :global(ol) {
list-style: decimal;
}
.markdown :global(h1),
.markdown :global(h2),
.markdown :global(h3),
.markdown :global(h4) {
font-weight: 600;
margin: 0.75rem 0 0.25rem;
}
.markdown :global(code) {
background: var(--pb-surface-sunken);
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
.markdown :global(pre) {
background: var(--pb-surface-sunken);
border-radius: 0.5rem;
padding: 0.75rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.markdown :global(pre code) {
background: transparent;
padding: 0;
}
.markdown :global(a) {
color: var(--pb-secondary);
text-decoration: underline;
}
.markdown :global(blockquote) {
border-left: 3px solid var(--pb-border-strong);
padding-left: 0.75rem;
color: var(--pb-ink-muted);
margin: 0.5rem 0;
}
.markdown :global(table) {
border-collapse: collapse;
margin: 0.5rem 0;
}
.markdown :global(th),
.markdown :global(td) {
border: 1px solid var(--pb-border);
padding: 0.25rem 0.5rem;
text-align: left;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import MoreHorizontal from '@lucide/svelte/icons/ellipsis';
import type { Component } from 'svelte';
import { m } from '$lib/paraglide/messages';
// The quiet half of a screen's actions: everything that has to be findable
// without competing with the one action people came for. Entries are
// labelled — an icon-only row makes the reader guess, which is exactly the
// thing this menu exists to stop.
type Item = {
label: string;
icon?: Component;
onselect: () => void;
/** Destructive entries render in the danger color, at the bottom. */
danger?: boolean;
testid?: string;
};
let {
items,
label,
'data-testid': testId
}: { items: Item[]; label?: string; 'data-testid'?: string } = $props();
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="cursor-pointer rounded-lg border border-border p-2 text-ink-muted transition-colors hover:border-border-strong hover:text-ink focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
aria-label={label ?? m.common_more()}
data-testid={testId}
>
<MoreHorizontal size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={6}
class="z-50 min-w-52 rounded-xl border border-border bg-surface-raised p-1 shadow-lg"
>
{#each items as item (item.label)}
<DropdownMenu.Item
onSelect={item.onselect}
data-testid={item.testid}
class="flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-colors data-highlighted:bg-surface-sunken {item.danger
? 'text-danger'
: 'text-ink'}"
>
{#if item.icon}
{@const Icon = item.icon}
<Icon size={15} class="shrink-0" />
{/if}
{item.label}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
@@ -0,0 +1,44 @@
<script lang="ts">
import { Popover } from 'bits-ui';
import type { Snippet } from 'svelte';
type Props = {
/** Trigger content — rendered inside the Bits trigger button. */
trigger: Snippet;
/** Popover body. */
children: Snippet;
triggerClass?: string;
contentClass?: string;
triggerLabel?: string;
side?: 'top' | 'right' | 'bottom' | 'left';
open?: boolean;
};
let {
trigger,
children,
triggerClass = '',
contentClass = '',
triggerLabel,
side = 'bottom',
open = $bindable(false)
}: Props = $props();
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none {triggerClass}"
aria-label={triggerLabel}
>
{@render trigger()}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
{side}
sideOffset={6}
class="z-50 max-w-sm rounded-lg border border-border bg-surface-raised p-3 text-sm shadow-lg {contentClass}"
>
{@render children()}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import type { HTMLSelectAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
type Props = HTMLSelectAttributes & { children: Snippet };
// The width is the caller's, with full width as the default that suits a
// FormField. Passing `class` REPLACES it rather than fighting it: two
// width utilities in one class attribute are resolved by stylesheet
// order, not by the order they are written, so "w-full w-44" is a coin
// flip.
let { value = $bindable(''), class: className = 'w-full', children, ...rest }: Props = $props();
</script>
<!--
The select twin of Input: same pill shape, same border, same focus
behaviour. It exists because seven selects had drifted into three
different roundings, and a shared primitive is the only way that stays
fixed.
The caret is a real icon rather than a background-image data URI. In a
data URI the SVG is its own document with no CSS context, so
`currentColor` never resolves and the arrow renders black: invisible on
the dark theme, which is the default. An overlaid element inherits
`text-ink-muted` and works in both themes.
`pointer-events-none` on the caret so clicking it still opens the select,
and `pr-9` reserves the space it sits in.
-->
<div class="relative {className}">
<select
bind:value
class="w-full appearance-none rounded-full border border-border bg-surface-raised py-2 pr-9 pl-4 text-sm text-ink transition-colors focus:border-border-strong focus:outline-none disabled:opacity-50"
{...rest}
>
{@render children()}
</select>
<ChevronDown
size={16}
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-ink-muted"
/>
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
// Renders streamed model output token-by-token with a fade-in, the way the
// Claude web UI does: each arriving token materialises instead of snapping
// in. Every token is its own keyed span, so Svelte mounts only the newest
// one per update and its fade plays exactly once — re-rendering the whole
// Markdown string on each token (what {@html} does) would replay every
// element's animation and flicker.
//
// This is plain text on purpose: it is the transient streaming view. The
// caller swaps to the sanitizing <Markdown> renderer once the turn settles,
// so formatting still arrives — just at the end, without mid-stream reflow.
let { tokens }: { tokens: string[] } = $props();
</script>
<div class="streaming-text text-sm break-words whitespace-pre-wrap">
{#each tokens as token, i (i)}<span class="tok">{token}</span>{/each}
</div>
<style>
@keyframes pb-token-in {
from {
opacity: 0;
filter: blur(3px);
}
to {
opacity: 1;
filter: blur(0);
}
}
.tok {
animation: pb-token-in 0.55s ease-out both;
}
/* Motion is decorative; the settled text is the same either way. */
@media (prefers-reduced-motion: reduce) {
.tok {
animation: none;
}
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { Tabs } from 'bits-ui';
import type { Snippet } from 'svelte';
type Tab = { value: string; label: string };
type Props = {
tabs: Tab[];
value?: string;
/** Rendered once per tab; receives that tab's value. */
panel: Snippet<[string]>;
class?: string;
};
let {
tabs,
value = $bindable(tabs[0]?.value ?? ''),
panel,
class: className = ''
}: Props = $props();
</script>
<Tabs.Root bind:value class="flex min-h-0 flex-col {className}">
<Tabs.List class="flex gap-1 rounded-md bg-surface-sunken p-1">
{#each tabs as tab (tab.value)}
<Tabs.Trigger
value={tab.value}
data-testid="tab-{tab.value}"
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium text-ink-muted transition-colors data-[state=active]:bg-surface-raised data-[state=active]:text-ink"
>
{tab.label}
</Tabs.Trigger>
{/each}
</Tabs.List>
{#each tabs as tab (tab.value)}
<Tabs.Content value={tab.value} class="mt-2 min-h-0 flex-1">
<!-- Only the open tab is rendered. Bits keeps inactive panels mounted
(just hidden), which for a page of independent panels means every
one of them fetches its data on arrival and hidden controls sit in
the DOM where a click can never reach them. -->
{#if tab.value === value}{@render panel(tab.value)}{/if}
</Tabs.Content>
{/each}
</Tabs.Root>
@@ -0,0 +1,63 @@
<script lang="ts">
import { Tooltip } from 'bits-ui';
import type { Snippet } from 'svelte';
/**
* Text-only by default: tooltip content is never HTML, so untrusted
* strings (document excerpts, model output) are safe here.
* Pass the `content` snippet for structured — still plain-text — bodies.
*/
type Props = {
text?: string;
content?: Snippet;
children: Snippet;
triggerClass?: string;
side?: 'top' | 'right' | 'bottom' | 'left';
delay?: number;
/** The trigger IS a button — hook clicks here, never nest one inside. */
onclick?: () => void;
/** Accessible name. Required for icon-only triggers, whose visible
* content is an SVG and therefore nameless. */
label?: string;
'data-testid'?: string;
};
let {
text,
content,
children,
triggerClass = '',
side = 'top',
delay = 200,
onclick,
label,
'data-testid': testId
}: Props = $props();
</script>
<Tooltip.Provider delayDuration={delay}>
<Tooltip.Root>
<Tooltip.Trigger
{onclick}
aria-label={label}
data-testid={testId}
class="cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none {triggerClass}"
>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
{side}
sideOffset={6}
data-testid="tooltip-content"
class="z-50 max-w-xs rounded-md border border-border bg-surface-raised px-2.5 py-1.5 text-xs text-ink shadow-lg"
>
{#if content}
{@render content()}
{:else}
{text}
{/if}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,207 @@
<script lang="ts">
import Building2 from '@lucide/svelte/icons/building-2';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Globe from '@lucide/svelte/icons/globe';
import KeyRound from '@lucide/svelte/icons/key-round';
import { SvelteSet } from 'svelte/reactivity';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import Popover from '$lib/components/Popover.svelte';
import Select from '$lib/components/Select.svelte';
import { visibilityLabel } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// "Who can see this?" — the answer is on the chip, the controls are one
// click behind it. Visibility and the extra departments are the same
// question asked twice, so they are answered in one place; both are the
// owner's call, which is why a reviewer sees the answer and no controls.
type Department = components['schemas']['DepartmentOut'];
type DocumentDetail = components['schemas']['DocumentDetail'];
let {
document: doc,
canManage,
onChanged
}: {
document: DocumentDetail;
canManage: boolean;
onChanged?: () => Promise<void> | void;
} = $props();
let open = $state(false);
let departments = $state<Department[]>([]);
const selected = new SvelteSet<string>();
let busy = $state(false);
let error = $state<string | null>(null);
// A change that would remove the editing admin's own access is held until
// they confirm it; `pending` is what they are being asked about.
let pending = $state<{ visibility?: DocumentDetail['visibility'] } | null>(null);
const shared = $derived(doc.shared_departments);
const shareable = $derived(departments.filter((entry) => entry.id !== doc.department_id));
const VisibilityIcon = $derived(doc.visibility === 'restricted' ? KeyRound : Globe);
$effect(() => {
if (!open) return;
selected.clear();
for (const department of shared) selected.add(department.id);
error = null;
pending = null;
void api.GET('/api/departments').then(({ data }) => (departments = data ?? []));
});
function toggle(id: string) {
if (selected.has(id)) selected.delete(id);
else selected.add(id);
}
async function setVisibility(visibility: DocumentDetail['visibility'], confirmLockout = false) {
busy = true;
error = null;
const { response, error: err } = await api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: doc.id } },
body: { visibility, confirm_lockout: confirmLockout }
});
busy = false;
if (response.ok) {
pending = null;
await onChanged?.();
return;
}
if ((err as { code?: string })?.code === 'self_lockout_warning' && !confirmLockout) {
pending = { visibility };
return;
}
error = m.visibility_save_failed();
}
async function saveDepartments(confirmLockout = false) {
busy = true;
error = null;
const { response, error: err } = await api.PUT('/api/documents/{document_id}/departments', {
params: { path: { document_id: doc.id } },
body: { department_ids: [...selected], confirm_lockout: confirmLockout }
});
busy = false;
if (response.ok) {
pending = null;
await onChanged?.();
return;
}
if ((err as { code?: string })?.code === 'self_lockout_warning' && !confirmLockout) {
pending = {};
return;
}
error = m.sharing_save_failed();
}
</script>
<Popover bind:open contentClass="w-80" triggerLabel={m.access_popover_title()}>
{#snippet trigger()}
<span
class="flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-ink-muted transition-colors hover:border-border-strong hover:text-ink"
data-testid="access-chip"
>
<VisibilityIcon size={12} />
{visibilityLabel(doc.visibility)}
{#if shared.length > 0}
<span class="text-ink-muted">{m.access_plus_departments({ count: shared.length })}</span>
{/if}
<ChevronDown size={12} class="opacity-60" />
</span>
{/snippet}
<div class="flex flex-col gap-3" data-testid="access-controls">
<p class="text-sm font-medium">{m.access_popover_title()}</p>
{#if canManage}
<label class="flex flex-col gap-1 text-xs text-ink-muted">
{m.visibility_label()}
<Select
value={doc.visibility}
disabled={busy}
onchange={(event) =>
setVisibility(event.currentTarget.value as DocumentDetail['visibility'])}
data-testid="visibility-select"
>
<option value="public">{m.document_visibility_public()}</option>
<option value="department">{m.document_visibility_department()}</option>
<option value="restricted">{m.document_visibility_restricted()}</option>
</Select>
</label>
{:else}
<p class="text-sm">{m.document_visibility_line({ visibility: doc.visibility })}</p>
{/if}
<div class="flex flex-col gap-1">
<p class="text-xs text-ink-muted">{m.access_extra_departments()}</p>
{#if canManage}
{#if shareable.length === 0}
<p class="text-sm text-ink-muted">{m.sharing_none_shareable()}</p>
{:else}
<ul class="flex max-h-44 flex-col overflow-y-auto">
{#each shareable as department (department.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 text-sm hover:bg-surface-sunken"
>
<input
type="checkbox"
class="accent-accent"
checked={selected.has(department.id)}
onchange={() => toggle(department.id)}
/>
{department.name}
</label>
</li>
{/each}
</ul>
<Button
size="sm"
class="self-start"
disabled={busy}
onclick={() => saveDepartments()}
data-testid="share-save"
>
{m.sharing_save()}
</Button>
{/if}
{:else if shared.length === 0}
<p class="text-sm text-ink-muted">{m.access_no_extra_departments()}</p>
{:else}
<ul class="flex flex-col gap-1 text-sm">
{#each shared as department (department.id)}
<li class="flex items-center gap-1.5">
<Building2 size={13} class="text-ink-muted" />
{department.name}
</li>
{/each}
</ul>
{/if}
</div>
{#if pending}
<div class="flex flex-col gap-2 rounded-lg bg-warning-muted px-3 py-2">
<span class="text-sm text-warning" role="alert">{m.sharing_lockout_warning()}</span>
<div class="flex gap-2">
<Button
variant="danger"
size="sm"
disabled={busy}
onclick={() =>
pending?.visibility ? setVisibility(pending.visibility, true) : saveDepartments(true)}
>
{m.sharing_lockout_confirm()}
</Button>
<Button variant="ghost" size="sm" onclick={() => (pending = null)}>
{m.common_cancel()}
</Button>
</div>
</div>
{/if}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</div>
</Popover>
@@ -0,0 +1,112 @@
<script lang="ts">
import Eye from '@lucide/svelte/icons/eye';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import ReviewRequestForm from '$lib/documents/ReviewRequestForm.svelte';
import { m } from '$lib/paraglide/messages';
// The moment the document becomes readable for everyone it is visible to.
// Offered right here, because this is when the doubt is freshest: if you
// are not sure about a detail, ask someone now — the document stays
// published and carries the question until they answer.
let { open = $bindable(false), documentId }: { open?: boolean; documentId: string } = $props();
let mode = $state<'done' | 'ask'>('done');
let askedName = $state<string | null>(null);
// Reset each time the reward opens (the component stays mounted).
$effect(() => {
if (open) {
mode = 'done';
askedName = null;
}
});
async function view() {
open = false;
await goto(resolve(`/documents/${documentId}`));
}
</script>
<Dialog bind:open title={m.capture_success_title()} data-testid="capture-success">
<div class="flex flex-col items-center gap-4 pt-2 text-center">
<div class="check" aria-hidden="true">
<svg viewBox="0 0 52 52">
<circle cx="26" cy="26" r="24" />
<path d="M15 27l7 7 15-15" />
</svg>
</div>
{#if mode === 'done'}
<p class="max-w-sm text-sm text-ink-muted">{m.capture_success_body()}</p>
<div class="mt-1 flex flex-wrap justify-center gap-2">
<Button onclick={view} data-testid="success-view">
<Eye size={16} />
{m.capture_success_view()}
</Button>
<Button variant="secondary" onclick={() => (mode = 'ask')} data-testid="success-ask">
<MessageCircleQuestion size={16} />
{m.capture_success_ask()}
</Button>
</div>
{:else if askedName}
<p class="text-sm text-ink" data-testid="review-asked">
{m.review_ask_sent({ name: askedName })}
</p>
<Button onclick={view}>{m.capture_success_view()}</Button>
{:else}
<div class="w-full text-left">
<p class="mb-3 text-sm text-ink-muted">{m.review_ask_hint()}</p>
<ReviewRequestForm {documentId} onSent={(name) => void (askedName = name)} />
</div>
<Button variant="ghost" size="sm" onclick={() => (mode = 'done')}>
{m.common_back()}
</Button>
{/if}
</div>
</Dialog>
<style>
.check svg {
width: 4rem;
height: 4rem;
}
.check circle {
fill: none;
stroke: var(--pb-success);
stroke-width: 3;
stroke-dasharray: 151;
stroke-dashoffset: 151;
animation: pb-check-circle 0.5s ease-out forwards;
}
.check path {
fill: none;
stroke: var(--pb-success);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 40;
stroke-dashoffset: 40;
animation: pb-check-mark 0.35s 0.4s ease-out forwards;
}
@keyframes pb-check-circle {
to {
stroke-dashoffset: 0;
}
}
@keyframes pb-check-mark {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.check circle,
.check path {
animation: none;
stroke-dashoffset: 0;
}
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
import BookOpen from '@lucide/svelte/icons/book-open';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import PencilLine from '@lucide/svelte/icons/pencil-line';
import { resolve } from '$app/paths';
import type { DocumentRow } from '$lib/documents/list.svelte';
import { formatDate } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// A row in a list of a hundred: the title carries it, everything else is
// context in one grey line. Badges are for EXCEPTIONS only — "published",
// "public" and "yours" are the normal case, and repeating them on every
// card turns the two rows that actually need attention into more of the
// same. What is marked here: an unanswered question, a draft nobody else
// can see, a help page shipped with the product.
let { document, departmentName }: { document: DocumentRow; departmentName: string | undefined } =
$props();
const flagged = $derived(document.open_reviews > 0);
</script>
<a
href={resolve(`/documents/${document.id}`)}
class="flex h-full flex-col gap-1.5 rounded-xl border bg-surface-raised px-4 py-3 transition-colors hover:border-border-strong {flagged
? 'border-warning/40'
: 'border-border'}"
>
<p class="truncate font-medium">{document.title}</p>
<p class="truncate text-xs text-ink-muted">
<!-- A search hit shows the section that matched; a browsed row its
department, which is what the filters are about. -->
{document.heading_path || departmentName || m.documents_no_department()}
<span class="opacity-70">
· {m.documents_updated_at({ date: formatDate(document.updated_at) })}
</span>
</p>
{#if flagged || document.status !== 'published' || document.is_builtin}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{#if document.open_reviews > 0}
<!-- Somebody asked whether this is still right, and nobody has
answered — true of drafts and published documents alike. -->
<span class="flex items-center gap-1 text-warning" data-testid="open-review-badge">
<MessageCircleQuestion size={12} />
{m.documents_badge_open_reviews({ count: document.open_reviews })}
</span>
{/if}
{#if document.status === 'draft'}
<span class="flex items-center gap-1 text-ink-muted">
<PencilLine size={12} />
{m.document_draft_chip()}
</span>
{:else if document.status === 'archived'}
<span class="text-ink-muted">{m.documents_status_archived()}</span>
{/if}
{#if document.is_builtin}
<span class="flex items-center gap-1 text-secondary">
<BookOpen size={12} />
{m.documents_badge_builtin()}
</span>
{/if}
</div>
{/if}
</a>
@@ -0,0 +1,213 @@
<script lang="ts">
import Download from '@lucide/svelte/icons/download';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import X from '@lucide/svelte/icons/x';
import type { components } from '$lib/api/schema';
import Input from '$lib/components/Input.svelte';
import Select from '$lib/components/Select.svelte';
import type { AccessFilter, DocumentList } from '$lib/documents/list.svelte';
import { documentView } from '$lib/documents/view.svelte';
import { m } from '$lib/paraglide/messages';
// The search field is the tool people reach for; the six ways to narrow a
// list are the tool they reach for once a month. So the field owns the row
// and the rest lives behind one "Filter" toggle — except the filters that
// are currently ON, which stay visible as removable chips, because a list
// that is quietly filtered is a list that lies.
type Department = components['schemas']['DepartmentOut'];
let { list, departments }: { list: DocumentList; departments: Department[] } = $props();
let showFilters = $state(false);
// $derived: a const list keeps the language it was built in.
const SORTS = $derived([
{ value: 'updated' as const, label: m.documents_sort_updated() },
{ value: 'created' as const, label: m.documents_sort_created() }
]);
const ACCESS_FILTERS = $derived([
{ value: 'all' as const, label: m.documents_access_all() },
{ value: 'author' as const, label: m.documents_access_mine() },
{ value: 'department' as const, label: m.documents_access_department() },
{ value: 'public' as const, label: m.documents_access_public() },
{ value: 'granted' as const, label: m.documents_access_granted() }
]);
const statusLabels = $derived<Record<string, string>>({
published: m.documents_status_published(),
draft: m.documents_status_draft(),
archived: m.documents_status_archived()
});
// What is narrowing the list right now, in the words of the control that
// set it — each one removable where it is shown.
const active = $derived.by(() => {
const chips: { label: string; clear: () => void }[] = [];
if (list.status) {
chips.push({
label: statusLabels[list.status] ?? list.status,
clear: () => {
list.status = '';
list.reload();
}
});
}
if (list.department) {
const name = departments.find((entry) => entry.id === list.department)?.name;
if (name) {
chips.push({
label: name,
clear: () => {
list.department = '';
list.reload();
}
});
}
}
if (list.access !== 'all') {
const label = ACCESS_FILTERS.find((entry) => entry.value === list.access)?.label;
if (label) chips.push({ label, clear: () => (list.access = 'all') });
}
if (list.assignedToMe) {
chips.push({
label: m.documents_filter_my_reviews(),
clear: () => {
list.assignedToMe = false;
list.reload();
}
});
}
return chips;
});
const filterHint = $derived(list.searching ? m.documents_filter_disabled_hint() : undefined);
</script>
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-56 flex-1">
<Input
placeholder={m.documents_search_placeholder()}
bind:value={list.search}
oninput={() => list.onSearchInput()}
data-testid="document-search"
/>
</div>
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-2 text-sm transition-colors {showFilters ||
active.length > 0
? 'border-border-strong text-ink'
: 'border-border text-ink-muted hover:text-ink'}"
onclick={() => (showFilters = !showFilters)}
data-testid="filters-toggle"
>
<SlidersHorizontal size={15} />
{m.documents_filters()}
</button>
<!-- A ZIP of everything the user can read (Markdown + frontmatter), built
and streamed by the backend; a plain download, not a route. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href="/api/documents/export"
download
title={m.documents_export_hint()}
class="flex items-center gap-1.5 rounded-full border border-border px-3.5 py-2 text-sm text-ink-muted transition-colors hover:border-border-strong hover:text-ink"
data-testid="export-button"
>
<Download size={15} />
{m.documents_export()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</div>
{#if active.length > 0 && !showFilters}
<div class="flex flex-wrap items-center gap-1.5" data-testid="active-filters">
{#each active as chip (chip.label)}
<button
type="button"
class="flex cursor-pointer items-center gap-1 rounded-full border border-border-strong bg-surface-sunken px-3 py-1 text-xs text-ink"
onclick={chip.clear}
>
{chip.label}
<X size={12} class="opacity-60" />
</button>
{/each}
</div>
{/if}
{#if showFilters}
<div class="flex flex-col gap-2 rounded-xl border border-border bg-surface-raised p-3">
<div class="flex flex-wrap items-end gap-2">
<Select
class="w-44"
bind:value={list.status}
onchange={() => list.reload()}
disabled={list.searching}
title={filterHint}
data-testid="status-filter"
>
<option value="">{m.documents_filter_all_statuses()}</option>
<option value="published">{m.documents_status_published()}</option>
<option value="draft">{m.documents_status_draft()}</option>
<option value="archived">{m.documents_status_archived()}</option>
</Select>
<Select
class="w-44"
bind:value={list.department}
onchange={() => list.reload()}
disabled={list.searching}
title={filterHint}
>
<option value="">{m.documents_filter_all_departments()}</option>
{#each departments as entry (entry.id)}
<option value={entry.id}>{entry.name}</option>
{/each}
</Select>
<Select
class="w-44"
value={documentView.sort}
onchange={(event) => {
documentView.set({ sort: event.currentTarget.value as 'updated' | 'created' });
list.reload();
}}
disabled={list.searching}
title={list.searching ? m.documents_sort_disabled_hint() : undefined}
data-testid="sort-select"
>
{#each SORTS as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</Select>
<!-- Documents somebody asked this user to check. -->
<button
class="cursor-pointer rounded-full border px-3 py-2 text-sm transition-colors {list.assignedToMe
? 'border-accent bg-accent/10 text-ink'
: 'border-border text-ink-muted hover:text-ink'}"
onclick={() => {
list.assignedToMe = !list.assignedToMe;
list.reload();
}}
disabled={list.searching}
data-testid="review-filter"
>
{m.documents_filter_my_reviews()}
</button>
</div>
<div class="flex flex-wrap items-center gap-1.5" data-testid="access-filter">
<span class="mr-1 text-xs text-ink-muted">{m.documents_access_filter_label()}</span>
{#each ACCESS_FILTERS as option (option.value)}
<button
class="cursor-pointer rounded-full border px-3 py-1 text-xs transition-colors {list.access ===
option.value
? 'border-border-strong bg-surface-sunken text-ink'
: 'border-border text-ink-muted hover:text-ink'}"
onclick={() => (list.access = option.value as AccessFilter)}
>
{option.label}
</button>
{/each}
</div>
</div>
{/if}
@@ -0,0 +1,133 @@
<script lang="ts">
import History from '@lucide/svelte/icons/history';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Card from '$lib/components/Card.svelte';
import VersionDiffDialog from '$lib/documents/VersionDiffDialog.svelte';
import { i18n } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
type DocumentEventOut = components['schemas']['DocumentEventOut'];
type DocumentVersion = components['schemas']['DocumentVersion'];
let {
documentId,
canEdit,
onChanged
}: {
documentId: string;
canEdit: boolean;
onChanged?: () => void;
} = $props();
let events = $state<DocumentEventOut[]>([]);
// The last few entries answer "what happened lately"; the whole trail is a
// click away rather than a wall of rows under every document.
let expanded = $state(false);
const SHOWN = 4;
const visible = $derived(expanded ? events : events.slice(0, SHOWN));
// The version whose own change is shown in the modal: its snapshot against
// the one it replaced, so the entry you click is the change you see.
let selected = $state<DocumentVersion | null>(null);
const dateFormat = $derived(
new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium', timeStyle: 'short' })
);
function formatDate(iso: string): string {
return dateFormat.format(new Date(iso));
}
const actionLabels = $derived<Record<string, string>>({
created: m.history_action_created(),
edited: m.history_action_edited(),
published: m.history_action_published(),
archived: m.history_action_archived(),
visibility_changed: m.history_action_visibility_changed(),
review_requested: m.history_action_review_requested(),
review_resolved: m.history_action_review_resolved()
});
async function load() {
const { data } = await api.GET('/api/documents/{document_id}/history', {
params: { path: { document_id: documentId } }
});
events = data ?? [];
}
$effect(() => {
void documentId;
void load();
});
async function view(event: DocumentEventOut) {
const { data } = await api.GET('/api/documents/{document_id}/versions/{event_id}', {
params: { path: { document_id: documentId, event_id: event.id } }
});
if (data) selected = data;
}
async function restore() {
if (!selected?.content_md) return;
await api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: documentId } },
body: { content_md: selected.content_md }
});
selected = null;
await load();
onChanged?.();
}
</script>
<Card>
<h2 class="flex items-center gap-1.5 text-sm font-semibold text-ink-muted">
<History size={14} />
{m.history_title()}
</h2>
{#if events.length === 0}
<p class="mt-2 text-sm text-ink-muted">{m.history_empty()}</p>
{:else}
<ol class="mt-3 space-y-2" data-testid="document-history">
{#each visible as event (event.id)}
<li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
<span class="font-medium">{actionLabels[event.action] ?? event.action}</span>
<span class="text-ink-muted">
{m.history_by({ actor: event.actor_name ?? m.history_actor_unknown() })}
</span>
<span class="text-xs text-ink-muted">{formatDate(event.created_at)}</span>
{#if event.has_snapshot}
<button
type="button"
class="cursor-pointer text-xs text-secondary underline"
onclick={() => view(event)}
>
{m.history_view_changes()}
</button>
{/if}
</li>
{/each}
</ol>
{#if events.length > SHOWN}
<button
type="button"
class="mt-2 cursor-pointer text-xs text-secondary underline"
onclick={() => (expanded = !expanded)}
data-testid="history-toggle"
>
{expanded ? m.history_show_less() : m.history_show_all({ count: events.length })}
</button>
{/if}
{/if}
</Card>
{#if selected}
<VersionDiffDialog
open={true}
onClose={() => (selected = null)}
title={m.history_diff_title()}
description={formatDate(selected.created_at)}
original={selected.previous_content_md ?? ''}
modified={selected.content_md ?? ''}
restoreLabel={canEdit ? m.history_restore() : undefined}
onRestore={canEdit ? restore : undefined}
/>
{/if}
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import ClipboardCheck from '@lucide/svelte/icons/clipboard-check';
import PencilLine from '@lucide/svelte/icons/pencil-line';
import Send from '@lucide/svelte/icons/send';
import { resolve } from '$app/paths';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import { formatDate } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// What is waiting for this person, on the page they land on: drafts they
// started and never published, and documents a colleague asked them to
// check. Both are invisible everywhere else — a draft is private by
// definition, and a question addressed to you is easy to miss in a list —
// so they are the one thing the landing page volunteers.
type DocumentSummary = components['schemas']['DocumentSummary'];
const SHOWN = 3;
let drafts = $state<DocumentSummary[]>([]);
let reviewCount = $state(0);
let publishing = $state<string | null>(null);
async function load() {
// Drafts are author-only, bar one exception: a draft someone asked you
// to check is readable too. "Your drafts" means the ones you wrote.
const [mine, queue] = await Promise.all([
api.GET('/api/documents', {
params: { query: { status: 'draft', sort: 'updated', per_page: 20 } }
}),
api.GET('/api/documents', {
params: { query: { assigned_to_me: true, per_page: 1 } }
})
]);
drafts = (mine.data?.items ?? []).filter((item) => item.access_reason === 'author');
reviewCount = queue.data?.total ?? 0;
}
$effect(() => {
void load();
});
async function publish(id: string) {
publishing = id;
await api.POST('/api/documents/{document_id}/publish', {
params: { path: { document_id: id } }
});
publishing = null;
await load();
}
</script>
{#if reviewCount > 0}
<!-- The path IS resolved; the query string is data, not part of the route. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href="{resolve('/documents')}?review=1"
class="flex items-center justify-between gap-3 rounded-xl border border-accent bg-accent/10 px-4 py-3 text-sm transition-colors hover:border-accent-hover"
data-testid="review-queue-banner"
>
<span class="flex items-center gap-2 font-medium">
<ClipboardCheck size={16} class="text-accent" />
{m.landing_review_pending({ count: reviewCount })}
</span>
<span class="whitespace-nowrap text-accent">{m.landing_review_open()}</span>
</a>
{/if}
{#if drafts.length > 0}
<div class="rounded-xl border border-border bg-surface-raised p-4" data-testid="drafts-card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="flex items-center gap-2 text-sm font-medium">
<PencilLine size={15} class="text-ink-muted" />
{m.landing_drafts_title({ count: drafts.length })}
</p>
<p class="text-xs text-ink-muted">{m.landing_drafts_hint()}</p>
</div>
<ul class="mt-2 flex flex-col divide-y divide-border">
{#each drafts.slice(0, SHOWN) as draft (draft.id)}
<li class="flex flex-wrap items-center gap-2 py-2">
<a
href={resolve(`/documents/${draft.id}/edit`)}
class="min-w-0 flex-1 truncate text-sm hover:underline"
>
{draft.title}
<span class="ml-1 text-xs text-ink-muted">
{m.documents_updated_at({ date: formatDate(draft.updated_at) })}
</span>
</a>
<button
class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border px-3 py-1 text-xs text-ink-muted transition-colors hover:border-accent hover:text-ink disabled:opacity-50"
onclick={() => publish(draft.id)}
disabled={publishing === draft.id}
data-testid="draft-publish"
>
<Send size={13} />
{m.landing_drafts_publish()}
</button>
</li>
{/each}
</ul>
{#if drafts.length > SHOWN}
<!-- The path IS resolved; the query string is data, not part of the route. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a
href="{resolve('/documents')}?status=draft"
class="mt-1 inline-block text-xs text-secondary underline"
>
{m.landing_drafts_all({ count: drafts.length })}
</a>
{/if}
</div>
{/if}
@@ -0,0 +1,129 @@
<script lang="ts">
import Check from '@lucide/svelte/icons/check';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import Pencil from '@lucide/svelte/icons/pencil';
import UserCheck from '@lucide/svelte/icons/user-check';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import { formatDate } from '$lib/documents/presentation';
import { m } from '$lib/paraglide/messages';
// What is still open on this document, and what the reader can do about it.
//
// An open question is the one thing a reader must see before trusting the
// text, so it sits above the content — not in the history, not behind a
// tab. The person who was asked gets the answer buttons; the author can
// close a question that has become moot.
type DocumentDetail = components['schemas']['DocumentDetail'];
let {
document: doc,
onEdit,
onChanged
}: {
document: DocumentDetail;
onEdit: () => void;
onChanged: () => Promise<void> | void;
} = $props();
let busy = $state(false);
let error = $state<string | null>(null);
const open = $derived(doc.reviews.filter((review) => review.resolved_at === null));
const answered = $derived(doc.reviews.filter((review) => review.resolved_at !== null));
async function resolve(reviewId: string) {
busy = true;
error = null;
const { data } = await api.POST('/api/documents/{document_id}/reviews/{review_id}/resolve', {
params: { path: { document_id: doc.id, review_id: reviewId } }
});
busy = false;
if (!data) {
error = m.document_review_failed();
return;
}
await onChanged();
}
</script>
{#if open.length > 0}
<div
class="flex flex-col gap-3 rounded-xl border border-warning/40 bg-warning-muted p-4"
data-testid="open-reviews"
>
{#each open as review (review.id)}
<div class="flex flex-col gap-2">
<p class="flex items-start gap-2 text-sm">
<MessageCircleQuestion size={16} class="mt-0.5 shrink-0 text-warning" />
<span>
{#if review.question}
<span class="text-ink-muted">
{m.document_review_asked_by({ name: review.requester_name ?? '' })}
</span>
<!-- The question is user-written text: plain, never rendered. -->
<span class="font-medium">{review.question}</span>
{:else}
<span class="font-medium">
{m.document_review_asked_plain({ name: review.requester_name ?? '' })}
</span>
{/if}
<span class="mt-0.5 block text-xs text-ink-muted">
{m.document_review_waiting_on({
name: review.reviewer_name ?? '',
date: formatDate(review.created_at)
})}
</span>
</span>
</p>
<div class="flex flex-wrap gap-2 pl-6">
{#if review.is_mine}
<Button
size="sm"
disabled={busy}
onclick={() => resolve(review.id)}
data-testid="review-confirm"
>
<Check size={15} />
{m.document_review_confirm()}
</Button>
{#if doc.can_edit}
<Button size="sm" variant="secondary" onclick={onEdit}>
<Pencil size={15} />
{m.document_review_fix()}
</Button>
{/if}
{:else if doc.can_edit}
<Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => resolve(review.id)}
data-testid="review-close"
>
{m.document_review_close()}
</Button>
{/if}
</div>
</div>
{/each}
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
</div>
{/if}
{#if answered.length > 0}
<ul class="flex flex-col gap-1 text-xs text-ink-muted" data-testid="answered-reviews">
{#each answered as review (review.id)}
<li class="flex items-center gap-1.5">
<UserCheck size={13} class="shrink-0 text-success" />
{m.document_review_answered({
name: review.resolved_by_name ?? '',
date: formatDate(review.resolved_at ?? review.created_at)
})}
</li>
{/each}
</ul>
{/if}
@@ -0,0 +1,85 @@
<script lang="ts">
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import Button from '$lib/components/Button.svelte';
import Select from '$lib/components/Select.svelte';
import { m } from '$lib/paraglide/messages';
// Asking a colleague to check something: who, and what exactly to look at.
// The question is the point — "please review" says nothing, "do the 14
// holiday days still hold?" is answerable — so it gets the larger field,
// but it stays optional.
type ReviewerCandidate = components['schemas']['ReviewerCandidate'];
let {
documentId,
onSent
}: { documentId: string; onSent: (name: string) => Promise<void> | void } = $props();
let candidates = $state<ReviewerCandidate[]>([]);
let loaded = $state(false);
let selected = $state('');
let question = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
$effect(() => {
void (async () => {
const { data } = await api.GET('/api/documents/{document_id}/reviewers', {
params: { path: { document_id: documentId } }
});
candidates = data ?? [];
selected = candidates[0]?.id ?? '';
loaded = true;
})();
});
async function send() {
if (!selected) return;
busy = true;
error = null;
const { data } = await api.POST('/api/documents/{document_id}/reviews', {
params: { path: { document_id: documentId } },
body: { reviewer_id: selected, question: question.trim() || null }
});
busy = false;
if (!data) {
error = m.review_ask_failed();
return;
}
await onSent(candidates.find((candidate) => candidate.id === selected)?.name ?? '');
}
</script>
{#if loaded && candidates.length === 0}
<p class="text-sm text-ink-muted" data-testid="review-ask-empty">{m.review_ask_none()}</p>
{:else}
<div class="flex flex-col gap-3">
<label class="flex flex-col gap-1 text-sm">
<span class="text-ink-muted">{m.review_ask_reviewer()}</span>
<Select bind:value={selected} data-testid="reviewer-select">
{#each candidates as candidate (candidate.id)}
<option value={candidate.id}>{candidate.name}</option>
{/each}
</Select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="text-ink-muted">{m.review_ask_question()}</span>
<textarea
bind:value={question}
rows="3"
maxlength="2000"
placeholder={m.review_ask_question_placeholder()}
class="w-full resize-y rounded-lg border border-border bg-surface p-2.5 text-sm text-ink transition-colors placeholder:text-ink-muted focus:border-border-strong focus:outline-none"
data-testid="review-question"></textarea>
</label>
{#if error}
<p role="alert" class="text-sm text-danger">{error}</p>
{/if}
<div class="flex justify-end">
<Button onclick={send} disabled={busy || !selected} data-testid="review-ask-send">
{m.review_ask_send()}
</Button>
</div>
</div>
{/if}
@@ -0,0 +1,154 @@
<script lang="ts">
import Sparkles from '@lucide/svelte/icons/sparkles';
import { lineNumbers, EditorView } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { api } from '$lib/api/client';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import Input from '$lib/components/Input.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import { editorTheme } from '$lib/documents/editorTheme';
import { m } from '$lib/paraglide/messages';
// Read before you save: saving shows what changed since the last save and
// asks again. There is no autosave, because this look at the diff is the
// point — you see what you are about to put your name on, and can still
// back out. The title sits right next to it: this is the moment you notice
// that the document is still called "Onboarding: Neue Kollegin". A draft
// can go straight from here to published.
type Props = {
open: boolean;
documentId: string;
/** The text being saved, and the last saved text it is diffed against. */
content: string;
baseline: string;
title: string;
/** Whether this save may also publish: a draft, and the caller's to
* publish. */
canPublish: boolean;
busy: boolean;
error: string | null;
onSave: () => void;
onPublish: () => void;
};
let {
open = $bindable(),
documentId,
content,
baseline,
title = $bindable(),
canPublish,
busy,
error,
onSave,
onPublish
}: Props = $props();
let mergeHost = $state<HTMLDivElement>();
let suggesting = $state(false);
let suggestError = $state<string | null>(null);
const unchanged = $derived(content === baseline);
// A read-only unified merge view (VSCode-style), mounted only while open.
$effect(() => {
if (!open || !mergeHost) return;
const view = new EditorView({
parent: mergeHost,
doc: content,
extensions: [
lineNumbers(),
markdown(),
EditorView.lineWrapping,
EditorView.editable.of(false),
editorTheme,
unifiedMergeView({ original: baseline, mergeControls: false })
]
});
return () => view.destroy();
});
// Asked for, not volunteered: a title suggestion costs a model call, and
// most saves are on a document that is already named. The result lands in
// the field, where it can be edited or typed over.
async function suggestTitle() {
suggesting = true;
suggestError = null;
const { data } = await api.POST('/api/documents/{document_id}/suggest-title', {
params: { path: { document_id: documentId } }
});
suggesting = false;
if (!data) {
suggestError = m.editor_title_suggest_failed();
return;
}
title = data.title;
}
</script>
<Dialog
bind:open
title={m.editor_save_title()}
description={m.editor_save_hint()}
contentClass="w-[min(52rem,calc(100vw-2rem))]"
data-testid="editor-save-dialog"
>
<div class="mb-3 flex flex-col gap-1">
<span class="text-sm text-ink-muted">{m.editor_title_label()}</span>
<div class="flex items-center gap-2">
<Input bind:value={title} class="flex-1 font-medium" data-testid="save-title" />
<Tooltip text={m.editor_title_suggest()}>
<button
type="button"
class="cursor-pointer rounded-full border border-border p-2 text-accent transition-colors hover:border-accent disabled:opacity-50"
onclick={suggestTitle}
disabled={suggesting}
aria-label={m.editor_title_suggest()}
data-testid="title-suggest"
>
<Sparkles size={16} class={suggesting ? 'animate-pulse' : ''} />
</button>
</Tooltip>
</div>
{#if suggestError}
<p role="alert" class="text-sm text-danger">{suggestError}</p>
{/if}
</div>
{#if unchanged}
<p class="text-sm text-ink-muted" data-testid="editor-no-changes">{m.editor_no_changes()}</p>
{:else}
<div
bind:this={mergeHost}
class="max-h-[55vh] overflow-auto rounded-lg border border-border bg-surface"
data-testid="editor-diff"
></div>
{/if}
{#if error}
<p role="alert" class="mt-2 text-sm text-danger">{error}</p>
{/if}
<div class="mt-4 flex flex-wrap justify-end gap-2">
<Button variant="ghost" disabled={busy} onclick={() => (open = false)}>
{m.common_cancel()}
</Button>
<Button
variant={canPublish ? 'secondary' : 'primary'}
disabled={busy}
onclick={onSave}
data-testid="editor-save"
>
{m.editor_save()}
</Button>
{#if canPublish}
<!-- The draft's way out: saved and readable in one step, the author's
own decision — nobody has to approve it. -->
<Button disabled={busy} onclick={onPublish} data-testid="editor-publish">
{m.editor_save_and_publish()}
</Button>
{/if}
</div>
</Dialog>
@@ -0,0 +1,72 @@
<script lang="ts">
import { EditorView, lineNumbers } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import { editorTheme } from '$lib/documents/editorTheme';
let {
open = $bindable(false),
onClose,
title,
description,
original,
modified,
restoreLabel,
onRestore
}: {
open?: boolean;
onClose?: () => void;
title: string;
description?: string;
// Read-only unified diff: `original` (the older text) on the left,
// `modified` (usually the current document) as the working copy.
original: string;
modified: string;
restoreLabel?: string;
onRestore?: () => void;
} = $props();
let host = $state<HTMLDivElement>();
// Mounted only while the modal is open (the host binds when the portal
// renders), like the editor's own pre-save diff.
$effect(() => {
if (!open || !host) return;
const view = new EditorView({
parent: host,
doc: modified,
extensions: [
lineNumbers(),
markdown(),
EditorView.lineWrapping,
EditorView.editable.of(false),
editorTheme,
unifiedMergeView({ original, mergeControls: false })
]
});
return () => view.destroy();
});
</script>
<Dialog
bind:open
onOpenChange={(next) => {
if (!next) onClose?.();
}}
{title}
{description}
contentClass="w-[min(52rem,calc(100vw-2rem))]"
data-testid="version-diff"
>
<div
bind:this={host}
class="max-h-[60vh] overflow-auto rounded-md border border-border p-2"
></div>
{#if restoreLabel && onRestore}
<div class="mt-4 flex justify-end">
<Button variant="ghost" size="sm" onclick={onRestore}>{restoreLabel}</Button>
</div>
{/if}
</Dialog>
@@ -0,0 +1,464 @@
<script lang="ts">
import { EditorView, keymap, lineNumbers, drawSelection } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { markdown } from '@codemirror/lang-markdown';
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { api } from '$lib/api/client';
import { errorMessage } from '$lib/api/errors';
import type { components } from '$lib/api/schema';
import { streamRefine } from '$lib/api/refine';
import Button from '$lib/components/Button.svelte';
import Input from '$lib/components/Input.svelte';
import CaptureSuccess from '$lib/documents/CaptureSuccess.svelte';
import SaveDialog from '$lib/documents/SaveDialog.svelte';
import { InlineSuggestion } from '$lib/documents/editor/inlineSuggestion';
import { editorTheme } from '$lib/documents/editorTheme';
import { activeSection } from '$lib/documents/sections';
import { i18n } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
import { untrack } from 'svelte';
type DocumentDetail = components['schemas']['DocumentDetail'];
let { document: doc }: { document: DocumentDetail } = $props();
// The route mounts this keyed on `doc.id`, so the document is fixed for the
// component's life: read it once (untrack) and keep editable copies.
const documentId = untrack(() => doc.id);
const isDraft = untrack(() => doc.status === 'draft');
const initialDoc = untrack(() => doc.content_md);
// Publishing is the owner's call. A colleague asked to check a draft edits
// and saves here like anyone else, but does not decide who gets to read it.
const canPublish = untrack(
() => doc.access_reason === 'author' || page.data.user?.role === 'admin'
);
let content = $state(initialDoc);
let baseline = $state(initialDoc); // last saved — the diff is against this
let title = $state(untrack(() => doc.title));
let busy = $state(false);
// Two errors, because they belong to two surfaces: a refinement that could
// not run is about the text you are writing (and is shown once, with the
// pause), a failed save is about the dialog you are standing in. Sharing
// one made the save modal report that the model was unreachable.
let error = $state<string | null>(null);
let saveError = $state<string | null>(null);
let saving = $state(false); // the save/diff modal is open
let published = $state(false); // the success/reward modal is open
// After an accept or dismiss the writer must add some new text before the
// next suggestion fires, so it does not immediately re-propose what it just
// wrote. Starts large so the first suggestion is allowed.
let charsSinceGate = Infinity;
const COOLDOWN_CHARS = 40;
const IDLE_MS = 2000;
// A dead endpoint must not be knocked on every two seconds. After a
// failure the suggestions go quiet and say so, and the next attempt waits:
// a while for an endpoint that is not there, briefly for one that is just
// busy. Writing continues untouched either way — the assistant is the
// optional half of this editor.
const RETRY_MS: Record<string, number> = {
llm_unreachable: 120_000,
llm_misconfigured: 300_000,
llm_busy: 30_000,
llm_failed: 60_000
};
let pausedCode = $state<string | null>(null);
let retryAt = 0;
// What is happening to this document right now, in the writer's terms.
// Picking a documentation type creates the draft immediately (rule 6: the
// document IS the state), and leaving an untouched skeleton deletes it
// again — both are right, and both were invisible, which is what made a
// document that "did not exist yet" confusing. Now the line under the
// editor says which of the three it is.
let savedAt = $state<string | null>(null);
const untouched = $derived(content === initialDoc && isEmptySkeleton(content));
let host = $state<HTMLDivElement>();
let view: EditorView | undefined;
let refineTimer: ReturnType<typeof setTimeout> | undefined;
let refineAbort: AbortController | undefined;
// The suggestion is a block inside the editor, not a pane beside it; it owns
// its DOM and its CodeMirror extension (lib/documents/editor).
const inline = new InlineSuggestion({ onAccept: accept, onDismiss: dismiss });
// Plain locals, not $state: the suggestion is rendered by the widget, so
// nothing here needs to drive Svelte's template.
let suggesting = false;
let suggestion = '';
let suggestionRange: { start: number; end: number } | null = null;
function cursorLine(state: EditorState): number {
return state.doc.lineAt(state.selection.main.head).number;
}
function firstHeading(text: string, range: { start: number; end: number }): string {
const first = text.split('\n')[range.start - 1] ?? '';
const match = /^#{1,6}\s+(.*)$/.exec(first.trim());
return match ? match[1] : '';
}
function cancelSuggestion() {
refineAbort?.abort();
refineAbort = undefined;
suggesting = false;
suggestion = '';
suggestionRange = null;
inline.clear();
}
function scheduleRefine() {
clearTimeout(refineTimer);
// Any edit makes a shown suggestion stale, so drop it and re-arm.
cancelSuggestion();
refineTimer = setTimeout(runRefine, IDLE_MS);
}
async function runRefine() {
if (!view || !content.trim() || charsSinceGate < COOLDOWN_CHARS || suggesting) return;
// Still in the quiet period after a failure: no request, no second
// error message about the same dead endpoint.
if (Date.now() < retryAt) return;
const line = cursorLine(view.state);
const heading = firstHeading(content, activeSection(content, line));
suggesting = true;
suggestion = '';
suggestionRange = null;
refineAbort = new AbortController();
try {
for await (const event of streamRefine(documentId, content, line, refineAbort.signal)) {
if (event.type === 'section') {
suggestionRange = { start: event.start_line, end: event.end_line };
// Anchor the widget just below the section it will replace.
const lines = view.state.doc;
inline.show(lines.line(Math.min(event.end_line, lines.lines)).to, heading);
} else if (event.type === 'grounding') {
inline.setGrounding(event.references);
} else if (event.type === 'token') {
suggestion += event.text;
inline.stream(suggestion);
} else if (event.type === 'error') {
pause(event.code);
inline.clear();
break;
} else if (event.type === 'done') {
break;
}
}
if (suggestion.trim()) {
inline.finish(suggestion);
// It answered, so whatever was wrong is over.
resume();
} else {
inline.clear();
}
} catch {
// Aborted because the user resumed typing — expected, stay quiet.
inline.clear();
} finally {
suggesting = false;
refineAbort = undefined;
}
}
function accept() {
if (!view || !suggestionRange || !suggestion.trim()) return;
const lines = view.state.doc;
const from = lines.line(Math.min(suggestionRange.start, lines.lines)).from;
const to = lines.line(Math.min(suggestionRange.end, lines.lines)).to;
const text = suggestion.trimEnd();
inline.clear();
view.dispatch({ changes: { from, to, insert: text } });
charsSinceGate = 0; // start the cooldown (the dispatch above re-armed it)
suggestion = '';
suggestionRange = null;
view.focus();
}
function dismiss() {
cancelSuggestion();
charsSinceGate = 0;
view?.focus();
}
function pause(code: string) {
pausedCode = code;
error = errorMessage(code);
retryAt = Date.now() + (RETRY_MS[code] ?? RETRY_MS.llm_failed);
}
function resume() {
pausedCode = null;
error = null;
retryAt = 0;
}
/** "Try now" — the writer knows better than a timer when the endpoint is
* back, so asking again is one click and does not wait it out. */
function retryNow() {
resume();
charsSinceGate = Infinity;
void runRefine();
}
$effect(() => {
if (!host) return;
const listener = EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
content = update.state.doc.toString();
let added = 0;
update.changes.iterChanges((_a, _b, _c, _d, inserted) => (added += inserted.length));
charsSinceGate += added;
scheduleRefine();
});
view = new EditorView({
doc: initialDoc,
parent: host,
extensions: [
lineNumbers(),
history(),
drawSelection(),
keymap.of([...defaultKeymap, ...historyKeymap]),
markdown(),
EditorView.lineWrapping,
editorTheme,
inline.extension,
listener
]
});
inline.bind(view);
return () => {
clearTimeout(refineTimer);
refineAbort?.abort();
inline.bind(undefined);
view?.destroy();
view = undefined;
};
});
async function save(): Promise<boolean> {
busy = true;
saveError = null;
const { data } = await api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: documentId } },
body: { title, content_md: content }
});
busy = false;
if (!data) {
saveError = m.document_save_failed();
return false;
}
baseline = content;
savedAt = new Intl.DateTimeFormat(i18n.locale, { timeStyle: 'short' }).format(new Date());
saving = false;
return true;
}
// Save, then make the draft readable — one action, the author's own.
async function publish() {
if (!(await save())) return;
busy = true;
const { data } = await api.POST('/api/documents/{document_id}/publish', {
params: { path: { document_id: documentId } }
});
busy = false;
if (!data) {
saveError = m.document_save_failed();
return;
}
published = true; // open the reward modal
}
// A draft that is only the (unedited) template skeleton — headings and blank
// lines, no captured knowledge.
function isEmptySkeleton(text: string): boolean {
return text.split('\n').every((line) => line.trim() === '' || /^#{1,6}\s/.test(line.trim()));
}
// On the way out, take care of the draft so nothing is lost and nothing is
// left as clutter. Skipped once published (the document is no longer a draft).
beforeNavigate(() => {
if (published || !isDraft) return;
if (content === initialDoc && isEmptySkeleton(content)) {
// An abandoned, never-filled template: discard it so empty drafts do
// not pile up in the document list.
void api.DELETE('/api/documents/{document_id}', {
params: { path: { document_id: documentId } }
});
} else if (content !== baseline) {
// Unsaved draft edits: persist them (a draft is private and not
// indexed, so this is cheap) so leaving never loses work.
void api.PATCH('/api/documents/{document_id}', {
params: { path: { document_id: documentId } },
body: { title, content_md: content }
});
}
});
</script>
<div class="flex min-h-0 flex-1 flex-col gap-3">
<!-- Title and text, and nothing else: who may READ this is a property of the
document as it stands, and is set where it is shown (AccessPopover). -->
<div class="flex items-center gap-2">
<a
href={resolve(`/documents/${documentId}`)}
class="flex shrink-0 items-center gap-1 rounded-lg border border-border px-2.5 py-2 text-sm text-ink-muted transition-colors hover:border-border-strong hover:text-ink"
data-testid="editor-back"
>
<ArrowLeft size={15} />
{m.editor_back()}
</a>
<Input bind:value={title} class="text-lg font-semibold" data-testid="editor-title" />
</div>
{#if pausedCode}
<!-- One line, once: the endpoint is not answering, suggestions are off
until it does (or until this button says otherwise). -->
<p class="flex flex-wrap items-center gap-x-2 text-sm text-warning" data-testid="refine-paused">
<AlertTriangle size={14} class="shrink-0" />
{error}
<span class="text-ink-muted">{m.editor_suggestions_paused()}</span>
<button
type="button"
class="cursor-pointer underline decoration-dotted underline-offset-2"
onclick={retryNow}
data-testid="refine-retry"
>
{m.editor_suggestions_retry()}
</button>
</p>
{/if}
<!-- You write here. A refined version of the section at your cursor streams
INLINE as a block right below that section, so the suggestion appears
exactly where you are editing; accepting overwrites that section. -->
<div
bind:this={host}
class="editor-host min-h-0 flex-1 overflow-auto rounded-xl border border-border bg-surface px-3"
data-testid="editor-source"
></div>
<!-- Saving sits where you finish: bottom right, after the text. It opens the
diff rather than writing straight through — see SaveDialog. -->
<div class="flex items-center justify-end gap-2">
<span class="text-xs text-ink-muted" data-testid="editor-state">
{#if content !== baseline}
{m.editor_unsaved()}
{:else if savedAt}
{m.editor_saved_at({ time: savedAt })}
{:else if untouched}
<!-- Nothing written yet: leaving now takes the empty draft with it,
which is better said than discovered. -->
{m.editor_untouched_draft()}
{:else}
{m.editor_draft_exists()}
{/if}
</span>
<Button onclick={() => (saving = true)} data-testid="editor-open-save">
{m.editor_save()}
</Button>
</div>
</div>
<SaveDialog
bind:open={saving}
bind:title
{documentId}
{content}
{baseline}
canPublish={isDraft && canPublish}
{busy}
error={saveError}
onSave={save}
onPublish={publish}
/>
<CaptureSuccess bind:open={published} {documentId} />
<style>
/* The inline suggestion block, injected by CodeMirror into the editor flow.
Styled globally because it is not part of Svelte's scoped markup, and keyed
to the design tokens so it follows light/dark. */
.editor-host :global(.pb-suggestion) {
margin: 0.4rem 0 0.7rem;
padding: 0.55rem 0.75rem 0.65rem;
border: 1px solid var(--pb-accent);
border-radius: 0.6rem;
background: var(--pb-surface-raised);
color: var(--pb-ink);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
sans-serif;
font-size: 0.9rem;
line-height: 1.55;
white-space: normal;
}
.editor-host :global(.pb-suggestion-header) {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--pb-accent);
}
.editor-host :global(.pb-suggestion-header)::before {
content: '✨';
}
.editor-host :global(.pb-suggestion--loading .pb-suggestion-body)::after {
content: '▍';
margin-left: 1px;
color: var(--pb-secondary);
animation: pb-suggestion-blink 1s step-start infinite;
}
@keyframes pb-suggestion-blink {
50% {
opacity: 0;
}
}
.editor-host :global(.pb-suggestion-grounding) {
margin-top: 0.4rem;
font-size: 0.72rem;
color: var(--pb-ink-muted);
}
.editor-host :global(.pb-suggestion-actions) {
display: flex;
gap: 0.4rem;
margin-top: 0.55rem;
}
.editor-host :global(.pb-suggestion-btn) {
cursor: pointer;
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--pb-border);
background: transparent;
color: var(--pb-ink-muted);
transition:
color 0.15s,
background 0.15s,
border-color 0.15s;
}
.editor-host :global(.pb-suggestion-btn:hover) {
color: var(--pb-ink);
border-color: var(--pb-border-strong);
}
.editor-host :global(.pb-suggestion-accept) {
background: var(--pb-success-muted);
color: var(--pb-success);
border-color: transparent;
}
</style>
@@ -0,0 +1,172 @@
// The refinement suggestion, shown as a block INSIDE the editor.
//
// It is a CodeMirror block widget anchored just below the section it would
// replace, so the suggestion appears exactly where the writer is working
// rather than in a disconnected pane. The DOM is built and mutated
// imperatively on purpose: the widget outlives Svelte's render cycle (
// CodeMirror keeps the element while the decoration lives), and streaming
// tokens into a node is cheaper than re-rendering a component per token.
//
// Its styles live with the component that hosts the editor
// (`WritingEditor.svelte`), scoped through `.editor-host :global(...)`, since
// that is the element they are injected into.
import { StateEffect, StateField, type Extension } from '@codemirror/state';
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
import type { GroundingReference } from '$lib/api/refine';
import { renderMarkdown } from '$lib/markdown';
import { m } from '$lib/paraglide/messages';
type Handlers = { onAccept: () => void; onDismiss: () => void };
/** Wraps an element we own; CodeMirror positions it in the document flow. */
class SuggestionWidget extends WidgetType {
constructor(private readonly el: HTMLElement) {
super();
}
toDOM() {
return this.el;
}
eq(other: SuggestionWidget) {
return other.el === this.el;
}
ignoreEvent() {
// Let the accept/dismiss buttons handle their own clicks.
return true;
}
}
export class InlineSuggestion {
readonly extension: Extension;
#root: HTMLElement;
#label: HTMLElement;
#body: HTMLElement;
#grounding: HTMLElement;
#actions: HTMLElement;
#setPos = StateEffect.define<number | null>();
#view: EditorView | undefined;
#measureScheduled = false;
constructor(handlers: Handlers) {
const root = document.createElement('div');
root.className = 'pb-suggestion';
root.setAttribute('data-testid', 'editor-suggestion');
const header = document.createElement('div');
header.className = 'pb-suggestion-header';
this.#label = document.createElement('span');
this.#label.className = 'pb-suggestion-label';
header.append(this.#label);
this.#body = document.createElement('div');
this.#body.className = 'pb-suggestion-body markdown';
this.#grounding = document.createElement('div');
this.#grounding.className = 'pb-suggestion-grounding';
this.#grounding.hidden = true;
this.#actions = document.createElement('div');
this.#actions.className = 'pb-suggestion-actions';
this.#actions.hidden = true;
this.#actions.append(
this.#button(m.editor_accept(), 'editor-accept', handlers.onAccept, true),
this.#button(m.editor_dismiss(), 'editor-dismiss', handlers.onDismiss, false)
);
root.append(header, this.#body, this.#grounding, this.#actions);
this.#root = root;
const setPos = this.#setPos;
const element = () => this.#root;
this.extension = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes);
for (const effect of tr.effects) {
if (effect.is(setPos)) {
deco =
effect.value === null
? Decoration.none
: Decoration.set([
Decoration.widget({
widget: new SuggestionWidget(element()),
block: true,
side: 1
}).range(effect.value)
]);
}
}
return deco;
},
provide: (field) => EditorView.decorations.from(field)
});
}
#button(text: string, testid: string, handler: () => void, primary: boolean) {
const button = document.createElement('button');
button.type = 'button';
button.className = `pb-suggestion-btn${primary ? ' pb-suggestion-accept' : ''}`;
button.textContent = text;
button.setAttribute('data-testid', testid);
// preventDefault on mousedown keeps the editor from blurring first.
button.addEventListener('mousedown', (event) => event.preventDefault());
button.addEventListener('click', handler);
return button;
}
/** The view this suggestion lives in; set once the editor exists. */
bind(view: EditorView | undefined) {
this.#view = view;
}
/** Open the block at `pos`, in its loading state, titled by the section. */
show(pos: number, heading: string) {
if (!this.#view) return;
this.#root.classList.add('pb-suggestion--loading');
this.#label.textContent = heading || m.editor_suggestion_title();
this.#body.textContent = '';
this.#actions.hidden = true;
this.#grounding.hidden = true;
this.#view.dispatch({ effects: this.#setPos.of(pos) });
}
/** Plain text while it streams: Markdown is only rendered once complete. */
stream(text: string) {
this.#body.textContent = text;
this.#measure();
}
finish(text: string) {
this.#root.classList.remove('pb-suggestion--loading');
this.#body.innerHTML = renderMarkdown(text);
this.#actions.hidden = false;
this.#measure();
}
/** What the suggestion drew from — titles and heading paths, no content. */
setGrounding(references: GroundingReference[]) {
if (!references.length) {
this.#grounding.hidden = true;
return;
}
const names = references.map((r) => r.heading_path || r.title).join(' · ');
this.#grounding.textContent = `${m.editor_grounding_label()}: ${names}`;
this.#grounding.hidden = false;
}
clear() {
this.#view?.dispatch({ effects: this.#setPos.of(null) });
}
/** Mutating a widget's DOM does not tell CodeMirror its height changed; a
* throttled requestMeasure keeps the lines below it laid out correctly. */
#measure() {
if (this.#measureScheduled || !this.#view) return;
this.#measureScheduled = true;
requestAnimationFrame(() => {
this.#measureScheduled = false;
this.#view?.requestMeasure();
});
}
}
+28
View File
@@ -0,0 +1,28 @@
import { EditorView } from '@codemirror/view';
/**
* The shared CodeMirror theme, keyed to the design tokens rather than a
* CodeMirror theme, so the writing editor and the read-only diff views match
* the app in both light and dark mode.
*/
export const editorTheme = EditorView.theme({
'&': { color: 'var(--color-ink)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.9rem',
padding: '0.5rem 0',
caretColor: 'var(--color-accent)'
},
'.cm-scroller': { lineHeight: '1.7' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--color-accent)', borderLeftWidth: '2px' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'var(--color-surface-sunken)'
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--color-ink-muted)',
border: 'none'
},
'.cm-activeLineGutter, .cm-activeLine': { backgroundColor: 'transparent' }
});
+121
View File
@@ -0,0 +1,121 @@
// What the document list is currently showing, and how it gets there.
//
// Two different queries wear one screen: an empty search browses (filtered,
// sorted, paged) and a non-empty one goes through hybrid retrieval (ranked, no
// paging, no filters). Keeping both here means the page renders results and
// this decides what "results" are.
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import { documentView } from '$lib/documents/view.svelte';
type DocumentSummary = components['schemas']['DocumentSummary'];
type DocumentSearchHit = components['schemas']['DocumentSearchHit'];
/** A row is a summary, plus the matched heading when it came from a search. */
export type DocumentRow = DocumentSummary & Partial<DocumentSearchHit>;
export type AccessFilter = 'all' | 'author' | 'department' | 'public' | 'granted';
// Typing fires a request per keystroke. Showing "Loading…" immediately makes
// the list flicker on every fast response, so the spinner only appears once a
// request is actually slow — the previous results stay until the new ones come.
const LOADING_DELAY_MS = 400;
// Debounced: every keystroke would otherwise cost an embedding call.
const SEARCH_DEBOUNCE_MS = 300;
export class DocumentList {
documents = $state<DocumentRow[]>([]);
total = $state(0);
perPage = $state(30);
page = $state(1);
loading = $state(true);
search = $state('');
status = $state('');
department = $state('');
assignedToMe = $state(false);
// access_reason is computed per request, so filtering by it is a client
// concern — no extra round trip.
access = $state<AccessFilter>('all');
#inFlight = 0;
#debounce: ReturnType<typeof setTimeout> | undefined;
constructor(options: { reviewQueue?: boolean; status?: string } = {}) {
// The landing page deep-links into the two lists it advertises: the
// documents someone asked this user to check (?review=1), and their own
// drafts (?status=draft). Both are otherwise filter controls that are
// easy to miss.
if (options.reviewQueue) this.assignedToMe = true;
if (options.status) this.status = options.status;
}
get searching(): boolean {
return this.search.trim().length > 0;
}
/** Search is ranked rather than paged, so the pager only applies to browsing. */
get pages(): number {
return this.searching ? 1 : Math.max(1, Math.ceil(this.total / this.perPage));
}
get visible(): DocumentRow[] {
return this.access === 'all'
? this.documents
: this.documents.filter((row) => row.access_reason === this.access);
}
async load(): Promise<void> {
const request = ++this.#inFlight;
const slow = setTimeout(() => {
if (request === this.#inFlight) this.loading = true;
}, LOADING_DELAY_MS);
const query = this.search.trim();
const { data } = query
? await api.GET('/api/documents/search', { params: { query: { q: query } } })
: await api.GET('/api/documents', {
params: {
query: {
status: (this.status || undefined) as DocumentSummary['status'] | undefined,
department: this.department || undefined,
assigned_to_me: this.assignedToMe || undefined,
sort: documentView.sort,
page: this.page
}
}
});
clearTimeout(slow);
// A slower earlier request must not overwrite newer results.
if (request !== this.#inFlight) return;
if (Array.isArray(data)) {
this.documents = data;
this.total = data.length;
} else {
this.documents = data?.items ?? [];
this.total = data?.total ?? 0;
this.perPage = data?.per_page ?? this.perPage;
}
this.loading = false;
}
/** Any change to what is listed starts over at page one — staying on page 4
* of a different result set shows an empty screen. */
reload(): void {
this.page = 1;
void this.load();
}
goTo(next: number): void {
this.page = Math.min(Math.max(1, next), this.pages);
void this.load();
}
onSearchInput(): void {
clearTimeout(this.#debounce);
this.#debounce = setTimeout(() => this.reload(), SEARCH_DEBOUNCE_MS);
}
}
+126
View File
@@ -0,0 +1,126 @@
// How a document describes itself: why you may see it, where it stands, when
// it was touched.
//
// The list and the detail page both answer those three questions, so the
// vocabulary lives here rather than twice. A plain module, not `.svelte.ts`:
// nothing here holds state, and the reactivity comes from the caller reading
// `i18n.locale` inside its own template. Written as functions with explicit
// cases: Paraglide is a compiler and can only check and tree-shake message
// keys it can see literally (docs/i18n.md), so a lookup by computed key would
// ship every message and turn a typo into a blank.
import Archive from '@lucide/svelte/icons/archive';
import Building2 from '@lucide/svelte/icons/building-2';
import CheckCircle2 from '@lucide/svelte/icons/circle-check-big';
import Globe from '@lucide/svelte/icons/globe';
import KeyRound from '@lucide/svelte/icons/key-round';
import MessageCircleQuestion from '@lucide/svelte/icons/message-circle-question';
import PencilLine from '@lucide/svelte/icons/pencil-line';
import UserIcon from '@lucide/svelte/icons/user';
import { i18n } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
export const ACCESS_ICONS: Record<string, typeof Globe> = {
author: UserIcon,
department: Building2,
public: Globe,
granted: KeyRound,
review: MessageCircleQuestion
};
export function accessLabel(reason: string): string {
switch (reason) {
case 'author':
return m.documents_access_label_author();
case 'department':
return m.documents_access_label_department();
case 'public':
return m.documents_access_label_public();
case 'review':
return m.documents_access_label_review();
default:
return m.documents_access_label_granted();
}
}
export function accessHint(reason: string): string {
switch (reason) {
case 'author':
return m.documents_access_hint_author();
case 'department':
return m.documents_access_hint_department();
case 'public':
return m.documents_access_hint_public();
case 'review':
return m.documents_access_hint_review();
default:
return m.documents_access_hint_granted();
}
}
// Three states, and only three: a document is being written, readable, or
// retired. Doubt about the CONTENT is a review request instead — it can sit on
// a draft or on a document published for months, so it was never a status.
export const STATUS_ICONS: Record<string, typeof CheckCircle2> = {
draft: PencilLine,
published: CheckCircle2,
archived: Archive
};
export const STATUS_VARIANTS: Record<string, 'neutral' | 'warning' | 'success'> = {
draft: 'neutral',
published: 'success',
archived: 'neutral'
};
export function statusLabel(status: string): string {
switch (status) {
case 'draft':
return m.documents_status_draft();
case 'published':
return m.documents_status_published();
default:
return m.documents_status_archived();
}
}
/** The body without its own leading `# Title`.
*
* Every document starts with a heading that repeats its title, and every
* surface that shows the document already shows that title above the text.
* Rendering both makes the reader read the same words twice, so the heading
* is dropped where it is a duplicate — and kept when the author wrote
* something else there.
*/
export function bodyWithoutTitle(contentMd: string, title: string): string {
const match = /^\s*#\s+(.+?)\s*(\n|$)/.exec(contentMd);
if (!match || match[1].trim().toLowerCase() !== title.trim().toLowerCase()) return contentMd;
return contentMd.slice(match[0].length).replace(/^\n+/, '');
}
export function visibilityLabel(visibility: string): string {
switch (visibility) {
case 'public':
return m.documents_visibility_public();
case 'department':
return m.documents_visibility_department();
default:
return m.documents_visibility_restricted();
}
}
// The INTERFACE language, not the browser's: someone on an English browser who
// picked German would otherwise get German labels around English dates.
// Reading i18n.locale inside the function keeps it reactive; the formatter is
// kept until the language changes, because building one per row is wasteful.
let formatter: { locale: string; format: Intl.DateTimeFormat } | null = null;
export function formatDate(iso: string): string {
if (formatter?.locale !== i18n.locale) {
formatter = {
locale: i18n.locale,
format: new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium' })
};
}
return formatter.format.format(new Date(iso));
}
+56
View File
@@ -0,0 +1,56 @@
// Client mirror of backend app/authoring/sections.py::active_section, used
// ONLY for the visual highlight of the section the cursor is in. The server
// owns the authoritative range an accepted suggestion overwrites (the
// `section` SSE frame), so this stays deliberately simple: heading-delimited,
// no oversized-section paragraph fallback.
const HEADING = /^(#{1,6})\s+/;
function headingLines(lines: string[]): Array<[number, number]> {
const out: Array<[number, number]> = [];
let inFence = false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trimStart().startsWith('```')) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const match = HEADING.exec(lines[i]);
if (match) out.push([i, match[1].length]);
}
return out;
}
/** 1-based inclusive [start, end] line range of the section at `cursorLine`. */
export function activeSection(text: string, cursorLine: number): { start: number; end: number } {
const lines = text.split('\n');
const n = lines.length;
if (n === 0) return { start: 1, end: 1 };
const cursor0 = Math.max(1, Math.min(cursorLine, n)) - 1;
const headings = headingLines(lines);
let owner: [number, number] | null = null;
for (const heading of headings) {
if (heading[0] <= cursor0) owner = heading;
else break;
}
let start0: number;
let end0: number;
if (!owner) {
start0 = 0;
end0 = headings.length ? headings[0][0] - 1 : n - 1;
} else {
start0 = owner[0];
const level = owner[1];
end0 = n - 1;
for (const [idx, lvl] of headings) {
if (idx > start0 && lvl <= level) {
end0 = idx - 1;
break;
}
}
}
while (end0 > start0 && lines[end0].trim() === '') end0--;
return { start: start0 + 1, end: end0 + 1 };
}
+48
View File
@@ -0,0 +1,48 @@
// How this person likes to look at the document list.
//
// Per-device, like the theme and the sidebar collapse: which layout reads
// better depends on the screen in front of you, not on who you are. Stored
// under one key so a future third preference does not need a third entry.
const STORAGE_KEY = 'pablan.documents.view';
export type Sort = 'updated' | 'created';
type Stored = { sort: Sort };
// Grid is the only view for now: a card shows the department and the
// updated date next to the title, which is what people scan for. The list
// layout and its toggle were removed rather than kept as dead options;
// the markup is one branch away in git if it comes back.
const DEFAULTS: Stored = { sort: 'updated' };
function parse(raw: string | null): Stored {
if (!raw) return DEFAULTS;
try {
const value = JSON.parse(raw) as Partial<Stored>;
return { sort: value.sort === 'created' ? 'created' : 'updated' };
} catch {
// Corrupted or from an older shape: the defaults are always valid.
return DEFAULTS;
}
}
class DocumentView {
#state = $state<Stored>({ ...DEFAULTS });
/** Call once the component is mounted — localStorage has no server side. */
init(): void {
this.#state = parse(localStorage.getItem(STORAGE_KEY));
}
get sort(): Sort {
return this.#state.sort;
}
set(patch: Partial<Stored>): void {
this.#state = { ...this.#state, ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.#state));
}
}
export const documentView = new DocumentView();
+86
View File
@@ -0,0 +1,86 @@
// The active interface language, as a rune the whole UI reads through.
//
// Paraglide message functions call `getLocale()` internally. Pointing that
// at a `$state` (see `init()`) means every `m.some_key()` in markup becomes
// reactive: switching the language re-renders the strings in place, with no
// reload and therefore no flash of the old language or lost form input.
import { browser } from '$app/environment';
import { api } from '$lib/api/client';
import {
defineCustomClientStrategy,
getLocale,
overwriteGetLocale,
setLocale
} from '$lib/paraglide/runtime';
export type Locale = 'de' | 'en';
class I18n {
/** Resolved language. Never null: the strategy chain always lands on
* something, at worst the base locale. */
#locale = $state<Locale>('de');
/** What the ACCOUNT says, which is a different question: null means
* "follow the browser", and the switcher has to show that as its own
* option rather than as whichever language it currently resolves to. */
#preference = $state<Locale | null>(null);
get locale(): Locale {
return this.#locale;
}
get preference(): Locale | null {
return this.#preference;
}
/** Wire the runtime to this store. Called once from the root layout,
* before anything renders a message. */
init(preference: Locale | null): void {
this.#preference = preference;
this.#locale = getLocale() as Locale;
// BROWSER ONLY. This module is a singleton, and on the server that
// singleton is shared by every concurrent request: overwriting the
// runtime's locale resolution there leaks one visitor's language into
// everyone else's render. On the server, Paraglide's own middleware
// already resolves per request, which is exactly what we want.
// In the browser the singleton is per tab, so pointing getLocale() at
// a rune is safe and is what makes a switch re-render in place.
if (browser) {
overwriteGetLocale(() => this.#locale);
}
}
/** null puts the account back to following the browser. */
async choose(preference: Locale | null): Promise<void> {
this.#preference = preference;
// Clearing the preference has to fall back to the browser rather than
// stick on the language that happened to be showing.
const next = preference ?? detectFromBrowser();
this.#locale = next;
// NOT `getLocale()`: init() pointed that at #locale, so reading it
// back here would just return the value we are trying to replace.
await api.PUT('/api/account/locale', { body: { locale: preference } });
// reload: false, so the switch happens in place. The runtime still
// runs the chain to persist the cookie for the next server render.
await setLocale(next, { reload: false });
// Server-rendered on first paint; on a client switch it is ours.
document.documentElement.lang = next;
}
}
function detectFromBrowser(): Locale {
if (!browser) return 'de';
return navigator.languages?.some((tag) => tag.toLowerCase().startsWith('en')) ? 'en' : 'de';
}
export const i18n = new I18n();
// The account preference is written through the API, so the strategy has
// nothing to persist itself; it only reports what the store already knows.
// Registering it is still required, because the compiled chain names it.
defineCustomClientStrategy('custom-userPreference', {
getLocale: () => i18n.preference ?? undefined,
setLocale: async () => {
/* handled by I18n.choose, which owns the API call */
}
});
+21
View File
@@ -0,0 +1,21 @@
import { defineCustomServerStrategy } from '$lib/paraglide/runtime';
/** The locale of the user behind a request, stashed by the auth handle.
*
* A custom server strategy only receives the `Request`, but the locale
* lives on the user row, and `hooks.server.ts` has already fetched `/me`
* to populate `locals.user`. Handing the answer over through a WeakMap
* keyed by the request object avoids a second round trip per page, and the
* entry disappears with the request rather than being cleaned up by hand.
*/
const localeByRequest = new WeakMap<Request, string>();
export function rememberRequestLocale(request: Request, locale: string | null): void {
if (locale) localeByRequest.set(request, locale);
}
defineCustomServerStrategy('custom-userPreference', {
// The runtime types `request` as optional because not every strategy
// needs one; ours does, and without it there is simply no user to ask.
getLocale: (request) => (request ? localeByRequest.get(request) : undefined)
});
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+34
View File
@@ -0,0 +1,34 @@
import DOMPurify from 'dompurify';
import { Marked } from 'marked';
import markedKatex from 'marked-katex-extension';
// One configured instance (module scope), so the KaTeX extension is registered
// exactly once rather than per component render. The local model emits real
// LaTeX — inline `$...$` and display `$$...$$`, with \frac, \sqrt, superscripts
// etc. — so it is rendered as math instead of shown as raw source.
const marked = new Marked();
marked.use(
markedKatex({
// A malformed formula renders as plain text, never throws mid-answer.
throwOnError: false,
// Also accept `$...$` / `$$...$$` the way the model tends to write it.
nonStandard: true
})
);
// KaTeX emits <span class="katex"> trees positioned with inline styles, plus a
// MathML mirror for screen readers. DOMPurify already allows MathML and keeps
// class; these ADD_* just make sure the positioning styles and a few KaTeX
// attributes survive. ADD_* extend the default allow-list, they do not replace
// it, so the XSS guarantees on the rest of the document are unchanged.
const SANITIZE = {
ADD_ATTR: ['aria-hidden', 'style', 'encoding'],
ADD_TAGS: ['annotation', 'semantics', 'math']
};
/** Render untrusted Markdown (model / document output) to sanitized HTML, with
* LaTeX math rendered by KaTeX. Browser-only: DOMPurify needs a DOM. */
export function renderMarkdown(content: string): string {
const raw = marked.parse(content, { async: false }) as string;
return DOMPurify.sanitize(raw, SANITIZE) as unknown as string;
}
+267
View File
@@ -0,0 +1,267 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import LogOut from '@lucide/svelte/icons/log-out';
import Monitor from '@lucide/svelte/icons/monitor';
import Moon from '@lucide/svelte/icons/moon';
import Shield from '@lucide/svelte/icons/shield';
import Sun from '@lucide/svelte/icons/sun';
import UserPen from '@lucide/svelte/icons/user-pen';
import { resolve } from '$app/paths';
import { api } from '$lib/api/client';
import type { components } from '$lib/api/schema';
import { i18n, type Locale } from '$lib/i18n/locale.svelte';
import { m } from '$lib/paraglide/messages';
import Badge from '$lib/components/Badge.svelte';
import Button from '$lib/components/Button.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormField from '$lib/components/FormField.svelte';
import Input from '$lib/components/Input.svelte';
import { theme, type Theme } from '$lib/theme.svelte';
type Props = {
open?: boolean;
user: components['schemas']['UserOut'];
};
let { open = $bindable(false), user }: Props = $props();
// $derived, not a constant: the labels are messages, so they have to
// re-evaluate when the language changes. This is the pattern every
// migrated route follows for option lists.
const THEME_OPTIONS = $derived<{ value: Theme; label: string; icon: typeof Sun }[]>([
{ value: 'system', label: m.settings_theme_system(), icon: Monitor },
{ value: 'light', label: m.settings_theme_light(), icon: Sun },
{ value: 'dark', label: m.settings_theme_dark(), icon: Moon }
]);
// Language names stay in their own language: "Deutsch" is not translated
// to "German", because the point of the entry is to be recognised by
// someone who cannot read the current interface language.
const LOCALE_OPTIONS = $derived<{ value: Locale | null; label: string }[]>([
{ value: null, label: m.settings_locale_automatic() },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' }
]);
let changing = $state(false);
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
let done = $state(false);
function reset() {
changing = false;
currentPassword = '';
newPassword = '';
confirmPassword = '';
error = null;
done = false;
}
// Reopening the dialog should never show a stale form or message.
$effect(() => {
if (!open) reset();
});
async function pickLocale(value: Locale | null) {
// The store owns both the API write and the runtime switch, so the
// interface changes language in place instead of reloading.
await i18n.choose(value);
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (newPassword !== confirmPassword) {
error = m.settings_password_mismatch();
return;
}
busy = true;
error = null;
const { response } = await api.POST('/api/account/password', {
body: { current_password: currentPassword, new_password: newPassword }
});
busy = false;
if (response.status === 204) {
done = true;
changing = false;
currentPassword = newPassword = confirmPassword = '';
return;
}
error =
response.status === 403 ? m.settings_password_wrong_current() : m.settings_password_failed();
}
async function logout() {
open = false;
await api.POST('/api/auth/logout');
// Clear the language cookie: locale resolves account, then this
// cookie, then the browser. Without clearing it, a next user WITHOUT
// an account language would inherit this user's — a shared workshop
// terminal would stay German for everyone after one German user.
// Account preferences still win over everything.
document.cookie = 'PARAGLIDE_LOCALE=; path=/; max-age=0; samesite=lax';
// Auth boundaries are full document navigations, never client-side.
// Every module singleton derived from the session
// (conversation list, chat state, resolved locale) lives for one page
// load, so reloading at the session boundary guarantees the next user
// starts clean. A client goto would keep this user's conversation
// titles on screen until a manual refresh — information disclosure.
// Immune to stores added later. Do not convert this to goto.
window.location.assign(resolve('/login'));
}
</script>
{#snippet section(title: string, body: Snippet)}
<div class="flex flex-col gap-2 border-t border-border pt-4">
<p class="text-xs font-medium tracking-wide text-ink-muted uppercase">{title}</p>
{@render body()}
</div>
{/snippet}
<Dialog bind:open title={m.settings_title()} data-testid="settings-dialog">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<p class="font-medium">{user.name}</p>
<p class="text-sm text-ink-muted">{user.email}</p>
<div class="mt-1">
<Badge variant={user.role === 'admin' ? 'accent' : 'neutral'}>{user.role}</Badge>
</div>
</div>
{#snippet appearance()}
<div class="flex flex-wrap gap-1.5" data-testid="theme-switch">
{#each THEME_OPTIONS as option (option.value)}
{@const Icon = option.icon}
{@const selected = theme.choice === option.value}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors {selected
? 'border-border-strong bg-surface-sunken text-ink'
: 'border-border text-ink-muted hover:bg-surface-sunken hover:text-ink'}"
aria-pressed={selected}
onclick={() => theme.set(option.value)}
>
<Icon size={14} />
{option.label}
</button>
{/each}
</div>
{/snippet}
{@render section(m.settings_section_appearance(), appearance)}
{#snippet language()}
<div class="flex flex-wrap gap-1.5" data-testid="locale-switch">
{#each LOCALE_OPTIONS as option (option.value ?? 'auto')}
{@const selected = i18n.preference === option.value}
<button
type="button"
class="cursor-pointer rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors {selected
? 'border-border-strong bg-surface-sunken text-ink'
: 'border-border text-ink-muted hover:bg-surface-sunken hover:text-ink'}"
aria-pressed={selected}
onclick={() => pickLocale(option.value)}
>
{option.label}
</button>
{/each}
</div>
<p class="text-xs text-ink-muted">{m.settings_locale_hint()}</p>
{/snippet}
{@render section(m.settings_section_language(), language)}
{#snippet security()}
{#if changing}
<form class="flex flex-col gap-3" onsubmit={submit}>
<FormField label={m.settings_password_current()} for="current-password">
<Input
id="current-password"
type="password"
autocomplete="current-password"
bind:value={currentPassword}
required
/>
</FormField>
<FormField label={m.settings_password_new()} for="new-password">
<Input
id="new-password"
type="password"
autocomplete="new-password"
minlength={8}
bind:value={newPassword}
required
/>
</FormField>
<FormField label={m.settings_password_repeat()} for="confirm-password" {error}>
<Input
id="confirm-password"
type="password"
autocomplete="new-password"
bind:value={confirmPassword}
required
/>
</FormField>
<p class="text-xs text-ink-muted">{m.settings_password_other_devices()}</p>
<div class="flex gap-2">
<Button type="submit" size="sm" disabled={busy} data-testid="submit-password">
{m.settings_password_change()}
</Button>
<Button variant="ghost" size="sm" type="button" onclick={reset}>
{m.common_cancel()}
</Button>
</div>
</form>
{:else if done}
<p class="text-sm text-success" data-testid="password-changed">
{m.settings_password_changed()}
</p>
{:else}
<div>
<Button
variant="ghost"
size="sm"
onclick={() => (changing = true)}
data-testid="change-password"
>
{m.settings_password_change()}
</Button>
</div>
{/if}
{/snippet}
{@render section(m.settings_section_security(), security)}
<div class="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
variant="ghost"
size="sm"
href={resolve('/account/profile')}
onclick={() => (open = false)}
data-testid="open-profile"
>
<UserPen size={15} />
{m.settings_edit_profile()}
</Button>
{#if user.role === 'admin'}
<!-- Everything that affects other people lives on /admin; this
dialog only holds what a person changes about themselves. -->
<Button
variant="ghost"
size="sm"
href={resolve('/admin')}
onclick={() => (open = false)}
data-testid="open-admin"
>
<Shield size={15} />
{m.settings_administration()}
</Button>
{/if}
<div class="flex-1"></div>
<Button variant="ghost" size="sm" onclick={logout} data-testid="logout">
<LogOut size={15} />
{m.settings_logout()}
</Button>
</div>
</div>
</Dialog>
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import FileText from '@lucide/svelte/icons/file-text';
import PanelLeft from '@lucide/svelte/icons/panel-left';
import Plus from '@lucide/svelte/icons/plus';
import Shield from '@lucide/svelte/icons/shield';
import Trash2 from '@lucide/svelte/icons/trash-2';
import UserIcon from '@lucide/svelte/icons/user';
import Users from '@lucide/svelte/icons/users';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import type { components } from '$lib/api/schema';
import { conversationStore } from '$lib/chat/conversations.svelte';
import { m } from '$lib/paraglide/messages';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SettingsDialog from '$lib/nav/SettingsDialog.svelte';
type Props = {
user: components['schemas']['UserOut'];
};
let { user }: Props = $props();
let settingsOpen = $state(false);
// The conversation pending deletion (confirmed in the app's own modal).
let confirmDelete = $state<string | null>(null);
const COLLAPSE_KEY = 'pablan.sidebar.collapsed';
const RECENT_LIMIT = 15;
// Per-device ergonomics, not a user preference that should roam.
//
// The VISUAL collapse comes from data-sidebar on <html> (set before paint
// by the boot script in app.html and styled in app.css) — this state only
// backs the toggle's own label. Rendering the collapse conditionally here
// would flash the expanded sidebar on every reload, because Svelte cannot
// read localStorage until it hydrates.
let collapsed = $state(false);
onMount(() => {
collapsed = document.documentElement.dataset.sidebar === 'collapsed';
});
function toggleCollapsed() {
collapsed = !collapsed;
localStorage.setItem(COLLAPSE_KEY, String(collapsed));
if (collapsed) {
document.documentElement.setAttribute('data-sidebar', 'collapsed');
} else {
document.documentElement.removeAttribute('data-sidebar');
}
}
$effect(() => {
if (!conversationStore.loaded) void conversationStore.load();
});
const recent = $derived(conversationStore.items.slice(0, RECENT_LIMIT));
const activeConversationId = $derived(page.params.id ?? null);
function isActive(path: string) {
return page.url.pathname === path;
}
</script>
<aside
class="sidebar m-2 flex shrink-0 flex-col gap-1 overflow-hidden rounded-2xl border border-border bg-surface-raised p-2 transition-[width]"
data-testid="sidebar"
>
<div class="sidebar-row flex items-center justify-between gap-1 px-1 py-1">
<a href={resolve('/')} class="brand-gradient-text sidebar-label text-lg font-bold">Pablan.</a>
<button
class="cursor-pointer rounded-md p-1.5 text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
onclick={toggleCollapsed}
aria-label={collapsed ? m.nav_sidebar_expand() : m.nav_sidebar_collapse()}
data-testid="sidebar-toggle"
>
<PanelLeft size={18} />
</button>
</div>
<!-- Capturing knowledge is started from the conversation itself, so the
sidebar keeps a single primary action. -->
<a
href={resolve('/chat')}
class="sidebar-row flex items-center gap-2 rounded-full bg-primary px-3 py-2 text-sm font-medium whitespace-nowrap text-primary-fg transition-colors hover:bg-primary-hover"
title={m.nav_new_conversation()}
data-testid="sidebar-new-conversation"
>
<Plus size={16} class="shrink-0" />
<span class="sidebar-label">{m.nav_new_conversation()}</span>
</a>
<!-- Stays in the tree while collapsed: it is also the flexible spacer that
pushes the nav to the bottom. No heading and no empty-state text —
an empty sidebar is quieter than one explaining itself. -->
<div class="mt-2 min-h-0 flex-1 overflow-y-auto">
{#if recent.length > 0}
<ul class="sidebar-label flex flex-col gap-0.5" data-testid="conversation-list">
{#each recent as conversation (conversation.id)}
<li
class="group flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors {conversation.id ===
activeConversationId
? 'border-border-strong bg-surface-sunken text-ink'
: 'border-transparent text-ink-muted hover:bg-surface-sunken hover:text-ink'}"
>
<a
href={resolve(`/chat/${conversation.id}`)}
class="min-w-0 flex-1 truncate"
data-sveltekit-preload-data="hover"
>
{conversation.title ?? m.nav_conversation_untitled()}
</a>
<button
class="cursor-pointer text-ink-muted opacity-0 transition-opacity group-hover:opacity-100 hover:text-danger"
aria-label={m.nav_conversation_delete()}
onclick={() => (confirmDelete = conversation.id)}
>
<Trash2 size={14} />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<nav class="flex flex-col gap-0.5 border-t border-border pt-2">
<a
href={resolve('/documents')}
class="sidebar-row flex items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-surface-sunken {isActive(
'/documents'
)
? 'text-ink'
: 'text-ink-muted'}"
title={m.nav_documents()}
>
<FileText size={16} class="shrink-0" />
<span class="sidebar-label">{m.nav_documents()}</span>
</a>
<a
href={resolve('/people')}
class="sidebar-row flex items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-surface-sunken {isActive(
'/people'
)
? 'text-ink'
: 'text-ink-muted'}"
title={m.nav_people()}
>
<Users size={16} class="shrink-0" />
<span class="sidebar-label">{m.nav_people()}</span>
</a>
{#if user.role === 'admin'}
<a
href={resolve('/admin')}
class="sidebar-row flex items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap transition-colors hover:bg-surface-sunken {isActive(
'/admin'
)
? 'text-ink'
: 'text-ink-muted'}"
title={m.nav_administration()}
>
<!-- The same shield the settings dialog uses for the admin role:
one mark for "this is the administration side". -->
<Shield size={16} class="shrink-0" />
<span class="sidebar-label">{m.nav_administration()}</span>
</a>
{/if}
<!-- A dialog rather than a menu: settings hold forms (password change,
theme, language), which a menu cannot. -->
<button
class="sidebar-row flex w-full cursor-pointer items-center gap-2 rounded-full px-3 py-2 text-sm whitespace-nowrap text-ink transition-colors hover:bg-surface-sunken"
onclick={() => (settingsOpen = true)}
aria-label={m.nav_settings()}
title={user.name}
data-testid="user-menu"
>
<UserIcon size={16} class="shrink-0" />
<span class="sidebar-label truncate">{user.name}</span>
</button>
</nav>
</aside>
<SettingsDialog bind:open={settingsOpen} {user} />
<ConfirmDialog
open={confirmDelete !== null}
title={m.nav_conversation_delete()}
message={m.conversation_delete_confirm()}
onConfirm={() => {
if (confirmDelete) void conversationStore.remove(confirmDelete);
}}
onClose={() => (confirmDelete = null)}
/>

Some files were not shown because too many files have changed in this diff Show More