add bring your own key support
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
export interface LLMGenerateRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
export interface LLMRefineRequest {
|
||||
prompt: string;
|
||||
siteId: string;
|
||||
provider: string;
|
||||
currentStructure: SiteStructure;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
references?: Reference[];
|
||||
}
|
||||
|
||||
@@ -64,3 +72,11 @@ export interface GenerationVersion {
|
||||
fullStructure: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface LLMKeyEntry {
|
||||
configured: boolean;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export type LLMKeyStatus = Record<string, LLMKeyEntry>;
|
||||
|
||||
21
frontend/src/app/core/services/llm-config.service.ts
Normal file
21
frontend/src/app/core/services/llm-config.service.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { LLMKeyStatus } from '../models/llm';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LlmConfigService {
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getStatus(): Observable<LLMKeyStatus> {
|
||||
return this.http.get<LLMKeyStatus>('/api/user/llm-config');
|
||||
}
|
||||
|
||||
saveKey(provider: string, apiKey?: string, baseUrl?: string, model?: string): Observable<void> {
|
||||
return this.http.put<void>('/api/user/llm-config', { provider, apiKey, baseUrl, model });
|
||||
}
|
||||
|
||||
deleteKey(provider: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/user/llm-config/${provider}`);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
@@ -16,8 +17,9 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { LlmApiService } from '../core/services/llm-api.service';
|
||||
import { LlmConfigService } from '../core/services/llm-config.service';
|
||||
import { LlmSessionService } from './llm-session.service';
|
||||
import { SiteStructure, GenerationVersion } from '../core/models/llm';
|
||||
import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm';
|
||||
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
|
||||
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
|
||||
|
||||
@@ -27,10 +29,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
imports: [
|
||||
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
|
||||
MatToolbarModule, MatButtonModule, MatIconModule,
|
||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule,
|
||||
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
|
||||
MatSnackBarModule, MatCardModule,
|
||||
MatDividerModule, MatExpansionModule, MatDialogModule,
|
||||
],
|
||||
styles: [`
|
||||
.provider-select { width: 140px; }
|
||||
.provider-select .mat-mdc-form-field-subscript-wrapper { display: none; }
|
||||
`],
|
||||
template: `
|
||||
<div class="h-screen flex flex-col bg-gray-50">
|
||||
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
|
||||
@@ -45,6 +51,8 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
|
||||
@@ -64,15 +72,29 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
<span class="text-xs text-gray-400">
|
||||
Describe the pages, layout, theme, and content you want.
|
||||
</span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!prompt.trim() || (session.generating$ | async)"
|
||||
(click)="generate()"
|
||||
>
|
||||
<mat-icon>auto_awesome</mat-icon>
|
||||
Generate
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
||||
<mat-label>Provider</mat-label>
|
||||
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
|
||||
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
|
||||
(click)="generate()"
|
||||
>
|
||||
<mat-icon>auto_awesome</mat-icon>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p class="text-sm text-yellow-800">
|
||||
No API keys configured. Go to <a class="underline font-medium cursor-pointer" (click)="goToSettings()">Settings</a> to add an API key for an AI provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -180,7 +202,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
|
||||
[disabled]="(session.generating$ | async) === true"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end mt-2">
|
||||
<div class="flex justify-end items-center gap-2 mt-2">
|
||||
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
||||
<mat-label>Provider</mat-label>
|
||||
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
|
||||
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="accent"
|
||||
@@ -261,6 +290,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private api = inject(LlmApiService);
|
||||
private llmConfigService = inject(LlmConfigService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
session = inject(LlmSessionService);
|
||||
@@ -270,6 +300,11 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
showRefine = false;
|
||||
siteId: string | null = null;
|
||||
versions: GenerationVersion[] = [];
|
||||
llmKeyStatus: LLMKeyStatus = {};
|
||||
llmBaseUrls: Record<string, string> = {};
|
||||
llmModels: Record<string, string> = {};
|
||||
configuredProviders: string[] = [];
|
||||
selectedProvider = '';
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -285,10 +320,36 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
this.versions = [];
|
||||
this.session.clearHistory();
|
||||
this.loadVersions();
|
||||
this.loadLlmConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadLlmConfig(): void {
|
||||
this.llmConfigService.getStatus()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.llmKeyStatus = status;
|
||||
const urls: Record<string, string> = {};
|
||||
const models: Record<string, string> = {};
|
||||
this.configuredProviders = Object.keys(status).filter(k => {
|
||||
if (status[k]?.baseUrl) urls[k] = status[k].baseUrl!;
|
||||
if (status[k]?.model) models[k] = status[k].model!;
|
||||
return status[k]?.configured;
|
||||
});
|
||||
this.llmBaseUrls = urls;
|
||||
this.llmModels = models;
|
||||
if (this.configuredProviders.length === 1) {
|
||||
this.selectedProvider = this.configuredProviders[0];
|
||||
} else if (this.configuredProviders.length > 1 && !this.selectedProvider) {
|
||||
this.selectedProvider = this.configuredProviders[0];
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
private loadVersions(): void {
|
||||
if (!this.siteId) return;
|
||||
this.session.setLoadingVersions(true);
|
||||
@@ -320,13 +381,21 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
|
||||
|
||||
generate(): void {
|
||||
if (!this.prompt.trim() || !this.siteId) return;
|
||||
if (!this.prompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
this.showRefine = false;
|
||||
this.session.setPrompt(this.prompt);
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
this.api.generate({ prompt: this.prompt, siteId: this.siteId })
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.api.generate({
|
||||
prompt: this.prompt,
|
||||
siteId: this.siteId,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
@@ -344,13 +413,18 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
|
||||
|
||||
refine(): void {
|
||||
const result = this.session.currentResult;
|
||||
if (!result || !this.refinePrompt.trim() || !this.siteId) return;
|
||||
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
|
||||
this.session.setGenerating(true);
|
||||
this.session.clearError();
|
||||
|
||||
const baseUrl = this.llmBaseUrls[this.selectedProvider];
|
||||
const model = this.llmModels[this.selectedProvider];
|
||||
this.api.refine({
|
||||
prompt: this.refinePrompt,
|
||||
siteId: this.siteId,
|
||||
provider: this.selectedProvider,
|
||||
baseUrl,
|
||||
model,
|
||||
currentStructure: result,
|
||||
})
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
|
||||
@@ -17,6 +17,8 @@ import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { SiteService } from '../../core/services/site.service';
|
||||
import { DeploymentService } from '../../core/services/deployment.service';
|
||||
import { LlmConfigService } from '../../core/services/llm-config.service';
|
||||
import { LLMKeyEntry, LLMKeyStatus } from '../../core/models/llm';
|
||||
import { SelfHostedConfigResponse } from '../../core/models/deployment';
|
||||
|
||||
@Component({
|
||||
@@ -224,6 +226,65 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment';
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-4" appearance="outlined">
|
||||
<mat-card-content class="!p-0">
|
||||
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">LLM API Keys</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
Add API keys for AI providers to enable site generation. Keys are encrypted at rest.
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div *ngFor="let provider of llmProviders" class="border rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full shrink-0"
|
||||
[class.bg-green-500]="llmKeyStatus[provider]?.configured"
|
||||
[class.bg-gray-300]="!llmKeyStatus[provider]?.configured"></span>
|
||||
<span class="text-sm font-medium capitalize">{{ provider }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button mat-stroked-button color="warn" *ngIf="llmKeyStatus[provider]?.configured"
|
||||
(click)="deleteLlmKey(provider)" class="!text-xs !min-w-0 !px-2">
|
||||
<mat-icon>delete</mat-icon>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>API Key</mat-label>
|
||||
<input matInput [type]="llmKeyVisible[provider] ? 'text' : 'password'"
|
||||
[(ngModel)]="llmNewKeys[provider]" [disabled]="llmSaving[provider]" />
|
||||
<button mat-icon-button matSuffix (click)="toggleKeyVisible(provider)"
|
||||
[attr.aria-label]="llmKeyVisible[provider] ? 'Hide key' : 'Show key'">
|
||||
<mat-icon>{{ llmKeyVisible[provider] ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full !mb-4" *ngIf="provider === 'openai'">
|
||||
<mat-label>Base URL (optional)</mat-label>
|
||||
<input matInput
|
||||
placeholder="https://api.deepseek.com/v1"
|
||||
[(ngModel)]="llmNewBaseUrls[provider]" [disabled]="llmSaving[provider]" />
|
||||
<mat-hint>For OpenAI-compatible providers like DeepSeek. Leave empty for OpenAI.</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Model (optional)</mat-label>
|
||||
<input matInput
|
||||
placeholder="{{ provider === 'gemini' ? 'gemini-3.1-flash-lite' : 'gpt-4o-mini' }}"
|
||||
[(ngModel)]="llmNewModels[provider]" [disabled]="llmSaving[provider]" />
|
||||
<mat-hint>Override the default model name for this provider.</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="flex justify-end">
|
||||
<button mat-flat-button color="primary"
|
||||
[disabled]="(!llmNewKeys[provider] || !llmNewKeys[provider].trim()) && (!llmNewBaseUrls[provider] || !llmNewBaseUrls[provider].trim()) && (!llmNewModels[provider] || !llmNewModels[provider].trim()) || llmSaving[provider]"
|
||||
(click)="saveLlmKey(provider)">
|
||||
<mat-icon>save</mat-icon>
|
||||
{{ llmSaving[provider] ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="p-4" appearance="outlined">
|
||||
<mat-card-content class="!p-0">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -249,6 +310,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private router = inject(Router);
|
||||
private siteService = inject(SiteService);
|
||||
private deploymentService = inject(DeploymentService);
|
||||
private llmConfigService = inject(LlmConfigService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
siteId: string | null = null;
|
||||
@@ -278,6 +340,14 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
shDeployedAt: string | null = null;
|
||||
shSaving = false;
|
||||
|
||||
llmProviders = ['gemini', 'openai'];
|
||||
llmKeyStatus: LLMKeyStatus = {};
|
||||
llmNewKeys: Record<string, string> = {};
|
||||
llmNewBaseUrls: Record<string, string> = {};
|
||||
llmNewModels: Record<string, string> = {};
|
||||
llmSaving: Record<string, boolean> = {};
|
||||
llmKeyVisible: Record<string, boolean> = {};
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -285,7 +355,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(params => {
|
||||
this.siteId = params.get('siteId');
|
||||
if (this.siteId) this.loadSite();
|
||||
if (this.siteId) {
|
||||
this.loadSite();
|
||||
this.loadLlmConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,6 +418,73 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private loadLlmConfig(): void {
|
||||
this.llmConfigService.getStatus()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.llmKeyStatus = status;
|
||||
for (const p of this.llmProviders) {
|
||||
if (status[p]?.baseUrl) {
|
||||
this.llmNewBaseUrls[p] = status[p].baseUrl!;
|
||||
}
|
||||
if (status[p]?.model) {
|
||||
this.llmNewModels[p] = status[p].model!;
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
saveLlmKey(provider: string): void {
|
||||
const key = this.llmNewKeys[provider]?.trim();
|
||||
const baseUrl = this.llmNewBaseUrls[provider]?.trim() || undefined;
|
||||
const model = this.llmNewModels[provider]?.trim() || undefined;
|
||||
if (!key && !baseUrl && !model) return;
|
||||
this.llmSaving[provider] = true;
|
||||
this.llmConfigService.saveKey(provider, key, baseUrl, model)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
const entry: LLMKeyEntry = { configured: true };
|
||||
if (baseUrl) entry.baseUrl = baseUrl;
|
||||
if (model) entry.model = model;
|
||||
this.llmKeyStatus[provider] = entry;
|
||||
this.llmNewKeys[provider] = '';
|
||||
this.snackBar.open(`${provider} config saved`, 'Close', { duration: 2000 });
|
||||
},
|
||||
error: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
this.snackBar.open(`Failed to save ${provider} API key`, 'Close', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deleteLlmKey(provider: string): void {
|
||||
this.llmSaving[provider] = true;
|
||||
this.llmConfigService.deleteKey(provider)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
this.llmKeyStatus[provider] = { configured: false };
|
||||
this.llmNewBaseUrls[provider] = '';
|
||||
this.llmNewModels[provider] = '';
|
||||
this.snackBar.open(`${provider} API key removed`, 'Close', { duration: 2000 });
|
||||
},
|
||||
error: () => {
|
||||
this.llmSaving[provider] = false;
|
||||
this.snackBar.open(`Failed to remove ${provider} API key`, 'Close', { duration: 3000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleKeyVisible(provider: string): void {
|
||||
this.llmKeyVisible[provider] = !this.llmKeyVisible[provider];
|
||||
}
|
||||
|
||||
onDeploymentTargetChange(): void {
|
||||
if (!this.siteId) return;
|
||||
this.saving = true;
|
||||
|
||||
Reference in New Issue
Block a user