fix(chat): align frontend types with backend camelCase and redesign message bubbles

- Update all frontend types and components to use camelCase properties matching backend API
- Redesign MessageBubble with gradient avatars, shadows, and modern speech bubble styling
- Update TypingIndicator to match new bubble design with Robot avatar
- Fix marked.js undefined input error by ensuring proper property access

🤖 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 16:04:19 +01:00
parent a3247086eb
commit 0467ac3891
24 changed files with 532 additions and 370 deletions

View file

@ -1,17 +1,41 @@
<script lang="ts">
import { PaperPlaneTilt } from '@manacore/shared-icons';
import { PaperPlaneTilt, FileText, CaretDown } from '@manacore/shared-icons';
import type { AIModel } from '@chat/types';
interface Props {
onSend: (message: string) => void;
disabled?: boolean;
placeholder?: string;
models?: AIModel[];
selectedModelId?: string;
onModelSelect?: (modelId: string) => void;
documentMode?: boolean;
onDocumentModeToggle?: () => void;
}
let { onSend, disabled = false, placeholder = 'Nachricht eingeben...' }: Props = $props();
let {
onSend,
disabled = false,
placeholder = 'Nachricht eingeben...',
models = [],
selectedModelId = '',
onModelSelect,
documentMode = false,
onDocumentModeToggle,
}: Props = $props();
let inputValue = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
// Focus input on mount
let hasFocused = false;
$effect(() => {
if (textareaEl && !disabled && !hasFocused) {
textareaEl.focus();
hasFocused = true;
}
});
function handleSubmit() {
const trimmed = inputValue.trim();
if (trimmed && !disabled) {
@ -42,35 +66,82 @@
<div class="relative">
<div
class="flex items-end gap-3 rounded-2xl border border-border bg-white/70 dark:bg-black/50 backdrop-blur-xl p-2 shadow-lg"
class="flex flex-col gap-2 rounded-2xl bg-white/80 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/20 p-2 shadow-lg"
>
<div class="flex-1 relative">
<textarea
bind:this={textareaEl}
bind:value={inputValue}
onkeydown={handleKeyDown}
oninput={handleInput}
{placeholder}
{disabled}
rows="1"
class="w-full resize-none rounded-xl border-0 bg-transparent
px-4 py-3 text-sm text-foreground
focus:outline-none focus:ring-0
disabled:opacity-50 disabled:cursor-not-allowed
placeholder:text-muted-foreground"
></textarea>
<!-- Input Row -->
<div class="flex items-end gap-3">
<div class="flex-1 relative">
<textarea
bind:this={textareaEl}
bind:value={inputValue}
onkeydown={handleKeyDown}
oninput={handleInput}
{placeholder}
{disabled}
rows="1"
class="w-full resize-none rounded-xl border-0 bg-transparent
px-4 py-3 text-sm text-foreground
focus:outline-none focus:ring-0
disabled:opacity-50 disabled:cursor-not-allowed
placeholder:text-muted-foreground"
></textarea>
</div>
<button
onclick={handleSubmit}
disabled={disabled || !inputValue.trim()}
aria-label="Nachricht senden"
class="flex-shrink-0 p-3 rounded-xl bg-white/90 dark:bg-white/20 backdrop-blur-sm
border border-black/10 dark:border-white/20 text-primary
hover:bg-white dark:hover:bg-white/30 hover:shadow-lg
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-md"
>
<PaperPlaneTilt size={20} weight="bold" />
</button>
</div>
<button
onclick={handleSubmit}
disabled={disabled || !inputValue.trim()}
aria-label="Nachricht senden"
class="flex-shrink-0 p-3 rounded-xl bg-primary text-primary-foreground
hover:bg-primary/90 active:bg-primary/80
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary
transition-all duration-200 shadow-md hover:shadow-lg"
>
<PaperPlaneTilt size={20} weight="bold" />
</button>
<!-- Options Row -->
{#if models.length > 0 || onDocumentModeToggle}
<div class="flex items-center gap-2 px-2">
<!-- Model Selector -->
{#if models.length > 0 && onModelSelect}
<div class="relative">
<select
value={selectedModelId}
onchange={(e) => onModelSelect?.((e.target as HTMLSelectElement).value)}
{disabled}
class="appearance-none bg-white/50 dark:bg-white/10 text-foreground text-xs font-medium rounded-lg pl-3 pr-7 py-1.5 border border-black/5 dark:border-white/10 focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50 cursor-pointer transition-colors hover:bg-white/80 dark:hover:bg-white/20"
>
{#each models as model (model.id)}
<option value={model.id}>{model.name}</option>
{/each}
</select>
<div class="absolute inset-y-0 right-1.5 flex items-center pointer-events-none">
<CaretDown size={12} weight="bold" class="text-muted-foreground" />
</div>
</div>
{/if}
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Document Mode Toggle -->
{#if onDocumentModeToggle}
<button
onclick={onDocumentModeToggle}
{disabled}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all duration-200 disabled:opacity-50
{documentMode
? 'bg-primary/20 text-primary border border-primary/30'
: 'bg-white/50 dark:bg-white/10 text-muted-foreground border border-black/5 dark:border-white/10 hover:bg-white/80 dark:hover:bg-white/20 hover:text-foreground'}"
title="Dokumentmodus aktivieren"
>
<FileText size={14} weight={documentMode ? 'fill' : 'bold'} />
<span>Dokument</span>
</button>
{/if}
</div>
{/if}
</div>
<p class="text-xs text-muted-foreground text-center mt-2 opacity-70">
Enter zum Senden, Shift+Enter für neue Zeile

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 } from '@manacore/shared-icons';
import { MagnifyingGlass, X, Plus, ChatCircle, Archive, Trash } from '@manacore/shared-icons';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
interface Props {
@ -94,6 +95,28 @@
function isActive(convId: string): boolean {
return $page.params.id === convId;
}
// Archive conversation
async function handleArchive(e: MouseEvent, convId: string) {
e.preventDefault();
e.stopPropagation();
const success = await conversationsStore.archiveConversation(convId);
if (success && isActive(convId)) {
goto('/chat');
}
}
// Delete conversation
async function handleDelete(e: MouseEvent, convId: string) {
e.preventDefault();
e.stopPropagation();
if (confirm('Möchtest du diese Konversation wirklich löschen?')) {
const success = await conversationsStore.deleteConversation(convId);
if (success && isActive(convId)) {
goto('/chat');
}
}
}
</script>
<div
@ -183,7 +206,7 @@
{#each filteredConversations as conv (conv.id)}
<a
href="/chat/{conv.id}"
class="block w-full rounded-xl bg-white/60 dark:bg-white/5 backdrop-blur-sm border border-black/10 dark:border-white/20 p-4 text-left transition-all mb-3 hover:shadow-md hover:bg-white/80 dark:hover:bg-white/10
class="group block w-full rounded-xl bg-white/60 dark:bg-white/5 backdrop-blur-sm border border-black/10 dark:border-white/20 p-4 text-left transition-all mb-3 hover:shadow-md hover:bg-white/80 dark:hover:bg-white/10
{isActive(conv.id)
? 'bg-white/90 dark:bg-white/15 shadow-md border-primary/30'
: ''}"
@ -210,15 +233,34 @@
<!-- Footer -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">
{formatDate(conv.updated_at || conv.created_at)}
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
{#if conv.document_mode}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full"
>
Dokument
</span>
{/if}
<div class="flex items-center gap-1">
{#if conv.documentMode}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full"
>
Dokument
</span>
{/if}
<!-- Action Buttons (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<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"
title="Archivieren"
>
<Archive size={14} weight="bold" />
</button>
<button
onclick={(e) => handleDelete(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Löschen"
>
<Trash size={14} weight="bold" />
</button>
</div>
</div>
</div>
</a>
{/each}

View file

@ -77,7 +77,7 @@
{truncateTitle(conv.title || 'Neue Konversation')}
</span>
<span class="text-xs text-muted-foreground flex-shrink-0">
{formatDate(conv.updated_at || conv.created_at)}
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
</div>
</a>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { marked } from 'marked';
import type { Message } from '@chat/types';
import { Robot, User } from '@manacore/shared-icons';
interface Props {
message: Message;
@ -16,31 +17,216 @@
gfm: true,
});
const htmlContent = $derived(isUser ? message.message_text : marked.parse(message.message_text));
const htmlContent = $derived(
isUser ? message.messageText : marked.parse(message.messageText || '')
);
const formattedTime = $derived(
new Date(message.created_at).toLocaleTimeString('de-DE', {
new Date(message.createdAt).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
);
</script>
<div class="flex {isUser ? 'justify-end' : 'justify-start'} mb-4">
<div class="group flex gap-3 {isUser ? 'flex-row-reverse' : 'flex-row'} mb-6 animate-fade-in">
<!-- Avatar -->
<div
class="max-w-[80%] rounded-2xl px-4 py-3 {isUser
? 'bg-primary text-primary-foreground rounded-br-md'
: 'bg-muted text-foreground rounded-bl-md'}"
class="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center shadow-md
{isUser
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-violet-500 to-purple-600 text-white'}"
>
{#if isUser}
<p class="whitespace-pre-wrap">{message.message_text}</p>
<User size={18} weight="bold" />
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html htmlContent}
</div>
<Robot size={18} weight="bold" />
{/if}
<div class="text-xs mt-1 {isUser ? 'text-primary-foreground/70' : 'text-muted-foreground'}">
{formattedTime}
</div>
<!-- Message Content -->
<div class="flex flex-col {isUser ? 'items-end' : 'items-start'} max-w-[75%]">
<!-- Bubble -->
<div
class="relative px-4 py-3 shadow-md
{isUser
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white rounded-2xl rounded-tr-md'
: 'bg-white dark:bg-white/10 text-foreground border border-black/5 dark:border-white/10 rounded-2xl rounded-tl-md'}"
>
{#if isUser}
<p class="whitespace-pre-wrap text-[15px] leading-relaxed">{message.messageText}</p>
{:else}
<div class="prose-chat">
{@html htmlContent}
</div>
{/if}
</div>
<!-- Time -->
<div
class="flex items-center gap-2 mt-1.5 px-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<span class="text-xs text-muted-foreground">
{formattedTime}
</span>
</div>
</div>
</div>
<style>
/* Fade-in animation */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
/* Markdown/Prose styling for assistant messages */
:global(.prose-chat) {
font-size: 15px;
line-height: 1.6;
}
:global(.prose-chat p) {
margin: 0;
}
:global(.prose-chat p + p) {
margin-top: 0.75em;
}
:global(.prose-chat strong) {
font-weight: 600;
}
:global(.prose-chat em) {
font-style: italic;
}
:global(.prose-chat ul),
:global(.prose-chat ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
:global(.prose-chat li) {
margin: 0.25em 0;
}
:global(.prose-chat code) {
background: rgba(0, 0, 0, 0.08);
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: 'SF Mono', 'Fira Code', monospace;
}
:global(.dark .prose-chat code) {
background: rgba(255, 255, 255, 0.1);
}
:global(.prose-chat pre) {
background: rgba(0, 0, 0, 0.05);
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 0.75em 0;
}
:global(.dark .prose-chat pre) {
background: rgba(0, 0, 0, 0.3);
}
:global(.prose-chat pre code) {
background: none;
padding: 0;
font-size: 0.85em;
}
:global(.prose-chat blockquote) {
border-left: 3px solid var(--color-primary, #3b82f6);
padding-left: 1em;
margin: 0.75em 0;
color: var(--color-muted-foreground);
font-style: italic;
}
:global(.prose-chat h1),
:global(.prose-chat h2),
:global(.prose-chat h3),
:global(.prose-chat h4) {
margin-top: 1em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.3;
}
:global(.prose-chat h1) {
font-size: 1.25em;
}
:global(.prose-chat h2) {
font-size: 1.15em;
}
:global(.prose-chat h3) {
font-size: 1.05em;
}
:global(.prose-chat a) {
color: var(--color-primary, #3b82f6);
text-decoration: underline;
text-underline-offset: 2px;
}
:global(.prose-chat a:hover) {
text-decoration: none;
}
:global(.prose-chat hr) {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
margin: 1em 0;
}
:global(.dark .prose-chat hr) {
border-top-color: rgba(255, 255, 255, 0.1);
}
:global(.prose-chat table) {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.9em;
}
:global(.prose-chat th),
:global(.prose-chat td) {
padding: 0.5em 0.75em;
border: 1px solid rgba(0, 0, 0, 0.1);
text-align: left;
}
:global(.dark .prose-chat th),
:global(.dark .prose-chat td) {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.prose-chat th) {
background: rgba(0, 0, 0, 0.03);
font-weight: 600;
}
:global(.dark .prose-chat th) {
background: rgba(255, 255, 255, 0.05);
}
</style>

View file

@ -22,11 +22,11 @@
value={selectedModelId}
onchange={handleChange}
{disabled}
class="appearance-none bg-muted text-foreground
text-sm rounded-lg px-3 py-2 pr-8 border border-border
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
class="appearance-none bg-white/80 dark:bg-white/10 backdrop-blur-xl text-foreground
text-sm font-medium rounded-xl px-4 py-2.5 pr-9 border border-black/10 dark:border-white/20
focus:outline-none focus:ring-2 focus:ring-primary/50
disabled:opacity-50 disabled:cursor-not-allowed
cursor-pointer min-w-[160px]"
cursor-pointer min-w-[160px] shadow-md hover:shadow-lg transition-all"
>
{#if models.length === 0}
<option value="">Laden...</option>
@ -36,7 +36,7 @@
{/each}
{/if}
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<CaretDown size={14} weight="bold" class="text-muted-foreground" />
</div>
</div>

View file

@ -1,27 +1,56 @@
<script lang="ts">
// Typing indicator with animated dots
import { Robot } from '@manacore/shared-icons';
</script>
<div class="flex justify-start mb-4">
<div class="bg-muted rounded-2xl rounded-bl-md px-4 py-3">
<div class="flex items-center gap-1">
<div
class="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce"
style="animation-delay: 0ms"
></div>
<div
class="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce"
style="animation-delay: 150ms"
></div>
<div
class="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce"
style="animation-delay: 300ms"
></div>
<div class="flex gap-3 flex-row mb-6 animate-fade-in">
<!-- Avatar -->
<div
class="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center shadow-sm
bg-gradient-to-br from-violet-500 to-purple-600 text-white"
>
<Robot size={18} weight="bold" />
</div>
<!-- Typing Bubble -->
<div class="flex flex-col items-start max-w-[75%]">
<div
class="relative px-4 py-3 shadow-sm
bg-white dark:bg-white/10 text-foreground border border-black/5 dark:border-white/10 rounded-2xl rounded-tl-md"
>
<div class="flex items-center gap-1.5">
<div
class="w-2 h-2 bg-muted-foreground/60 rounded-full animate-bounce"
style="animation-delay: 0ms"
></div>
<div
class="w-2 h-2 bg-muted-foreground/60 rounded-full animate-bounce"
style="animation-delay: 150ms"
></div>
<div
class="w-2 h-2 bg-muted-foreground/60 rounded-full animate-bounce"
style="animation-delay: 300ms"
></div>
</div>
</div>
</div>
</div>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
@keyframes bounce {
0%,
60%,

View file

@ -67,7 +67,7 @@
{/if}
<p class="text-xs text-muted-foreground">
Erstellt: {formatDate(space.created_at)}
Erstellt: {formatDate(space.createdAt)}
</p>
</div>

View file

@ -20,7 +20,7 @@
<div
class="group relative flex rounded-xl overflow-hidden bg-surface shadow-sm hover:shadow-md transition-all
{template.is_default
{template.isDefault
? 'ring-2 ring-primary'
: 'border border-border'}"
>
@ -35,7 +35,7 @@
<h3 class="text-base font-semibold text-foreground truncate">
{template.name}
</h3>
{#if template.is_default}
{#if template.isDefault}
<span class="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded">
Standard
</span>
@ -49,13 +49,13 @@
{/if}
<p class="text-xs text-muted-foreground italic line-clamp-2">
{truncatePrompt(template.system_prompt)}
{truncatePrompt(template.systemPrompt)}
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{#if !template.is_default}
{#if !template.isDefault}
<button
onclick={() => onSetDefault(template.id)}
class="p-1.5 text-muted-foreground hover:text-yellow-500 hover:bg-muted rounded-lg transition-colors"

View file

@ -27,11 +27,11 @@
// Form state
let name = $state(template?.name ?? '');
let description = $state(template?.description ?? '');
let systemPrompt = $state(template?.system_prompt ?? '');
let initialQuestion = $state(template?.initial_question ?? '');
let systemPrompt = $state(template?.systemPrompt ?? '');
let initialQuestion = $state(template?.initialQuestion ?? '');
let selectedColor = $state(template?.color ?? TEMPLATE_COLORS[0]);
let selectedModelId = $state(template?.model_id ?? '');
let documentMode = $state(template?.document_mode ?? false);
let selectedModelId = $state(template?.modelId ?? '');
let documentMode = $state(template?.documentMode ?? false);
// Models
let models = $state<AIModel[]>([]);
@ -67,11 +67,11 @@
id: template?.id,
name,
description: description.trim() || null,
system_prompt: systemPrompt,
initial_question: initialQuestion.trim() || null,
systemPrompt: systemPrompt,
initialQuestion: initialQuestion.trim() || null,
color: selectedColor,
model_id: selectedModelId || null,
document_mode: documentMode,
modelId: selectedModelId || null,
documentMode: documentMode,
});
}
</script>

View file

@ -211,10 +211,10 @@ export type Template = {
id: string;
userId: string;
name: string;
description?: string;
description: string | null;
systemPrompt: string;
initialQuestion?: string;
modelId?: string;
initialQuestion: string | null;
modelId: string | null;
color: string;
isDefault: boolean;
documentMode: boolean;
@ -585,7 +585,7 @@ export type Model = {
id: string;
name: string;
description?: string;
provider: string;
provider: 'gemini' | 'azure' | 'openai';
parameters?: {
deployment?: string;
temperature?: number;
@ -593,6 +593,7 @@ export type Model = {
top_p?: number;
};
isActive: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
};

View file

@ -7,7 +7,7 @@ import { documentApi, conversationApi, type Document } from './api';
export type { Document };
export type DocumentWithConversation = Document & {
conversation_title: string;
conversationTitle: string;
};
export const documentService = {
@ -35,7 +35,7 @@ export const documentService = {
if (doc) {
documents.push({
...doc,
conversation_title: conv.title || 'Unbenannte Konversation',
conversationTitle: conv.title || 'Unbenannte Konversation',
});
}
}

View file

@ -27,7 +27,7 @@ export const spaceService = {
async createSpace(space: {
name: string;
description?: string;
owner_id: string;
ownerId: string;
}): Promise<string | null> {
const result = await spaceApi.createSpace(space.name, space.description);
return result?.id || null;

View file

@ -71,10 +71,10 @@ export const chatStore = {
// Add user message
const userMessage: Message = {
id: `temp-${++messageCounter}`,
conversation_id: '',
conversationId: '',
sender: 'user',
message_text: text,
created_at: new Date().toISOString(),
messageText: text,
createdAt: new Date().toISOString(),
};
messages = [...messages, userMessage];
@ -82,7 +82,7 @@ export const chatStore = {
// Build chat messages for API
const chatMessages: ChatMessage[] = messages.map((m) => ({
role: m.sender === 'user' ? 'user' : 'assistant',
content: m.message_text,
content: m.messageText,
}));
const request: ChatCompletionRequest = {
@ -96,10 +96,10 @@ export const chatStore = {
// Add assistant message
const assistantMessage: Message = {
id: `temp-${++messageCounter}`,
conversation_id: '',
conversationId: '',
sender: 'assistant',
message_text: response.content,
created_at: new Date().toISOString(),
messageText: response.content,
createdAt: new Date().toISOString(),
};
messages = [...messages, assistantMessage];
} else {

View file

@ -84,7 +84,7 @@ export const conversationsStore = {
const conversation = conversations.find((c) => c.id === conversationId);
if (conversation) {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = [{ ...conversation, is_archived: true }, ...archivedConversations];
archivedConversations = [{ ...conversation, isArchived: true }, ...archivedConversations];
}
}
@ -101,7 +101,7 @@ export const conversationsStore = {
const conversation = archivedConversations.find((c) => c.id === conversationId);
if (conversation) {
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
conversations = [{ ...conversation, is_archived: false }, ...conversations];
conversations = [{ ...conversation, isArchived: false }, ...conversations];
}
}

View file

@ -49,7 +49,7 @@ export const spacesStore = {
const spaceId = await spaceService.createSpace(space);
if (spaceId) {
// Reload spaces to get the new one with full data
await this.loadSpaces(space.owner_id);
await this.loadSpaces(space.ownerId);
}
return spaceId;
} catch (e) {

View file

@ -106,7 +106,7 @@ export const templatesStore = {
if (success) {
templates = templates.map((t) => ({
...t,
is_default: t.id === templateId,
isDefault: t.id === templateId,
}));
}
return success;

View file

@ -119,13 +119,13 @@
{conv.title || 'Unbenannte Konversation'}
</h3>
</div>
<span class="text-xs text-muted-foreground">{formatDate(conv.updated_at)}</span>
<span class="text-xs text-muted-foreground">{formatDate(conv.updatedAt)}</span>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="px-2 py-0.5 bg-muted rounded">
{conv.conversation_mode === 'free'
{conv.conversationMode === 'free'
? 'Freier Modus'
: conv.conversation_mode === 'guided'
: conv.conversationMode === 'guided'
? 'Geführter Modus'
: 'Vorlagen-Modus'}
</span>

View file

@ -7,10 +7,9 @@
import { authStore } from '$lib/stores/auth.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import ChatLayout from '$lib/components/chat/ChatLayout.svelte';
import type { AIModel, Message, Template } from '@chat/types';
import { FileText, Sparkle } from '@manacore/shared-icons';
import { Sparkle } from '@manacore/shared-icons';
let models = $state<AIModel[]>([]);
let templates = $state<Template[]>([]);
@ -157,7 +156,7 @@
</p>
<!-- Suggestion Pills -->
<div class="flex flex-wrap justify-center gap-3 mb-8">
<div class="flex flex-wrap justify-center gap-3">
<button
onclick={() => handleSend('Erkläre mir, wie KI funktioniert')}
class="px-5 py-2.5 text-sm font-medium rounded-full bg-white/80 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/20 text-foreground shadow-md hover:shadow-lg hover:bg-white dark:hover:bg-white/20 transition-all duration-200 hover:-translate-y-0.5"
@ -177,101 +176,6 @@
Tech-Trends
</button>
</div>
<!-- Options Bar -->
<div
class="inline-flex items-center gap-2 p-1.5 rounded-full bg-white/70 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/20 shadow-md"
>
<!-- Model Selector -->
<div class="relative">
<select
value={selectedModelId}
onchange={(e) => handleModelSelect((e.target as HTMLSelectElement).value)}
disabled={isSending}
class="appearance-none bg-transparent text-foreground text-sm font-medium rounded-full pl-4 pr-8 py-2 border-0 focus:outline-none focus:ring-0 disabled:opacity-50 cursor-pointer"
>
{#if models.length === 0}
<option value="">Laden...</option>
{:else}
{#each models as model (model.id)}
<option value={model.id}>{model.name}</option>
{/each}
{/if}
</select>
<div
class="absolute inset-y-0 right-2 flex items-center pointer-events-none"
>
<svg
class="w-4 h-4 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
<!-- Divider -->
<div class="w-px h-6 bg-black/10 dark:bg-white/20"></div>
<!-- Template Selector -->
{#if templates.length > 0}
<div class="relative">
<select
onchange={handleTemplateSelect}
value={selectedTemplateId}
disabled={isSending}
class="appearance-none bg-transparent text-foreground text-sm font-medium rounded-full pl-4 pr-8 py-2 border-0 focus:outline-none focus:ring-0 disabled:opacity-50 cursor-pointer"
>
<option value="">Ohne Vorlage</option>
{#each templates as template}
<option value={template.id}>
{template.name}
{template.isDefault ? ' ★' : ''}
</option>
{/each}
</select>
<div
class="absolute inset-y-0 right-2 flex items-center pointer-events-none"
>
<svg
class="w-4 h-4 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
<div class="w-px h-6 bg-black/10 dark:bg-white/20"></div>
{/if}
<!-- Document Mode Toggle -->
<button
onclick={toggleDocumentMode}
disabled={isSending}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 disabled:opacity-50
{documentMode
? 'bg-primary/20 text-primary'
: 'text-foreground hover:bg-black/5 dark:hover:bg-white/10'}"
title="Dokumentmodus aktivieren"
>
<FileText size={16} weight={documentMode ? 'fill' : 'bold'} />
<span>Dokument</span>
</button>
</div>
</div>
</div>
{:else}
@ -284,7 +188,15 @@
<!-- Floating Chat Input -->
<div class="flex-shrink-0 p-4 bg-gradient-to-t from-background via-background to-transparent">
<div class="max-w-3xl mx-auto">
<ChatInput onSend={handleSend} disabled={isSending || isLoading} />
<ChatInput
onSend={handleSend}
disabled={isSending || isLoading}
{models}
{selectedModelId}
onModelSelect={handleModelSelect}
{documentMode}
onDocumentModeToggle={toggleDocumentMode}
/>
</div>
</div>

View file

@ -1,25 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { conversationService } from '$lib/services/conversation';
import { chatService } from '$lib/services/chat';
import { documentService } from '$lib/services/document';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
import ChatLayout from '$lib/components/chat/ChatLayout.svelte';
import type { Conversation, Message, AIModel, Document } from '@chat/types';
import {
Archive,
Trash,
FileText,
ClockCounterClockwise,
X,
FloppyDisk,
ChatCircle,
} from '@manacore/shared-icons';
let conversation = $state<Conversation | null>(null);
@ -39,7 +33,7 @@
let showDocumentPanel = $state(true);
const conversationId = $derived($page.params.id ?? '');
const isDocumentMode = $derived(conversation?.document_mode ?? false);
const isDocumentMode = $derived(conversation?.documentMode ?? false);
onMount(async () => {
await loadData();
@ -62,13 +56,13 @@
}
// Set model from conversation
selectedModelId = conversation.model_id;
selectedModelId = conversation.modelId;
// Load messages
messages = await conversationService.getMessages(conversationId);
// Load document if in document mode
if (conversation.document_mode) {
if (conversation.documentMode) {
document = await documentService.getLatestDocument(conversationId);
documentContent = document?.content ?? '';
}
@ -121,10 +115,10 @@
// Optimistic update - add user message
const tempUserMessage: Message = {
id: `temp-user-${Date.now()}`,
conversation_id: conversationId,
conversationId: conversationId,
sender: 'user',
message_text: text,
created_at: new Date().toISOString(),
messageText: text,
createdAt: new Date().toISOString(),
};
messages = [...messages, tempUserMessage];
@ -155,26 +149,6 @@
function handleModelSelect(modelId: string) {
selectedModelId = modelId;
}
async function handleArchive() {
if (!conversation) return;
const success = await conversationsStore.archiveConversation(conversationId);
if (success) {
goto('/chat');
}
}
async function handleDelete() {
if (!conversation) return;
if (confirm('Möchtest du diese Konversation wirklich löschen?')) {
const success = await conversationsStore.deleteConversation(conversationId);
if (success) {
goto('/chat');
}
}
}
</script>
<svelte:head>
@ -196,70 +170,6 @@
</div>
{:else}
<div class="flex flex-col h-full">
<!-- Chat Header -->
<header
class="flex-shrink-0 border-b border-border bg-surface/50 backdrop-blur-sm px-6 py-4"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center"
>
<ChatCircle size={22} weight="fill" class="text-primary-foreground" />
</div>
<div>
<h2 class="text-lg font-semibold text-foreground truncate max-w-xs">
{conversation?.title || 'Chat'}
</h2>
<p class="text-sm text-muted-foreground">
{messages.length} Nachricht{messages.length !== 1 ? 'en' : ''}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Model Selector -->
<ModelSelector
{models}
{selectedModelId}
onSelect={handleModelSelect}
disabled={isSending}
/>
{#if isDocumentMode}
<button
onclick={toggleDocumentPanel}
class="p-2.5 transition-colors rounded-xl
{showDocumentPanel
? 'text-primary bg-primary/10 border border-primary/30'
: 'text-muted-foreground bg-muted border border-border hover:bg-muted/80'}"
aria-label="Dokument-Panel"
title="Dokument-Panel ein/ausblenden"
>
<FileText size={18} weight="bold" />
</button>
{/if}
<button
onclick={handleArchive}
class="p-2.5 text-muted-foreground bg-muted border border-border rounded-xl hover:bg-muted/80 transition-colors"
aria-label="Archivieren"
title="Archivieren"
>
<Archive size={18} weight="bold" />
</button>
<button
onclick={handleDelete}
class="p-2.5 text-destructive bg-muted border border-border rounded-xl hover:bg-destructive/10 transition-colors"
aria-label="Löschen"
title="Löschen"
>
<Trash size={18} weight="bold" />
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<div class="flex-1 flex overflow-hidden">
<!-- Chat Area -->
@ -277,10 +187,17 @@
<!-- Floating Chat Input -->
<div
class="flex-shrink-0 p-4 bg-gradient-to-t from-surface via-surface to-transparent"
class="flex-shrink-0 p-4 bg-gradient-to-t from-background via-background to-transparent"
>
<div class="max-w-3xl mx-auto">
<ChatInput onSend={handleSend} disabled={isSending} />
<ChatInput
onSend={handleSend}
disabled={isSending}
{models}
{selectedModelId}
onModelSelect={handleModelSelect}
documentMode={isDocumentMode}
/>
</div>
</div>
</div>
@ -399,7 +316,7 @@ Du kannst Markdown verwenden:
{version.id === document?.id ? ' (aktuell)' : ''}
</span>
<span class="text-xs text-muted-foreground">
{new Date(version.created_at).toLocaleDateString('de-DE', {
{new Date(version.createdAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
hour: '2-digit',

View file

@ -139,7 +139,7 @@
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each documents as doc (doc.id)}
<button
onclick={() => navigateToConversation(doc.conversation_id)}
onclick={() => navigateToConversation(doc.conversationId)}
class="text-left p-0 bg-surface rounded-xl border border-border
shadow-sm hover:shadow-md hover:border-primary/50 transition-all overflow-hidden"
>
@ -149,9 +149,9 @@
{extractTitle(doc.content)}
</h3>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span class="truncate">{doc.conversation_title}</span>
<span class="truncate">{doc.conversationTitle}</span>
<div class="flex items-center gap-2 flex-shrink-0">
<span>{formatDate(doc.updated_at)}</span>
<span>{formatDate(doc.updatedAt)}</span>
<span class="px-1.5 py-0.5 bg-muted rounded font-medium">
v{doc.version}
</span>

View file

@ -64,7 +64,7 @@
await spacesStore.createSpace({
name: data.name,
description: data.description,
owner_id: authStore.user.id,
ownerId: authStore.user.id,
});
}
@ -78,7 +78,7 @@
}
function isOwner(space: Space): boolean {
return space.owner_id === authStore.user?.id;
return space.ownerId === authStore.user?.id;
}
</script>

View file

@ -189,7 +189,7 @@
<h3 class="font-medium text-foreground">
{conv.title || 'Neue Konversation'}
</h3>
<span class="text-xs text-muted-foreground">{formatDate(conv.updated_at)}</span>
<span class="text-xs text-muted-foreground">{formatDate(conv.updatedAt)}</span>
</div>
</a>
{/each}

View file

@ -50,10 +50,10 @@
// Create a new conversation with this template
const conversationId = await conversationService.createConversation(
authStore.user.id,
template.model_id || '550e8400-e29b-41d4-a716-446655440101', // Default to Gemini 2.5 Flash
template.modelId || '550e8400-e29b-41d4-a716-446655440101', // Default to Gemini 2.5 Flash
'template',
template.id,
template.document_mode
template.documentMode
);
if (conversationId) {
@ -70,24 +70,24 @@
await templatesStore.updateTemplate(data.id, {
name: data.name,
description: data.description,
system_prompt: data.system_prompt,
initial_question: data.initial_question,
systemPrompt: data.systemPrompt,
initialQuestion: data.initialQuestion,
color: data.color,
model_id: data.model_id,
document_mode: data.document_mode,
modelId: data.modelId,
documentMode: data.documentMode,
});
} else {
// Create new template
await templatesStore.createTemplate({
user_id: authStore.user.id,
userId: authStore.user.id,
name: data.name!,
description: data.description ?? null,
system_prompt: data.system_prompt!,
initial_question: data.initial_question ?? null,
systemPrompt: data.systemPrompt!,
initialQuestion: data.initialQuestion ?? null,
color: data.color!,
model_id: data.model_id ?? null,
is_default: false,
document_mode: data.document_mode ?? false,
modelId: data.modelId ?? null,
isDefault: false,
documentMode: data.documentMode ?? false,
});
}

View file

@ -10,42 +10,46 @@ export interface ChatMessage {
export interface Message {
id: string;
conversation_id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
message_text: string;
created_at: string;
updated_at?: string;
messageText: string;
createdAt: string;
updatedAt?: string;
}
// Conversation Types
export interface Conversation {
id: string;
user_id: string;
model_id: string;
template_id?: string;
conversation_mode: 'free' | 'guided' | 'template';
document_mode: boolean;
userId: string;
modelId: string;
templateId?: string;
spaceId?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
title?: string;
is_archived: boolean;
created_at: string;
updated_at: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
// AI Model Types
export interface AIModelParameters {
model?: string;
temperature: number;
max_tokens: number;
provider: 'azure' | 'openai';
deployment: string;
endpoint: string;
api_version: string;
maxTokens?: number;
max_tokens?: number; // Legacy support
}
export interface AIModel {
id: string;
name: string;
description: string;
description?: string;
provider: 'gemini' | 'azure' | 'openai';
parameters: AIModelParameters;
isActive: boolean;
isDefault: boolean;
createdAt?: string;
updatedAt?: string;
}
// Token Usage Types
@ -64,22 +68,22 @@ export interface ChatCompletionResponse {
// Template Types
export interface Template {
id: string;
user_id: string;
userId: string;
name: string;
description: string | null;
system_prompt: string;
initial_question: string | null;
model_id: string | null;
systemPrompt: string;
initialQuestion: string | null;
modelId: string | null;
color: string;
is_default: boolean;
document_mode: boolean;
created_at: string;
updated_at: string;
isDefault: boolean;
documentMode: boolean;
createdAt: string;
updatedAt: string;
}
export type TemplateCreate = Omit<Template, 'id' | 'created_at' | 'updated_at'>;
export type TemplateCreate = Omit<Template, 'id' | 'createdAt' | 'updatedAt'>;
export type TemplateUpdate = Partial<
Omit<Template, 'id' | 'user_id' | 'created_at' | 'updated_at'>
Omit<Template, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
>;
// Space Types
@ -87,38 +91,38 @@ export interface Space {
id: string;
name: string;
description?: string;
owner_id: string;
is_archived: boolean;
created_at: string;
updated_at: string;
ownerId: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export type SpaceCreate = Pick<Space, 'name' | 'description' | 'owner_id'>;
export type SpaceUpdate = Partial<Pick<Space, 'name' | 'description' | 'is_archived'>>;
export type SpaceCreate = Pick<Space, 'name' | 'description' | 'ownerId'>;
export type SpaceUpdate = Partial<Pick<Space, 'name' | 'description' | 'isArchived'>>;
export interface SpaceMember {
id: string;
space_id: string;
user_id: string;
spaceId: string;
userId: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
invitation_status: 'pending' | 'accepted' | 'declined';
invited_by?: string;
invited_at: string;
joined_at?: string;
created_at: string;
updated_at: string;
invitationStatus: 'pending' | 'accepted' | 'declined';
invitedBy?: string;
invitedAt: string;
joinedAt?: string;
createdAt: string;
updatedAt: string;
}
// Document Types
export interface Document {
id: string;
conversation_id: string;
conversationId: string;
content: string;
version: number;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
}
export interface DocumentWithConversation extends Document {
conversation_title: string;
conversationTitle: string;
}