mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(chat): add SvelteKit web app with full feature parity to mobile
Web app features: - Auth: Login, Register, Forgot Password with shared-auth-ui - Chat: New chat, conversation view, message list, model selector - Templates: List, create, edit, delete templates - Spaces: Team workspaces with member management - Documents: Document mode with version history - Archive & Profile pages Technical: - SvelteKit 2 with Svelte 5 runes - Tailwind CSS 4 with shared themes - Supabase Auth with SSR - ChatLogo added to shared-branding - dev:*:app commands for web+backend 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ba3e0538b4
commit
9a84cc06d6
58 changed files with 7198 additions and 53 deletions
6
chat/apps/web/.env.example
Normal file
6
chat/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Supabase Configuration (same as mobile app)
|
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
|
||||
# Chat Backend API
|
||||
PUBLIC_BACKEND_URL=http://localhost:3001
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chat/types": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
|
|
@ -36,6 +37,8 @@
|
|||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"marked": "^17.0.0"
|
||||
}
|
||||
|
|
|
|||
5
chat/apps/web/postcss.config.js
Normal file
5
chat/apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
|
|
@ -1 +1,8 @@
|
|||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../../packages/shared-ui/src";
|
||||
@source "../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../packages/shared-branding/src";
|
||||
@source "../../../../packages/shared-theme-ui/src";
|
||||
|
|
|
|||
21
chat/apps/web/src/app.d.ts
vendored
Normal file
21
chat/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
supabase: SupabaseClient;
|
||||
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
|
||||
}
|
||||
interface PageData {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
41
chat/apps/web/src/hooks.server.ts
Normal file
41
chat/apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Server Hooks for SvelteKit
|
||||
* Handles Supabase session management
|
||||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { createSupabaseServerClient } from '$lib/services/supabase';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Create Supabase client for this request
|
||||
event.locals.supabase = createSupabaseServerClient(event.cookies);
|
||||
|
||||
// Get session
|
||||
event.locals.safeGetSession = async () => {
|
||||
const {
|
||||
data: { session },
|
||||
} = await event.locals.supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
// Validate user (not just reading from cookies)
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await event.locals.supabase.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
};
|
||||
|
||||
return resolve(event, {
|
||||
filterSerializedResponseHeaders(name) {
|
||||
return name === 'content-range' || name === 'x-supabase-api-version';
|
||||
},
|
||||
});
|
||||
};
|
||||
82
chat/apps/web/src/lib/components/chat/ChatInput.svelte
Normal file
82
chat/apps/web/src/lib/components/chat/ChatInput.svelte
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { onSend, disabled = false, placeholder = 'Nachricht eingeben...' }: Props = $props();
|
||||
|
||||
let inputValue = $state('');
|
||||
let textareaEl: HTMLTextAreaElement | undefined = $state();
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = inputValue.trim();
|
||||
if (trimmed && !disabled) {
|
||||
onSend(trimmed);
|
||||
inputValue = '';
|
||||
// Reset textarea height
|
||||
if (textareaEl) {
|
||||
textareaEl.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
// Auto-resize textarea
|
||||
if (textareaEl) {
|
||||
textareaEl.style.height = 'auto';
|
||||
textareaEl.style.height = Math.min(textareaEl.scrollHeight, 200) + 'px';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
|
||||
<div class="flex items-end gap-3 max-w-4xl mx-auto">
|
||||
<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 border-gray-300 dark:border-gray-600
|
||||
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
px-4 py-3 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
placeholder:text-gray-500 dark:placeholder:text-gray-400"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={disabled || !inputValue.trim()}
|
||||
aria-label="Nachricht senden"
|
||||
class="flex-shrink-0 p-3 rounded-xl bg-blue-600 text-white
|
||||
hover:bg-blue-700 active:bg-blue-800
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600
|
||||
transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
|
||||
Enter zum Senden, Shift+Enter für neue Zeile
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import type { Conversation } from '@chat/types';
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { conversations, isLoading = false }: Props = $props();
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return 'Gestern';
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||
} else {
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
}
|
||||
|
||||
function truncateTitle(title: string, maxLength: number = 30): string {
|
||||
if (title.length <= maxLength) return title;
|
||||
return title.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- New Chat Button -->
|
||||
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<a
|
||||
href="/chat"
|
||||
class="flex items-center justify-center gap-2 w-full px-4 py-2.5
|
||||
bg-blue-600 hover:bg-blue-700 text-white rounded-lg
|
||||
font-medium transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neuer Chat
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin w-6 h-6 border-2 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if conversations.length === 0}
|
||||
<div class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="text-sm">Keine Konversationen</p>
|
||||
<p class="text-xs mt-1">Starte einen neuen Chat</p>
|
||||
</div>
|
||||
{:else}
|
||||
<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-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'}"
|
||||
>
|
||||
<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-gray-500 dark:text-gray-500 flex-shrink-0">
|
||||
{formatDate(conv.updated_at || conv.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
52
chat/apps/web/src/lib/components/chat/MessageBubble.svelte
Normal file
52
chat/apps/web/src/lib/components/chat/MessageBubble.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import type { Message } from '@chat/types';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
let { message }: Props = $props();
|
||||
|
||||
const isUser = $derived(message.sender === 'user');
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
const htmlContent = $derived(
|
||||
isUser ? message.message_text : marked.parse(message.message_text)
|
||||
);
|
||||
|
||||
const formattedTime = $derived(
|
||||
new Date(message.created_at).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex {isUser ? 'justify-end' : 'justify-start'} mb-4">
|
||||
<div
|
||||
class="max-w-[80%] rounded-2xl px-4 py-3 {isUser
|
||||
? 'bg-blue-600 text-white rounded-br-md'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-md'}"
|
||||
>
|
||||
{#if isUser}
|
||||
<p class="whitespace-pre-wrap">{message.message_text}</p>
|
||||
{:else}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html htmlContent}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs mt-1 {isUser
|
||||
? 'text-blue-200'
|
||||
: 'text-gray-500 dark:text-gray-400'}"
|
||||
>
|
||||
{formattedTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
64
chat/apps/web/src/lib/components/chat/MessageList.svelte
Normal file
64
chat/apps/web/src/lib/components/chat/MessageList.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Message } from '@chat/types';
|
||||
import MessageBubble from './MessageBubble.svelte';
|
||||
import TypingIndicator from './TypingIndicator.svelte';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
isTyping?: boolean;
|
||||
}
|
||||
|
||||
let { messages, isTyping = false }: Props = $props();
|
||||
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
$effect(() => {
|
||||
if (messages.length > 0 && containerEl) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToBottom() {
|
||||
if (containerEl) {
|
||||
containerEl.scrollTop = containerEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
class="flex-1 overflow-y-auto px-4 py-6"
|
||||
>
|
||||
{#if messages.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
<svg
|
||||
class="w-16 h-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">Keine Nachrichten</p>
|
||||
<p class="text-sm">Starte eine Konversation!</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as message (message.id)}
|
||||
<MessageBubble {message} />
|
||||
{/each}
|
||||
{#if isTyping}
|
||||
<TypingIndicator />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
55
chat/apps/web/src/lib/components/chat/ModelSelector.svelte
Normal file
55
chat/apps/web/src/lib/components/chat/ModelSelector.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import type { AIModel } from '@chat/types';
|
||||
|
||||
interface Props {
|
||||
models: AIModel[];
|
||||
selectedModelId: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { models, selectedModelId, onSelect, disabled = false }: Props = $props();
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
onSelect(target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onchange={handleChange}
|
||||
{disabled}
|
||||
class="appearance-none bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
text-sm rounded-lg px-3 py-2 pr-8 border border-gray-200 dark:border-gray-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
cursor-pointer min-w-[160px]"
|
||||
>
|
||||
{#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-0 flex items-center pr-2 pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-500"
|
||||
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>
|
||||
41
chat/apps/web/src/lib/components/chat/TypingIndicator.svelte
Normal file
41
chat/apps/web/src/lib/components/chat/TypingIndicator.svelte
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
// Typing indicator with animated dots
|
||||
</script>
|
||||
|
||||
<div class="flex justify-start mb-4">
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 0ms"
|
||||
></div>
|
||||
<div
|
||||
class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 150ms"
|
||||
></div>
|
||||
<div
|
||||
class="w-2 h-2 bg-gray-400 dark:bg-gray-500 rounded-full animate-bounce"
|
||||
style="animation-delay: 300ms"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
</style>
|
||||
162
chat/apps/web/src/lib/components/spaces/SpaceCard.svelte
Normal file
162
chat/apps/web/src/lib/components/spaces/SpaceCard.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts">
|
||||
import type { Space } from '@chat/types';
|
||||
|
||||
interface Props {
|
||||
space: Space;
|
||||
isOwner: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onLeave: (id: string) => void;
|
||||
}
|
||||
|
||||
let { space, isOwner, onSelect, onEdit, onDelete, onLeave }: Props = $props();
|
||||
let showMenu = $state(false);
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function handleMenuClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
showMenu = !showMenu;
|
||||
}
|
||||
|
||||
function handleAction(action: () => void) {
|
||||
showMenu = false;
|
||||
action();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => (showMenu = false)} />
|
||||
|
||||
<div
|
||||
class="group relative bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
|
||||
shadow-sm hover:shadow-md transition-all cursor-pointer"
|
||||
onclick={() => onSelect(space.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSelect(space.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<svg class="w-5 h-5 text-blue-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">
|
||||
{space.name}
|
||||
</h3>
|
||||
{#if isOwner}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded">
|
||||
Besitzer
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if space.description}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
|
||||
{space.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Erstellt: {formatDate(space.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Options Menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={handleMenuClick}
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
aria-label="Optionen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 py-1 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg
|
||||
border border-gray-200 dark:border-gray-700 z-10"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={() => {}}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if isOwner}
|
||||
<button
|
||||
onclick={() => handleAction(() => onEdit(space.id))}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Einstellungen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleAction(() => onDelete(space.id))}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => handleAction(() => onLeave(space.id))}
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Verlassen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
102
chat/apps/web/src/lib/components/spaces/SpaceForm.svelte
Normal file
102
chat/apps/web/src/lib/components/spaces/SpaceForm.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import type { Space } from '@chat/types';
|
||||
|
||||
interface Props {
|
||||
space?: Space;
|
||||
onSubmit: (data: { name: string; description?: string }) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { space, onSubmit, onCancel }: Props = $props();
|
||||
|
||||
let name = $state(space?.name ?? '');
|
||||
let description = $state(space?.description ?? '');
|
||||
let errors = $state<{ name?: string }>({});
|
||||
|
||||
const isEditMode = !!space?.id;
|
||||
|
||||
function validateForm(): boolean {
|
||||
const newErrors: { name?: string } = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Bitte gib einen Namen ein.';
|
||||
}
|
||||
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl max-w-lg mx-auto">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{isEditMode ? 'Space bearbeiten' : 'Neuen Space erstellen'}
|
||||
</h2>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-5">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
maxlength={100}
|
||||
placeholder="Name des Spaces"
|
||||
class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-800
|
||||
text-gray-900 dark:text-white placeholder-gray-500
|
||||
{errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
maxlength={500}
|
||||
rows={3}
|
||||
placeholder="Worum geht es in diesem Space?"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
|
||||
rounded-lg font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{isEditMode ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
114
chat/apps/web/src/lib/components/templates/TemplateCard.svelte
Normal file
114
chat/apps/web/src/lib/components/templates/TemplateCard.svelte
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
import type { Template } from '@chat/types';
|
||||
|
||||
interface Props {
|
||||
template: Template;
|
||||
onUse: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSetDefault: (id: string) => void;
|
||||
}
|
||||
|
||||
let { template, onUse, onEdit, onDelete, onSetDefault }: Props = $props();
|
||||
|
||||
function truncatePrompt(text: string, maxLength: number = 80): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative flex rounded-xl overflow-hidden bg-white dark:bg-gray-800 shadow-sm hover:shadow-md transition-all
|
||||
{template.is_default ? 'ring-2 ring-blue-500' : 'border border-gray-200 dark:border-gray-700'}"
|
||||
>
|
||||
<!-- Color Indicator -->
|
||||
<div class="w-2 flex-shrink-0" style="background-color: {template.color}"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">
|
||||
{template.name}
|
||||
</h3>
|
||||
{#if template.is_default}
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-blue-500 text-white rounded">
|
||||
Standard
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if template.description}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
|
||||
{template.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 italic line-clamp-2">
|
||||
{truncatePrompt(template.system_prompt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if !template.is_default}
|
||||
<button
|
||||
onclick={() => onSetDefault(template.id)}
|
||||
class="p-1.5 text-gray-500 hover:text-yellow-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Als Standard setzen"
|
||||
aria-label="Als Standard setzen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => onEdit(template.id)}
|
||||
class="p-1.5 text-gray-500 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Bearbeiten"
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => onDelete(template.id)}
|
||||
class="p-1.5 text-gray-500 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Use Button -->
|
||||
<button
|
||||
onclick={() => onUse(template.id)}
|
||||
class="mt-3 w-full py-2 px-3 text-sm font-medium text-white rounded-lg transition-colors"
|
||||
style="background-color: {template.color}"
|
||||
>
|
||||
Chat starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
265
chat/apps/web/src/lib/components/templates/TemplateForm.svelte
Normal file
265
chat/apps/web/src/lib/components/templates/TemplateForm.svelte
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
<script lang="ts">
|
||||
import type { Template, AIModel } from '@chat/types';
|
||||
import { chatService } from '$lib/services/chat';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
template?: Template;
|
||||
onSubmit: (data: Partial<Template>) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { template, onSubmit, onCancel }: Props = $props();
|
||||
|
||||
// Available colors
|
||||
const TEMPLATE_COLORS = [
|
||||
'#0A84FF', // Blue
|
||||
'#32D74B', // Green
|
||||
'#FF375F', // Red
|
||||
'#FF9F0A', // Orange
|
||||
'#5E5CE6', // Purple
|
||||
'#BF5AF2', // Pink
|
||||
'#64D2FF', // Light Blue
|
||||
'#30D158', // Green 2
|
||||
'#FF453A', // Red 2
|
||||
];
|
||||
|
||||
// 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 selectedColor = $state(template?.color ?? TEMPLATE_COLORS[0]);
|
||||
let selectedModelId = $state(template?.model_id ?? '');
|
||||
let documentMode = $state(template?.document_mode ?? false);
|
||||
|
||||
// Models
|
||||
let models = $state<AIModel[]>([]);
|
||||
|
||||
// Validation
|
||||
let errors = $state<{ name?: string; systemPrompt?: string }>({});
|
||||
|
||||
const isEditMode = !!template?.id;
|
||||
|
||||
onMount(async () => {
|
||||
models = await chatService.getModels();
|
||||
});
|
||||
|
||||
function validateForm(): boolean {
|
||||
const newErrors: { name?: string; systemPrompt?: string } = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = 'Bitte gib einen Namen ein.';
|
||||
}
|
||||
|
||||
if (!systemPrompt.trim()) {
|
||||
newErrors.systemPrompt = 'Der System-Prompt darf nicht leer sein.';
|
||||
}
|
||||
|
||||
errors = newErrors;
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
onSubmit({
|
||||
id: template?.id,
|
||||
name,
|
||||
description: description.trim() || null,
|
||||
system_prompt: systemPrompt,
|
||||
initial_question: initialQuestion.trim() || null,
|
||||
color: selectedColor,
|
||||
model_id: selectedModelId || null,
|
||||
document_mode: documentMode,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-white dark:bg-gray-900 p-6 rounded-xl max-w-2xl mx-auto">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
{isEditMode ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
|
||||
</h2>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-5">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
maxlength={50}
|
||||
placeholder="Name der Vorlage"
|
||||
class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-800
|
||||
text-gray-900 dark:text-white placeholder-gray-500
|
||||
{errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="mt-1 text-sm text-red-500">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
maxlength={200}
|
||||
rows={2}
|
||||
placeholder="Kurze Beschreibung dieser Vorlage"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- System Prompt -->
|
||||
<div>
|
||||
<label for="systemPrompt" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
System-Prompt *
|
||||
</label>
|
||||
<textarea
|
||||
id="systemPrompt"
|
||||
bind:value={systemPrompt}
|
||||
rows={5}
|
||||
placeholder="System-Prompt für die KI"
|
||||
class="w-full px-3 py-2 border rounded-lg bg-gray-50 dark:bg-gray-800
|
||||
text-gray-900 dark:text-white placeholder-gray-500
|
||||
{errors.systemPrompt ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
></textarea>
|
||||
{#if errors.systemPrompt}
|
||||
<p class="mt-1 text-sm text-red-500">{errors.systemPrompt}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Der System-Prompt definiert die Rolle und das Verhalten der KI.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Initial Question -->
|
||||
<div>
|
||||
<label for="initialQuestion" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Beispielfrage (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="initialQuestion"
|
||||
bind:value={initialQuestion}
|
||||
rows={2}
|
||||
placeholder="Beispiel für eine passende Frage oder Anweisung"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Diese Frage wird als Vorschlag angezeigt, wenn die Vorlage ausgewählt wird.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Farbe
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each TEMPLATE_COLORS as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedColor = color)}
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center transition-transform hover:scale-110
|
||||
{selectedColor === color ? 'ring-2 ring-offset-2 ring-gray-900 dark:ring-white' : ''}"
|
||||
style="background-color: {color}"
|
||||
aria-label="Farbe {color}"
|
||||
>
|
||||
{#if selectedColor === color}
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Bevorzugtes Modell (optional)
|
||||
</label>
|
||||
<select
|
||||
id="model"
|
||||
bind:value={selectedModelId}
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Kein Modell ausgewählt</option>
|
||||
{#each models as model}
|
||||
<option value={model.id}>{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Falls ausgewählt, wird dieses Modell automatisch mit der Vorlage verwendet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Document Mode -->
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (documentMode = !documentMode)}
|
||||
class="w-full flex items-center justify-between p-4 border rounded-lg transition-colors
|
||||
{documentMode
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800'}"
|
||||
>
|
||||
<div class="text-left">
|
||||
<p class="font-medium text-gray-900 dark:text-white">Dokumentmodus aktivieren</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
Ermöglicht die Bearbeitung eines Dokuments während der Konversation
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center
|
||||
{documentMode ? 'bg-blue-500' : 'bg-gray-400'}"
|
||||
>
|
||||
{#if documentMode}
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300
|
||||
rounded-lg font-medium hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{isEditMode ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
52
chat/apps/web/src/lib/services/api.ts
Normal file
52
chat/apps/web/src/lib/services/api.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Backend API Client for Chat
|
||||
*/
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3001';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: errorText || `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'GET' }),
|
||||
post: <T>(endpoint: string, body: unknown) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
put: <T>(endpoint: string, body: unknown) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
46
chat/apps/web/src/lib/services/chat.ts
Normal file
46
chat/apps/web/src/lib/services/chat.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Chat Service - AI Completions via Backend
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
import type { ChatMessage, ChatCompletionResponse, AIModel } from '@chat/types';
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
messages: ChatMessage[];
|
||||
modelId: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export const chatService = {
|
||||
/**
|
||||
* Get available AI models
|
||||
*/
|
||||
async getModels(): Promise<AIModel[]> {
|
||||
const { data, error } = await api.get<AIModel[]>('/api/chat/models');
|
||||
if (error) {
|
||||
console.error('Failed to fetch models:', error);
|
||||
return [];
|
||||
}
|
||||
return data || [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Send chat completion request
|
||||
*/
|
||||
async createCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResponse | null> {
|
||||
const { data, error } = await api.post<ChatCompletionResponse>('/api/chat/completions', {
|
||||
messages: request.messages,
|
||||
modelId: request.modelId,
|
||||
temperature: request.temperature ?? 0.7,
|
||||
maxTokens: request.maxTokens ?? 1000,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Chat completion failed:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data || null;
|
||||
},
|
||||
};
|
||||
351
chat/apps/web/src/lib/services/conversation.ts
Normal file
351
chat/apps/web/src/lib/services/conversation.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* Conversation Service - CRUD operations via Supabase
|
||||
*/
|
||||
|
||||
import { createSupabaseBrowserClient } from './supabase';
|
||||
import { chatService } from './chat';
|
||||
import type { Conversation, Message, ChatMessage } from '@chat/types';
|
||||
|
||||
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
|
||||
|
||||
function getSupabase() {
|
||||
if (!supabase) {
|
||||
supabase = createSupabaseBrowserClient();
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export const conversationService = {
|
||||
/**
|
||||
* Create a new conversation
|
||||
*/
|
||||
async createConversation(
|
||||
userId: string,
|
||||
modelId: string,
|
||||
mode: 'free' | 'guided' | 'template' = 'free',
|
||||
templateId?: string,
|
||||
documentMode: boolean = false,
|
||||
spaceId?: string
|
||||
): Promise<string | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('conversations')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
model_id: modelId,
|
||||
template_id: templateId,
|
||||
conversation_mode: mode,
|
||||
document_mode: documentMode,
|
||||
space_id: spaceId,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all active conversations for a user
|
||||
*/
|
||||
async getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
let query = sb
|
||||
.from('conversations')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_archived', false);
|
||||
|
||||
if (spaceId) {
|
||||
query = query.eq('space_id', spaceId);
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as Conversation[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get archived conversations
|
||||
*/
|
||||
async getArchivedConversations(userId: string): Promise<Conversation[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('conversations')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_archived', true)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading archived conversations:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as Conversation[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single conversation
|
||||
*/
|
||||
async getConversation(conversationId: string): Promise<Conversation | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('conversations')
|
||||
.select('*')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading conversation:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Conversation;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get messages for a conversation
|
||||
*/
|
||||
async getMessages(conversationId: string): Promise<Message[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as Message[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a message to a conversation
|
||||
*/
|
||||
async addMessage(
|
||||
conversationId: string,
|
||||
sender: 'user' | 'assistant' | 'system',
|
||||
messageText: string
|
||||
): Promise<string | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('messages')
|
||||
.insert({
|
||||
conversation_id: conversationId,
|
||||
sender,
|
||||
message_text: messageText,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error adding message:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update conversation title
|
||||
*/
|
||||
async updateTitle(conversationId: string, title: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb
|
||||
.from('conversations')
|
||||
.update({ title, updated_at: new Date().toISOString() })
|
||||
.eq('id', conversationId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating title:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a conversation
|
||||
*/
|
||||
async archiveConversation(conversationId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb
|
||||
.from('conversations')
|
||||
.update({ is_archived: true })
|
||||
.eq('id', conversationId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error archiving conversation:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Unarchive a conversation
|
||||
*/
|
||||
async unarchiveConversation(conversationId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb
|
||||
.from('conversations')
|
||||
.update({ is_archived: false })
|
||||
.eq('id', conversationId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error unarchiving conversation:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a conversation permanently
|
||||
*/
|
||||
async deleteConversation(conversationId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
// Delete messages first
|
||||
const { error: messagesError } = await sb
|
||||
.from('messages')
|
||||
.delete()
|
||||
.eq('conversation_id', conversationId);
|
||||
|
||||
if (messagesError) {
|
||||
console.error('Error deleting messages:', messagesError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete conversation
|
||||
const { error: conversationError } = await sb
|
||||
.from('conversations')
|
||||
.delete()
|
||||
.eq('id', conversationId);
|
||||
|
||||
if (conversationError) {
|
||||
console.error('Error deleting conversation:', conversationError);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a message and get AI response
|
||||
*/
|
||||
async sendMessageAndGetResponse(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
modelId: string
|
||||
): Promise<{
|
||||
userMessageId: string | null;
|
||||
assistantMessageId: string | null;
|
||||
assistantResponse: string;
|
||||
title?: string;
|
||||
}> {
|
||||
// Add user message
|
||||
const userMessageId = await this.addMessage(conversationId, 'user', userMessage);
|
||||
|
||||
// Load all messages for context
|
||||
const messages = await this.getMessages(conversationId);
|
||||
|
||||
// Build chat messages for API
|
||||
const chatMessages: ChatMessage[] = messages.map((m) => ({
|
||||
role: m.sender === 'user' ? 'user' : m.sender === 'assistant' ? 'assistant' : 'system',
|
||||
content: m.message_text,
|
||||
}));
|
||||
|
||||
// Get AI response
|
||||
const response = await chatService.createCompletion({
|
||||
messages: chatMessages,
|
||||
modelId,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
userMessageId,
|
||||
assistantMessageId: null,
|
||||
assistantResponse: 'Fehler beim Abrufen der Antwort.',
|
||||
};
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
const assistantMessageId = await this.addMessage(conversationId, 'assistant', response.content);
|
||||
|
||||
// Update conversation timestamp
|
||||
const sb = getSupabase();
|
||||
await sb
|
||||
.from('conversations')
|
||||
.update({ updated_at: new Date().toISOString() })
|
||||
.eq('id', conversationId);
|
||||
|
||||
// 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);
|
||||
if (title) {
|
||||
await this.updateTitle(conversationId, title);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userMessageId,
|
||||
assistantMessageId,
|
||||
assistantResponse: response.content,
|
||||
title,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a conversation title based on user message
|
||||
*/
|
||||
async generateTitle(userMessage: string): Promise<string> {
|
||||
const titlePrompt = `Schreibe eine kurze, prägnante Überschrift (maximal 5 Wörter) für diesen Chat: "${userMessage}"`;
|
||||
|
||||
const response = await chatService.createCompletion({
|
||||
messages: [{ role: 'user', content: titlePrompt }],
|
||||
modelId: '550e8400-e29b-41d4-a716-446655440004', // GPT-4o-Mini
|
||||
temperature: 0.3,
|
||||
maxTokens: 50,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return 'Neue Konversation';
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
let title = response.content
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\.$/g, '');
|
||||
|
||||
if (title.length > 100) {
|
||||
title = title.substring(0, 97) + '...';
|
||||
}
|
||||
|
||||
return title;
|
||||
},
|
||||
};
|
||||
176
chat/apps/web/src/lib/services/document.ts
Normal file
176
chat/apps/web/src/lib/services/document.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* Document Service - Manage documents in document mode conversations
|
||||
*/
|
||||
|
||||
import { createSupabaseBrowserClient } from './supabase';
|
||||
import type { Document, DocumentWithConversation } from '@chat/types';
|
||||
|
||||
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
|
||||
|
||||
function getSupabase() {
|
||||
if (!supabase) {
|
||||
supabase = createSupabaseBrowserClient();
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export const documentService = {
|
||||
/**
|
||||
* Get all documents for a user (latest version of each)
|
||||
*/
|
||||
async getUserDocuments(userId: string): Promise<DocumentWithConversation[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
// Get all conversations with document_mode enabled
|
||||
const { data: conversations, error: convError } = await sb
|
||||
.from('conversations')
|
||||
.select('id, title, document_mode')
|
||||
.eq('user_id', userId)
|
||||
.eq('document_mode', true);
|
||||
|
||||
if (convError) {
|
||||
console.error('Error loading conversations:', convError);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!conversations || conversations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For each conversation, load the latest document version
|
||||
const documents: DocumentWithConversation[] = [];
|
||||
|
||||
for (const conv of conversations) {
|
||||
const { data: docData, error: docError } = await sb
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('conversation_id', conv.id)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (docError && docError.code !== 'PGRST116') {
|
||||
console.error(`Error loading document for conversation ${conv.id}:`, docError);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (docData) {
|
||||
documents.push({
|
||||
...docData,
|
||||
conversation_title: conv.title || 'Unbenannte Konversation',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the latest document for a conversation
|
||||
*/
|
||||
async getLatestDocument(conversationId: string): Promise<Document | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (error.code !== 'PGRST116') {
|
||||
console.error('Error loading document:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Document;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new document
|
||||
*/
|
||||
async createDocument(conversationId: string, content: string): Promise<Document | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('documents')
|
||||
.insert({
|
||||
conversation_id: conversationId,
|
||||
version: 1,
|
||||
content,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating document:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Document;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new version of a document
|
||||
*/
|
||||
async createDocumentVersion(conversationId: string, content: string): Promise<Document | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
// Get the current highest version
|
||||
const { data: latestVersionData, error: versionError } = await sb
|
||||
.from('documents')
|
||||
.select('version')
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (versionError && versionError.code !== 'PGRST116') {
|
||||
console.error('Error loading latest document version:', versionError);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newVersion = (latestVersionData?.version || 0) + 1;
|
||||
|
||||
// Create a new document version
|
||||
const { data, error } = await sb
|
||||
.from('documents')
|
||||
.insert({
|
||||
conversation_id: conversationId,
|
||||
version: newVersion,
|
||||
content,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating document version:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Document;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all versions of a document
|
||||
*/
|
||||
async getAllDocumentVersions(conversationId: string): Promise<Document[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('version', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading document versions:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as Document[];
|
||||
},
|
||||
};
|
||||
214
chat/apps/web/src/lib/services/space.ts
Normal file
214
chat/apps/web/src/lib/services/space.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Space Service - CRUD operations via Supabase
|
||||
*/
|
||||
|
||||
import { createSupabaseBrowserClient } from './supabase';
|
||||
import type { Space, SpaceMember, SpaceCreate, SpaceUpdate } from '@chat/types';
|
||||
|
||||
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
|
||||
|
||||
function getSupabase() {
|
||||
if (!supabase) {
|
||||
supabase = createSupabaseBrowserClient();
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export const spaceService = {
|
||||
/**
|
||||
* Get all spaces for a user (both owned and member of)
|
||||
*/
|
||||
async getUserSpaces(userId: string): Promise<Space[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
// Get space IDs the user is a member of (with accepted status)
|
||||
const { data: memberData, error: memberError } = await sb
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.eq('user_id', userId)
|
||||
.eq('invitation_status', 'accepted');
|
||||
|
||||
if (memberError) {
|
||||
console.error('Error fetching user space memberships:', memberError);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!memberData || memberData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const spaceIds = memberData.map((m) => m.space_id);
|
||||
|
||||
// Fetch the actual space data
|
||||
const { data: spaces, error: spacesError } = await sb
|
||||
.from('spaces')
|
||||
.select('*')
|
||||
.in('id', spaceIds)
|
||||
.eq('is_archived', false)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (spacesError) {
|
||||
console.error('Error fetching spaces:', spacesError);
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaces as Space[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single space by ID
|
||||
*/
|
||||
async getSpace(spaceId: string): Promise<Space | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb.from('spaces').select('*').eq('id', spaceId).single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching space:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Space;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new space
|
||||
*/
|
||||
async createSpace(space: SpaceCreate): Promise<string | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('spaces')
|
||||
.insert({
|
||||
name: space.name,
|
||||
description: space.description,
|
||||
owner_id: space.owner_id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating space:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a space
|
||||
*/
|
||||
async updateSpace(spaceId: string, updates: SpaceUpdate): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb.from('spaces').update(updates).eq('id', spaceId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating space:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a space
|
||||
*/
|
||||
async deleteSpace(spaceId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb.from('spaces').delete().eq('id', spaceId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting space:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get members of a space
|
||||
*/
|
||||
async getSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('space_members')
|
||||
.select('*')
|
||||
.eq('space_id', spaceId)
|
||||
.order('role', { ascending: true })
|
||||
.order('joined_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching space members:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as SpaceMember[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's role in a space
|
||||
*/
|
||||
async getUserRoleInSpace(
|
||||
spaceId: string,
|
||||
userId: string
|
||||
): Promise<'owner' | 'admin' | 'member' | 'viewer' | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
// First check if they're the owner
|
||||
const { data: space, error: spaceError } = await sb
|
||||
.from('spaces')
|
||||
.select('owner_id')
|
||||
.eq('id', spaceId)
|
||||
.single();
|
||||
|
||||
if (spaceError) {
|
||||
console.error('Error checking space ownership:', spaceError);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (space.owner_id === userId) {
|
||||
return 'owner';
|
||||
}
|
||||
|
||||
// If not owner, check membership
|
||||
const { data: member, error: memberError } = await sb
|
||||
.from('space_members')
|
||||
.select('role, invitation_status')
|
||||
.eq('space_id', spaceId)
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
if (memberError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (member && member.invitation_status === 'accepted') {
|
||||
return member.role as 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Leave a space
|
||||
*/
|
||||
async leaveSpace(spaceId: string, userId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb
|
||||
.from('space_members')
|
||||
.delete()
|
||||
.eq('space_id', spaceId)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error leaving space:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
42
chat/apps/web/src/lib/services/supabase.ts
Normal file
42
chat/apps/web/src/lib/services/supabase.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Supabase Client for Chat Web App
|
||||
* Uses the same Supabase instance as the mobile app
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { createBrowserClient, createServerClient } from '@supabase/ssr';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
|
||||
const supabaseUrl = env.PUBLIC_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = env.PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
/**
|
||||
* Browser client for client-side operations
|
||||
*/
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server client for SSR operations
|
||||
*/
|
||||
export function createSupabaseServerClient(cookies: Cookies) {
|
||||
return createServerClient(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookies.set(name, value, { ...options, path: '/' });
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple client for basic operations (no SSR)
|
||||
*/
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
157
chat/apps/web/src/lib/services/template.ts
Normal file
157
chat/apps/web/src/lib/services/template.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Template Service - CRUD operations via Supabase
|
||||
*/
|
||||
|
||||
import { createSupabaseBrowserClient } from './supabase';
|
||||
import type { Template, TemplateCreate, TemplateUpdate } from '@chat/types';
|
||||
|
||||
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
|
||||
|
||||
function getSupabase() {
|
||||
if (!supabase) {
|
||||
supabase = createSupabaseBrowserClient();
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export const templateService = {
|
||||
/**
|
||||
* Get all templates for a user
|
||||
*/
|
||||
async getTemplates(userId: string): Promise<Template[]> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('name');
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as Template[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single template by ID
|
||||
*/
|
||||
async getTemplate(templateId: string): Promise<Template | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('templates')
|
||||
.select('*')
|
||||
.eq('id', templateId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading template:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Template;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the default template for a user
|
||||
*/
|
||||
async getDefaultTemplate(userId: string): Promise<Template | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_default', true)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading default template:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Template;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*/
|
||||
async createTemplate(template: TemplateCreate): Promise<Template | null> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('templates')
|
||||
.insert(template)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating template:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data as Template;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a template
|
||||
*/
|
||||
async updateTemplate(templateId: string, updates: TemplateUpdate): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb
|
||||
.from('templates')
|
||||
.update(updates)
|
||||
.eq('id', templateId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating template:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a template
|
||||
*/
|
||||
async deleteTemplate(templateId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
const { error } = await sb.from('templates').delete().eq('id', templateId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting template:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a template as default
|
||||
*/
|
||||
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
|
||||
const sb = getSupabase();
|
||||
|
||||
// First, unset all defaults for this user
|
||||
await sb.from('templates').update({ is_default: false }).eq('user_id', userId);
|
||||
|
||||
// Then set the selected template as default
|
||||
const { error } = await sb
|
||||
.from('templates')
|
||||
.update({ is_default: true })
|
||||
.eq('id', templateId)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
console.error('Error setting default template:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
160
chat/apps/web/src/lib/stores/auth.svelte.ts
Normal file
160
chat/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Compatible with Chat mobile app (same Supabase instance)
|
||||
*/
|
||||
|
||||
import { createSupabaseBrowserClient } from '$lib/services/supabase';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
|
||||
// State
|
||||
let session = $state<Session | null>(null);
|
||||
let user = $state<User | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
// Create browser client
|
||||
let supabase: ReturnType<typeof createSupabaseBrowserClient> | null = null;
|
||||
|
||||
function getSupabase() {
|
||||
if (!supabase) {
|
||||
supabase = createSupabaseBrowserClient();
|
||||
}
|
||||
return supabase;
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
// Getters
|
||||
get session() {
|
||||
return session;
|
||||
},
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from Supabase session
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const sb = getSupabase();
|
||||
|
||||
// Get current session
|
||||
const {
|
||||
data: { session: currentSession },
|
||||
} = await sb.auth.getSession();
|
||||
|
||||
session = currentSession;
|
||||
user = currentSession?.user ?? null;
|
||||
|
||||
// Subscribe to auth changes
|
||||
sb.auth.onAuthStateChange((_event, newSession) => {
|
||||
session = newSession;
|
||||
user = newSession?.user ?? null;
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
session = null;
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const sb = getSupabase();
|
||||
const { data, error } = await sb.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
session = data.session;
|
||||
user = data.user;
|
||||
return { success: true, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const sb = getSupabase();
|
||||
const { data, error } = await sb.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
email_confirmed: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, needsVerification: false };
|
||||
}
|
||||
|
||||
// Check if email confirmation is required
|
||||
if (data.user && !data.session) {
|
||||
return { success: true, error: null, needsVerification: true };
|
||||
}
|
||||
|
||||
session = data.session;
|
||||
user = data.user;
|
||||
return { success: true, error: null, needsVerification: false };
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const sb = getSupabase();
|
||||
await sb.auth.signOut();
|
||||
session = null;
|
||||
user = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const sb = getSupabase();
|
||||
const { error } = await sb.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true, error: null };
|
||||
},
|
||||
|
||||
/**
|
||||
* Set session from server-side data
|
||||
*/
|
||||
setSession(newSession: Session | null) {
|
||||
session = newSession;
|
||||
user = newSession?.user ?? null;
|
||||
initialized = true;
|
||||
loading = false;
|
||||
},
|
||||
};
|
||||
125
chat/apps/web/src/lib/stores/chat.svelte.ts
Normal file
125
chat/apps/web/src/lib/stores/chat.svelte.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Chat Store - Manages current chat state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { chatService, type ChatCompletionRequest } from '$lib/services/chat';
|
||||
import type { Message, AIModel, ChatMessage } from '@chat/types';
|
||||
|
||||
// State
|
||||
let messages = $state<Message[]>([]);
|
||||
let models = $state<AIModel[]>([]);
|
||||
let selectedModelId = $state<string>('');
|
||||
let isLoading = $state(false);
|
||||
let isSending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Temporary message counter for IDs
|
||||
let messageCounter = 0;
|
||||
|
||||
export const chatStore = {
|
||||
// Getters
|
||||
get messages() {
|
||||
return messages;
|
||||
},
|
||||
get models() {
|
||||
return models;
|
||||
},
|
||||
get selectedModelId() {
|
||||
return selectedModelId;
|
||||
},
|
||||
get selectedModel() {
|
||||
return models.find((m) => m.id === selectedModelId) || null;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get isSending() {
|
||||
return isSending;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
// Actions
|
||||
async loadModels() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
models = await chatService.getModels();
|
||||
if (models.length > 0 && !selectedModelId) {
|
||||
selectedModelId = models[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load models';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedModel(modelId: string) {
|
||||
selectedModelId = modelId;
|
||||
},
|
||||
|
||||
async sendMessage(text: string) {
|
||||
if (!text.trim() || !selectedModelId) return;
|
||||
|
||||
isSending = true;
|
||||
error = null;
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: `temp-${++messageCounter}`,
|
||||
conversation_id: '',
|
||||
sender: 'user',
|
||||
message_text: text,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
messages = [...messages, userMessage];
|
||||
|
||||
try {
|
||||
// Build chat messages for API
|
||||
const chatMessages: ChatMessage[] = messages.map((m) => ({
|
||||
role: m.sender === 'user' ? 'user' : 'assistant',
|
||||
content: m.message_text,
|
||||
}));
|
||||
|
||||
const request: ChatCompletionRequest = {
|
||||
messages: chatMessages,
|
||||
modelId: selectedModelId,
|
||||
};
|
||||
|
||||
const response = await chatService.createCompletion(request);
|
||||
|
||||
if (response) {
|
||||
// Add assistant message
|
||||
const assistantMessage: Message = {
|
||||
id: `temp-${++messageCounter}`,
|
||||
conversation_id: '',
|
||||
sender: 'assistant',
|
||||
message_text: response.content,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
messages = [...messages, assistantMessage];
|
||||
} else {
|
||||
error = 'Failed to get response';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to send message';
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages() {
|
||||
messages = [];
|
||||
messageCounter = 0;
|
||||
error = null;
|
||||
},
|
||||
|
||||
reset() {
|
||||
messages = [];
|
||||
messageCounter = 0;
|
||||
error = null;
|
||||
isSending = false;
|
||||
},
|
||||
};
|
||||
135
chat/apps/web/src/lib/stores/conversations.svelte.ts
Normal file
135
chat/apps/web/src/lib/stores/conversations.svelte.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Conversations Store - Manages conversation list using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { conversationService } from '$lib/services/conversation';
|
||||
import type { Conversation } from '@chat/types';
|
||||
|
||||
// State
|
||||
let conversations = $state<Conversation[]>([]);
|
||||
let archivedConversations = $state<Conversation[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const conversationsStore = {
|
||||
// Getters
|
||||
get conversations() {
|
||||
return conversations;
|
||||
},
|
||||
get archivedConversations() {
|
||||
return archivedConversations;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load conversations for a user
|
||||
*/
|
||||
async loadConversations(userId: string, spaceId?: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
conversations = await conversationService.getConversations(userId, spaceId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load conversations';
|
||||
conversations = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load archived conversations
|
||||
*/
|
||||
async loadArchivedConversations(userId: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
archivedConversations = await conversationService.getArchivedConversations(userId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load archived conversations';
|
||||
archivedConversations = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new conversation to the list
|
||||
*/
|
||||
addConversation(conversation: Conversation) {
|
||||
conversations = [conversation, ...conversations];
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a conversation in the list
|
||||
*/
|
||||
updateConversation(conversationId: string, updates: Partial<Conversation>) {
|
||||
conversations = conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, ...updates } : c
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a conversation
|
||||
*/
|
||||
async archiveConversation(conversationId: string) {
|
||||
const success = await conversationService.archiveConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
if (conversation) {
|
||||
conversations = conversations.filter((c) => c.id !== conversationId);
|
||||
archivedConversations = [{ ...conversation, is_archived: true }, ...archivedConversations];
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Unarchive a conversation
|
||||
*/
|
||||
async unarchiveConversation(conversationId: string) {
|
||||
const success = await conversationService.unarchiveConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
const conversation = archivedConversations.find((c) => c.id === conversationId);
|
||||
if (conversation) {
|
||||
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
|
||||
conversations = [{ ...conversation, is_archived: false }, ...conversations];
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a conversation
|
||||
*/
|
||||
async deleteConversation(conversationId: string) {
|
||||
const success = await conversationService.deleteConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
conversations = conversations.filter((c) => c.id !== conversationId);
|
||||
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
|
||||
}
|
||||
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
reset() {
|
||||
conversations = [];
|
||||
archivedConversations = [];
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
122
chat/apps/web/src/lib/stores/spaces.svelte.ts
Normal file
122
chat/apps/web/src/lib/stores/spaces.svelte.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Spaces Store - Manages space list using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { spaceService } from '$lib/services/space';
|
||||
import type { Space, SpaceCreate, SpaceUpdate } from '@chat/types';
|
||||
|
||||
// State
|
||||
let spaces = $state<Space[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const spacesStore = {
|
||||
// Getters
|
||||
get spaces() {
|
||||
return spaces;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load spaces for a user
|
||||
*/
|
||||
async loadSpaces(userId: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
spaces = await spaceService.getUserSpaces(userId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load spaces';
|
||||
spaces = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new space
|
||||
*/
|
||||
async createSpace(space: SpaceCreate): Promise<string | null> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const spaceId = await spaceService.createSpace(space);
|
||||
if (spaceId) {
|
||||
// Reload spaces to get the new one with full data
|
||||
await this.loadSpaces(space.owner_id);
|
||||
}
|
||||
return spaceId;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create space';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a space
|
||||
*/
|
||||
async updateSpace(spaceId: string, updates: SpaceUpdate): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const success = await spaceService.updateSpace(spaceId, updates);
|
||||
if (success) {
|
||||
spaces = spaces.map((s) => (s.id === spaceId ? { ...s, ...updates } : s));
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update space';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a space
|
||||
*/
|
||||
async deleteSpace(spaceId: string): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const success = await spaceService.deleteSpace(spaceId);
|
||||
if (success) {
|
||||
spaces = spaces.filter((s) => s.id !== spaceId);
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete space';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Leave a space
|
||||
*/
|
||||
async leaveSpace(spaceId: string, userId: string): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const success = await spaceService.leaveSpace(spaceId, userId);
|
||||
if (success) {
|
||||
spaces = spaces.filter((s) => s.id !== spaceId);
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to leave space';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset store
|
||||
*/
|
||||
reset() {
|
||||
spaces = [];
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
126
chat/apps/web/src/lib/stores/templates.svelte.ts
Normal file
126
chat/apps/web/src/lib/stores/templates.svelte.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Templates Store - Manages template list using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { templateService } from '$lib/services/template';
|
||||
import type { Template, TemplateCreate, TemplateUpdate } from '@chat/types';
|
||||
|
||||
// State
|
||||
let templates = $state<Template[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const templatesStore = {
|
||||
// Getters
|
||||
get templates() {
|
||||
return templates;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load templates for a user
|
||||
*/
|
||||
async loadTemplates(userId: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
templates = await templateService.getTemplates(userId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load templates';
|
||||
templates = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*/
|
||||
async createTemplate(template: TemplateCreate): Promise<Template | null> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const newTemplate = await templateService.createTemplate(template);
|
||||
if (newTemplate) {
|
||||
templates = [...templates, newTemplate].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return newTemplate;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create template';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a template
|
||||
*/
|
||||
async updateTemplate(templateId: string, updates: TemplateUpdate): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const success = await templateService.updateTemplate(templateId, updates);
|
||||
if (success) {
|
||||
templates = templates
|
||||
.map((t) => (t.id === templateId ? { ...t, ...updates } : t))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update template';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a template
|
||||
*/
|
||||
async deleteTemplate(templateId: string): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const success = await templateService.deleteTemplate(templateId);
|
||||
if (success) {
|
||||
templates = templates.filter((t) => t.id !== templateId);
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete template';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a template as default
|
||||
*/
|
||||
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const success = await templateService.setDefaultTemplate(templateId, userId);
|
||||
if (success) {
|
||||
templates = templates.map((t) => ({
|
||||
...t,
|
||||
is_default: t.id === templateId,
|
||||
}));
|
||||
}
|
||||
return success;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to set default template';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset store
|
||||
*/
|
||||
reset() {
|
||||
templates = [];
|
||||
error = null;
|
||||
},
|
||||
};
|
||||
10
chat/apps/web/src/lib/stores/theme.ts
Normal file
10
chat/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'chat',
|
||||
defaultVariant: 'ocean',
|
||||
primaryColor: {
|
||||
light: '217 91% 60%', // Blue
|
||||
dark: '217 91% 60%',
|
||||
},
|
||||
});
|
||||
9
chat/apps/web/src/routes/(auth)/+layout.svelte
Normal file
9
chat/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4 py-12">
|
||||
<div class="w-full max-w-md">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
101
chat/apps/web/src/routes/(auth)/forgot-password/+page.svelte
Normal file
101
chat/apps/web/src/routes/(auth)/forgot-password/+page.svelte
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let success = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
const result = await authStore.resetPassword(email);
|
||||
|
||||
if (result.success) {
|
||||
success = true;
|
||||
} else {
|
||||
error = result.error || 'Fehler beim Zurücksetzen des Passworts';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Passwort zurücksetzen | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Passwort zurücksetzen</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Gib deine E-Mail-Adresse ein, um dein Passwort zurückzusetzen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if success}
|
||||
<div class="p-4 bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p class="text-green-700 dark:text-green-400 text-center">
|
||||
Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen deines Passworts gesendet.
|
||||
</p>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
disabled={loading}
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Wird gesendet...
|
||||
</span>
|
||||
{:else}
|
||||
Link senden
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/login" class="text-gray-600 dark:text-gray-400 hover:underline">
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
56
chat/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
56
chat/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { ChatLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// German translations
|
||||
const translations = {
|
||||
title: 'Anmelden',
|
||||
subtitle: 'Melde dich mit deinem Konto an',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
passwordPlaceholder: 'Passwort',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
signInButton: 'Anmelden',
|
||||
signingIn: 'Wird angemeldet...',
|
||||
success: 'Erfolgreich!',
|
||||
orDivider: 'oder',
|
||||
noAccount: 'Noch kein Konto?',
|
||||
createAccount: 'Jetzt registrieren',
|
||||
skipToForm: 'Zum Login-Formular springen',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
passwordRequired: 'Passwort ist erforderlich',
|
||||
signInFailed: 'Anmeldung fehlgeschlagen',
|
||||
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
|
||||
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
|
||||
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...'
|
||||
};
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anmelden | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="ManaChat"
|
||||
logo={ChatLogo}
|
||||
primaryColor="#0ea5e9"
|
||||
onSignIn={handleSignIn}
|
||||
goto={goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/chat"
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#e0f2fe"
|
||||
darkBackground="#0c1929"
|
||||
{translations}
|
||||
/>
|
||||
156
chat/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
156
chat/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let success = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Die Passwörter stimmen nicht überein';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
error = 'Das Passwort muss mindestens 6 Zeichen lang sein';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
const result = await authStore.signUp(email, password);
|
||||
|
||||
if (result.success) {
|
||||
if (result.needsVerification) {
|
||||
success = true;
|
||||
} else {
|
||||
goto('/');
|
||||
}
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registrieren | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">ManaChat</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">Erstelle dein Konto</p>
|
||||
</div>
|
||||
|
||||
{#if success}
|
||||
<div class="p-4 bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p class="text-green-700 dark:text-green-400 text-center">
|
||||
Bitte überprüfe deine E-Mails, um dein Konto zu bestätigen.
|
||||
</p>
|
||||
<div class="mt-4 text-center">
|
||||
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
Zur Anmeldung
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
disabled={loading}
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
disabled={loading}
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
disabled={loading}
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg
|
||||
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<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"></path>
|
||||
</svg>
|
||||
Wird registriert...
|
||||
</span>
|
||||
{:else}
|
||||
Registrieren
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Bereits ein Konto?
|
||||
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
Jetzt anmelden
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
22
chat/apps/web/src/routes/(protected)/+layout.server.ts
Normal file
22
chat/apps/web/src/routes/(protected)/+layout.server.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Protected routes layout server
|
||||
* Validates session and redirects to login if not authenticated
|
||||
*/
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
if (!session) {
|
||||
// Redirect to login with return URL
|
||||
const redirectTo = encodeURIComponent(url.pathname);
|
||||
redirect(303, `/login?redirectTo=${redirectTo}`);
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
};
|
||||
};
|
||||
112
chat/apps/web/src/routes/(protected)/+layout.svelte
Normal file
112
chat/apps/web/src/routes/(protected)/+layout.svelte
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||
|
||||
// Set session from server data
|
||||
onMount(() => {
|
||||
if (data.session) {
|
||||
authStore.setSession(data.session);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSignOut() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Top Navigation -->
|
||||
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
ManaChat
|
||||
</a>
|
||||
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
|
||||
<a
|
||||
href="/"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{$page.url.pathname === '/' || $page.url.pathname.startsWith('/chat')
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Chat
|
||||
</a>
|
||||
<a
|
||||
href="/templates"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{$page.url.pathname.startsWith('/templates')
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Templates
|
||||
</a>
|
||||
<a
|
||||
href="/spaces"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{$page.url.pathname.startsWith('/spaces')
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Spaces
|
||||
</a>
|
||||
<a
|
||||
href="/documents"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{$page.url.pathname.startsWith('/documents')
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Dokumente
|
||||
</a>
|
||||
<a
|
||||
href="/archive"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
{$page.url.pathname.startsWith('/archive')
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
Archiv
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.user}
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 hidden sm:block">
|
||||
{data.user.email}
|
||||
</span>
|
||||
{/if}
|
||||
<a
|
||||
href="/profile"
|
||||
class="p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
aria-label="Profil"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
166
chat/apps/web/src/routes/(protected)/archive/+page.svelte
Normal file
166
chat/apps/web/src/routes/(protected)/archive/+page.svelte
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import type { Conversation } from '@chat/types';
|
||||
|
||||
let conversations = $state<Conversation[]>([]);
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.user) {
|
||||
await conversationsStore.loadArchivedConversations(authStore.user.id);
|
||||
conversations = conversationsStore.archivedConversations;
|
||||
}
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
// Keep conversations in sync with store
|
||||
$effect(() => {
|
||||
conversations = conversationsStore.archivedConversations;
|
||||
});
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function handleConversationClick(id: string) {
|
||||
goto(`/chat/${id}`);
|
||||
}
|
||||
|
||||
async function handleUnarchive(id: string) {
|
||||
await conversationsStore.unarchiveConversation(id);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm('Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
|
||||
await conversationsStore.deleteConversation(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Archiv | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Archiv</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Deine archivierten Konversationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if conversations.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<svg
|
||||
class="w-16 h-16 text-gray-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine archivierten Konversationen</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
Archivierte Gespräche erscheinen hier.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Conversation List -->
|
||||
<div class="space-y-3">
|
||||
{#each conversations as conv (conv.id)}
|
||||
<div
|
||||
class="group bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
|
||||
shadow-sm hover:shadow-md transition-all overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleConversationClick(conv.id)}
|
||||
class="w-full p-4 text-left"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{conv.title || 'Unbenannte Konversation'}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{formatDate(conv.updated_at)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">
|
||||
{conv.conversation_mode === 'free' ? 'Freier Modus' :
|
||||
conv.conversation_mode === 'guided' ? 'Geführter Modus' : 'Vorlagen-Modus'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 px-4 py-2 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<button
|
||||
onclick={() => handleUnarchive(conv.id)}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400
|
||||
hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20
|
||||
rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(conv.id)}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400
|
||||
hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if conversationsStore.error}
|
||||
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{conversationsStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
66
chat/apps/web/src/routes/(protected)/chat/+layout.svelte
Normal file
66
chat/apps/web/src/routes/(protected)/chat/+layout.svelte
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import ConversationList from '$lib/components/chat/ConversationList.svelte';
|
||||
|
||||
let { children }: { children: any } = $props();
|
||||
let showSidebar = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.user) {
|
||||
await conversationsStore.loadConversations(authStore.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
showSidebar = !showSidebar;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-[calc(100vh-4rem)]">
|
||||
<!-- Sidebar Toggle (mobile) -->
|
||||
<button
|
||||
onclick={toggleSidebar}
|
||||
class="fixed bottom-4 left-4 z-50 p-3 bg-blue-600 text-white rounded-full shadow-lg
|
||||
sm:hidden hover:bg-blue-700 transition-colors"
|
||||
aria-label={showSidebar ? 'Seitenleiste schließen' : 'Seitenleiste öffnen'}
|
||||
>
|
||||
{#if showSidebar}
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="w-72 flex-shrink-0 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700
|
||||
transition-transform duration-200 ease-in-out
|
||||
fixed sm:static inset-y-0 left-0 z-40 top-16
|
||||
{showSidebar ? 'translate-x-0' : '-translate-x-full sm:translate-x-0 sm:w-0 sm:border-0'}"
|
||||
>
|
||||
<ConversationList
|
||||
conversations={conversationsStore.conversations}
|
||||
isLoading={conversationsStore.isLoading}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
{#if showSidebar}
|
||||
<button
|
||||
class="fixed inset-0 bg-black/50 z-30 sm:hidden"
|
||||
onclick={toggleSidebar}
|
||||
aria-label="Seitenleiste schließen"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-hidden">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
227
chat/apps/web/src/routes/(protected)/chat/+page.svelte
Normal file
227
chat/apps/web/src/routes/(protected)/chat/+page.svelte
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { chatService } from '$lib/services/chat';
|
||||
import { conversationService } from '$lib/services/conversation';
|
||||
import { templateService } from '$lib/services/template';
|
||||
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 { theme } from '$lib/stores/theme';
|
||||
import type { AIModel, Message, Template } from '@chat/types';
|
||||
|
||||
let models = $state<AIModel[]>([]);
|
||||
let templates = $state<Template[]>([]);
|
||||
let selectedModelId = $state('');
|
||||
let selectedTemplateId = $state('');
|
||||
let documentMode = $state(false);
|
||||
let messages = $state<Message[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let isSending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Get selected template
|
||||
const selectedTemplate = $derived(templates.find((t) => t.id === selectedTemplateId));
|
||||
|
||||
onMount(async () => {
|
||||
models = await chatService.getModels();
|
||||
if (models.length > 0) {
|
||||
selectedModelId = models[0].id;
|
||||
}
|
||||
|
||||
// Load user templates
|
||||
if (authStore.user) {
|
||||
templates = await templateService.getTemplates(authStore.user.id);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
async function handleSend(text: string) {
|
||||
if (!authStore.user || !selectedModelId) return;
|
||||
|
||||
isSending = true;
|
||||
error = null;
|
||||
|
||||
// Add optimistic user message
|
||||
const tempUserMessage: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
conversation_id: '',
|
||||
sender: 'user',
|
||||
message_text: text,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
messages = [...messages, tempUserMessage];
|
||||
|
||||
try {
|
||||
// Determine mode and model based on template
|
||||
const mode = selectedTemplate ? 'template' : 'free';
|
||||
const modelToUse = selectedTemplate?.model_id || selectedModelId;
|
||||
const docMode = selectedTemplate?.document_mode || documentMode;
|
||||
|
||||
// Create new conversation
|
||||
const conversationId = await conversationService.createConversation(
|
||||
authStore.user.id,
|
||||
modelToUse,
|
||||
mode as 'free' | 'guided' | 'template',
|
||||
selectedTemplate?.id,
|
||||
docMode
|
||||
);
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Konversation konnte nicht erstellt werden');
|
||||
}
|
||||
|
||||
// Send message and get response
|
||||
const result = await conversationService.sendMessageAndGetResponse(
|
||||
conversationId,
|
||||
text,
|
||||
modelToUse
|
||||
);
|
||||
|
||||
// Reload conversations list
|
||||
await conversationsStore.loadConversations(authStore.user.id);
|
||||
|
||||
// Navigate to the new conversation
|
||||
goto(`/chat/${conversationId}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Konversation';
|
||||
messages = [];
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleModelSelect(modelId: string) {
|
||||
selectedModelId = modelId;
|
||||
}
|
||||
|
||||
function handleTemplateSelect(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
selectedTemplateId = target.value;
|
||||
|
||||
// If template has a model, update selected model
|
||||
const template = templates.find((t) => t.id === target.value);
|
||||
if (template?.model_id) {
|
||||
selectedModelId = template.model_id;
|
||||
}
|
||||
// If template has document mode, enable it
|
||||
if (template?.document_mode) {
|
||||
documentMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDocumentMode() {
|
||||
documentMode = !documentMode;
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Chat | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Chat Header -->
|
||||
<header class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
|
||||
<div class="flex items-center justify-between max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Neuer Chat</h2>
|
||||
|
||||
<!-- Model Selector -->
|
||||
<ModelSelector
|
||||
{models}
|
||||
{selectedModelId}
|
||||
onSelect={handleModelSelect}
|
||||
disabled={isSending}
|
||||
/>
|
||||
|
||||
<!-- Template Selector -->
|
||||
{#if templates.length > 0}
|
||||
<select
|
||||
onchange={handleTemplateSelect}
|
||||
value={selectedTemplateId}
|
||||
disabled={isSending}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-white
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
disabled:opacity-50"
|
||||
>
|
||||
<option value="">Ohne Vorlage</option>
|
||||
{#each templates as template}
|
||||
<option value={template.id}>
|
||||
{template.name}
|
||||
{template.is_default ? ' (Standard)' : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<!-- Document Mode Toggle -->
|
||||
<button
|
||||
onclick={toggleDocumentMode}
|
||||
disabled={isSending}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg transition-colors
|
||||
{documentMode
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-300 dark:border-blue-700'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-300 dark:border-gray-600'}
|
||||
hover:bg-opacity-80 disabled:opacity-50"
|
||||
title="Dokumentmodus aktivieren"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Dokument
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="p-2 text-gray-700 dark:text-gray-300
|
||||
bg-gray-100 dark:bg-gray-800 rounded-lg
|
||||
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Theme wechseln"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Messages Area -->
|
||||
<main class="flex-1 overflow-hidden bg-white dark:bg-gray-900">
|
||||
<div class="h-full max-w-4xl mx-auto flex flex-col">
|
||||
<MessageList {messages} isTyping={isSending} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Input Area -->
|
||||
<ChatInput onSend={handleSend} disabled={isSending || isLoading} />
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div
|
||||
class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-red-500 text-white rounded-lg shadow-lg"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
403
chat/apps/web/src/routes/(protected)/chat/[id]/+page.svelte
Normal file
403
chat/apps/web/src/routes/(protected)/chat/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
<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 { theme } from '$lib/stores/theme';
|
||||
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 type { Conversation, Message, AIModel, Document } from '@chat/types';
|
||||
|
||||
let conversation = $state<Conversation | null>(null);
|
||||
let messages = $state<Message[]>([]);
|
||||
let models = $state<AIModel[]>([]);
|
||||
let selectedModelId = $state('');
|
||||
let isLoading = $state(true);
|
||||
let isSending = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Document mode state
|
||||
let document = $state<Document | null>(null);
|
||||
let documentContent = $state('');
|
||||
let documentVersions = $state<Document[]>([]);
|
||||
let isSavingDocument = $state(false);
|
||||
let showVersionsModal = $state(false);
|
||||
let showDocumentPanel = $state(true);
|
||||
|
||||
const conversationId = $derived($page.params.id ?? '');
|
||||
const isDocumentMode = $derived(conversation?.document_mode ?? false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Load models
|
||||
models = await chatService.getModels();
|
||||
|
||||
// Load conversation
|
||||
conversation = await conversationService.getConversation(conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
error = 'Konversation nicht gefunden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Set model from conversation
|
||||
selectedModelId = conversation.model_id;
|
||||
|
||||
// Load messages
|
||||
messages = await conversationService.getMessages(conversationId);
|
||||
|
||||
// Load document if in document mode
|
||||
if (conversation.document_mode) {
|
||||
document = await documentService.getLatestDocument(conversationId);
|
||||
documentContent = document?.content ?? '';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDocument() {
|
||||
if (!documentContent.trim()) return;
|
||||
|
||||
isSavingDocument = true;
|
||||
try {
|
||||
if (document) {
|
||||
// Create new version
|
||||
document = await documentService.createDocumentVersion(conversationId, documentContent);
|
||||
} else {
|
||||
// Create first document
|
||||
document = await documentService.createDocument(conversationId, documentContent);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Speichern';
|
||||
} finally {
|
||||
isSavingDocument = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
documentVersions = await documentService.getAllDocumentVersions(conversationId);
|
||||
showVersionsModal = true;
|
||||
}
|
||||
|
||||
function restoreVersion(version: Document) {
|
||||
documentContent = version.content;
|
||||
showVersionsModal = false;
|
||||
}
|
||||
|
||||
function toggleDocumentPanel() {
|
||||
showDocumentPanel = !showDocumentPanel;
|
||||
}
|
||||
|
||||
async function handleSend(text: string) {
|
||||
if (!conversation || !selectedModelId) return;
|
||||
|
||||
isSending = true;
|
||||
error = null;
|
||||
|
||||
// Optimistic update - add user message
|
||||
const tempUserMessage: Message = {
|
||||
id: `temp-user-${Date.now()}`,
|
||||
conversation_id: conversationId,
|
||||
sender: 'user',
|
||||
message_text: text,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
messages = [...messages, tempUserMessage];
|
||||
|
||||
try {
|
||||
const result = await conversationService.sendMessageAndGetResponse(
|
||||
conversationId,
|
||||
text,
|
||||
selectedModelId
|
||||
);
|
||||
|
||||
// Update messages with real data
|
||||
messages = await conversationService.getMessages(conversationId);
|
||||
|
||||
// Update conversation title if generated
|
||||
if (result.title && conversation) {
|
||||
conversation = { ...conversation, title: result.title };
|
||||
conversationsStore.updateConversation(conversationId, { title: result.title });
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Senden';
|
||||
// Remove optimistic message on error
|
||||
messages = messages.filter((m) => m.id !== tempUserMessage.id);
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{conversation?.title || 'Chat'} | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if error && !conversation}
|
||||
<div class="flex flex-col items-center justify-center h-full text-center p-4">
|
||||
<p class="text-red-500 mb-4">{error}</p>
|
||||
<a href="/chat" class="text-blue-600 hover:underline">Zurück zum Chat</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Chat Header -->
|
||||
<header class="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
|
||||
<div class="flex items-center justify-between max-w-4xl mx-auto">
|
||||
<div class="flex items-center gap-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white truncate max-w-xs">
|
||||
{conversation?.title || 'Chat'}
|
||||
</h2>
|
||||
<ModelSelector
|
||||
{models}
|
||||
{selectedModelId}
|
||||
onSelect={handleModelSelect}
|
||||
disabled={isSending}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isDocumentMode}
|
||||
<button
|
||||
onclick={toggleDocumentPanel}
|
||||
class="p-2 transition-colors rounded-lg
|
||||
{showDocumentPanel
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'}"
|
||||
aria-label="Dokument-Panel"
|
||||
title="Dokument-Panel ein/ausblenden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={handleArchive}
|
||||
class="p-2 text-gray-700 dark:text-gray-300
|
||||
bg-gray-100 dark:bg-gray-800 rounded-lg
|
||||
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Archivieren"
|
||||
title="Archivieren"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="p-2 text-red-600 dark:text-red-400
|
||||
bg-gray-100 dark:bg-gray-800 rounded-lg
|
||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
aria-label="Löschen"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="p-2 text-gray-700 dark:text-gray-300
|
||||
bg-gray-100 dark:bg-gray-800 rounded-lg
|
||||
hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Theme wechseln"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Chat Area -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden {isDocumentMode && showDocumentPanel ? 'lg:w-1/2' : 'w-full'}">
|
||||
<!-- Messages Area -->
|
||||
<main class="flex-1 overflow-hidden bg-white dark:bg-gray-900">
|
||||
<div class="h-full max-w-4xl mx-auto flex flex-col">
|
||||
<MessageList {messages} isTyping={isSending} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Input Area -->
|
||||
<ChatInput onSend={handleSend} disabled={isSending} />
|
||||
</div>
|
||||
|
||||
<!-- Document Panel -->
|
||||
{#if isDocumentMode && showDocumentPanel}
|
||||
<div class="hidden lg:flex lg:w-1/2 flex-col border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<!-- Document Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">Dokument</span>
|
||||
{#if document}
|
||||
<span class="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||
v{document.version}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={loadVersions}
|
||||
class="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white
|
||||
hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||
title="Versionen anzeigen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={saveDocument}
|
||||
disabled={isSavingDocument || !documentContent.trim()}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white
|
||||
bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400
|
||||
rounded-lg transition-colors"
|
||||
>
|
||||
{#if isSavingDocument}
|
||||
<div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
{/if}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Editor -->
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<textarea
|
||||
bind:value={documentContent}
|
||||
placeholder="Beginne hier mit dem Schreiben deines Dokuments...
|
||||
|
||||
Du kannst Markdown verwenden:
|
||||
# Überschrift
|
||||
## Unterüberschrift
|
||||
- Aufzählung
|
||||
**Fett** und *Kursiv*"
|
||||
class="w-full h-full min-h-[300px] p-4 text-sm font-mono
|
||||
bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white
|
||||
border border-gray-200 dark:border-gray-700 rounded-lg
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-red-500 text-white rounded-lg shadow-lg">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Versions Modal -->
|
||||
{#if showVersionsModal}
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Dokumentversionen</h3>
|
||||
<button
|
||||
onclick={() => showVersionsModal = false}
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
{#if documentVersions.length === 0}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 py-8">Keine Versionen vorhanden</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each documentVersions as version (version.id)}
|
||||
<button
|
||||
onclick={() => restoreVersion(version)}
|
||||
class="w-full p-3 text-left rounded-lg border border-gray-200 dark:border-gray-700
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors
|
||||
{version.id === document?.id ? 'ring-2 ring-blue-500' : ''}"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
Version {version.version}
|
||||
{version.id === document?.id ? ' (aktuell)' : ''}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{new Date(version.created_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{version.content.substring(0, 100)}...
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
176
chat/apps/web/src/routes/(protected)/documents/+page.svelte
Normal file
176
chat/apps/web/src/routes/(protected)/documents/+page.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { documentService } from '$lib/services/document';
|
||||
import type { DocumentWithConversation } from '@chat/types';
|
||||
|
||||
let documents = $state<DocumentWithConversation[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.user) {
|
||||
await loadDocuments();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDocuments() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
documents = await documentService.getUserDocuments(authStore.user!.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden der Dokumente';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTitle(content: string): string {
|
||||
// Look for markdown heading level 1 at the start
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
return titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Alternative: Look for heading level 2
|
||||
const subtitleMatch = content.match(/^##\s+(.+)$/m);
|
||||
if (subtitleMatch && subtitleMatch[1]) {
|
||||
return subtitleMatch[1].trim();
|
||||
}
|
||||
|
||||
// If no heading found, take first words
|
||||
const firstLine = content.split('\n')[0].trim();
|
||||
if (firstLine.length > 0) {
|
||||
return firstLine.length > 40 ? `${firstLine.substring(0, 37)}...` : firstLine;
|
||||
}
|
||||
|
||||
return 'Dokument ohne Titel';
|
||||
}
|
||||
|
||||
function getPreview(content: string): string {
|
||||
// Remove the first heading if present
|
||||
let preview = content.replace(/^#\s+.+$/m, '').trim();
|
||||
// Take first 200 characters
|
||||
if (preview.length > 200) {
|
||||
preview = preview.substring(0, 200) + '...';
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function navigateToConversation(conversationId: string) {
|
||||
goto(`/chat/${conversationId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dokumente | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Dokumente</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Alle Dokumente aus deinen Konversationen im Dokumentmodus.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadDocuments}
|
||||
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200
|
||||
hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
aria-label="Aktualisieren"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if documents.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<svg
|
||||
class="w-16 h-16 text-gray-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine Dokumente gefunden</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||
Erstelle ein neues Dokument in einer Konversation mit aktiviertem Dokumentmodus.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Documents Grid -->
|
||||
<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)}
|
||||
class="text-left p-0 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
|
||||
shadow-sm hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 mb-2">
|
||||
{extractTitle(doc.content)}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span class="truncate">{doc.conversation_title}</span>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span>{formatDate(doc.updated_at)}</span>
|
||||
<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded font-medium">
|
||||
v{doc.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="p-4 h-32 overflow-hidden">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-5">
|
||||
{getPreview(doc.content)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
132
chat/apps/web/src/routes/(protected)/profile/+page.svelte
Normal file
132
chat/apps/web/src/routes/(protected)/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
||||
function handleSignOut() {
|
||||
authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div class="max-w-2xl mx-auto px-4">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Profil</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Verwalte dein Konto und deine Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden mb-6">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{authStore.user?.email || 'Benutzer'}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Mitglied seit {formatDate(authStore.user?.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">E-Mail</p>
|
||||
<p class="text-sm text-gray-500">{authStore.user?.email || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">Benutzer-ID</p>
|
||||
<p class="text-sm text-gray-500 font-mono">{authStore.user?.id || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden mb-6">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Einstellungen</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Theme Toggle -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">Dunkler Modus</p>
|
||||
<p class="text-sm text-gray-500">Aktiviere den dunklen Modus für die App</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="relative w-12 h-6 rounded-full transition-colors
|
||||
{theme.mode === 'dark' ? 'bg-blue-600' : 'bg-gray-300'}"
|
||||
role="switch"
|
||||
aria-checked={theme.mode === 'dark'}
|
||||
aria-label="Dunkler Modus umschalten"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||
{theme.mode === 'dark' ? 'translate-x-6' : 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div class="p-6">
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 dark:bg-red-900/20
|
||||
text-red-600 dark:text-red-400 rounded-lg font-medium
|
||||
hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
175
chat/apps/web/src/routes/(protected)/spaces/+page.svelte
Normal file
175
chat/apps/web/src/routes/(protected)/spaces/+page.svelte
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { spacesStore } from '$lib/stores/spaces.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import SpaceCard from '$lib/components/spaces/SpaceCard.svelte';
|
||||
import SpaceForm from '$lib/components/spaces/SpaceForm.svelte';
|
||||
import type { Space } from '@chat/types';
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingSpace = $state<Space | undefined>(undefined);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.user) {
|
||||
await spacesStore.loadSpaces(authStore.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
function handleCreateNew() {
|
||||
editingSpace = undefined;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function handleSelect(id: string) {
|
||||
// Navigate to space - load conversations filtered by this space
|
||||
goto(`/spaces/${id}`);
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
const space = spacesStore.spaces.find((s) => s.id === id);
|
||||
if (space) {
|
||||
editingSpace = space;
|
||||
showForm = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm('Möchtest du diesen Space wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.')) {
|
||||
await spacesStore.deleteSpace(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLeave(id: string) {
|
||||
if (!authStore.user) return;
|
||||
|
||||
if (confirm('Möchtest du diesen Space wirklich verlassen?')) {
|
||||
await spacesStore.leaveSpace(id, authStore.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: { name: string; description?: string }) {
|
||||
if (!authStore.user) return;
|
||||
|
||||
if (editingSpace) {
|
||||
// Update existing space
|
||||
await spacesStore.updateSpace(editingSpace.id, data);
|
||||
} else {
|
||||
// Create new space
|
||||
await spacesStore.createSpace({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
owner_id: authStore.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
showForm = false;
|
||||
editingSpace = undefined;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
showForm = false;
|
||||
editingSpace = undefined;
|
||||
}
|
||||
|
||||
function isOwner(space: Space): boolean {
|
||||
return space.owner_id === authStore.user?.id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Spaces | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Spaces</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Organisiere deine Konversationen in kollaborativen Arbeitsbereichen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleCreateNew}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neuen Space erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if spacesStore.isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if spacesStore.spaces.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<svg
|
||||
class="w-16 h-16 text-gray-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine Spaces gefunden</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||
Erstelle einen neuen Space oder frage nach einer Einladung
|
||||
</p>
|
||||
<button
|
||||
onclick={handleCreateNew}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Ersten Space erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Spaces Grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each spacesStore.spaces as space (space.id)}
|
||||
<SpaceCard
|
||||
{space}
|
||||
isOwner={isOwner(space)}
|
||||
onSelect={handleSelect}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onLeave={handleLeave}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if spacesStore.error}
|
||||
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{spacesStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl shadow-xl">
|
||||
<SpaceForm space={editingSpace} onSubmit={handleSubmit} onCancel={handleCancel} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
188
chat/apps/web/src/routes/(protected)/spaces/[id]/+page.svelte
Normal file
188
chat/apps/web/src/routes/(protected)/spaces/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { spaceService } from '$lib/services/space';
|
||||
import { conversationService } from '$lib/services/conversation';
|
||||
import { chatService } from '$lib/services/chat';
|
||||
import type { Space, Conversation, AIModel } from '@chat/types';
|
||||
|
||||
const spaceId = $derived($page.params.id ?? '');
|
||||
|
||||
let space = $state<Space | null>(null);
|
||||
let conversations = $state<Conversation[]>([]);
|
||||
let models = $state<AIModel[]>([]);
|
||||
let selectedModelId = $state('');
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Load space details
|
||||
space = await spaceService.getSpace(spaceId);
|
||||
if (!space) {
|
||||
error = 'Space nicht gefunden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Load conversations in this space
|
||||
if (authStore.user) {
|
||||
conversations = await conversationService.getConversations(authStore.user.id, spaceId);
|
||||
}
|
||||
|
||||
// Load models
|
||||
models = await chatService.getModels();
|
||||
if (models.length > 0) {
|
||||
selectedModelId = models[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewChat() {
|
||||
if (!authStore.user || !selectedModelId) return;
|
||||
|
||||
const conversationId = await conversationService.createConversation(
|
||||
authStore.user.id,
|
||||
selectedModelId,
|
||||
'free',
|
||||
undefined,
|
||||
false,
|
||||
spaceId
|
||||
);
|
||||
|
||||
if (conversationId) {
|
||||
goto(`/chat/${conversationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{space?.name || 'Space'} | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-[calc(100vh-4rem)]">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center h-[calc(100vh-4rem)] text-center p-4">
|
||||
<p class="text-red-500 mb-4">{error}</p>
|
||||
<a href="/spaces" class="text-blue-600 hover:underline">Zurück zu Spaces</a>
|
||||
</div>
|
||||
{:else if space}
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<a
|
||||
href="/spaces"
|
||||
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Zurück zu Spaces"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{space.name}</h1>
|
||||
</div>
|
||||
{#if space.description}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{space.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- New Chat Section -->
|
||||
<div class="mb-8 p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Neuen Chat starten</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
bind:value={selectedModelId}
|
||||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{#each models as model}
|
||||
<option value={model.id}>{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
onclick={handleNewChat}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Chat starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Konversationen in diesem Space
|
||||
</h2>
|
||||
|
||||
{#if conversations.length === 0}
|
||||
<div class="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<svg
|
||||
class="w-12 h-12 text-gray-400 mx-auto mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
Noch keine Konversationen in diesem Space.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each conversations as conv (conv.id)}
|
||||
<a
|
||||
href="/chat/{conv.id}"
|
||||
class="block p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700
|
||||
hover:border-blue-300 dark:hover:border-blue-600 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
{conv.title || 'Neue Konversation'}
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500">{formatDate(conv.updated_at)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
197
chat/apps/web/src/routes/(protected)/templates/+page.svelte
Normal file
197
chat/apps/web/src/routes/(protected)/templates/+page.svelte
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { templatesStore } from '$lib/stores/templates.svelte';
|
||||
import { conversationService } from '$lib/services/conversation';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import TemplateCard from '$lib/components/templates/TemplateCard.svelte';
|
||||
import TemplateForm from '$lib/components/templates/TemplateForm.svelte';
|
||||
import type { Template } from '@chat/types';
|
||||
|
||||
let showForm = $state(false);
|
||||
let editingTemplate = $state<Template | undefined>(undefined);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.user) {
|
||||
await templatesStore.loadTemplates(authStore.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
function handleCreateNew() {
|
||||
editingTemplate = undefined;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function handleEdit(id: string) {
|
||||
const template = templatesStore.templates.find((t) => t.id === id);
|
||||
if (template) {
|
||||
editingTemplate = template;
|
||||
showForm = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (confirm('Möchtest du diese Vorlage wirklich löschen?')) {
|
||||
await templatesStore.deleteTemplate(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetDefault(id: string) {
|
||||
if (authStore.user) {
|
||||
await templatesStore.setDefaultTemplate(id, authStore.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUse(id: string) {
|
||||
const template = templatesStore.templates.find((t) => t.id === id);
|
||||
if (!template || !authStore.user) return;
|
||||
|
||||
// Create a new conversation with this template
|
||||
const conversationId = await conversationService.createConversation(
|
||||
authStore.user.id,
|
||||
template.model_id || '550e8400-e29b-41d4-a716-446655440004', // Default to GPT-4o-Mini
|
||||
'template',
|
||||
template.id,
|
||||
template.document_mode
|
||||
);
|
||||
|
||||
if (conversationId) {
|
||||
await conversationsStore.loadConversations(authStore.user.id);
|
||||
goto(`/chat/${conversationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(data: Partial<Template>) {
|
||||
if (!authStore.user) return;
|
||||
|
||||
if (data.id) {
|
||||
// Update existing template
|
||||
await templatesStore.updateTemplate(data.id, {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
system_prompt: data.system_prompt,
|
||||
initial_question: data.initial_question,
|
||||
color: data.color,
|
||||
model_id: data.model_id,
|
||||
document_mode: data.document_mode,
|
||||
});
|
||||
} else {
|
||||
// Create new template
|
||||
await templatesStore.createTemplate({
|
||||
user_id: authStore.user.id,
|
||||
name: data.name!,
|
||||
description: data.description ?? null,
|
||||
system_prompt: data.system_prompt!,
|
||||
initial_question: data.initial_question ?? null,
|
||||
color: data.color!,
|
||||
model_id: data.model_id ?? null,
|
||||
is_default: false,
|
||||
document_mode: data.document_mode ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
showForm = false;
|
||||
editingTemplate = undefined;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
showForm = false;
|
||||
editingTemplate = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Vorlagen | ManaChat</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div class="max-w-4xl mx-auto px-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Vorlagen</h1>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Erstelle Vorlagen mit benutzerdefinierten System-Prompts für verschiedene KI-Verhaltensweisen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleCreateNew}
|
||||
class="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Vorlage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if templatesStore.isLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="animate-spin w-8 h-8 border-4 border-blue-500 border-r-transparent rounded-full"></div>
|
||||
</div>
|
||||
{:else if templatesStore.templates.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<svg
|
||||
class="w-16 h-16 text-gray-400 mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">Keine Vorlagen vorhanden</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||
Erstelle deine erste Vorlage, um loszulegen
|
||||
</p>
|
||||
<button
|
||||
onclick={handleCreateNew}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
|
||||
hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Erste Vorlage erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Templates Grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each templatesStore.templates as template (template.id)}
|
||||
<TemplateCard
|
||||
{template}
|
||||
onUse={handleUse}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onSetDefault={handleSetDefault}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if templatesStore.error}
|
||||
<div class="mt-4 p-4 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg">
|
||||
{templatesStore.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl shadow-xl">
|
||||
<TemplateForm template={editingTemplate} onSubmit={handleSubmit} onCancel={handleCancel} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
const cleanup = theme.initialize();
|
||||
return cleanup;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
|
|
|
|||
|
|
@ -1,23 +1,27 @@
|
|||
<script lang="ts">
|
||||
// Chat web app - main page
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/chat', { replaceState: true });
|
||||
} else {
|
||||
goto('/login', { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>ManaChat - AI Chat Assistant</title>
|
||||
<meta name="description" content="Chat with AI models - GPT-4o, GPT-4o-Mini, and more" />
|
||||
<title>ManaChat - AI Chat Assistant</title>
|
||||
<meta name="description" content="Chat with AI models - GPT-4o, GPT-4o-Mini, and more" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<div class="text-center">
|
||||
<h1 class="mb-4 text-4xl font-bold text-gray-900 dark:text-white">ManaChat</h1>
|
||||
<p class="mb-8 text-lg text-gray-600 dark:text-gray-300">
|
||||
AI Chat Assistant - Coming Soon
|
||||
</p>
|
||||
<div class="rounded-lg bg-blue-100 p-4 dark:bg-blue-900">
|
||||
<p class="text-blue-800 dark:text-blue-200">
|
||||
The web application is under development.
|
||||
Please use the mobile app in the meantime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin w-10 h-10 border-4 border-blue-500 border-r-transparent rounded-full mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,22 @@ import { defineConfig } from 'vite';
|
|||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
ssr: {
|
||||
noExternal: ['marked']
|
||||
noExternal: [
|
||||
'marked',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui'
|
||||
]
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-theme-ui'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue