diff --git a/.gitignore b/.gitignore index 1719a7d..e69de29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,108 +0,0 @@ -*.backup -backend/storage/rag_documents/* -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# Virtual environments -venv/ -env/ -ENV/ -env.bak/ -venv.bak/ -.venv/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Node.js -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.npm -.eslintcache -.next/ -.nuxt/ -out/ -dist/ - -# Environment variables -.env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Docker -*.log -docker-compose.override.yml - -# Database -*.db -*.sqlite - -# Redis -dump.rdb - -# Logs -logs/ -*.log - -# Coverage -coverage/ -.coverage -.nyc_output - -# Cache -.cache/ -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ - -# Temporary files -*.tmp -*.temp -.tmp/ - -# Security - sensitive files -backend/.config_encryption_key -*.key -*.pem -*.crt - -# Generated files -backend/performance_report.json -performance_report*.json diff --git a/backend/app/api/v1/prompt_templates.py b/backend/app/api/v1/prompt_templates.py index 2f58a65..6612149 100644 --- a/backend/app/api/v1/prompt_templates.py +++ b/backend/app/api/v1/prompt_templates.py @@ -484,7 +484,7 @@ async def seed_default_templates( select(PromptTemplate).where(PromptTemplate.type_key == type_key) ) existing_template = existing.scalar_one_or_none() - + if existing_template: # Only update if it's still the default (version 1) if existing_template.version == 1 and existing_template.is_default: @@ -494,21 +494,40 @@ async def seed_default_templates( existing_template.updated_at = datetime.utcnow() updated_templates.append(type_key) else: - # Create new template - new_template = PromptTemplate( - id=str(uuid.uuid4()), - name=template_data["name"], - type_key=type_key, - description=template_data["description"], - system_prompt=template_data["prompt"], - is_default=True, - is_active=True, - version=1, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow() + # Check if any inactive template exists with this type_key + inactive_result = await db.execute( + select(PromptTemplate) + .where(PromptTemplate.type_key == type_key) + .where(PromptTemplate.is_active == False) ) - db.add(new_template) - created_templates.append(type_key) + inactive_template = inactive_result.scalar_one_or_none() + + if inactive_template: + # Reactivate the inactive template + inactive_template.is_active = True + inactive_template.name = template_data["name"] + inactive_template.description = template_data["description"] + inactive_template.system_prompt = template_data["prompt"] + inactive_template.is_default = True + inactive_template.version = 1 + inactive_template.updated_at = datetime.utcnow() + updated_templates.append(type_key) + else: + # Create new template + new_template = PromptTemplate( + id=str(uuid.uuid4()), + name=template_data["name"], + type_key=type_key, + description=template_data["description"], + system_prompt=template_data["prompt"], + is_default=True, + is_active=True, + version=1, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + db.add(new_template) + created_templates.append(type_key) await db.commit() diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts new file mode 100644 index 0000000..126f733 --- /dev/null +++ b/frontend/src/lib/api-client.ts @@ -0,0 +1,96 @@ +export interface AppError extends Error { + code: 'UNAUTHORIZED' | 'NETWORK_ERROR' | 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'TIMEOUT' | 'UNKNOWN' + status?: number + details?: any +} + +function makeError(message: string, code: AppError['code'], status?: number, details?: any): AppError { + const err = new Error(message) as AppError + err.code = code + err.status = status + err.details = details + return err +} + +async function getAuthHeader(): Promise> { + try { + const { tokenManager } = await import('./token-manager') + const token = await tokenManager.getAccessToken() + return token ? { Authorization: `Bearer ${token}` } : {} + } catch { + return {} + } +} + +async function request(method: string, url: string, body?: any, extraInit?: RequestInit): Promise { + try { + const headers: Record = { + 'Accept': 'application/json', + ...(method !== 'GET' && method !== 'HEAD' ? { 'Content-Type': 'application/json' } : {}), + ...(await getAuthHeader()), + ...(extraInit?.headers as Record | undefined), + } + + const res = await fetch(url, { + method, + headers, + body: body != null && method !== 'GET' && method !== 'HEAD' ? JSON.stringify(body) : undefined, + ...extraInit, + }) + + if (!res.ok) { + let details: any = undefined + try { details = await res.json() } catch { details = await res.text() } + const status = res.status + if (status === 401) throw makeError('Unauthorized', 'UNAUTHORIZED', status, details) + if (status === 403) throw makeError('Forbidden', 'FORBIDDEN', status, details) + if (status === 404) throw makeError('Not found', 'NOT_FOUND', status, details) + if (status === 400) throw makeError('Validation error', 'VALIDATION_ERROR', status, details) + throw makeError('Request failed', 'UNKNOWN', status, details) + } + + const contentType = res.headers.get('content-type') || '' + if (contentType.includes('application/json')) { + return (await res.json()) as T + } + // @ts-expect-error allow non-json generic + return (await res.text()) as T + } catch (e: any) { + if (e?.code) throw e + if (e?.name === 'AbortError') throw makeError('Request timed out', 'TIMEOUT') + throw makeError(e?.message || 'Network error', 'NETWORK_ERROR') + } +} + +export const apiClient = { + get: (url: string, init?: RequestInit) => request('GET', url, undefined, init), + post: (url: string, body?: any, init?: RequestInit) => request('POST', url, body, init), + put: (url: string, body?: any, init?: RequestInit) => request('PUT', url, body, init), + delete: (url: string, init?: RequestInit) => request('DELETE', url, undefined, init), +} + +export const chatbotApi = { + async listChatbots() { + try { + return await apiClient.get('/api-internal/v1/chatbot/list') + } catch { + return await apiClient.get('/api-internal/v1/chatbot/instances') + } + }, + createChatbot(config: any) { + return apiClient.post('/api-internal/v1/chatbot/create', config) + }, + updateChatbot(id: string, config: any) { + return apiClient.put(`/api-internal/v1/chatbot/update/${encodeURIComponent(id)}`, config) + }, + deleteChatbot(id: string) { + return apiClient.delete(`/api-internal/v1/chatbot/delete/${encodeURIComponent(id)}`) + }, + sendMessage(chatbotId: string, message: string, conversationId?: string, history?: Array<{role: string; content: string}>) { + const body: any = { chatbot_id: chatbotId, message } + if (conversationId) body.conversation_id = conversationId + if (history) body.history = history + return apiClient.post('/api-internal/v1/chatbot/chat', body) + }, +} + diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts new file mode 100644 index 0000000..e194a3f --- /dev/null +++ b/frontend/src/lib/config.ts @@ -0,0 +1,15 @@ +export const config = { + getPublicApiUrl(): string { + if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_BASE_URL) { + return process.env.NEXT_PUBLIC_BASE_URL + } + if (typeof window !== 'undefined') { + return window.location.origin + } + return 'http://localhost:3000' + }, + getAppName(): string { + return process.env.NEXT_PUBLIC_APP_NAME || 'Enclava' + }, +} + diff --git a/frontend/src/lib/file-download.ts b/frontend/src/lib/file-download.ts new file mode 100644 index 0000000..137b274 --- /dev/null +++ b/frontend/src/lib/file-download.ts @@ -0,0 +1,51 @@ +import { tokenManager } from './token-manager' + +export async function downloadFile(path: string, filename: string, params?: URLSearchParams | Record) { + const url = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000') + if (params) { + const p = params instanceof URLSearchParams ? params : new URLSearchParams(params) + p.forEach((v, k) => url.searchParams.set(k, v)) + } + + const token = await tokenManager.getAccessToken() + const res = await fetch(url.toString(), { + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) + if (!res.ok) throw new Error(`Failed to download file (${res.status})`) + const blob = await res.blob() + + if (typeof window !== 'undefined') { + const link = document.createElement('a') + const href = URL.createObjectURL(blob) + link.href = href + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(href) + } +} + +export async function uploadFile(path: string, file: File, extraFields?: Record) { + const form = new FormData() + form.append('file', file) + if (extraFields) Object.entries(extraFields).forEach(([k, v]) => form.append(k, v)) + + const token = await tokenManager.getAccessToken() + const res = await fetch(path, { + method: 'POST', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: form, + }) + if (!res.ok) { + let details: any + try { details = await res.json() } catch { details = await res.text() } + throw new Error(typeof details === 'string' ? details : (details?.error || 'Upload failed')) + } + return await res.json() +} + diff --git a/frontend/src/lib/id-utils.ts b/frontend/src/lib/id-utils.ts new file mode 100644 index 0000000..d511e03 --- /dev/null +++ b/frontend/src/lib/id-utils.ts @@ -0,0 +1,16 @@ +export function generateId(prefix = "id"): string { + const rand = Math.random().toString(36).slice(2, 10) + return `${prefix}_${rand}` +} + +export function generateShortId(prefix = "id"): string { + const rand = Math.random().toString(36).slice(2, 7) + return `${prefix}_${rand}` +} + +export function generateTimestampId(prefix = "id"): string { + const ts = Date.now() + const rand = Math.floor(Math.random() * 1000).toString().padStart(3, '0') + return `${prefix}_${ts}_${rand}` +} + diff --git a/frontend/src/lib/proxy-auth.ts b/frontend/src/lib/proxy-auth.ts new file mode 100644 index 0000000..bbf7109 --- /dev/null +++ b/frontend/src/lib/proxy-auth.ts @@ -0,0 +1,31 @@ +const BACKEND_URL = process.env.INTERNAL_API_URL || `http://enclava-backend:${process.env.BACKEND_INTERNAL_PORT || '8000'}` + +function mapPath(path: string): string { + // Convert '/api-internal/..' to backend '/api/..' + if (path.startsWith('/api-internal/')) { + return path.replace('/api-internal/', '/api/') + } + return path +} + +export async function proxyRequest(path: string, init?: RequestInit): Promise { + const url = `${BACKEND_URL}${mapPath(path)}` + const headers: Record = { + 'Content-Type': 'application/json', + ...(init?.headers as Record | undefined), + } + return fetch(url, { ...init, headers }) +} + +export async function handleProxyResponse(response: Response, defaultMessage = 'Request failed'): Promise { + if (!response.ok) { + let details: any + try { details = await response.json() } catch { details = await response.text() } + throw new Error(typeof details === 'string' ? `${defaultMessage}: ${details}` : (details?.error || defaultMessage)) + } + const contentType = response.headers.get('content-type') || '' + if (contentType.includes('application/json')) return (await response.json()) as T + // @ts-ignore allow non-json + return (await response.text()) as T +} + diff --git a/frontend/src/lib/token-manager.ts b/frontend/src/lib/token-manager.ts new file mode 100644 index 0000000..b4699df --- /dev/null +++ b/frontend/src/lib/token-manager.ts @@ -0,0 +1,141 @@ +type Listener = (...args: any[]) => void + +class SimpleEmitter { + private listeners = new Map>() + + on(event: string, listener: Listener) { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()) + this.listeners.get(event)!.add(listener) + } + + off(event: string, listener: Listener) { + this.listeners.get(event)?.delete(listener) + } + + emit(event: string, ...args: any[]) { + this.listeners.get(event)?.forEach(l => l(...args)) + } +} + +interface StoredTokens { + access_token: string + refresh_token: string + access_expires_at: number // epoch ms + refresh_expires_at?: number // epoch ms +} + +const ACCESS_LIFETIME_FALLBACK_MS = 30 * 60 * 1000 // 30 minutes +const REFRESH_LIFETIME_FALLBACK_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +function now() { return Date.now() } + +function readTokens(): StoredTokens | null { + if (typeof window === 'undefined') return null + try { + const raw = window.localStorage.getItem('auth_tokens') + return raw ? JSON.parse(raw) as StoredTokens : null + } catch { + return null + } +} + +function writeTokens(tokens: StoredTokens | null) { + if (typeof window === 'undefined') return + if (tokens) { + window.localStorage.setItem('auth_tokens', JSON.stringify(tokens)) + } else { + window.localStorage.removeItem('auth_tokens') + } +} + +class TokenManager extends SimpleEmitter { + private refreshTimer: ReturnType | null = null + + isAuthenticated(): boolean { + const t = readTokens() + return !!t && t.access_expires_at > now() + } + + getTokenExpiry(): Date | null { + const t = readTokens() + return t ? new Date(t.access_expires_at) : null + } + + getRefreshTokenExpiry(): Date | null { + const t = readTokens() + return t?.refresh_expires_at ? new Date(t.refresh_expires_at) : null + } + + setTokens(accessToken: string, refreshToken: string, expiresInSeconds?: number) { + const access_expires_at = now() + (expiresInSeconds ? expiresInSeconds * 1000 : ACCESS_LIFETIME_FALLBACK_MS) + const refresh_expires_at = now() + REFRESH_LIFETIME_FALLBACK_MS + const tokens: StoredTokens = { + access_token: accessToken, + refresh_token: refreshToken, + access_expires_at, + refresh_expires_at, + } + writeTokens(tokens) + this.scheduleRefresh() + this.emit('tokensUpdated') + } + + clearTokens() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + this.refreshTimer = null + } + writeTokens(null) + this.emit('tokensCleared') + } + + logout() { + this.clearTokens() + this.emit('logout') + } + + private scheduleRefresh() { + if (typeof window === 'undefined') return + const t = readTokens() + if (!t) return + if (this.refreshTimer) clearTimeout(this.refreshTimer) + const msUntilRefresh = Math.max(5_000, t.access_expires_at - now() - 60_000) // 1 minute before expiry + this.refreshTimer = setTimeout(() => { + this.refreshAccessToken().catch(() => { + this.emit('sessionExpired', 'refresh_failed') + this.clearTokens() + }) + }, msUntilRefresh) + } + + async getAccessToken(): Promise { + const t = readTokens() + if (!t) return null + if (t.access_expires_at - now() > 10_000) return t.access_token + try { + await this.refreshAccessToken() + return readTokens()?.access_token || null + } catch { + this.emit('sessionExpired', 'expired') + this.clearTokens() + return null + } + } + + private async refreshAccessToken(): Promise { + const t = readTokens() + if (!t?.refresh_token) throw new Error('No refresh token') + const res = await fetch('/api-internal/v1/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: t.refresh_token }), + }) + if (!res.ok) throw new Error('Refresh failed') + const data = await res.json() + const expiresIn = data.expires_in as number | undefined + this.setTokens(data.access_token, data.refresh_token || t.refresh_token, expiresIn) + } +} + +export const tokenManager = new TokenManager() + diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..02aca5c --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,8 @@ +import { type ClassValue } from 'clsx' +import { clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +