feat(chat): add auto title generation, inline renaming, and styled delete modal

- Fix missing conversationsStore import for auto title generation
- Make model ID dynamic in generateTitle() with error handling and fallback
- Add inline editing for manual conversation renaming in sidebar
- Add updateConversationTitle API endpoint and store method
- Replace browser confirm() with styled ConfirmationModal for delete
- Update Modal and ConfirmationModal with glassmorphism styling
- Add DEV_BYPASS_AUTH and GOOGLE_GENAI_API_KEY to env generation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 22:43:41 +01:00
parent a32c4f0a16
commit 05fe8ca5b6
14 changed files with 447 additions and 71 deletions

View file

@ -165,6 +165,34 @@ export class ConversationController {
return result.value;
}
@Patch(':id/pin')
async pinConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData
): Promise<Conversation> {
const result = await this.conversationService.pinConversation(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Patch(':id/unpin')
async unpinConversation(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData
): Promise<Conversation> {
const result = await this.conversationService.unpinConversation(id, user.userId);
if (!isOk(result)) {
throw result.error;
}
return result.value;
}
@Delete(':id')
async deleteConversation(
@Param('id') id: string,

View file

@ -28,7 +28,7 @@ export class ConversationService {
.select()
.from(conversations)
.where(and(...conditions))
.orderBy(desc(conversations.updatedAt));
.orderBy(desc(conversations.isPinned), desc(conversations.updatedAt));
return ok(result);
} catch (error) {
@ -267,4 +267,46 @@ export class ConversationService {
return err(DatabaseError.queryFailed('Failed to get message count'));
}
}
async pinConversation(conversationId: string, userId: string): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.update(conversations)
.set({ isPinned: true, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error pinning conversation', error);
return err(DatabaseError.queryFailed('Failed to pin conversation'));
}
}
async unpinConversation(conversationId: string, userId: string): AsyncResult<Conversation> {
try {
// First verify the conversation belongs to the user
const convResult = await this.getConversation(conversationId, userId);
if (!convResult.ok) {
return err(convResult.error);
}
const result = await this.db
.update(conversations)
.set({ isPinned: false, updatedAt: new Date() })
.where(eq(conversations.id, conversationId))
.returning();
return ok(result[0]);
} catch (error) {
this.logger.error('Error unpinning conversation', error);
return err(DatabaseError.queryFailed('Failed to unpin conversation'));
}
}
}

View file

@ -18,6 +18,7 @@ export const conversations = pgTable('conversations', {
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
documentMode: boolean('document_mode').default(false).notNull(),
isArchived: boolean('is_archived').default(false).notNull(),
isPinned: boolean('is_pinned').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -4,7 +4,8 @@
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { isSidebarMode, isNavCollapsed } from '$lib/stores/navigation';
import { MagnifyingGlass, X, Plus, ChatCircle, Archive, Trash } from '@manacore/shared-icons';
import { MagnifyingGlass, X, Plus, ChatCircle, Archive, Trash, PushPin } from '@manacore/shared-icons';
import { ConfirmationModal } from '@manacore/shared-ui';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
@ -14,6 +15,11 @@
let { children }: Props = $props();
// Delete confirmation modal state
let showDeleteModal = $state(false);
let deleteTargetId = $state<string | null>(null);
let isDeleting = $state(false);
// Resizer state
let leftColumnWidth = $state(320);
let isResizing = $state(false);
@ -106,17 +112,48 @@
}
}
// Delete conversation
async function handleDelete(e: MouseEvent, convId: string) {
// Pin/unpin conversation
async function handleTogglePin(e: MouseEvent, convId: string, isPinned: boolean) {
e.preventDefault();
e.stopPropagation();
if (confirm('Möchtest du diese Konversation wirklich löschen?')) {
const success = await conversationsStore.deleteConversation(convId);
if (success && isActive(convId)) {
if (isPinned) {
await conversationsStore.unpinConversation(convId);
} else {
await conversationsStore.pinConversation(convId);
}
}
// Open delete confirmation modal
function handleDelete(e: MouseEvent, convId: string) {
e.preventDefault();
e.stopPropagation();
deleteTargetId = convId;
showDeleteModal = true;
}
// Confirm delete action
async function confirmDelete() {
if (!deleteTargetId) return;
isDeleting = true;
try {
const wasActive = isActive(deleteTargetId);
const success = await conversationsStore.deleteConversation(deleteTargetId);
if (success && wasActive) {
goto('/chat');
}
} finally {
isDeleting = false;
showDeleteModal = false;
deleteTargetId = null;
}
}
// Close delete modal
function closeDeleteModal() {
showDeleteModal = false;
deleteTargetId = null;
}
</script>
<div
@ -213,13 +250,21 @@
>
<!-- Title Row -->
<div class="mb-1.5 flex items-center gap-2">
<ChatCircle
size={16}
weight={isActive(conv.id) ? 'fill' : 'regular'}
class="flex-shrink-0 {isActive(conv.id)
? 'text-primary'
: 'text-muted-foreground'}"
/>
{#if conv.isPinned}
<PushPin
size={16}
weight="fill"
class="flex-shrink-0 text-primary"
/>
{:else}
<ChatCircle
size={16}
weight={isActive(conv.id) ? 'fill' : 'regular'}
class="flex-shrink-0 {isActive(conv.id)
? 'text-primary'
: 'text-muted-foreground'}"
/>
{/if}
<h3 class="text-sm font-semibold line-clamp-1 text-foreground flex-1">
{conv.title || 'Neue Konversation'}
</h3>
@ -245,6 +290,13 @@
{/if}
<!-- Action Buttons (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onclick={(e) => handleTogglePin(e, conv.id, conv.isPinned)}
class="p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors {conv.isPinned ? 'text-primary' : ''}"
title={conv.isPinned ? 'Nicht mehr anpinnen' : 'Anpinnen'}
>
<PushPin size={14} weight={conv.isPinned ? 'fill' : 'bold'} />
</button>
<button
onclick={(e) => handleArchive(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
@ -284,6 +336,19 @@
</div>
</div>
<!-- Delete Confirmation Modal -->
<ConfirmationModal
visible={showDeleteModal}
onClose={closeDeleteModal}
onConfirm={confirmDelete}
variant="danger"
title="Konversation löschen?"
message="Diese Aktion kann nicht rückgängig gemacht werden. Alle Nachrichten werden dauerhaft gelöscht."
confirmLabel="Löschen"
cancelLabel="Abbrechen"
loading={isDeleting}
/>
<style>
/* Hide scrollbar completely */
.scrollbar-hide {

View file

@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import type { Conversation } from '@chat/types';
import { Plus } from '@manacore/shared-icons';
import { Plus, PencilSimple, Check, X } from '@manacore/shared-icons';
interface Props {
conversations: Conversation[];
@ -11,6 +11,11 @@
let { conversations, isLoading = false }: Props = $props();
// Edit state
let editingId = $state<string | null>(null);
let editTitle = $state('');
let isSaving = $state(false);
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
@ -32,6 +37,40 @@
if (title.length <= maxLength) return title;
return title.substring(0, maxLength - 3) + '...';
}
function startEdit(conv: Conversation, event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
editingId = conv.id;
editTitle = conv.title || '';
}
async function saveTitle(conversationId: string) {
if (!editTitle.trim() || isSaving) return;
isSaving = true;
try {
await conversationsStore.updateConversationTitle(conversationId, editTitle.trim());
} finally {
isSaving = false;
editingId = null;
editTitle = '';
}
}
function cancelEdit() {
editingId = null;
editTitle = '';
}
function handleKeydown(event: KeyboardEvent, conversationId: string) {
if (event.key === 'Enter') {
event.preventDefault();
saveTitle(conversationId);
} else if (event.key === 'Escape') {
cancelEdit();
}
}
</script>
<div class="flex flex-col h-full">
@ -65,22 +104,65 @@
<div class="py-2">
{#each conversations as conv (conv.id)}
{@const isActive = $page.params.id === conv.id}
<a
href="/chat/{conv.id}"
class="block px-3 py-2 mx-2 rounded-lg transition-colors
{isActive
? 'bg-primary/10 text-primary'
: 'hover:bg-muted text-foreground'}"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium truncate">
{truncateTitle(conv.title || 'Neue Konversation')}
</span>
<span class="text-xs text-muted-foreground flex-shrink-0">
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
{#if editingId === conv.id}
<!-- Edit Mode -->
<div class="flex items-center gap-1 px-3 py-2 mx-2">
<input
type="text"
bind:value={editTitle}
onkeydown={(e) => handleKeydown(e, conv.id)}
class="flex-1 px-2 py-1 text-sm bg-background border border-border rounded-md
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
autofocus
/>
<button
onclick={() => saveTitle(conv.id)}
disabled={isSaving || !editTitle.trim()}
class="p-1.5 text-primary hover:bg-primary/10 rounded-md transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
title="Speichern"
>
<Check size={16} weight="bold" />
</button>
<button
onclick={cancelEdit}
class="p-1.5 text-muted-foreground hover:bg-muted rounded-md transition-colors"
title="Abbrechen"
>
<X size={16} weight="bold" />
</button>
</div>
</a>
{:else}
<!-- View Mode -->
<div class="group relative">
<a
href="/chat/{conv.id}"
class="block px-3 py-2 mx-2 rounded-lg transition-colors
{isActive
? 'bg-primary/10 text-primary'
: 'hover:bg-muted text-foreground'}"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium truncate pr-6">
{truncateTitle(conv.title || 'Neue Konversation')}
</span>
<span class="text-xs text-muted-foreground flex-shrink-0">
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
</div>
</a>
<!-- Edit Button (visible on hover) -->
<button
onclick={(e) => startEdit(conv, e)}
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-md
text-muted-foreground hover:text-foreground hover:bg-muted
opacity-0 group-hover:opacity-100 transition-opacity"
title="Umbenennen"
>
<PencilSimple size={14} weight="bold" />
</button>
</div>
{/if}
{/each}
</div>
{/if}

View file

@ -73,6 +73,7 @@ export type Conversation = {
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
isPinned: boolean;
createdAt: string;
updatedAt: string;
};
@ -203,6 +204,28 @@ export const conversationApi = {
}
return true;
},
async pinConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(`/conversations/${conversationId}/pin`, {
method: 'PATCH',
});
if (error) {
console.error('Error pinning conversation:', error);
return false;
}
return true;
},
async unpinConversation(conversationId: string): Promise<boolean> {
const { error } = await fetchApi<Conversation>(`/conversations/${conversationId}/unpin`, {
method: 'PATCH',
});
if (error) {
console.error('Error unpinning conversation:', error);
return false;
}
return true;
},
};
// ============ Template API ============

View file

@ -97,6 +97,20 @@ export const conversationService = {
return conversationApi.deleteConversation(conversationId);
},
/**
* Pin a conversation
*/
async pinConversation(conversationId: string): Promise<boolean> {
return conversationApi.pinConversation(conversationId);
},
/**
* Unpin a conversation
*/
async unpinConversation(conversationId: string): Promise<boolean> {
return conversationApi.unpinConversation(conversationId);
},
/**
* Send a message and get AI response
*/
@ -142,7 +156,7 @@ export const conversationService = {
// Generate title if this is a new conversation (first or second message)
let title: string | undefined;
if (messages.length <= 2) {
title = await this.generateTitle(userMessage);
title = await this.generateTitle(userMessage, modelId);
if (title) {
await this.updateTitle(conversationId, title);
}
@ -157,32 +171,50 @@ export const conversationService = {
},
/**
* Generate a conversation title based on user message
* Generate a conversation title based on user message using AI
*/
async generateTitle(userMessage: string): Promise<string> {
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
async generateTitle(userMessage: string, modelId: string): Promise<string> {
try {
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
const response = await chatApi.createCompletion({
messages: [{ role: 'user', content: titlePrompt }],
modelId: '550e8400-e29b-41d4-a716-446655440101', // Gemini 2.5 Flash (default)
temperature: 0.3,
maxTokens: 50,
});
const response = await chatApi.createCompletion({
messages: [{ role: 'user', content: titlePrompt }],
modelId,
temperature: 0.3,
maxTokens: 50,
});
if (!response) {
return 'Neue Konversation';
if (!response) {
console.warn('Title generation returned no response, using fallback');
return this.createFallbackTitle(userMessage);
}
// Clean up title
let title = response.content
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\.$/g, '');
if (title.length > 100) {
title = title.substring(0, 97) + '...';
}
return title || this.createFallbackTitle(userMessage);
} catch (error) {
console.error('Error generating title:', error);
return this.createFallbackTitle(userMessage);
}
},
// Clean up title
let title = response.content
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\.$/g, '');
if (title.length > 100) {
title = title.substring(0, 97) + '...';
/**
* Create a fallback title from the first words of the message
*/
createFallbackTitle(message: string): string {
const words = message.trim().split(/\s+/).slice(0, 5);
let title = words.join(' ');
if (message.trim().split(/\s+/).length > 5) {
title += '...';
}
return title;
return title || 'Neue Konversation';
},
};

View file

@ -74,6 +74,17 @@ export const conversationsStore = {
conversations = conversations.map((c) => (c.id === conversationId ? { ...c, ...updates } : c));
},
/**
* Update conversation title via API and update local state
*/
async updateConversationTitle(conversationId: string, title: string): Promise<boolean> {
const success = await conversationService.updateTitle(conversationId, title);
if (success) {
this.updateConversation(conversationId, { title });
}
return success;
},
/**
* Archive a conversation
*/
@ -122,6 +133,48 @@ export const conversationsStore = {
return success;
},
/**
* Pin a conversation (moves it to top of list)
*/
async pinConversation(conversationId: string) {
const success = await conversationService.pinConversation(conversationId);
if (success) {
conversations = conversations.map((c) =>
c.id === conversationId ? { ...c, isPinned: true } : c
);
// Re-sort: pinned first, then by updatedAt
conversations = [...conversations].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
return success;
},
/**
* Unpin a conversation
*/
async unpinConversation(conversationId: string) {
const success = await conversationService.unpinConversation(conversationId);
if (success) {
conversations = conversations.map((c) =>
c.id === conversationId ? { ...c, isPinned: false } : c
);
// Re-sort: pinned first, then by updatedAt
conversations = [...conversations].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
return success;
},
/**
* Clear all data
*/

View file

@ -5,6 +5,7 @@
import { chatService } from '$lib/services/chat';
import { documentService } from '$lib/services/document';
import { authStore } from '$lib/stores/auth.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
import ChatLayout from '$lib/components/chat/ChatLayout.svelte';

View file

@ -15,6 +15,9 @@ export default defineConfig({
'@manacore/shared-branding',
'@manacore/shared-ui',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-types',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-ui',
],
},
optimizeDeps: {
@ -24,6 +27,9 @@ export default defineConfig({
'@manacore/shared-branding',
'@manacore/shared-ui',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-types',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-ui',
],
},
});

View file

@ -28,6 +28,7 @@ export interface Conversation {
documentMode: boolean;
title?: string;
isArchived: boolean;
isPinned: boolean;
createdAt: string;
updatedAt: string;
}

View file

@ -31,9 +31,9 @@
* ```
*/
import { Warning, WarningCircle, Info } from '@manacore/shared-icons';
import { Warning, WarningCircle, Info, X, Trash, Check } from '@manacore/shared-icons';
import Modal from './Modal.svelte';
import { Text, Button } from '../atoms';
import { Text } from '../atoms';
type ConfirmationVariant = 'danger' | 'warning' | 'info';
@ -72,19 +72,25 @@
const variantConfig: Record<
ConfirmationVariant,
{ iconColor: string; buttonVariant: 'danger' | 'primary' }
{ iconColor: string; iconBg: string; buttonColor: string; buttonHover: string }
> = {
danger: {
iconColor: 'text-red-500',
buttonVariant: 'danger',
iconBg: 'bg-red-500/10',
buttonColor: 'bg-red-500 text-white',
buttonHover: 'hover:bg-red-600 hover:shadow-lg hover:shadow-red-500/25',
},
warning: {
iconColor: 'text-yellow-500',
buttonVariant: 'primary',
iconBg: 'bg-yellow-500/10',
buttonColor: 'bg-yellow-500 text-white',
buttonHover: 'hover:bg-yellow-600 hover:shadow-lg hover:shadow-yellow-500/25',
},
info: {
iconColor: 'text-blue-500',
buttonVariant: 'primary',
iconBg: 'bg-blue-500/10',
buttonColor: 'bg-blue-500 text-white',
buttonHover: 'hover:bg-blue-600 hover:shadow-lg hover:shadow-blue-500/25',
},
};
@ -97,7 +103,7 @@
<Modal {visible} {onClose} {title} maxWidth="sm">
{#snippet icon()}
<div class="p-2 rounded-full bg-menu-hover {config.iconColor}">
<div class="p-2.5 rounded-xl {config.iconBg} {config.iconColor}">
{#if variant === 'danger'}
<Warning size={20} weight="bold" />
{:else if variant === 'warning'}
@ -117,13 +123,46 @@
</div>
{#snippet footer()}
<div class="flex gap-3 justify-end">
<Button variant="ghost" onclick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button variant={config.buttonVariant} onclick={handleConfirm} {loading}>
<div class="flex flex-col gap-3">
<!-- Confirm Button -->
<button
onclick={handleConfirm}
disabled={loading}
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm
{config.buttonColor} {config.buttonHover}
transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
>
{#if loading}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{:else if variant === 'danger'}
<Trash size={18} weight="bold" />
{:else}
<Check size={18} weight="bold" />
{/if}
{confirmLabel}
</Button>
</button>
<!-- Cancel Button -->
<button
onclick={onClose}
disabled={loading}
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm
bg-black/5 dark:bg-white/10 text-foreground
hover:bg-black/10 dark:hover:bg-white/20 hover:shadow-md
transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0"
>
<X size={18} weight="bold" />
{cancelLabel}
</button>
</div>
{/snippet}
</Modal>

View file

@ -65,14 +65,14 @@
<div
class="relative flex max-h-[90vh] w-full {maxWidthClasses[
maxWidth
]} flex-col rounded-xl border border-theme bg-menu shadow-xl"
]} flex-col rounded-2xl border border-black/10 dark:border-white/20 bg-white/80 dark:bg-white/10 backdrop-blur-xl shadow-2xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
{#if showHeader}
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-theme">
<div class="flex items-center gap-2 flex-1">
<div class="flex items-center justify-between p-6 border-b border-black/10 dark:border-white/10">
<div class="flex items-center gap-3 flex-1">
{#if icon}
{@render icon()}
{/if}
@ -84,10 +84,10 @@
</div>
<button
onclick={onClose}
class="p-2 rounded-full hover:bg-menu-hover transition-colors"
class="p-2 rounded-xl bg-black/5 dark:bg-white/10 hover:bg-black/10 dark:hover:bg-white/20 transition-all duration-200 hover:scale-105"
aria-label="Close"
>
<X size={20} weight="bold" class="text-theme-muted" />
<X size={18} weight="bold" class="text-muted-foreground" />
</button>
</div>
{/if}
@ -99,7 +99,7 @@
<!-- Footer (optional) -->
{#if footer}
<div class="border-t border-theme p-6">
<div class="border-t border-black/10 dark:border-white/10 p-6">
{@render footer()}
</div>
{/if}

View file

@ -77,6 +77,7 @@ const APP_CONFIGS = [
CREDITS_DAILY_FREE: (env) => env.CREDITS_DAILY_FREE,
RATE_LIMIT_TTL: (env) => env.RATE_LIMIT_TTL,
RATE_LIMIT_MAX: (env) => env.RATE_LIMIT_MAX,
GOOGLE_GENAI_API_KEY: (env) => env.GOOGLE_GENAI_API_KEY,
},
},
@ -86,9 +87,11 @@ const APP_CONFIGS = [
vars: {
NODE_ENV: () => 'development',
PORT: (env) => env.CHAT_BACKEND_PORT || '3002',
DEV_BYPASS_AUTH: () => 'true',
AZURE_OPENAI_ENDPOINT: (env) => env.AZURE_OPENAI_ENDPOINT,
AZURE_OPENAI_API_KEY: (env) => env.AZURE_OPENAI_API_KEY,
AZURE_OPENAI_API_VERSION: (env) => env.AZURE_OPENAI_API_VERSION,
GOOGLE_GENAI_API_KEY: (env) => env.GOOGLE_GENAI_API_KEY,
MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL,
DATABASE_URL: (env) => env.CHAT_DATABASE_URL,
},