feat(chat): integrate chat project into monorepo with full app structure

- Restructure chat as apps/mobile, apps/web, apps/landing, backend
- Add NestJS backend for secure Azure OpenAI API calls
- Remove exposed API key from mobile app (security fix)
- Add shared chat-types package
- Create SvelteKit web app scaffold
- Create Astro landing page scaffold
- Update pnpm workspace configuration
- Add project-level CLAUDE.md documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 13:48:24 +01:00
parent fcf3a344b1
commit c638a7ffee
155 changed files with 22622 additions and 348 deletions

View file

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ChatModule } from './chat/chat.module';
import { ConversationModule } from './conversation/conversation.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ChatModule,
ConversationModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,20 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ChatService } from './chat.service';
import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto';
@Controller('chat')
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get('models')
getModels() {
return this.chatService.getAvailableModels();
}
@Post('completions')
async createCompletion(
@Body() dto: ChatCompletionDto,
): Promise<ChatCompletionResponseDto> {
return this.chatService.createCompletion(dto);
}
}

View 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 {}

View file

@ -0,0 +1,156 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChatCompletionDto, ChatCompletionResponseDto } from './dto/chat-completion.dto';
export interface AIModel {
id: string;
name: string;
description: string;
parameters: {
temperature: number;
max_tokens: number;
provider: string;
deployment: string;
endpoint: string;
api_version: string;
};
}
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
private readonly apiKey: string;
private readonly endpoint: string;
private readonly apiVersion: string;
// Available models configuration
private readonly availableModels: AIModel[] = [
{
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',
},
},
];
constructor(private configService: ConfigService) {
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!');
}
}
getAvailableModels(): AIModel[] {
return this.availableModels;
}
getModelById(modelId: string): AIModel | undefined {
return this.availableModels.find((m) => m.id === modelId);
}
async createCompletion(dto: ChatCompletionDto): Promise<ChatCompletionResponseDto> {
const model = this.getModelById(dto.modelId);
if (!model) {
throw new BadRequestException(`Model with ID ${dto.modelId} not found`);
}
const deployment = model.parameters.deployment;
const temperature = dto.temperature ?? model.parameters.temperature;
const maxTokens = dto.maxTokens ?? model.parameters.max_tokens;
// 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}`);
throw new BadRequestException(`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');
throw new BadRequestException('No response generated');
}
return {
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);
throw error;
}
}
}

View 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;
};
}

View file

@ -0,0 +1,41 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ConversationService } from './conversation.service';
@Controller('conversations')
export class ConversationController {
constructor(private readonly conversationService: ConversationService) {}
@Get()
async getConversations(@Query('userId') userId: string) {
return this.conversationService.getConversations(userId);
}
@Get(':id')
async getConversation(@Param('id') id: string) {
return this.conversationService.getConversation(id);
}
@Get(':id/messages')
async getMessages(@Param('id') id: string) {
return this.conversationService.getMessages(id);
}
@Post()
async createConversation(
@Body() body: { userId: string; modelId: string; title?: string },
) {
return this.conversationService.createConversation(
body.userId,
body.modelId,
body.title,
);
}
@Post(':id/messages')
async addMessage(
@Param('id') id: string,
@Body() body: { sender: 'user' | 'assistant' | 'system'; messageText: string },
) {
return this.conversationService.addMessage(id, body.sender, body.messageText);
}
}

View file

@ -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 {}

View file

@ -0,0 +1,158 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
export interface Conversation {
id: string;
user_id: string;
model_id: string;
title?: string;
is_archived: boolean;
created_at: string;
updated_at: string;
}
export interface Message {
id: string;
conversation_id: string;
sender: 'user' | 'assistant' | 'system';
message_text: string;
created_at: string;
}
@Injectable()
export class ConversationService {
private readonly logger = new Logger(ConversationService.name);
private supabase: SupabaseClient;
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_KEY');
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
} else {
this.logger.warn('Supabase configuration missing');
}
}
async getConversations(userId: string): Promise<Conversation[]> {
if (!this.supabase) {
this.logger.warn('Supabase not configured');
return [];
}
const { data, error } = await this.supabase
.from('conversations')
.select('*')
.eq('user_id', userId)
.eq('is_archived', false)
.order('updated_at', { ascending: false });
if (error) {
this.logger.error('Error fetching conversations', error);
throw error;
}
return data || [];
}
async getConversation(id: string): Promise<Conversation | null> {
if (!this.supabase) {
return null;
}
const { data, error } = await this.supabase
.from('conversations')
.select('*')
.eq('id', id)
.single();
if (error) {
this.logger.error('Error fetching conversation', error);
return null;
}
return data;
}
async getMessages(conversationId: string): Promise<Message[]> {
if (!this.supabase) {
return [];
}
const { data, error } = await this.supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true });
if (error) {
this.logger.error('Error fetching messages', error);
throw error;
}
return data || [];
}
async createConversation(
userId: string,
modelId: string,
title?: string,
): Promise<Conversation> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const { data, error } = await this.supabase
.from('conversations')
.insert({
user_id: userId,
model_id: modelId,
title: title || 'Neue Unterhaltung',
is_archived: false,
})
.select()
.single();
if (error) {
this.logger.error('Error creating conversation', error);
throw error;
}
return data;
}
async addMessage(
conversationId: string,
sender: 'user' | 'assistant' | 'system',
messageText: string,
): Promise<Message> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const { data, error } = await this.supabase
.from('messages')
.insert({
conversation_id: conversationId,
sender,
message_text: messageText,
})
.select()
.single();
if (error) {
this.logger.error('Error adding message', error);
throw error;
}
// Update conversation updated_at
await this.supabase
.from('conversations')
.update({ updated_at: new Date().toISOString() })
.eq('id', conversationId);
return data;
}
}

View 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',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

36
chat/backend/src/main.ts Normal file
View file

@ -0,0 +1,36 @@
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',
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// 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 || 3001;
await app.listen(port);
console.log(`Chat backend running on http://localhost:${port}`);
}
bootstrap();