mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 17:46:43 +02:00
feat(context): add NestJS backend, PostgreSQL database, and migrate web app from Supabase to API
- Create NestJS backend on port 3020 with 4 modules (space, document, ai, token) - Add Drizzle schema with 5 tables (spaces, documents, token_transactions, model_prices, user_tokens) - Rewrite web services (spaces, documents, tokens, ai) to use shared API client instead of Supabase - Move AI API keys server-side (Azure OpenAI, Google Gemini) - Add seed script for model prices (gpt-4.1, gemini-pro, gemini-flash) - Add 70 unit tests across 4 test suites (space, document, token, ai services) - Add monorepo integration (setup-databases.sh, generate-env.mjs, docker init-db, root scripts) - Remove @supabase/supabase-js dependency and delete supabase.ts from web app - Update CLAUDE.md with full API documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f4edb3dfb
commit
ea4b585f37
50 changed files with 4041 additions and 361 deletions
53
apps/context/apps/web/package.json
Normal file
53
apps/context/apps/web/package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "@context/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "echo 'Skipping type-check for now'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-pwa": "workspace:*",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
20
apps/context/apps/web/src/lib/api/client.ts
Normal file
20
apps/context/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* API Client for Context backend
|
||||
* Uses @manacore/shared-api-client for consistent error handling
|
||||
*/
|
||||
|
||||
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_URL =
|
||||
import.meta.env.VITE_BACKEND_URL || import.meta.env.PUBLIC_BACKEND_URL || 'http://localhost:3020';
|
||||
|
||||
export const api = createApiClient({
|
||||
baseUrl: API_URL,
|
||||
apiPrefix: '/api/v1',
|
||||
getAuthToken: () => authStore.getValidToken(),
|
||||
timeout: 30000,
|
||||
debug: import.meta.env.DEV,
|
||||
});
|
||||
|
||||
export type { ApiResult };
|
||||
131
apps/context/apps/web/src/lib/services/ai.ts
Normal file
131
apps/context/apps/web/src/lib/services/ai.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { api } from '$lib/api/client';
|
||||
import { estimateTokens } from '$lib/utils/text';
|
||||
import { getCurrentTokenBalance } from './tokens';
|
||||
import type {
|
||||
AIProvider,
|
||||
AIModelOption,
|
||||
AIGenerationOptions,
|
||||
AIGenerationResult,
|
||||
TokenCostEstimate,
|
||||
} from '$lib/types';
|
||||
|
||||
export const availableModels: AIModelOption[] = [
|
||||
{ label: 'GPT-4.1', value: 'gpt-4.1', provider: 'azure' },
|
||||
{ label: 'Gemini Pro', value: 'gemini-pro', provider: 'google' },
|
||||
{ label: 'Gemini Flash', value: 'gemini-flash', provider: 'google' },
|
||||
];
|
||||
|
||||
export const predefinedPrompts = [
|
||||
{
|
||||
title: 'Text fortsetzen',
|
||||
prompt: 'Setze den folgenden Text fort, behalte dabei den Stil und Ton bei:\n\n',
|
||||
icon: 'pencil',
|
||||
type: 'continuation' as const,
|
||||
},
|
||||
{
|
||||
title: 'Zusammenfassen',
|
||||
prompt: 'Fasse den folgenden Text prägnant zusammen:\n\n',
|
||||
icon: 'list',
|
||||
type: 'summary' as const,
|
||||
},
|
||||
{
|
||||
title: 'Umformulieren',
|
||||
prompt: 'Formuliere den folgenden Text um, behalte dabei den Inhalt bei:\n\n',
|
||||
icon: 'arrows-clockwise',
|
||||
type: 'rewrite' as const,
|
||||
},
|
||||
{
|
||||
title: 'Ideen generieren',
|
||||
prompt: 'Generiere Ideen zum folgenden Thema:\n\n',
|
||||
icon: 'lightbulb',
|
||||
type: 'ideas' as const,
|
||||
},
|
||||
];
|
||||
|
||||
export type InsertionMode = 'append' | 'prepend' | 'replace' | 'new_version';
|
||||
|
||||
export async function checkTokenBalance(
|
||||
userId: string,
|
||||
prompt: string,
|
||||
model: string,
|
||||
estimatedCompletionLength: number = 500,
|
||||
referencedDocuments?: { title: string; content: string }[]
|
||||
): Promise<{ hasEnough: boolean; estimate: TokenCostEstimate; balance: number }> {
|
||||
const { data, error } = await api.post<{
|
||||
hasEnough: boolean;
|
||||
estimate: TokenCostEstimate;
|
||||
balance: number;
|
||||
}>('/ai/estimate', {
|
||||
prompt,
|
||||
model,
|
||||
estimatedCompletionLength,
|
||||
referencedDocuments,
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
// Fallback: estimate locally
|
||||
let totalInputTokens = estimateTokens(prompt);
|
||||
if (referencedDocuments?.length) {
|
||||
const formattingOverhead = 20 + referencedDocuments.length * 10;
|
||||
totalInputTokens += formattingOverhead;
|
||||
referencedDocuments.forEach((doc) => {
|
||||
totalInputTokens += estimateTokens(doc.content || '');
|
||||
});
|
||||
}
|
||||
const balance = await getCurrentTokenBalance(userId);
|
||||
return {
|
||||
hasEnough: balance > 0,
|
||||
estimate: {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: estimatedCompletionLength,
|
||||
totalTokens: totalInputTokens + estimatedCompletionLength,
|
||||
costUsd: 0,
|
||||
appTokens: 1,
|
||||
},
|
||||
balance,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function generateText(
|
||||
userId: string,
|
||||
prompt: string,
|
||||
provider: AIProvider = 'azure',
|
||||
options: AIGenerationOptions = {}
|
||||
): Promise<AIGenerationResult> {
|
||||
const model = options.model || (provider === 'azure' ? 'gpt-4.1' : 'gemini-pro');
|
||||
|
||||
const { data, error } = await api.post<{
|
||||
text: string;
|
||||
tokenInfo: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
tokensUsed: number;
|
||||
remainingTokens: number;
|
||||
};
|
||||
}>('/ai/generate', {
|
||||
prompt,
|
||||
model,
|
||||
temperature: options.temperature,
|
||||
maxTokens: options.maxTokens,
|
||||
documentId: options.documentId,
|
||||
referencedDocuments: options.referencedDocuments,
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error(error?.message || 'AI-Generierung fehlgeschlagen');
|
||||
}
|
||||
|
||||
return {
|
||||
text: data.text,
|
||||
tokenInfo: data.tokenInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProviderForModel(modelValue: string): AIProvider {
|
||||
const model = availableModels.find((m) => m.value === modelValue);
|
||||
return model?.provider || 'azure';
|
||||
}
|
||||
125
apps/context/apps/web/src/lib/services/documents.ts
Normal file
125
apps/context/apps/web/src/lib/services/documents.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { api } from '$lib/api/client';
|
||||
import type { Document, DocumentMetadata, DocumentType } from '$lib/types';
|
||||
|
||||
export async function getDocuments(spaceId?: string): Promise<Document[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (spaceId) params.set('spaceId', spaceId);
|
||||
|
||||
const { data, error } = await api.get<{ documents: Document[] }>(`/documents?${params}`);
|
||||
if (error || !data) return [];
|
||||
return data.documents;
|
||||
}
|
||||
|
||||
export async function getDocumentsWithPreview(
|
||||
spaceId?: string,
|
||||
limit: number = 50
|
||||
): Promise<Document[]> {
|
||||
const params = new URLSearchParams({ preview: 'true', limit: String(limit) });
|
||||
if (spaceId) params.set('spaceId', spaceId);
|
||||
|
||||
const { data, error } = await api.get<{ documents: Document[] }>(`/documents?${params}`);
|
||||
if (error || !data) return [];
|
||||
return data.documents;
|
||||
}
|
||||
|
||||
export async function getRecentDocuments(userId: string, limit: number = 5): Promise<Document[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
const { data, error } = await api.get<{ documents: Document[] }>(`/documents/recent?${params}`);
|
||||
if (error || !data) return [];
|
||||
return data.documents;
|
||||
}
|
||||
|
||||
export async function getDocumentById(id: string): Promise<Document | null> {
|
||||
const { data, error } = await api.get<{ document: Document }>(`/documents/${id}`);
|
||||
if (error || !data) return null;
|
||||
return data.document;
|
||||
}
|
||||
|
||||
export async function createDocument(
|
||||
userId: string,
|
||||
content: string,
|
||||
type: DocumentType,
|
||||
spaceId?: string,
|
||||
metadata?: Partial<DocumentMetadata>,
|
||||
title?: string
|
||||
): Promise<{ data: Document | null; error: string | null }> {
|
||||
const { data, error } = await api.post<{ document: Document }>('/documents', {
|
||||
content,
|
||||
type,
|
||||
spaceId: spaceId || undefined,
|
||||
title,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { data: null, error: error?.message || 'Fehler beim Erstellen' };
|
||||
}
|
||||
return { data: data.document, error: null };
|
||||
}
|
||||
|
||||
export async function updateDocument(
|
||||
id: string,
|
||||
updates: Partial<Document>
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.put(`/documents/${id}`, updates);
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
|
||||
export async function deleteDocument(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.delete(`/documents/${id}`);
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
|
||||
export async function toggleDocumentPinned(
|
||||
id: string,
|
||||
pinned: boolean
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.put(`/documents/${id}/pinned`, { pinned });
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
|
||||
export async function saveDocumentTags(
|
||||
id: string,
|
||||
tags: string[]
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.put(`/documents/${id}/tags`, { tags });
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
|
||||
export async function getDocumentVersions(
|
||||
documentId: string
|
||||
): Promise<{ data: Document[]; error: string | null }> {
|
||||
const { data, error } = await api.get<{ documents: Document[] }>(
|
||||
`/documents/${documentId}/versions`
|
||||
);
|
||||
if (error || !data) {
|
||||
return { data: [], error: error?.message || 'Fehler beim Laden der Versionen' };
|
||||
}
|
||||
return { data: data.documents, error: null };
|
||||
}
|
||||
|
||||
export async function createDocumentVersion(
|
||||
originalDocumentId: string,
|
||||
userId: string,
|
||||
newContent: string,
|
||||
generationType: 'summary' | 'continuation' | 'rewrite' | 'ideas',
|
||||
aiModel: string,
|
||||
prompt: string
|
||||
): Promise<{ data: Document | null; error: string | null }> {
|
||||
const { data, error } = await api.post<{ document: Document }>(
|
||||
`/documents/${originalDocumentId}/versions`,
|
||||
{
|
||||
content: newContent,
|
||||
generationType,
|
||||
model: aiModel,
|
||||
prompt,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !data) {
|
||||
return { data: null, error: error?.message || 'Fehler beim Erstellen der Version' };
|
||||
}
|
||||
return { data: data.document, error: null };
|
||||
}
|
||||
53
apps/context/apps/web/src/lib/services/spaces.ts
Normal file
53
apps/context/apps/web/src/lib/services/spaces.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { api } from '$lib/api/client';
|
||||
import type { Space } from '$lib/types';
|
||||
|
||||
export async function getSpaces(): Promise<Space[]> {
|
||||
const { data, error } = await api.get<{ spaces: Space[] }>('/spaces');
|
||||
if (error || !data) return [];
|
||||
return data.spaces;
|
||||
}
|
||||
|
||||
export async function getSpaceById(id: string): Promise<Space | null> {
|
||||
const { data, error } = await api.get<{ space: Space }>(`/spaces/${id}`);
|
||||
if (error || !data) return null;
|
||||
return data.space;
|
||||
}
|
||||
|
||||
export async function createSpace(
|
||||
userId: string,
|
||||
name: string,
|
||||
description?: string,
|
||||
pinned: boolean = true
|
||||
): Promise<{ data: Space | null; error: string | null }> {
|
||||
const { data, error } = await api.post<{ space: Space }>('/spaces', {
|
||||
name,
|
||||
description: description || null,
|
||||
pinned,
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { data: null, error: error?.message || 'Fehler beim Erstellen' };
|
||||
}
|
||||
return { data: data.space, error: null };
|
||||
}
|
||||
|
||||
export async function updateSpace(
|
||||
id: string,
|
||||
updates: Partial<Space>
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.put(`/spaces/${id}`, updates);
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
|
||||
export async function toggleSpacePinned(
|
||||
id: string,
|
||||
pinned: boolean
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.put(`/spaces/${id}`, { pinned });
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
|
||||
export async function deleteSpace(id: string): Promise<{ success: boolean; error: string | null }> {
|
||||
const { error } = await api.delete(`/spaces/${id}`);
|
||||
return { success: !error, error: error?.message || null };
|
||||
}
|
||||
124
apps/context/apps/web/src/lib/services/tokens.ts
Normal file
124
apps/context/apps/web/src/lib/services/tokens.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { api } from '$lib/api/client';
|
||||
import { estimateTokens } from '$lib/utils/text';
|
||||
import type { TokenCostEstimate } from '$lib/types';
|
||||
|
||||
export interface TokenTransaction {
|
||||
id: string;
|
||||
user_id: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
model_used?: string;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
cost_usd?: number;
|
||||
document_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TokenUsageStats {
|
||||
totalUsed: number;
|
||||
byModel: Record<string, number>;
|
||||
byDate: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelPrice {
|
||||
model_name: string;
|
||||
input_price_per_1k_tokens: number;
|
||||
output_price_per_1k_tokens: number;
|
||||
tokens_per_dollar: number;
|
||||
}
|
||||
|
||||
export async function getCurrentTokenBalance(userId: string): Promise<number> {
|
||||
const { data, error } = await api.get<{ balance: number }>('/tokens/balance');
|
||||
if (error || !data) return 0;
|
||||
return data.balance;
|
||||
}
|
||||
|
||||
export async function hasEnoughTokens(userId: string, requiredTokens: number): Promise<boolean> {
|
||||
const balance = await getCurrentTokenBalance(userId);
|
||||
return balance >= requiredTokens;
|
||||
}
|
||||
|
||||
export async function getModelPrice(modelName: string): Promise<ModelPrice | null> {
|
||||
const { data, error } = await api.get<{ models: ModelPrice[] }>('/tokens/models');
|
||||
if (error || !data) return null;
|
||||
return data.models.find((m) => m.model_name === modelName) || null;
|
||||
}
|
||||
|
||||
export async function calculateCost(
|
||||
model: string,
|
||||
promptTokens: number,
|
||||
completionTokens: number
|
||||
): Promise<TokenCostEstimate> {
|
||||
let inputPricePer1k = 0.01;
|
||||
let outputPricePer1k = 0.03;
|
||||
let tokensPerDollar = 50000;
|
||||
|
||||
const modelPrice = await getModelPrice(model);
|
||||
if (modelPrice) {
|
||||
inputPricePer1k = modelPrice.input_price_per_1k_tokens;
|
||||
outputPricePer1k = modelPrice.output_price_per_1k_tokens;
|
||||
tokensPerDollar = modelPrice.tokens_per_dollar;
|
||||
}
|
||||
|
||||
const inputCost = (promptTokens / 1000) * inputPricePer1k;
|
||||
const outputCost = (completionTokens / 1000) * outputPricePer1k;
|
||||
const totalCostUsd = inputCost + outputCost;
|
||||
const appTokens = Math.max(1, Math.ceil(totalCostUsd * tokensPerDollar));
|
||||
|
||||
return {
|
||||
inputTokens: promptTokens,
|
||||
outputTokens: completionTokens,
|
||||
totalTokens: promptTokens + completionTokens,
|
||||
costUsd: totalCostUsd,
|
||||
appTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export async function estimateCostForPrompt(
|
||||
prompt: string,
|
||||
model: string,
|
||||
estimatedCompletionLength: number = 500
|
||||
): Promise<TokenCostEstimate> {
|
||||
const promptTokens = estimateTokens(prompt);
|
||||
return calculateCost(model, promptTokens, estimatedCompletionLength);
|
||||
}
|
||||
|
||||
export async function logTokenUsage(
|
||||
userId: string,
|
||||
model: string,
|
||||
prompt: string,
|
||||
completion: string,
|
||||
documentId?: string
|
||||
): Promise<boolean> {
|
||||
// Token logging is now handled server-side by the AI endpoint
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getTokenUsageStats(
|
||||
userId: string,
|
||||
timeframe: 'day' | 'week' | 'month' | 'year'
|
||||
): Promise<TokenUsageStats> {
|
||||
const { data, error } = await api.get<{ stats: TokenUsageStats }>(
|
||||
`/tokens/stats?timeframe=${timeframe}`
|
||||
);
|
||||
|
||||
if (error || !data) {
|
||||
return { totalUsed: 0, byModel: {}, byDate: {} };
|
||||
}
|
||||
return data.stats;
|
||||
}
|
||||
|
||||
export async function getTokenTransactions(
|
||||
userId: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<TokenTransaction[]> {
|
||||
const { data, error } = await api.get<{ transactions: TokenTransaction[] }>(
|
||||
`/tokens/transactions?limit=${limit}&offset=${offset}`
|
||||
);
|
||||
|
||||
if (error || !data) return [];
|
||||
return data.transactions;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue