This commit is contained in:
2025-09-18 15:37:00 +02:00
parent 8adb4775f8
commit 25778ab94e
9 changed files with 392 additions and 123 deletions

108
.gitignore vendored
View File

@@ -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

View File

@@ -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()

View File

@@ -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<Record<string, string>> {
try {
const { tokenManager } = await import('./token-manager')
const token = await tokenManager.getAccessToken()
return token ? { Authorization: `Bearer ${token}` } : {}
} catch {
return {}
}
}
async function request<T = any>(method: string, url: string, body?: any, extraInit?: RequestInit): Promise<T> {
try {
const headers: Record<string, string> = {
'Accept': 'application/json',
...(method !== 'GET' && method !== 'HEAD' ? { 'Content-Type': 'application/json' } : {}),
...(await getAuthHeader()),
...(extraInit?.headers as Record<string, string> | 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: <T = any>(url: string, init?: RequestInit) => request<T>('GET', url, undefined, init),
post: <T = any>(url: string, body?: any, init?: RequestInit) => request<T>('POST', url, body, init),
put: <T = any>(url: string, body?: any, init?: RequestInit) => request<T>('PUT', url, body, init),
delete: <T = any>(url: string, init?: RequestInit) => request<T>('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)
},
}

View File

@@ -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'
},
}

View File

@@ -0,0 +1,51 @@
import { tokenManager } from './token-manager'
export async function downloadFile(path: string, filename: string, params?: URLSearchParams | Record<string, string>) {
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<string, string>) {
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()
}

View File

@@ -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}`
}

View File

@@ -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<Response> {
const url = `${BACKEND_URL}${mapPath(path)}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(init?.headers as Record<string, string> | undefined),
}
return fetch(url, { ...init, headers })
}
export async function handleProxyResponse<T = any>(response: Response, defaultMessage = 'Request failed'): Promise<T> {
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
}

View File

@@ -0,0 +1,141 @@
type Listener = (...args: any[]) => void
class SimpleEmitter {
private listeners = new Map<string, Set<Listener>>()
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<typeof setTimeout> | 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<string | null> {
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<void> {
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()

View File

@@ -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))
}