mirror of
https://github.com/aljazceru/enclava.git
synced 2025-12-17 07:24:34 +01:00
fix
This commit is contained in:
108
.gitignore
vendored
108
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
96
frontend/src/lib/api-client.ts
Normal file
96
frontend/src/lib/api-client.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
|
||||
15
frontend/src/lib/config.ts
Normal file
15
frontend/src/lib/config.ts
Normal 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'
|
||||
},
|
||||
}
|
||||
|
||||
51
frontend/src/lib/file-download.ts
Normal file
51
frontend/src/lib/file-download.ts
Normal 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()
|
||||
}
|
||||
|
||||
16
frontend/src/lib/id-utils.ts
Normal file
16
frontend/src/lib/id-utils.ts
Normal 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}`
|
||||
}
|
||||
|
||||
31
frontend/src/lib/proxy-auth.ts
Normal file
31
frontend/src/lib/proxy-auth.ts
Normal 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
|
||||
}
|
||||
|
||||
141
frontend/src/lib/token-manager.ts
Normal file
141
frontend/src/lib/token-manager.ts
Normal 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()
|
||||
|
||||
8
frontend/src/lib/utils.ts
Normal file
8
frontend/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user