mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
refactor: restructure
monorepo with apps/ and services/ directories
This commit is contained in:
parent
25824ed0ac
commit
ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions
122
apps/chat/CLAUDE.md
Normal file
122
apps/chat/CLAUDE.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Chat Project Guide
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/chat/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@chat/backend)
|
||||
│ ├── landing/ # Astro marketing landing page (@chat/landing)
|
||||
│ ├── web/ # SvelteKit web application (@chat/web)
|
||||
│ └── mobile/ # Expo/React Native mobile app (@chat/mobile)
|
||||
├── packages/
|
||||
│ └── chat-types/ # Shared TypeScript types (@chat/types)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level
|
||||
```bash
|
||||
pnpm chat:dev # Run all chat apps
|
||||
pnpm dev:chat:mobile # Start mobile app
|
||||
pnpm dev:chat:web # Start web app
|
||||
pnpm dev:chat:landing # Start landing page
|
||||
pnpm dev:chat:backend # Start backend server
|
||||
```
|
||||
|
||||
### Mobile App (chat/apps/mobile)
|
||||
```bash
|
||||
pnpm dev # Start Expo dev server
|
||||
pnpm ios # Run on iOS simulator
|
||||
pnpm android # Run on Android emulator
|
||||
pnpm build:dev # Build development version
|
||||
pnpm build:preview # Build preview version
|
||||
pnpm build:prod # Build production version
|
||||
```
|
||||
|
||||
### Backend (apps/chat/apps/backend)
|
||||
```bash
|
||||
pnpm start:dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
```
|
||||
|
||||
### Web App (chat/apps/web)
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Landing Page (chat/apps/landing)
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Mobile**: React Native 0.76.7 + Expo SDK 52, NativeWind, Expo Router
|
||||
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
|
||||
- **Landing**: Astro 5.16, Tailwind CSS
|
||||
- **Backend**: NestJS 10, Azure OpenAI, Supabase
|
||||
- **Types**: TypeScript 5.x
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Health check |
|
||||
| `/api/chat/models` | GET | List available AI models |
|
||||
| `/api/chat/completions` | POST | Create chat completion |
|
||||
| `/api/conversations` | GET | List user conversations |
|
||||
| `/api/conversations/:id` | GET | Get conversation details |
|
||||
| `/api/conversations/:id/messages` | GET | Get conversation messages |
|
||||
| `/api/conversations` | POST | Create new conversation |
|
||||
| `/api/conversations/:id/messages` | POST | Add message to conversation |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Backend (.env)
|
||||
```
|
||||
AZURE_OPENAI_ENDPOINT=https://...
|
||||
AZURE_OPENAI_API_KEY=...
|
||||
AZURE_OPENAI_API_VERSION=2024-12-01-preview
|
||||
SUPABASE_URL=https://...
|
||||
SUPABASE_SERVICE_KEY=...
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
#### Mobile (.env)
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://...
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
- **TypeScript**: Strict typing with interfaces
|
||||
- **Mobile**: Functional components with hooks
|
||||
- **Web**: Svelte 5 runes mode
|
||||
- **Styling**: Tailwind CSS everywhere
|
||||
- **Formatting**: 100 char line limit, 2 space tabs, single quotes
|
||||
|
||||
## AI Models Available
|
||||
|
||||
| Model ID | Name | Description |
|
||||
|----------|------|-------------|
|
||||
| 550e8400-e29b-41d4-a716-446655440000 | GPT-O3-Mini | Fast, efficient responses |
|
||||
| 550e8400-e29b-41d4-a716-446655440004 | GPT-4o-Mini | Compact, powerful |
|
||||
| 550e8400-e29b-41d4-a716-446655440005 | GPT-4o | Most advanced |
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Security**: API keys are stored in the backend only - never in client apps
|
||||
2. **Authentication**: Uses Supabase Auth, shared with Mana Core ecosystem
|
||||
3. **Database**: Supabase PostgreSQL with RLS policies
|
||||
4. **Deployment**: Backend runs on port 3001 by default
|
||||
395
apps/chat/INTEGRATION_COMPLETE.md
Normal file
395
apps/chat/INTEGRATION_COMPLETE.md
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
# ✅ Mana Core Auth Integration - COMPLETE
|
||||
|
||||
**Date:** 2025-11-25
|
||||
**Status:** 🎉 All code changes implemented
|
||||
**Project:** Chat (Backend, Web, Mobile)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Summary
|
||||
|
||||
The Chat project has been **fully migrated** from Supabase Auth to **Mana Core Auth**! All three apps (backend, web, mobile) now use the centralized authentication system with built-in credit management.
|
||||
|
||||
---
|
||||
|
||||
## ✅ What Was Done
|
||||
|
||||
### 1. **Updated `@manacore/shared-auth` Package** ✅
|
||||
|
||||
**Location:** `/packages/shared-auth/src/core/authService.ts`
|
||||
|
||||
**Changes:**
|
||||
- Updated API endpoints to match Mana Core Auth (`/api/v1/auth/*`)
|
||||
- Fixed login response handling (`accessToken` instead of `appToken`)
|
||||
- Fixed signup flow (register then login separately)
|
||||
- Updated refresh token endpoint
|
||||
- Updated credits balance endpoint
|
||||
|
||||
**Status:** Package is now 100% compatible with Mana Core Auth API
|
||||
|
||||
---
|
||||
|
||||
### 2. **Chat Backend Integration** ✅
|
||||
|
||||
**Files Modified:**
|
||||
- ✅ `chat/backend/src/common/guards/jwt-auth.guard.ts` (NEW)
|
||||
- ✅ `chat/backend/src/common/decorators/current-user.decorator.ts` (NEW)
|
||||
- ✅ `chat/backend/src/chat/chat.controller.ts`
|
||||
- ✅ `chat/backend/src/chat/chat.service.ts`
|
||||
- ✅ `chat/backend/src/conversation/conversation.controller.ts`
|
||||
- ✅ `chat/backend/.env.example`
|
||||
|
||||
**Changes:**
|
||||
- Created JWT Auth Guard that validates tokens with Mana Core Auth
|
||||
- Created CurrentUser decorator to inject user data into controllers
|
||||
- Updated all controllers to use JwtAuthGuard
|
||||
- Removed userId from request body (now extracted from JWT)
|
||||
- Added MANA_CORE_AUTH_URL environment variable
|
||||
- Changed PORT from 3001 to 3002 (to avoid conflict with auth service)
|
||||
|
||||
**Key Features:**
|
||||
- All endpoints now protected with JWT validation
|
||||
- User context automatically injected via @CurrentUser decorator
|
||||
- Token validation happens via Mana Core Auth API
|
||||
- Proper error handling for invalid/expired tokens
|
||||
|
||||
---
|
||||
|
||||
### 3. **Chat Web App Integration** ✅
|
||||
|
||||
**Files Modified:**
|
||||
- ✅ `chat/apps/web/src/lib/stores/auth.svelte.ts`
|
||||
- ✅ `chat/apps/web/.env.example`
|
||||
|
||||
**Changes:**
|
||||
- Completely rewrote auth store to use `@manacore/shared-auth`
|
||||
- Removed Supabase auth dependencies
|
||||
- Added `initializeWebAuth()` initialization
|
||||
- Added `getCredits()` method for credit balance
|
||||
- Added `getAccessToken()` method for API calls
|
||||
- Added MANA_CORE_AUTH_URL environment variable
|
||||
|
||||
**API Compatibility:**
|
||||
- Same method signatures as before (signIn, signUp, signOut, resetPassword)
|
||||
- Minimal breaking changes for existing code
|
||||
- Additional methods: `getCredits()`, `getAccessToken()`
|
||||
|
||||
---
|
||||
|
||||
### 4. **Chat Mobile App Integration** ✅
|
||||
|
||||
**Files Modified:**
|
||||
- ✅ `chat/apps/mobile/context/AuthProvider.tsx`
|
||||
- ✅ `chat/apps/mobile/.env.example`
|
||||
|
||||
**Changes:**
|
||||
- Rewrote AuthProvider to use `@manacore/shared-auth`
|
||||
- Created SecureStore adapter for token storage
|
||||
- Created React Native device adapter
|
||||
- Created React Native network adapter
|
||||
- Removed Supabase auth dependencies
|
||||
- Added MANA_CORE_AUTH_URL environment variable
|
||||
|
||||
**Key Features:**
|
||||
- Tokens stored securely in Expo SecureStore
|
||||
- Device ID generated and persisted
|
||||
- Same API as before (useAuth hook remains unchanged)
|
||||
- Auto sign-in after successful signup
|
||||
|
||||
---
|
||||
|
||||
## 📝 Configuration Changes
|
||||
|
||||
### Backend `.env`
|
||||
|
||||
```env
|
||||
# OLD (Remove):
|
||||
# SUPABASE_URL=...
|
||||
# SUPABASE_SERVICE_KEY=...
|
||||
# PORT=3001
|
||||
|
||||
# NEW (Add):
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
PORT=3002
|
||||
|
||||
# Keep (for database):
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_KEY=your-service-key-here
|
||||
```
|
||||
|
||||
### Web App `.env`
|
||||
|
||||
```env
|
||||
# OLD (Remove):
|
||||
# PUBLIC_SUPABASE_URL=...
|
||||
# PUBLIC_SUPABASE_ANON_KEY=...
|
||||
# PUBLIC_BACKEND_URL=http://localhost:3001
|
||||
|
||||
# NEW (Add):
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
|
||||
# Keep (for database):
|
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
```
|
||||
|
||||
### Mobile App `.env`
|
||||
|
||||
```env
|
||||
# OLD (Remove):
|
||||
# EXPO_PUBLIC_SUPABASE_URL=...
|
||||
# EXPO_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
# EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
|
||||
|
||||
# NEW (Add):
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
|
||||
# Keep (for database):
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How to Run
|
||||
|
||||
### 1. Start Mana Core Auth (Terminal 1)
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
cp .env.example .env
|
||||
# Edit .env and add JWT keys (see mana-core-auth/QUICKSTART.md)
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
Service runs on: `http://localhost:3001`
|
||||
|
||||
### 2. Start Chat Backend (Terminal 2)
|
||||
|
||||
```bash
|
||||
cd chat/backend
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# - Add MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
# - Change PORT=3002
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
Service runs on: `http://localhost:3002`
|
||||
|
||||
### 3. Start Web App (Terminal 3)
|
||||
|
||||
```bash
|
||||
cd chat/apps/web
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# - Add PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
# - Change PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
App runs on: `http://localhost:5173`
|
||||
|
||||
### 4. Start Mobile App (Terminal 4)
|
||||
|
||||
```bash
|
||||
cd chat/apps/mobile
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# - Add EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
# - Change EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Start backend on port 3002
|
||||
- [ ] Try accessing `/api/chat/models` without token → Should return 401
|
||||
- [ ] Login via Mana Core Auth
|
||||
- [ ] Access `/api/chat/models` with token → Should work
|
||||
- [ ] Access `/api/conversations` with token → Should work
|
||||
|
||||
### Web App
|
||||
|
||||
- [ ] Go to `/login`
|
||||
- [ ] Register new user
|
||||
- [ ] Should redirect and auto-login
|
||||
- [ ] Check user is authenticated
|
||||
- [ ] Try protected routes
|
||||
- [ ] Logout
|
||||
- [ ] Try protected routes again → Should redirect to login
|
||||
|
||||
### Mobile App
|
||||
|
||||
- [ ] Open app
|
||||
- [ ] Register new user
|
||||
- [ ] Should auto-login
|
||||
- [ ] Check chat functionality works
|
||||
- [ ] Logout
|
||||
- [ ] Login again with same credentials
|
||||
|
||||
---
|
||||
|
||||
## 💡 New Features Available
|
||||
|
||||
### Credit System (Built-in)
|
||||
|
||||
All users now have access to the credit system:
|
||||
|
||||
```typescript
|
||||
// Web App
|
||||
const credits = await authStore.getCredits();
|
||||
console.log(credits); // { credits: 150, maxCreditLimit: 1000, userId: "..." }
|
||||
|
||||
// Mobile App (need to add this method to AuthProvider if needed)
|
||||
const credits = await authService.getUserCredits();
|
||||
```
|
||||
|
||||
**Default Credits:**
|
||||
- Signup bonus: 150 free credits
|
||||
- Daily free credits: 5 credits every 24 hours
|
||||
- Pricing: 100 mana = €1.00
|
||||
|
||||
---
|
||||
|
||||
## 🔄 What Changed for Users
|
||||
|
||||
| Aspect | Before (Supabase) | After (Mana Core) | Impact |
|
||||
|--------|-------------------|-------------------|---------|
|
||||
| **Registration** | Immediate session | Register → Login | Minimal (auto-login in mobile) |
|
||||
| **Login** | Supabase JWT | Mana Core JWT | None (transparent) |
|
||||
| **Token Storage** | Supabase cookies | localStorage/SecureStore | None (same security) |
|
||||
| **Sessions** | Supabase sessions | JWT + refresh tokens | Better (token rotation) |
|
||||
| **Credits** | ❌ None | ✅ 150 initial + 5 daily | **NEW FEATURE!** |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Port Configuration
|
||||
|
||||
| Service | Port | URL |
|
||||
|---------|------|-----|
|
||||
| **Mana Core Auth** | 3001 | http://localhost:3001 |
|
||||
| **Chat Backend** | 3002 | http://localhost:3002 |
|
||||
| **Web App** | 5173 | http://localhost:5173 |
|
||||
| **Mobile App** | 8081 | exp://localhost:8081 |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Potential Issues & Solutions
|
||||
|
||||
### Issue: "Connection refused" to Mana Core Auth
|
||||
|
||||
**Solution:** Make sure Mana Core Auth is running on port 3001
|
||||
```bash
|
||||
cd mana-core-auth && pnpm start:dev
|
||||
```
|
||||
|
||||
### Issue: "Invalid token" errors
|
||||
|
||||
**Solution:** Clear stored tokens and login again
|
||||
```typescript
|
||||
// Web: Clear localStorage
|
||||
localStorage.clear();
|
||||
|
||||
// Mobile: Uninstall and reinstall app, or clear SecureStore
|
||||
await SecureStore.deleteItemAsync('@auth/appToken');
|
||||
await SecureStore.deleteItemAsync('@auth/refreshToken');
|
||||
```
|
||||
|
||||
### Issue: CORS errors from web app
|
||||
|
||||
**Solution:** Add web app URL to Mana Core Auth CORS config
|
||||
```env
|
||||
# In mana-core-auth/.env
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
|
||||
```
|
||||
|
||||
### Issue: Backend can't validate tokens
|
||||
|
||||
**Solution:** Check MANA_CORE_AUTH_URL in backend .env
|
||||
```env
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API Endpoint Reference
|
||||
|
||||
### Mana Core Auth (Port 3001)
|
||||
|
||||
- POST `/api/v1/auth/register` - Register new user
|
||||
- POST `/api/v1/auth/login` - Login with email/password
|
||||
- POST `/api/v1/auth/refresh` - Refresh access token
|
||||
- POST `/api/v1/auth/logout` - Logout and revoke session
|
||||
- POST `/api/v1/auth/validate` - Validate JWT token
|
||||
- GET `/api/v1/credits/balance` - Get credit balance
|
||||
|
||||
### Chat Backend (Port 3002)
|
||||
|
||||
- GET `/api/chat/models` - List AI models (protected)
|
||||
- POST `/api/chat/completions` - Create chat completion (protected)
|
||||
- GET `/api/conversations` - List conversations (protected)
|
||||
- POST `/api/conversations` - Create conversation (protected)
|
||||
- GET `/api/conversations/:id` - Get conversation (protected)
|
||||
- GET `/api/conversations/:id/messages` - Get messages (protected)
|
||||
- POST `/api/conversations/:id/messages` - Add message (protected)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Next Steps (Optional Enhancements)
|
||||
|
||||
1. **Add Credit Usage Tracking**
|
||||
- Deduct credits when using AI models
|
||||
- Show remaining credits in UI
|
||||
|
||||
2. **Add OAuth Providers**
|
||||
- Google Sign-In
|
||||
- Apple Sign-In
|
||||
|
||||
3. **Add Email Verification**
|
||||
- Send verification emails on signup
|
||||
- Verify email before allowing login
|
||||
|
||||
4. **Add Password Reset**
|
||||
- Implement forgot password flow
|
||||
- Send reset emails
|
||||
|
||||
5. **Add 2FA**
|
||||
- Enable two-factor authentication
|
||||
- Support TOTP apps
|
||||
|
||||
6. **Add Session Management**
|
||||
- Show active sessions
|
||||
- Revoke specific sessions
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- **Integration Guide:** `/chat/MANA_CORE_AUTH_INTEGRATION.md`
|
||||
- **Mana Core Auth README:** `/mana-core-auth/README.md`
|
||||
- **Quick Start:** `/mana-core-auth/QUICKSTART.md`
|
||||
- **Master Plan:** `/.hive-mind/MASTER_PLAN_CENTRAL_AUTH_SYSTEM.md`
|
||||
|
||||
---
|
||||
|
||||
## ✨ Benefits of Migration
|
||||
|
||||
1. **✅ Centralized Authentication** - Single auth system for all Mana Core apps
|
||||
2. **✅ Built-in Credits** - No need to build separate credit system
|
||||
3. **✅ Better Security** - RS256 JWT, refresh token rotation, optimistic locking
|
||||
4. **✅ Cost Savings** - Self-hosted, no per-user charges
|
||||
5. **✅ Full Control** - Complete ownership of user data
|
||||
6. **✅ Consistent API** - Same auth flow across all apps
|
||||
|
||||
---
|
||||
|
||||
**Status:** 🎉 **INTEGRATION COMPLETE - READY FOR TESTING!**
|
||||
|
||||
All code changes are done. Follow the "How to Run" section above to test the integration.
|
||||
544
apps/chat/MANA_CORE_AUTH_INTEGRATION.md
Normal file
544
apps/chat/MANA_CORE_AUTH_INTEGRATION.md
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
# Mana Core Auth Integration Guide - Chat Project
|
||||
|
||||
This guide explains how to integrate the Chat project with the new **Mana Core Auth** system, replacing Supabase Auth.
|
||||
|
||||
## Overview
|
||||
|
||||
The Chat project currently uses **Supabase Auth** across all apps. We're migrating to **Mana Core Auth**, our centralized authentication system with built-in credit management.
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ **Unified Authentication** - Single auth system for all Mana Core apps
|
||||
- ✅ **Built-in Credits** - Automatic credit balance management (150 signup bonus + 5 daily)
|
||||
- ✅ **Better Security** - RS256 JWT, refresh token rotation, optimistic locking
|
||||
- ✅ **Cost Savings** - Self-hosted, no per-user charges
|
||||
- ✅ **Full Control** - Complete ownership of user data and auth flow
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Chat Apps (Web, Mobile, Landing)
|
||||
↓
|
||||
@manacore/shared-auth (Client Library)
|
||||
↓
|
||||
Mana Core Auth Service (NestJS)
|
||||
↓
|
||||
PostgreSQL (Users, Sessions, Credits)
|
||||
```
|
||||
|
||||
## What Changed
|
||||
|
||||
### 1. Shared Auth Package Updated ✅
|
||||
|
||||
The `@manacore/shared-auth` package has been updated to work with Mana Core Auth endpoints:
|
||||
|
||||
**Updated endpoints:**
|
||||
- `POST /api/v1/auth/register` - User registration
|
||||
- `POST /api/v1/auth/login` - Email/password login
|
||||
- `POST /api/v1/auth/refresh` - Token refresh
|
||||
- `POST /api/v1/auth/logout` - Logout
|
||||
- `GET /api/v1/credits/balance` - Get credit balance
|
||||
|
||||
**Response format changes:**
|
||||
- Login returns: `{ accessToken, refreshToken, user, expiresIn, tokenType }`
|
||||
- Credits balance returns: `{ balance, freeCreditsRemaining, totalEarned, totalSpent }`
|
||||
|
||||
## Step-by-Step Integration
|
||||
|
||||
### Step 1: Update Environment Variables
|
||||
|
||||
#### Backend `.env`
|
||||
|
||||
```env
|
||||
# Remove Supabase variables
|
||||
# SUPABASE_URL=...
|
||||
# SUPABASE_SERVICE_KEY=...
|
||||
|
||||
# Add Mana Core Auth URL
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
#### Web App `.env`
|
||||
|
||||
```env
|
||||
# Remove
|
||||
# PUBLIC_SUPABASE_URL=...
|
||||
# PUBLIC_SUPABASE_ANON_KEY=...
|
||||
|
||||
# Add
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
#### Mobile App `.env`
|
||||
|
||||
```env
|
||||
# Remove
|
||||
# EXPO_PUBLIC_SUPABASE_URL=...
|
||||
# EXPO_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
|
||||
# Add
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
### Step 2: Update Backend (NestJS)
|
||||
|
||||
#### 2.1 Install Dependencies
|
||||
|
||||
```bash
|
||||
cd chat/backend
|
||||
pnpm add jsonwebtoken
|
||||
pnpm add -D @types/jsonwebtoken
|
||||
```
|
||||
|
||||
#### 2.2 Create JWT Auth Guard
|
||||
|
||||
Create `chat/backend/src/common/guards/jwt-auth.guard.ts`:
|
||||
|
||||
```typescript
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get public key from Mana Core Auth
|
||||
const authUrl = this.configService.get<string>('MANA_CORE_AUTH_URL');
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const { valid, payload } = await response.json();
|
||||
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Update Controllers
|
||||
|
||||
Replace Supabase guards with JWT Auth guard:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { SupabaseGuard } from './guards/supabase.guard';
|
||||
|
||||
@UseGuards(SupabaseGuard)
|
||||
|
||||
// After
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
```
|
||||
|
||||
### Step 3: Update Web App (SvelteKit)
|
||||
|
||||
#### 3.1 Update Auth Store
|
||||
|
||||
Edit `chat/apps/web/src/lib/stores/auth.svelte.ts`:
|
||||
|
||||
```typescript
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
|
||||
const MANA_AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
// Initialize Mana Core Auth
|
||||
const { authService, tokenManager } = initializeWebAuth({
|
||||
baseUrl: MANA_AUTH_URL,
|
||||
});
|
||||
|
||||
class AuthStore {
|
||||
user = $state<UserData | null>(null);
|
||||
isLoading = $state(true);
|
||||
|
||||
async initialize() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
this.user = userData;
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const result = await authService.signIn(email, password);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
this.user = userData;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async signUp(email: string, password: string) {
|
||||
const result = await authService.signUp(email, password);
|
||||
// After signup, automatically sign in
|
||||
if (result.success) {
|
||||
return this.signIn(email, password);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await authService.signOut();
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
async resetPassword(email: string) {
|
||||
return authService.forgotPassword(email);
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = new AuthStore();
|
||||
```
|
||||
|
||||
#### 3.2 Update Server Hooks
|
||||
|
||||
Edit `chat/apps/web/src/hooks.server.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Get token from cookies
|
||||
const token = event.cookies.get('auth_token');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Validate token with Mana Core Auth
|
||||
const authUrl = process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { valid, payload } = await response.json();
|
||||
if (valid) {
|
||||
event.locals.user = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error validating token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: Update Mobile App (Expo)
|
||||
|
||||
#### 4.1 Update AuthProvider
|
||||
|
||||
Edit `chat/apps/mobile/context/AuthProvider.tsx`:
|
||||
|
||||
```typescript
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import {
|
||||
createAuthService,
|
||||
createTokenManager,
|
||||
setStorageAdapter,
|
||||
setDeviceAdapter,
|
||||
setNetworkAdapter,
|
||||
type UserData,
|
||||
} from '@manacore/shared-auth';
|
||||
import { createSecureStoreAdapter } from '@manacore/shared-auth/native'; // You may need to create this
|
||||
|
||||
const MANA_AUTH_URL = process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
// Initialize auth service
|
||||
const authService = createAuthService({ baseUrl: MANA_AUTH_URL });
|
||||
const tokenManager = createTokenManager(authService);
|
||||
|
||||
type AuthContextType = {
|
||||
user: UserData | null;
|
||||
isLoading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signUp: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
resetPassword: (email: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<UserData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
initialize();
|
||||
}, []);
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
setUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function signIn(email: string, password: string) {
|
||||
const result = await authService.signIn(email, password);
|
||||
if (result.success) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
setUser(userData);
|
||||
} else {
|
||||
throw new Error(result.error || 'Sign in failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function signUp(email: string, password: string) {
|
||||
const result = await authService.signUp(email, password);
|
||||
if (result.success) {
|
||||
// Auto sign in after signup
|
||||
await signIn(email, password);
|
||||
} else {
|
||||
throw new Error(result.error || 'Sign up failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await authService.signOut();
|
||||
setUser(null);
|
||||
}
|
||||
|
||||
async function resetPassword(email: string) {
|
||||
const result = await authService.forgotPassword(email);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Password reset failed');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, isLoading, signIn, signUp, signOut, resetPassword }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Remove Supabase Dependencies
|
||||
|
||||
#### 5.1 Web App
|
||||
|
||||
```bash
|
||||
cd chat/apps/web
|
||||
pnpm remove @supabase/ssr @supabase/supabase-js
|
||||
```
|
||||
|
||||
Delete or update these files:
|
||||
- `src/lib/services/supabase.ts` (no longer needed)
|
||||
|
||||
#### 5.2 Mobile App
|
||||
|
||||
```bash
|
||||
cd chat/apps/mobile
|
||||
pnpm remove @supabase/supabase-js
|
||||
```
|
||||
|
||||
Delete or update these files:
|
||||
- `utils/supabase.ts` (no longer needed)
|
||||
|
||||
#### 5.3 Backend
|
||||
|
||||
```bash
|
||||
cd chat/backend
|
||||
pnpm remove @supabase/supabase-js
|
||||
```
|
||||
|
||||
### Step 6: Test the Integration
|
||||
|
||||
#### 6.1 Start Mana Core Auth
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
cd mana-core-auth
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
Service should be running at `http://localhost:3001`
|
||||
|
||||
#### 6.2 Start Chat Backend
|
||||
|
||||
```bash
|
||||
cd chat/backend
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
#### 6.3 Start Web App
|
||||
|
||||
```bash
|
||||
cd chat/apps/web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### 6.4 Test Flow
|
||||
|
||||
1. **Register a new user**
|
||||
- Go to `/register`
|
||||
- Enter email and password
|
||||
- Should redirect to login
|
||||
|
||||
2. **Login**
|
||||
- Enter credentials
|
||||
- Should receive tokens and redirect to app
|
||||
|
||||
3. **Check credits**
|
||||
- User should have 150 initial credits
|
||||
- Call `authService.getUserCredits()` to verify
|
||||
|
||||
4. **Protected routes**
|
||||
- Try accessing `/chat` or other protected routes
|
||||
- Should work with valid token
|
||||
|
||||
5. **Logout**
|
||||
- Click logout
|
||||
- Tokens should be cleared
|
||||
- Should redirect to login
|
||||
|
||||
## API Compatibility
|
||||
|
||||
### Mana Core Auth vs Supabase
|
||||
|
||||
| Feature | Supabase Auth | Mana Core Auth | Status |
|
||||
|---------|---------------|----------------|--------|
|
||||
| Email/Password | ✅ | ✅ | Migrated |
|
||||
| OAuth (Google) | ✅ | 🚧 | TODO |
|
||||
| OAuth (Apple) | ✅ | 🚧 | TODO |
|
||||
| Password Reset | ✅ | 🚧 | TODO |
|
||||
| Email Verification | ✅ | 🚧 | TODO |
|
||||
| Credits | ❌ | ✅ | New! |
|
||||
| Session Management | ✅ | ✅ | Migrated |
|
||||
| JWT Tokens | ✅ | ✅ | Migrated |
|
||||
|
||||
## Credits System
|
||||
|
||||
Mana Core Auth includes a built-in credit system:
|
||||
|
||||
```typescript
|
||||
// Get credit balance
|
||||
const credits = await authService.getUserCredits();
|
||||
console.log(credits);
|
||||
// {
|
||||
// credits: 150, // balance + freeCreditsRemaining
|
||||
// maxCreditLimit: 1000,
|
||||
// userId: "user-id"
|
||||
// }
|
||||
```
|
||||
|
||||
### Default Credit Allocation
|
||||
|
||||
- **Signup Bonus:** 150 free credits
|
||||
- **Daily Free:** 5 credits every 24 hours
|
||||
- **Pricing:** 100 mana = €1.00
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection refused" to Mana Core Auth
|
||||
|
||||
**Solution:** Make sure Mana Core Auth is running:
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### "Invalid token" errors
|
||||
|
||||
**Solution:** Clear stored tokens and login again:
|
||||
```typescript
|
||||
await authService.clearAuthStorage();
|
||||
```
|
||||
|
||||
### CORS errors
|
||||
|
||||
**Solution:** Add Chat app URLs to Mana Core Auth `.env`:
|
||||
```env
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:8081
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Update `@manacore/shared-auth` package
|
||||
2. ⏳ Integrate into Chat backend
|
||||
3. ⏳ Update Chat web app
|
||||
4. ⏳ Update Chat mobile app
|
||||
5. ⏳ Test end-to-end
|
||||
6. 🔜 Add OAuth providers
|
||||
7. 🔜 Add email verification
|
||||
8. 🔜 Add password reset
|
||||
|
||||
## Resources
|
||||
|
||||
- **Mana Core Auth README:** `/mana-core-auth/README.md`
|
||||
- **Shared Auth Package:** `/packages/shared-auth/`
|
||||
- **API Documentation:** `/mana-core-auth/README.md#api-endpoints`
|
||||
- **Quick Start:** `/mana-core-auth/QUICKSTART.md`
|
||||
|
||||
---
|
||||
|
||||
**Status:** 🚧 Integration Guide Complete - Implementation Pending
|
||||
|
||||
**Date:** 2025-11-25
|
||||
681
apps/chat/TESTING_GUIDE.md
Normal file
681
apps/chat/TESTING_GUIDE.md
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
# Testing Guide - Mana Core Auth Integration
|
||||
|
||||
This guide walks you through testing the Chat project with Mana Core Auth.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before testing, make sure you have:
|
||||
- ✅ Node.js 20+
|
||||
- ✅ pnpm installed
|
||||
- ✅ All dependencies installed (`pnpm install` from monorepo root)
|
||||
- ✅ PostgreSQL running (or Docker for Mana Core Auth)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Generate JWT Keys for Mana Core Auth
|
||||
|
||||
Mana Core Auth requires RS256 JWT keys. Generate them first:
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
chmod +x scripts/generate-keys.sh
|
||||
./scripts/generate-keys.sh
|
||||
```
|
||||
|
||||
**You'll see output like:**
|
||||
```
|
||||
Generating RS256 key pair...
|
||||
Keys generated successfully!
|
||||
|
||||
Private key: private.pem
|
||||
Public key: public.pem
|
||||
|
||||
Add these to your .env file:
|
||||
|
||||
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKC...
|
||||
-----END RSA PRIVATE KEY-----"
|
||||
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBg...
|
||||
-----END PUBLIC KEY-----"
|
||||
```
|
||||
|
||||
**Copy these keys - you'll need them in the next step!**
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Configure Environment Variables
|
||||
|
||||
### 2.1 Mana Core Auth
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `mana-core-auth/.env` and add:
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=postgresql://manacore:password@localhost:5432/manacore
|
||||
|
||||
# Paste the keys from Step 1
|
||||
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||
YOUR_PRIVATE_KEY_HERE
|
||||
-----END RSA PRIVATE KEY-----"
|
||||
|
||||
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
|
||||
YOUR_PUBLIC_KEY_HERE
|
||||
-----END PUBLIC KEY-----"
|
||||
|
||||
# Other settings (use defaults for now)
|
||||
REDIS_PASSWORD=
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
### 2.2 Chat Backend
|
||||
|
||||
```bash
|
||||
cd ../chat/backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `chat/backend/.env`:
|
||||
|
||||
```env
|
||||
# Azure OpenAI (your existing keys)
|
||||
AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
|
||||
AZURE_OPENAI_API_KEY=your-api-key
|
||||
AZURE_OPENAI_API_VERSION=2024-12-01-preview
|
||||
|
||||
# Mana Core Auth (NEW)
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Supabase (for database, not auth)
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_SERVICE_KEY=your-service-key
|
||||
|
||||
# Server
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
### 2.3 Chat Web App
|
||||
|
||||
```bash
|
||||
cd ../apps/web
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `chat/apps/web/.env`:
|
||||
|
||||
```env
|
||||
# Mana Core Auth (NEW)
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Backend API (NEW PORT)
|
||||
PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
|
||||
# Supabase (for database)
|
||||
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
### 2.4 Chat Mobile App
|
||||
|
||||
```bash
|
||||
cd ../mobile
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `chat/apps/mobile/.env`:
|
||||
|
||||
```env
|
||||
# Mana Core Auth (NEW)
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Backend API (NEW PORT)
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
|
||||
# Supabase (for database)
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Start Services (4 Terminals)
|
||||
|
||||
### Terminal 1: Mana Core Auth
|
||||
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
|
||||
# Start PostgreSQL (if using Docker)
|
||||
docker-compose up postgres -d
|
||||
|
||||
# Run migrations
|
||||
pnpm migration:run
|
||||
|
||||
# Start auth service
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
🚀 Mana Core Auth running on: http://localhost:3001
|
||||
📚 Environment: development
|
||||
```
|
||||
|
||||
**Leave this running!**
|
||||
|
||||
### Terminal 2: Chat Backend
|
||||
|
||||
```bash
|
||||
cd chat/backend
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
[Nest] LOG [NestApplication] Nest application successfully started
|
||||
[Nest] LOG Listening on port 3002
|
||||
```
|
||||
|
||||
**Leave this running!**
|
||||
|
||||
### Terminal 3: Chat Web App
|
||||
|
||||
```bash
|
||||
cd chat/apps/web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
VITE v5.x.x ready in xxx ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
```
|
||||
|
||||
**Leave this running!**
|
||||
|
||||
### Terminal 4: Chat Mobile App (Optional)
|
||||
|
||||
```bash
|
||||
cd chat/apps/mobile
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
› Metro waiting on exp://localhost:8081
|
||||
› Scan the QR code above with Expo Go (Android) or Camera (iOS)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Test Web App Authentication
|
||||
|
||||
### 4.1 Open Web App
|
||||
|
||||
Open browser: http://localhost:5173
|
||||
|
||||
### 4.2 Register New User
|
||||
|
||||
1. Click **"Register"** or go to `/register`
|
||||
2. Enter test credentials:
|
||||
- Email: `test@example.com`
|
||||
- Password: `Test1234!`
|
||||
3. Click **"Register"**
|
||||
|
||||
**Expected:**
|
||||
- Registration succeeds
|
||||
- Automatically redirects to login
|
||||
- Login happens automatically
|
||||
- You're redirected to the main app
|
||||
|
||||
### 4.3 Check Authentication
|
||||
|
||||
Open browser console (F12) and run:
|
||||
|
||||
```javascript
|
||||
// Check if user is authenticated
|
||||
console.log('Authenticated:', window.localStorage.getItem('@auth/appToken'));
|
||||
```
|
||||
|
||||
**Expected:** You should see a JWT token
|
||||
|
||||
### 4.4 Check Credits
|
||||
|
||||
In browser console:
|
||||
|
||||
```javascript
|
||||
// Get credit balance
|
||||
const authStore = window.authStore; // If exported globally
|
||||
// Or navigate to a page that displays credits
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
- 150 initial credits
|
||||
- API call to `/api/v1/credits/balance` succeeds
|
||||
|
||||
### 4.5 Test Logout
|
||||
|
||||
1. Click **"Logout"** button
|
||||
2. Check you're redirected to login page
|
||||
3. Try accessing protected route → Should redirect to login
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Test Backend API
|
||||
|
||||
### 5.1 Get Access Token
|
||||
|
||||
First, login via web app, then get the token from localStorage:
|
||||
|
||||
**In browser console:**
|
||||
```javascript
|
||||
const token = localStorage.getItem('@auth/appToken');
|
||||
console.log(token);
|
||||
// Copy this token!
|
||||
```
|
||||
|
||||
### 5.2 Test Protected Endpoints
|
||||
|
||||
Use `curl` or Postman/Insomnia:
|
||||
|
||||
#### Test 1: Get AI Models (Protected)
|
||||
|
||||
```bash
|
||||
# Without token - Should fail with 401
|
||||
curl http://localhost:3002/api/chat/models
|
||||
|
||||
# With token - Should succeed
|
||||
curl http://localhost:3002/api/chat/models \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
```
|
||||
|
||||
**Expected Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "GPT-O3-Mini",
|
||||
"description": "Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.",
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
#### Test 2: List Conversations (Protected)
|
||||
|
||||
```bash
|
||||
curl http://localhost:3002/api/conversations \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
```
|
||||
|
||||
**Expected:** Array of conversations (may be empty for new user)
|
||||
|
||||
#### Test 3: Create Conversation (Protected)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3002/api/conversations \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"modelId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"title": "Test Conversation"
|
||||
}'
|
||||
```
|
||||
|
||||
**Expected:** New conversation object
|
||||
|
||||
#### Test 4: Chat Completion (Protected)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3002/api/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"modelId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Say hello!"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**Expected:** AI response with content and usage stats
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Test Mobile App (Optional)
|
||||
|
||||
### 6.1 Install Expo Go
|
||||
|
||||
- **iOS:** Install from App Store
|
||||
- **Android:** Install from Google Play Store
|
||||
|
||||
### 6.2 Scan QR Code
|
||||
|
||||
1. Look at Terminal 4 (mobile app terminal)
|
||||
2. Scan the QR code with:
|
||||
- iOS: Camera app
|
||||
- Android: Expo Go app
|
||||
|
||||
### 6.3 Register/Login
|
||||
|
||||
1. App opens to login screen
|
||||
2. Tap **"Register"**
|
||||
3. Enter credentials:
|
||||
- Email: `mobile@example.com`
|
||||
- Password: `Mobile1234!`
|
||||
4. Tap **"Register"**
|
||||
|
||||
**Expected:**
|
||||
- Registration succeeds
|
||||
- Auto-login
|
||||
- Redirected to chat interface
|
||||
|
||||
### 6.4 Test Chat
|
||||
|
||||
1. Try sending a message
|
||||
2. Should get AI response
|
||||
3. Check conversation is saved
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Test Token Validation
|
||||
|
||||
### 7.1 Test Invalid Token
|
||||
|
||||
```bash
|
||||
curl http://localhost:3002/api/chat/models \
|
||||
-H "Authorization: Bearer invalid-token-here"
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
```json
|
||||
{
|
||||
"statusCode": 401,
|
||||
"message": "Invalid token"
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Test Expired Token
|
||||
|
||||
After 15 minutes, the access token expires. Try using it:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3002/api/chat/models \
|
||||
-H "Authorization: Bearer EXPIRED_TOKEN"
|
||||
```
|
||||
|
||||
**Expected:** 401 Unauthorized
|
||||
|
||||
### 7.3 Test Token Refresh
|
||||
|
||||
The `@manacore/shared-auth` package automatically refreshes tokens. To test:
|
||||
|
||||
1. Wait 15+ minutes (or change `JWT_ACCESS_TOKEN_EXPIRY=1m` for testing)
|
||||
2. Make an API call from web/mobile app
|
||||
3. Check Network tab - should see automatic refresh
|
||||
4. Request succeeds with new token
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Test Credit System
|
||||
|
||||
### 8.1 Check Initial Balance
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/v1/credits/balance \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
```json
|
||||
{
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 150,
|
||||
"totalEarned": 0,
|
||||
"totalSpent": 0,
|
||||
"dailyFreeCredits": 5
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Use Credits
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/v1/credits/use \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"amount": 10,
|
||||
"appId": "chat",
|
||||
"description": "Test chat completion",
|
||||
"idempotencyKey": "test-123"
|
||||
}'
|
||||
```
|
||||
|
||||
**Expected:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"transaction": { ... },
|
||||
"newBalance": {
|
||||
"balance": 0,
|
||||
"freeCreditsRemaining": 140,
|
||||
"totalSpent": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Check Transaction History
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/api/v1/credits/transactions \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**Expected:** Array with signup bonus and usage transactions
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue 1: "Connection refused" to port 3001
|
||||
|
||||
**Problem:** Mana Core Auth not running
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd mana-core-auth
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
### Issue 2: "Invalid token" errors
|
||||
|
||||
**Problem:** JWT keys mismatch or token expired
|
||||
|
||||
**Solution:**
|
||||
1. Clear tokens: `localStorage.clear()` in browser
|
||||
2. Login again
|
||||
3. Verify JWT keys are identical in Mana Core Auth .env
|
||||
|
||||
### Issue 3: CORS errors in browser
|
||||
|
||||
**Problem:** Web app URL not in CORS whitelist
|
||||
|
||||
**Solution:**
|
||||
Edit `mana-core-auth/.env`:
|
||||
```env
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
|
||||
```
|
||||
|
||||
Restart Mana Core Auth
|
||||
|
||||
### Issue 4: "Database connection failed"
|
||||
|
||||
**Problem:** PostgreSQL not running
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# If using Docker
|
||||
cd mana-core-auth
|
||||
docker-compose up postgres -d
|
||||
|
||||
# Check it's running
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Issue 5: "Port 3001 already in use"
|
||||
|
||||
**Problem:** Another service using port 3001
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Find what's using the port
|
||||
lsof -ti:3001
|
||||
|
||||
# Kill it
|
||||
kill -9 $(lsof -ti:3001)
|
||||
```
|
||||
|
||||
### Issue 6: Mobile app can't connect
|
||||
|
||||
**Problem:** Using localhost on mobile device
|
||||
|
||||
**Solution:**
|
||||
Edit `chat/apps/mobile/.env`:
|
||||
```env
|
||||
# Replace localhost with your computer's IP
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://192.168.1.XXX:3001
|
||||
EXPO_PUBLIC_BACKEND_URL=http://192.168.1.XXX:3002
|
||||
```
|
||||
|
||||
Get your IP:
|
||||
```bash
|
||||
# macOS
|
||||
ipconfig getifaddr en0
|
||||
|
||||
# Linux
|
||||
hostname -I
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Test Script
|
||||
|
||||
Save this as `test-auth.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
echo "🧪 Testing Mana Core Auth Integration"
|
||||
echo ""
|
||||
|
||||
# Test 1: Register user
|
||||
echo "1️⃣ Testing registration..."
|
||||
REGISTER_RESPONSE=$(curl -s -X POST http://localhost:3001/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"Test1234!"}')
|
||||
|
||||
echo "Response: $REGISTER_RESPONSE"
|
||||
echo ""
|
||||
|
||||
# Test 2: Login
|
||||
echo "2️⃣ Testing login..."
|
||||
LOGIN_RESPONSE=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"Test1234!"}')
|
||||
|
||||
# Extract token
|
||||
TOKEN=$(echo $LOGIN_RESPONSE | grep -o '"accessToken":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "❌ Login failed!"
|
||||
echo "Response: $LOGIN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Login successful! Token: ${TOKEN:0:50}..."
|
||||
echo ""
|
||||
|
||||
# Test 3: Get credits
|
||||
echo "3️⃣ Testing credit balance..."
|
||||
CREDITS_RESPONSE=$(curl -s http://localhost:3001/api/v1/credits/balance \
|
||||
-H "Authorization: Bearer $TOKEN")
|
||||
|
||||
echo "Response: $CREDITS_RESPONSE"
|
||||
echo ""
|
||||
|
||||
# Test 4: Backend protected endpoint
|
||||
echo "4️⃣ Testing backend protected endpoint..."
|
||||
MODELS_RESPONSE=$(curl -s http://localhost:3002/api/chat/models \
|
||||
-H "Authorization: Bearer $TOKEN")
|
||||
|
||||
echo "Response: $MODELS_RESPONSE"
|
||||
echo ""
|
||||
|
||||
echo "✅ All tests complete!"
|
||||
```
|
||||
|
||||
Make it executable and run:
|
||||
```bash
|
||||
chmod +x test-auth.sh
|
||||
./test-auth.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
Use this checklist to verify everything works:
|
||||
|
||||
### Mana Core Auth ✅
|
||||
- [ ] Service starts on port 3001
|
||||
- [ ] Can register new user
|
||||
- [ ] Can login with credentials
|
||||
- [ ] Can refresh access token
|
||||
- [ ] Can logout
|
||||
- [ ] Can check credit balance
|
||||
- [ ] Can use credits
|
||||
|
||||
### Chat Backend ✅
|
||||
- [ ] Service starts on port 3002
|
||||
- [ ] Protected endpoints return 401 without token
|
||||
- [ ] Protected endpoints work with valid token
|
||||
- [ ] Can list AI models
|
||||
- [ ] Can create conversation
|
||||
- [ ] Can list conversations
|
||||
- [ ] Can send messages
|
||||
|
||||
### Web App ✅
|
||||
- [ ] App loads on port 5173
|
||||
- [ ] Can register new user
|
||||
- [ ] Can login
|
||||
- [ ] Can logout
|
||||
- [ ] Can access protected routes
|
||||
- [ ] Can send chat messages
|
||||
- [ ] Can see conversations
|
||||
|
||||
### Mobile App ✅
|
||||
- [ ] App loads in Expo Go
|
||||
- [ ] Can register new user
|
||||
- [ ] Can login
|
||||
- [ ] Can logout
|
||||
- [ ] Can send chat messages
|
||||
- [ ] Can see conversations
|
||||
- [ ] Tokens persist after app restart
|
||||
|
||||
---
|
||||
|
||||
**Status:** Ready for Testing! 🚀
|
||||
|
||||
Follow these steps and check off items as you test. If you encounter issues, check the "Common Issues" section above.
|
||||
37
apps/chat/apps/backend/.dockerignore
Normal file
37
apps/chat/apps/backend/.dockerignore
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build output
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
|
||||
# Development files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Test files
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
*.md
|
||||
!README.md
|
||||
.git
|
||||
.gitignore
|
||||
20
apps/chat/apps/backend/.env.docker
Normal file
20
apps/chat/apps/backend/.env.docker
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Docker Environment Configuration
|
||||
# Copy this file to .env and fill in the values
|
||||
|
||||
# Database Configuration
|
||||
DB_USER=chat
|
||||
DB_PASSWORD=chatpassword
|
||||
DB_NAME=chat
|
||||
DB_PORT=5432
|
||||
|
||||
# Azure OpenAI Configuration (required)
|
||||
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
|
||||
AZURE_OPENAI_API_KEY=your-api-key-here
|
||||
AZURE_OPENAI_API_VERSION=2024-12-01-preview
|
||||
|
||||
# Mana Core Auth URL
|
||||
# Use host.docker.internal to connect to services running on host machine
|
||||
MANA_CORE_AUTH_URL=http://host.docker.internal:3001
|
||||
|
||||
# Backend Port (exposed on host)
|
||||
BACKEND_PORT=3002
|
||||
13
apps/chat/apps/backend/.env.example
Normal file
13
apps/chat/apps/backend/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Azure OpenAI Configuration
|
||||
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
|
||||
AZURE_OPENAI_API_KEY=your-api-key-here
|
||||
AZURE_OPENAI_API_VERSION=2024-12-01-preview
|
||||
|
||||
# Mana Core Auth Configuration
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# PostgreSQL Database Configuration
|
||||
DATABASE_URL=postgresql://chat:password@localhost:5432/chat
|
||||
|
||||
# Server Configuration
|
||||
PORT=3002
|
||||
63
apps/chat/apps/backend/Dockerfile
Normal file
63
apps/chat/apps/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root workspace files
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Copy shared packages
|
||||
COPY packages/shared-errors ./packages/shared-errors
|
||||
|
||||
# Copy chat backend
|
||||
COPY apps/chat/apps/backend ./apps/chat/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build shared packages first
|
||||
WORKDIR /app/packages/shared-errors
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/apps/chat/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install pnpm and postgresql-client for health checks
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
|
||||
&& apk add --no-cache postgresql-client
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from builder (including node_modules)
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/apps/chat/apps/backend ./apps/chat/apps/backend
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY apps/chat/apps/backend/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
WORKDIR /app/apps/chat/apps/backend
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3002
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
|
||||
|
||||
# Run entrypoint script
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
67
apps/chat/apps/backend/docker-compose.yml
Normal file
67
apps/chat/apps/backend/docker-compose.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: chat-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-chat}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-chatpassword}
|
||||
POSTGRES_DB: ${DB_NAME:-chat}
|
||||
ports:
|
||||
- "${DB_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-chat} -d ${DB_NAME:-chat}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Chat Backend API
|
||||
backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: chat/backend/Dockerfile
|
||||
container_name: chat-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Database
|
||||
DATABASE_URL: postgresql://${DB_USER:-chat}:${DB_PASSWORD:-chatpassword}@postgres:5432/${DB_NAME:-chat}
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-chat}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-chatpassword}
|
||||
DB_NAME: ${DB_NAME:-chat}
|
||||
|
||||
# Azure OpenAI
|
||||
AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT}
|
||||
AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY}
|
||||
AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-12-01-preview}
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL: ${MANA_CORE_AUTH_URL:-http://host.docker.internal:3001}
|
||||
|
||||
# Server
|
||||
PORT: 3002
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3002}:3002"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: chat-network
|
||||
34
apps/chat/apps/backend/docker-entrypoint.sh
Normal file
34
apps/chat/apps/backend/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "=== Chat Backend Entrypoint ==="
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
echo "Waiting for PostgreSQL..."
|
||||
until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-chat} 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 2
|
||||
done
|
||||
echo "PostgreSQL is up!"
|
||||
|
||||
cd /app/chat/backend
|
||||
|
||||
# Run schema push (for development) or migrations (for production)
|
||||
if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then
|
||||
echo "Running database migrations..."
|
||||
npx tsx src/db/migrate.ts
|
||||
echo "Migrations completed!"
|
||||
else
|
||||
echo "Pushing database schema (development mode)..."
|
||||
npx drizzle-kit push --force
|
||||
echo "Schema push completed!"
|
||||
fi
|
||||
|
||||
# Run seed (only seeds if data doesn't exist)
|
||||
echo "Running database seed..."
|
||||
npx tsx src/db/seed.ts
|
||||
echo "Seed completed!"
|
||||
|
||||
# Execute the main command
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
12
apps/chat/apps/backend/drizzle.config.ts
Normal file
12
apps/chat/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
10
apps/chat/apps/backend/nest-cli.json
Normal file
10
apps/chat/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
60
apps/chat/apps/backend/package.json
Normal file
60
apps/chat/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "@chat/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"docker:build": "docker compose build",
|
||||
"docker:up": "docker compose up -d",
|
||||
"docker:down": "docker compose down",
|
||||
"docker:logs": "docker compose logs -f",
|
||||
"docker:restart": "docker compose restart",
|
||||
"docker:clean": "docker compose down -v --rmi local"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-errors": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"openai": "^4.77.0",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
28
apps/chat/apps/backend/src/app.module.ts
Normal file
28
apps/chat/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { ConversationModule } from './conversation/conversation.module';
|
||||
import { TemplateModule } from './template/template.module';
|
||||
import { SpaceModule } from './space/space.module';
|
||||
import { DocumentModule } from './document/document.module';
|
||||
import { ModelModule } from './model/model.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
ChatModule,
|
||||
ConversationModule,
|
||||
TemplateModule,
|
||||
SpaceModule,
|
||||
DocumentModule,
|
||||
ModelModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
37
apps/chat/apps/backend/src/chat/chat.controller.ts
Normal file
37
apps/chat/apps/backend/src/chat/chat.controller.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { ChatService } from './chat.service';
|
||||
import {
|
||||
ChatCompletionDto,
|
||||
ChatCompletionResponseDto,
|
||||
} from './dto/chat-completion.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('chat')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ChatController {
|
||||
constructor(private readonly chatService: ChatService) {}
|
||||
|
||||
@Get('models')
|
||||
async getModels() {
|
||||
return this.chatService.getAvailableModels();
|
||||
}
|
||||
|
||||
@Post('completions')
|
||||
async createCompletion(
|
||||
@Body() dto: ChatCompletionDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<ChatCompletionResponseDto> {
|
||||
const result = await this.chatService.createCompletion(dto, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error; // Caught by AppExceptionFilter
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/chat/chat.module.ts
Normal file
10
apps/chat/apps/backend/src/chat/chat.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ChatController } from './chat.controller';
|
||||
import { ChatService } from './chat.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService],
|
||||
exports: [ChatService],
|
||||
})
|
||||
export class ChatModule {}
|
||||
168
apps/chat/apps/backend/src/chat/chat.service.ts
Normal file
168
apps/chat/apps/backend/src/chat/chat.service.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
ValidationError,
|
||||
ServiceError,
|
||||
} from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { models, type Model } from '../db/schema/models.schema';
|
||||
import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly logger = new Logger(ChatService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly endpoint: string;
|
||||
private readonly apiVersion: string;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {
|
||||
this.apiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY') || '';
|
||||
this.endpoint =
|
||||
this.configService.get<string>('AZURE_OPENAI_ENDPOINT') ||
|
||||
'https://memoroseopenai.openai.azure.com';
|
||||
this.apiVersion =
|
||||
this.configService.get<string>('AZURE_OPENAI_API_VERSION') ||
|
||||
'2024-12-01-preview';
|
||||
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('AZURE_OPENAI_API_KEY is not set!');
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableModels(): Promise<Model[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(eq(models.isActive, true));
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching models from database', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getModelById(modelId: string): Promise<Model | undefined> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(eq(models.id, modelId))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching model from database', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async createCompletion(
|
||||
dto: ChatCompletionDto,
|
||||
userId?: string,
|
||||
): AsyncResult<ChatCompletionResponseDto> {
|
||||
const model = await this.getModelById(dto.modelId);
|
||||
|
||||
if (!model) {
|
||||
return err(
|
||||
ValidationError.invalidInput('modelId', `Model ${dto.modelId} not found`),
|
||||
);
|
||||
}
|
||||
|
||||
// Log user context for tracking (optional)
|
||||
if (userId) {
|
||||
this.logger.log(
|
||||
`User ${userId} creating chat completion with model ${dto.modelId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params = model.parameters as {
|
||||
deployment?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
} | null;
|
||||
|
||||
const deployment = params?.deployment || 'gpt-4o-mini-se';
|
||||
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
|
||||
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 1000;
|
||||
|
||||
// Prepare request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
messages: dto.messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
})),
|
||||
};
|
||||
|
||||
// Model-specific parameters
|
||||
const isGPTOModel =
|
||||
deployment.includes('gpt-o') || deployment.includes('gpt-4o');
|
||||
|
||||
if (!isGPTOModel) {
|
||||
requestBody.max_tokens = maxTokens;
|
||||
requestBody.temperature = temperature;
|
||||
}
|
||||
|
||||
const url = `${this.endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${this.apiVersion}`;
|
||||
|
||||
this.logger.log(`Sending request to: ${url}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': this.apiKey,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger.error(`API error: ${response.status} - ${errorText}`);
|
||||
return err(
|
||||
ServiceError.externalError(
|
||||
'Azure OpenAI',
|
||||
`API error: ${response.status}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const messageContent = data.choices?.[0]?.message?.content;
|
||||
|
||||
if (!messageContent) {
|
||||
this.logger.warn('No message content in response');
|
||||
return err(
|
||||
ServiceError.generationFailed('Azure OpenAI', 'No response generated'),
|
||||
);
|
||||
}
|
||||
|
||||
return ok({
|
||||
content: messageContent,
|
||||
usage: {
|
||||
prompt_tokens: data.usage?.prompt_tokens || 0,
|
||||
completion_tokens: data.usage?.completion_tokens || 0,
|
||||
total_tokens: data.usage?.total_tokens || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Error calling Azure OpenAI API', error);
|
||||
return err(
|
||||
ServiceError.generationFailed(
|
||||
'Azure OpenAI',
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
error instanceof Error ? error : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
apps/chat/apps/backend/src/chat/dto/chat-completion.dto.ts
Normal file
40
apps/chat/apps/backend/src/chat/dto/chat-completion.dto.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class ChatMessageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
}
|
||||
|
||||
export class ChatCompletionDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ChatMessageDto)
|
||||
messages: ChatMessageDto[];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelId: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
temperature?: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export class ChatCompletionResponseDto {
|
||||
content: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
66
apps/chat/apps/backend/src/common/guards/jwt-auth.guard.ts
Normal file
66
apps/chat/apps/backend/src/common/guards/jwt-auth.guard.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Mana Core Auth URL from config
|
||||
const authUrl =
|
||||
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
'http://localhost:3001';
|
||||
|
||||
// Validate token with Mana Core Auth
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const { valid, payload } = await response.json();
|
||||
|
||||
if (!valid || !payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Error validating token:', error);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { ConversationService } from './conversation.service';
|
||||
import { type Conversation } from '../db/schema/conversations.schema';
|
||||
import { type Message } from '../db/schema/messages.schema';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('conversations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ConversationController {
|
||||
constructor(private readonly conversationService: ConversationService) {}
|
||||
|
||||
@Get()
|
||||
async getConversations(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('spaceId') spaceId?: string,
|
||||
): Promise<Conversation[]> {
|
||||
const result = await this.conversationService.getConversations(
|
||||
user.userId,
|
||||
spaceId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('archived')
|
||||
async getArchivedConversations(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation[]> {
|
||||
const result = await this.conversationService.getArchivedConversations(
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getConversation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.getConversation(
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id/messages')
|
||||
async getMessages(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Message[]> {
|
||||
const result = await this.conversationService.getMessages(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createConversation(
|
||||
@Body()
|
||||
body: {
|
||||
modelId: string;
|
||||
title?: string;
|
||||
templateId?: string;
|
||||
conversationMode?: 'free' | 'guided' | 'template';
|
||||
documentMode?: boolean;
|
||||
spaceId?: string;
|
||||
},
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.createConversation(
|
||||
user.userId,
|
||||
body.modelId,
|
||||
{
|
||||
title: body.title,
|
||||
templateId: body.templateId,
|
||||
conversationMode: body.conversationMode,
|
||||
documentMode: body.documentMode,
|
||||
spaceId: body.spaceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Post(':id/messages')
|
||||
async addMessage(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Message> {
|
||||
const result = await this.conversationService.addMessage(
|
||||
id,
|
||||
user.userId,
|
||||
body.sender,
|
||||
body.messageText,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id/title')
|
||||
async updateTitle(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { title: string },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.updateTitle(
|
||||
id,
|
||||
user.userId,
|
||||
body.title,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id/archive')
|
||||
async archiveConversation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.archiveConversation(
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id/unarchive')
|
||||
async unarchiveConversation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Conversation> {
|
||||
const result = await this.conversationService.unarchiveConversation(
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteConversation(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await this.conversationService.deleteConversation(
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConversationController } from './conversation.controller';
|
||||
import { ConversationService } from './conversation.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ConversationController],
|
||||
providers: [ConversationService],
|
||||
exports: [ConversationService],
|
||||
})
|
||||
export class ConversationModule {}
|
||||
319
apps/chat/apps/backend/src/conversation/conversation.service.ts
Normal file
319
apps/chat/apps/backend/src/conversation/conversation.service.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import {
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
} from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
conversations,
|
||||
type Conversation,
|
||||
type NewConversation,
|
||||
} from '../db/schema/conversations.schema';
|
||||
import { messages, type Message, type NewMessage } from '../db/schema/messages.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ConversationService {
|
||||
private readonly logger = new Logger(ConversationService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
|
||||
async getConversations(
|
||||
userId: string,
|
||||
spaceId?: string,
|
||||
): AsyncResult<Conversation[]> {
|
||||
try {
|
||||
const conditions = [
|
||||
eq(conversations.userId, userId),
|
||||
eq(conversations.isArchived, false),
|
||||
];
|
||||
|
||||
if (spaceId) {
|
||||
conditions.push(eq(conversations.spaceId, spaceId));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(conversations.updatedAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching conversations', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch conversations'));
|
||||
}
|
||||
}
|
||||
|
||||
async getArchivedConversations(userId: string): AsyncResult<Conversation[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.userId, userId),
|
||||
eq(conversations.isArchived, true),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(conversations.updatedAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching archived conversations', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch archived conversations'));
|
||||
}
|
||||
}
|
||||
|
||||
async getConversation(id: string, userId: string): AsyncResult<Conversation> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(eq(conversations.id, id), eq(conversations.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('Conversation', id));
|
||||
}
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch conversation'));
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<Message[]> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId))
|
||||
.orderBy(asc(messages.createdAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching messages', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch messages'));
|
||||
}
|
||||
}
|
||||
|
||||
async createConversation(
|
||||
userId: string,
|
||||
modelId: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
templateId?: string;
|
||||
conversationMode?: 'free' | 'guided' | 'template';
|
||||
documentMode?: boolean;
|
||||
spaceId?: string;
|
||||
},
|
||||
): AsyncResult<Conversation> {
|
||||
try {
|
||||
const newConversation: NewConversation = {
|
||||
userId,
|
||||
modelId,
|
||||
title: options?.title || 'Neue Unterhaltung',
|
||||
templateId: options?.templateId,
|
||||
conversationMode: options?.conversationMode || 'free',
|
||||
documentMode: options?.documentMode || false,
|
||||
spaceId: options?.spaceId,
|
||||
isArchived: false,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(conversations)
|
||||
.values(newConversation)
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to create conversation'));
|
||||
}
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
sender: 'user' | 'assistant' | 'system',
|
||||
messageText: string,
|
||||
): AsyncResult<Message> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const newMessage: NewMessage = {
|
||||
conversationId,
|
||||
sender,
|
||||
messageText,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(messages)
|
||||
.values(newMessage)
|
||||
.returning();
|
||||
|
||||
// Update conversation updated_at
|
||||
await this.db
|
||||
.update(conversations)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId));
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error adding message', error);
|
||||
return err(DatabaseError.queryFailed('Failed to add message'));
|
||||
}
|
||||
}
|
||||
|
||||
async updateTitle(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
title: string,
|
||||
): AsyncResult<Conversation> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(conversations)
|
||||
.set({ title, updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error updating title', error);
|
||||
return err(DatabaseError.queryFailed('Failed to update title'));
|
||||
}
|
||||
}
|
||||
|
||||
async archiveConversation(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<Conversation> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(conversations)
|
||||
.set({ isArchived: true, updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error archiving conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to archive conversation'));
|
||||
}
|
||||
}
|
||||
|
||||
async unarchiveConversation(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<Conversation> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.id, conversationId),
|
||||
eq(conversations.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (convResult.length === 0) {
|
||||
return err(new NotFoundError('Conversation', conversationId));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(conversations)
|
||||
.set({ isArchived: false, updatedAt: new Date() })
|
||||
.where(eq(conversations.id, conversationId))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error unarchiving conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to unarchive conversation'));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<void> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
// Messages will be cascade deleted due to foreign key constraint
|
||||
await this.db
|
||||
.delete(conversations)
|
||||
.where(eq(conversations.id, conversationId));
|
||||
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error deleting conversation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to delete conversation'));
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCount(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<number> {
|
||||
try {
|
||||
// First verify the conversation belongs to the user
|
||||
const convResult = await this.getConversation(conversationId, userId);
|
||||
if (!convResult.ok) {
|
||||
return err(convResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId));
|
||||
|
||||
return ok(Number(result[0]?.count || 0));
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting message count', error);
|
||||
return err(DatabaseError.queryFailed('Failed to get message count'));
|
||||
}
|
||||
}
|
||||
}
|
||||
38
apps/chat/apps/backend/src/db/connection.ts
Normal file
38
apps/chat/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
apps/chat/apps/backend/src/db/database.module.ts
Normal file
28
apps/chat/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
29
apps/chat/apps/backend/src/db/migrate.ts
Normal file
29
apps/chat/apps/backend/src/db/migrate.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { config } from 'dotenv';
|
||||
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
|
||||
// Load environment variables
|
||||
config();
|
||||
|
||||
async function runMigrations() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
console.log('Running migrations...');
|
||||
|
||||
try {
|
||||
const db = getDb(databaseUrl);
|
||||
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
||||
console.log('Migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
43
apps/chat/apps/backend/src/db/schema/conversations.schema.ts
Normal file
43
apps/chat/apps/backend/src/db/schema/conversations.schema.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { messages } from './messages.schema';
|
||||
import { documents } from './documents.schema';
|
||||
import { spaces } from './spaces.schema';
|
||||
import { models } from './models.schema';
|
||||
import { templates } from './templates.schema';
|
||||
|
||||
export const conversationModeEnum = pgEnum('conversation_mode', ['free', 'guided', 'template']);
|
||||
|
||||
export const conversations = pgTable('conversations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
templateId: uuid('template_id').references(() => templates.id),
|
||||
spaceId: uuid('space_id').references(() => spaces.id, { onDelete: 'set null' }),
|
||||
title: text('title'),
|
||||
conversationMode: conversationModeEnum('conversation_mode').default('free').notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
|
||||
model: one(models, {
|
||||
fields: [conversations.modelId],
|
||||
references: [models.id],
|
||||
}),
|
||||
template: one(templates, {
|
||||
fields: [conversations.templateId],
|
||||
references: [templates.id],
|
||||
}),
|
||||
space: one(spaces, {
|
||||
fields: [conversations.spaceId],
|
||||
references: [spaces.id],
|
||||
}),
|
||||
messages: many(messages),
|
||||
documents: many(documents),
|
||||
}));
|
||||
|
||||
export type Conversation = typeof conversations.$inferSelect;
|
||||
export type NewConversation = typeof conversations.$inferInsert;
|
||||
24
apps/chat/apps/backend/src/db/schema/documents.schema.ts
Normal file
24
apps/chat/apps/backend/src/db/schema/documents.schema.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { pgTable, uuid, text, timestamp, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { conversations } from './conversations.schema';
|
||||
|
||||
export const documents = pgTable('documents', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
version: integer('version').default(1).notNull(),
|
||||
content: text('content').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||
conversation: one(conversations, {
|
||||
fields: [documents.conversationId],
|
||||
references: [conversations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Document = typeof documents.$inferSelect;
|
||||
export type NewDocument = typeof documents.$inferInsert;
|
||||
7
apps/chat/apps/backend/src/db/schema/index.ts
Normal file
7
apps/chat/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from './conversations.schema';
|
||||
export * from './messages.schema';
|
||||
export * from './models.schema';
|
||||
export * from './templates.schema';
|
||||
export * from './spaces.schema';
|
||||
export * from './documents.schema';
|
||||
export * from './usage-logs.schema';
|
||||
26
apps/chat/apps/backend/src/db/schema/messages.schema.ts
Normal file
26
apps/chat/apps/backend/src/db/schema/messages.schema.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { conversations } from './conversations.schema';
|
||||
|
||||
export const senderEnum = pgEnum('sender', ['user', 'assistant', 'system']);
|
||||
|
||||
export const messages = pgTable('messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
sender: senderEnum('sender').notNull(),
|
||||
messageText: text('message_text').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
conversation: one(conversations, {
|
||||
fields: [messages.conversationId],
|
||||
references: [conversations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Message = typeof messages.$inferSelect;
|
||||
export type NewMessage = typeof messages.$inferInsert;
|
||||
20
apps/chat/apps/backend/src/db/schema/models.schema.ts
Normal file
20
apps/chat/apps/backend/src/db/schema/models.schema.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { pgTable, uuid, text, timestamp, jsonb, boolean } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const models = pgTable('models', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
provider: text('provider').notNull(), // 'azure', 'openai', 'anthropic', etc.
|
||||
parameters: jsonb('parameters').$type<{
|
||||
deployment?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
}>(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Model = typeof models.$inferSelect;
|
||||
export type NewModel = typeof models.$inferInsert;
|
||||
46
apps/chat/apps/backend/src/db/schema/spaces.schema.ts
Normal file
46
apps/chat/apps/backend/src/db/schema/spaces.schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const memberRoleEnum = pgEnum('member_role', ['owner', 'admin', 'member', 'viewer']);
|
||||
export const invitationStatusEnum = pgEnum('invitation_status', ['pending', 'accepted', 'declined']);
|
||||
|
||||
export const spaces = pgTable('spaces', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ownerId: uuid('owner_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const spaceMembers = pgTable('space_members', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
spaceId: uuid('space_id')
|
||||
.references(() => spaces.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
role: memberRoleEnum('role').default('member').notNull(),
|
||||
invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(),
|
||||
invitedBy: uuid('invited_by'),
|
||||
invitedAt: timestamp('invited_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
joinedAt: timestamp('joined_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const spacesRelations = relations(spaces, ({ many }) => ({
|
||||
members: many(spaceMembers),
|
||||
}));
|
||||
|
||||
export const spaceMembersRelations = relations(spaceMembers, ({ one }) => ({
|
||||
space: one(spaces, {
|
||||
fields: [spaceMembers.spaceId],
|
||||
references: [spaces.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Space = typeof spaces.$inferSelect;
|
||||
export type NewSpace = typeof spaces.$inferInsert;
|
||||
export type SpaceMember = typeof spaceMembers.$inferSelect;
|
||||
export type NewSpaceMember = typeof spaceMembers.$inferInsert;
|
||||
28
apps/chat/apps/backend/src/db/schema/templates.schema.ts
Normal file
28
apps/chat/apps/backend/src/db/schema/templates.schema.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { models } from './models.schema';
|
||||
|
||||
export const templates = pgTable('templates', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
systemPrompt: text('system_prompt').notNull(),
|
||||
initialQuestion: text('initial_question'),
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
color: text('color').default('#3b82f6').notNull(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
documentMode: boolean('document_mode').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const templatesRelations = relations(templates, ({ one }) => ({
|
||||
model: one(models, {
|
||||
fields: [templates.modelId],
|
||||
references: [models.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Template = typeof templates.$inferSelect;
|
||||
export type NewTemplate = typeof templates.$inferInsert;
|
||||
40
apps/chat/apps/backend/src/db/schema/usage-logs.schema.ts
Normal file
40
apps/chat/apps/backend/src/db/schema/usage-logs.schema.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { pgTable, uuid, timestamp, integer, numeric } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { conversations } from './conversations.schema';
|
||||
import { messages } from './messages.schema';
|
||||
import { models } from './models.schema';
|
||||
|
||||
export const usageLogs = pgTable('usage_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.references(() => conversations.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
messageId: uuid('message_id')
|
||||
.references(() => messages.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
modelId: uuid('model_id').references(() => models.id),
|
||||
promptTokens: integer('prompt_tokens').default(0).notNull(),
|
||||
completionTokens: integer('completion_tokens').default(0).notNull(),
|
||||
totalTokens: integer('total_tokens').default(0).notNull(),
|
||||
estimatedCost: numeric('estimated_cost', { precision: 10, scale: 6 }).default('0'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const usageLogsRelations = relations(usageLogs, ({ one }) => ({
|
||||
conversation: one(conversations, {
|
||||
fields: [usageLogs.conversationId],
|
||||
references: [conversations.id],
|
||||
}),
|
||||
message: one(messages, {
|
||||
fields: [usageLogs.messageId],
|
||||
references: [messages.id],
|
||||
}),
|
||||
model: one(models, {
|
||||
fields: [usageLogs.modelId],
|
||||
references: [models.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type UsageLog = typeof usageLogs.$inferSelect;
|
||||
export type NewUsageLog = typeof usageLogs.$inferInsert;
|
||||
100
apps/chat/apps/backend/src/db/seed.ts
Normal file
100
apps/chat/apps/backend/src/db/seed.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Database Seed Script
|
||||
* Seeds initial data for the chat application
|
||||
*/
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { models } from './schema';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgresql://chat:password@localhost:5432/chat';
|
||||
|
||||
async function seed() {
|
||||
console.log('Starting database seed...');
|
||||
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client);
|
||||
|
||||
try {
|
||||
// Check if models already exist
|
||||
const existingModels = await db.select().from(models);
|
||||
|
||||
if (existingModels.length > 0) {
|
||||
console.log(`Found ${existingModels.length} existing models. Skipping seed.`);
|
||||
await client.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed AI models
|
||||
console.log('Seeding AI models...');
|
||||
|
||||
const modelData = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'GPT-O3-Mini',
|
||||
description: 'Fast, efficient responses for everyday tasks',
|
||||
provider: 'azure',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 800,
|
||||
deployment: 'gpt-o3-mini-se',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440004',
|
||||
name: 'GPT-4o-Mini',
|
||||
description: 'Compact and powerful for complex tasks',
|
||||
provider: 'azure',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
deployment: 'gpt-4o-mini-se',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440005',
|
||||
name: 'GPT-4o',
|
||||
description: 'Most advanced model for demanding tasks',
|
||||
provider: 'azure',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
deployment: 'gpt-4o-se',
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(models).values(modelData);
|
||||
|
||||
console.log(`Seeded ${modelData.length} AI models successfully!`);
|
||||
|
||||
// Log the seeded models
|
||||
const seededModels = await db.select().from(models);
|
||||
console.log('Seeded models:');
|
||||
seededModels.forEach((model) => {
|
||||
console.log(` - ${model.name} (${model.id})`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error seeding database:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run seed
|
||||
seed()
|
||||
.then(() => {
|
||||
console.log('Seed completed!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
129
apps/chat/apps/backend/src/document/document.controller.ts
Normal file
129
apps/chat/apps/backend/src/document/document.controller.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { DocumentService } from './document.service';
|
||||
import { type Document } from '../db/schema/documents.schema';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('documents')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DocumentController {
|
||||
constructor(private readonly documentService: DocumentService) {}
|
||||
|
||||
@Get('conversation/:conversationId')
|
||||
async getLatestDocument(
|
||||
@Param('conversationId') conversationId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Document | null> {
|
||||
const result = await this.documentService.getLatestDocument(
|
||||
conversationId,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('conversation/:conversationId/versions')
|
||||
async getAllDocumentVersions(
|
||||
@Param('conversationId') conversationId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Document[]> {
|
||||
const result = await this.documentService.getAllDocumentVersions(
|
||||
conversationId,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('conversation/:conversationId/exists')
|
||||
async hasDocument(
|
||||
@Param('conversationId') conversationId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ exists: boolean }> {
|
||||
const result = await this.documentService.hasDocument(
|
||||
conversationId,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { exists: result.value };
|
||||
}
|
||||
|
||||
@Post('conversation/:conversationId')
|
||||
async createDocument(
|
||||
@Param('conversationId') conversationId: string,
|
||||
@Body() body: { content: string },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Document> {
|
||||
const result = await this.documentService.createDocument(
|
||||
conversationId,
|
||||
user.userId,
|
||||
body.content,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Post('conversation/:conversationId/version')
|
||||
async createDocumentVersion(
|
||||
@Param('conversationId') conversationId: string,
|
||||
@Body() body: { content: string },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Document> {
|
||||
const result = await this.documentService.createDocumentVersion(
|
||||
conversationId,
|
||||
user.userId,
|
||||
body.content,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteDocumentVersion(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await this.documentService.deleteDocumentVersion(
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/document/document.module.ts
Normal file
10
apps/chat/apps/backend/src/document/document.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DocumentController } from './document.controller';
|
||||
import { DocumentService } from './document.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DocumentController],
|
||||
providers: [DocumentService],
|
||||
exports: [DocumentService],
|
||||
})
|
||||
export class DocumentModule {}
|
||||
239
apps/chat/apps/backend/src/document/document.service.ts
Normal file
239
apps/chat/apps/backend/src/document/document.service.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import {
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
} from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
documents,
|
||||
type Document,
|
||||
type NewDocument,
|
||||
} from '../db/schema/documents.schema';
|
||||
import { conversations } from '../db/schema/conversations.schema';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentService {
|
||||
private readonly logger = new Logger(DocumentService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
|
||||
private async verifyConversationOwnership(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<void> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.id, conversationId),
|
||||
eq(conversations.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('Conversation', conversationId));
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async createDocument(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
content: string,
|
||||
): AsyncResult<Document> {
|
||||
try {
|
||||
// Verify conversation ownership
|
||||
const ownershipResult = await this.verifyConversationOwnership(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!ownershipResult.ok) {
|
||||
return err(ownershipResult.error);
|
||||
}
|
||||
|
||||
const newDocument: NewDocument = {
|
||||
conversationId,
|
||||
version: 1,
|
||||
content,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(documents)
|
||||
.values(newDocument)
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating document', error);
|
||||
return err(DatabaseError.queryFailed('Failed to create document'));
|
||||
}
|
||||
}
|
||||
|
||||
async createDocumentVersion(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
content: string,
|
||||
): AsyncResult<Document> {
|
||||
try {
|
||||
// Verify conversation ownership
|
||||
const ownershipResult = await this.verifyConversationOwnership(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!ownershipResult.ok) {
|
||||
return err(ownershipResult.error);
|
||||
}
|
||||
|
||||
// Get the latest version number
|
||||
const latestDoc = await this.db
|
||||
.select({ version: documents.version })
|
||||
.from(documents)
|
||||
.where(eq(documents.conversationId, conversationId))
|
||||
.orderBy(desc(documents.version))
|
||||
.limit(1);
|
||||
|
||||
const newVersion = (latestDoc[0]?.version || 0) + 1;
|
||||
|
||||
const newDocument: NewDocument = {
|
||||
conversationId,
|
||||
version: newVersion,
|
||||
content,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(documents)
|
||||
.values(newDocument)
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating document version', error);
|
||||
return err(DatabaseError.queryFailed('Failed to create document version'));
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestDocument(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<Document | null> {
|
||||
try {
|
||||
// Verify conversation ownership
|
||||
const ownershipResult = await this.verifyConversationOwnership(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!ownershipResult.ok) {
|
||||
return err(ownershipResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.conversationId, conversationId))
|
||||
.orderBy(desc(documents.version))
|
||||
.limit(1);
|
||||
|
||||
return ok(result.length > 0 ? result[0] : null);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching latest document', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch latest document'));
|
||||
}
|
||||
}
|
||||
|
||||
async getAllDocumentVersions(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<Document[]> {
|
||||
try {
|
||||
// Verify conversation ownership
|
||||
const ownershipResult = await this.verifyConversationOwnership(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!ownershipResult.ok) {
|
||||
return err(ownershipResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.conversationId, conversationId))
|
||||
.orderBy(desc(documents.version));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching document versions', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch document versions'));
|
||||
}
|
||||
}
|
||||
|
||||
async hasDocument(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): AsyncResult<boolean> {
|
||||
try {
|
||||
// Verify conversation ownership
|
||||
const ownershipResult = await this.verifyConversationOwnership(
|
||||
conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!ownershipResult.ok) {
|
||||
return err(ownershipResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(documents)
|
||||
.where(eq(documents.conversationId, conversationId));
|
||||
|
||||
return ok(Number(result[0]?.count || 0) > 0);
|
||||
} catch (error) {
|
||||
this.logger.error('Error checking document existence', error);
|
||||
return err(DatabaseError.queryFailed('Failed to check document existence'));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDocumentVersion(
|
||||
documentId: string,
|
||||
userId: string,
|
||||
): AsyncResult<void> {
|
||||
try {
|
||||
// Get the document to verify ownership
|
||||
const doc = await this.db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.id, documentId))
|
||||
.limit(1);
|
||||
|
||||
if (doc.length === 0) {
|
||||
return err(new NotFoundError('Document', documentId));
|
||||
}
|
||||
|
||||
// Verify conversation ownership
|
||||
const ownershipResult = await this.verifyConversationOwnership(
|
||||
doc[0].conversationId,
|
||||
userId,
|
||||
);
|
||||
if (!ownershipResult.ok) {
|
||||
return err(ownershipResult.error);
|
||||
}
|
||||
|
||||
await this.db.delete(documents).where(eq(documents.id, documentId));
|
||||
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error deleting document version', error);
|
||||
return err(DatabaseError.queryFailed('Failed to delete document version'));
|
||||
}
|
||||
}
|
||||
}
|
||||
13
apps/chat/apps/backend/src/health/health.controller.ts
Normal file
13
apps/chat/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'chat-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/chat/apps/backend/src/health/health.module.ts
Normal file
7
apps/chat/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
apps/chat/apps/backend/src/main.ts
Normal file
40
apps/chat/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001', // Mana Core Auth
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global exception filter will be added later via module
|
||||
// app.useGlobalFilters(new AppExceptionFilter());
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT || 3002;
|
||||
await app.listen(port);
|
||||
console.log(`Chat backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
33
apps/chat/apps/backend/src/model/model.controller.ts
Normal file
33
apps/chat/apps/backend/src/model/model.controller.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { ModelService } from './model.service';
|
||||
import { type Model } from '../db/schema/models.schema';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('models')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ModelController {
|
||||
constructor(private readonly modelService: ModelService) {}
|
||||
|
||||
@Get()
|
||||
async getModels(): Promise<Model[]> {
|
||||
const result = await this.modelService.getModels();
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getModel(@Param('id') id: string): Promise<Model> {
|
||||
const result = await this.modelService.getModel(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/model/model.module.ts
Normal file
10
apps/chat/apps/backend/src/model/model.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ModelController } from './model.controller';
|
||||
import { ModelService } from './model.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ModelController],
|
||||
providers: [ModelService],
|
||||
exports: [ModelService],
|
||||
})
|
||||
export class ModelModule {}
|
||||
55
apps/chat/apps/backend/src/model/model.service.ts
Normal file
55
apps/chat/apps/backend/src/model/model.service.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import {
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
} from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { models, type Model } from '../db/schema/models.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ModelService {
|
||||
private readonly logger = new Logger(ModelService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
|
||||
async getModels(): AsyncResult<Model[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(eq(models.isActive, true))
|
||||
.orderBy(asc(models.name));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching models', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch models'));
|
||||
}
|
||||
}
|
||||
|
||||
async getModel(id: string): AsyncResult<Model> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(eq(models.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('Model', id));
|
||||
}
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching model', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch model'));
|
||||
}
|
||||
}
|
||||
}
|
||||
219
apps/chat/apps/backend/src/space/space.controller.ts
Normal file
219
apps/chat/apps/backend/src/space/space.controller.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { SpaceService } from './space.service';
|
||||
import { type Space, type SpaceMember } from '../db/schema/spaces.schema';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('spaces')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SpaceController {
|
||||
constructor(private readonly spaceService: SpaceService) {}
|
||||
|
||||
@Get()
|
||||
async getUserSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
|
||||
const result = await this.spaceService.getUserSpaces(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('owned')
|
||||
async getOwnedSpaces(@CurrentUser() user: CurrentUserData): Promise<Space[]> {
|
||||
const result = await this.spaceService.getOwnedSpaces(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('invitations')
|
||||
async getPendingInvitations(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Array<{ invitation: SpaceMember; space: Space }>> {
|
||||
const result = await this.spaceService.getPendingInvitations(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getSpace(@Param('id') id: string): Promise<Space> {
|
||||
const result = await this.spaceService.getSpace(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id/members')
|
||||
async getSpaceMembers(
|
||||
@Param('id') id: string,
|
||||
): Promise<SpaceMember[]> {
|
||||
const result = await this.spaceService.getSpaceMembers(id);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id/role')
|
||||
async getUserRoleInSpace(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ role: 'owner' | 'admin' | 'member' | 'viewer' | null }> {
|
||||
const result = await this.spaceService.getUserRoleInSpace(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { role: result.value };
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createSpace(
|
||||
@Body() body: { name: string; description?: string },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Space> {
|
||||
const result = await this.spaceService.createSpace(
|
||||
user.userId,
|
||||
body.name,
|
||||
body.description,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateSpace(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; description?: string; isArchived?: boolean },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Space> {
|
||||
const result = await this.spaceService.updateSpace(id, user.userId, body);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteSpace(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await this.spaceService.deleteSpace(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/invite')
|
||||
async inviteUser(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { userId: string; role?: 'admin' | 'member' | 'viewer' },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SpaceMember> {
|
||||
const result = await this.spaceService.inviteUserToSpace(
|
||||
id,
|
||||
body.userId,
|
||||
user.userId,
|
||||
body.role,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Post(':id/respond')
|
||||
async respondToInvitation(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: 'accepted' | 'declined' },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SpaceMember> {
|
||||
const result = await this.spaceService.respondToInvitation(
|
||||
id,
|
||||
user.userId,
|
||||
body.status,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Delete(':id/members/:userId')
|
||||
async removeMember(
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await this.spaceService.removeMember(id, userId, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch(':id/members/:userId/role')
|
||||
async changeMemberRole(
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: { role: 'admin' | 'member' | 'viewer' },
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SpaceMember> {
|
||||
const result = await this.spaceService.changeMemberRole(
|
||||
id,
|
||||
userId,
|
||||
body.role,
|
||||
user.userId,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/space/space.module.ts
Normal file
10
apps/chat/apps/backend/src/space/space.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SpaceController } from './space.controller';
|
||||
import { SpaceService } from './space.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SpaceController],
|
||||
providers: [SpaceService],
|
||||
exports: [SpaceService],
|
||||
})
|
||||
export class SpaceModule {}
|
||||
449
apps/chat/apps/backend/src/space/space.service.ts
Normal file
449
apps/chat/apps/backend/src/space/space.service.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
} from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
spaces,
|
||||
spaceMembers,
|
||||
type Space,
|
||||
type NewSpace,
|
||||
type SpaceMember,
|
||||
type NewSpaceMember,
|
||||
} from '../db/schema/spaces.schema';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceService {
|
||||
private readonly logger = new Logger(SpaceService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
|
||||
async getUserSpaces(userId: string): AsyncResult<Space[]> {
|
||||
try {
|
||||
// Get all space IDs where user is an accepted member
|
||||
const memberData = await this.db
|
||||
.select({ spaceId: spaceMembers.spaceId })
|
||||
.from(spaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.userId, userId),
|
||||
eq(spaceMembers.invitationStatus, 'accepted'),
|
||||
),
|
||||
);
|
||||
|
||||
if (memberData.length === 0) {
|
||||
return ok([]);
|
||||
}
|
||||
|
||||
const spaceIds = memberData.map((m) => m.spaceId);
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(spaces)
|
||||
.where(and(inArray(spaces.id, spaceIds), eq(spaces.isArchived, false)))
|
||||
.orderBy(desc(spaces.createdAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching user spaces', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch user spaces'));
|
||||
}
|
||||
}
|
||||
|
||||
async getOwnedSpaces(userId: string): AsyncResult<Space[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(spaces)
|
||||
.where(and(eq(spaces.ownerId, userId), eq(spaces.isArchived, false)))
|
||||
.orderBy(desc(spaces.createdAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching owned spaces', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch owned spaces'));
|
||||
}
|
||||
}
|
||||
|
||||
async getSpace(id: string): AsyncResult<Space> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(spaces)
|
||||
.where(eq(spaces.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('Space', id));
|
||||
}
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching space', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch space'));
|
||||
}
|
||||
}
|
||||
|
||||
async createSpace(
|
||||
userId: string,
|
||||
name: string,
|
||||
description?: string,
|
||||
): AsyncResult<Space> {
|
||||
try {
|
||||
const newSpace: NewSpace = {
|
||||
ownerId: userId,
|
||||
name,
|
||||
description,
|
||||
isArchived: false,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(spaces)
|
||||
.values(newSpace)
|
||||
.returning();
|
||||
|
||||
// Add owner as an accepted member
|
||||
const memberData: NewSpaceMember = {
|
||||
spaceId: result[0].id,
|
||||
userId,
|
||||
role: 'owner',
|
||||
invitationStatus: 'accepted',
|
||||
joinedAt: new Date(),
|
||||
};
|
||||
|
||||
await this.db.insert(spaceMembers).values(memberData);
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating space', error);
|
||||
return err(DatabaseError.queryFailed('Failed to create space'));
|
||||
}
|
||||
}
|
||||
|
||||
async updateSpace(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: { name?: string; description?: string; isArchived?: boolean },
|
||||
): AsyncResult<Space> {
|
||||
try {
|
||||
// Verify ownership
|
||||
const spaceResult = await this.getSpace(id);
|
||||
if (!spaceResult.ok) {
|
||||
return err(spaceResult.error);
|
||||
}
|
||||
|
||||
if (spaceResult.value.ownerId !== userId) {
|
||||
return err(new NotFoundError('Space', id));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(spaces)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(spaces.id, id))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error updating space', error);
|
||||
return err(DatabaseError.queryFailed('Failed to update space'));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSpace(id: string, userId: string): AsyncResult<void> {
|
||||
try {
|
||||
// Verify ownership
|
||||
const spaceResult = await this.getSpace(id);
|
||||
if (!spaceResult.ok) {
|
||||
return err(spaceResult.error);
|
||||
}
|
||||
|
||||
if (spaceResult.value.ownerId !== userId) {
|
||||
return err(new NotFoundError('Space', id));
|
||||
}
|
||||
|
||||
// Members will be cascade deleted
|
||||
await this.db.delete(spaces).where(eq(spaces.id, id));
|
||||
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error deleting space', error);
|
||||
return err(DatabaseError.queryFailed('Failed to delete space'));
|
||||
}
|
||||
}
|
||||
|
||||
async getSpaceMembers(spaceId: string): AsyncResult<SpaceMember[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(spaceMembers)
|
||||
.where(eq(spaceMembers.spaceId, spaceId))
|
||||
.orderBy(spaceMembers.role, desc(spaceMembers.joinedAt));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching space members', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch space members'));
|
||||
}
|
||||
}
|
||||
|
||||
async inviteUserToSpace(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
invitedByUserId: string,
|
||||
role: 'admin' | 'member' | 'viewer' = 'member',
|
||||
): AsyncResult<SpaceMember> {
|
||||
try {
|
||||
// Check if user is already a member
|
||||
const existingMember = await this.db
|
||||
.select()
|
||||
.from(spaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.spaceId, spaceId),
|
||||
eq(spaceMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
if (existingMember[0].invitationStatus === 'accepted') {
|
||||
return ok(existingMember[0]);
|
||||
}
|
||||
|
||||
// Update existing invitation
|
||||
const result = await this.db
|
||||
.update(spaceMembers)
|
||||
.set({
|
||||
role,
|
||||
invitationStatus: 'pending',
|
||||
invitedBy: invitedByUserId,
|
||||
invitedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(spaceMembers.id, existingMember[0].id))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
}
|
||||
|
||||
// Create new invitation
|
||||
const memberData: NewSpaceMember = {
|
||||
spaceId,
|
||||
userId,
|
||||
role,
|
||||
invitationStatus: 'pending',
|
||||
invitedBy: invitedByUserId,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(spaceMembers)
|
||||
.values(memberData)
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error inviting user to space', error);
|
||||
return err(DatabaseError.queryFailed('Failed to invite user to space'));
|
||||
}
|
||||
}
|
||||
|
||||
async respondToInvitation(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
status: 'accepted' | 'declined',
|
||||
): AsyncResult<SpaceMember> {
|
||||
try {
|
||||
const updates: Partial<SpaceMember> = {
|
||||
invitationStatus: status,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (status === 'accepted') {
|
||||
updates.joinedAt = new Date();
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(spaceMembers)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.spaceId, spaceId),
|
||||
eq(spaceMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
|
||||
}
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error responding to invitation', error);
|
||||
return err(DatabaseError.queryFailed('Failed to respond to invitation'));
|
||||
}
|
||||
}
|
||||
|
||||
async removeMember(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
requestingUserId: string,
|
||||
): AsyncResult<void> {
|
||||
try {
|
||||
// Verify the requesting user is the owner or an admin
|
||||
const spaceResult = await this.getSpace(spaceId);
|
||||
if (!spaceResult.ok) {
|
||||
return err(spaceResult.error);
|
||||
}
|
||||
|
||||
const requestingMember = await this.db
|
||||
.select()
|
||||
.from(spaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.spaceId, spaceId),
|
||||
eq(spaceMembers.userId, requestingUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const isOwner = spaceResult.value.ownerId === requestingUserId;
|
||||
const isAdmin =
|
||||
requestingMember.length > 0 && requestingMember[0].role === 'admin';
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
|
||||
}
|
||||
|
||||
await this.db
|
||||
.delete(spaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.spaceId, spaceId),
|
||||
eq(spaceMembers.userId, userId),
|
||||
),
|
||||
);
|
||||
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error removing member', error);
|
||||
return err(DatabaseError.queryFailed('Failed to remove member'));
|
||||
}
|
||||
}
|
||||
|
||||
async changeMemberRole(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
newRole: 'admin' | 'member' | 'viewer',
|
||||
requestingUserId: string,
|
||||
): AsyncResult<SpaceMember> {
|
||||
try {
|
||||
// Verify the requesting user is the owner
|
||||
const spaceResult = await this.getSpace(spaceId);
|
||||
if (!spaceResult.ok) {
|
||||
return err(spaceResult.error);
|
||||
}
|
||||
|
||||
if (spaceResult.value.ownerId !== requestingUserId) {
|
||||
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(spaceMembers)
|
||||
.set({ role: newRole, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.spaceId, spaceId),
|
||||
eq(spaceMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('SpaceMember', `${spaceId}:${userId}`));
|
||||
}
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error changing member role', error);
|
||||
return err(DatabaseError.queryFailed('Failed to change member role'));
|
||||
}
|
||||
}
|
||||
|
||||
async getUserRoleInSpace(
|
||||
spaceId: string,
|
||||
userId: string,
|
||||
): AsyncResult<'owner' | 'admin' | 'member' | 'viewer' | null> {
|
||||
try {
|
||||
// Check if owner
|
||||
const spaceResult = await this.getSpace(spaceId);
|
||||
if (!spaceResult.ok) {
|
||||
return err(spaceResult.error);
|
||||
}
|
||||
|
||||
if (spaceResult.value.ownerId === userId) {
|
||||
return ok('owner');
|
||||
}
|
||||
|
||||
// Check membership
|
||||
const memberResult = await this.db
|
||||
.select()
|
||||
.from(spaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.spaceId, spaceId),
|
||||
eq(spaceMembers.userId, userId),
|
||||
eq(spaceMembers.invitationStatus, 'accepted'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (memberResult.length === 0) {
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
return ok(memberResult[0].role as 'admin' | 'member' | 'viewer');
|
||||
} catch (error) {
|
||||
this.logger.error('Error getting user role in space', error);
|
||||
return err(DatabaseError.queryFailed('Failed to get user role in space'));
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingInvitations(
|
||||
userId: string,
|
||||
): AsyncResult<Array<{ invitation: SpaceMember; space: Space }>> {
|
||||
try {
|
||||
const invitations = await this.db
|
||||
.select()
|
||||
.from(spaceMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(spaceMembers.userId, userId),
|
||||
eq(spaceMembers.invitationStatus, 'pending'),
|
||||
),
|
||||
);
|
||||
|
||||
const results: Array<{ invitation: SpaceMember; space: Space }> = [];
|
||||
|
||||
for (const invitation of invitations) {
|
||||
const spaceResult = await this.getSpace(invitation.spaceId);
|
||||
if (spaceResult.ok) {
|
||||
results.push({ invitation, space: spaceResult.value });
|
||||
}
|
||||
}
|
||||
|
||||
return ok(results);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching pending invitations', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch pending invitations'));
|
||||
}
|
||||
}
|
||||
}
|
||||
141
apps/chat/apps/backend/src/template/template.controller.ts
Normal file
141
apps/chat/apps/backend/src/template/template.controller.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { isOk } from '@manacore/shared-errors';
|
||||
import { TemplateService } from './template.service';
|
||||
import { type Template } from '../db/schema/templates.schema';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
CurrentUserData,
|
||||
} from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('templates')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TemplateController {
|
||||
constructor(private readonly templateService: TemplateService) {}
|
||||
|
||||
@Get()
|
||||
async getTemplates(@CurrentUser() user: CurrentUserData): Promise<Template[]> {
|
||||
const result = await this.templateService.getTemplates(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get('default')
|
||||
async getDefaultTemplate(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Template | null> {
|
||||
const result = await this.templateService.getDefaultTemplate(user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getTemplate(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.getTemplate(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createTemplate(
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion?: string;
|
||||
modelId?: string;
|
||||
color?: string;
|
||||
documentMode?: boolean;
|
||||
},
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.createTemplate(user.userId, body);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateTemplate(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion: string;
|
||||
modelId: string;
|
||||
color: string;
|
||||
documentMode: boolean;
|
||||
}>,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.updateTemplate(
|
||||
id,
|
||||
user.userId,
|
||||
body,
|
||||
);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Patch(':id/default')
|
||||
async setDefaultTemplate(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<Template> {
|
||||
const result = await this.templateService.setDefaultTemplate(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteTemplate(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await this.templateService.deleteTemplate(id, user.userId);
|
||||
|
||||
if (!isOk(result)) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/backend/src/template/template.module.ts
Normal file
10
apps/chat/apps/backend/src/template/template.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TemplateController } from './template.controller';
|
||||
import { TemplateService } from './template.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TemplateController],
|
||||
providers: [TemplateService],
|
||||
exports: [TemplateService],
|
||||
})
|
||||
export class TemplateModule {}
|
||||
191
apps/chat/apps/backend/src/template/template.service.ts
Normal file
191
apps/chat/apps/backend/src/template/template.service.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import {
|
||||
type AsyncResult,
|
||||
ok,
|
||||
err,
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
} from '@manacore/shared-errors';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import {
|
||||
templates,
|
||||
type Template,
|
||||
type NewTemplate,
|
||||
} from '../db/schema/templates.schema';
|
||||
|
||||
@Injectable()
|
||||
export class TemplateService {
|
||||
private readonly logger = new Logger(TemplateService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
) {}
|
||||
|
||||
async getTemplates(userId: string): AsyncResult<Template[]> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(templates)
|
||||
.where(eq(templates.userId, userId))
|
||||
.orderBy(asc(templates.name));
|
||||
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching templates', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch templates'));
|
||||
}
|
||||
}
|
||||
|
||||
async getTemplate(id: string, userId: string): AsyncResult<Template> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(templates)
|
||||
.where(and(eq(templates.id, id), eq(templates.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return err(new NotFoundError('Template', id));
|
||||
}
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch template'));
|
||||
}
|
||||
}
|
||||
|
||||
async getDefaultTemplate(userId: string): AsyncResult<Template | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(templates)
|
||||
.where(
|
||||
and(eq(templates.userId, userId), eq(templates.isDefault, true)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return ok(result.length > 0 ? result[0] : null);
|
||||
} catch (error) {
|
||||
this.logger.error('Error fetching default template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to fetch default template'));
|
||||
}
|
||||
}
|
||||
|
||||
async createTemplate(
|
||||
userId: string,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion?: string;
|
||||
modelId?: string;
|
||||
color?: string;
|
||||
documentMode?: boolean;
|
||||
},
|
||||
): AsyncResult<Template> {
|
||||
try {
|
||||
const newTemplate: NewTemplate = {
|
||||
userId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
systemPrompt: data.systemPrompt,
|
||||
initialQuestion: data.initialQuestion,
|
||||
modelId: data.modelId,
|
||||
color: data.color || '#3b82f6',
|
||||
documentMode: data.documentMode || false,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const result = await this.db
|
||||
.insert(templates)
|
||||
.values(newTemplate)
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to create template'));
|
||||
}
|
||||
}
|
||||
|
||||
async updateTemplate(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
initialQuestion: string;
|
||||
modelId: string;
|
||||
color: string;
|
||||
documentMode: boolean;
|
||||
}>,
|
||||
): AsyncResult<Template> {
|
||||
try {
|
||||
// First verify the template belongs to the user
|
||||
const templateResult = await this.getTemplate(id, userId);
|
||||
if (!templateResult.ok) {
|
||||
return err(templateResult.error);
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(templates)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(templates.id, id))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error updating template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to update template'));
|
||||
}
|
||||
}
|
||||
|
||||
async setDefaultTemplate(id: string, userId: string): AsyncResult<Template> {
|
||||
try {
|
||||
// First verify the template belongs to the user
|
||||
const templateResult = await this.getTemplate(id, userId);
|
||||
if (!templateResult.ok) {
|
||||
return err(templateResult.error);
|
||||
}
|
||||
|
||||
// Clear all default flags for this user
|
||||
await this.db
|
||||
.update(templates)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(eq(templates.userId, userId));
|
||||
|
||||
// Set the new default
|
||||
const result = await this.db
|
||||
.update(templates)
|
||||
.set({ isDefault: true, updatedAt: new Date() })
|
||||
.where(eq(templates.id, id))
|
||||
.returning();
|
||||
|
||||
return ok(result[0]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error setting default template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to set default template'));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTemplate(id: string, userId: string): AsyncResult<void> {
|
||||
try {
|
||||
// First verify the template belongs to the user
|
||||
const templateResult = await this.getTemplate(id, userId);
|
||||
if (!templateResult.ok) {
|
||||
return err(templateResult.error);
|
||||
}
|
||||
|
||||
await this.db.delete(templates).where(eq(templates.id, id));
|
||||
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error deleting template', error);
|
||||
return err(DatabaseError.queryFailed('Failed to delete template'));
|
||||
}
|
||||
}
|
||||
}
|
||||
4
apps/chat/apps/backend/tsconfig.build.json
Normal file
4
apps/chat/apps/backend/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
apps/chat/apps/backend/tsconfig.json
Normal file
25
apps/chat/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
11
apps/chat/apps/landing/astro.config.mjs
Normal file
11
apps/chat/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://chat.manacore.app',
|
||||
integrations: [
|
||||
tailwind(),
|
||||
sitemap()
|
||||
]
|
||||
});
|
||||
26
apps/chat/apps/landing/package.json
Normal file
26
apps/chat/apps/landing/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@chat/landing",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
80
apps/chat/apps/landing/src/components/Footer.astro
Normal file
80
apps/chat/apps/landing/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
---
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
],
|
||||
legal: [
|
||||
{ href: '/privacy', label: 'Datenschutz' },
|
||||
{ href: '/terms', label: 'AGB' },
|
||||
{ href: '/imprint', label: 'Impressum' }
|
||||
]
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-background-card border-t border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-8 h-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
<span class="font-bold text-xl text-text-primary">ManaChat</span>
|
||||
</a>
|
||||
<p class="text-text-secondary text-sm max-w-md">
|
||||
Dein intelligenter KI-Chat-Assistent. Chatte mit GPT-4o, GPT-4o-Mini und mehr -
|
||||
alles in einer einfachen, eleganten Oberfläche.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.product.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.legal.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p class="text-text-muted text-sm">
|
||||
© {currentYear} ManaChat. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Made with 💙 in Germany
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
86
apps/chat/apps/landing/src/components/Navigation.astro
Normal file
86
apps/chat/apps/landing/src/components/Navigation.astro
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: 'So funktioniert\'s' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
];
|
||||
---
|
||||
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<svg class="w-8 h-8 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
</svg>
|
||||
<span class="font-bold text-xl text-text-primary">ManaChat</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="#download"
|
||||
class="btn-primary text-sm px-4 py-2"
|
||||
>
|
||||
App herunterladen
|
||||
</a>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Menu"
|
||||
id="mobile-menu-button"
|
||||
>
|
||||
<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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
47
apps/chat/apps/landing/src/layouts/Layout.astro
Normal file
47
apps/chat/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'ManaChat - Dein intelligenter KI-Chat-Assistent mit GPT-4o und mehr'
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
259
apps/chat/apps/landing/src/pages/index.astro
Normal file
259
apps/chat/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
|
||||
// Shared components
|
||||
import HeroSection from '@manacore/shared-landing-ui/sections/HeroSection.astro';
|
||||
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
|
||||
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
|
||||
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
|
||||
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
|
||||
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
|
||||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Mehrere KI-Modelle',
|
||||
description: 'Wähle zwischen GPT-4o, GPT-4o-Mini und weiteren leistungsstarken Modellen für deine Gespräche.'
|
||||
},
|
||||
{
|
||||
icon: '💬',
|
||||
title: 'Konversationen speichern',
|
||||
description: 'Alle deine Chats werden sicher in der Cloud gespeichert und sind jederzeit abrufbar.'
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Plattformübergreifend',
|
||||
description: 'Nutze ManaChat auf iOS, Android und im Web - deine Daten sind überall synchronisiert.'
|
||||
},
|
||||
{
|
||||
icon: '📝',
|
||||
title: 'Dokument-Modus',
|
||||
description: 'Arbeite mit der KI an längeren Texten im speziellen Dokument-Modus mit Versionierung.'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Vorlagen',
|
||||
description: 'Nutze vorgefertigte Vorlagen für häufige Aufgaben wie Texte schreiben, Code erklären oder Übersetzungen.'
|
||||
},
|
||||
{
|
||||
icon: '🔒',
|
||||
title: 'Privatsphäre',
|
||||
description: 'Deine Daten sind sicher. Wir verkaufen keine Nutzerdaten und sind DSGVO-konform.'
|
||||
}
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'App herunterladen',
|
||||
description: 'Lade ManaChat kostenlos im App Store oder Google Play Store herunter.',
|
||||
image: '/screenshots/download.png'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Konto erstellen',
|
||||
description: 'Registriere dich in wenigen Sekunden mit E-Mail oder Google-Account.',
|
||||
image: '/screenshots/register.png'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Loslegen',
|
||||
description: 'Starte dein erstes Gespräch mit der KI - einfach und intuitiv.',
|
||||
image: '/screenshots/chat.png'
|
||||
}
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '50 Nachrichten/Tag', included: true },
|
||||
{ text: 'GPT-4o-Mini Zugang', included: true },
|
||||
{ text: 'Chat-Verlauf speichern', included: true },
|
||||
{ text: 'Basis-Vorlagen', included: true },
|
||||
{ text: 'GPT-4o Zugang', included: false },
|
||||
{ text: 'Dokument-Modus', included: false }
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#download'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '9,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Power-User',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Nachrichten', included: true },
|
||||
{ text: 'Alle KI-Modelle', included: true },
|
||||
{ text: 'Dokument-Modus', included: true },
|
||||
{ text: 'Alle Vorlagen', included: true },
|
||||
{ text: 'Prioritäts-Antworten', included: true },
|
||||
{ text: 'Premium-Support', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro werden',
|
||||
href: '#download'
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt'
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
price: '24,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Teams und Unternehmen',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Team-Verwaltung', included: true },
|
||||
{ text: 'Geteilte Chats', included: true },
|
||||
{ text: 'Admin-Dashboard', included: true },
|
||||
{ text: 'API-Zugang', included: true },
|
||||
{ text: 'Dedizierter Support', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Team starten',
|
||||
href: '#download'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Welche KI-Modelle sind verfügbar?',
|
||||
answer: 'ManaChat bietet Zugang zu GPT-4o, GPT-4o-Mini und weiteren Modellen. Du kannst das Modell für jedes Gespräch individuell auswählen, je nach Komplexität deiner Anfrage.'
|
||||
},
|
||||
{
|
||||
question: 'Wie sicher sind meine Daten?',
|
||||
answer: 'Deine Daten werden verschlüsselt übertragen und gespeichert. Wir verkaufen keine Nutzerdaten an Dritte und sind vollständig DSGVO-konform. Du kannst deine Daten jederzeit exportieren oder löschen.'
|
||||
},
|
||||
{
|
||||
question: 'Was ist der Dokument-Modus?',
|
||||
answer: 'Im Dokument-Modus kannst du mit der KI an längeren Texten arbeiten. Die KI hilft dir beim Schreiben, Überarbeiten und Verbessern. Alle Änderungen werden versioniert, sodass du jederzeit frühere Versionen wiederherstellen kannst.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich ManaChat offline nutzen?',
|
||||
answer: 'Da ManaChat auf Cloud-KI-Modellen basiert, ist eine Internetverbindung erforderlich. Dein Chat-Verlauf wird jedoch lokal zwischengespeichert und synchronisiert, sobald du wieder online bist.'
|
||||
},
|
||||
{
|
||||
question: 'Wie funktioniert die Synchronisierung?',
|
||||
answer: 'Alle deine Chats werden automatisch in der Cloud gespeichert und sind auf allen deinen Geräten verfügbar. Melde dich einfach mit dem gleichen Account an und du hast sofort Zugriff auf alle Gespräche.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer: 'Ja, du kannst dein Pro- oder Team-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.'
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="ManaChat - Dein intelligenter KI-Chat-Assistent">
|
||||
<Navigation />
|
||||
|
||||
<main class="pt-16">
|
||||
<HeroSection
|
||||
title="Chatte mit den besten KI-Modellen"
|
||||
subtitle="ManaChat gibt dir Zugang zu GPT-4o, GPT-4o-Mini und mehr. Eine elegante App für intelligente Gespräche - auf allen deinen Geräten."
|
||||
variant="default"
|
||||
primaryCta={{
|
||||
text: 'Jetzt kostenlos starten',
|
||||
href: '#download'
|
||||
}}
|
||||
secondaryCta={{
|
||||
text: 'Features entdecken',
|
||||
href: '#features',
|
||||
variant: 'secondary'
|
||||
}}
|
||||
trustBadges={[
|
||||
{ icon: '✓', text: 'Kostenlos testen' },
|
||||
{ icon: '🔒', text: 'DSGVO-konform' },
|
||||
{ icon: '📱', text: 'iOS, Android & Web' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles was du für KI-Chats brauchst"
|
||||
subtitle="ManaChat kombiniert die besten KI-Modelle mit einer intuitiven Oberfläche für maximale Produktivität."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten loslegen"
|
||||
subtitle="So einfach startest du mit ManaChat"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Wähle deinen Plan"
|
||||
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
|
||||
plans={pricingPlans}
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über ManaChat wissen musst"
|
||||
faqs={faqs}
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="download"
|
||||
title="Bereit für intelligente Gespräche?"
|
||||
subtitle="Lade ManaChat jetzt herunter und starte dein erstes Gespräch mit GPT-4o. Kostenlos und ohne Kreditkarte."
|
||||
primaryCta={{ text: 'App herunterladen', href: '#' }}
|
||||
variant="highlighted"
|
||||
>
|
||||
<!-- App Store Buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
|
||||
</a>
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust Indicators -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
|
||||
</div>
|
||||
</div>
|
||||
</CTASection>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</Layout>
|
||||
103
apps/chat/apps/landing/src/styles/global.css
Normal file
103
apps/chat/apps/landing/src/styles/global.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ManaChat Theme CSS Variables - Sky Blue */
|
||||
:root {
|
||||
/* Primary colors - ManaChat Sky Blue */
|
||||
--color-primary: #0ea5e9;
|
||||
--color-primary-hover: #38bdf8;
|
||||
--color-primary-glow: rgba(14, 165, 233, 0.3);
|
||||
|
||||
/* Text colors */
|
||||
--color-text-primary: #f9fafb;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-muted: #6b7280;
|
||||
|
||||
/* Background colors */
|
||||
--color-background-page: #0c1929;
|
||||
--color-background-card: #142236;
|
||||
--color-background-card-hover: #1e3a50;
|
||||
|
||||
/* Border colors */
|
||||
--color-border: #1e3a50;
|
||||
--color-border-hover: #2d5a73;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: var(--color-background-page);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-background-card);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-hover);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
|
||||
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
|
||||
@apply hover:border-border-hover hover:bg-background-card;
|
||||
}
|
||||
39
apps/chat/apps/landing/tailwind.config.mjs
Normal file
39
apps/chat/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// ManaChat Sky Blue Theme
|
||||
primary: {
|
||||
DEFAULT: '#0ea5e9',
|
||||
hover: '#38bdf8',
|
||||
glow: 'rgba(14, 165, 233, 0.3)'
|
||||
},
|
||||
background: {
|
||||
page: '#0c1929',
|
||||
card: '#142236',
|
||||
'card-hover': '#1e3a50'
|
||||
},
|
||||
text: {
|
||||
primary: '#f9fafb',
|
||||
secondary: '#d1d5db',
|
||||
muted: '#6b7280'
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#1e3a50',
|
||||
hover: '#2d5a73'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography')
|
||||
]
|
||||
};
|
||||
9
apps/chat/apps/landing/tsconfig.json
Normal file
9
apps/chat/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/chat/apps/mobile/.env.example
Normal file
10
apps/chat/apps/mobile/.env.example
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Mana Core Auth Configuration
|
||||
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Supabase Configuration (for database only, not auth)
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
|
||||
# Chat Backend API
|
||||
# The backend handles AI API calls securely - no API keys needed in the mobile app
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
|
||||
25
apps/chat/apps/mobile/.gitignore
vendored
Normal file
25
apps/chat/apps/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
52
apps/chat/apps/mobile/CLAUDE.md
Normal file
52
apps/chat/apps/mobile/CLAUDE.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Claude's Guide to Chat Mobile App
|
||||
|
||||
## Commands
|
||||
- Start app: `pnpm dev` or `pnpm start`
|
||||
- iOS: `pnpm ios`
|
||||
- Android: `pnpm android`
|
||||
- Lint: `pnpm lint`
|
||||
- Format: `pnpm format`
|
||||
- Build: `pnpm build:dev`, `pnpm build:preview`, `pnpm build:prod`
|
||||
- Supabase: `pnpm supabase:cli`, `pnpm supabase:update-models`, `pnpm supabase:setup`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Integration
|
||||
- **AI API calls go through the backend** - NOT directly from the mobile app
|
||||
- Backend URL configured via `EXPO_PUBLIC_BACKEND_URL` environment variable
|
||||
- API keys are stored securely in the backend only
|
||||
- `utils/backendApi.ts` - Backend client for AI completions
|
||||
- `utils/api.ts` - API wrapper that routes calls to backend
|
||||
|
||||
### Key Files
|
||||
- `config/azure.ts` - Model definitions (NO API keys!)
|
||||
- `services/openai.ts` - Chat service using backend
|
||||
- `utils/backendApi.ts` - Backend API client
|
||||
- `utils/supabase.ts` - Supabase client for data persistence
|
||||
|
||||
## Code Style Guidelines
|
||||
- **TypeScript**: Strict typing with interfaces for props and state
|
||||
- **Components**: Functional components with hooks, located in `/components`
|
||||
- **Navigation**: Expo Router in `/app` directory
|
||||
- **Styling**: NativeWind (Tailwind CSS for React Native)
|
||||
- **Imports**: Path aliases with `~/*` for project root
|
||||
- **Formatting**: 100 char line limit, 2 space tabs, single quotes
|
||||
- **State**: React Context API for global state
|
||||
- **Backend**: Uses NestJS backend for AI calls, Supabase for data
|
||||
- **Naming**: PascalCase for components, camelCase for functions/variables
|
||||
- **Error Handling**: Try/catch with contextual error messages
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://...
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
|
||||
EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Running with Backend
|
||||
|
||||
1. Start the backend first: `pnpm dev:chat:backend`
|
||||
2. Then start the mobile app: `pnpm dev:chat:mobile`
|
||||
|
||||
The mobile app will connect to the backend for AI completions.
|
||||
63
apps/chat/apps/mobile/README.md
Normal file
63
apps/chat/apps/mobile/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Chat App
|
||||
|
||||
Eine moderne mobile Chat-Anwendung zur Interaktion mit verschiedenen KI-Sprachmodellen.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- 💬 Chat mit verschiedenen KI-Modellen (GPT-4, GPT-3.5, Claude 3)
|
||||
- 🔄 Verschiedene Konversationsmodi (frei, geführt, vorlagenbasiert)
|
||||
- 👤 Benutzerauthentifizierung (Registrierung, Anmeldung, Passwort-Reset)
|
||||
- 📱 Cross-Platform (iOS, Android, Web) mit Expo
|
||||
- 🎨 Modernes UI mit NativeWind (Tailwind CSS)
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- **Frontend:** React Native mit Expo SDK 52
|
||||
- **Routing:** Expo Router v4
|
||||
- **Styling:** NativeWind (Tailwind CSS)
|
||||
- **Backend:** Supabase (Auth, PostgreSQL)
|
||||
- **API:** Azure OpenAI API
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. Repository klonen
|
||||
```
|
||||
git clone <repository-url>
|
||||
cd chat
|
||||
```
|
||||
|
||||
2. Abhängigkeiten installieren
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Umgebungsvariablen konfigurieren
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
Dann `.env` mit deinen Supabase- und Azure OpenAI-Zugangsdaten bearbeiten.
|
||||
|
||||
4. Entwicklungsserver starten
|
||||
```
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
- `/app` - Hauptanwendungslogik (Expo Router)
|
||||
- `/components` - Wiederverwendbare UI-Komponenten
|
||||
- `/services` - Business-Logik und API-Dienste
|
||||
- `/utils` - Hilfsfunktionen
|
||||
- `/context` - React Context Provider
|
||||
|
||||
## Nutzung
|
||||
|
||||
Nach dem Start kannst du:
|
||||
- Dich registrieren oder anmelden
|
||||
- Ein KI-Modell auswählen
|
||||
- Eine neue Konversation starten
|
||||
- Zwischen verschiedenen Konversationsmodi wechseln
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT
|
||||
55
apps/chat/apps/mobile/VEREINFACHUNG.md
Normal file
55
apps/chat/apps/mobile/VEREINFACHUNG.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Vereinfachungsplan für Chat App
|
||||
|
||||
Basierend auf der Codeanalyse schlage ich folgende Maßnahmen zur Vereinfachung des Projekts vor:
|
||||
|
||||
## 1. Komponenten-Konsolidierung
|
||||
|
||||
- **Chat-Eingabefelder**: `MessageInput.tsx` und `ChatPromptInput.tsx` zu einer Komponente zusammenführen
|
||||
- **Modell-Auswahl**: Die Logik aus `ModelDropdown.tsx` und `model-selection.tsx` in einen gemeinsamen Service extrahieren
|
||||
- **Nachrichten-Darstellung**: Eine wiederverwendbare `MessageRenderer`-Komponente für alle Nachrichten-Displaytypen erstellen
|
||||
|
||||
## 2. Code-Reduktion
|
||||
|
||||
- **Redundante Modell-Definitionen**: Gemeinsame Typendefinitionen in `types/index.ts` zentralisieren
|
||||
- **API-Wrapper**: XHR durch einfachen Fetch-API-Wrapper in `utils/api.ts` ersetzen
|
||||
- **Error Handling**: Zentrales Fehlerbehandlungssystem statt wiederholter try/catch-Blöcke
|
||||
- **Styling**: Vollständig auf NativeWind umstellen und StyleSheet.create entfernen
|
||||
|
||||
## 3. Architektur-Optimierung
|
||||
|
||||
- **State Management**:
|
||||
- Auth-Zustand über einen zentralen Store verwalten
|
||||
- Modell- und Konversationszustand aus UI-Komponenten in Services verlagern
|
||||
|
||||
- **Typ-System**:
|
||||
- Gemeinsame Schnittstellen für Modelle, Nachrichten und Konversationen
|
||||
- Striktere Typprüfung für alle API-Antworten
|
||||
|
||||
- **Service-Layer**:
|
||||
- Klare Trennung zwischen UI, Datenmodell und API-Logik
|
||||
- Einheitliche Fehlerrückgabe mit Typisierung
|
||||
|
||||
## 4. Dateistruktur
|
||||
|
||||
```
|
||||
/app - Screens & Routing
|
||||
/components - UI-Komponenten
|
||||
/hooks - Gemeinsame React Hooks
|
||||
/services - Business-Logik
|
||||
/types - Typendefinitionen
|
||||
/utils - Hilfsfunktionen
|
||||
```
|
||||
|
||||
## 5. Performance-Optimierungen
|
||||
|
||||
- Virtualisierte Listen für große Nachrichtenthreads
|
||||
- Optimistische UI-Updates für bessere UX
|
||||
- Caching von Modellantworten zur Reduzierung von API-Aufrufen
|
||||
|
||||
## Implementierungsreihenfolge
|
||||
|
||||
1. Typensystem konsolidieren
|
||||
2. API-Wrapper erstellen
|
||||
3. State Management umstellen
|
||||
4. UI-Komponenten vereinheitlichen
|
||||
5. Styling standardisieren
|
||||
38
apps/chat/apps/mobile/VEREINFACHUNG_STATUS.md
Normal file
38
apps/chat/apps/mobile/VEREINFACHUNG_STATUS.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Vereinfachungsplan: Status
|
||||
|
||||
Fortschritt bei der Umsetzung des Vereinfachungsplans:
|
||||
|
||||
## ✅ Zentrale Typendefinitionen
|
||||
- Typendefinitionen für Message, Model, Conversation, etc. in `/types/index.ts` erstellt
|
||||
- Stellt sicher, dass alle Komponenten die gleichen Typen verwenden
|
||||
|
||||
## ✅ API-Wrapper
|
||||
- Modern `fetch`-basierter API-Wrapper in `/utils/api.ts` erstellt
|
||||
- Ersetzt ältere XHR-Implementierung
|
||||
- Implementiert Timeout-Handling, Fehlerbehandlung und Typsicherheit
|
||||
|
||||
## ✅ Fehlerbehandlung
|
||||
- Zentrale Fehlerbehandlung in `/utils/error.ts` erstellt
|
||||
- Unterstützt verschiedene Fehlertypen (API, Netzwerk, Validierung, etc.)
|
||||
- Bietet einheitliche Fehleranzeige und -protokollierung
|
||||
|
||||
## ✅ UI-Komponenten
|
||||
- `useChatInput`-Hook für Eingabefelder erstellt
|
||||
- `ChatInput`-Komponente vereinheitlicht die verschiedenen Nachrichteneingabefelder
|
||||
- `MessageRenderer`-Komponente für einheitliche Nachrichtenanzeige erstellt
|
||||
|
||||
## ✅ Services
|
||||
- `modelService.ts` zentralisiert die Modell-Logik
|
||||
- Implementiert Caching, Fallback-Modelle und Validierung
|
||||
|
||||
## ⏳ Noch ausstehend
|
||||
- Umstellung redundanter Modell-Code auf den neuen `modelService`
|
||||
- Konsolidierung der Konversationslogik
|
||||
- Standardisierung aller Komponenten auf NativeWind
|
||||
- Erstellen weiterer gemeinsamer React Hooks
|
||||
|
||||
## Verbesserungen
|
||||
1. **Einfachere Codeorganisation**: zentrale Typen, weniger doppelter Code
|
||||
2. **Verbesserte Fehlerbehandlung**: konsistente Fehlermeldungen
|
||||
3. **Reduzierte Redundanz**: vereinheitlichte UI-Komponenten
|
||||
4. **Bessere Wartbarkeit**: klare Trennung zwischen Datenzugriff und UI
|
||||
2
apps/chat/apps/mobile/app-env.d.ts
vendored
Normal file
2
apps/chat/apps/mobile/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
56
apps/chat/apps/mobile/app.json
Normal file
56
apps/chat/apps/mobile/app.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "chat",
|
||||
"slug": "chat",
|
||||
"version": "1.0.0",
|
||||
"scheme": "chat",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "server",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.chat"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.chat"
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "67f22a8b-3cae-487d-af1f-55bdaca50e81"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/chat/apps/mobile/app/(drawer)/_layout.tsx
Normal file
79
apps/chat/apps/mobile/app/(drawer)/_layout.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Drawer } from 'expo-router/drawer';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAppTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
export default function DrawerLayout() {
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
// Anpassen des Drawer-Stils basierend auf dem Farbschema
|
||||
const drawerStyles = {
|
||||
backgroundColor: isDarkMode ? '#1C1C1E' : '#FFFFFF',
|
||||
contentOptions: {
|
||||
activeTintColor: '#0A84FF',
|
||||
inactiveTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
activeBackgroundColor: isDarkMode ? '#2C2C2E' : '#E5E5EA',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
drawerStyle: {
|
||||
backgroundColor: drawerStyles.backgroundColor,
|
||||
},
|
||||
drawerActiveTintColor: drawerStyles.contentOptions.activeTintColor,
|
||||
drawerInactiveTintColor: drawerStyles.contentOptions.inactiveTintColor,
|
||||
drawerActiveBackgroundColor: drawerStyles.contentOptions.activeBackgroundColor,
|
||||
}}
|
||||
>
|
||||
<Drawer.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Chat',
|
||||
drawerIcon: ({ color, size }) => (
|
||||
<Ionicons name="chatbubbles-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="documents"
|
||||
options={{
|
||||
title: 'Dokumente',
|
||||
drawerIcon: ({ color, size }) => (
|
||||
<Ionicons name="document-text-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="archive"
|
||||
options={{
|
||||
title: 'Archiv',
|
||||
drawerIcon: ({ color, size }) => (
|
||||
<Ionicons name="archive-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="templates"
|
||||
options={{
|
||||
title: 'Vorlagen',
|
||||
drawerIcon: ({ color, size }) => (
|
||||
<Ionicons name="file-tray-full-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profil',
|
||||
drawerIcon: ({ color, size }) => (
|
||||
<Ionicons name="person-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
46
apps/chat/apps/mobile/app/+html.tsx
Normal file
46
apps/chat/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
24
apps/chat/apps/mobile/app/+not-found.tsx
Normal file
24
apps/chat/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { Container } from '~/components/Container';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<Container>
|
||||
<Text className={styles.title}>This screen doesn't exist.</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
72
apps/chat/apps/mobile/app/_layout.tsx
Normal file
72
apps/chat/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import '../global.css';
|
||||
|
||||
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { ThemeProvider as NavigationThemeProvider } from '@react-navigation/native';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import { ThemeProvider } from '../theme/ThemeProvider';
|
||||
import { AuthProvider, useAuth } from '../context/AuthProvider';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: '(drawer)',
|
||||
};
|
||||
|
||||
function Layout() {
|
||||
const { theme } = useAppTheme();
|
||||
|
||||
return (
|
||||
<NavigationThemeProvider value={theme}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ title: 'Modal', presentation: 'modal' }} />
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="model-selection" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="templates" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="conversation/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="auth/register" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="auth/reset-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="profile" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
</NavigationThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Authentifizierungsprüfung und Umleitung
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
const inAuthGroup = segments[0] === 'auth';
|
||||
|
||||
if (!user && !inAuthGroup) {
|
||||
// Wenn kein Benutzer angemeldet ist und nicht auf einer Auth-Seite, zur Login-Seite umleiten
|
||||
router.replace('/auth/login');
|
||||
} else if (user && inAuthGroup) {
|
||||
// Wenn ein Benutzer angemeldet ist und auf einer Auth-Seite, zur Hauptseite umleiten
|
||||
router.replace('/');
|
||||
}
|
||||
}, [user, loading, segments]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
<Layout />
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
148
apps/chat/apps/mobile/app/api/models+api.ts
Normal file
148
apps/chat/apps/mobile/app/api/models+api.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { supabase } from '../../utils/supabase';
|
||||
|
||||
// Definiere den Typ für ein Modell
|
||||
export type Model = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
parameters?: Record<string, any>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
// Fallback-Modelle, falls keine aus der Datenbank geladen werden können
|
||||
const FALLBACK_MODELS: Model[] = [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'GPT-O3-Mini',
|
||||
description: 'Azure OpenAI O3-Mini: Effizientes Modell für schnelle Antworten.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 800,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-o3-mini-se',
|
||||
endpoint: 'https://memoroseopenai.openai.azure.com',
|
||||
api_version: '2024-12-01-preview'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440004',
|
||||
name: 'GPT-4o-Mini',
|
||||
description: 'Azure OpenAI GPT-4o-Mini: Kompaktes, leistungsstarkes KI-Modell.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-4o-mini-se',
|
||||
endpoint: 'https://memoroseopenai.openai.azure.com',
|
||||
api_version: '2024-12-01-preview'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440005',
|
||||
name: 'GPT-4o',
|
||||
description: 'Azure OpenAI GPT-4o: Das fortschrittlichste multimodale KI-Modell.',
|
||||
parameters: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 1200,
|
||||
provider: 'azure',
|
||||
deployment: 'gpt-4o-se',
|
||||
endpoint: 'https://memoroseopenai.openai.azure.com',
|
||||
api_version: '2024-12-01-preview'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// GET-Handler für Modelle
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Versuche, Modelle aus der Supabase-Datenbank zu laden
|
||||
let models: Model[] = FALLBACK_MODELS;
|
||||
|
||||
// Wenn Supabase konfiguriert ist, versuche die Modelle von dort zu laden
|
||||
try {
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('models')
|
||||
.select('*');
|
||||
// Entfernt: .order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
console.error('Fehler beim Laden der Modelle aus Supabase:', error);
|
||||
} else if (data && data.length > 0) {
|
||||
models = data as Model[];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler bei der Supabase-Verbindung:', e);
|
||||
// Fallback zu den vordefinierten Modellen
|
||||
}
|
||||
|
||||
return Response.json(models);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten der Anfrage:', error);
|
||||
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// POST-Handler zum Erstellen eines neuen Modells
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validiere die Eingabedaten
|
||||
if (!body.name || !body.description) {
|
||||
return new Response(JSON.stringify({ error: 'Name und Beschreibung sind erforderlich' }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Erstelle ein neues Modell in der Datenbank
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('models')
|
||||
.insert([{
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
parameters: body.parameters || {},
|
||||
}])
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
console.error('Fehler beim Erstellen des Modells:', error);
|
||||
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen des Modells' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json(data[0]);
|
||||
} else {
|
||||
// Wenn Supabase nicht verfügbar ist, gib einen Fehler zurück
|
||||
return new Response(JSON.stringify({ error: 'Datenbank nicht verfügbar' }), {
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten der Anfrage:', error);
|
||||
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
137
apps/chat/apps/mobile/app/api/usage+api.ts
Normal file
137
apps/chat/apps/mobile/app/api/usage+api.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { supabase } from '../../utils/supabase';
|
||||
|
||||
// Typ für die Token-Nutzung pro Modell
|
||||
export type ModelUsage = {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: number;
|
||||
};
|
||||
|
||||
// Typ für die Token-Nutzung nach Zeitraum
|
||||
export type UsageByPeriod = {
|
||||
time_period: string;
|
||||
total_tokens: number;
|
||||
total_cost: number;
|
||||
};
|
||||
|
||||
// Typ für die Token-Nutzung einer Konversation
|
||||
export type ConversationUsage = {
|
||||
message_id: string;
|
||||
created_at: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
estimated_cost: number;
|
||||
};
|
||||
|
||||
// Handler für GET /api/usage
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get('userId');
|
||||
const period = url.searchParams.get('period') || 'month';
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: 'User ID ist erforderlich' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Lade die Tokennutzung nach Modell
|
||||
const { data: modelUsage, error: modelError } = await supabase
|
||||
.rpc('get_user_model_usage', { user_id: userId });
|
||||
|
||||
if (modelError) {
|
||||
console.error('Fehler beim Laden der Modellnutzung:', modelError);
|
||||
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Modellnutzung' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Lade die Tokennutzung nach Zeitraum
|
||||
const { data: periodUsage, error: periodError } = await supabase
|
||||
.rpc('get_user_usage_by_period', {
|
||||
user_id: userId,
|
||||
period: period
|
||||
});
|
||||
|
||||
if (periodError) {
|
||||
console.error('Fehler beim Laden der Zeitraumnutzung:', periodError);
|
||||
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Zeitraumnutzung' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Berechne Gesamtkosten und Token
|
||||
const totalCost = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0);
|
||||
const totalTokens = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0);
|
||||
|
||||
return Response.json({
|
||||
modelUsage,
|
||||
periodUsage,
|
||||
summary: {
|
||||
totalCost,
|
||||
totalTokens
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten der Anfrage:', error);
|
||||
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handler für GET /api/usage/conversation
|
||||
export async function GET_conversation(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const conversationId = url.searchParams.get('conversationId');
|
||||
|
||||
if (!conversationId) {
|
||||
return new Response(JSON.stringify({ error: 'Conversation ID ist erforderlich' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Lade die Tokennutzung für die Konversation
|
||||
const { data: conversationUsage, error } = await supabase
|
||||
.rpc('get_conversation_usage', { conversation_id: conversationId });
|
||||
|
||||
if (error) {
|
||||
console.error('Fehler beim Laden der Konversationsnutzung:', error);
|
||||
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Konversationsnutzung' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Berechne Gesamtkosten und Token für diese Konversation
|
||||
const usage = conversationUsage as ConversationUsage[];
|
||||
const totalCost = usage.reduce((sum, item) => sum + item.estimated_cost, 0);
|
||||
const totalTokens = usage.reduce((sum, item) => sum + item.total_tokens, 0);
|
||||
|
||||
return Response.json({
|
||||
conversationUsage,
|
||||
summary: {
|
||||
totalCost,
|
||||
totalTokens,
|
||||
messageCount: usage.length
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Verarbeiten der Anfrage:', error);
|
||||
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
507
apps/chat/apps/mobile/app/archive.tsx
Normal file
507
apps/chat/apps/mobile/app/archive.tsx
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
ActivityIndicator
|
||||
} from 'react-native';
|
||||
import { useTheme, useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import CustomDrawer from '../components/CustomDrawer';
|
||||
import {
|
||||
getArchivedConversations,
|
||||
getMessages,
|
||||
deleteConversation,
|
||||
unarchiveConversation
|
||||
} from '../services/conversation';
|
||||
import { supabase } from '../utils/supabase';
|
||||
|
||||
// Typendefinitionen für Konversationen
|
||||
type ConversationItem = {
|
||||
id: string;
|
||||
modelName: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: Date;
|
||||
mode: 'frei' | 'geführt' | 'vorlage';
|
||||
};
|
||||
|
||||
// Hilfsfunktion zur Formatierung des Datums
|
||||
const formatDate = (date: Date) => {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(date);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${day}. ${month}, ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export default function ArchiveScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [conversations, setConversations] = useState<ConversationItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
|
||||
const loadConversations = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("Lade archivierte Konversationen für User:", user.id);
|
||||
// Lade alle archivierten Konversationen des Benutzers
|
||||
const userConversations = await getArchivedConversations(user.id);
|
||||
console.log(`${userConversations.length} archivierte Konversationen geladen`, new Date().toLocaleTimeString());
|
||||
|
||||
// Lade für jede Konversation die letzte Nachricht und das Modell
|
||||
const conversationItems: ConversationItem[] = [];
|
||||
|
||||
for (const conv of userConversations) {
|
||||
try {
|
||||
// Lade die Nachrichten der Konversation
|
||||
const messages = await getMessages(conv.id);
|
||||
// Lade das Modell aus der Datenbank
|
||||
const { data: modelData } = await supabase
|
||||
.from('models')
|
||||
.select('name')
|
||||
.eq('id', conv.model_id)
|
||||
.single();
|
||||
|
||||
// Finde die letzte Nachricht (die nicht vom System ist)
|
||||
const lastMessage = messages
|
||||
.filter(msg => msg.sender !== 'system')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||
|
||||
if (lastMessage) {
|
||||
conversationItems.push({
|
||||
id: conv.id,
|
||||
modelName: modelData?.name || 'Unbekanntes Modell',
|
||||
title: conv.title || 'Unbenannte Konversation',
|
||||
lastMessage: lastMessage.message_text,
|
||||
timestamp: new Date(conv.updated_at),
|
||||
mode: conv.conversation_mode === 'free' ? 'frei' :
|
||||
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Laden der Details für Konversation ${conv.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setConversations(conversationItems);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konversationen:', error);
|
||||
Alert.alert('Fehler', 'Die Konversationen konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade die Konversationen beim ersten Rendern und wenn sich der User ändert
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [user]);
|
||||
|
||||
// Lade Konversationen erneut, wenn der Screen fokussiert wird
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (user) loadConversations();
|
||||
return () => {};
|
||||
}, [user])
|
||||
);
|
||||
|
||||
const handleConversationPress = (id: string) => {
|
||||
// Navigiere zum Konversations-Screen mit der ID
|
||||
router.push(`/conversation/${id}`);
|
||||
};
|
||||
|
||||
// Löschen einer Konversation
|
||||
const handleDeleteConversation = (id: string) => {
|
||||
Alert.alert(
|
||||
"Konversation löschen",
|
||||
"Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
const success = await deleteConversation(id);
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setConversations(prev => prev.filter(conv => conv.id !== id));
|
||||
Alert.alert("Erfolg", "Die Konversation wurde gelöscht.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen der Konversation:', error);
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Wiederherstellen einer archivierten Konversation
|
||||
const handleUnarchiveConversation = async (id: string) => {
|
||||
try {
|
||||
const success = await unarchiveConversation(id);
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setConversations(prev => prev.filter(conv => conv.id !== id));
|
||||
Alert.alert("Erfolg", "Die Konversation wurde wiederhergestellt.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht wiederhergestellt werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Wiederherstellen der Konversation:', error);
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht wiederhergestellt werden.");
|
||||
}
|
||||
};
|
||||
|
||||
// Zustandsverwaltung für die Optionsmenüs der Konversationselemente
|
||||
const [expandedConversationId, setExpandedConversationId] = useState<string | null>(null);
|
||||
|
||||
// Toggle-Funktion für das Optionsmenü
|
||||
const toggleOptionsMenu = (id: string) => {
|
||||
setExpandedConversationId(expandedConversationId === id ? null : id);
|
||||
};
|
||||
|
||||
const renderConversationItem = ({ item }: { item: ConversationItem }) => {
|
||||
const showOptions = expandedConversationId === item.id;
|
||||
|
||||
return (
|
||||
<View style={[styles.conversationItemWrapper, { backgroundColor: colors.card }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.conversationItem}
|
||||
onPress={() => handleConversationPress(item.id)}
|
||||
onLongPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
<View style={styles.conversationContent}>
|
||||
<View style={styles.conversationHeader}>
|
||||
<View style={styles.titleRow}>
|
||||
<Ionicons
|
||||
name="archive-outline"
|
||||
size={18}
|
||||
color={colors.text}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
|
||||
{formatDate(item.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.modelContainer}>
|
||||
<Text style={[styles.modelName, { color: colors.text + 'AA' }]}>
|
||||
{item.modelName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[styles.lastMessage, { color: colors.text + 'CC' }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.lastMessage}
|
||||
</Text>
|
||||
|
||||
<View style={styles.modeContainer}>
|
||||
<Text style={[styles.modeText, { color: colors.text + '80' }]}>
|
||||
{item.mode === 'frei' ? 'Freier Modus' :
|
||||
item.mode === 'geführt' ? 'Geführter Modus' : 'Vorlagen-Modus'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={() => toggleOptionsMenu(item.id)}>
|
||||
<Ionicons name="ellipsis-vertical" size={20} color={colors.text + '80'} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
|
||||
{showOptions && (
|
||||
<View style={[styles.optionsContainer, { backgroundColor: colors.card }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.optionButton}
|
||||
onPress={() => handleUnarchiveConversation(item.id)}
|
||||
>
|
||||
<Ionicons name="arrow-undo-outline" size={18} color={colors.text} />
|
||||
<Text style={[styles.optionText, { color: colors.text }]}>Wiederherstellen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.optionButton}
|
||||
onPress={() => handleDeleteConversation(item.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.mainLayout}>
|
||||
{/* Permanenter Drawer links */}
|
||||
{isDrawerOpen && (
|
||||
<View style={styles.drawerContainer}>
|
||||
<CustomDrawer
|
||||
isVisible={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<View style={styles.mainContainer}>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.menuButton}
|
||||
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
|
||||
>
|
||||
<Ionicons
|
||||
name="menu-outline"
|
||||
size={28}
|
||||
color={colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerContentContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.title, { color: colors.text }]}>Archiv</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Konversationsliste */}
|
||||
<View style={styles.listContainer}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Konversationen werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : conversations.length > 0 ? (
|
||||
<FlatList
|
||||
data={conversations}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderConversationItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name="archive-outline"
|
||||
size={64}
|
||||
color={colors.text + '40'}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine archivierten Konversationen
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
|
||||
Archivierte Gespräche erscheinen hier
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
mainLayout: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
drawerContainer: {
|
||||
width: 260,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
maxWidth: 1200,
|
||||
width: '100%',
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
menuButton: {
|
||||
padding: 12,
|
||||
marginRight: 0,
|
||||
zIndex: 5,
|
||||
},
|
||||
headerContentContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 120,
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
conversationItemWrapper: {
|
||||
borderRadius: 12,
|
||||
marginTop: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
conversationItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
conversationContent: {
|
||||
flex: 1,
|
||||
},
|
||||
optionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 4,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
marginLeft: 12,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 14,
|
||||
marginLeft: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
conversationHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
titleIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
},
|
||||
modelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
lastMessage: {
|
||||
fontSize: 14,
|
||||
marginBottom: 6,
|
||||
},
|
||||
modeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modeText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
// Container für den Ladezustand
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
marginTop: 40,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
marginTop: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
8
apps/chat/apps/mobile/app/auth/_layout.tsx
Normal file
8
apps/chat/apps/mobile/app/auth/_layout.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
);
|
||||
}
|
||||
295
apps/chat/apps/mobile/app/auth/login.tsx
Normal file
295
apps/chat/apps/mobile/app/auth/login.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter, Link } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthProvider';
|
||||
import { supabase } from '../../utils/supabase';
|
||||
import { useAppTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { signIn } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse und dein Passwort ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
console.log('Anmeldung mit Passwort fehlgeschlagen, versuche direkte Anmeldung...');
|
||||
// Wenn die normale Anmeldung fehlschlägt, versuche eine direkte Anmeldung
|
||||
const { error: directError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (directError) {
|
||||
Alert.alert('Anmeldung fehlgeschlagen', directError.message);
|
||||
} else {
|
||||
router.replace('/');
|
||||
}
|
||||
} else {
|
||||
// Erfolgreich angemeldet, navigiere zur Hauptseite
|
||||
router.replace('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Anmeldung:', error);
|
||||
Alert.alert('Fehler', 'Bei der Anmeldung ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMagicLink = async () => {
|
||||
if (!email) {
|
||||
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: 'exp://localhost:8081/',
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Fehler', error.message);
|
||||
} else {
|
||||
setIsMagicLinkSent(true);
|
||||
Alert.alert(
|
||||
'Magic Link gesendet',
|
||||
'Wir haben dir einen Magic Link an deine E-Mail-Adresse gesendet. Bitte öffne den Link, um dich anzumelden.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Senden des Magic Links:', error);
|
||||
Alert.alert('Fehler', 'Beim Senden des Magic Links ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Willkommen zurück</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
|
||||
Melde dich an, um deine Konversationen fortzusetzen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>E-Mail</Text>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
|
||||
}
|
||||
]}>
|
||||
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="deine@email.de"
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Passwort</Text>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
|
||||
}
|
||||
]}>
|
||||
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Passwort"
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
|
||||
<Ionicons
|
||||
name={showPassword ? "eye-off-outline" : "eye-outline"}
|
||||
size={20}
|
||||
color={colors.text + '80'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.forgotPassword}
|
||||
onPress={() => router.push('/auth/reset-password')}
|
||||
>
|
||||
<Text style={[styles.forgotPasswordText, { color: colors.primary }]}>
|
||||
Passwort vergessen?
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.loginButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
loading && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<Text style={styles.loginButtonText}>Anmelden</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.magicLinkButton,
|
||||
{ backgroundColor: 'transparent', borderColor: colors.primary, borderWidth: 1 },
|
||||
loading && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={handleMagicLink}
|
||||
disabled={loading || isMagicLinkSent}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.magicLinkButtonText, { color: colors.primary }]}>
|
||||
{isMagicLinkSent ? 'Magic Link gesendet' : 'Mit Magic Link anmelden'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.signupContainer}>
|
||||
<Text style={[styles.signupText, { color: colors.text + 'CC' }]}>
|
||||
Noch kein Konto?
|
||||
</Text>
|
||||
<Link href="/auth/register" asChild>
|
||||
<TouchableOpacity>
|
||||
<Text style={[styles.signupLink, { color: colors.primary }]}>
|
||||
Registrieren
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
marginTop: 40,
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
marginLeft: 12,
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: 24,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginButton: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
magicLinkButton: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
magicLinkButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
signupContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
signupText: {
|
||||
fontSize: 14,
|
||||
marginRight: 4,
|
||||
},
|
||||
signupLink: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
244
apps/chat/apps/mobile/app/auth/register.tsx
Normal file
244
apps/chat/apps/mobile/app/auth/register.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter, Link } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthProvider';
|
||||
import { useAppTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { signUp } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password || !confirmPassword) {
|
||||
Alert.alert('Fehler', 'Bitte fülle alle Felder aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
Alert.alert('Fehler', 'Die Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
Alert.alert('Fehler', 'Das Passwort muss mindestens 6 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data, error } = await signUp(email, password);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Registrierung fehlgeschlagen', error.message);
|
||||
} else if (data?.user) {
|
||||
Alert.alert(
|
||||
'Registrierung erfolgreich',
|
||||
'Dein Konto wurde erfolgreich erstellt. Du wirst jetzt angemeldet.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/')
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Registrierung:', error);
|
||||
Alert.alert('Fehler', 'Bei der Registrierung ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Konto erstellen</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
|
||||
Erstelle ein Konto, um mit KI-Modellen zu chatten
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>E-Mail</Text>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
|
||||
}
|
||||
]}>
|
||||
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="deine@email.de"
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Passwort</Text>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
|
||||
}
|
||||
]}>
|
||||
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Passwort"
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
|
||||
<Ionicons
|
||||
name={showPassword ? "eye-off-outline" : "eye-outline"}
|
||||
size={20}
|
||||
color={colors.text + '80'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Passwort bestätigen</Text>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
|
||||
}
|
||||
]}>
|
||||
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Passwort bestätigen"
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.registerButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
loading && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<Text style={styles.registerButtonText}>Registrieren</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.loginContainer}>
|
||||
<Text style={[styles.loginText, { color: colors.text + 'CC' }]}>
|
||||
Bereits ein Konto?
|
||||
</Text>
|
||||
<Link href="/auth/login" asChild>
|
||||
<TouchableOpacity>
|
||||
<Text style={[styles.loginLink, { color: colors.primary }]}>
|
||||
Anmelden
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
marginTop: 40,
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
marginLeft: 12,
|
||||
},
|
||||
registerButton: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
registerButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loginText: {
|
||||
fontSize: 14,
|
||||
marginRight: 4,
|
||||
},
|
||||
loginLink: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
172
apps/chat/apps/mobile/app/auth/reset-password.tsx
Normal file
172
apps/chat/apps/mobile/app/auth/reset-password.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthProvider';
|
||||
import { useAppTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
export default function ResetPasswordScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { resetPassword } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!email) {
|
||||
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { error } = await resetPassword(email);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Fehler', error.message);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'E-Mail gesendet',
|
||||
'Eine E-Mail mit Anweisungen zum Zurücksetzen deines Passworts wurde an deine E-Mail-Adresse gesendet.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/auth/login')
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Zurücksetzen des Passworts:', error);
|
||||
Alert.alert('Fehler', 'Beim Zurücksetzen des Passworts ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Passwort zurücksetzen</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
|
||||
Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen deines Passworts zu erhalten
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>E-Mail</Text>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
{
|
||||
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
|
||||
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA'
|
||||
}
|
||||
]}>
|
||||
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="deine@email.de"
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.resetButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
loading && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#FFFFFF" size="small" />
|
||||
) : (
|
||||
<Text style={styles.resetButtonText}>Link senden</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Text style={[styles.backButtonText, { color: colors.text }]}>
|
||||
Zurück zur Anmeldung
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
marginTop: 40,
|
||||
marginBottom: 40,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
marginLeft: 12,
|
||||
},
|
||||
resetButton: {
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
resetButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
1006
apps/chat/apps/mobile/app/conversation/[id].tsx
Normal file
1006
apps/chat/apps/mobile/app/conversation/[id].tsx
Normal file
File diff suppressed because it is too large
Load diff
129
apps/chat/apps/mobile/app/conversation/new/index.tsx
Normal file
129
apps/chat/apps/mobile/app/conversation/new/index.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet, Text } from 'react-native';
|
||||
import { createConversation, sendMessageAndGetResponse } from '../../../services/conversation';
|
||||
import { useAuth } from '../../../context/AuthProvider';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
// Typendefinition für Parameter
|
||||
interface ConversationNewParams {
|
||||
initialMessage?: string;
|
||||
modelId?: string;
|
||||
templateId?: string;
|
||||
mode?: 'free' | 'guided' | 'template';
|
||||
documentMode?: string; // String, da Query-Parameter immer Strings sind
|
||||
spaceId?: string; // ID des Space, falls vorhanden
|
||||
}
|
||||
|
||||
export default function NewConversation() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<ConversationNewParams>();
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
|
||||
// Extrahiere die Parameter
|
||||
const initialMessage = params?.initialMessage || '';
|
||||
const modelId = params?.modelId || '550e8400-e29b-41d4-a716-446655440000'; // Default zu GPT-4o-mini
|
||||
const templateId = params?.templateId;
|
||||
const mode = (params?.mode || 'free') as 'free' | 'guided' | 'template';
|
||||
const documentMode = params?.documentMode === 'true';
|
||||
const spaceId = params?.spaceId;
|
||||
|
||||
console.log('Erhaltene Parameter:', {
|
||||
initialMessage: initialMessage.substring(0, 50),
|
||||
modelId,
|
||||
templateId,
|
||||
mode,
|
||||
documentMode,
|
||||
spaceId: spaceId || 'nicht angegeben'
|
||||
});
|
||||
|
||||
// Log für Debug-Zwecke
|
||||
console.log("⭐️ Neue Konversation wird erstellt mit Space ID:", spaceId || "keine");
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
console.error('Kein Benutzer gefunden');
|
||||
router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialMessage) {
|
||||
console.warn('Keine Nachricht gefunden');
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const startConversation = async () => {
|
||||
try {
|
||||
setIsFetching(true);
|
||||
console.log('Erstelle Konversation...');
|
||||
|
||||
// 1. Erstelle eine neue Konversation
|
||||
const conversationId = await createConversation(
|
||||
user.id,
|
||||
modelId,
|
||||
mode,
|
||||
templateId,
|
||||
documentMode,
|
||||
spaceId
|
||||
);
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Fehler beim Erstellen der Konversation');
|
||||
}
|
||||
|
||||
console.log('Konversation erstellt mit ID:', conversationId);
|
||||
|
||||
// 2. Sende die initiale Nachricht
|
||||
const response = await sendMessageAndGetResponse(
|
||||
conversationId,
|
||||
initialMessage,
|
||||
modelId,
|
||||
templateId,
|
||||
documentMode
|
||||
);
|
||||
|
||||
console.log('Antwort erhalten');
|
||||
|
||||
// 3. Navigiere zur Konversation
|
||||
router.replace(`/conversation/${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Starten der Konversation:', error);
|
||||
Alert.alert(
|
||||
'Fehler',
|
||||
'Die Konversation konnte nicht gestartet werden.',
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/')
|
||||
}
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
startConversation();
|
||||
}, [user, initialMessage, modelId, templateId, mode, documentMode, spaceId, router]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#0000ff" />
|
||||
<Text style={styles.text}>Starte Konversation...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
marginTop: 20,
|
||||
fontSize: 16,
|
||||
}
|
||||
});
|
||||
604
apps/chat/apps/mobile/app/conversations.tsx
Normal file
604
apps/chat/apps/mobile/app/conversations.tsx
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Platform,
|
||||
Dimensions
|
||||
} from 'react-native';
|
||||
import { useTheme, useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import CustomDrawer from '../components/CustomDrawer';
|
||||
import {
|
||||
getConversations,
|
||||
getMessages,
|
||||
deleteConversation,
|
||||
archiveConversation
|
||||
} from '../services/conversation';
|
||||
import { supabase } from '../utils/supabase';
|
||||
|
||||
// Typendefinitionen für Konversationen
|
||||
type ConversationItem = {
|
||||
id: string;
|
||||
modelName: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: Date;
|
||||
mode: 'frei' | 'geführt' | 'vorlage';
|
||||
};
|
||||
|
||||
// Hilfsfunktion zur Formatierung des Datums
|
||||
const formatDate = (date: Date) => {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(date);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${day}. ${month}, ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export default function ConversationsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [conversations, setConversations] = useState<ConversationItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
|
||||
const loadConversations = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("Lade Konversationen für User:", user.id);
|
||||
// Lade alle nicht-archivierten Konversationen des Benutzers
|
||||
const userConversations = await getConversations(user.id);
|
||||
console.log(`${userConversations.length} Konversationen geladen`, new Date().toLocaleTimeString());
|
||||
|
||||
// Lade für jede Konversation die letzte Nachricht und das Modell
|
||||
const conversationItems: ConversationItem[] = [];
|
||||
|
||||
for (const conv of userConversations) {
|
||||
try {
|
||||
// Lade die Nachrichten der Konversation
|
||||
const messages = await getMessages(conv.id);
|
||||
// Lade das Modell aus der Datenbank
|
||||
const { data: modelData } = await supabase
|
||||
.from('models')
|
||||
.select('name')
|
||||
.eq('id', conv.model_id)
|
||||
.single();
|
||||
|
||||
// Finde die letzte Nachricht (die nicht vom System ist)
|
||||
const lastMessage = messages
|
||||
.filter(msg => msg.sender !== 'system')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||
|
||||
if (lastMessage) {
|
||||
conversationItems.push({
|
||||
id: conv.id,
|
||||
modelName: modelData?.name || 'Unbekanntes Modell',
|
||||
title: conv.title || 'Unbenannte Konversation',
|
||||
lastMessage: lastMessage.message_text,
|
||||
timestamp: new Date(conv.updated_at),
|
||||
mode: conv.conversation_mode === 'free' ? 'frei' :
|
||||
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Laden der Details für Konversation ${conv.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setConversations(conversationItems);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konversationen:', error);
|
||||
Alert.alert('Fehler', 'Die Konversationen konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade die Konversationen beim ersten Rendern und wenn sich der User ändert
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [user]);
|
||||
|
||||
// Lade Konversationen erneut, wenn der Screen fokussiert wird
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (user) loadConversations();
|
||||
return () => {};
|
||||
}, [user])
|
||||
);
|
||||
|
||||
const handleConversationPress = (id: string) => {
|
||||
// Navigiere zum Konversations-Screen mit der ID
|
||||
router.push(`/conversation/${id}`);
|
||||
};
|
||||
|
||||
// Löschen einer Konversation
|
||||
const handleDeleteConversation = (id: string) => {
|
||||
Alert.alert(
|
||||
"Konversation löschen",
|
||||
"Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
const success = await deleteConversation(id);
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setConversations(prev => prev.filter(conv => conv.id !== id));
|
||||
Alert.alert("Erfolg", "Die Konversation wurde gelöscht.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen der Konversation:', error);
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Archivieren einer Konversation
|
||||
const handleArchiveConversation = async (id: string) => {
|
||||
try {
|
||||
const success = await archiveConversation(id);
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setConversations(prev => prev.filter(conv => conv.id !== id));
|
||||
Alert.alert("Erfolg", "Die Konversation wurde archiviert.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Archivieren der Konversation:', error);
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
|
||||
}
|
||||
};
|
||||
|
||||
// Zustandsverwaltung für die Optionsmenüs der Konversationselemente
|
||||
const [expandedConversationId, setExpandedConversationId] = useState<string | null>(null);
|
||||
|
||||
// Toggle-Funktion für das Optionsmenü
|
||||
const toggleOptionsMenu = (id: string) => {
|
||||
setExpandedConversationId(expandedConversationId === id ? null : id);
|
||||
};
|
||||
|
||||
const renderConversationItem = ({ item }: { item: ConversationItem }) => {
|
||||
const showOptions = expandedConversationId === item.id;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.conversationItemWrapper,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
}
|
||||
]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.conversationItem,
|
||||
hovered && { backgroundColor: colors.cardHover },
|
||||
pressed && { opacity: 0.9 }
|
||||
]}
|
||||
onPress={() => handleConversationPress(item.id)}
|
||||
onLongPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<View style={styles.conversationContent}>
|
||||
<View style={styles.conversationHeader}>
|
||||
<View style={styles.titleRow}>
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.badgeContainer}>
|
||||
<View style={[styles.modelBadge, { backgroundColor: colors.primary + '15' }]}>
|
||||
<Text style={[styles.modelName, { color: colors.primary }]}>
|
||||
{item.modelName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.modeBadge, { backgroundColor: colors.muted + '30' }]}>
|
||||
<Text style={[styles.modeText, { color: colors.text + '90' }]}>
|
||||
{item.mode === 'frei' ? 'Frei' :
|
||||
item.mode === 'geführt' ? 'Geführt' : 'Vorlage'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
|
||||
{formatDate(item.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[styles.lastMessage, { color: colors.text + 'CC' }]}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{item.lastMessage}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionsButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Ionicons
|
||||
name="ellipsis-vertical"
|
||||
size={20}
|
||||
color={colors.text + '80'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{showOptions && (
|
||||
<View style={[styles.optionsContainer, {
|
||||
backgroundColor: colors.card,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border
|
||||
}]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => handleArchiveConversation(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="archive-outline" size={18} color={colors.text} />
|
||||
<Text style={[styles.optionText, { color: colors.text }]}>Archivieren</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.dangerHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => handleDeleteConversation(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.mainLayout}>
|
||||
{/* Permanenter Drawer links */}
|
||||
{isDrawerOpen && (
|
||||
<View style={styles.drawerContainer}>
|
||||
<CustomDrawer
|
||||
isVisible={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<View style={styles.mainContainer}>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.menuButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Ionicons
|
||||
name="menu-outline"
|
||||
size={28}
|
||||
color={colors.text}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={[styles.headerTitle, { color: colors.text }]}>Konversationen</Text>
|
||||
</View>
|
||||
|
||||
{/* Konversationsliste */}
|
||||
<View style={styles.listContainer}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Konversationen werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : conversations.length > 0 ? (
|
||||
<FlatList
|
||||
data={conversations}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderConversationItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
numColumns={Platform.OS === 'web' ? Math.min(Math.floor((Dimensions.get('window').width - 32) / 400), 3) : 1}
|
||||
key={Platform.OS === 'web' ? Math.min(Math.floor((Dimensions.get('window').width - 32) / 400), 3) : 1}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name="chatbubbles-outline"
|
||||
size={64}
|
||||
color={colors.text + '40'}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Konversationen vorhanden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
|
||||
Starte eine neue Konversation über den Hauptbildschirm
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
mainLayout: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
drawerContainer: {
|
||||
width: 260,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
maxWidth: 1200,
|
||||
width: '100%',
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
zIndex: 10, // Stelle sicher, dass der Header über allem anderen liegt
|
||||
elevation: 10, // Für Android
|
||||
},
|
||||
menuButton: {
|
||||
padding: 10,
|
||||
marginRight: 12,
|
||||
zIndex: 5,
|
||||
borderRadius: 20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 20,
|
||||
paddingTop: 12,
|
||||
gap: 16,
|
||||
alignSelf: 'center',
|
||||
justifyContent: Platform.OS === 'web' ? 'flex-start' : undefined,
|
||||
},
|
||||
conversationItemWrapper: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
margin: 8,
|
||||
width: Platform.OS === 'web' ? 380 : undefined,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 3,
|
||||
},
|
||||
web: {
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
conversationItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
},
|
||||
conversationContent: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
},
|
||||
optionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
marginLeft: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 14,
|
||||
marginLeft: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
conversationHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
titleIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginBottom: 2,
|
||||
},
|
||||
badgeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
modelBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modeBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
marginLeft: 'auto', // Um es an den rechten Rand zu schieben
|
||||
},
|
||||
lastMessage: {
|
||||
fontSize: 14,
|
||||
marginBottom: 6,
|
||||
lineHeight: 20,
|
||||
marginTop: 4,
|
||||
flex: 1, // Damit die Nachricht den verbleibenden Platz einnimmt
|
||||
},
|
||||
modeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
optionsButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Container für den Ladezustand
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
marginTop: 40,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
marginTop: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
465
apps/chat/apps/mobile/app/documents.tsx
Normal file
465
apps/chat/apps/mobile/app/documents.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Document } from '../services/document';
|
||||
import { supabase } from '../utils/supabase';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
type DocumentWithTitle = Document & {
|
||||
conversation_title: string;
|
||||
};
|
||||
|
||||
export default function DocumentsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const [documents, setDocuments] = useState<DocumentWithTitle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
|
||||
// Berechne die Anzahl der Spalten basierend auf der Bildschirmbreite
|
||||
const columnsCount = useMemo(() => {
|
||||
// Mobile (schmaler Bildschirm)
|
||||
if (width < 600) {
|
||||
return 1;
|
||||
}
|
||||
// Tablet
|
||||
if (width < 1100) {
|
||||
return 2;
|
||||
}
|
||||
// Desktop oder großes Tablet
|
||||
return 3;
|
||||
}, [width]);
|
||||
|
||||
// Berechne die Breite jeder Karte basierend auf der Spaltenanzahl
|
||||
const cardWidth = useMemo(() => {
|
||||
const padding = 16; // Container-Padding rechts und links
|
||||
const gap = 16; // Abstand zwischen Karten
|
||||
const contentWidth = width - (padding * 2);
|
||||
const gapTotal = gap * (columnsCount - 1);
|
||||
const availableWidth = contentWidth - gapTotal;
|
||||
|
||||
// Verhältnis für schmalere Karten, je nach Spaltenanzahl anpassen
|
||||
const widthRatio = columnsCount === 1 ? 0.95 : // Fast volle Breite bei 1 Spalte
|
||||
columnsCount === 2 ? 0.48 : // Etwas schmaler bei 2 Spalten
|
||||
0.31; // Noch schmaler bei 3 Spalten
|
||||
|
||||
return (availableWidth * widthRatio);
|
||||
}, [width, columnsCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkUser = async () => {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (data?.user) {
|
||||
setUserId(data.user.id);
|
||||
} else {
|
||||
// In einer echten App würden wir hier zur Login-Seite weiterleiten
|
||||
// Für jetzt verwenden wir eine Test-ID
|
||||
setUserId('test-user-id');
|
||||
}
|
||||
};
|
||||
checkUser();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
loadDocuments();
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Lade alle Konversationen des Benutzers, die im Dokumentmodus sind
|
||||
const { data: conversations, error: convError } = await supabase
|
||||
.from('conversations')
|
||||
.select('id, title, document_mode')
|
||||
.eq('user_id', userId)
|
||||
.eq('document_mode', true);
|
||||
|
||||
if (convError) {
|
||||
console.error('Fehler beim Laden der Konversationen:', convError);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversations || conversations.length === 0) {
|
||||
setDocuments([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Für jede Konversation den neuesten Dokumentstand laden
|
||||
const latestDocuments: DocumentWithTitle[] = [];
|
||||
|
||||
for (const conv of conversations) {
|
||||
const { data: docData, error: docError } = await supabase
|
||||
.from('documents')
|
||||
.select('*')
|
||||
.eq('conversation_id', conv.id)
|
||||
.order('version', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (docError) {
|
||||
if (docError.code !== 'PGRST116') { // Ignore "No rows found" error
|
||||
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (docData) {
|
||||
latestDocuments.push({
|
||||
...docData,
|
||||
conversation_title: conv.title || 'Unbenannte Konversation'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDocuments(latestDocuments);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Dokumente:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToConversation = (conversationId: string) => {
|
||||
router.push(`/conversation/${conversationId}`);
|
||||
};
|
||||
|
||||
// Funktion zum Extrahieren eines Titels aus dem Dokumentinhalt
|
||||
const extractDocumentTitle = (content: string): string => {
|
||||
// Suche nach einer Markdown-Überschrift Ebene 1 am Anfang
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
return titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Alternativ: Suche nach einer Markdown-Überschrift Ebene 2
|
||||
const subtitleMatch = content.match(/^##\s+(.+)$/m);
|
||||
if (subtitleMatch && subtitleMatch[1]) {
|
||||
return subtitleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Wenn keine Überschrift gefunden wurde, nimm die ersten Wörter
|
||||
const firstLine = content.split('\n')[0].trim();
|
||||
if (firstLine.length > 0) {
|
||||
return firstLine.length > 40 ? `${firstLine.substring(0, 37)}...` : firstLine;
|
||||
}
|
||||
|
||||
return 'Dokument ohne Titel';
|
||||
};
|
||||
|
||||
// Funktion zum Entfernen nur der ersten H1-Überschrift aus dem Inhalt
|
||||
const removeHeadingFromContent = (content: string, title: string): string => {
|
||||
// Prüfe, ob das Dokument mit einer H1-Überschrift beginnt
|
||||
const firstLineMatch = content.match(/^#\s+(.+)$/m);
|
||||
|
||||
if (firstLineMatch && firstLineMatch.index === 0) {
|
||||
// Entferne nur die erste H1-Überschrift am Anfang des Dokuments
|
||||
const parts = content.split('\n');
|
||||
parts.shift(); // Entferne die erste Zeile (H1-Überschrift)
|
||||
|
||||
// Entferne leere Zeilen am Anfang
|
||||
let modifiedContent = parts.join('\n').replace(/^\s+/, '');
|
||||
|
||||
return modifiedContent;
|
||||
}
|
||||
|
||||
// Wenn keine H1-Überschrift am Anfang gefunden wurde,
|
||||
// gib den ursprünglichen Inhalt zurück
|
||||
return content;
|
||||
};
|
||||
|
||||
// Funktion zum Formatieren des Datums
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.text }]}>Alle Dokumente</Text>
|
||||
<TouchableOpacity style={styles.refreshButton} onPress={loadDocuments}>
|
||||
<Ionicons name="refresh" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text }]}>
|
||||
Dokumente werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : documents.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="document-text-outline" size={64} color={colors.text} style={styles.emptyIcon} />
|
||||
<Text style={[styles.emptyText, { color: colors.text }]}>
|
||||
Keine Dokumente gefunden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: colors.text }]}>
|
||||
Erstelle ein neues Dokument in einer Konversation mit aktiviertem Dokumentmodus.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollContainer} contentContainerStyle={styles.documentsContainer}>
|
||||
{documents.map((doc) => (
|
||||
<TouchableOpacity
|
||||
key={doc.id}
|
||||
style={[
|
||||
styles.documentCard,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
width: cardWidth,
|
||||
// Keine quadratischen Karten mehr, stattdessen festgelegte Höhen
|
||||
height: 280,
|
||||
minHeight: 220,
|
||||
maxHeight: 320
|
||||
}
|
||||
]}
|
||||
onPress={() => navigateToConversation(doc.conversation_id)}
|
||||
>
|
||||
<View style={styles.documentHeader}>
|
||||
<Text style={[styles.documentTitle, { color: colors.text }]}>
|
||||
{extractDocumentTitle(doc.content)}
|
||||
</Text>
|
||||
<View style={styles.documentMeta}>
|
||||
<Text style={[styles.conversationTitle, { color: colors.text }]}>
|
||||
{doc.conversation_title}
|
||||
</Text>
|
||||
<View style={styles.metaRight}>
|
||||
<Text style={[styles.documentDate, { color: colors.text }]}>
|
||||
{formatDate(doc.updated_at)}
|
||||
</Text>
|
||||
<Text style={[styles.documentVersion, { color: colors.text }]}>
|
||||
v{doc.version}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
<ScrollView style={styles.documentContent} nestedScrollEnabled={true}>
|
||||
<Markdown
|
||||
style={{
|
||||
body: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
lineHeight: 18
|
||||
},
|
||||
// Normale Anzeige für H1-Überschriften im Inhalt
|
||||
heading1: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 8,
|
||||
marginBottom: 6,
|
||||
lineHeight: 20,
|
||||
paddingBottom: 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
heading2: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginVertical: 5,
|
||||
lineHeight: 18
|
||||
},
|
||||
paragraph: {
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
fontSize: 13,
|
||||
lineHeight: 18
|
||||
},
|
||||
blockquote: {
|
||||
backgroundColor: colors.card,
|
||||
borderLeftColor: colors.primary,
|
||||
borderLeftWidth: 2,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginVertical: 6
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: colors.card,
|
||||
padding: 6,
|
||||
borderRadius: 3,
|
||||
fontSize: 12,
|
||||
lineHeight: 16
|
||||
},
|
||||
link: { color: colors.primary }
|
||||
}}
|
||||
>
|
||||
{removeHeadingFromContent(doc.content, extractDocumentTitle(doc.content))}
|
||||
</Markdown>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
backButton: {
|
||||
padding: 6,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
refreshButton: {
|
||||
padding: 6,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyIcon: {
|
||||
marginBottom: 20,
|
||||
opacity: 0.6,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
maxWidth: '80%',
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
documentsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
padding: 16,
|
||||
// In einem flexiblen Layout nicht mehr space-between verwenden
|
||||
// sondern einen festen Abstand zwischen Items
|
||||
gap: 20,
|
||||
// Alignment um die Karten horizontal zu zentrieren
|
||||
justifyContent: 'center'
|
||||
},
|
||||
documentCard: {
|
||||
// width wird dynamisch basierend auf columnsCount berechnet
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Shadow für die Karten hinzufügen
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 3,
|
||||
},
|
||||
web: {
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
documentHeader: {
|
||||
padding: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
documentTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
lineHeight: 22,
|
||||
},
|
||||
documentMeta: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: 8,
|
||||
},
|
||||
conversationTitle: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
flex: 1,
|
||||
},
|
||||
metaRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
documentDate: {
|
||||
fontSize: 11,
|
||||
opacity: 0.7,
|
||||
},
|
||||
documentVersion: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
// Vorschau-Bereich kleiner machen
|
||||
maxHeight: 180,
|
||||
},
|
||||
documentContent: {
|
||||
padding: 12,
|
||||
// Zusätzliche Eigenschaften für einen besseren Vorschaubereich
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
905
apps/chat/apps/mobile/app/index.tsx
Normal file
905
apps/chat/apps/mobile/app/index.tsx
Normal file
|
|
@ -0,0 +1,905 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, TextInput, Pressable, Platform, ScrollView } from 'react-native';
|
||||
import { useTheme, useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
|
||||
import NewChatButton from '../components/NewChatButton';
|
||||
import ConversationStarter, { ConversationStarterRef } from '../components/ConversationStarter';
|
||||
import CustomDrawer from '../components/CustomDrawer';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import { getConversations, getMessages, deleteConversation, archiveConversation } from '../services/conversation';
|
||||
import { getUserSpaces, Space } from '../services/space';
|
||||
import { supabase } from '../utils/supabase';
|
||||
|
||||
// Typendefinitionen für Konversationen
|
||||
type ConversationItem = {
|
||||
id: string;
|
||||
modelName: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: Date;
|
||||
mode: 'frei' | 'geführt' | 'vorlage';
|
||||
};
|
||||
|
||||
// Hilfsfunktion zur Formatierung des Datums
|
||||
const formatDate = (date: Date) => {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = new Intl.DateTimeFormat('de-DE', { month: 'short' }).format(date);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${day}. ${month}, ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user, signOut } = useAuth();
|
||||
const [conversations, setConversations] = useState<ConversationItem[]>([]);
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingSpaces, setIsLoadingSpaces] = useState(true);
|
||||
const { isDarkMode } = useAppTheme();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const chatInputRef = useRef<ConversationStarterRef>(null);
|
||||
|
||||
// Eine Funktion, die Konversationen lädt und wiederverwendet werden kann
|
||||
// Fokussiere das Eingabefeld beim ersten Laden
|
||||
useEffect(() => {
|
||||
// Kurze Verzögerung, um sicherzustellen, dass die Komponente vollständig gerendert ist
|
||||
setTimeout(() => {
|
||||
if (chatInputRef.current) {
|
||||
chatInputRef.current.focus();
|
||||
}
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const loadConversations = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("Lade Konversationen für User:", user.id);
|
||||
console.log("Selected Space ID:", selectedSpaceId || "Alle Spaces");
|
||||
|
||||
// Lade Konversationen des Benutzers, gefiltert nach Space wenn ausgewählt
|
||||
const userConversations = await getConversations(user.id, selectedSpaceId || undefined);
|
||||
console.log(`${userConversations.length} Konversationen geladen`, new Date().toLocaleTimeString());
|
||||
|
||||
// Lade für jede Konversation die letzte Nachricht und das Modell
|
||||
const conversationItems: ConversationItem[] = [];
|
||||
|
||||
for (const conv of userConversations) {
|
||||
try {
|
||||
// Lade die Nachrichten der Konversation
|
||||
const messages = await getMessages(conv.id);
|
||||
// Lade das Modell aus der Datenbank
|
||||
const { data: modelData } = await supabase
|
||||
.from('models')
|
||||
.select('name')
|
||||
.eq('id', conv.model_id)
|
||||
.single();
|
||||
|
||||
// Finde die letzte Nachricht (die nicht vom System ist)
|
||||
const lastMessage = messages
|
||||
.filter(msg => msg.sender !== 'system')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||
|
||||
if (lastMessage) {
|
||||
conversationItems.push({
|
||||
id: conv.id,
|
||||
modelName: modelData?.name || 'Unbekanntes Modell',
|
||||
title: conv.title || 'Unbenannte Konversation',
|
||||
lastMessage: lastMessage.message_text,
|
||||
timestamp: new Date(conv.updated_at),
|
||||
mode: conv.conversation_mode === 'free' ? 'frei' :
|
||||
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Laden der Details für Konversation ${conv.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setConversations(conversationItems);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konversationen:', error);
|
||||
Alert.alert('Fehler', 'Die Konversationen konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade Spaces
|
||||
const loadSpaces = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoadingSpaces(true);
|
||||
try {
|
||||
const userSpaces = await getUserSpaces(user.id);
|
||||
setSpaces(userSpaces);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Spaces:', error);
|
||||
} finally {
|
||||
setIsLoadingSpaces(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Lade die Konversationen beim ersten Rendern und wenn sich der User oder selectedSpaceId ändert
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, [user, selectedSpaceId]);
|
||||
|
||||
// Lade Spaces beim ersten Rendern
|
||||
useEffect(() => {
|
||||
loadSpaces();
|
||||
}, [loadSpaces]);
|
||||
|
||||
// Lade Konversationen und Spaces erneut, wenn der Screen fokussiert wird
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (user) {
|
||||
loadConversations();
|
||||
loadSpaces();
|
||||
}
|
||||
return () => {};
|
||||
}, [user, loadSpaces, selectedSpaceId])
|
||||
);
|
||||
|
||||
// Space auswählen
|
||||
const handleSpaceSelect = (spaceId: string | null) => {
|
||||
console.log("Space ausgewählt:", spaceId);
|
||||
setSelectedSpaceId(spaceId);
|
||||
|
||||
// Alert für Debug-Zwecke
|
||||
Alert.alert(
|
||||
"Space ausgewählt",
|
||||
`Space ID: ${spaceId || 'Alle Spaces'}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewChat = () => {
|
||||
// Navigiere zum Modellauswahl-Screen
|
||||
router.push('/model-selection');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
router.replace('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abmelden:', error);
|
||||
Alert.alert('Fehler', 'Bei der Abmeldung ist ein Fehler aufgetreten.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConversationPress = (id: string) => {
|
||||
// Navigiere zum Konversations-Screen mit der ID
|
||||
router.push(`/conversation/${id}`);
|
||||
};
|
||||
|
||||
// Löschen einer Konversation
|
||||
const handleDeleteConversation = (id: string) => {
|
||||
Alert.alert(
|
||||
"Konversation löschen",
|
||||
"Möchtest du diese Konversation wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
const success = await deleteConversation(id);
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setConversations(prev => prev.filter(conv => conv.id !== id));
|
||||
Alert.alert("Erfolg", "Die Konversation wurde gelöscht.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen der Konversation:', error);
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Archivieren einer Konversation
|
||||
const handleArchiveConversation = async (id: string) => {
|
||||
try {
|
||||
const success = await archiveConversation(id);
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setConversations(prev => prev.filter(conv => conv.id !== id));
|
||||
Alert.alert("Erfolg", "Die Konversation wurde archiviert.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Archivieren der Konversation:', error);
|
||||
Alert.alert("Fehler", "Die Konversation konnte nicht archiviert werden.");
|
||||
}
|
||||
};
|
||||
|
||||
// Zustandsverwaltung für die Optionsmenüs der Konversationselemente
|
||||
const [expandedConversationId, setExpandedConversationId] = useState<string | null>(null);
|
||||
|
||||
// Toggle-Funktion für das Optionsmenü
|
||||
const toggleOptionsMenu = (id: string) => {
|
||||
setExpandedConversationId(expandedConversationId === id ? null : id);
|
||||
};
|
||||
|
||||
const renderConversationItem = ({ item }: { item: ConversationItem }) => {
|
||||
const showOptions = expandedConversationId === item.id;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.conversationItemWrapper,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
}
|
||||
]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.conversationItem,
|
||||
hovered && { backgroundColor: colors.cardHover },
|
||||
pressed && { opacity: 0.9 }
|
||||
]}
|
||||
onPress={() => handleConversationPress(item.id)}
|
||||
onLongPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<View style={styles.conversationContent}>
|
||||
<View style={styles.conversationHeader}>
|
||||
<View style={styles.titleRow}>
|
||||
<Ionicons
|
||||
name="chatbubble-ellipses-outline"
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.badgeContainer}>
|
||||
<View style={[styles.modelBadge, { backgroundColor: colors.primary + '15' }]}>
|
||||
<Text style={[styles.modelName, { color: colors.primary }]}>
|
||||
{item.modelName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.modeBadge, { backgroundColor: colors.muted + '30' }]}>
|
||||
<Text style={[styles.modeText, { color: colors.text + '90' }]}>
|
||||
{item.mode === 'frei' ? 'Frei' :
|
||||
item.mode === 'geführt' ? 'Geführt' : 'Vorlage'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
|
||||
{formatDate(item.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[styles.lastMessage, { color: colors.text + 'CC' }]}
|
||||
numberOfLines={3}
|
||||
>
|
||||
{item.lastMessage}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionsButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Ionicons
|
||||
name="ellipsis-vertical"
|
||||
size={20}
|
||||
color={colors.text + '80'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{showOptions && (
|
||||
<View style={[styles.optionsContainer, {
|
||||
backgroundColor: colors.card,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border
|
||||
}]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => handleArchiveConversation(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="archive-outline" size={18} color={colors.text} />
|
||||
<Text style={[styles.optionText, { color: colors.text }]}>Archivieren</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.dangerHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => handleDeleteConversation(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Fokussiere das Eingabefeld, wenn der Benutzer auf "Neuen Chat starten" klickt
|
||||
const handleFocusInput = useCallback(() => {
|
||||
if (chatInputRef.current) {
|
||||
chatInputRef.current.focus();
|
||||
}
|
||||
}, [chatInputRef]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.mainLayout}>
|
||||
{/* Permanenter Drawer links */}
|
||||
{isDrawerOpen && (
|
||||
<View style={styles.drawerContainer}>
|
||||
<CustomDrawer
|
||||
isVisible={isDrawerOpen}
|
||||
focusInputOnHomeNavigate={handleFocusInput}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<View style={styles.mainContainer}>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.menuButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Ionicons
|
||||
name="menu-outline"
|
||||
size={28}
|
||||
color={colors.text}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={[styles.title, { color: colors.text }]}>Chats</Text>
|
||||
</View>
|
||||
|
||||
{/* Space-Auswahl */}
|
||||
{spaces.length > 0 && (
|
||||
<View style={styles.spaceSelector} pointerEvents="box-none">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.spacePills}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.spacePill,
|
||||
{
|
||||
backgroundColor: selectedSpaceId === null
|
||||
? colors.primary
|
||||
: 'transparent',
|
||||
borderColor: colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => handleSpaceSelect(null)}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={[
|
||||
styles.spacePillText,
|
||||
{
|
||||
color: selectedSpaceId === null
|
||||
? 'white'
|
||||
: colors.primary
|
||||
}
|
||||
]}>
|
||||
Alle
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{spaces.map(space => (
|
||||
<TouchableOpacity
|
||||
key={space.id}
|
||||
style={[
|
||||
styles.spacePill,
|
||||
{
|
||||
backgroundColor: selectedSpaceId === space.id
|
||||
? colors.primary
|
||||
: 'transparent',
|
||||
borderColor: colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => handleSpaceSelect(space.id)}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={[
|
||||
styles.spacePillText,
|
||||
{
|
||||
color: selectedSpaceId === space.id
|
||||
? 'white'
|
||||
: colors.primary
|
||||
}
|
||||
]}>
|
||||
{space.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.spacePillAdd,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
borderColor: colors.primary,
|
||||
borderStyle: 'dashed'
|
||||
}
|
||||
]}
|
||||
onPress={() => router.push('/spaces')}
|
||||
activeOpacity={0.7}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<View style={styles.spacePillAddContent}>
|
||||
<Ionicons name="add" size={16} color={colors.primary} />
|
||||
<Text style={[styles.spacePillAddText, { color: colors.primary }]}>
|
||||
Verwalten
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Zentrierter ConversationStarter */}
|
||||
<View style={styles.centerContainer}>
|
||||
<ConversationStarter
|
||||
ref={chatInputRef}
|
||||
placeholder="Was möchtest du wissen?"
|
||||
spaceId={selectedSpaceId}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Konversationsliste unten */}
|
||||
<View style={styles.bottomSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
Letzte Konversationen
|
||||
</Text>
|
||||
{conversations.length > 0 && (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.viewAllButton,
|
||||
hovered && { backgroundColor: colors.buttonHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => router.push('/conversations')}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Text style={[styles.viewAllText, { color: colors.primary }]}>
|
||||
Alle anzeigen
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Konversationen werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : conversations.length > 0 ? (
|
||||
<FlatList
|
||||
data={conversations.slice(0, 10)} // Bis zu 10 letzte Einträge
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderConversationItem}
|
||||
contentContainerStyle={styles.gridContent}
|
||||
horizontal={true}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
snapToInterval={396} // 380px Kartenbreite + 16px Abstand
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name="chatbubbles-outline"
|
||||
size={64}
|
||||
color={colors.text + '40'}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Konversationen vorhanden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
|
||||
Stelle eine Frage im Eingabefeld oben
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
mainLayout: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
drawerContainer: {
|
||||
width: 260,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
maxWidth: 1200,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
zIndex: 10, // Stelle sicher, dass der Header über allem anderen liegt
|
||||
elevation: 10, // Für Android
|
||||
},
|
||||
menuButton: {
|
||||
padding: 10,
|
||||
marginRight: 8,
|
||||
zIndex: 5,
|
||||
borderRadius: 20,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
spaceSelector: {
|
||||
paddingTop: 8,
|
||||
paddingBottom: 12,
|
||||
zIndex: 20, // Erhöht, um über anderen Elementen zu liegen
|
||||
elevation: 20, // Für Android
|
||||
position: 'relative', // Setzt einen neuen Stacking-Kontext
|
||||
},
|
||||
spacePills: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 8,
|
||||
},
|
||||
spacePill: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
minWidth: 60,
|
||||
minHeight: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 25, // Noch höher als spaceSelector
|
||||
elevation: 25, // Für Android
|
||||
},
|
||||
spacePillText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
spacePillAdd: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
minWidth: 100,
|
||||
minHeight: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 25, // Gleich wie normaler spacePill
|
||||
elevation: 25, // Für Android
|
||||
},
|
||||
spacePillAddContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
spacePillAddText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginLeft: 4,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 20, // Erhöht, um mehr Platz für Space-Pills zu lassen
|
||||
zIndex: 10, // Zwischen Space-Selector und den Pills
|
||||
},
|
||||
bottomSection: {
|
||||
flex: 0.4, // Nimmt 40% des verfügbaren Platzes ein
|
||||
width: '100%',
|
||||
},
|
||||
gridContent: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 4, // Reduziertes Padding rechts, da die Karten marginRight haben
|
||||
paddingBottom: 20,
|
||||
paddingTop: 10,
|
||||
},
|
||||
conversationItemWrapper: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
width: 380, // Breitere Karten
|
||||
height: 180, // Feste Höhe für einheitlichere Darstellung
|
||||
marginRight: 16, // Abstand zwischen den Karten
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 3,
|
||||
},
|
||||
web: {
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
conversationItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
},
|
||||
conversationContent: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
},
|
||||
optionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
marginLeft: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 14,
|
||||
marginLeft: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
conversationHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
titleIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginBottom: 2,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
badgeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
modelBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modeBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 11,
|
||||
marginLeft: 'auto', // Um es an den rechten Rand zu schieben
|
||||
},
|
||||
lastMessage: {
|
||||
fontSize: 14,
|
||||
marginBottom: 6,
|
||||
lineHeight: 20,
|
||||
marginTop: 4,
|
||||
flex: 1, // Damit die Nachricht den verbleibenden Platz einnimmt
|
||||
},
|
||||
modeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
optionsButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Container für den Ladezustand
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
marginTop: -40,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
marginTop: -80, // Nach oben verschieben, um Platz für das Eingabefeld zu machen
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
userContainer: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoutButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
logoutText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 4,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
maxWidth: 800,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
viewAllButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
viewAllText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
178
apps/chat/apps/mobile/app/model-selection.tsx
Normal file
178
apps/chat/apps/mobile/app/model-selection.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, SafeAreaView, TouchableOpacity } from 'react-native';
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import ModelCard from '../components/ModelCard';
|
||||
import { getModels } from '../services/modelService';
|
||||
import { Model } from '../types';
|
||||
import { availableModels } from '../config/azure';
|
||||
|
||||
export default function ModelSelectionScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
const initialMessage = params.initialMessage as string || '';
|
||||
const [models, setModels] = useState<Model[]>(availableModels);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string>(availableModels[0].id);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Extrahiere mögliche Space ID aus den Parametern
|
||||
const spaceId = params.spaceId as string || null;
|
||||
|
||||
useEffect(() => {
|
||||
// Lade Modelle vom Service
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const modelsList = await getModels();
|
||||
setModels(modelsList);
|
||||
|
||||
// Setze das erste Modell als Standard, wenn noch keins ausgewählt ist
|
||||
if (!selectedModelId && modelsList.length > 0) {
|
||||
setSelectedModelId(modelsList[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Modelle:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
const handleSelectModel = (id: string) => {
|
||||
setSelectedModelId(id);
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
// Navigiere zum Konversationsscreen mit ausgewähltem Modell und initialem Text
|
||||
router.push({
|
||||
pathname: '/conversation/new',
|
||||
params: {
|
||||
initialMessage,
|
||||
modelId: selectedModelId,
|
||||
mode: 'free',
|
||||
...(spaceId && { spaceId }) // Füge spaceId hinzu, wenn vorhanden
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>
|
||||
Modell auswählen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.subtitle, { color: colors.text + 'CC' }]}>
|
||||
Wähle das KI-Modell, mit dem du chatten möchtest
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Modelle werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={models}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ModelCard
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
description={item.description}
|
||||
isSelected={item.id === selectedModelId}
|
||||
onSelect={handleSelectModel}
|
||||
model={item}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.startButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleStart}
|
||||
>
|
||||
<Text style={styles.startButtonText}>Konversation starten</Text>
|
||||
<Ionicons name="arrow-forward" size={18} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
padding: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0,0,0,0.1)',
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
startButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
startButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
720
apps/chat/apps/mobile/app/profile.tsx
Normal file
720
apps/chat/apps/mobile/app/profile.tsx
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, Image, ScrollView, ActivityIndicator, Platform } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
import { supabase } from '../utils/supabase';
|
||||
|
||||
// Typendefinitionen für die Token-Nutzung
|
||||
type ModelUsage = {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
total_tokens: number;
|
||||
total_cost: number;
|
||||
};
|
||||
|
||||
type UsageByPeriod = {
|
||||
time_period: string;
|
||||
total_tokens: number;
|
||||
total_cost: number;
|
||||
};
|
||||
|
||||
type UsageSummary = {
|
||||
totalCost: number;
|
||||
totalTokens: number;
|
||||
modelCount: number;
|
||||
periodCount: number;
|
||||
};
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { colors } = useTheme();
|
||||
const { isDarkMode, toggleTheme } = useAppTheme();
|
||||
const router = useRouter();
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
// Zustandsvariablen für Token-Nutzungsdaten
|
||||
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
|
||||
const [periodUsage, setPeriodUsage] = useState<UsageByPeriod[]>([]);
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'day' | 'month' | 'year'>('month');
|
||||
|
||||
// Funktion zum Laden der Token-Nutzungsdaten
|
||||
const loadUsageData = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Lade die Token-Nutzung nach Modell
|
||||
const { data: modelData, error: modelError } = await supabase
|
||||
.rpc('get_user_model_usage', { user_id: user.id });
|
||||
|
||||
if (modelError) {
|
||||
console.error('Fehler beim Laden der Modellnutzung:', modelError);
|
||||
} else if (modelData) {
|
||||
setModelUsage(modelData as ModelUsage[]);
|
||||
}
|
||||
|
||||
// Lade die Token-Nutzung nach Zeitraum
|
||||
const { data: periodData, error: periodError } = await supabase
|
||||
.rpc('get_user_usage_by_period', {
|
||||
user_id: user.id,
|
||||
period: selectedPeriod
|
||||
});
|
||||
|
||||
if (periodError) {
|
||||
console.error('Fehler beim Laden der Zeitraumnutzung:', periodError);
|
||||
} else if (periodData) {
|
||||
setPeriodUsage(periodData as UsageByPeriod[]);
|
||||
}
|
||||
|
||||
// Berechne die Zusammenfassung
|
||||
if (modelData) {
|
||||
const totalCost = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0);
|
||||
const totalTokens = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0);
|
||||
|
||||
setSummary({
|
||||
totalCost,
|
||||
totalTokens,
|
||||
modelCount: (modelData as ModelUsage[]).length,
|
||||
periodCount: periodData ? (periodData as UsageByPeriod[]).length : 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Nutzungsdaten:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade die Nutzungsdaten beim ersten Rendern und wenn sich der Zeitraum ändert
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadUsageData();
|
||||
}
|
||||
}, [user, selectedPeriod]);
|
||||
|
||||
// Formatierungsfunktionen
|
||||
const formatCost = (cost: number): string => {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
};
|
||||
|
||||
const formatTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000000) {
|
||||
return `${(tokens / 1000000).toFixed(2)}M`;
|
||||
} else if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}K`;
|
||||
} else {
|
||||
return tokens.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePeriodChange = (period: 'day' | 'month' | 'year') => {
|
||||
setSelectedPeriod(period);
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
Alert.alert(
|
||||
'Abmelden',
|
||||
'Möchtest du dich wirklich abmelden?',
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Abmelden',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await signOut();
|
||||
router.replace('/auth/login');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Profil</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.profileSection}>
|
||||
<View style={[styles.avatarContainer, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Text style={[styles.avatarText, { color: colors.primary }]}>
|
||||
{user?.email?.charAt(0).toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.userInfo}>
|
||||
<Text style={[styles.userName, { color: colors.text }]}>
|
||||
{user?.email?.split('@')[0] || 'Benutzer'}
|
||||
</Text>
|
||||
<Text style={[styles.userEmail, { color: colors.text + '80' }]}>
|
||||
{user?.email || 'E-Mail nicht verfügbar'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Token-Nutzungsstatistiken */}
|
||||
<View style={styles.usageSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Token-Nutzung</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Lade Nutzungsdaten...
|
||||
</Text>
|
||||
</View>
|
||||
) : summary ? (
|
||||
<>
|
||||
{/* Zusammenfassung der Nutzung */}
|
||||
<View style={[styles.usageSummaryCard, {
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
shadowColor: isDarkMode ? undefined : '#000',
|
||||
}]}>
|
||||
<View style={styles.usageSummaryRow}>
|
||||
<View style={styles.usageSummaryItem}>
|
||||
<Text style={[styles.usageSummaryValue, { color: colors.primary }]}>
|
||||
{formatTokens(summary.totalTokens)}
|
||||
</Text>
|
||||
<Text style={[styles.usageSummaryLabel, { color: colors.text + '80' }]}>
|
||||
Tokens gesamt
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.usageSummaryDivider} />
|
||||
|
||||
<View style={styles.usageSummaryItem}>
|
||||
<Text style={[styles.usageSummaryValue, { color: colors.primary }]}>
|
||||
${summary.totalCost.toFixed(4)}
|
||||
</Text>
|
||||
<Text style={[styles.usageSummaryLabel, { color: colors.text + '80' }]}>
|
||||
Kosten gesamt
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Zeitraumauswahl */}
|
||||
<View style={styles.periodSelector}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.periodButton,
|
||||
selectedPeriod === 'day' && {
|
||||
backgroundColor: colors.primary + '20',
|
||||
borderColor: colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => handlePeriodChange('day')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.periodButtonText,
|
||||
{ color: colors.text },
|
||||
selectedPeriod === 'day' && { color: colors.primary, fontWeight: '600' }
|
||||
]}>
|
||||
Tag
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.periodButton,
|
||||
selectedPeriod === 'month' && {
|
||||
backgroundColor: colors.primary + '20',
|
||||
borderColor: colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => handlePeriodChange('month')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.periodButtonText,
|
||||
{ color: colors.text },
|
||||
selectedPeriod === 'month' && { color: colors.primary, fontWeight: '600' }
|
||||
]}>
|
||||
Monat
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.periodButton,
|
||||
selectedPeriod === 'year' && {
|
||||
backgroundColor: colors.primary + '20',
|
||||
borderColor: colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => handlePeriodChange('year')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.periodButtonText,
|
||||
{ color: colors.text },
|
||||
selectedPeriod === 'year' && { color: colors.primary, fontWeight: '600' }
|
||||
]}>
|
||||
Jahr
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Modellnutzung */}
|
||||
{modelUsage.length > 0 ? (
|
||||
<View style={styles.modelUsageContainer}>
|
||||
<Text style={[styles.usageSubtitle, { color: colors.text }]}>
|
||||
Modelle
|
||||
</Text>
|
||||
|
||||
{modelUsage.map((model, index) => (
|
||||
<View
|
||||
key={model.model_id}
|
||||
style={[
|
||||
styles.modelUsageItem,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border
|
||||
},
|
||||
index === modelUsage.length - 1 && { marginBottom: 0 }
|
||||
]}
|
||||
>
|
||||
<View style={styles.modelUsageHeader}>
|
||||
<Text style={[styles.modelName, { color: colors.text }]}>
|
||||
{model.model_name}
|
||||
</Text>
|
||||
<Text style={[styles.modelCost, { color: colors.primary }]}>
|
||||
${model.total_cost.toFixed(4)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.modelUsageDetails}>
|
||||
<View style={styles.tokenItem}>
|
||||
<Text style={[styles.tokenCount, { color: colors.text }]}>
|
||||
{formatTokens(model.total_prompt_tokens)}
|
||||
</Text>
|
||||
<Text style={[styles.tokenLabel, { color: colors.text + '70' }]}>
|
||||
Prompt
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tokenItem}>
|
||||
<Text style={[styles.tokenCount, { color: colors.text }]}>
|
||||
{formatTokens(model.total_completion_tokens)}
|
||||
</Text>
|
||||
<Text style={[styles.tokenLabel, { color: colors.text + '70' }]}>
|
||||
Completion
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tokenItem}>
|
||||
<Text style={[styles.tokenCount, { color: colors.text }]}>
|
||||
{formatTokens(model.total_tokens)}
|
||||
</Text>
|
||||
<Text style={[styles.tokenLabel, { color: colors.text + '70' }]}>
|
||||
Gesamt
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Modellnutzung vorhanden
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Nutzung nach Zeitraum */}
|
||||
{periodUsage.length > 0 ? (
|
||||
<View style={styles.periodUsageContainer}>
|
||||
<Text style={[styles.usageSubtitle, { color: colors.text }]}>
|
||||
Nutzung nach {
|
||||
selectedPeriod === 'day' ? 'Tagen' :
|
||||
selectedPeriod === 'month' ? 'Monaten' : 'Jahren'
|
||||
}
|
||||
</Text>
|
||||
|
||||
{periodUsage.slice(0, 5).map((period, index) => (
|
||||
<View
|
||||
key={period.time_period}
|
||||
style={[
|
||||
styles.periodUsageItem,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.periodLabel, { color: colors.text }]}>
|
||||
{period.time_period}
|
||||
</Text>
|
||||
<View style={styles.periodUsageContent}>
|
||||
<Text style={[styles.periodTokens, { color: colors.text + 'CC' }]}>
|
||||
{formatTokens(period.total_tokens)} Tokens
|
||||
</Text>
|
||||
<Text style={[styles.periodCost, { color: colors.primary }]}>
|
||||
${period.total_cost.toFixed(4)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{periodUsage.length > 5 && (
|
||||
<TouchableOpacity style={[styles.viewMoreButton, { borderColor: colors.border }]}>
|
||||
<Text style={[styles.viewMoreText, { color: colors.primary }]}>
|
||||
Mehr anzeigen...
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Nutzungsdaten für diesen Zeitraum
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Nutzungsdaten verfügbar
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.settingsSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Einstellungen</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.settingItem, { borderBottomColor: colors.border }]}
|
||||
onPress={toggleTheme}
|
||||
>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<Ionicons
|
||||
name={isDarkMode ? "moon" : "sunny"}
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={[styles.settingTitle, { color: colors.text }]}>
|
||||
Erscheinungsbild
|
||||
</Text>
|
||||
<Text style={[styles.settingValue, { color: colors.text + '80' }]}>
|
||||
{isDarkMode ? 'Dunkel' : 'Hell'}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.settingItem, { borderBottomColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<Ionicons name="notifications" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={[styles.settingTitle, { color: colors.text }]}>
|
||||
Benachrichtigungen
|
||||
</Text>
|
||||
<Text style={[styles.settingValue, { color: colors.text + '80' }]}>
|
||||
Ein
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.accountSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Konto</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.settingItem, { borderBottomColor: colors.border }]}
|
||||
>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<Ionicons name="shield-checkmark" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={[styles.settingTitle, { color: colors.text }]}>
|
||||
Passwort ändern
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.settingItem, { borderBottomColor: colors.border }]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<Ionicons name="log-out" size={24} color="#FF3B30" />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text style={[styles.settingTitle, { color: '#FF3B30' }]}>
|
||||
Abmelden
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.appInfo}>
|
||||
<Text style={[styles.versionText, { color: colors.text + '60' }]}>
|
||||
Version 1.0.0
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
},
|
||||
header: {
|
||||
marginTop: 20,
|
||||
marginBottom: 30,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
profileSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 30,
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 35,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
userName: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 14,
|
||||
},
|
||||
// Nutzungsstatistik-Stile
|
||||
usageSection: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
usageSummaryCard: {
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 2,
|
||||
},
|
||||
web: {
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
usageSummaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
},
|
||||
usageSummaryItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
usageSummaryDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: '#E5E5EA',
|
||||
marginHorizontal: 10,
|
||||
},
|
||||
usageSummaryValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 6,
|
||||
},
|
||||
usageSummaryLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
periodSelector: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
periodButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
marginHorizontal: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5EA',
|
||||
},
|
||||
periodButtonText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
modelUsageContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
usageSubtitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 10,
|
||||
},
|
||||
modelUsageItem: {
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
modelUsageHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modelCost: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modelUsageDetails: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
tokenItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
tokenCount: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tokenLabel: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
periodUsageContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
periodUsageItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
periodLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
periodUsageContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
periodTokens: {
|
||||
fontSize: 14,
|
||||
marginRight: 10,
|
||||
},
|
||||
periodCost: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
viewMoreButton: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
viewMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
// Bestehende Stile
|
||||
settingsSection: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
accountSection: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 40,
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
settingValue: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
appInfo: {
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
634
apps/chat/apps/mobile/app/spaces/[id]/index.tsx
Normal file
634
apps/chat/apps/mobile/app/spaces/[id]/index.tsx
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, FlatList, Pressable, Platform } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useLocalSearchParams, useRouter, useFocusEffect } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../../context/AuthProvider';
|
||||
import { getSpace, getSpaceMembers, getUserRoleInSpace, Space, SpaceMember } from '../../../services/space';
|
||||
import { getConversations, Conversation } from '../../../services/conversation';
|
||||
|
||||
export default function SpaceDetailScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { id } = useLocalSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [space, setSpace] = useState<Space | null>(null);
|
||||
const [members, setMembers] = useState<SpaceMember[]>([]);
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [userRole, setUserRole] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'conversations' | 'members'>('conversations');
|
||||
|
||||
// Lade Space-Details, Mitglieder und Konversationen
|
||||
const loadSpaceData = useCallback(async () => {
|
||||
if (!user || !id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Parallele Anfragen für bessere Performance
|
||||
const [spaceData, membersData, roleData] = await Promise.all([
|
||||
getSpace(id as string),
|
||||
getSpaceMembers(id as string),
|
||||
getUserRoleInSpace(id as string, user.id)
|
||||
]);
|
||||
|
||||
if (spaceData) {
|
||||
setSpace(spaceData);
|
||||
|
||||
// Lade Konversationen nur, wenn der Space gefunden wurde
|
||||
const spaceConversations = await getConversations(user.id, spaceData.id);
|
||||
setConversations(spaceConversations.filter(c => c.space_id === spaceData.id));
|
||||
} else {
|
||||
console.error('Space nicht gefunden');
|
||||
Alert.alert('Fehler', 'Der Space konnte nicht gefunden werden.');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
setMembers(membersData);
|
||||
setUserRole(roleData);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Space-Daten:', error);
|
||||
Alert.alert('Fehler', 'Die Space-Daten konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, id, router]);
|
||||
|
||||
// Lade Daten beim ersten Rendern
|
||||
useEffect(() => {
|
||||
loadSpaceData();
|
||||
}, [loadSpaceData]);
|
||||
|
||||
// Lade Daten erneut, wenn der Screen fokussiert wird
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadSpaceData();
|
||||
return () => {};
|
||||
}, [loadSpaceData])
|
||||
);
|
||||
|
||||
// Zu einer Konversation navigieren
|
||||
const handleConversationPress = (conversationId: string) => {
|
||||
router.push(`/conversation/${conversationId}`);
|
||||
};
|
||||
|
||||
// Neue Konversation in diesem Space starten
|
||||
const handleNewConversation = () => {
|
||||
if (!space) return;
|
||||
|
||||
router.push({
|
||||
pathname: '/model-selection',
|
||||
params: { spaceId: space.id }
|
||||
});
|
||||
};
|
||||
|
||||
// Neues Mitglied einladen
|
||||
const handleInviteMember = () => {
|
||||
if (!space) return;
|
||||
|
||||
router.push(`/spaces/${space.id}/invite`);
|
||||
};
|
||||
|
||||
// Mitgliederliste rendern
|
||||
const renderMemberItem = ({ item }: { item: SpaceMember }) => {
|
||||
const isOwner = item.role === 'owner';
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.memberItem,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border
|
||||
}
|
||||
]}>
|
||||
<View style={[styles.memberAvatar, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.memberInitial}>
|
||||
{item.user_id.substring(0, 1).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.memberContent}>
|
||||
<Text style={[styles.memberUserId, { color: colors.text }]}>
|
||||
{item.user_id.substring(0, 8)}...
|
||||
</Text>
|
||||
|
||||
<View style={styles.memberMeta}>
|
||||
<View style={[
|
||||
styles.roleBadge,
|
||||
{
|
||||
backgroundColor: isOwner
|
||||
? colors.primary + '20'
|
||||
: item.role === 'admin'
|
||||
? colors.notification + '20'
|
||||
: colors.border + '80'
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.roleBadgeText,
|
||||
{
|
||||
color: isOwner
|
||||
? colors.primary
|
||||
: item.role === 'admin'
|
||||
? colors.notification
|
||||
: colors.text + '80'
|
||||
}
|
||||
]}>
|
||||
{isOwner ? 'Besitzer' :
|
||||
item.role === 'admin' ? 'Admin' :
|
||||
item.role === 'member' ? 'Mitglied' : 'Zuschauer'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.joinedDate, { color: colors.text + '70' }]}>
|
||||
{item.joined_at
|
||||
? `Beigetreten: ${new Date(item.joined_at).toLocaleDateString()}`
|
||||
: item.invitation_status === 'pending'
|
||||
? 'Einladung ausstehend'
|
||||
: 'Status: ' + item.invitation_status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Konversationsliste rendern
|
||||
const renderConversationItem = ({ item }: { item: Conversation }) => {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.conversationItem,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border
|
||||
},
|
||||
hovered && { backgroundColor: colors.cardHover },
|
||||
pressed && { opacity: 0.9 }
|
||||
]}
|
||||
onPress={() => handleConversationPress(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<View style={styles.conversationIcon}>
|
||||
<Ionicons name="chatbubble-ellipses-outline" size={24} color={colors.primary} />
|
||||
</View>
|
||||
|
||||
<View style={styles.conversationContent}>
|
||||
<Text style={[styles.conversationTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.title || 'Unbenannte Konversation'}
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.conversationDate, { color: colors.text + '70' }]}>
|
||||
{new Date(item.updated_at).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.text + '50'} />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Space wird geladen...
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!space) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle-outline" size={64} color={colors.text + '50'} />
|
||||
<Text style={[styles.errorText, { color: colors.text }]}>
|
||||
Space nicht gefunden
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.backToSpacesButton, { backgroundColor: colors.primary }]}
|
||||
onPress={() => router.push('/spaces')}
|
||||
>
|
||||
<Text style={styles.backToSpacesText}>Zurück zu Spaces</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
{space.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Space-Info Card */}
|
||||
<View style={[styles.spaceInfoCard, {
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border
|
||||
}]}>
|
||||
<View style={styles.spaceInfoHeader}>
|
||||
<View style={styles.spaceInfoTitleRow}>
|
||||
<Ionicons name="people" size={24} color={colors.primary} style={styles.spaceInfoIcon} />
|
||||
<View style={styles.spaceInfoTitleContainer}>
|
||||
<Text style={[styles.spaceInfoTitle, { color: colors.text }]}>{space.name}</Text>
|
||||
<Text style={[styles.spaceInfoSubtitle, { color: colors.text + '70' }]}>
|
||||
{userRole === 'owner' ? 'Du bist Besitzer' :
|
||||
userRole === 'admin' ? 'Du bist Admin' :
|
||||
userRole === 'member' ? 'Du bist Mitglied' : 'Du bist Zuschauer'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{(userRole === 'owner' || userRole === 'admin') && (
|
||||
<TouchableOpacity
|
||||
style={[styles.editButton, { backgroundColor: colors.primary + '20' }]}
|
||||
onPress={() => router.push(`/spaces/${space.id}/settings`)}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={18} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{space.description && (
|
||||
<Text style={[styles.spaceInfoDescription, { color: colors.text + '90' }]}>
|
||||
{space.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.spaceInfoDetails}>
|
||||
<View style={styles.spaceInfoDetail}>
|
||||
<Ionicons name="people-outline" size={16} color={colors.text + '70'} />
|
||||
<Text style={[styles.spaceInfoDetailText, { color: colors.text + '70' }]}>
|
||||
{members.length} Mitglieder
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.spaceInfoDetail}>
|
||||
<Ionicons name="calendar-outline" size={16} color={colors.text + '70'} />
|
||||
<Text style={[styles.spaceInfoDetailText, { color: colors.text + '70' }]}>
|
||||
Erstellt: {new Date(space.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tabs */}
|
||||
<View style={[styles.tabContainer, { borderBottomColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === 'conversations' && { borderBottomColor: colors.primary, borderBottomWidth: 2 }
|
||||
]}
|
||||
onPress={() => setActiveTab('conversations')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.tabButtonText,
|
||||
{ color: activeTab === 'conversations' ? colors.primary : colors.text + '70' }
|
||||
]}>
|
||||
Konversationen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.tabButton,
|
||||
activeTab === 'members' && { borderBottomColor: colors.primary, borderBottomWidth: 2 }
|
||||
]}
|
||||
onPress={() => setActiveTab('members')}
|
||||
>
|
||||
<Text style={[
|
||||
styles.tabButtonText,
|
||||
{ color: activeTab === 'members' ? colors.primary : colors.text + '70' }
|
||||
]}>
|
||||
Mitglieder
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab-Inhalte */}
|
||||
{activeTab === 'conversations' ? (
|
||||
<View style={styles.tabContent}>
|
||||
<TouchableOpacity
|
||||
style={[styles.newButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleNewConversation}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="white" />
|
||||
<Text style={styles.newButtonText}>Neue Konversation</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{conversations.length > 0 ? (
|
||||
<FlatList
|
||||
data={conversations}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderConversationItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="chatbubbles-outline" size={60} color={colors.text + '30'} />
|
||||
<Text style={[styles.emptyTitle, { color: colors.text }]}>
|
||||
Keine Konversationen
|
||||
</Text>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '70' }]}>
|
||||
Starte eine neue Konversation in diesem Space
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.tabContent}>
|
||||
{(userRole === 'owner' || userRole === 'admin') && (
|
||||
<TouchableOpacity
|
||||
style={[styles.newButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleInviteMember}
|
||||
>
|
||||
<Ionicons name="person-add" size={20} color="white" />
|
||||
<Text style={styles.newButtonText}>Mitglied einladen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{members.length > 0 ? (
|
||||
<FlatList
|
||||
data={members}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderMemberItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="people-outline" size={60} color={colors.text + '30'} />
|
||||
<Text style={[styles.emptyTitle, { color: colors.text }]}>
|
||||
Keine Mitglieder
|
||||
</Text>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '70' }]}>
|
||||
Lade Mitglieder zu diesem Space ein
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginVertical: 16,
|
||||
},
|
||||
backToSpacesButton: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
marginTop: 20,
|
||||
},
|
||||
backToSpacesText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
spaceInfoCard: {
|
||||
margin: 16,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 3,
|
||||
},
|
||||
web: {
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
spaceInfoHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
spaceInfoTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
spaceInfoIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
spaceInfoTitleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
spaceInfoTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
spaceInfoSubtitle: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
editButton: {
|
||||
padding: 8,
|
||||
borderRadius: 20,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
spaceInfoDescription: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
marginBottom: 16,
|
||||
},
|
||||
spaceInfoDetails: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
spaceInfoDetail: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
spaceInfoDetailText: {
|
||||
fontSize: 13,
|
||||
marginLeft: 6,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
tabButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tabContent: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
newButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
},
|
||||
newButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
conversationItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
conversationIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
conversationContent: {
|
||||
flex: 1,
|
||||
},
|
||||
conversationTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
conversationDate: {
|
||||
fontSize: 13,
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
marginBottom: 10,
|
||||
},
|
||||
memberAvatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
memberInitial: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
memberContent: {
|
||||
flex: 1,
|
||||
},
|
||||
memberUserId: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
memberMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
roleBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
marginRight: 8,
|
||||
},
|
||||
roleBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
joinedDate: {
|
||||
fontSize: 12,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 40,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
503
apps/chat/apps/mobile/app/spaces/index.tsx
Normal file
503
apps/chat/apps/mobile/app/spaces/index.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, SafeAreaView, Alert, ActivityIndicator, Pressable, Platform } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthProvider';
|
||||
import { getUserSpaces, Space, deleteSpace } from '../../services/space';
|
||||
|
||||
export default function SpaceListScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expandedSpaceId, setExpandedSpaceId] = useState<string | null>(null);
|
||||
|
||||
// Laden der Spaces beim ersten Rendern und wenn der Screen fokussiert wird
|
||||
const loadSpaces = useCallback(async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("Lade Spaces für User:", user.id);
|
||||
const userSpaces = await getUserSpaces(user.id);
|
||||
console.log(`${userSpaces.length} Spaces geladen`);
|
||||
setSpaces(userSpaces);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Spaces:', error);
|
||||
Alert.alert('Fehler', 'Die Spaces konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Lade Spaces beim ersten Rendern
|
||||
useEffect(() => {
|
||||
loadSpaces();
|
||||
}, [loadSpaces]);
|
||||
|
||||
// Lade Spaces erneut, wenn der Screen fokussiert wird
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadSpaces();
|
||||
return () => {};
|
||||
}, [loadSpaces])
|
||||
);
|
||||
|
||||
// Erstellen eines neuen Spaces
|
||||
const handleCreateSpace = () => {
|
||||
router.push('/spaces/new');
|
||||
};
|
||||
|
||||
// Zu einem Space navigieren
|
||||
const handleSpacePress = (id: string) => {
|
||||
router.push(`/spaces/${id}`);
|
||||
};
|
||||
|
||||
// Toggle-Funktion für das Optionsmenü
|
||||
const toggleOptionsMenu = (id: string) => {
|
||||
setExpandedSpaceId(expandedSpaceId === id ? null : id);
|
||||
};
|
||||
|
||||
// Einen Space verlassen
|
||||
const handleLeaveSpace = async (id: string) => {
|
||||
Alert.alert(
|
||||
"Space verlassen",
|
||||
"Möchtest du diesen Space wirklich verlassen?",
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Verlassen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
// Diese Funktion würde einen Benutzer aus einem Space entfernen
|
||||
// TODO: removeMember(id, user.id); implementieren
|
||||
Alert.alert("Info", "Diese Funktion ist noch nicht implementiert.");
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Einen Space löschen (nur für Besitzer)
|
||||
const handleDeleteSpace = async (id: string) => {
|
||||
Alert.alert(
|
||||
"Space löschen",
|
||||
"Möchtest du diesen Space wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
const success = await deleteSpace(id);
|
||||
|
||||
if (success) {
|
||||
// Aus der lokalen Liste entfernen
|
||||
setSpaces(prev => prev.filter(space => space.id !== id));
|
||||
Alert.alert("Erfolg", "Der Space wurde gelöscht.");
|
||||
} else {
|
||||
Alert.alert("Fehler", "Der Space konnte nicht gelöscht werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Spaces:', error);
|
||||
Alert.alert("Fehler", "Der Space konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpaceItem = ({ item }: { item: Space }) => {
|
||||
const showOptions = expandedSpaceId === item.id;
|
||||
const isOwner = item.owner_id === user?.id;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.spaceItemWrapper,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
}
|
||||
]}>
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.spaceItem,
|
||||
hovered && { backgroundColor: colors.cardHover },
|
||||
pressed && { opacity: 0.9 }
|
||||
]}
|
||||
onPress={() => handleSpacePress(item.id)}
|
||||
onLongPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<View style={styles.spaceContent}>
|
||||
<View style={styles.spaceHeader}>
|
||||
<View style={styles.titleRow}>
|
||||
<Ionicons
|
||||
name="people-outline"
|
||||
size={18}
|
||||
color={colors.primary}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{isOwner && (
|
||||
<View style={[styles.ownerBadge, { backgroundColor: colors.primary + '20' }]}>
|
||||
<Text style={[styles.ownerBadgeText, { color: colors.primary }]}>
|
||||
Besitzer
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.description && (
|
||||
<Text
|
||||
style={[styles.description, { color: colors.text + 'CC' }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
<Text style={[styles.timestamp, { color: colors.text + '80' }]}>
|
||||
Erstellt: {new Date(item.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionsButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.7 }
|
||||
]}
|
||||
onPress={() => toggleOptionsMenu(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<Ionicons
|
||||
name="ellipsis-vertical"
|
||||
size={20}
|
||||
color={colors.text + '80'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{showOptions && (
|
||||
<View style={[styles.optionsContainer, {
|
||||
backgroundColor: colors.card,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border
|
||||
}]}>
|
||||
{isOwner && (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => router.push(`/spaces/${item.id}/settings`)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="settings-outline" size={18} color={colors.text} />
|
||||
<Text style={[styles.optionText, { color: colors.text }]}>Einstellungen</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.menuItemHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => router.push(`/spaces/${item.id}/invite`)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="person-add-outline" size={18} color={colors.text} />
|
||||
<Text style={[styles.optionText, { color: colors.text }]}>Einladen</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{isOwner ? (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.dangerHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => handleDeleteSpace(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Löschen</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.optionButton,
|
||||
hovered && { backgroundColor: colors.dangerHover },
|
||||
pressed && { opacity: 0.8 }
|
||||
]}
|
||||
onPress={() => handleLeaveSpace(item.id)}
|
||||
>
|
||||
{({ pressed, hovered }) => (
|
||||
<>
|
||||
<Ionicons name="exit-outline" size={18} color="#FF3B30" />
|
||||
<Text style={[styles.optionText, { color: "#FF3B30" }]}>Verlassen</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.text }]}>Spaces</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
{/* Create new space button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.createSpaceButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleCreateSpace}
|
||||
>
|
||||
<Ionicons name="add" size={24} color="white" />
|
||||
<Text style={styles.createSpaceText}>Neuen Space erstellen</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Space list */}
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Spaces werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : spaces.length > 0 ? (
|
||||
<FlatList
|
||||
data={spaces}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderSpaceItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name="people-outline"
|
||||
size={64}
|
||||
color={colors.text + '40'}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Spaces gefunden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
|
||||
Erstelle einen neuen Space oder frage nach einer Einladung
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
createSpaceButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
createSpaceText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 16,
|
||||
},
|
||||
spaceItemWrapper: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {
|
||||
elevation: 3,
|
||||
},
|
||||
web: {
|
||||
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}),
|
||||
},
|
||||
spaceItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
},
|
||||
spaceContent: {
|
||||
flex: 1,
|
||||
},
|
||||
spaceHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
titleIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
},
|
||||
ownerBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
ownerBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
lineHeight: 20,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
},
|
||||
optionsButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
optionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 8,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
marginLeft: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 14,
|
||||
marginLeft: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
214
apps/chat/apps/mobile/app/spaces/new.tsx
Normal file
214
apps/chat/apps/mobile/app/spaces/new.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput, SafeAreaView, Alert, ActivityIndicator, ScrollView } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../../context/AuthProvider';
|
||||
import { createSpace } from '../../services/space';
|
||||
|
||||
export default function NewSpaceScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Validieren der Eingaben
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
// Erstellen eines neuen Spaces
|
||||
const handleCreateSpace = async () => {
|
||||
if (!isValid || !user) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const spaceId = await createSpace(user.id, name.trim(), description.trim() || undefined);
|
||||
|
||||
if (spaceId) {
|
||||
// Navigation zum neuen Space
|
||||
Alert.alert("Erfolg", "Space wurde erfolgreich erstellt.", [
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.push(`/spaces/${spaceId}`)
|
||||
}
|
||||
]);
|
||||
} else {
|
||||
Alert.alert("Fehler", "Der Space konnte nicht erstellt werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Spaces:', error);
|
||||
Alert.alert("Fehler", "Der Space konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.text }]}>Neuen Space erstellen</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.contentContainer} contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.formSection}>
|
||||
<Text style={[styles.label, { color: colors.text }]}>Name *</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
color: colors.text
|
||||
}
|
||||
]}
|
||||
placeholder="Name des Spaces"
|
||||
placeholderTextColor={colors.text + '70'}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
maxLength={50}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: colors.text, marginTop: 20 }]}>Beschreibung</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textArea,
|
||||
{
|
||||
backgroundColor: colors.card,
|
||||
borderColor: colors.border,
|
||||
color: colors.text
|
||||
}
|
||||
]}
|
||||
placeholder="Beschreibung des Spaces (optional)"
|
||||
placeholderTextColor={colors.text + '70'}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
maxLength={500}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<Ionicons name="information-circle-outline" size={20} color={colors.text + '80'} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: colors.text + '80' }]}>
|
||||
Spaces sind Bereiche zum Organisieren von Konversationen und können mit anderen Nutzern geteilt werden.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.footer, { borderTopColor: colors.border }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.createButton,
|
||||
{
|
||||
backgroundColor: isValid ? colors.primary : colors.primary + '50',
|
||||
opacity: isCreating ? 0.7 : 1
|
||||
}
|
||||
]}
|
||||
onPress={handleCreateSpace}
|
||||
disabled={!isValid || isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Text style={styles.createButtonText}>Space erstellen</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
formSection: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
minHeight: 120,
|
||||
},
|
||||
infoSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
infoItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoIcon: {
|
||||
marginRight: 8,
|
||||
marginTop: 2,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
createButton: {
|
||||
paddingVertical: 14,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
createButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
435
apps/chat/apps/mobile/app/templates.tsx
Normal file
435
apps/chat/apps/mobile/app/templates.tsx
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
Modal,
|
||||
ActivityIndicator
|
||||
} from 'react-native';
|
||||
import { useTheme, useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthProvider';
|
||||
import { useAppTheme } from '../theme/ThemeProvider';
|
||||
|
||||
import TemplateCard from '../components/TemplateCard';
|
||||
import TemplateForm from '../components/TemplateForm';
|
||||
import CustomDrawer from '../components/CustomDrawer';
|
||||
import {
|
||||
Template,
|
||||
getTemplates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
setDefaultTemplate
|
||||
} from '../services/template';
|
||||
|
||||
export default function TemplatesScreen() {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { isDarkMode } = useAppTheme();
|
||||
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFormModalVisible, setIsFormModalVisible] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
// Lade die Vorlagen
|
||||
const loadTemplates = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const userTemplates = await getTemplates(user.id);
|
||||
setTemplates(userTemplates);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', error);
|
||||
Alert.alert('Fehler', 'Die Vorlagen konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Lade Vorlagen beim ersten Laden und wenn der Benutzer sich ändert
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [user]);
|
||||
|
||||
// Lade Vorlagen erneut, wenn der Screen fokussiert wird
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (user) loadTemplates();
|
||||
return () => {};
|
||||
}, [user])
|
||||
);
|
||||
|
||||
// Öffne das Formular zum Erstellen einer neuen Vorlage
|
||||
const handleCreateTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setIsFormModalVisible(true);
|
||||
};
|
||||
|
||||
// Öffne das Formular zum Bearbeiten einer Vorlage
|
||||
const handleEditTemplate = (id: string) => {
|
||||
const template = templates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setSelectedTemplate(template);
|
||||
setIsFormModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Lösche eine Vorlage nach Bestätigung
|
||||
const handleDeleteTemplate = (id: string) => {
|
||||
Alert.alert(
|
||||
"Vorlage löschen",
|
||||
"Möchtest du diese Vorlage wirklich löschen?",
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
style: "cancel"
|
||||
},
|
||||
{
|
||||
text: "Löschen",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
const success = await deleteTemplate(id);
|
||||
if (success) {
|
||||
setTemplates(prev => prev.filter(t => t.id !== id));
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Vorlage konnte nicht gelöscht werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen der Vorlage:', error);
|
||||
Alert.alert("Fehler", "Die Vorlage konnte nicht gelöscht werden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Setze eine Vorlage als Standard
|
||||
const handleSetDefaultTemplate = async (id: string) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const success = await setDefaultTemplate(id, user.id);
|
||||
if (success) {
|
||||
// Aktualisiere den lokalen Zustand, um die Änderungen anzuzeigen
|
||||
setTemplates(prev =>
|
||||
prev.map(t => ({
|
||||
...t,
|
||||
is_default: t.id === id
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Standardvorlage konnte nicht gesetzt werden.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Setzen der Standardvorlage:', error);
|
||||
Alert.alert("Fehler", "Die Standardvorlage konnte nicht gesetzt werden.");
|
||||
}
|
||||
};
|
||||
|
||||
// Speichert eine neue oder bearbeitete Vorlage
|
||||
const handleSubmitTemplate = async (templateData: Partial<Template>) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Prüfe, ob wir eine bestehende Vorlage bearbeiten oder eine neue erstellen
|
||||
if (templateData.id) {
|
||||
// Aktualisiere eine bestehende Vorlage
|
||||
const success = await updateTemplate(templateData.id, {
|
||||
name: templateData.name,
|
||||
description: templateData.description,
|
||||
system_prompt: templateData.system_prompt,
|
||||
initial_question: templateData.initial_question,
|
||||
color: templateData.color,
|
||||
model_id: templateData.model_id,
|
||||
document_mode: templateData.document_mode
|
||||
});
|
||||
|
||||
if (success) {
|
||||
setTemplates(prev =>
|
||||
prev.map(t =>
|
||||
t.id === templateData.id
|
||||
? { ...t, ...templateData }
|
||||
: t
|
||||
)
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Vorlage konnte nicht aktualisiert werden.");
|
||||
}
|
||||
} else {
|
||||
// Erstelle eine neue Vorlage
|
||||
const newTemplate = await createTemplate({
|
||||
user_id: user.id,
|
||||
name: templateData.name!,
|
||||
description: templateData.description,
|
||||
system_prompt: templateData.system_prompt!,
|
||||
initial_question: templateData.initial_question,
|
||||
color: templateData.color!,
|
||||
model_id: templateData.model_id,
|
||||
is_default: false,
|
||||
document_mode: templateData.document_mode || false,
|
||||
});
|
||||
|
||||
if (newTemplate) {
|
||||
setTemplates(prev => [...prev, newTemplate]);
|
||||
} else {
|
||||
Alert.alert("Fehler", "Die Vorlage konnte nicht erstellt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// Schließe das Modal
|
||||
setIsFormModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Vorlage:', error);
|
||||
Alert.alert("Fehler", "Die Vorlage konnte nicht gespeichert werden.");
|
||||
}
|
||||
};
|
||||
|
||||
// Starte einen neuen Chat mit einer Vorlage
|
||||
const handleUseTemplate = (id: string) => {
|
||||
const template = templates.find(t => t.id === id);
|
||||
if (template) {
|
||||
// Erstelle einen neuen Chat mit dieser Vorlage
|
||||
router.push({
|
||||
pathname: '/conversation/new',
|
||||
params: {
|
||||
templateId: template.id,
|
||||
mode: 'template',
|
||||
documentMode: template.document_mode ? 'true' : 'false'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.mainLayout}>
|
||||
{/* Drawer / Seitenmenü */}
|
||||
{isDrawerOpen && (
|
||||
<View style={styles.drawerContainer}>
|
||||
<CustomDrawer
|
||||
isVisible={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hauptinhalt */}
|
||||
<View style={styles.mainContainer}>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.menuButton}
|
||||
onPress={() => setIsDrawerOpen(!isDrawerOpen)}
|
||||
>
|
||||
<Ionicons
|
||||
name="menu-outline"
|
||||
size={28}
|
||||
color={colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={[styles.title, { color: colors.text }]}>Vorlagen</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleCreateTemplate}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="white" />
|
||||
<Text style={styles.addButtonText}>Neue Vorlage</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Beschreibung */}
|
||||
<View style={styles.descriptionContainer}>
|
||||
<Text style={[styles.description, { color: colors.text + 'CC' }]}>
|
||||
Erstelle Vorlagen mit benutzerdefinierten System-Prompts für verschiedene KI-Verhaltensweisen.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Vorlagenliste */}
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.text + '80' }]}>
|
||||
Vorlagen werden geladen...
|
||||
</Text>
|
||||
</View>
|
||||
) : templates.length > 0 ? (
|
||||
<FlatList
|
||||
data={templates}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TemplateCard
|
||||
id={item.id}
|
||||
name={item.name}
|
||||
description={item.description}
|
||||
systemPrompt={item.system_prompt}
|
||||
color={item.color}
|
||||
isDefault={item.is_default}
|
||||
onPress={handleUseTemplate}
|
||||
onEdit={handleEditTemplate}
|
||||
onDelete={handleDeleteTemplate}
|
||||
onSetDefault={handleSetDefaultTemplate}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name="document-text-outline"
|
||||
size={64}
|
||||
color={colors.text + '40'}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
|
||||
Keine Vorlagen vorhanden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: colors.text + '60' }]}>
|
||||
Erstelle deine erste Vorlage, um loszulegen
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Modal für das Erstellen/Bearbeiten von Vorlagen */}
|
||||
<Modal
|
||||
visible={isFormModalVisible}
|
||||
animationType="slide"
|
||||
transparent={false}
|
||||
onRequestClose={() => setIsFormModalVisible(false)}
|
||||
>
|
||||
<SafeAreaView style={styles.modalContainer}>
|
||||
<TemplateForm
|
||||
initialData={selectedTemplate || undefined}
|
||||
onSubmit={handleSubmitTemplate}
|
||||
onCancel={() => setIsFormModalVisible(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
mainLayout: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
mainContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
drawerContainer: {
|
||||
width: 260,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
maxWidth: 1200,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
menuButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
},
|
||||
addButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
addButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: '500',
|
||||
marginLeft: 4,
|
||||
},
|
||||
descriptionContainer: {
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 16,
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 120,
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 40,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingTop: 40,
|
||||
height: 300,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
BIN
apps/chat/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/chat/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/chat/apps/mobile/assets/favicon.png
Normal file
BIN
apps/chat/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/chat/apps/mobile/assets/icon.png
Normal file
BIN
apps/chat/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/chat/apps/mobile/assets/splash.png
Normal file
BIN
apps/chat/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
12
apps/chat/apps/mobile/babel.config.js
Normal file
12
apps/chat/apps/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
plugins.push('react-native-reanimated/plugin');
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
40
apps/chat/apps/mobile/cesconfig.json
Normal file
40
apps/chat/apps/mobile/cesconfig.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"cesVersion": "2.14.1",
|
||||
"projectName": "chat",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "drawer + tabs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "supabase",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.7.0"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "24.1.0"
|
||||
}
|
||||
}
|
||||
22
apps/chat/apps/mobile/components/Button.tsx
Normal file
22
apps/chat/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
} & TouchableOpacityProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
93
apps/chat/apps/mobile/components/ChatHeader.tsx
Normal file
93
apps/chat/apps/mobile/components/ChatHeader.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTheme } from '@react-navigation/native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
|
||||
type ChatHeaderProps = {
|
||||
title?: string;
|
||||
modelName: string;
|
||||
conversationMode: string;
|
||||
onBackPress?: () => void;
|
||||
};
|
||||
|
||||
export default function ChatHeader({
|
||||
title,
|
||||
modelName,
|
||||
conversationMode,
|
||||
onBackPress
|
||||
}: ChatHeaderProps) {
|
||||
const { colors } = useTheme();
|
||||
const router = useRouter();
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>
|
||||
{title || 'Neuer Chat'}
|
||||
</Text>
|
||||
<View style={styles.subtitleContainer}>
|
||||
<Text style={[styles.modelName, { color: colors.text + '80' }]}>
|
||||
{modelName}
|
||||
</Text>
|
||||
<Text style={[styles.modeText, { color: colors.text + '80' }]}>
|
||||
{conversationMode === 'frei' ? 'Freier Modus' :
|
||||
conversationMode === 'geführt' ? 'Geführter Modus' : 'Vorlagen-Modus'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.menuButton}>
|
||||
<Ionicons name="ellipsis-vertical" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: 'rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: 1200,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
backButton: {
|
||||
padding: 4,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
},
|
||||
modeText: {
|
||||
fontSize: 13,
|
||||
marginLeft: 8,
|
||||
},
|
||||
menuButton: {
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
122
apps/chat/apps/mobile/components/ChatInput.tsx
Normal file
122
apps/chat/apps/mobile/components/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import useChatInput from '../hooks/useChatInput';
|
||||
import ModelDropdown from './ModelDropdown';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
isLoading?: boolean;
|
||||
placeholder?: string;
|
||||
showModelSelection?: boolean;
|
||||
selectedModelId?: string;
|
||||
onSelectModel?: (id: string) => void;
|
||||
showAttachments?: boolean;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
isLoading = false,
|
||||
placeholder = 'Nachricht eingeben...',
|
||||
showModelSelection = false,
|
||||
selectedModelId = '550e8400-e29b-41d4-a716-446655440000',
|
||||
onSelectModel = () => {},
|
||||
showAttachments = false,
|
||||
showSearch = false,
|
||||
}: ChatInputProps) {
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
handleSend,
|
||||
canSend,
|
||||
isDarkMode,
|
||||
} = useChatInput({
|
||||
onSend,
|
||||
isLoading,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="w-full px-4">
|
||||
<View className={`rounded-lg p-4 ${isDarkMode ? 'bg-[#2C2C2E]' : 'bg-white'}`}>
|
||||
{showModelSelection && (
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<Text className={`text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
Modell:
|
||||
</Text>
|
||||
<ModelDropdown
|
||||
selectedModelId={selectedModelId}
|
||||
onSelectModel={onSelectModel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
className={`w-full min-h-[40px] text-base rounded-lg px-4 py-2 ${
|
||||
isDarkMode
|
||||
? 'text-white bg-[#1C1C1E]'
|
||||
: 'text-black bg-gray-100'
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={isDarkMode ? '#8E8E93' : '#8E8E93'}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
multiline
|
||||
maxLength={1000}
|
||||
editable={!isLoading}
|
||||
/>
|
||||
|
||||
<View className="flex-row justify-between items-center mt-4">
|
||||
{(showAttachments || showSearch) && (
|
||||
<View className="flex-row space-x-4">
|
||||
{showAttachments && (
|
||||
<TouchableOpacity className="flex-row items-center">
|
||||
<Ionicons name="attach" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Attach</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{showSearch && (
|
||||
<TouchableOpacity className="flex-row items-center">
|
||||
<Ionicons name="search" size={20} color={isDarkMode ? '#FFFFFF' : '#000000'} />
|
||||
<Text className={`ml-1 ${isDarkMode ? 'text-white' : 'text-black'}`}>Search</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
className={`flex-row items-center px-3 py-2 rounded-full ${
|
||||
canSend ? 'bg-[#0A84FF]' : 'bg-[#0A84FF]/20'
|
||||
}`}
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-row items-center">
|
||||
<View className="h-4 w-4 mr-1">
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
</View>
|
||||
<Text className="text-white">Wird gesendet...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={18}
|
||||
color={canSend ? '#FFFFFF' : '#0A84FF'}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${canSend ? 'text-white' : 'text-[#0A84FF]'}`}
|
||||
>
|
||||
Senden
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue