Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/middleware/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const DEV_BYPASS_USER: Express.User = {
* the schema expects a number) throws ZodError at parse time instead of
* cascading as `undefined` through the render tree.
*/
function toBetterAuthUser(user: BetterAuthUser): UserProfile {
export function toBetterAuthUser(user: BetterAuthUser): UserProfile {
const nullStripped = Object.fromEntries(
Object.entries(user).map(([k, v]) => [k, v === null ? undefined : v])
);
Expand Down
7 changes: 7 additions & 0 deletions apps/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import rateLimit from 'express-rate-limit';

import authMiddleware from './middleware/authMiddleware.js';
import antraegeRouter from './routes/antraege/index.js';
import { mountAuthStatusContractRouter } from './routes/auth/authStatusContractRouter.js';
import authInitRouter from './routes/auth/initController.js';
import { mountAdminVorlagenContractRouter } from './routes/auth/templates/adminVorlagenContractRouter.js';
import { mountUserProfileContractRouter } from './routes/auth/userProfileContractRouter.js';
Expand Down Expand Up @@ -276,6 +277,12 @@ export async function setupRoutes(app: Application): Promise<void> {
// enforced per-handler via `checkIsAdmin` inside the contract).
app.use('/api/auth/admin/vorlagen', requireAuth);
mountAdminVorlagenContractRouter(app);
// ts-rest contract router for /api/auth/status — mounts BEFORE the legacy
// authRouter so the contract route matches first. NO requireAuth at the
// prefix: /status must respond to unauthed callers (returns
// { isAuthenticated: false, user: null }) so the frontend can decide
// whether to show the login screen.
mountAuthStatusContractRouter(app);
app.use('/api/auth', authenticatedReadLimiter, authRouter);
// ts-rest contract router for notebook collections — mounts BEFORE the
// legacy router so contract-modeled routes match first. requireAuth is
Expand Down
41 changes: 4 additions & 37 deletions apps/api/routes/auth/authCore.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
/**
* Core authentication routes
* Handles status, logout, profile, locale, health, and error pages
* Handles logout, profile, locale, health, and error pages
* Status (/api/auth/status) is served by authStatusContractRouter
* Login/callback handled by Better Auth at /api/auth/v2/*
*/

import { fromNodeHeaders } from 'better-auth/node';
import { and, eq, like } from 'drizzle-orm';
import express, { type Router, type Response } from 'express';

import { auth, type BetterAuthUser } from '../../config/betterAuth.js';
import { auth } from '../../config/betterAuth.js';
import { env } from '../../config/env.js';
import { ba_accounts } from '../../database/schema/auth.js';
import { getDrizzleInstance } from '../../database/services/DrizzleService.js';
import authMiddlewareModule from '../../middleware/authMiddleware.js';
import * as chatMemory from '../../services/chat/ChatMemoryService.js';
import { createLogger } from '../../utils/logger.js';

import type { AuthRequest, AuthStatusResponse, LocaleUpdateBody } from './types.js';
import type { AuthRequest, LocaleUpdateBody } from './types.js';

const log = createLogger('authCore');
const { requireAuth: ensureAuthenticated } = authMiddlewareModule;
Expand All @@ -39,40 +40,6 @@ router.get('/test', (_req: AuthRequest, res: Response): void => {
});
});

// ============================================================================
// Status Route
// ============================================================================

router.get('/status', async (req: AuthRequest, res: Response): Promise<void> => {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});

if (session?.user) {
const user = session.user as BetterAuthUser & { locale?: string };
res.json({
isAuthenticated: true,
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
...(user.locale && { locale: user.locale }),
},
});
return;
}
} catch {
// Session check failed
}

res.json({ isAuthenticated: false, user: null } as AuthStatusResponse);
});

// ============================================================================
// Error Route
// ============================================================================
Expand Down
65 changes: 65 additions & 0 deletions apps/api/routes/auth/authStatusContractRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* ts-rest contract router for the auth status endpoint.
*
* GET /api/auth/status
*
* The status endpoint reports the current session and is intentionally
* NOT protected by `requireAuth` — it must respond gracefully for
* unauthenticated callers (with `isAuthenticated: false, user: null`),
* since the frontend uses it to decide whether to show the login screen.
*
* The contract enforces the response shape end-to-end. The handler returns
* the canonical `UserProfile` via `toBetterAuthUser()`, which is the single
* null-strip + Zod-parse boundary shared with the auth middleware. That
* removes the manual hand-pick step that previously dropped fields like
* `is_admin` and broke the admin gate on the frontend.
*/

import { authStatusContract } from '@gruenerator/contracts';
import { createExpressEndpoints, initServer } from '@ts-rest/express';
import { fromNodeHeaders } from 'better-auth/node';

import { auth } from '../../config/betterAuth.js';
import { toBetterAuthUser } from '../../middleware/authMiddleware.js';
import { logContractValidationError } from '../../utils/contractValidationLogger.js';
import { createLogger } from '../../utils/logger.js';

import type { Application } from 'express';

const log = createLogger('authStatusContractRouter');

const s = initServer();

export const authStatusContractRouter = s.router(authStatusContract, {
getStatus: async (args) => {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(args.req.headers),
});
if (session?.user) {
return {
status: 200 as const,
body: { isAuthenticated: true, user: toBetterAuthUser(session.user) },
};
}
} catch (err) {
log.error('[authStatus.getStatus] session check failed: %s', (err as Error).message);
}
return { status: 200 as const, body: { isAuthenticated: false, user: null } };
},
});

/**
* Mount the ts-rest auth status contract router onto an Express app.
* Call from routes.ts BEFORE the legacy `app.use('/api/auth', authRouter)` so
* the contract route matches first; the legacy `/status` handler can then
* be removed.
*
* No `requireAuth` wrapper at the prefix — the endpoint must work for both
* authed and unauthed callers.
*/
export function mountAuthStatusContractRouter(app: Application): void {
createExpressEndpoints(authStatusContract, authStatusContractRouter, app, {
requestValidationErrorHandler: logContractValidationError(log, 'authStatusContract'),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function NotebookStartpage({
className={cn(pillBase, activeView === 'globalChat' ? pillActive : pillInactive)}
aria-pressed={activeView === 'globalChat'}
>
Globaler Chat
Chat
</button>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ function TopicDistribution({ data, sampleSize }: { data: TopicCount[]; sampleSiz
function Loading() {
return (
<div className="flex flex-col gap-lg">
<div className="grid gap-sm grid-cols-4 max-md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div className="grid gap-sm grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className={cardClass}>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-6 w-12" />
Expand Down Expand Up @@ -145,10 +145,8 @@ export function StatisticsSection({
<Loading />
) : (
<div className="flex flex-col gap-lg">
<div className="grid gap-sm grid-cols-4 max-md:grid-cols-2">
<div className="grid gap-sm grid-cols-2">
<StatCard label="Dokumente" value={stats.totalDocuments.toLocaleString('de-DE')} />
<StatCard label="Kategorien" value={stats.categoryDistribution.length} />
<StatCard label="Quellen" value={stats.sourceDistribution.length} />
<StatCard label="Zeitraum" value={formatDateRange(stats.dateRange)} />
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { memo, useMemo } from 'react';
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown';
import remarkGfm from 'remark-gfm';
import { useCitations } from '../../context/CitationContext';
import { useMarkdownSmooth } from '../../context/MarkdownStreamingContext';
import { escapeCitationMarkers } from '../../lib/citationProcessing';
import { makeCitationComponents } from '../../lib/citationMarkdownComponents';

const remarkPlugins = [remarkGfm];

function CitationMarkdownTextImpl() {
const citations = useCitations();
const smooth = useMarkdownSmooth();
const citationMap = useMemo(() => new Map(citations.map((c) => [c.id, c])), [citations]);
const components = useMemo(() => makeCitationComponents(citationMap), [citationMap]);

Expand All @@ -19,6 +21,7 @@ function CitationMarkdownTextImpl() {
remarkPlugins={remarkPlugins}
components={components}
preprocess={escapeCitationMarkers}
smooth={smooth}
/>
);
}
Expand Down
38 changes: 38 additions & 0 deletions packages/chat/src/context/MarkdownStreamingContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

import { createContext, useContext, type ReactNode } from 'react';

/**
* Per-thread switch for assistant-ui's `MarkdownTextPrimitive` smooth-text
* animation.
*
* Default: `true` (matches assistant-ui's own default, used by general chat).
*
* Notebook chats override this to `false` because:
* - Notebook answers are dense with `[N]` citation markers that become
* inline `<sup>` badges via `processChildren(..., true)`. Smooth's
* character-at-a-time reveal makes line wrap (and badge placement)
* recalculate on every frame, producing the visible up/down jump pattern.
* - The notebook adapter already throttles SSE yields to 50ms; a second
* animation layer doesn't help perceived smoothness.
*
* General chat keeps it `true` because chat answers have fewer citations,
* shorter messages, and benefit from the typewriter feel.
*/
const MarkdownStreamingContext = createContext<boolean>(true);

export function MarkdownStreamingProvider({
smooth,
children,
}: {
smooth: boolean;
children: ReactNode;
}) {
return (
<MarkdownStreamingContext.Provider value={smooth}>{children}</MarkdownStreamingContext.Provider>
);
}

export function useMarkdownSmooth(): boolean {
return useContext(MarkdownStreamingContext);
}
3 changes: 3 additions & 0 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ export {
type FetchFullTextFn,
} from './context/CitationContext';

// Markdown streaming animation toggle (per-thread)
export { MarkdownStreamingProvider, useMarkdownSmooth } from './context/MarkdownStreamingContext';

// Citation Panel (chunk-level navigation)
export {
CitationPanelProvider,
Expand Down
56 changes: 38 additions & 18 deletions packages/chat/src/runtime/NotebookChatProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type ThreadMessageLike,
} from '@assistant-ui/react';
import { VoxtralDictationAdapter } from '@gruenerator/voice';
import { MarkdownStreamingProvider } from '../context/MarkdownStreamingContext';
import {
createNotebookModelAdapter,
type NotebookAdapterConfig,
Expand Down Expand Up @@ -64,14 +65,30 @@ function NotebookChatProviderInner({
threadId: initialThreadId,
}: NotebookChatProviderProps) {
const isMulti = collections.length > 1;
// Use a ref for threadId so adapter is not recreated when it changes mid-conversation
// Refs for all config inputs so the adapter — and therefore the AUI runtime
// — is created exactly once per provider mount. Without this, any prop
// identity churn upstream (e.g. config.collections rebuilt by getNotebookConfig
// on every render, or a fresh documentIds array) recreates the adapter,
// reinitializes assistant-ui's runtime, and resets scroll/streaming state.
const threadIdRef = useRef<string | null>(initialThreadId || null);
// Use a ref for getFilters so adapter reads latest filters directly from store at request time
const getFiltersRef = useRef(getFilters);
getFiltersRef.current = getFilters;
// Keep filters ref as fallback for consumers that pass static filters prop
const filtersRef = useRef(filters);
filtersRef.current = filters;
const collectionsRef = useRef(collections);
collectionsRef.current = collections;
const isMultiRef = useRef(isMulti);
isMultiRef.current = isMulti;
const localeRef = useRef(locale);
localeRef.current = locale;
const extraParamsRef = useRef(extraParams);
extraParamsRef.current = extraParams;
const modeRef = useRef(mode);
modeRef.current = mode;
const endpointRef = useRef(endpoint);
endpointRef.current = endpoint;
const documentIdsRef = useRef(documentIds);
documentIdsRef.current = documentIds;

const handleThreadCreated = useCallback(
(newThreadId: string) => {
Expand All @@ -81,22 +98,21 @@ function NotebookChatProviderInner({
[onThreadCreated]
);

const getConfig = useCallback(
(): NotebookAdapterConfig => ({
...(isMulti
? { collectionIds: collections.map((c) => c.id) }
: { collectionId: collections[0]?.id }),
collectionLinkType: isMulti ? 'url' : collections[0]?.linkType,
const getConfig = useCallback((): NotebookAdapterConfig => {
const cs = collectionsRef.current;
const multi = isMultiRef.current;
return {
...(multi ? { collectionIds: cs.map((c) => c.id) } : { collectionId: cs[0]?.id }),
collectionLinkType: multi ? 'url' : cs[0]?.linkType,
filters: getFiltersRef.current?.() ?? filtersRef.current,
locale,
extraParams,
mode,
endpoint,
documentIds,
locale: localeRef.current,
extraParams: extraParamsRef.current,
mode: modeRef.current,
endpoint: endpointRef.current,
documentIds: documentIdsRef.current,
threadId: threadIdRef.current,
}),
[collections, isMulti, locale, extraParams, mode, endpoint, documentIds]
);
};
}, []);

const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
Expand Down Expand Up @@ -134,7 +150,11 @@ function NotebookChatProviderInner({
adapters: { dictation: dictationAdapter },
});

return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
return (
<AssistantRuntimeProvider runtime={runtime}>
<MarkdownStreamingProvider smooth={false}>{children}</MarkdownStreamingProvider>
</AssistantRuntimeProvider>
);
}

export function NotebookChatProvider(props: NotebookChatProviderProps) {
Expand Down
32 changes: 32 additions & 0 deletions packages/contracts/src/contracts/authStatusContract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* ts-rest contract for the auth status endpoint.
*
* GET /api/auth/status
*
* Reports current session: { isAuthenticated, user } where user is the full
* canonical UserProfile or null. Always returns 200 — the unauthenticated
* branch is part of the contract, not an error.
*/
import { initContract } from '@ts-rest/core';

import { authStatusResponseSchema } from '../schemas/authStatus.js';

const c = initContract();

export const authStatusContract = c.router(
{
/**
* GET /api/auth/status
* Returns the current session state.
*/
getStatus: {
method: 'GET',
path: '/api/auth/status',
responses: {
200: authStatusResponseSchema,
},
summary: 'Get current auth/session status',
},
},
{ pathPrefix: '' }
);
1 change: 1 addition & 0 deletions packages/contracts/src/contracts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export { transferContract } from './transferContract.js';
export { unsplashContract } from './unsplashContract.js';
export { notificationsContract } from './notificationsContract.js';
export { adminVorlagenContract } from './adminVorlagenContract.js';
export { authStatusContract } from './authStatusContract.js';
Loading
Loading