mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 19:21:25 +02:00
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:
parent
fcf3a344b1
commit
c638a7ffee
155 changed files with 22622 additions and 348 deletions
18
chat/backend/src/app.module.ts
Normal file
18
chat/backend/src/app.module.ts
Normal 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 {}
|
||||
20
chat/backend/src/chat/chat.controller.ts
Normal file
20
chat/backend/src/chat/chat.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
chat/backend/src/chat/chat.module.ts
Normal file
10
chat/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 {}
|
||||
156
chat/backend/src/chat/chat.service.ts
Normal file
156
chat/backend/src/chat/chat.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
chat/backend/src/chat/dto/chat-completion.dto.ts
Normal file
40
chat/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;
|
||||
};
|
||||
}
|
||||
41
chat/backend/src/conversation/conversation.controller.ts
Normal file
41
chat/backend/src/conversation/conversation.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
chat/backend/src/conversation/conversation.module.ts
Normal file
10
chat/backend/src/conversation/conversation.module.ts
Normal 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 {}
|
||||
158
chat/backend/src/conversation/conversation.service.ts
Normal file
158
chat/backend/src/conversation/conversation.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
chat/backend/src/health/health.controller.ts
Normal file
13
chat/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
chat/backend/src/health/health.module.ts
Normal file
7
chat/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 {}
|
||||
36
chat/backend/src/main.ts
Normal file
36
chat/backend/src/main.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue