Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export const routes: Routes = [
canActivate: [authGuard],
loadComponent: () => import('./features/chat/chat.component').then((m) => m.ChatComponent),
},
{
path: 'billing',
canActivate: [authGuard],
loadComponent: () => import('./pages/billing/billing').then((m) => m.BillingComponent),
},
{
path: 'projects/:projectId/pipeline',
canActivate: [authGuard],
Expand Down
57 changes: 57 additions & 0 deletions client/src/app/core/services/billing.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

export interface SubscriptionInfo {
tier: 'Free' | 'Pro' | 'Enterprise';
status: string;
currentPeriodEnd: string | null;
stripeCustomerId: string | null;
}

export interface UsageInfo {
queriesUsed: number;
queriesLimit: number;
documentsUsed: number;
documentsLimit: number;
projectsUsed: number;
projectsLimit: number;
storageBytesUsed: number;
storageBytesLimit: number;
tier: 'Free' | 'Pro' | 'Enterprise';
}

@Injectable({ providedIn: 'root' })
export class BillingService {
private http = inject(HttpClient);

readonly subscription = signal<SubscriptionInfo | null>(null);
readonly usage = signal<UsageInfo | null>(null);
readonly loading = signal(false);

async loadSubscription(): Promise<void> {
const sub = await firstValueFrom(this.http.get<SubscriptionInfo>('/api/billing/subscription'));
this.subscription.set(sub);
}

async loadUsage(): Promise<void> {
const usage = await firstValueFrom(this.http.get<UsageInfo>('/api/billing/usage'));
this.usage.set(usage);
}

async createCheckoutSession(tier: string): Promise<string> {
const res = await firstValueFrom(this.http.post<{ url: string }>('/api/billing/create-checkout-session', {
tier,
successUrl: `${window.location.origin}/billing?success=true`,
cancelUrl: `${window.location.origin}/billing?cancelled=true`,
}));
return res.url;
}

async createPortalSession(): Promise<string> {
const res = await firstValueFrom(this.http.post<{ url: string }>('/api/billing/create-portal-session', {
returnUrl: `${window.location.origin}/billing`,
}));
return res.url;
}
}
Original file line number Diff line number Diff line change
@@ -1,93 +1,58 @@
import { defaultBlocks, getBlockFields, BLOCK_ICONS, BLOCK_LABELS, BlockType } from './pipeline-block.model';
import { createDefaultBlocks, BlockType } from './pipeline-block.model';

describe('PipelineBlock Model', () => {
describe('defaultBlocks', () => {
describe('createDefaultBlocks', () => {
it('should return 5 blocks', () => {
expect(defaultBlocks().length).toBe(5);
expect(createDefaultBlocks().length).toBe(5);
});

it('should have correct order: source → chunking → embedding → retrieval → generation', () => {
const types = defaultBlocks().map(b => b.type);
const types = createDefaultBlocks().map(b => b.type);
expect(types).toEqual(['source', 'chunking', 'embedding', 'retrieval', 'generation']);
});

it('should have unique IDs', () => {
const ids = defaultBlocks().map(b => b.id);
const ids = createDefaultBlocks().map(b => b.id);
expect(new Set(ids).size).toBe(5);
});

it('should have default config values for chunking', () => {
const chunking = defaultBlocks().find(b => b.type === 'chunking')!;
const chunking = createDefaultBlocks().find(b => b.type === 'chunking')!;
expect(chunking.config['chunkSize']).toBe(512);
expect(chunking.config['chunkOverlap']).toBe(50);
expect(chunking.config['strategy']).toBe('recursive');
});

it('should have default config values for embedding', () => {
const embedding = defaultBlocks().find(b => b.type === 'embedding')!;
const embedding = createDefaultBlocks().find(b => b.type === 'embedding')!;
expect(embedding.config['model']).toBe('text-embedding-3-small');
expect(embedding.config['dimensions']).toBe(1536);
});

it('should have default config values for generation', () => {
const gen = defaultBlocks().find(b => b.type === 'generation')!;
const gen = createDefaultBlocks().find(b => b.type === 'generation')!;
expect(gen.config['model']).toBe('gpt-4o-mini');
expect(gen.config['temperature']).toBe(0.7);
expect(gen.config['maxTokens']).toBe(2048);
});

it('should return independent copies each call', () => {
const a = defaultBlocks();
const b = defaultBlocks();
const a = createDefaultBlocks();
const b = createDefaultBlocks();
a[0].config['test'] = 'modified';
expect(b[0].config['test']).toBeUndefined();
});
});

describe('BLOCK_ICONS', () => {
it('should have icons for all 5 block types', () => {
const types: BlockType[] = ['source', 'chunking', 'embedding', 'retrieval', 'generation'];
for (const t of types) {
expect(BLOCK_ICONS[t]).toBeTruthy();
it('should have icons for all blocks', () => {
for (const block of createDefaultBlocks()) {
expect(block.icon).toBeTruthy();
}
});
});

describe('BLOCK_LABELS', () => {
it('should have labels for all block types', () => {
expect(BLOCK_LABELS['source']).toBe('Data Source');
expect(BLOCK_LABELS['generation']).toBe('Generation');
});
});

describe('getBlockFields', () => {
it('should return fields for chunking block', () => {
const fields = getBlockFields('chunking');
expect(fields.length).toBe(3);
expect(fields.map(f => f.key)).toEqual(['strategy', 'chunkSize', 'chunkOverlap']);
});

it('should return fields for embedding block', () => {
const fields = getBlockFields('embedding');
expect(fields.length).toBe(2);
expect(fields[0].key).toBe('model');
});

it('should return fields for retrieval block', () => {
const fields = getBlockFields('retrieval');
expect(fields.find(f => f.key === 'topK')?.max).toBe(20);
});

it('should return fields for generation block', () => {
const fields = getBlockFields('generation');
const modelField = fields.find(f => f.key === 'model')!;
expect(modelField.options!.length).toBeGreaterThanOrEqual(3);
});

it('should return fields for source block', () => {
const fields = getBlockFields('source');
expect(fields.length).toBe(1);
expect(fields[0].key).toBe('sourceType');
it('should have labels for all blocks', () => {
for (const block of createDefaultBlocks()) {
expect(block.label).toBeTruthy();
}
});
});
});
25 changes: 25 additions & 0 deletions client/src/app/features/pipeline-builder/pipeline-block.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type BlockType = 'source' | 'chunking' | 'embedding' | 'retrieval' | 'generation';

export interface PipelineBlock {
id: string;
type: BlockType;
label: string;
icon: string;
config: Record<string, unknown>;
}

export interface ChunkPreview {
index: number;
text: string;
tokens: number;
}

export function createDefaultBlocks(): PipelineBlock[] {
return [
{ id: crypto.randomUUID(), type: 'source', label: 'Document Source', icon: '📄', config: { sourceType: 'upload', fileTypes: '.pdf,.txt,.md,.docx' } },
{ id: crypto.randomUUID(), type: 'chunking', label: 'Text Chunking', icon: '✂️', config: { strategy: 'recursive', chunkSize: 512, chunkOverlap: 50 } },
{ id: crypto.randomUUID(), type: 'embedding', label: 'Embeddings', icon: '🧬', config: { model: 'text-embedding-3-small', dimensions: 1536, batchSize: 100 } },
{ id: crypto.randomUUID(), type: 'retrieval', label: 'Retrieval', icon: '🔍', config: { strategy: 'semantic', topK: 5, scoreThreshold: 0.7, reranking: false } },
{ id: crypto.randomUUID(), type: 'generation', label: 'Generation', icon: '🤖', config: { model: 'gpt-4o-mini', temperature: 0.7, maxTokens: 2048, systemPrompt: '' } },
];
}
Loading