diff --git a/.env b/.env index 4f74a26cfe463567bfccc6da4f63233909f1c8c8..14f8c1b844f2241fddec39cf6c4c0667be17e44d 100644 --- a/.env +++ b/.env @@ -1,34 +1,34 @@ -# Flare Environment Configuration - -# JWT Configuration -JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4 -JWT_ALGORITHM=HS256 -JWT_EXPIRATION_HOURS=24 - -# Encryption Key for Cloud Tokens -FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA= - -# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) -LOG_LEVEL=DEBUG - -# CORS allowed origins (comma-separated) -ALLOWED_ORIGINS=http://localhost:4200 - -# Environment mode -ENVIRONMENT=development - -# Encryption key for sensitive data (32-byte base64 key) -FERNET_KEY=your-32-byte-base64-key - -# Session configuration -SESSION_TIMEOUT_MINUTES=30 -MAX_CONCURRENT_SESSIONS=1000 - -# Elasticsearch configuration (optional) -ELASTICSEARCH_URL= - -# Database configuration (future use) -DATABASE_URL= - -# Redis configuration (future use) +# Flare Environment Configuration + +# JWT Configuration +JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4 +JWT_ALGORITHM=HS256 +JWT_EXPIRATION_HOURS=24 + +# Encryption Key for Cloud Tokens +FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA= + +# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +LOG_LEVEL=DEBUG + +# CORS allowed origins (comma-separated) +ALLOWED_ORIGINS=http://localhost:4200 + +# Environment mode +ENVIRONMENT=development + +# Encryption key for sensitive data (32-byte base64 key) +FERNET_KEY=your-32-byte-base64-key + +# Session configuration +SESSION_TIMEOUT_MINUTES=30 +MAX_CONCURRENT_SESSIONS=1000 + +# Elasticsearch configuration (optional) +ELASTICSEARCH_URL= + +# Database configuration (future use) +DATABASE_URL= + +# Redis configuration (future use) REDIS_URL= \ No newline at end of file diff --git a/.env.example b/.env.example index 9168c7575c7338e61a86f5a6d0dbe21f232e55b6..4d4ce8056b005c599840d05e1e309352e542a366 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,34 @@ -# Flare Environment Configuration - -# JWT Configuration -JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4 -JWT_ALGORITHM=HS256 -JWT_EXPIRATION_HOURS=24 - -# Encryption Key for Cloud Tokens -FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA= - -# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) -LOG_LEVEL=INFO - -# CORS allowed origins (comma-separated) -ALLOWED_ORIGINS=http://localhost:4200 - -# Environment mode -ENVIRONMENT=development - -# Encryption key for sensitive data (32-byte base64 key) -FERNET_KEY=your-32-byte-base64-key - -# Session configuration -SESSION_TIMEOUT_MINUTES=30 -MAX_CONCURRENT_SESSIONS=1000 - -# Elasticsearch configuration (optional) -ELASTICSEARCH_URL= - -# Database configuration (future use) -DATABASE_URL= - -# Redis configuration (future use) +# Flare Environment Configuration + +# JWT Configuration +JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4 +JWT_ALGORITHM=HS256 +JWT_EXPIRATION_HOURS=24 + +# Encryption Key for Cloud Tokens +FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA= + +# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +LOG_LEVEL=INFO + +# CORS allowed origins (comma-separated) +ALLOWED_ORIGINS=http://localhost:4200 + +# Environment mode +ENVIRONMENT=development + +# Encryption key for sensitive data (32-byte base64 key) +FERNET_KEY=your-32-byte-base64-key + +# Session configuration +SESSION_TIMEOUT_MINUTES=30 +MAX_CONCURRENT_SESSIONS=1000 + +# Elasticsearch configuration (optional) +ELASTICSEARCH_URL= + +# Database configuration (future use) +DATABASE_URL= + +# Redis configuration (future use) REDIS_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 93a499f9b9b1fe80c7193a9e80a59140aa0d2ec3..22367c28c3137e6d4c4e308f1e462b05bbaf2df9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,32 @@ -# Environment variables -.env -.env.local -.env.production - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -venv/ -ENV/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log - -# Angular -flare-ui/node_modules/ -flare-ui/dist/ +# Environment variables +.env +.env.local +.env.production + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Angular +flare-ui/node_modules/ +flare-ui/dist/ static/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 6df2bb6e33a6b254728102cf959e1ae134f0cd8b..fc9eb4239398d94846aa90c04e5dbac2c5b2048b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,88 +1,88 @@ -# ============================== BASE IMAGE ============================== -# Build Angular UI -FROM node:18-slim AS angular-build - -# Build argument: production/development -ARG BUILD_ENV=development - -WORKDIR /app - -# Copy package files first for better caching -COPY flare-ui/package*.json ./flare-ui/ -WORKDIR /app/flare-ui - -# Clean npm cache and install with legacy peer deps -RUN npm cache clean --force && npm install --legacy-peer-deps - -# Copy the entire flare-ui directory -COPY flare-ui/ ./ - -# ✅ Clean Angular cache before build -RUN rm -rf .angular/ dist/ node_modules/.cache/ - -# Build the Angular app based on BUILD_ENV -RUN if [ "$BUILD_ENV" = "development" ] ; then \ - echo "🔧 Building for DEVELOPMENT..." && \ - npm run build:dev -- --output-path=dist/flare-ui ; \ - else \ - echo "🚀 Building for PRODUCTION..." && \ - npm run build:prod -- --output-path=dist/flare-ui ; \ - fi - -# Add environment info to container -ENV BUILD_ENVIRONMENT=$BUILD_ENV - -# Debug: List directories to see where the build output is -RUN ls -la /app/flare-ui/ && ls -la /app/flare-ui/dist/ || true - -# Python runtime -FROM python:3.10-slim - -# ====================== SYSTEM-LEVEL DEPENDENCIES ====================== -# gcc & friends → bcrypt / uvicorn[standard] gibi C eklentilerini derlemek için -RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc g++ make libffi-dev \ - && rm -rf /var/lib/apt/lists/* - -# ============================== WORKDIR ================================ -WORKDIR /app - -# ===================== HF CACHE & WRITE PERMS ========================== -# Hugging Face Spaces özel dizinleri – yazma izni 777 -RUN mkdir -p /app/.cache /tmp/.triton /tmp/torchinductor_cache \ - && chmod -R 777 /app/.cache /tmp/.triton /tmp/torchinductor_cache - -ENV HF_HOME=/app/.cache \ - HF_DATASETS_CACHE=/app/.cache \ - HF_HUB_CACHE=/app/.cache \ - TRITON_CACHE_DIR=/tmp/.triton \ - TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache - -# ============================ REQUIREMENTS ============================= -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -# ============================== APP CODE =============================== -COPY . . - -# ✅ Config dizini ve dosya izinleri - HF Spaces için özel ayarlar -# Tüm app dizinine ve config dosyasına herkesin yazabilmesi için 777 -RUN chmod -R 777 /app && \ - touch /app/service_config.jsonc && \ - chmod 777 /app/service_config.jsonc && \ - # Ayrıca bir .tmp uzantılı dosya da oluştur izinlerle - touch /app/service_config.tmp && \ - chmod 777 /app/service_config.tmp - -# ✅ Angular build output'u kopyalanıyor -COPY --from=angular-build /app/flare-ui/dist/flare-ui ./static - -# Create assets directory if it doesn't exist -RUN mkdir -p ./static/assets - -# Debug: Check if static files exist -RUN ls -la ./static/ || echo "No static directory" -RUN ls -la ./static/index.html || echo "No index.html" - -# ============================== START CMD ============================== +# ============================== BASE IMAGE ============================== +# Build Angular UI +FROM node:18-slim AS angular-build + +# Build argument: production/development +ARG BUILD_ENV=development + +WORKDIR /app + +# Copy package files first for better caching +COPY flare-ui/package*.json ./flare-ui/ +WORKDIR /app/flare-ui + +# Clean npm cache and install with legacy peer deps +RUN npm cache clean --force && npm install --legacy-peer-deps + +# Copy the entire flare-ui directory +COPY flare-ui/ ./ + +# ✅ Clean Angular cache before build +RUN rm -rf .angular/ dist/ node_modules/.cache/ + +# Build the Angular app based on BUILD_ENV +RUN if [ "$BUILD_ENV" = "development" ] ; then \ + echo "🔧 Building for DEVELOPMENT..." && \ + npm run build:dev -- --output-path=dist/flare-ui ; \ + else \ + echo "🚀 Building for PRODUCTION..." && \ + npm run build:prod -- --output-path=dist/flare-ui ; \ + fi + +# Add environment info to container +ENV BUILD_ENVIRONMENT=$BUILD_ENV + +# Debug: List directories to see where the build output is +RUN ls -la /app/flare-ui/ && ls -la /app/flare-ui/dist/ || true + +# Python runtime +FROM python:3.10-slim + +# ====================== SYSTEM-LEVEL DEPENDENCIES ====================== +# gcc & friends → bcrypt / uvicorn[standard] gibi C eklentilerini derlemek için +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc g++ make libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +# ============================== WORKDIR ================================ +WORKDIR /app + +# ===================== HF CACHE & WRITE PERMS ========================== +# Hugging Face Spaces özel dizinleri – yazma izni 777 +RUN mkdir -p /app/.cache /tmp/.triton /tmp/torchinductor_cache \ + && chmod -R 777 /app/.cache /tmp/.triton /tmp/torchinductor_cache + +ENV HF_HOME=/app/.cache \ + HF_DATASETS_CACHE=/app/.cache \ + HF_HUB_CACHE=/app/.cache \ + TRITON_CACHE_DIR=/tmp/.triton \ + TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache + +# ============================ REQUIREMENTS ============================= +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# ============================== APP CODE =============================== +COPY . . + +# ✅ Config dizini ve dosya izinleri - HF Spaces için özel ayarlar +# Tüm app dizinine ve config dosyasına herkesin yazabilmesi için 777 +RUN chmod -R 777 /app && \ + touch /app/config/service_config.jsonc && \ + chmod 777 /app/config/service_config.jsonc && \ + # Ayrıca bir .tmp uzantılı dosya da oluştur izinlerle + touch /app/config/service_config.tmp && \ + chmod 777 /app/config/service_config.tmp + +# ✅ Angular build output'u kopyalanıyor +COPY --from=angular-build /app/flare-ui/dist/flare-ui ./static + +# Create assets directory if it doesn't exist +RUN mkdir -p ./static/assets + +# Debug: Check if static files exist +RUN ls -la ./static/ || echo "No static directory" +RUN ls -la ./static/index.html || echo "No index.html" + +# ============================== START CMD ============================== CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file diff --git a/api_connector.py b/api_connector.py index 8d62c11dc434c884773f72103fc9c63a3ffbc6c7..31160306a78d93bd9e50957b72ff97e7b292cbd7 100644 --- a/api_connector.py +++ b/api_connector.py @@ -1,99 +1,99 @@ -import requests -from logger import log_info, log_error, log_warning, log_debug - -class APIConnector: - def __init__(self, service_config): - self.service_config = service_config - - def resolve_placeholders(self, template, session): - resolved = template - for key, value in session.variables.items(): - resolved = resolved.replace(f"{{variables.{key}}}", str(value)) - for api, tokens in session.auth_tokens.items(): - resolved = resolved.replace(f"{{auth_tokens.{api}.token}}", tokens.get("token", "")) - return resolved - - def get_auth_token(self, api_name, auth_config, session): - auth_endpoint = auth_config.get("auth_endpoint") - auth_body = { - k: self.resolve_placeholders(str(v), session) - for k, v in auth_config.get("auth_body", {}).items() - } - token_path = auth_config.get("auth_token_path") - - response = requests.post(auth_endpoint, json=auth_body, timeout=5) - response.raise_for_status() - json_resp = response.json() - - token = json_resp - for part in token_path.split("."): - token = token.get(part) - if token is None: - raise Exception(f"Could not resolve token path: {token_path}") - - refresh_token = json_resp.get("refresh_token") - session.auth_tokens[api_name] = {"token": token, "refresh_token": refresh_token} - - log(f"🔑 Retrieved auth token for {api_name}") - return token - - def refresh_auth_token(self, api_name, auth_config, session): - refresh_endpoint = auth_config.get("auth_refresh_endpoint") - refresh_body = { - k: self.resolve_placeholders(str(v), session) - for k, v in auth_config.get("refresh_body", {}).items() - } - token_path = auth_config.get("auth_token_path") - - response = requests.post(refresh_endpoint, json=refresh_body, timeout=5) - response.raise_for_status() - json_resp = response.json() - - token = json_resp - for part in token_path.split("."): - token = token.get(part) - if token is None: - raise Exception(f"Could not resolve token path: {token_path}") - - new_refresh_token = json_resp.get("refresh_token", session.auth_tokens[api_name].get("refresh_token")) - session.auth_tokens[api_name] = {"token": token, "refresh_token": new_refresh_token} - - log_info(f"🔁 Refreshed auth token for {api_name}") - return token - - def call_api(self, intent_def, session): - api_name = intent_def.get("action") - api_def = self.service_config.get_api_config(api_name) - if not api_def: - raise Exception(f"API config not found: {api_name}") - - url = api_def["url"] - method = api_def.get("method", "POST") - headers = {h["key"]: self.resolve_placeholders(h["value"], session) for h in api_def.get("headers", [])} - body = {k: self.resolve_placeholders(str(v), session) for k, v in api_def.get("body", {}).items()} - timeout = api_def.get("timeout", 5) - retry_count = api_def.get("retry_count", 0) - auth_config = api_def.get("auth") - - # Get auth token if needed - if auth_config and api_name not in session.auth_tokens: - self.get_auth_token(api_name, auth_config, session) - - for attempt in range(retry_count + 1): - try: - response = requests.request(method, url, headers=headers, json=body, timeout=timeout) - if response.status_code == 401 and auth_config and attempt < retry_count: - log_info(f"🔁 Token expired for {api_name}, refreshing...") - self.refresh_auth_token(api_name, auth_config, session) - continue - response.raise_for_status() - log_info(f"✅ API call successful: {api_name}") - return response.json() - except requests.Timeout: - fallback = intent_def.get("fallback_timeout_message", "This operation is currently unavailable.") - log_warning(f"⚠️ API timeout for {api_name} → {fallback}") - return {"fallback": fallback} - except Exception as e: - log_error(f"❌ API call error for {api_name}", e) - fallback = intent_def.get("fallback_error_message", "An error occurred during the operation.") - return {"fallback": fallback} +import requests +from utils.logger import log_info, log_error, log_warning, log_debug + +class APIConnector: + def __init__(self, service_config): + self.service_config = service_config + + def resolve_placeholders(self, template, session): + resolved = template + for key, value in session.variables.items(): + resolved = resolved.replace(f"{{variables.{key}}}", str(value)) + for api, tokens in session.auth_tokens.items(): + resolved = resolved.replace(f"{{auth_tokens.{api}.token}}", tokens.get("token", "")) + return resolved + + def get_auth_token(self, api_name, auth_config, session): + auth_endpoint = auth_config.get("auth_endpoint") + auth_body = { + k: self.resolve_placeholders(str(v), session) + for k, v in auth_config.get("auth_body", {}).items() + } + token_path = auth_config.get("auth_token_path") + + response = requests.post(auth_endpoint, json=auth_body, timeout=5) + response.raise_for_status() + json_resp = response.json() + + token = json_resp + for part in token_path.split("."): + token = token.get(part) + if token is None: + raise Exception(f"Could not resolve token path: {token_path}") + + refresh_token = json_resp.get("refresh_token") + session.auth_tokens[api_name] = {"token": token, "refresh_token": refresh_token} + + log(f"🔑 Retrieved auth token for {api_name}") + return token + + def refresh_auth_token(self, api_name, auth_config, session): + refresh_endpoint = auth_config.get("auth_refresh_endpoint") + refresh_body = { + k: self.resolve_placeholders(str(v), session) + for k, v in auth_config.get("refresh_body", {}).items() + } + token_path = auth_config.get("auth_token_path") + + response = requests.post(refresh_endpoint, json=refresh_body, timeout=5) + response.raise_for_status() + json_resp = response.json() + + token = json_resp + for part in token_path.split("."): + token = token.get(part) + if token is None: + raise Exception(f"Could not resolve token path: {token_path}") + + new_refresh_token = json_resp.get("refresh_token", session.auth_tokens[api_name].get("refresh_token")) + session.auth_tokens[api_name] = {"token": token, "refresh_token": new_refresh_token} + + log_info(f"🔁 Refreshed auth token for {api_name}") + return token + + def call_api(self, intent_def, session): + api_name = intent_def.get("action") + api_def = self.service_config.get_api_config(api_name) + if not api_def: + raise Exception(f"API config not found: {api_name}") + + url = api_def["url"] + method = api_def.get("method", "POST") + headers = {h["key"]: self.resolve_placeholders(h["value"], session) for h in api_def.get("headers", [])} + body = {k: self.resolve_placeholders(str(v), session) for k, v in api_def.get("body", {}).items()} + timeout = api_def.get("timeout", 5) + retry_count = api_def.get("retry_count", 0) + auth_config = api_def.get("auth") + + # Get auth token if needed + if auth_config and api_name not in session.auth_tokens: + self.get_auth_token(api_name, auth_config, session) + + for attempt in range(retry_count + 1): + try: + response = requests.request(method, url, headers=headers, json=body, timeout=timeout) + if response.status_code == 401 and auth_config and attempt < retry_count: + log_info(f"🔁 Token expired for {api_name}, refreshing...") + self.refresh_auth_token(api_name, auth_config, session) + continue + response.raise_for_status() + log_info(f"✅ API call successful: {api_name}") + return response.json() + except requests.Timeout: + fallback = intent_def.get("fallback_timeout_message", "This operation is currently unavailable.") + log_warning(f"⚠️ API timeout for {api_name} → {fallback}") + return {"fallback": fallback} + except Exception as e: + log_error(f"❌ API call error for {api_name}", e) + fallback = intent_def.get("fallback_error_message", "An error occurred during the operation.") + return {"fallback": fallback} diff --git a/api_executor.py b/api_executor.py index 4708bd02c5dcbefb3fc682cf4d3abc339ab9f04d..2d6f533bae089707e1eccf581a9d163c2b73abb8 100644 --- a/api_executor.py +++ b/api_executor.py @@ -1,426 +1,426 @@ -""" -Flare – API Executor (v2.0 · session-aware token management) -""" - -from __future__ import annotations -import json, re, time, requests -from typing import Any, Dict, Optional, Union -from logger import log_info, log_error, log_warning, log_debug, LogTimer -from config_provider import ConfigProvider, APIConfig -from session import Session -import os - -MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB -DEFAULT_TIMEOUT = int(os.getenv("API_TIMEOUT_SECONDS", "30")) - -_placeholder = re.compile(r"\{\{\s*([^\}]+?)\s*\}\}") - -def _get_variable_value(session: Session, var_path: str) -> Any: - cfg = ConfigProvider.get() - - """Get variable value with proper type from session""" - if var_path.startswith("variables."): - var_name = var_path.split(".", 1)[1] - return session.variables.get(var_name) - elif var_path.startswith("auth_tokens."): - parts = var_path.split(".") - if len(parts) >= 3: - token_api = parts[1] - token_field = parts[2] - token_data = session._auth_tokens.get(token_api, {}) - return token_data.get(token_field) - elif var_path.startswith("config."): - attr_name = var_path.split(".", 1)[1] - return getattr(cfg.global_config, attr_name, None) - return None - -def _render_value(value: Any) -> Union[str, int, float, bool, None]: - """Convert value to appropriate JSON type""" - if value is None: - return None - elif isinstance(value, bool): - return value - elif isinstance(value, (int, float)): - return value - elif isinstance(value, str): - # Check if it's a number string - if value.isdigit(): - return int(value) - try: - return float(value) - except ValueError: - pass - # Check if it's a boolean string - if value.lower() in ('true', 'false'): - return value.lower() == 'true' - # Return as string - return value - else: - return str(value) - -def _render_json(obj: Any, session: Session, api_name: str) -> Any: - """Render JSON preserving types""" - if isinstance(obj, str): - # Check if entire string is a template - template_match = _placeholder.fullmatch(obj.strip()) - if template_match: - # This is a pure template like {{variables.pnr}} - var_path = template_match.group(1).strip() - value = _get_variable_value(session, var_path) - return _render_value(value) - else: - # String with embedded templates or regular string - def replacer(match): - var_path = match.group(1).strip() - value = _get_variable_value(session, var_path) - return str(value) if value is not None else "" - - return _placeholder.sub(replacer, obj) - - elif isinstance(obj, dict): - return {k: _render_json(v, session, api_name) for k, v in obj.items()} - - elif isinstance(obj, list): - return [_render_json(v, session, api_name) for v in obj] - - else: - # Return as-is for numbers, booleans, None - return obj - -def _render(obj: Any, session: Session, api_name: str) -> Any: - """Render template with session variables and tokens""" - # For headers and other string-only contexts - if isinstance(obj, str): - def replacer(match): - var_path = match.group(1).strip() - value = _get_variable_value(session, var_path) - return str(value) if value is not None else "" - - return _placeholder.sub(replacer, obj) - - elif isinstance(obj, dict): - return {k: _render(v, session, api_name) for k, v in obj.items()} - - elif isinstance(obj, list): - return [_render(v, session, api_name) for v in obj] - - return obj - -def _fetch_token(api: APIConfig, session: Session) -> None: - """Fetch new auth token""" - if not api.auth or not api.auth.enabled: - return - - log_info(f"🔑 Fetching token for {api.name}") - - try: - # Use _render_json for body to preserve types - body = _render_json(api.auth.token_request_body, session, api.name) - headers = {"Content-Type": "application/json"} - - response = requests.post( - str(api.auth.token_endpoint), - json=body, - headers=headers, - timeout=api.timeout_seconds - ) - response.raise_for_status() - - json_data = response.json() - - # Extract token using path - token = json_data - for path_part in api.auth.response_token_path.split("."): - token = token.get(path_part) - if token is None: - raise ValueError(f"Token path {api.auth.response_token_path} not found in response") - - # Store in session - session._auth_tokens[api.name] = { - "token": token, - "expiry": time.time() + 3500, # ~1 hour - "refresh_token": json_data.get("refresh_token") - } - - log_info(f"✅ Token obtained for {api.name}") - - except Exception as e: - log_error(f"❌ Token fetch failed for {api.name}", e) - raise - -def _refresh_token(api: APIConfig, session: Session) -> bool: - """Refresh existing token""" - if not api.auth or not api.auth.token_refresh_endpoint: - return False - - token_info = session._auth_tokens.get(api.name, {}) - if not token_info.get("refresh_token"): - return False - - log_info(f"🔄 Refreshing token for {api.name}") - - try: - body = _render_json(api.auth.token_refresh_body or {}, session, api.name) - body["refresh_token"] = token_info["refresh_token"] - - response = requests.post( - str(api.auth.token_refresh_endpoint), - json=body, - timeout=api.timeout_seconds - ) - response.raise_for_status() - - json_data = response.json() - - # Extract new token - token = json_data - for path_part in api.auth.response_token_path.split("."): - token = token.get(path_part) - if token is None: - raise ValueError(f"Token path {api.auth.response_token_path} not found in refresh response") - - # Update session - session._auth_tokens[api.name] = { - "token": token, - "expiry": time.time() + 3500, - "refresh_token": json_data.get("refresh_token", token_info["refresh_token"]) - } - - log_info(f"✅ Token refreshed for {api.name}") - return True - - except Exception as e: - log_error(f"❌ Token refresh failed for {api.name}", e) - return False - -def _ensure_token(api: APIConfig, session: Session) -> None: - """Ensure valid token exists for API""" - if not api.auth or not api.auth.enabled: - return - - token_info = session._auth_tokens.get(api.name) - - # No token yet - if not token_info: - _fetch_token(api, session) - return - - # Token still valid - if token_info.get("expiry", 0) > time.time(): - return - - # Try refresh first - if _refresh_token(api, session): - return - - # Refresh failed, get new token - _fetch_token(api, session) - -def call_api(api: APIConfig, session: Session) -> requests.Response: - """Execute API call with automatic token management and better error handling""" - - # Ensure valid token - _ensure_token(api, session) - - # Prepare request - headers = _render(api.headers, session, api.name) - body = _render_json(api.body_template, session, api.name) - - # Get timeout with fallback - timeout = api.timeout_seconds if api.timeout_seconds else DEFAULT_TIMEOUT - - # Handle proxy - proxies = None - if api.proxy: - if isinstance(api.proxy, str): - proxies = {"http": api.proxy, "https": api.proxy} - elif hasattr(api.proxy, "enabled") and api.proxy.enabled: - proxy_url = str(api.proxy.url) - proxies = {"http": proxy_url, "https": proxy_url} - - # Prepare request parameters - request_params = { - "method": api.method, - "url": str(api.url), - "headers": headers, - "timeout": timeout, # Use configured timeout - "stream": True # Enable streaming for large responses - } - - # Add body based on method - if api.method in ("POST", "PUT", "PATCH"): - request_params["json"] = body - elif api.method == "GET" and body: - request_params["params"] = body - - if proxies: - request_params["proxies"] = proxies - - # Execute with retry - retry_count = api.retry.retry_count if api.retry else 0 - last_error = None - response = None - - for attempt in range(retry_count + 1): - try: - # Use LogTimer for performance tracking - with LogTimer(f"API call {api.name}", attempt=attempt + 1): - log_info( - f"🌐 API call starting", - api=api.name, - method=api.method, - url=api.url, - attempt=f"{attempt + 1}/{retry_count + 1}", - timeout=timeout - ) - - if body: - log_debug(f"📋 Request body", body=json.dumps(body, ensure_ascii=False)[:500]) - - # Make request with streaming - response = requests.request(**request_params) - - # Check response size from headers - content_length = response.headers.get('content-length') - if content_length and int(content_length) > MAX_RESPONSE_SIZE: - response.close() - raise ValueError(f"Response too large: {int(content_length)} bytes (max: {MAX_RESPONSE_SIZE})") - - # Handle 401 Unauthorized - if response.status_code == 401 and api.auth and api.auth.enabled and attempt < retry_count: - log_warning(f"🔒 Got 401, refreshing token", api=api.name) - _fetch_token(api, session) - headers = _render(api.headers, session, api.name) - request_params["headers"] = headers - response.close() - continue - - # Read response with size limit - content_size = 0 - chunks = [] - - for chunk in response.iter_content(chunk_size=8192): - chunks.append(chunk) - content_size += len(chunk) - - if content_size > MAX_RESPONSE_SIZE: - response.close() - raise ValueError(f"Response exceeded size limit: {content_size} bytes") - - # Reconstruct response content - response._content = b''.join(chunks) - response._content_consumed = True - - # Check status - response.raise_for_status() - - log_info( - f"✅ API call successful", - api=api.name, - status_code=response.status_code, - response_size=content_size, - duration_ms=f"{response.elapsed.total_seconds() * 1000:.2f}" - ) - - # Mevcut response mapping işlemi korunacak - if response.status_code in (200, 201, 202, 204) and hasattr(api, 'response_mappings') and api.response_mappings: - try: - if response.status_code != 204 and response.content: - response_json = response.json() - - for mapping in api.response_mappings: - var_name = mapping.get('variable_name') - var_type = mapping.get('type', 'str') - json_path = mapping.get('json_path') - - if not all([var_name, json_path]): - continue - - # JSON path'ten değeri al - value = response_json - for path_part in json_path.split('.'): - if isinstance(value, dict): - value = value.get(path_part) - if value is None: - break - - if value is not None: - # Type conversion - if var_type == 'int': - value = int(value) - elif var_type == 'float': - value = float(value) - elif var_type == 'bool': - value = bool(value) - elif var_type == 'date': - value = str(value) - else: # str - value = str(value) - - # Session'a kaydet - session.variables[var_name] = value - log_info(f"📝 Mapped response", variable=var_name, value=value) - - except Exception as e: - log_error("⚠️ Response mapping error", error=str(e)) - - return response - - except requests.exceptions.Timeout as e: - last_error = e - log_warning( - f"⏱️ API timeout", - api=api.name, - attempt=attempt + 1, - timeout=timeout - ) - - except requests.exceptions.RequestException as e: - last_error = e - log_error( - f"❌ API request error", - api=api.name, - error=str(e), - attempt=attempt + 1 - ) - - except ValueError as e: # Size limit exceeded - log_error( - f"❌ Response size error", - api=api.name, - error=str(e) - ) - raise # Don't retry for size errors - - except Exception as e: - last_error = e - log_error( - f"❌ Unexpected API error", - api=api.name, - error=str(e), - attempt=attempt + 1 - ) - - # Retry backoff - if attempt < retry_count: - backoff = api.retry.backoff_seconds if api.retry else 2 - if api.retry and api.retry.strategy == "exponential": - backoff = backoff * (2 ** attempt) - log_info(f"⏳ Retry backoff", wait_seconds=backoff, next_attempt=attempt + 2) - time.sleep(backoff) - - # All retries failed - error_msg = f"API call failed after {retry_count + 1} attempts" - log_error(error_msg, api=api.name, last_error=str(last_error)) - - if last_error: - raise last_error - raise requests.exceptions.RequestException(error_msg) - -def format_size(size_bytes: int) -> str: - """Format bytes to human readable format""" - for unit in ['B', 'KB', 'MB', 'GB']: - if size_bytes < 1024.0: - return f"{size_bytes:.2f} {unit}" - size_bytes /= 1024.0 +""" +Flare – API Executor (v2.0 · session-aware token management) +""" + +from __future__ import annotations +import json, re, time, requests +from typing import Any, Dict, Optional, Union +from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer +from config.config_provider import ConfigProvider, APIConfig +from session import Session +import os + +MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB +DEFAULT_TIMEOUT = int(os.getenv("API_TIMEOUT_SECONDS", "30")) + +_placeholder = re.compile(r"\{\{\s*([^\}]+?)\s*\}\}") + +def _get_variable_value(session: Session, var_path: str) -> Any: + cfg = ConfigProvider.get() + + """Get variable value with proper type from session""" + if var_path.startswith("variables."): + var_name = var_path.split(".", 1)[1] + return session.variables.get(var_name) + elif var_path.startswith("auth_tokens."): + parts = var_path.split(".") + if len(parts) >= 3: + token_api = parts[1] + token_field = parts[2] + token_data = session._auth_tokens.get(token_api, {}) + return token_data.get(token_field) + elif var_path.startswith("config."): + attr_name = var_path.split(".", 1)[1] + return getattr(cfg.global_config, attr_name, None) + return None + +def _render_value(value: Any) -> Union[str, int, float, bool, None]: + """Convert value to appropriate JSON type""" + if value is None: + return None + elif isinstance(value, bool): + return value + elif isinstance(value, (int, float)): + return value + elif isinstance(value, str): + # Check if it's a number string + if value.isdigit(): + return int(value) + try: + return float(value) + except ValueError: + pass + # Check if it's a boolean string + if value.lower() in ('true', 'false'): + return value.lower() == 'true' + # Return as string + return value + else: + return str(value) + +def _render_json(obj: Any, session: Session, api_name: str) -> Any: + """Render JSON preserving types""" + if isinstance(obj, str): + # Check if entire string is a template + template_match = _placeholder.fullmatch(obj.strip()) + if template_match: + # This is a pure template like {{variables.pnr}} + var_path = template_match.group(1).strip() + value = _get_variable_value(session, var_path) + return _render_value(value) + else: + # String with embedded templates or regular string + def replacer(match): + var_path = match.group(1).strip() + value = _get_variable_value(session, var_path) + return str(value) if value is not None else "" + + return _placeholder.sub(replacer, obj) + + elif isinstance(obj, dict): + return {k: _render_json(v, session, api_name) for k, v in obj.items()} + + elif isinstance(obj, list): + return [_render_json(v, session, api_name) for v in obj] + + else: + # Return as-is for numbers, booleans, None + return obj + +def _render(obj: Any, session: Session, api_name: str) -> Any: + """Render template with session variables and tokens""" + # For headers and other string-only contexts + if isinstance(obj, str): + def replacer(match): + var_path = match.group(1).strip() + value = _get_variable_value(session, var_path) + return str(value) if value is not None else "" + + return _placeholder.sub(replacer, obj) + + elif isinstance(obj, dict): + return {k: _render(v, session, api_name) for k, v in obj.items()} + + elif isinstance(obj, list): + return [_render(v, session, api_name) for v in obj] + + return obj + +def _fetch_token(api: APIConfig, session: Session) -> None: + """Fetch new auth token""" + if not api.auth or not api.auth.enabled: + return + + log_info(f"🔑 Fetching token for {api.name}") + + try: + # Use _render_json for body to preserve types + body = _render_json(api.auth.token_request_body, session, api.name) + headers = {"Content-Type": "application/json"} + + response = requests.post( + str(api.auth.token_endpoint), + json=body, + headers=headers, + timeout=api.timeout_seconds + ) + response.raise_for_status() + + json_data = response.json() + + # Extract token using path + token = json_data + for path_part in api.auth.response_token_path.split("."): + token = token.get(path_part) + if token is None: + raise ValueError(f"Token path {api.auth.response_token_path} not found in response") + + # Store in session + session._auth_tokens[api.name] = { + "token": token, + "expiry": time.time() + 3500, # ~1 hour + "refresh_token": json_data.get("refresh_token") + } + + log_info(f"✅ Token obtained for {api.name}") + + except Exception as e: + log_error(f"❌ Token fetch failed for {api.name}", e) + raise + +def _refresh_token(api: APIConfig, session: Session) -> bool: + """Refresh existing token""" + if not api.auth or not api.auth.token_refresh_endpoint: + return False + + token_info = session._auth_tokens.get(api.name, {}) + if not token_info.get("refresh_token"): + return False + + log_info(f"🔄 Refreshing token for {api.name}") + + try: + body = _render_json(api.auth.token_refresh_body or {}, session, api.name) + body["refresh_token"] = token_info["refresh_token"] + + response = requests.post( + str(api.auth.token_refresh_endpoint), + json=body, + timeout=api.timeout_seconds + ) + response.raise_for_status() + + json_data = response.json() + + # Extract new token + token = json_data + for path_part in api.auth.response_token_path.split("."): + token = token.get(path_part) + if token is None: + raise ValueError(f"Token path {api.auth.response_token_path} not found in refresh response") + + # Update session + session._auth_tokens[api.name] = { + "token": token, + "expiry": time.time() + 3500, + "refresh_token": json_data.get("refresh_token", token_info["refresh_token"]) + } + + log_info(f"✅ Token refreshed for {api.name}") + return True + + except Exception as e: + log_error(f"❌ Token refresh failed for {api.name}", e) + return False + +def _ensure_token(api: APIConfig, session: Session) -> None: + """Ensure valid token exists for API""" + if not api.auth or not api.auth.enabled: + return + + token_info = session._auth_tokens.get(api.name) + + # No token yet + if not token_info: + _fetch_token(api, session) + return + + # Token still valid + if token_info.get("expiry", 0) > time.time(): + return + + # Try refresh first + if _refresh_token(api, session): + return + + # Refresh failed, get new token + _fetch_token(api, session) + +def call_api(api: APIConfig, session: Session) -> requests.Response: + """Execute API call with automatic token management and better error handling""" + + # Ensure valid token + _ensure_token(api, session) + + # Prepare request + headers = _render(api.headers, session, api.name) + body = _render_json(api.body_template, session, api.name) + + # Get timeout with fallback + timeout = api.timeout_seconds if api.timeout_seconds else DEFAULT_TIMEOUT + + # Handle proxy + proxies = None + if api.proxy: + if isinstance(api.proxy, str): + proxies = {"http": api.proxy, "https": api.proxy} + elif hasattr(api.proxy, "enabled") and api.proxy.enabled: + proxy_url = str(api.proxy.url) + proxies = {"http": proxy_url, "https": proxy_url} + + # Prepare request parameters + request_params = { + "method": api.method, + "url": str(api.url), + "headers": headers, + "timeout": timeout, # Use configured timeout + "stream": True # Enable streaming for large responses + } + + # Add body based on method + if api.method in ("POST", "PUT", "PATCH"): + request_params["json"] = body + elif api.method == "GET" and body: + request_params["params"] = body + + if proxies: + request_params["proxies"] = proxies + + # Execute with retry + retry_count = api.retry.retry_count if api.retry else 0 + last_error = None + response = None + + for attempt in range(retry_count + 1): + try: + # Use LogTimer for performance tracking + with LogTimer(f"API call {api.name}", attempt=attempt + 1): + log_info( + f"🌐 API call starting", + api=api.name, + method=api.method, + url=api.url, + attempt=f"{attempt + 1}/{retry_count + 1}", + timeout=timeout + ) + + if body: + log_debug(f"📋 Request body", body=json.dumps(body, ensure_ascii=False)[:500]) + + # Make request with streaming + response = requests.request(**request_params) + + # Check response size from headers + content_length = response.headers.get('content-length') + if content_length and int(content_length) > MAX_RESPONSE_SIZE: + response.close() + raise ValueError(f"Response too large: {int(content_length)} bytes (max: {MAX_RESPONSE_SIZE})") + + # Handle 401 Unauthorized + if response.status_code == 401 and api.auth and api.auth.enabled and attempt < retry_count: + log_warning(f"🔒 Got 401, refreshing token", api=api.name) + _fetch_token(api, session) + headers = _render(api.headers, session, api.name) + request_params["headers"] = headers + response.close() + continue + + # Read response with size limit + content_size = 0 + chunks = [] + + for chunk in response.iter_content(chunk_size=8192): + chunks.append(chunk) + content_size += len(chunk) + + if content_size > MAX_RESPONSE_SIZE: + response.close() + raise ValueError(f"Response exceeded size limit: {content_size} bytes") + + # Reconstruct response content + response._content = b''.join(chunks) + response._content_consumed = True + + # Check status + response.raise_for_status() + + log_info( + f"✅ API call successful", + api=api.name, + status_code=response.status_code, + response_size=content_size, + duration_ms=f"{response.elapsed.total_seconds() * 1000:.2f}" + ) + + # Mevcut response mapping işlemi korunacak + if response.status_code in (200, 201, 202, 204) and hasattr(api, 'response_mappings') and api.response_mappings: + try: + if response.status_code != 204 and response.content: + response_json = response.json() + + for mapping in api.response_mappings: + var_name = mapping.get('variable_name') + var_type = mapping.get('type', 'str') + json_path = mapping.get('json_path') + + if not all([var_name, json_path]): + continue + + # JSON path'ten değeri al + value = response_json + for path_part in json_path.split('.'): + if isinstance(value, dict): + value = value.get(path_part) + if value is None: + break + + if value is not None: + # Type conversion + if var_type == 'int': + value = int(value) + elif var_type == 'float': + value = float(value) + elif var_type == 'bool': + value = bool(value) + elif var_type == 'date': + value = str(value) + else: # str + value = str(value) + + # Session'a kaydet + session.variables[var_name] = value + log_info(f"📝 Mapped response", variable=var_name, value=value) + + except Exception as e: + log_error("⚠️ Response mapping error", error=str(e)) + + return response + + except requests.exceptions.Timeout as e: + last_error = e + log_warning( + f"⏱️ API timeout", + api=api.name, + attempt=attempt + 1, + timeout=timeout + ) + + except requests.exceptions.RequestException as e: + last_error = e + log_error( + f"❌ API request error", + api=api.name, + error=str(e), + attempt=attempt + 1 + ) + + except ValueError as e: # Size limit exceeded + log_error( + f"❌ Response size error", + api=api.name, + error=str(e) + ) + raise # Don't retry for size errors + + except Exception as e: + last_error = e + log_error( + f"❌ Unexpected API error", + api=api.name, + error=str(e), + attempt=attempt + 1 + ) + + # Retry backoff + if attempt < retry_count: + backoff = api.retry.backoff_seconds if api.retry else 2 + if api.retry and api.retry.strategy == "exponential": + backoff = backoff * (2 ** attempt) + log_info(f"⏳ Retry backoff", wait_seconds=backoff, next_attempt=attempt + 2) + time.sleep(backoff) + + # All retries failed + error_msg = f"API call failed after {retry_count + 1} attempts" + log_error(error_msg, api=api.name, last_error=str(last_error)) + + if last_error: + raise last_error + raise requests.exceptions.RequestException(error_msg) + +def format_size(size_bytes: int) -> str: + """Format bytes to human readable format""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 return f"{size_bytes:.2f} TB" \ No newline at end of file diff --git a/app.py b/app.py index 6f5c7f23c4d6b0fe970210d6c59a0772648c1f94..7e66c01159470d5b7fb589424c808c621a062b1e 100644 --- a/app.py +++ b/app.py @@ -1,388 +1,388 @@ -""" -Flare – Main Application (Refactored) -===================================== -""" -# FastAPI imports -from fastapi import FastAPI, WebSocket, Request, status -from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse, JSONResponse -from fastapi.middleware.cors import CORSMiddleware -from fastapi.encoders import jsonable_encoder - -# Standard library -import uvicorn -import os -from pathlib import Path -import mimetypes -import uuid -import traceback -from datetime import datetime -from pydantic import ValidationError -from dotenv import load_dotenv - -# Project imports -from websocket_handler import websocket_endpoint -from admin_routes import router as admin_router, start_cleanup_task -from llm_startup import run_in_thread -from session import session_store, start_session_cleanup -from config_provider import ConfigProvider - -# Logger imports (utils.log yerine) -from logger import log_error, log_info, log_warning - -# Exception imports -from exceptions import ( - DuplicateResourceError, - RaceConditionError, - ValidationError, - ResourceNotFoundError, - AuthenticationError, - AuthorizationError, - ConfigurationError, - get_http_status_code -) - -# Load .env file if exists -load_dotenv() - -ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200").split(",") - -# ===================== Environment Setup ===================== -def setup_environment(): - """Setup environment based on deployment mode""" - cfg = ConfigProvider.get() - - log_info("=" * 60) - log_info("🚀 Flare Starting", version="2.0.0") - log_info(f"🔌 LLM Provider: {cfg.global_config.llm_provider.name}") - log_info(f"🎤 TTS Provider: {cfg.global_config.tts_provider.name}") - log_info(f"🎧 STT Provider: {cfg.global_config.stt_provider.name}") - log_info("=" * 60) - - if cfg.global_config.is_cloud_mode(): - log_info("☁️ Cloud Mode: Using HuggingFace Secrets") - log_info("📌 Required secrets: JWT_SECRET, FLARE_TOKEN_KEY") - - # Check for provider-specific tokens - llm_config = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name) - if llm_config and llm_config.requires_repo_info: - log_info("📌 LLM requires SPARK_TOKEN for repository operations") - else: - log_info("🏢 On-Premise Mode: Using .env file") - if not Path(".env").exists(): - log_warning("⚠️ WARNING: .env file not found!") - log_info("📌 Copy .env.example to .env and configure it") - -# Run setup -setup_environment() - -# Fix MIME types for JavaScript files -mimetypes.add_type("application/javascript", ".js") -mimetypes.add_type("text/css", ".css") - -app = FastAPI( - title="Flare Orchestration Service", - version="2.0.0", - description="LLM-driven intent & API flow engine with multi-provider support", -) - -# CORS for development -if os.getenv("ENVIRONMENT", "development") == "development": - app.add_middleware( - CORSMiddleware, - allow_origins=ALLOWED_ORIGINS, - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allow_headers=["*"], - max_age=3600, - expose_headers=["X-Request-ID"] - ) - log_info(f"🔧 CORS enabled for origins: {ALLOWED_ORIGINS}") - -# Request ID middleware -@app.middleware("http") -async def add_request_id(request: Request, call_next): - """Add request ID for tracking""" - request_id = str(uuid.uuid4()) - request.state.request_id = request_id - - # Log request start - log_info( - "Request started", - request_id=request_id, - method=request.method, - path=request.url.path, - client=request.client.host if request.client else "unknown" - ) - - try: - response = await call_next(request) - - # Add request ID to response headers - response.headers["X-Request-ID"] = request_id - - # Log request completion - log_info( - "Request completed", - request_id=request_id, - status_code=response.status_code, - method=request.method, - path=request.url.path - ) - - return response - except Exception as e: - log_error( - "Request failed", - request_id=request_id, - error=str(e), - traceback=traceback.format_exc() - ) - raise - -run_in_thread() # Start LLM startup notifier if needed -start_cleanup_task() # Activity log cleanup -start_session_cleanup() # Session cleanup - -# ---------------- Core chat/session routes -------------------------- -from chat_handler import router as chat_router -app.include_router(chat_router, prefix="/api") - -# ---------------- Audio (TTS/STT) routes ------------------------------ -from audio_routes import router as audio_router -app.include_router(audio_router, prefix="/api") - -# ---------------- Admin API routes ---------------------------------- -app.include_router(admin_router, prefix="/api/admin") - -# ---------------- Exception Handlers ---------------------------------- -# Add global exception handler -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - """Handle all unhandled exceptions""" - request_id = getattr(request.state, 'request_id', 'unknown') - - # Log the full exception - log_error( - "Unhandled exception", - request_id=request_id, - endpoint=str(request.url), - method=request.method, - error=str(exc), - error_type=type(exc).__name__, - traceback=traceback.format_exc() - ) - - # Special handling for FlareExceptions - if isinstance(exc, FlareException): - status_code = get_http_status_code(exc) - response_body = format_error_response(exc, request_id) - - # Special message for race conditions - if isinstance(exc, RaceConditionError): - response_body["user_action"] = "Please reload the data and try again" - - return JSONResponse( - status_code=status_code, - content=jsonable_encoder(response_body) - ) - - # Generic error response - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content=jsonable_encoder({ - "error": "InternalServerError", - "message": "An unexpected error occurred. Please try again later.", - "request_id": request_id, - "timestamp": datetime.utcnow().isoformat() - }) - ) - -# Add custom exception handlers -@app.exception_handler(DuplicateResourceError) -async def duplicate_resource_handler(request: Request, exc: DuplicateResourceError): - """Handle duplicate resource errors""" - return JSONResponse( - status_code=409, - content={ - "detail": str(exc), - "error_type": "duplicate_resource", - "resource_type": exc.details.get("resource_type"), - "identifier": exc.details.get("identifier") - } - ) - -@app.exception_handler(RaceConditionError) -async def race_condition_handler(request: Request, exc: RaceConditionError): - """Handle race condition errors""" - return JSONResponse( - status_code=409, - content=exc.to_http_detail() - ) - -@app.exception_handler(ValidationError) -async def validation_error_handler(request: Request, exc: ValidationError): - """Handle validation errors""" - return JSONResponse( - status_code=422, - content={ - "detail": str(exc), - "error_type": "validation_error", - "details": exc.details - } - ) - -@app.exception_handler(ResourceNotFoundError) -async def resource_not_found_handler(request: Request, exc: ResourceNotFoundError): - """Handle resource not found errors""" - return JSONResponse( - status_code=404, - content={ - "detail": str(exc), - "error_type": "resource_not_found", - "resource_type": exc.details.get("resource_type"), - "identifier": exc.details.get("identifier") - } - ) - -@app.exception_handler(AuthenticationError) -async def authentication_error_handler(request: Request, exc: AuthenticationError): - """Handle authentication errors""" - return JSONResponse( - status_code=401, - content={ - "detail": str(exc), - "error_type": "authentication_error" - } - ) - -@app.exception_handler(AuthorizationError) -async def authorization_error_handler(request: Request, exc: AuthorizationError): - """Handle authorization errors""" - return JSONResponse( - status_code=403, - content={ - "detail": str(exc), - "error_type": "authorization_error" - } - ) - -@app.exception_handler(ConfigurationError) -async def configuration_error_handler(request: Request, exc: ConfigurationError): - """Handle configuration errors""" - return JSONResponse( - status_code=500, - content={ - "detail": str(exc), - "error_type": "configuration_error", - "config_key": exc.details.get("config_key") - } - ) - -# ---------------- Metrics endpoint ----------------- -@app.get("/metrics") -async def get_metrics(): - """Get system metrics""" - import psutil - import gc - - # Memory info - process = psutil.Process() - memory_info = process.memory_info() - - # Session stats - session_stats = session_store.get_session_stats() - - metrics = { - "memory": { - "rss_mb": memory_info.rss / 1024 / 1024, - "vms_mb": memory_info.vms / 1024 / 1024, - "percent": process.memory_percent() - }, - "cpu": { - "percent": process.cpu_percent(interval=0.1), - "num_threads": process.num_threads() - }, - "sessions": session_stats, - "gc": { - "collections": gc.get_count(), - "objects": len(gc.get_objects()) - }, - "uptime_seconds": time.time() - process.create_time() - } - - return metrics - -# ---------------- Health probe (HF Spaces watchdog) ----------------- -@app.get("/api/health") -def health_check(): - """Health check endpoint - moved to /api/health""" - return { - "status": "ok", - "version": "2.0.0", - "timestamp": datetime.utcnow().isoformat(), - "environment": os.getenv("ENVIRONMENT", "development") - } - -# ---------------- WebSocket route for real-time STT ------------------ -@app.websocket("/ws/conversation/{session_id}") -async def conversation_websocket(websocket: WebSocket, session_id: str): - await websocket_endpoint(websocket, session_id) - -# ---------------- Serve static files ------------------------------------ -# UI static files (production build) -static_path = Path(__file__).parent / "static" -if static_path.exists(): - app.mount("/static", StaticFiles(directory=str(static_path)), name="static") - - # Serve index.html for all non-API routes (SPA support) - @app.get("/", response_class=FileResponse) - async def serve_index(): - """Serve Angular app""" - index_path = static_path / "index.html" - if index_path.exists(): - return FileResponse(str(index_path)) - else: - return JSONResponse( - status_code=404, - content={"error": "UI not found. Please build the Angular app first."} - ) - - # Catch-all route for SPA - @app.get("/{full_path:path}") - async def serve_spa(full_path: str): - """Serve Angular app for all routes""" - # Skip API routes - if full_path.startswith("api/"): - return JSONResponse(status_code=404, content={"error": "Not found"}) - - # Serve static files - file_path = static_path / full_path - if file_path.exists() and file_path.is_file(): - return FileResponse(str(file_path)) - - # Fallback to index.html for SPA routing - index_path = static_path / "index.html" - if index_path.exists(): - return FileResponse(str(index_path)) - - return JSONResponse(status_code=404, content={"error": "Not found"}) -else: - log_warning(f"⚠️ Static files directory not found at {static_path}") - log_warning(" Run 'npm run build' in flare-ui directory to build the UI") - - @app.get("/") - async def no_ui(): - """No UI available""" - return JSONResponse( - status_code=503, - content={ - "error": "UI not available", - "message": "Please build the Angular UI first. Run: cd flare-ui && npm run build", - "api_docs": "/docs" - } - ) - -if __name__ == "__main__": - log_info("🌐 Starting Flare backend on port 7860...") +""" +Flare – Main Application (Refactored) +===================================== +""" +# FastAPI imports +from fastapi import FastAPI, WebSocket, Request, status +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.encoders import jsonable_encoder + +# Standard library +import uvicorn +import os +from pathlib import Path +import mimetypes +import uuid +import traceback +from datetime import datetime +from pydantic import ValidationError +from dotenv import load_dotenv + +# Project imports +from routes.websocket_handler import websocket_endpoint +from routes.admin_routes import router as admin_router, start_cleanup_task +from llm.llm_startup import run_in_thread +from session import session_store, start_session_cleanup +from config.config_provider import ConfigProvider + +# Logger imports (utils.log yerine) +from utils.logger import log_error, log_info, log_warning + +# Exception imports +from utils.exceptions import ( + DuplicateResourceError, + RaceConditionError, + ValidationError, + ResourceNotFoundError, + AuthenticationError, + AuthorizationError, + ConfigurationError, + get_http_status_code +) + +# Load .env file if exists +load_dotenv() + +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200").split(",") + +# ===================== Environment Setup ===================== +def setup_environment(): + """Setup environment based on deployment mode""" + cfg = ConfigProvider.get() + + log_info("=" * 60) + log_info("🚀 Flare Starting", version="2.0.0") + log_info(f"🔌 LLM Provider: {cfg.global_config.llm_provider.name}") + log_info(f"🎤 TTS Provider: {cfg.global_config.tts_provider.name}") + log_info(f"🎧 STT Provider: {cfg.global_config.stt_provider.name}") + log_info("=" * 60) + + if cfg.global_config.is_cloud_mode(): + log_info("☁️ Cloud Mode: Using HuggingFace Secrets") + log_info("📌 Required secrets: JWT_SECRET, FLARE_TOKEN_KEY") + + # Check for provider-specific tokens + llm_config = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name) + if llm_config and llm_config.requires_repo_info: + log_info("📌 LLM requires SPARK_TOKEN for repository operations") + else: + log_info("🏢 On-Premise Mode: Using .env file") + if not Path(".env").exists(): + log_warning("⚠️ WARNING: .env file not found!") + log_info("📌 Copy .env.example to .env and configure it") + +# Run setup +setup_environment() + +# Fix MIME types for JavaScript files +mimetypes.add_type("application/javascript", ".js") +mimetypes.add_type("text/css", ".css") + +app = FastAPI( + title="Flare Orchestration Service", + version="2.0.0", + description="LLM-driven intent & API flow engine with multi-provider support", +) + +# CORS for development +if os.getenv("ENVIRONMENT", "development") == "development": + app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], + max_age=3600, + expose_headers=["X-Request-ID"] + ) + log_info(f"🔧 CORS enabled for origins: {ALLOWED_ORIGINS}") + +# Request ID middleware +@app.middleware("http") +async def add_request_id(request: Request, call_next): + """Add request ID for tracking""" + request_id = str(uuid.uuid4()) + request.state.request_id = request_id + + # Log request start + log_info( + "Request started", + request_id=request_id, + method=request.method, + path=request.url.path, + client=request.client.host if request.client else "unknown" + ) + + try: + response = await call_next(request) + + # Add request ID to response headers + response.headers["X-Request-ID"] = request_id + + # Log request completion + log_info( + "Request completed", + request_id=request_id, + status_code=response.status_code, + method=request.method, + path=request.url.path + ) + + return response + except Exception as e: + log_error( + "Request failed", + request_id=request_id, + error=str(e), + traceback=traceback.format_exc() + ) + raise + +run_in_thread() # Start LLM startup notifier if needed +start_cleanup_task() # Activity log cleanup +start_session_cleanup() # Session cleanup + +# ---------------- Core chat/session routes -------------------------- +from routes.chat_handler import router as chat_router +app.include_router(chat_router, prefix="/api") + +# ---------------- Audio (TTS/STT) routes ------------------------------ +from routes.audio_routes import router as audio_router +app.include_router(audio_router, prefix="/api") + +# ---------------- Admin API routes ---------------------------------- +app.include_router(admin_router, prefix="/api/admin") + +# ---------------- Exception Handlers ---------------------------------- +# Add global exception handler +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Handle all unhandled exceptions""" + request_id = getattr(request.state, 'request_id', 'unknown') + + # Log the full exception + log_error( + "Unhandled exception", + request_id=request_id, + endpoint=str(request.url), + method=request.method, + error=str(exc), + error_type=type(exc).__name__, + traceback=traceback.format_exc() + ) + + # Special handling for FlareExceptions + if isinstance(exc, FlareException): + status_code = get_http_status_code(exc) + response_body = format_error_response(exc, request_id) + + # Special message for race conditions + if isinstance(exc, RaceConditionError): + response_body["user_action"] = "Please reload the data and try again" + + return JSONResponse( + status_code=status_code, + content=jsonable_encoder(response_body) + ) + + # Generic error response + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=jsonable_encoder({ + "error": "InternalServerError", + "message": "An unexpected error occurred. Please try again later.", + "request_id": request_id, + "timestamp": datetime.utcnow().isoformat() + }) + ) + +# Add custom exception handlers +@app.exception_handler(DuplicateResourceError) +async def duplicate_resource_handler(request: Request, exc: DuplicateResourceError): + """Handle duplicate resource errors""" + return JSONResponse( + status_code=409, + content={ + "detail": str(exc), + "error_type": "duplicate_resource", + "resource_type": exc.details.get("resource_type"), + "identifier": exc.details.get("identifier") + } + ) + +@app.exception_handler(RaceConditionError) +async def race_condition_handler(request: Request, exc: RaceConditionError): + """Handle race condition errors""" + return JSONResponse( + status_code=409, + content=exc.to_http_detail() + ) + +@app.exception_handler(ValidationError) +async def validation_error_handler(request: Request, exc: ValidationError): + """Handle validation errors""" + return JSONResponse( + status_code=422, + content={ + "detail": str(exc), + "error_type": "validation_error", + "details": exc.details + } + ) + +@app.exception_handler(ResourceNotFoundError) +async def resource_not_found_handler(request: Request, exc: ResourceNotFoundError): + """Handle resource not found errors""" + return JSONResponse( + status_code=404, + content={ + "detail": str(exc), + "error_type": "resource_not_found", + "resource_type": exc.details.get("resource_type"), + "identifier": exc.details.get("identifier") + } + ) + +@app.exception_handler(AuthenticationError) +async def authentication_error_handler(request: Request, exc: AuthenticationError): + """Handle authentication errors""" + return JSONResponse( + status_code=401, + content={ + "detail": str(exc), + "error_type": "authentication_error" + } + ) + +@app.exception_handler(AuthorizationError) +async def authorization_error_handler(request: Request, exc: AuthorizationError): + """Handle authorization errors""" + return JSONResponse( + status_code=403, + content={ + "detail": str(exc), + "error_type": "authorization_error" + } + ) + +@app.exception_handler(ConfigurationError) +async def configuration_error_handler(request: Request, exc: ConfigurationError): + """Handle configuration errors""" + return JSONResponse( + status_code=500, + content={ + "detail": str(exc), + "error_type": "configuration_error", + "config_key": exc.details.get("config_key") + } + ) + +# ---------------- Metrics endpoint ----------------- +@app.get("/metrics") +async def get_metrics(): + """Get system metrics""" + import psutil + import gc + + # Memory info + process = psutil.Process() + memory_info = process.memory_info() + + # Session stats + session_stats = session_store.get_session_stats() + + metrics = { + "memory": { + "rss_mb": memory_info.rss / 1024 / 1024, + "vms_mb": memory_info.vms / 1024 / 1024, + "percent": process.memory_percent() + }, + "cpu": { + "percent": process.cpu_percent(interval=0.1), + "num_threads": process.num_threads() + }, + "sessions": session_stats, + "gc": { + "collections": gc.get_count(), + "objects": len(gc.get_objects()) + }, + "uptime_seconds": time.time() - process.create_time() + } + + return metrics + +# ---------------- Health probe (HF Spaces watchdog) ----------------- +@app.get("/api/health") +def health_check(): + """Health check endpoint - moved to /api/health""" + return { + "status": "ok", + "version": "2.0.0", + "timestamp": datetime.utcnow().isoformat(), + "environment": os.getenv("ENVIRONMENT", "development") + } + +# ---------------- WebSocket route for real-time STT ------------------ +@app.websocket("/ws/conversation/{session_id}") +async def conversation_websocket(websocket: WebSocket, session_id: str): + await websocket_endpoint(websocket, session_id) + +# ---------------- Serve static files ------------------------------------ +# UI static files (production build) +static_path = Path(__file__).parent / "static" +if static_path.exists(): + app.mount("/static", StaticFiles(directory=str(static_path)), name="static") + + # Serve index.html for all non-API routes (SPA support) + @app.get("/", response_class=FileResponse) + async def serve_index(): + """Serve Angular app""" + index_path = static_path / "index.html" + if index_path.exists(): + return FileResponse(str(index_path)) + else: + return JSONResponse( + status_code=404, + content={"error": "UI not found. Please build the Angular app first."} + ) + + # Catch-all route for SPA + @app.get("/{full_path:path}") + async def serve_spa(full_path: str): + """Serve Angular app for all routes""" + # Skip API routes + if full_path.startswith("api/"): + return JSONResponse(status_code=404, content={"error": "Not found"}) + + # Serve static files + file_path = static_path / full_path + if file_path.exists() and file_path.is_file(): + return FileResponse(str(file_path)) + + # Fallback to index.html for SPA routing + index_path = static_path / "index.html" + if index_path.exists(): + return FileResponse(str(index_path)) + + return JSONResponse(status_code=404, content={"error": "Not found"}) +else: + log_warning(f"⚠️ Static files directory not found at {static_path}") + log_warning(" Run 'npm run build' in flare-ui directory to build the UI") + + @app.get("/") + async def no_ui(): + """No UI available""" + return JSONResponse( + status_code=503, + content={ + "error": "UI not available", + "message": "Please build the Angular UI first. Run: cd flare-ui && npm run build", + "api_docs": "/docs" + } + ) + +if __name__ == "__main__": + log_info("🌐 Starting Flare backend on port 7860...") uvicorn.run(app, host="0.0.0.0", port=7860) \ No newline at end of file diff --git a/config/config_provider.py b/config/config_provider.py index 9dbe134eabcbe89d53ab0ec20f822b11ef84fe33..15a11f6ad3448f29d6825cb34c4fb98f092935c8 100644 --- a/config/config_provider.py +++ b/config/config_provider.py @@ -13,24 +13,24 @@ import shutil from utils import get_current_timestamp, normalize_timestamp, timestamps_equal from config_models import ( - ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig, + ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig, IntentConfig, APIConfig, ActivityLogEntry, ParameterConfig, LLMConfiguration, GenerationConfig ) -from logger import log_info, log_error, log_warning, log_debug, LogTimer -from exceptions import ( +from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer +from utils.exceptions import ( RaceConditionError, ConfigurationError, ResourceNotFoundError, DuplicateResourceError, ValidationError ) -from encryption_utils import encrypt, decrypt +from utils.encryption_utils import encrypt, decrypt class ConfigProvider: """Thread-safe singleton configuration provider""" - + _instance: Optional[ServiceConfig] = None _lock = threading.RLock() # Reentrant lock for nested calls _file_lock = threading.Lock() # Separate lock for file operations - _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc" + _CONFIG_PATH = Path(__file__).parent.parent / "service_config.jsonc" @staticmethod def _normalize_date(date_str: Optional[str]) -> str: @@ -51,7 +51,7 @@ class ConfigProvider: cls._instance.build_index() log_info("Configuration loaded successfully") return cls._instance - + @classmethod def reload(cls) -> ServiceConfig: """Force reload configuration from file""" @@ -59,7 +59,7 @@ class ConfigProvider: log_info("Reloading configuration...") cls._instance = None return cls.get() - + @classmethod def _load(cls) -> ServiceConfig: """Load configuration from file""" @@ -69,15 +69,15 @@ class ConfigProvider: f"Config file not found: {cls._CONFIG_PATH}", config_key="service_config.jsonc" ) - + with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f: config_data = commentjson.load(f) - + # Debug: İlk project'in tarihini kontrol et if 'projects' in config_data and len(config_data['projects']) > 0: first_project = config_data['projects'][0] log_debug(f"🔍 Raw project data - last_update_date: {first_project.get('last_update_date')}") - + # Ensure required fields if 'config' not in config_data: config_data['config'] = {} @@ -88,10 +88,10 @@ class ConfigProvider: # Parse API configs (handle JSON strings) if 'apis' in config_data: cls._parse_api_configs(config_data['apis']) - + # Validate and create model cfg = ServiceConfig.model_validate(config_data) - + # Debug: Model'e dönüştükten sonra kontrol et if cfg.projects and len(cfg.projects) > 0: log_debug(f"🔍 Parsed project - last_update_date: {cfg.projects[0].last_update_date}") @@ -100,20 +100,20 @@ class ConfigProvider: # Log versions published status after parsing for version in cfg.projects[0].versions: log_debug(f"🔍 Parsed version {version.no} - published: {version.published} (type: {type(version.published)})") - + log_debug( "Configuration loaded", projects=len(cfg.projects), apis=len(cfg.apis), users=len(cfg.global_config.users) ) - + return cfg - + except Exception as e: log_error(f"Error loading config", error=str(e), path=str(cls._CONFIG_PATH)) raise ConfigurationError(f"Failed to load configuration: {e}") - + @classmethod def _parse_api_configs(cls, apis: List[Dict[str, Any]]) -> None: """Parse JSON string fields in API configs""" @@ -124,18 +124,18 @@ class ConfigProvider: api['headers'] = json.loads(api['headers']) except json.JSONDecodeError: api['headers'] = {} - + # Parse body_template if 'body_template' in api and isinstance(api['body_template'], str): try: api['body_template'] = json.loads(api['body_template']) except json.JSONDecodeError: api['body_template'] = {} - + # Parse auth configs if 'auth' in api and api['auth']: cls._parse_auth_config(api['auth']) - + @classmethod def _parse_auth_config(cls, auth: Dict[str, Any]) -> None: """Parse auth configuration""" @@ -145,14 +145,14 @@ class ConfigProvider: auth['token_request_body'] = json.loads(auth['token_request_body']) except json.JSONDecodeError: auth['token_request_body'] = {} - + # Parse token_refresh_body if 'token_refresh_body' in auth and isinstance(auth['token_refresh_body'], str): try: auth['token_refresh_body'] = json.loads(auth['token_refresh_body']) except json.JSONDecodeError: auth['token_refresh_body'] = {} - + @classmethod def save(cls, config: ServiceConfig, username: str) -> None: """Thread-safe configuration save with optimistic locking""" @@ -160,11 +160,11 @@ class ConfigProvider: try: # Convert to dict for JSON serialization config_dict = config.model_dump() - + # Load current config for race condition check try: current_config = cls._load() - + # Check for race condition if config.last_update_date and current_config.last_update_date: if not timestamps_equal(config.last_update_date, current_config.last_update_date): @@ -179,89 +179,89 @@ class ConfigProvider: # Eğer mevcut config yüklenemiyorsa, race condition kontrolünü atla log_warning(f"Could not load current config for race condition check: {e}") current_config = None - + # Update metadata config.last_update_date = get_current_timestamp() config.last_update_user = username - + # Convert to JSON - Pydantic v2 kullanımı data = config.model_dump(mode='json') json_str = json.dumps(data, ensure_ascii=False, indent=2) - + # Backup current file if exists backup_path = None if cls._CONFIG_PATH.exists(): backup_path = cls._CONFIG_PATH.with_suffix('.backup') shutil.copy2(str(cls._CONFIG_PATH), str(backup_path)) log_debug(f"Created backup at {backup_path}") - + try: # Write to temporary file first temp_path = cls._CONFIG_PATH.with_suffix('.tmp') with open(temp_path, 'w', encoding='utf-8') as f: f.write(json_str) - + # Validate the temp file by trying to load it with open(temp_path, 'r', encoding='utf-8') as f: test_data = commentjson.load(f) ServiceConfig.model_validate(test_data) - + # If validation passes, replace the original shutil.move(str(temp_path), str(cls._CONFIG_PATH)) - + # Delete backup if save successful if backup_path and backup_path.exists(): backup_path.unlink() - + except Exception as e: # Restore from backup if something went wrong if backup_path and backup_path.exists(): shutil.move(str(backup_path), str(cls._CONFIG_PATH)) log_error(f"Restored configuration from backup due to error: {e}") raise - + # Update cached instance with cls._lock: cls._instance = config - + log_info( "Configuration saved successfully", user=username, last_update=config.last_update_date ) - + except Exception as e: log_error(f"Failed to save config", error=str(e)) raise ConfigurationError( f"Failed to save configuration: {str(e)}", config_key="service_config.jsonc" ) - + # ===================== Environment Methods ===================== - + @classmethod def update_environment(cls, update_data: dict, username: str) -> None: """Update environment configuration""" with cls._lock: config = cls.get() - + # Update providers if 'llm_provider' in update_data: config.global_config.llm_provider = update_data['llm_provider'] - + if 'tts_provider' in update_data: config.global_config.tts_provider = update_data['tts_provider'] - + if 'stt_provider' in update_data: config.global_config.stt_provider = update_data['stt_provider'] - + # Log activity cls._add_activity( config, username, "UPDATE_ENVIRONMENT", "environment", None, f"Updated providers" ) - + # Save cls.save(config, username) @@ -270,9 +270,9 @@ class ConfigProvider: """Ensure config has required provider structure""" if 'config' not in config_data: config_data['config'] = {} - + config = config_data['config'] - + # Ensure provider settings exist if 'llm_provider' not in config: config['llm_provider'] = { @@ -281,7 +281,7 @@ class ConfigProvider: 'endpoint': 'http://localhost:8080', 'settings': {} } - + if 'tts_provider' not in config: config['tts_provider'] = { 'name': 'no_tts', @@ -289,7 +289,7 @@ class ConfigProvider: 'endpoint': None, 'settings': {} } - + if 'stt_provider' not in config: config['stt_provider'] = { 'name': 'no_stt', @@ -297,7 +297,7 @@ class ConfigProvider: 'endpoint': None, 'settings': {} } - + # Ensure providers list exists if 'providers' not in config: config['providers'] = [ @@ -329,27 +329,27 @@ class ConfigProvider: "description": "Speech-to-Text disabled" } ] - + # ===================== Project Methods ===================== - + @classmethod def get_project(cls, project_id: int) -> Optional[ProjectConfig]: """Get project by ID""" config = cls.get() return next((p for p in config.projects if p.id == project_id), None) - + @classmethod def create_project(cls, project_data: dict, username: str) -> ProjectConfig: """Create new project with initial version""" with cls._lock: config = cls.get() - + # Check for duplicate name existing_project = next((p for p in config.projects if p.name == project_data['name'] and not p.deleted), None) if existing_project: raise DuplicateResourceError("Project", project_data['name']) - + # Create project project = ProjectConfig( id=config.project_id_counter, @@ -359,7 +359,7 @@ class ConfigProvider: versions=[], # Boş başla **project_data ) - + # Create initial version with proper models initial_version = VersionConfig( no=1, @@ -387,46 +387,46 @@ class ConfigProvider: last_update_date=None, last_update_user=None, publish_date=None, - published_by=None + published_by=None ) - + # Add initial version to project project.versions.append(initial_version) project.version_id_counter = 2 # Next version will be 2 - + # Update config config.projects.append(project) config.project_id_counter += 1 - + # Log activity cls._add_activity( config, username, "CREATE_PROJECT", "project", project.name, f"Created with initial version" ) - + # Save cls.save(config, username) - + log_info( "Project created with initial version", project_id=project.id, name=project.name, user=username ) - + return project - + @classmethod def update_project(cls, project_id: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> ProjectConfig: """Update project with optimistic locking""" with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + # Check race condition if expected_last_update is not None and expected_last_update != '': if project.last_update_date and not timestamps_equal(expected_last_update, project.last_update_date): @@ -438,104 +438,104 @@ class ConfigProvider: entity_type="project", entity_id=project_id ) - + # Update fields for key, value in update_data.items(): if hasattr(project, key) and key not in ['id', 'created_date', 'created_by', 'last_update_date', 'last_update_user']: setattr(project, key, value) - + project.last_update_date = get_current_timestamp() project.last_update_user = username - + cls._add_activity( config, username, "UPDATE_PROJECT", "project", project.name ) - + # Save cls.save(config, username) - + log_info( "Project updated", project_id=project.id, user=username ) - + return project - + @classmethod def delete_project(cls, project_id: int, username: str) -> None: """Soft delete project""" with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + project.deleted = True project.last_update_date = get_current_timestamp() project.last_update_user = username - + cls._add_activity( config, username, "DELETE_PROJECT", "project", project.name ) - + # Save cls.save(config, username) - + log_info( "Project deleted", project_id=project.id, user=username ) - + @classmethod def toggle_project(cls, project_id: int, username: str) -> bool: """Toggle project enabled status""" with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + project.enabled = not project.enabled project.last_update_date = get_current_timestamp() project.last_update_user = username - + # Log activity cls._add_activity( config, username, "TOGGLE_PROJECT", "project", project.name, f"{'Enabled' if project.enabled else 'Disabled'}" ) - + # Save cls.save(config, username) - + log_info( "Project toggled", project_id=project.id, enabled=project.enabled, user=username ) - + return project.enabled - + # ===================== Version Methods ===================== - + @classmethod def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig: """Create new version""" with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + # Handle source version copy if 'source_version_no' in version_data and version_data['source_version_no']: source_version = next((v for v in project.versions if v.no == version_data['source_version_no']), None) @@ -543,7 +543,7 @@ class ConfigProvider: # Copy from source version version_dict = source_version.model_dump() # Remove fields that shouldn't be copied - for field in ['no', 'created_date', 'created_by', 'published', 'publish_date', + for field in ['no', 'created_date', 'created_by', 'published', 'publish_date', 'published_by', 'last_update_date', 'last_update_user']: version_dict.pop(field, None) # Override with provided data @@ -586,7 +586,7 @@ class ConfigProvider: }, 'intents': [] } - + # Create version version = VersionConfig( no=project.version_id_counter, @@ -600,60 +600,60 @@ class ConfigProvider: published_by=None, **version_dict ) - + # Update project project.versions.append(version) project.version_id_counter += 1 project.last_update_date = get_current_timestamp() project.last_update_user = username - + # Log activity cls._add_activity( config, username, "CREATE_VERSION", "version", version.no, f"{project.name} v{version.no}", f"Project: {project.name}" ) - + # Save cls.save(config, username) - + log_info( "Version created", project_id=project.id, version_no=version.no, user=username ) - + return version - + @classmethod def publish_version(cls, project_id: int, version_no: int, username: str) -> tuple[ProjectConfig, VersionConfig]: """Publish a version""" with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + version = next((v for v in project.versions if v.no == version_no), None) if not version: raise ResourceNotFoundError("version", version_no) - + # Unpublish other versions for v in project.versions: if v.published and v.no != version_no: v.published = False - + # Publish this version version.published = True version.publish_date = get_current_timestamp() version.published_by = username - + # Update project project.last_update_date = get_current_timestamp() project.last_update_user = username - + # Log activity cls._add_activity( config, username, "PUBLISH_VERSION", @@ -662,14 +662,14 @@ class ConfigProvider: # Save cls.save(config, username) - + log_info( "Version published", project_id=project.id, version_no=version.no, user=username ) - + return project, version @classmethod @@ -678,22 +678,22 @@ class ConfigProvider: with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + version = next((v for v in project.versions if v.no == version_no), None) if not version: raise ResourceNotFoundError("version", version_no) - + # Ensure published is a boolean (safety check) if version.published is None: version.published = False - + # Published versions cannot be edited if version.published: raise ValidationError("Published versions cannot be modified") - + # Check race condition if expected_last_update is not None and expected_last_update != '': if version.last_update_date and not timestamps_equal(expected_last_update, version.last_update_date): @@ -704,8 +704,8 @@ class ConfigProvider: last_update_date=version.last_update_date, entity_type="version", entity_id=f"{project_id}:{version_no}" - ) - + ) + # Update fields for key, value in update_data.items(): if hasattr(version, key) and key not in ['no', 'created_date', 'created_by', 'published', 'last_update_date']: @@ -723,125 +723,125 @@ class ConfigProvider: setattr(version, key, intents) else: setattr(version, key, value) - + version.last_update_date = get_current_timestamp() version.last_update_user = username - + # Update project last update project.last_update_date = get_current_timestamp() project.last_update_user = username - + # Log activity cls._add_activity( config, username, "UPDATE_VERSION", "version", f"{project.name} v{version.no}" ) - + # Save cls.save(config, username) - + log_info( "Version updated", project_id=project.id, version_no=version.no, user=username ) - + return version - + @classmethod def delete_version(cls, project_id: int, version_no: int, username: str) -> None: """Soft delete version""" with cls._lock: config = cls.get() project = cls.get_project(project_id) - + if not project: raise ResourceNotFoundError("project", project_id) - + version = next((v for v in project.versions if v.no == version_no), None) if not version: raise ResourceNotFoundError("version", version_no) - + if version.published: raise ValidationError("Cannot delete published version") - + version.deleted = True version.last_update_date = get_current_timestamp() version.last_update_user = username - + # Update project project.last_update_date = get_current_timestamp() project.last_update_user = username - + # Log activity cls._add_activity( config, username, "DELETE_VERSION", "version", f"{project.name} v{version.no}" ) - + # Save cls.save(config, username) - + log_info( "Version deleted", project_id=project.id, version_no=version.no, user=username ) - + # ===================== API Methods ===================== @classmethod def create_api(cls, api_data: dict, username: str) -> APIConfig: """Create new API""" with cls._lock: config = cls.get() - + # Check for duplicate name existing_api = next((a for a in config.apis if a.name == api_data['name'] and not a.deleted), None) if existing_api: raise DuplicateResourceError("API", api_data['name']) - + # Create API api = APIConfig( created_date=get_current_timestamp(), created_by=username, **api_data ) - + # Add to config config.apis.append(api) - + # Rebuild index config.build_index() - + # Log activity cls._add_activity( config, username, "CREATE_API", "api", api.name ) - + # Save cls.save(config, username) - + log_info( "API created", api_name=api.name, user=username ) - + return api - + @classmethod def update_api(cls, api_name: str, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> APIConfig: """Update API with optimistic locking""" with cls._lock: config = cls.get() api = config.get_api(api_name) - + if not api: raise ResourceNotFoundError("api", api_name) - + # Check race condition if expected_last_update is not None and expected_last_update != '': if api.last_update_date and not timestamps_equal(expected_last_update, api.last_update_date): @@ -852,68 +852,68 @@ class ConfigProvider: last_update_date=api.last_update_date, entity_type="api", entity_id=api.name - ) - + ) + # Update fields for key, value in update_data.items(): if hasattr(api, key) and key not in ['name', 'created_date', 'created_by', 'last_update_date']: setattr(api, key, value) - + api.last_update_date = get_current_timestamp() api.last_update_user = username - + # Rebuild index config.build_index() - + # Log activity cls._add_activity( config, username, "UPDATE_API", "api", api.name ) - + # Save cls.save(config, username) - + log_info( "API updated", api_name=api.name, user=username ) - + return api - + @classmethod def delete_api(cls, api_name: str, username: str) -> None: """Soft delete API""" with cls._lock: config = cls.get() api = config.get_api(api_name) - + if not api: raise ResourceNotFoundError("api", api_name) - + api.deleted = True api.last_update_date = get_current_timestamp() api.last_update_user = username - + # Rebuild index config.build_index() - + # Log activity cls._add_activity( config, username, "DELETE_API", "api", api.name ) - + # Save cls.save(config, username) - + log_info( "API deleted", api_name=api.name, user=username ) - + # ===================== Activity Methods ===================== @classmethod def _add_activity( @@ -930,9 +930,9 @@ class ConfigProvider: max_id = 0 if config.activity_log: max_id = max((entry.id for entry in config.activity_log if entry.id), default=0) - + activity_id = max_id + 1 - + activity = ActivityLogEntry( id=activity_id, timestamp=get_current_timestamp(), @@ -942,9 +942,9 @@ class ConfigProvider: entity_name=entity_name, details=details ) - + config.activity_log.append(activity) - + # Keep only last 1000 entries if len(config.activity_log) > 1000: config.activity_log = config.activity_log[-1000:] \ No newline at end of file diff --git a/config/locale_manager.py b/config/locale_manager.py index 31673494faffb6f6df4863db4b1af25703844129..049a44c04ebf141ec5b2e0da18c0a977b6b3b6b7 100644 --- a/config/locale_manager.py +++ b/config/locale_manager.py @@ -8,34 +8,34 @@ from pathlib import Path from typing import Dict, List, Optional from datetime import datetime import sys -from logger import log_info, log_error, log_debug, log_warning +from utils.logger import log_info, log_error, log_debug, log_warning class LocaleManager: """Manages locale files for TTS preprocessing and system-wide language support""" - + _cache: Dict[str, Dict] = {} _available_locales: Optional[List[Dict[str, str]]] = None - + @classmethod def get_locale(cls, language: str) -> Dict: """Get locale data with caching""" if language not in cls._cache: cls._cache[language] = cls._load_locale(language) return cls._cache[language] - + @classmethod def _load_locale(cls, language: str) -> Dict: """Load locale from file - accepts both 'tr' and 'tr-TR' formats""" - base_path = Path(__file__).parent / "locales" - + base_path = Path(__file__).parent.parent / "locales" + # First try exact match locale_file = base_path / f"{language}.json" - + # If not found and has region code, try without region (tr-TR -> tr) if not locale_file.exists() and '-' in language: language_code = language.split('-')[0] locale_file = base_path / f"{language_code}.json" - + if locale_file.exists(): try: with open(locale_file, 'r', encoding='utf-8') as f: @@ -44,7 +44,7 @@ class LocaleManager: return data except Exception as e: log_error(f"Failed to load locale file {locale_file}", e) - + # Try English fallback fallback_file = base_path / "en.json" if fallback_file.exists(): @@ -55,7 +55,7 @@ class LocaleManager: return data except: pass - + # Minimal fallback if no locale files exist log_warning(f"⚠️ No locale files found, using minimal fallback") return { @@ -64,24 +64,24 @@ class LocaleManager: "name": "Türkçe", "english_name": "Turkish" } - + @classmethod def list_available_locales(cls) -> List[str]: """List all available locale files""" - base_path = Path(__file__).parent / "locales" + base_path = Path(__file__).parent.parent / "locales" if not base_path.exists(): return ["en", "tr"] # Default locales return [f.stem for f in base_path.glob("*.json")] - + @classmethod def get_available_locales_with_names(cls) -> List[Dict[str, str]]: """Get list of all available locales with their display names""" if cls._available_locales is not None: return cls._available_locales - + cls._available_locales = [] - base_path = Path(__file__).parent / "locales" - + base_path = Path(__file__).parent.parent / "locales" + if not base_path.exists(): # Return default locales if directory doesn't exist cls._available_locales = [ @@ -97,29 +97,29 @@ class LocaleManager: } ] return cls._available_locales - + # Load all locale files for locale_file in base_path.glob("*.json"): try: locale_code = locale_file.stem locale_data = cls.get_locale(locale_code) - + cls._available_locales.append({ "code": locale_code, "name": locale_data.get("name", locale_code), "english_name": locale_data.get("english_name", locale_code) }) - + log_info(f"✅ Loaded locale: {locale_code} - {locale_data.get('name', 'Unknown')}") - + except Exception as e: log_error(f"❌ Failed to load locale {locale_file}", e) - + # Sort by name for consistent ordering cls._available_locales.sort(key=lambda x: x['name']) - + return cls._available_locales - + @classmethod def get_locale_details(cls, locale_code: str) -> Optional[Dict]: """Get detailed info for a specific locale""" @@ -130,33 +130,33 @@ class LocaleManager: return locale_data except: return None - + @classmethod def is_locale_supported(cls, locale_code: str) -> bool: """Check if a locale is supported system-wide""" available_codes = [locale['code'] for locale in cls.get_available_locales_with_names()] return locale_code in available_codes - + @classmethod def validate_project_languages(cls, languages: List[str]) -> List[str]: """Validate that all languages are system-supported, return invalid ones""" available_codes = [locale['code'] for locale in cls.get_available_locales_with_names()] invalid_languages = [ - lang for lang in languages + lang for lang in languages if lang not in available_codes ] return invalid_languages - + @classmethod def get_default_locale(cls) -> str: """Get system default locale""" available_locales = cls.get_available_locales_with_names() - + # Priority: tr-TR, en-US, first available for preferred in ["tr-TR", "en-US"]: if any(locale['code'] == preferred for locale in available_locales): return preferred - + # Return first available or fallback if available_locales: return available_locales[0]['code'] diff --git a/credentials/google-service-account.json b/credentials/google-service-account.json index cd7a2c9d57249064035d5e0549bb8e5d846bc2bb..1ea36d0193270b0a34bb0ce9aa11be5a531b4d95 100644 --- a/credentials/google-service-account.json +++ b/credentials/google-service-account.json @@ -1,13 +1,13 @@ -{ - "type": "service_account", - "project_id": "ucs-human-demo", - "private_key_id": "d60575b3b0ffe2f580eb01b56414b0e1ae950268", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDwwRPsu0J974WD\nkVQmQo2VI9ntuu9f1oDqZJ4Gr1SMbJaYzuUfzw28PXwv1hN4jmdXbGFn427g97BS\nHle0lMuR9ZXe4EWa0LsvT00VmsYsJgpnpUtxZT2WO/+guD4PSpwK2NiDnIyqO0qQ\ns0JcR8YioA2LOnxYUZkmz+N8oPAAfj+jed4d6vmXl8FHoUhk6L70Jq03HuJ3Uz7M\nrdptL0Hed1msvRjvNDhLAqxPWz8AANnVpvst0jjrFj4ZBtL6Yb+OiSeSXHSzrPK5\n46HiORZly4QMw18np7N3u+PBsnlnkj+/pv4cKWsvg7xOGXiqNtQTIb/pdWazXpU7\nJe3XDNGjAgMBAAECggEAK2CeVmjm8gnV5H6qyrnzCIwNF+g2eO4NDC5Uyp+MfECU\nYbPlVHXZ47CwT24i0/XUaMv+QNmZgK8f9avB4adthj7ZYe7Gm74/+6YuHVZlnk68\nUTBXB3dWQVtOE4cep2Kp+spXOF9ceM91/9xMeJP1/wcXaZ6ACOmqznNmaW4V0ACV\nvx/sV8FrX93LEF40t8e7jXqbd1yRPFU+WHPfPEQrNqdW/fnQKBgjMhvRiI45W4t2\nZEB+whHdoJ/UdTgjA33+K7KdDa9HayY939/ZAMFLV6j+l1NglMlx7/FhM2gx7tp6\nbS6vyIZRiRJzI94BuWo1wqKcUTn+GM6BoIxU5YV9AQKBgQD1UeHO4Q451J4epBss\nZlIky4CjICJA9XU1w8MetqPJcfFJyvxxXjl6VsF9Pf4ONXlxQiz6PDHH5L8tTy9I\nq4Dk3j/oi8dgDAvKzOOi2s8TZZK+PXkmLhoVqtftLL3LJhU5Ld72DJR1Lua2Gxnx\nJOfl1e8t5EfzNT6nGhBsjT/cLQKBgQD7PE+ovZrF+kcZqN9+uft5F4SLb0SYpGpK\npVA0FCEpcl2HGDIgPYwwNBpBAszYeI9YR1wbQFsSRYcsFTCCxX2cChynxy6jsQvM\n6OMDLbHmtYw8b9qzmDVcusfXQjGdzKqc82ep5iujHEcUSNOzaf4f6WrJyQenz7dK\nxIKnwI53DwKBgQCB1H/o+PqKaJf2J2uqJ8y5ZGoD6vG15zHM7nnJO2ebKQ5Fu4O2\ni+Nnd5qXKcPWyT4oTpl3JXxDCjCTTiD8GKfyeBzieXdewYFMJvsiKSMGZO8wd2Ay\ncJulc/EquE8JwHHi/P/OwAGhstyu69Di6mFAJeSbKQFbGYa68PRYPrjZUQKBgGg/\nfGZuVpyz33DcS/DPx3NVuOAKyZH1F03mDsOtXp1OIVT/Sz1pjJQr6oDzYoCodgKR\nibydFa0dQJugJ0L8I8TtxToxQj8WJele8WPOQDWVO52QZFWFYQ8bSfUeOGxcEqeR\nsIAlTBIgl7XpCj82SgZ/2pnkWtLdNBdIN1bYZcUtAoGANe8awW9FnUEEFfinrag/\n5Q8dgV9C5kyIEVWfNRi2dg2UpTj8c2vrvzKotOZDRkpGAkz9LYHg45mNLflXgaTO\nepNqV+IC/2lrPEr+VcLB9dJ6tBdnHhD7imwsZTyqaiIH1xFm0Z2sD8x3GNTaBBA7\nyetW5SYZy8pHXZcaC7PfcDg=\n-----END PRIVATE KEY-----\n", - "client_email": "745400736051-compute@developer.gserviceaccount.com", - "client_id": "116817469632088219353", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/745400736051-compute%40developer.gserviceaccount.com", - "universe_domain": "googleapis.com" -} +{ + "type": "service_account", + "project_id": "ucs-human-demo", + "private_key_id": "d60575b3b0ffe2f580eb01b56414b0e1ae950268", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDwwRPsu0J974WD\nkVQmQo2VI9ntuu9f1oDqZJ4Gr1SMbJaYzuUfzw28PXwv1hN4jmdXbGFn427g97BS\nHle0lMuR9ZXe4EWa0LsvT00VmsYsJgpnpUtxZT2WO/+guD4PSpwK2NiDnIyqO0qQ\ns0JcR8YioA2LOnxYUZkmz+N8oPAAfj+jed4d6vmXl8FHoUhk6L70Jq03HuJ3Uz7M\nrdptL0Hed1msvRjvNDhLAqxPWz8AANnVpvst0jjrFj4ZBtL6Yb+OiSeSXHSzrPK5\n46HiORZly4QMw18np7N3u+PBsnlnkj+/pv4cKWsvg7xOGXiqNtQTIb/pdWazXpU7\nJe3XDNGjAgMBAAECggEAK2CeVmjm8gnV5H6qyrnzCIwNF+g2eO4NDC5Uyp+MfECU\nYbPlVHXZ47CwT24i0/XUaMv+QNmZgK8f9avB4adthj7ZYe7Gm74/+6YuHVZlnk68\nUTBXB3dWQVtOE4cep2Kp+spXOF9ceM91/9xMeJP1/wcXaZ6ACOmqznNmaW4V0ACV\nvx/sV8FrX93LEF40t8e7jXqbd1yRPFU+WHPfPEQrNqdW/fnQKBgjMhvRiI45W4t2\nZEB+whHdoJ/UdTgjA33+K7KdDa9HayY939/ZAMFLV6j+l1NglMlx7/FhM2gx7tp6\nbS6vyIZRiRJzI94BuWo1wqKcUTn+GM6BoIxU5YV9AQKBgQD1UeHO4Q451J4epBss\nZlIky4CjICJA9XU1w8MetqPJcfFJyvxxXjl6VsF9Pf4ONXlxQiz6PDHH5L8tTy9I\nq4Dk3j/oi8dgDAvKzOOi2s8TZZK+PXkmLhoVqtftLL3LJhU5Ld72DJR1Lua2Gxnx\nJOfl1e8t5EfzNT6nGhBsjT/cLQKBgQD7PE+ovZrF+kcZqN9+uft5F4SLb0SYpGpK\npVA0FCEpcl2HGDIgPYwwNBpBAszYeI9YR1wbQFsSRYcsFTCCxX2cChynxy6jsQvM\n6OMDLbHmtYw8b9qzmDVcusfXQjGdzKqc82ep5iujHEcUSNOzaf4f6WrJyQenz7dK\nxIKnwI53DwKBgQCB1H/o+PqKaJf2J2uqJ8y5ZGoD6vG15zHM7nnJO2ebKQ5Fu4O2\ni+Nnd5qXKcPWyT4oTpl3JXxDCjCTTiD8GKfyeBzieXdewYFMJvsiKSMGZO8wd2Ay\ncJulc/EquE8JwHHi/P/OwAGhstyu69Di6mFAJeSbKQFbGYa68PRYPrjZUQKBgGg/\nfGZuVpyz33DcS/DPx3NVuOAKyZH1F03mDsOtXp1OIVT/Sz1pjJQr6oDzYoCodgKR\nibydFa0dQJugJ0L8I8TtxToxQj8WJele8WPOQDWVO52QZFWFYQ8bSfUeOGxcEqeR\nsIAlTBIgl7XpCj82SgZ/2pnkWtLdNBdIN1bYZcUtAoGANe8awW9FnUEEFfinrag/\n5Q8dgV9C5kyIEVWfNRi2dg2UpTj8c2vrvzKotOZDRkpGAkz9LYHg45mNLflXgaTO\nepNqV+IC/2lrPEr+VcLB9dJ6tBdnHhD7imwsZTyqaiIH1xFm0Z2sD8x3GNTaBBA7\nyetW5SYZy8pHXZcaC7PfcDg=\n-----END PRIVATE KEY-----\n", + "client_email": "745400736051-compute@developer.gserviceaccount.com", + "client_id": "116817469632088219353", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/745400736051-compute%40developer.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/flare-ui/angular.json b/flare-ui/angular.json index f154fff5d63c265e33d23aa561494fbefe21e96e..ab5845f2b6e2f114a567461f1e5137cb35d9b369 100644 --- a/flare-ui/angular.json +++ b/flare-ui/angular.json @@ -1,117 +1,117 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "newProjectRoot": "projects", - "projects": { - "flare-ui": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "outputPath": "dist/flare-ui", - "index": "src/index.html", - "main": "src/main.ts", - "polyfills": [ - "zone.js" - ], - "tsConfig": "tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "@angular/material/prebuilt-themes/indigo-pink.css", - "src/styles.scss" - ], - "scripts": [] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "2mb", - "maximumError": "4mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "8kb", - "maximumError": "16kb" - } - ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], - "outputHashing": "all" - }, - "development": { - "buildOptimizer": false, - "optimization": false, - "vendorChunk": true, - "extractLicenses": false, - "sourceMap": true, - "namedChunks": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "configurations": { - "production": { - "browserTarget": "flare-ui:build:production" - }, - "development": { - "browserTarget": "flare-ui:build:development" - } - }, - "defaultConfiguration": "development", - "options": { - "proxyConfig": "src/proxy.conf.json" - } - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "browserTarget": "flare-ui:build" - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/styles.scss" - ], - "scripts": [] - } - } - } - } - }, - "cli": { - "analytics": false - } +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "flare-ui": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/flare-ui", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "@angular/material/prebuilt-themes/indigo-pink.css", + "src/styles.scss" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "4mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "8kb", + "maximumError": "16kb" + } + ], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "outputHashing": "all" + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "browserTarget": "flare-ui:build:production" + }, + "development": { + "browserTarget": "flare-ui:build:development" + } + }, + "defaultConfiguration": "development", + "options": { + "proxyConfig": "src/proxy.conf.json" + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "flare-ui:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + } + } + } + } + }, + "cli": { + "analytics": false + } } \ No newline at end of file diff --git a/flare-ui/package.json b/flare-ui/package.json index e30d943242622e76a86043986df60bcb0b242e8b..101975dfc399c5b5f91d5c6595266a08c5ff7ba8 100644 --- a/flare-ui/package.json +++ b/flare-ui/package.json @@ -1,43 +1,43 @@ -{ - "name": "flare-ui", - "version": "0.1.0", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "build:dev": "ng build --configuration=development", - "build:prod": "ng build --configuration=production", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, - "private": true, - "dependencies": { - "@angular/animations": "^17.0.0", - "@angular/cdk": "^17.0.0", - "@angular/common": "^17.0.0", - "@angular/compiler": "^17.0.0", - "@angular/core": "^17.0.0", - "@angular/forms": "^17.0.0", - "@angular/material": "^17.0.0", - "@angular/platform-browser": "^17.0.0", - "@angular/platform-browser-dynamic": "^17.0.0", - "@angular/router": "^17.0.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.14.0" - }, - "devDependencies": { - "@angular-devkit/build-angular": "^17.0.0", - "@angular/cli": "^17.0.0", - "@angular/compiler-cli": "^17.0.0", - "@types/jasmine": "~5.1.0", - "@types/node": "^20.0.0", - "jasmine-core": "~5.1.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", - "typescript": "~5.2.2" - } +{ + "name": "flare-ui", + "version": "0.1.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "build:dev": "ng build --configuration=development", + "build:prod": "ng build --configuration=production", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/animations": "^17.0.0", + "@angular/cdk": "^17.0.0", + "@angular/common": "^17.0.0", + "@angular/compiler": "^17.0.0", + "@angular/core": "^17.0.0", + "@angular/forms": "^17.0.0", + "@angular/material": "^17.0.0", + "@angular/platform-browser": "^17.0.0", + "@angular/platform-browser-dynamic": "^17.0.0", + "@angular/router": "^17.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^17.0.0", + "@angular/cli": "^17.0.0", + "@angular/compiler-cli": "^17.0.0", + "@types/jasmine": "~5.1.0", + "@types/node": "^20.0.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.2.2" + } } \ No newline at end of file diff --git a/flare-ui/src/app/app.component.ts b/flare-ui/src/app/app.component.ts index 6dc5290c2496e84140a723946ee80febbce2cb12..18850dc797aa8d6eac514d2fc4cb2e9bcdcd2a5d 100644 --- a/flare-ui/src/app/app.component.ts +++ b/flare-ui/src/app/app.component.ts @@ -1,77 +1,77 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError, RouterOutlet } from '@angular/router'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; - -@Component({ - selector: 'app-root', - standalone: true, - imports: [CommonModule, RouterOutlet, MatProgressSpinnerModule], - template: ` -
- -
- -

Loading...

-
- - - -
- `, - styles: [` - .app-container { - position: relative; - min-height: 100vh; - } - - .global-spinner { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(255, 255, 255, 0.9); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - z-index: 9999; - } - - .global-spinner p { - margin-top: 20px; - color: #666; - font-size: 16px; - } - `] -}) -export class AppComponent implements OnInit { - loading = true; - - constructor(private router: Router) {} - - ngOnInit() { - // Router events - spinner'ı navigation event'lere göre yönet - this.router.events.subscribe(event => { - if (event instanceof NavigationStart) { - this.loading = true; - } else if ( - event instanceof NavigationEnd || - event instanceof NavigationCancel || - event instanceof NavigationError - ) { - // Navigation tamamlandığında spinner'ı kapat - setTimeout(() => { - this.loading = false; - - // Initial loader'ı kaldır (varsa) - const initialLoader = document.querySelector('.initial-loader'); - if (initialLoader) { - initialLoader.remove(); - } - }, 300); - } - }); - } +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError, RouterOutlet } from '@angular/router'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [CommonModule, RouterOutlet, MatProgressSpinnerModule], + template: ` +
+ +
+ +

Loading...

+
+ + + +
+ `, + styles: [` + .app-container { + position: relative; + min-height: 100vh; + } + + .global-spinner { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 9999; + } + + .global-spinner p { + margin-top: 20px; + color: #666; + font-size: 16px; + } + `] +}) +export class AppComponent implements OnInit { + loading = true; + + constructor(private router: Router) {} + + ngOnInit() { + // Router events - spinner'ı navigation event'lere göre yönet + this.router.events.subscribe(event => { + if (event instanceof NavigationStart) { + this.loading = true; + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ) { + // Navigation tamamlandığında spinner'ı kapat + setTimeout(() => { + this.loading = false; + + // Initial loader'ı kaldır (varsa) + const initialLoader = document.querySelector('.initial-loader'); + if (initialLoader) { + initialLoader.remove(); + } + }, 300); + } + }); + } } \ No newline at end of file diff --git a/flare-ui/src/app/app.config.ts b/flare-ui/src/app/app.config.ts index da1282816b2ec54dfec7732bf153d47170196cfe..d98d0f5266912838c20f43d3f87c4224b3d43480 100644 --- a/flare-ui/src/app/app.config.ts +++ b/flare-ui/src/app/app.config.ts @@ -1,17 +1,17 @@ -import { ApplicationConfig, ErrorHandler } from '@angular/core'; -import { provideRouter } from '@angular/router'; -import { routes } from './app.routes'; -import { provideAnimations } from '@angular/platform-browser/animations'; -import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http'; -import { authInterceptor } from './interceptors/auth.interceptor'; -import { GlobalErrorHandler, ErrorInterceptor } from './services/error-handler.service'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideRouter(routes), - provideAnimations(), - provideHttpClient(withInterceptors([authInterceptor])), - { provide: ErrorHandler, useClass: GlobalErrorHandler }, - { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true } - ] +import { ApplicationConfig, ErrorHandler } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { authInterceptor } from './interceptors/auth.interceptor'; +import { GlobalErrorHandler, ErrorInterceptor } from './services/error-handler.service'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + provideAnimations(), + provideHttpClient(withInterceptors([authInterceptor])), + { provide: ErrorHandler, useClass: GlobalErrorHandler }, + { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true } + ] }; \ No newline at end of file diff --git a/flare-ui/src/app/app.routes.ts b/flare-ui/src/app/app.routes.ts index ea2d96e98a4e5d6bb2d2b59565c99271bb5c6967..0b3dbd362b004a7a8c9569f91a2dae56b40db39d 100644 --- a/flare-ui/src/app/app.routes.ts +++ b/flare-ui/src/app/app.routes.ts @@ -1,60 +1,60 @@ -import { Routes } from '@angular/router'; -import { authGuard } from './guards/auth.guard'; -import { RealtimeChatComponent } from './components/chat/realtime-chat.component'; - -export const routes: Routes = [ - { - path: 'login', - loadComponent: () => import('./components/login/login.component').then(m => m.LoginComponent) - }, - { - path: '', - loadComponent: () => import('./components/main/main.component').then(m => m.MainComponent), - canActivate: [authGuard], - children: [ - { - path: 'user-info', - loadComponent: () => import('./components/user-info/user-info.component').then(m => m.UserInfoComponent) - }, - { - path: 'environment', - loadComponent: () => import('./components/environment/environment.component').then(m => m.EnvironmentComponent) - }, - { - path: 'apis', - loadComponent: () => import('./components/apis/apis.component').then(m => m.ApisComponent) - }, - { - path: 'projects', - loadComponent: () => import('./components/projects/projects.component').then(m => m.ProjectsComponent) - }, - { - path: 'test', - loadComponent: () => import('./components/test/test.component').then(m => m.TestComponent) - }, - { - path: 'chat', - loadComponent: () => import('./components/chat/chat.component').then(c => c.ChatComponent) - }, - { - path: 'spark', - loadComponent: () => import('./components/spark/spark.component').then(m => m.SparkComponent) - }, - { - path: 'realtime-chat/:sessionId', - loadComponent: () => import('./components/chat/realtime-chat.component').then(c => c.RealtimeChatComponent), - canActivate: [authGuard ], - data: { title: 'Real-time Chat' } - }, - { - path: '', - redirectTo: 'projects', - pathMatch: 'full' - } - ] - }, - { - path: '**', - redirectTo: '' - } +import { Routes } from '@angular/router'; +import { authGuard } from './guards/auth.guard'; +import { RealtimeChatComponent } from './components/chat/realtime-chat.component'; + +export const routes: Routes = [ + { + path: 'login', + loadComponent: () => import('./components/login/login.component').then(m => m.LoginComponent) + }, + { + path: '', + loadComponent: () => import('./components/main/main.component').then(m => m.MainComponent), + canActivate: [authGuard], + children: [ + { + path: 'user-info', + loadComponent: () => import('./components/user-info/user-info.component').then(m => m.UserInfoComponent) + }, + { + path: 'environment', + loadComponent: () => import('./components/environment/environment.component').then(m => m.EnvironmentComponent) + }, + { + path: 'apis', + loadComponent: () => import('./components/apis/apis.component').then(m => m.ApisComponent) + }, + { + path: 'projects', + loadComponent: () => import('./components/projects/projects.component').then(m => m.ProjectsComponent) + }, + { + path: 'test', + loadComponent: () => import('./components/test/test.component').then(m => m.TestComponent) + }, + { + path: 'chat', + loadComponent: () => import('./components/chat/chat.component').then(c => c.ChatComponent) + }, + { + path: 'spark', + loadComponent: () => import('./components/spark/spark.component').then(m => m.SparkComponent) + }, + { + path: 'realtime-chat/:sessionId', + loadComponent: () => import('./components/chat/realtime-chat.component').then(c => c.RealtimeChatComponent), + canActivate: [authGuard ], + data: { title: 'Real-time Chat' } + }, + { + path: '', + redirectTo: 'projects', + pathMatch: 'full' + } + ] + }, + { + path: '**', + redirectTo: '' + } ]; \ No newline at end of file diff --git a/flare-ui/src/app/components/activity-log/activity-log.component.ts b/flare-ui/src/app/components/activity-log/activity-log.component.ts index 1adef405d442e7d2b7c9d4a31dfeb2112d23e308..87d812a89d6b5deefc742eeebe68a0ee1fe12484 100644 --- a/flare-ui/src/app/components/activity-log/activity-log.component.ts +++ b/flare-ui/src/app/components/activity-log/activity-log.component.ts @@ -1,430 +1,430 @@ -import { Component, EventEmitter, Output, inject, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; -import { MatListModule } from '@angular/material/list'; -import { MatCardModule } from '@angular/material/card'; -import { MatDividerModule } from '@angular/material/divider'; -import { Subject, takeUntil } from 'rxjs'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; - -interface ActivityLog { - id: number; - timestamp: string; - user: string; - action: string; - entity_type: string; - entity_id: any; - entity_name: string; - details?: string; -} - -interface ActivityLogResponse { - items: ActivityLog[]; - total: number; - page: number; - limit: number; - pages: number; -} - -@Component({ - selector: 'app-activity-log', - standalone: true, - imports: [ - CommonModule, - MatProgressSpinnerModule, - MatButtonModule, - MatIconModule, - MatPaginatorModule, - MatListModule, - MatCardModule, - MatDividerModule, - MatSnackBarModule - ], - template: ` - - - - notifications - Recent Activities - - - - - - @if (loading && activities.length === 0) { -
- -
- } @else if (error && activities.length === 0) { -
- error_outline -

{{ error }}

- -
- } @else if (activities.length === 0) { -
- inbox -

No activities found

-
- } @else { - - @for (activity of activities; track activity.id) { - - {{ getActivityIcon(activity.action) }} -
- {{ getRelativeTime(activity.timestamp) }} -
-
- {{ activity.user }} {{ getActionText(activity) }} - {{ activity.entity_name }} - @if (activity.details) { - • {{ activity.details }} - } -
-
- @if (!$last) { - - } - } -
- } -
- - - - - - - - - -
- `, - styles: [` - .activity-log-dropdown { - width: 450px; - max-height: 600px; - display: flex; - flex-direction: column; - overflow: hidden; - } - - mat-card-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px; - background-color: #424242; - color: white; - - mat-card-title { - margin: 0; - display: flex; - align-items: center; - gap: 8px; - font-size: 18px; - color: white; - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - color: white; - } - } - - button { - color: white; - } - } - - mat-card-content { - flex: 1; - overflow-y: auto; - padding: 0; - min-height: 200px; - max-height: 400px; - } - - .activity-list { - padding: 0; - - mat-list-item { - height: auto; - min-height: 72px; - padding: 12px 16px; - - &:hover { - background-color: #f5f5f5; - } - - .activity-time { - font-size: 12px; - color: #666; - } - - strong { - color: #1976d2; - margin-right: 4px; - } - - em { - color: #673ab7; - font-style: normal; - font-weight: 500; - margin: 0 4px; - } - - .details { - color: #666; - font-size: 12px; - margin-left: 4px; - } - } - } - - mat-card-actions { - padding: 0; - margin: 0; - - mat-paginator { - background: transparent; - } - - .full-width { - width: 100%; - margin: 0; - } - } - - .loading, .empty, .error-state { - padding: 60px 20px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: #666; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - color: #e0e0e0; - margin-bottom: 16px; - } - - p { - margin: 0 0 16px; - font-size: 14px; - } - } - - .error-state { - mat-icon { - color: #f44336; - } - } - - ::ng-deep { - .mat-mdc-list-item-unscoped-content { - display: block; - } - - .mat-mdc-paginator { - .mat-mdc-paginator-container { - padding: 8px; - justify-content: center; - } - } - } - `] -}) -export class ActivityLogComponent implements OnInit, OnDestroy { - @Output() close = new EventEmitter(); - - private http = inject(HttpClient); - private snackBar = inject(MatSnackBar); - private destroyed$ = new Subject(); - - activities: ActivityLog[] = []; - loading = false; - error = ''; - currentPage = 1; - pageSize = 10; - totalItems = 0; - totalPages = 0; - - ngOnInit() { - this.loadActivities(); - } - - ngOnDestroy() { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - loadActivities(page: number = 1) { - this.loading = true; - this.error = ''; - this.currentPage = page; - - // Backend sadece limit parametresi alıyor, page almıyor - const limit = this.pageSize * page; // Toplam kaç kayıt istediğimizi hesapla - - this.http.get( - `/api/activity-log?limit=${limit}` - ).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: (response) => { - try { - // Response direkt array olarak geliyor - const allActivities = response || []; - - // Manual pagination yap - const startIndex = (page - 1) * this.pageSize; - const endIndex = startIndex + this.pageSize; - - this.activities = allActivities.slice(startIndex, endIndex); - this.totalItems = allActivities.length; - this.totalPages = Math.ceil(allActivities.length / this.pageSize); - this.loading = false; - } catch (err) { - console.error('Failed to process activities:', err); - this.error = 'Failed to process activity data'; - this.activities = []; - this.loading = false; - } - }, - error: (error) => { - console.error('Failed to load activities:', error); - this.error = this.getErrorMessage(error); - this.activities = []; - this.loading = false; - - // Show error in snackbar - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - }); - } - - onPageChange(event: PageEvent) { - this.pageSize = event.pageSize; - this.loadActivities(event.pageIndex + 1); - } - - openFullView() { - // TODO: Implement full activity log view - console.log('Open full activity log view'); - this.close.emit(); - } - - retry() { - this.loadActivities(this.currentPage); - } - - getRelativeTime(timestamp: string): string { - try { - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMs < 0) return 'just now'; // Future dates - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins} min ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; - if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; - - return date.toLocaleDateString(); - } catch (err) { - console.error('Invalid timestamp:', timestamp, err); - return 'Unknown'; - } - } - - getActionText(activity: ActivityLog): string { - const actions: Record = { - 'CREATE_PROJECT': 'created project', - 'UPDATE_PROJECT': 'updated project', - 'DELETE_PROJECT': 'deleted project', - 'ENABLE_PROJECT': 'enabled project', - 'DISABLE_PROJECT': 'disabled project', - 'PUBLISH_VERSION': 'published version of', - 'CREATE_VERSION': 'created version for', - 'UPDATE_VERSION': 'updated version of', - 'DELETE_VERSION': 'deleted version from', - 'CREATE_API': 'created API', - 'UPDATE_API': 'updated API', - 'DELETE_API': 'deleted API', - 'UPDATE_ENVIRONMENT': 'updated environment', - 'IMPORT_PROJECT': 'imported project', - 'CHANGE_PASSWORD': 'changed password', - 'LOGIN': 'logged in', - 'LOGOUT': 'logged out', - 'FAILED_LOGIN': 'failed login attempt' - }; - - return actions[activity.action] || activity.action.toLowerCase().replace(/_/g, ' '); - } - - getActivityIcon(action: string): string { - if (action.includes('CREATE')) return 'add_circle'; - if (action.includes('UPDATE')) return 'edit'; - if (action.includes('DELETE')) return 'delete'; - if (action.includes('ENABLE')) return 'check_circle'; - if (action.includes('DISABLE')) return 'cancel'; - if (action.includes('PUBLISH')) return 'publish'; - if (action.includes('IMPORT')) return 'cloud_upload'; - if (action.includes('PASSWORD')) return 'lock'; - if (action.includes('LOGIN')) return 'login'; - if (action.includes('LOGOUT')) return 'logout'; - return 'info'; - } - - trackByActivityId(index: number, activity: ActivityLog): number { - return activity.id; - } - - isLast(activity: ActivityLog): boolean { - return this.activities.indexOf(activity) === this.activities.length - 1; - } - - private getErrorMessage(error: any): string { - if (error.status === 0) { - return 'Unable to connect to server. Please check your connection.'; - } else if (error.status === 401) { - return 'Session expired. Please login again.'; - } else if (error.status === 403) { - return 'You do not have permission to view activity logs.'; - } else if (error.error?.detail) { - return error.error.detail; - } else if (error.message) { - return error.message; - } - return 'Failed to load activities. Please try again.'; - } +import { Component, EventEmitter, Output, inject, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatListModule } from '@angular/material/list'; +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { Subject, takeUntil } from 'rxjs'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; + +interface ActivityLog { + id: number; + timestamp: string; + user: string; + action: string; + entity_type: string; + entity_id: any; + entity_name: string; + details?: string; +} + +interface ActivityLogResponse { + items: ActivityLog[]; + total: number; + page: number; + limit: number; + pages: number; +} + +@Component({ + selector: 'app-activity-log', + standalone: true, + imports: [ + CommonModule, + MatProgressSpinnerModule, + MatButtonModule, + MatIconModule, + MatPaginatorModule, + MatListModule, + MatCardModule, + MatDividerModule, + MatSnackBarModule + ], + template: ` + + + + notifications + Recent Activities + + + + + + @if (loading && activities.length === 0) { +
+ +
+ } @else if (error && activities.length === 0) { +
+ error_outline +

{{ error }}

+ +
+ } @else if (activities.length === 0) { +
+ inbox +

No activities found

+
+ } @else { + + @for (activity of activities; track activity.id) { + + {{ getActivityIcon(activity.action) }} +
+ {{ getRelativeTime(activity.timestamp) }} +
+
+ {{ activity.user }} {{ getActionText(activity) }} + {{ activity.entity_name }} + @if (activity.details) { + • {{ activity.details }} + } +
+
+ @if (!$last) { + + } + } +
+ } +
+ + + + + + + + + +
+ `, + styles: [` + .activity-log-dropdown { + width: 450px; + max-height: 600px; + display: flex; + flex-direction: column; + overflow: hidden; + } + + mat-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background-color: #424242; + color: white; + + mat-card-title { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; + color: white; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + color: white; + } + } + + button { + color: white; + } + } + + mat-card-content { + flex: 1; + overflow-y: auto; + padding: 0; + min-height: 200px; + max-height: 400px; + } + + .activity-list { + padding: 0; + + mat-list-item { + height: auto; + min-height: 72px; + padding: 12px 16px; + + &:hover { + background-color: #f5f5f5; + } + + .activity-time { + font-size: 12px; + color: #666; + } + + strong { + color: #1976d2; + margin-right: 4px; + } + + em { + color: #673ab7; + font-style: normal; + font-weight: 500; + margin: 0 4px; + } + + .details { + color: #666; + font-size: 12px; + margin-left: 4px; + } + } + } + + mat-card-actions { + padding: 0; + margin: 0; + + mat-paginator { + background: transparent; + } + + .full-width { + width: 100%; + margin: 0; + } + } + + .loading, .empty, .error-state { + padding: 60px 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #666; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #e0e0e0; + margin-bottom: 16px; + } + + p { + margin: 0 0 16px; + font-size: 14px; + } + } + + .error-state { + mat-icon { + color: #f44336; + } + } + + ::ng-deep { + .mat-mdc-list-item-unscoped-content { + display: block; + } + + .mat-mdc-paginator { + .mat-mdc-paginator-container { + padding: 8px; + justify-content: center; + } + } + } + `] +}) +export class ActivityLogComponent implements OnInit, OnDestroy { + @Output() close = new EventEmitter(); + + private http = inject(HttpClient); + private snackBar = inject(MatSnackBar); + private destroyed$ = new Subject(); + + activities: ActivityLog[] = []; + loading = false; + error = ''; + currentPage = 1; + pageSize = 10; + totalItems = 0; + totalPages = 0; + + ngOnInit() { + this.loadActivities(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + loadActivities(page: number = 1) { + this.loading = true; + this.error = ''; + this.currentPage = page; + + // Backend sadece limit parametresi alıyor, page almıyor + const limit = this.pageSize * page; // Toplam kaç kayıt istediğimizi hesapla + + this.http.get( + `/api/activity-log?limit=${limit}` + ).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: (response) => { + try { + // Response direkt array olarak geliyor + const allActivities = response || []; + + // Manual pagination yap + const startIndex = (page - 1) * this.pageSize; + const endIndex = startIndex + this.pageSize; + + this.activities = allActivities.slice(startIndex, endIndex); + this.totalItems = allActivities.length; + this.totalPages = Math.ceil(allActivities.length / this.pageSize); + this.loading = false; + } catch (err) { + console.error('Failed to process activities:', err); + this.error = 'Failed to process activity data'; + this.activities = []; + this.loading = false; + } + }, + error: (error) => { + console.error('Failed to load activities:', error); + this.error = this.getErrorMessage(error); + this.activities = []; + this.loading = false; + + // Show error in snackbar + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + }); + } + + onPageChange(event: PageEvent) { + this.pageSize = event.pageSize; + this.loadActivities(event.pageIndex + 1); + } + + openFullView() { + // TODO: Implement full activity log view + console.log('Open full activity log view'); + this.close.emit(); + } + + retry() { + this.loadActivities(this.currentPage); + } + + getRelativeTime(timestamp: string): string { + try { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMs < 0) return 'just now'; // Future dates + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins} min ago`; + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`; + if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`; + + return date.toLocaleDateString(); + } catch (err) { + console.error('Invalid timestamp:', timestamp, err); + return 'Unknown'; + } + } + + getActionText(activity: ActivityLog): string { + const actions: Record = { + 'CREATE_PROJECT': 'created project', + 'UPDATE_PROJECT': 'updated project', + 'DELETE_PROJECT': 'deleted project', + 'ENABLE_PROJECT': 'enabled project', + 'DISABLE_PROJECT': 'disabled project', + 'PUBLISH_VERSION': 'published version of', + 'CREATE_VERSION': 'created version for', + 'UPDATE_VERSION': 'updated version of', + 'DELETE_VERSION': 'deleted version from', + 'CREATE_API': 'created API', + 'UPDATE_API': 'updated API', + 'DELETE_API': 'deleted API', + 'UPDATE_ENVIRONMENT': 'updated environment', + 'IMPORT_PROJECT': 'imported project', + 'CHANGE_PASSWORD': 'changed password', + 'LOGIN': 'logged in', + 'LOGOUT': 'logged out', + 'FAILED_LOGIN': 'failed login attempt' + }; + + return actions[activity.action] || activity.action.toLowerCase().replace(/_/g, ' '); + } + + getActivityIcon(action: string): string { + if (action.includes('CREATE')) return 'add_circle'; + if (action.includes('UPDATE')) return 'edit'; + if (action.includes('DELETE')) return 'delete'; + if (action.includes('ENABLE')) return 'check_circle'; + if (action.includes('DISABLE')) return 'cancel'; + if (action.includes('PUBLISH')) return 'publish'; + if (action.includes('IMPORT')) return 'cloud_upload'; + if (action.includes('PASSWORD')) return 'lock'; + if (action.includes('LOGIN')) return 'login'; + if (action.includes('LOGOUT')) return 'logout'; + return 'info'; + } + + trackByActivityId(index: number, activity: ActivityLog): number { + return activity.id; + } + + isLast(activity: ActivityLog): boolean { + return this.activities.indexOf(activity) === this.activities.length - 1; + } + + private getErrorMessage(error: any): string { + if (error.status === 0) { + return 'Unable to connect to server. Please check your connection.'; + } else if (error.status === 401) { + return 'Session expired. Please login again.'; + } else if (error.status === 403) { + return 'You do not have permission to view activity logs.'; + } else if (error.error?.detail) { + return error.error.detail; + } else if (error.message) { + return error.message; + } + return 'Failed to load activities. Please try again.'; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/apis/apis.component.ts b/flare-ui/src/app/components/apis/apis.component.ts index 92da2f58cae179a9e8fb28ffe36ae1540377fec3..f22448e152a80bf45eb2337baeb14761149b3a6b 100644 --- a/flare-ui/src/app/components/apis/apis.component.ts +++ b/flare-ui/src/app/components/apis/apis.component.ts @@ -1,742 +1,742 @@ -import { Component, inject, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatTableModule } from '@angular/material/table'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { ApiService, API } from '../../services/api.service'; -import { Subject, takeUntil } from 'rxjs'; - -@Component({ - selector: 'app-apis', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatDialogModule, - MatTableModule, - MatButtonModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - MatCheckboxModule, - MatProgressBarModule, - MatChipsModule, - MatMenuModule, - MatTooltipModule, - MatSnackBarModule, - MatDividerModule, - MatProgressSpinnerModule - ], - template: ` -
-
-

API Definitions

-
- - - - - Search APIs - - search - - - Display Deleted - -
-
- - - - @if (!loading && error) { -
- error_outline -

{{ error }}

- -
- } @else if (!loading && filteredAPIs.length === 0 && !searchTerm) { -
- api -

No APIs found.

- -
- } @else if (!loading && filteredAPIs.length === 0 && searchTerm) { -
- search_off -

No APIs match your search.

- -
- } @else if (!loading) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name{{ api.name }}URL - {{ api.url }} - Method - - {{ api.method }} - - Timeout{{ api.timeout_seconds }}sAuth - - {{ api.auth?.enabled ? 'lock' : 'lock_open' }} - - Status - @if (api.deleted) { - delete - } @else { - check_circle - } - Actions - - - - - - @if (!api.deleted) { - - - } @else { - - - } - -
- } -
- `, - styles: [` - .apis-container { - .toolbar { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - flex-wrap: wrap; - gap: 16px; - - h2 { - margin: 0; - font-size: 24px; - } - - .toolbar-actions { - display: flex; - gap: 16px; - align-items: center; - flex-wrap: wrap; - - .search-field { - width: 250px; - } - } - } - - .empty-state, .error-state { - text-align: center; - padding: 60px 20px; - background-color: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - - mat-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: #e0e0e0; - margin-bottom: 16px; - } - - p { - margin-bottom: 24px; - color: #666; - font-size: 16px; - } - } - - .error-state { - mat-icon { - color: #f44336; - } - } - - .apis-table { - width: 100%; - background: white; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - - .url-cell { - max-width: 300px; - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - display: block; - } - } - - mat-chip { - font-size: 12px; - min-height: 24px; - padding: 4px 12px; - - &.method-get { background-color: #4caf50; color: white; } - &.method-post { background-color: #2196f3; color: white; } - &.method-put { background-color: #ff9800; color: white; } - &.method-patch { background-color: #9c27b0; color: white; } - &.method-delete { background-color: #f44336; color: white; } - } - - tr.mat-mdc-row { - cursor: pointer; - transition: background-color 0.2s; - - &:hover { - background-color: #f5f5f5; - } - - &.deleted-row { - opacity: 0.6; - background-color: #fafafa; - cursor: default; - } - } - - mat-spinner { - display: inline-block; - } - } - } - - ::ng-deep { - .mat-mdc-form-field { - font-size: 14px; - } - - .mat-mdc-checkbox { - .mdc-form-field { - font-size: 14px; - } - } - } - `] -}) -export class ApisComponent implements OnInit, OnDestroy { - private apiService = inject(ApiService); - private dialog = inject(MatDialog); - private snackBar = inject(MatSnackBar); - private destroyed$ = new Subject(); - - apis: API[] = []; - filteredAPIs: API[] = []; - loading = true; - error = ''; - showDeleted = false; - searchTerm = ''; - actionLoading: { [key: string]: boolean } = {}; - - displayedColumns: string[] = ['name', 'url', 'method', 'timeout', 'auth', 'deleted', 'actions']; - - ngOnInit() { - this.loadAPIs(); - } - - ngOnDestroy() { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - loadAPIs() { - this.loading = true; - this.error = ''; - - this.apiService.getAPIs(this.showDeleted).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: (apis) => { - this.apis = apis; - this.filterAPIs(); - this.loading = false; - }, - error: (err) => { - this.error = this.getErrorMessage(err); - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.loading = false; - } - }); - } - - filterAPIs() { - const term = this.searchTerm.toLowerCase().trim(); - if (!term) { - this.filteredAPIs = [...this.apis]; - } else { - this.filteredAPIs = this.apis.filter(api => - api.name.toLowerCase().includes(term) || - api.url.toLowerCase().includes(term) || - api.method.toLowerCase().includes(term) - ); - } - } - - async createAPI() { - try { - const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); - - const dialogRef = this.dialog.open(ApiEditDialogComponent, { - width: '800px', - data: { mode: 'create' }, - disableClose: true - }); - - dialogRef.afterClosed().pipe( - takeUntil(this.destroyed$) - ).subscribe((result: any) => { - if (result) { - this.loadAPIs(); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.snackBar.open('Failed to open dialog', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - } - - async editAPI(api: API) { - if (api.deleted) return; - - try { - const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); - - const dialogRef = this.dialog.open(ApiEditDialogComponent, { - width: '800px', - data: { mode: 'edit', api: { ...api } }, - disableClose: true - }); - - dialogRef.afterClosed().pipe( - takeUntil(this.destroyed$) - ).subscribe((result: any) => { - if (result) { - this.loadAPIs(); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.snackBar.open('Failed to open dialog', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - } - - async testAPI(api: API) { - try { - const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); - - const dialogRef = this.dialog.open(ApiEditDialogComponent, { - width: '800px', - data: { - mode: 'test', - api: { ...api }, - activeTab: 4 - }, - disableClose: false - }); - - dialogRef.afterClosed().pipe( - takeUntil(this.destroyed$) - ).subscribe((result: any) => { - if (result) { - this.loadAPIs(); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.snackBar.open('Failed to open dialog', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - } - - async duplicateAPI(api: API) { - try { - const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); - - const duplicatedApi = { ...api }; - duplicatedApi.name = `${api.name}_copy`; - delete (duplicatedApi as any).last_update_date; - - const dialogRef = this.dialog.open(ApiEditDialogComponent, { - width: '800px', - data: { mode: 'duplicate', api: duplicatedApi }, - disableClose: true - }); - - dialogRef.afterClosed().pipe( - takeUntil(this.destroyed$) - ).subscribe((result: any) => { - if (result) { - this.loadAPIs(); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.snackBar.open('Failed to open dialog', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - } - - async deleteAPI(api: API) { - if (api.deleted) return; - - try { - const { default: ConfirmDialogComponent } = await import('../../dialogs/confirm-dialog/confirm-dialog.component'); - - const dialogRef = this.dialog.open(ConfirmDialogComponent, { - width: '400px', - data: { - title: 'Delete API', - message: `Are you sure you want to delete "${api.name}"?`, - confirmText: 'Delete', - confirmColor: 'warn' - } - }); - - dialogRef.afterClosed().pipe( - takeUntil(this.destroyed$) - ).subscribe((confirmed) => { - if (confirmed) { - this.actionLoading[api.name] = true; - - this.apiService.deleteAPI(api.name).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: () => { - this.snackBar.open(`API "${api.name}" deleted successfully`, 'Close', { - duration: 3000 - }); - this.loadAPIs(); - }, - error: (err) => { - const errorMsg = this.getErrorMessage(err); - this.snackBar.open(errorMsg, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.actionLoading[api.name] = false; - } - }); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.snackBar.open('Failed to open dialog', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - } - - async restoreAPI(api: API) { - if (!api.deleted) return; - - // Implement restore API functionality - this.snackBar.open('Restore functionality not implemented yet', 'Close', { - duration: 3000 - }); - } - - async importAPIs() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - - input.onchange = async (event: any) => { - const file = event.target.files[0]; - if (!file) return; - - try { - const text = await file.text(); - let apis: any[]; - - try { - apis = JSON.parse(text); - } catch (parseError) { - this.snackBar.open('Invalid JSON file format', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - return; - } - - if (!Array.isArray(apis)) { - this.snackBar.open('Invalid file format. Expected an array of APIs.', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - return; - } - - this.loading = true; - let imported = 0; - let failed = 0; - const errors: string[] = []; - - console.log('Starting API import, total APIs:', apis.length); - - for (const api of apis) { - try { - await this.apiService.createAPI(api).toPromise(); - imported++; - } catch (err: any) { - failed++; - const apiName = api.name || 'unnamed'; - - console.error(`❌ Failed to import API ${apiName}:`, err); - - // Parse error message - daha iyi hata mesajı parse etme - let errorMsg = 'Unknown error'; - - if (err.status === 409) { - // DuplicateResourceError durumu - errorMsg = `API with name '${apiName}' already exists`; - } else if (err.status === 500 && err.error?.detail?.includes('already exists')) { - // Backend'den gelen duplicate hatası - errorMsg = `API with name '${apiName}' already exists`; - } else if (err.error?.message) { - errorMsg = err.error.message; - } else if (err.error?.detail) { - errorMsg = err.error.detail; - } else if (err.message) { - errorMsg = err.message; - } - - errors.push(`${apiName}: ${errorMsg}`); - } - } - - this.loading = false; - - if (imported > 0) { - this.loadAPIs(); - } - - // Always show dialog for import results - try { - await this.showImportResultsDialog(imported, failed, errors); - } catch (dialogError) { - console.error('Failed to show import dialog:', dialogError); - // Fallback to snackbar - this.showImportResultsSnackbar(imported, failed, errors); - } - - } catch (error) { - this.loading = false; - this.snackBar.open('Failed to read file', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - }; - - input.click(); - } - - private async showImportResultsDialog(imported: number, failed: number, errors: string[]) { - try { - const { default: ImportResultsDialogComponent } = await import('../../dialogs/import-results-dialog/import-results-dialog.component'); - - this.dialog.open(ImportResultsDialogComponent, { - width: '600px', - data: { - title: 'API Import Results', - imported, - failed, - errors - } - }); - } catch (error) { - // Fallback to alert if dialog fails to load - alert(`Imported: ${imported}\nFailed: ${failed}\n\nErrors:\n${errors.join('\n')}`); - } - } - - // Fallback method - private showImportResultsSnackbar(imported: number, failed: number, errors: string[]) { - let message = ''; - if (imported > 0) { - message = `Successfully imported ${imported} API${imported > 1 ? 's' : ''}.`; - } - - if (failed > 0) { - if (message) message += '\n\n'; - message += `Failed to import ${failed} API${failed > 1 ? 's' : ''}:\n`; - message += errors.slice(0, 5).join('\n'); - if (errors.length > 5) { - message += `\n... and ${errors.length - 5} more errors`; - } - } - - this.snackBar.open(message, 'Close', { - duration: 10000, - panelClass: ['multiline-snackbar', failed > 0 ? 'error-snackbar' : 'success-snackbar'], - verticalPosition: 'top', - horizontalPosition: 'right' - }); - } - - exportAPIs() { - const selectedAPIs = this.filteredAPIs.filter(api => !api.deleted); - - if (selectedAPIs.length === 0) { - this.snackBar.open('No APIs to export', 'Close', { - duration: 3000 - }); - return; - } - - try { - const data = JSON.stringify(selectedAPIs, null, 2); - const blob = new Blob([data], { type: 'application/json' }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `apis_export_${new Date().getTime()}.json`; - link.click(); - window.URL.revokeObjectURL(url); - - this.snackBar.open(`Exported ${selectedAPIs.length} APIs`, 'Close', { - duration: 3000 - }); - } catch (error) { - console.error('Export failed:', error); - this.snackBar.open('Failed to export APIs', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - } - - private getErrorMessage(error: any): string { - if (error.status === 0) { - return 'Unable to connect to server. Please check your connection.'; - } else if (error.status === 401) { - return 'Session expired. Please login again.'; - } else if (error.status === 403) { - return 'You do not have permission to perform this action.'; - } else if (error.status === 409) { - return 'This API was modified by another user. Please refresh and try again.'; - } else if (error.error?.detail) { - return error.error.detail; - } else if (error.message) { - return error.message; - } - return 'An unexpected error occurred. Please try again.'; - } +import { Component, inject, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatTableModule } from '@angular/material/table'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ApiService, API } from '../../services/api.service'; +import { Subject, takeUntil } from 'rxjs'; + +@Component({ + selector: 'app-apis', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatTableModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatCheckboxModule, + MatProgressBarModule, + MatChipsModule, + MatMenuModule, + MatTooltipModule, + MatSnackBarModule, + MatDividerModule, + MatProgressSpinnerModule + ], + template: ` +
+
+

API Definitions

+
+ + + + + Search APIs + + search + + + Display Deleted + +
+
+ + + + @if (!loading && error) { +
+ error_outline +

{{ error }}

+ +
+ } @else if (!loading && filteredAPIs.length === 0 && !searchTerm) { +
+ api +

No APIs found.

+ +
+ } @else if (!loading && filteredAPIs.length === 0 && searchTerm) { +
+ search_off +

No APIs match your search.

+ +
+ } @else if (!loading) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ api.name }}URL + {{ api.url }} + Method + + {{ api.method }} + + Timeout{{ api.timeout_seconds }}sAuth + + {{ api.auth?.enabled ? 'lock' : 'lock_open' }} + + Status + @if (api.deleted) { + delete + } @else { + check_circle + } + Actions + + + + + + @if (!api.deleted) { + + + } @else { + + + } + +
+ } +
+ `, + styles: [` + .apis-container { + .toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; + + h2 { + margin: 0; + font-size: 24px; + } + + .toolbar-actions { + display: flex; + gap: 16px; + align-items: center; + flex-wrap: wrap; + + .search-field { + width: 250px; + } + } + } + + .empty-state, .error-state { + text-align: center; + padding: 60px 20px; + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: #e0e0e0; + margin-bottom: 16px; + } + + p { + margin-bottom: 24px; + color: #666; + font-size: 16px; + } + } + + .error-state { + mat-icon { + color: #f44336; + } + } + + .apis-table { + width: 100%; + background: white; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + .url-cell { + max-width: 300px; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + } + } + + mat-chip { + font-size: 12px; + min-height: 24px; + padding: 4px 12px; + + &.method-get { background-color: #4caf50; color: white; } + &.method-post { background-color: #2196f3; color: white; } + &.method-put { background-color: #ff9800; color: white; } + &.method-patch { background-color: #9c27b0; color: white; } + &.method-delete { background-color: #f44336; color: white; } + } + + tr.mat-mdc-row { + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #f5f5f5; + } + + &.deleted-row { + opacity: 0.6; + background-color: #fafafa; + cursor: default; + } + } + + mat-spinner { + display: inline-block; + } + } + } + + ::ng-deep { + .mat-mdc-form-field { + font-size: 14px; + } + + .mat-mdc-checkbox { + .mdc-form-field { + font-size: 14px; + } + } + } + `] +}) +export class ApisComponent implements OnInit, OnDestroy { + private apiService = inject(ApiService); + private dialog = inject(MatDialog); + private snackBar = inject(MatSnackBar); + private destroyed$ = new Subject(); + + apis: API[] = []; + filteredAPIs: API[] = []; + loading = true; + error = ''; + showDeleted = false; + searchTerm = ''; + actionLoading: { [key: string]: boolean } = {}; + + displayedColumns: string[] = ['name', 'url', 'method', 'timeout', 'auth', 'deleted', 'actions']; + + ngOnInit() { + this.loadAPIs(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + loadAPIs() { + this.loading = true; + this.error = ''; + + this.apiService.getAPIs(this.showDeleted).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: (apis) => { + this.apis = apis; + this.filterAPIs(); + this.loading = false; + }, + error: (err) => { + this.error = this.getErrorMessage(err); + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.loading = false; + } + }); + } + + filterAPIs() { + const term = this.searchTerm.toLowerCase().trim(); + if (!term) { + this.filteredAPIs = [...this.apis]; + } else { + this.filteredAPIs = this.apis.filter(api => + api.name.toLowerCase().includes(term) || + api.url.toLowerCase().includes(term) || + api.method.toLowerCase().includes(term) + ); + } + } + + async createAPI() { + try { + const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); + + const dialogRef = this.dialog.open(ApiEditDialogComponent, { + width: '800px', + data: { mode: 'create' }, + disableClose: true + }); + + dialogRef.afterClosed().pipe( + takeUntil(this.destroyed$) + ).subscribe((result: any) => { + if (result) { + this.loadAPIs(); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.snackBar.open('Failed to open dialog', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + } + + async editAPI(api: API) { + if (api.deleted) return; + + try { + const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); + + const dialogRef = this.dialog.open(ApiEditDialogComponent, { + width: '800px', + data: { mode: 'edit', api: { ...api } }, + disableClose: true + }); + + dialogRef.afterClosed().pipe( + takeUntil(this.destroyed$) + ).subscribe((result: any) => { + if (result) { + this.loadAPIs(); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.snackBar.open('Failed to open dialog', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + } + + async testAPI(api: API) { + try { + const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); + + const dialogRef = this.dialog.open(ApiEditDialogComponent, { + width: '800px', + data: { + mode: 'test', + api: { ...api }, + activeTab: 4 + }, + disableClose: false + }); + + dialogRef.afterClosed().pipe( + takeUntil(this.destroyed$) + ).subscribe((result: any) => { + if (result) { + this.loadAPIs(); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.snackBar.open('Failed to open dialog', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + } + + async duplicateAPI(api: API) { + try { + const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component'); + + const duplicatedApi = { ...api }; + duplicatedApi.name = `${api.name}_copy`; + delete (duplicatedApi as any).last_update_date; + + const dialogRef = this.dialog.open(ApiEditDialogComponent, { + width: '800px', + data: { mode: 'duplicate', api: duplicatedApi }, + disableClose: true + }); + + dialogRef.afterClosed().pipe( + takeUntil(this.destroyed$) + ).subscribe((result: any) => { + if (result) { + this.loadAPIs(); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.snackBar.open('Failed to open dialog', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + } + + async deleteAPI(api: API) { + if (api.deleted) return; + + try { + const { default: ConfirmDialogComponent } = await import('../../dialogs/confirm-dialog/confirm-dialog.component'); + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Delete API', + message: `Are you sure you want to delete "${api.name}"?`, + confirmText: 'Delete', + confirmColor: 'warn' + } + }); + + dialogRef.afterClosed().pipe( + takeUntil(this.destroyed$) + ).subscribe((confirmed) => { + if (confirmed) { + this.actionLoading[api.name] = true; + + this.apiService.deleteAPI(api.name).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: () => { + this.snackBar.open(`API "${api.name}" deleted successfully`, 'Close', { + duration: 3000 + }); + this.loadAPIs(); + }, + error: (err) => { + const errorMsg = this.getErrorMessage(err); + this.snackBar.open(errorMsg, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.actionLoading[api.name] = false; + } + }); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.snackBar.open('Failed to open dialog', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + } + + async restoreAPI(api: API) { + if (!api.deleted) return; + + // Implement restore API functionality + this.snackBar.open('Restore functionality not implemented yet', 'Close', { + duration: 3000 + }); + } + + async importAPIs() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (event: any) => { + const file = event.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + let apis: any[]; + + try { + apis = JSON.parse(text); + } catch (parseError) { + this.snackBar.open('Invalid JSON file format', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + return; + } + + if (!Array.isArray(apis)) { + this.snackBar.open('Invalid file format. Expected an array of APIs.', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + return; + } + + this.loading = true; + let imported = 0; + let failed = 0; + const errors: string[] = []; + + console.log('Starting API import, total APIs:', apis.length); + + for (const api of apis) { + try { + await this.apiService.createAPI(api).toPromise(); + imported++; + } catch (err: any) { + failed++; + const apiName = api.name || 'unnamed'; + + console.error(`❌ Failed to import API ${apiName}:`, err); + + // Parse error message - daha iyi hata mesajı parse etme + let errorMsg = 'Unknown error'; + + if (err.status === 409) { + // DuplicateResourceError durumu + errorMsg = `API with name '${apiName}' already exists`; + } else if (err.status === 500 && err.error?.detail?.includes('already exists')) { + // Backend'den gelen duplicate hatası + errorMsg = `API with name '${apiName}' already exists`; + } else if (err.error?.message) { + errorMsg = err.error.message; + } else if (err.error?.detail) { + errorMsg = err.error.detail; + } else if (err.message) { + errorMsg = err.message; + } + + errors.push(`${apiName}: ${errorMsg}`); + } + } + + this.loading = false; + + if (imported > 0) { + this.loadAPIs(); + } + + // Always show dialog for import results + try { + await this.showImportResultsDialog(imported, failed, errors); + } catch (dialogError) { + console.error('Failed to show import dialog:', dialogError); + // Fallback to snackbar + this.showImportResultsSnackbar(imported, failed, errors); + } + + } catch (error) { + this.loading = false; + this.snackBar.open('Failed to read file', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + }; + + input.click(); + } + + private async showImportResultsDialog(imported: number, failed: number, errors: string[]) { + try { + const { default: ImportResultsDialogComponent } = await import('../../dialogs/import-results-dialog/import-results-dialog.component'); + + this.dialog.open(ImportResultsDialogComponent, { + width: '600px', + data: { + title: 'API Import Results', + imported, + failed, + errors + } + }); + } catch (error) { + // Fallback to alert if dialog fails to load + alert(`Imported: ${imported}\nFailed: ${failed}\n\nErrors:\n${errors.join('\n')}`); + } + } + + // Fallback method + private showImportResultsSnackbar(imported: number, failed: number, errors: string[]) { + let message = ''; + if (imported > 0) { + message = `Successfully imported ${imported} API${imported > 1 ? 's' : ''}.`; + } + + if (failed > 0) { + if (message) message += '\n\n'; + message += `Failed to import ${failed} API${failed > 1 ? 's' : ''}:\n`; + message += errors.slice(0, 5).join('\n'); + if (errors.length > 5) { + message += `\n... and ${errors.length - 5} more errors`; + } + } + + this.snackBar.open(message, 'Close', { + duration: 10000, + panelClass: ['multiline-snackbar', failed > 0 ? 'error-snackbar' : 'success-snackbar'], + verticalPosition: 'top', + horizontalPosition: 'right' + }); + } + + exportAPIs() { + const selectedAPIs = this.filteredAPIs.filter(api => !api.deleted); + + if (selectedAPIs.length === 0) { + this.snackBar.open('No APIs to export', 'Close', { + duration: 3000 + }); + return; + } + + try { + const data = JSON.stringify(selectedAPIs, null, 2); + const blob = new Blob([data], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `apis_export_${new Date().getTime()}.json`; + link.click(); + window.URL.revokeObjectURL(url); + + this.snackBar.open(`Exported ${selectedAPIs.length} APIs`, 'Close', { + duration: 3000 + }); + } catch (error) { + console.error('Export failed:', error); + this.snackBar.open('Failed to export APIs', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + } + + private getErrorMessage(error: any): string { + if (error.status === 0) { + return 'Unable to connect to server. Please check your connection.'; + } else if (error.status === 401) { + return 'Session expired. Please login again.'; + } else if (error.status === 403) { + return 'You do not have permission to perform this action.'; + } else if (error.status === 409) { + return 'This API was modified by another user. Please refresh and try again.'; + } else if (error.error?.detail) { + return error.error.detail; + } else if (error.message) { + return error.message; + } + return 'An unexpected error occurred. Please try again.'; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/chat/chat.component.html b/flare-ui/src/app/components/chat/chat.component.html index 341c1f7d790a4689a4cc9c22450535d120b9ba8d..3dac6c09da979cb017bd2a5fb7035aaa2f420a91 100644 --- a/flare-ui/src/app/components/chat/chat.component.html +++ b/flare-ui/src/app/components/chat/chat.component.html @@ -1,157 +1,157 @@ -
- -
- - - chat_bubble_outline - Start a Chat Session - Select a project to begin testing - - - - - - Select Project - - {{ p }} - - Choose an enabled project with published version - - - - Language - - - {{ locale.name }} ({{ locale.english_name }}) - - - Select conversation language - - - - Use TTS (Text-to-Speech) - -
- TTS is not configured. Please configure a TTS engine in Environment settings. -
- - - Use STT (Speech-to-Text) - -
- STT is not configured. Please configure an STT engine in Environment settings. -
-
- When STT is enabled, use the Real-time Chat button for voice conversation. -
-
- - - - - - - -
-
- - - - - smart_toy - {{ selectedProject }} - Session: {{ sessionId.substring(0, 8) }}... -
- record_voice_over - -
- - - - -
- -
- -
-
- - {{ msg.author === 'user' ? 'person' : 'smart_toy' }} - -
- {{ msg.text }} - -
-
-
- - - -
- - Type your message - - Press Enter to send - - - -
-
- - - +
+ +
+ + + chat_bubble_outline + Start a Chat Session + Select a project to begin testing + + + + + + Select Project + + {{ p }} + + Choose an enabled project with published version + + + + Language + + + {{ locale.name }} ({{ locale.english_name }}) + + + Select conversation language + + + + Use TTS (Text-to-Speech) + +
+ TTS is not configured. Please configure a TTS engine in Environment settings. +
+ + + Use STT (Speech-to-Text) + +
+ STT is not configured. Please configure an STT engine in Environment settings. +
+
+ When STT is enabled, use the Real-time Chat button for voice conversation. +
+
+ + + + + + + +
+
+ + + + + smart_toy + {{ selectedProject }} + Session: {{ sessionId.substring(0, 8) }}... +
+ record_voice_over + +
+ + + + +
+ +
+ +
+
+ + {{ msg.author === 'user' ? 'person' : 'smart_toy' }} + +
+ {{ msg.text }} + +
+
+
+ + + +
+ + Type your message + + Press Enter to send + + + +
+
+ + +
\ No newline at end of file diff --git a/flare-ui/src/app/components/chat/chat.component.scss b/flare-ui/src/app/components/chat/chat.component.scss index 5165560a6a4587d34858e2ee664166d974cf136c..2a917823b533b3f0b054413c68d9a7113aa25d45 100644 --- a/flare-ui/src/app/components/chat/chat.component.scss +++ b/flare-ui/src/app/components/chat/chat.component.scss @@ -1,290 +1,290 @@ -.chat-container { - height: 100%; - padding: 24px; - max-width: 900px; - margin: 0 auto; -} - -.start-wrapper { - display: flex; - justify-content: center; - align-items: center; - min-height: 400px; - - mat-card { - max-width: 500px; - width: 100%; - } - - .project-select { - width: 100%; - margin-bottom: 16px; - } - - .tts-checkbox { - margin-bottom: 8px; - } - - .tts-hint { - color: #666; - font-size: 12px; - margin-bottom: 16px; - } -} - -.locale-select { - width: 100%; - margin-bottom: 16px; -} - -.chat-card { - height: calc(100vh - 200px); - display: flex; - flex-direction: column; - - mat-card-header { - background-color: #f5f5f5; - padding: 16px; - - .spacer { - flex: 1; - } - - .tts-indicator { - color: #4caf50; - margin-right: 8px; - } - } -} - -.waveform-container { - background-color: #f0f0f0; - padding: 8px; - display: flex; - justify-content: center; - align-items: center; - min-height: 116px; - - canvas { - border-radius: 4px; - background-color: #f0f0f0; - } -} - -.chat-history { - flex: 1; - overflow-y: auto; - padding: 16px; - background: #fafafa; - min-height: 300px; -} - -.msg-row { - display: flex; - align-items: flex-start; - margin: 12px 0; - gap: 8px; - - &.me { - justify-content: flex-end; - flex-direction: row-reverse; - - .bubble { - background: #3f51b5; - color: white; - border-bottom-right-radius: 4px; - } - - .msg-icon { - color: #3f51b5; - } - } - - &.bot { - justify-content: flex-start; - - .bubble { - background: #e8eaf6; - color: #000; - border-bottom-left-radius: 4px; - } - - .msg-icon { - color: #7986cb; - } - } - - .msg-icon { - margin-top: 4px; - } - - .msg-content { - display: flex; - align-items: flex-start; - gap: 8px; - max-width: 70%; - - .bubble { - padding: 12px 16px; - border-radius: 16px; - line-height: 1.5; - word-wrap: break-word; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); - animation: slideIn 0.3s ease-out; - } - - .play-button { - margin-top: 4px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - } - } -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.input-row { - display: flex; - padding: 16px; - gap: 12px; - align-items: flex-start; - background-color: #fff; - - .flex-1 { - flex: 1; - } - - .send-button { - margin-top: 8px; - } -} - -// Loading state -.loading-spinner { - display: flex; - justify-content: center; - padding: 20px; -} - -// Error state -.error-message { - color: #f44336; - padding: 16px; - text-align: center; - background-color: #ffebee; - border-radius: 4px; - margin: 16px; -} - -// Scrollbar styling -.chat-history::-webkit-scrollbar { - width: 8px; -} - -.chat-history::-webkit-scrollbar-track { - background: #f1f1f1; -} - -.chat-history::-webkit-scrollbar-thumb { - background: #888; - border-radius: 4px; -} - -.chat-history::-webkit-scrollbar-thumb:hover { - background: #555; -} - -.stt-hint { - font-size: 12px; - color: #666; - margin-top: 4px; - font-style: italic; -} - -.stt-checkbox { - margin-top: 16px; - - &[disabled] { - opacity: 0.5; - } -} - -.realtime-button { - margin-left: 16px; - background-color: #4caf50 !important; - - &:hover { - background-color: #45a049 !important; - } - - mat-icon { - margin-right: 8px; - } -} - -// Real-time indicator animation -@keyframes pulse { - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.1); - opacity: 0.7; - } - 100% { - transform: scale(1); - opacity: 1; - } -} - -.realtime-indicator { - color: #4caf50; - animation: pulse 2s infinite; -} - -// STT/TTS selection styling -.tts-checkbox, .stt-checkbox { - display: block; - margin-bottom: 8px; - - &[disabled] { - opacity: 0.5; - } -} - -.tts-hint, .stt-hint { - font-size: 12px; - color: #666; - margin-bottom: 16px; - margin-left: 32px; // Align with checkbox text - font-style: italic; -} - -// Highlight when STT is enabled -.stt-checkbox.mat-mdc-checkbox-checked + .stt-hint { - color: #4caf50; - font-weight: 500; -} - -// Button states -.mat-mdc-raised-button[disabled] { - opacity: 0.6; - - &.realtime-button { - opacity: 0.4; - } +.chat-container { + height: 100%; + padding: 24px; + max-width: 900px; + margin: 0 auto; +} + +.start-wrapper { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + + mat-card { + max-width: 500px; + width: 100%; + } + + .project-select { + width: 100%; + margin-bottom: 16px; + } + + .tts-checkbox { + margin-bottom: 8px; + } + + .tts-hint { + color: #666; + font-size: 12px; + margin-bottom: 16px; + } +} + +.locale-select { + width: 100%; + margin-bottom: 16px; +} + +.chat-card { + height: calc(100vh - 200px); + display: flex; + flex-direction: column; + + mat-card-header { + background-color: #f5f5f5; + padding: 16px; + + .spacer { + flex: 1; + } + + .tts-indicator { + color: #4caf50; + margin-right: 8px; + } + } +} + +.waveform-container { + background-color: #f0f0f0; + padding: 8px; + display: flex; + justify-content: center; + align-items: center; + min-height: 116px; + + canvas { + border-radius: 4px; + background-color: #f0f0f0; + } +} + +.chat-history { + flex: 1; + overflow-y: auto; + padding: 16px; + background: #fafafa; + min-height: 300px; +} + +.msg-row { + display: flex; + align-items: flex-start; + margin: 12px 0; + gap: 8px; + + &.me { + justify-content: flex-end; + flex-direction: row-reverse; + + .bubble { + background: #3f51b5; + color: white; + border-bottom-right-radius: 4px; + } + + .msg-icon { + color: #3f51b5; + } + } + + &.bot { + justify-content: flex-start; + + .bubble { + background: #e8eaf6; + color: #000; + border-bottom-left-radius: 4px; + } + + .msg-icon { + color: #7986cb; + } + } + + .msg-icon { + margin-top: 4px; + } + + .msg-content { + display: flex; + align-items: flex-start; + gap: 8px; + max-width: 70%; + + .bubble { + padding: 12px 16px; + border-radius: 16px; + line-height: 1.5; + word-wrap: break-word; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + animation: slideIn 0.3s ease-out; + } + + .play-button { + margin-top: 4px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.input-row { + display: flex; + padding: 16px; + gap: 12px; + align-items: flex-start; + background-color: #fff; + + .flex-1 { + flex: 1; + } + + .send-button { + margin-top: 8px; + } +} + +// Loading state +.loading-spinner { + display: flex; + justify-content: center; + padding: 20px; +} + +// Error state +.error-message { + color: #f44336; + padding: 16px; + text-align: center; + background-color: #ffebee; + border-radius: 4px; + margin: 16px; +} + +// Scrollbar styling +.chat-history::-webkit-scrollbar { + width: 8px; +} + +.chat-history::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.chat-history::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +.chat-history::-webkit-scrollbar-thumb:hover { + background: #555; +} + +.stt-hint { + font-size: 12px; + color: #666; + margin-top: 4px; + font-style: italic; +} + +.stt-checkbox { + margin-top: 16px; + + &[disabled] { + opacity: 0.5; + } +} + +.realtime-button { + margin-left: 16px; + background-color: #4caf50 !important; + + &:hover { + background-color: #45a049 !important; + } + + mat-icon { + margin-right: 8px; + } +} + +// Real-time indicator animation +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.7; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.realtime-indicator { + color: #4caf50; + animation: pulse 2s infinite; +} + +// STT/TTS selection styling +.tts-checkbox, .stt-checkbox { + display: block; + margin-bottom: 8px; + + &[disabled] { + opacity: 0.5; + } +} + +.tts-hint, .stt-hint { + font-size: 12px; + color: #666; + margin-bottom: 16px; + margin-left: 32px; // Align with checkbox text + font-style: italic; +} + +// Highlight when STT is enabled +.stt-checkbox.mat-mdc-checkbox-checked + .stt-hint { + color: #4caf50; + font-weight: 500; +} + +// Button states +.mat-mdc-raised-button[disabled] { + opacity: 0.6; + + &.realtime-button { + opacity: 0.4; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/chat/chat.component.ts b/flare-ui/src/app/components/chat/chat.component.ts index f3c67802377da5e05c648771146ea7e4df44a0f3..74f30c187da4dc5bfb0859249a0ba52b685c9289 100644 --- a/flare-ui/src/app/components/chat/chat.component.ts +++ b/flare-ui/src/app/components/chat/chat.component.ts @@ -1,631 +1,631 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatCardModule } from '@angular/material/card'; -import { MatSelectModule } from '@angular/material/select'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { Subject, takeUntil } from 'rxjs'; - -import { ApiService } from '../../services/api.service'; -import { EnvironmentService } from '../../services/environment.service'; -import { Router } from '@angular/router'; - -interface ChatMessage { - author: 'user' | 'assistant'; - text: string; - timestamp?: Date; - audioUrl?: string; -} - -@Component({ - selector: 'app-chat', - standalone: true, - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - MatButtonModule, - MatIconModule, - MatFormFieldModule, - MatInputModule, - MatCardModule, - MatSelectModule, - MatDividerModule, - MatTooltipModule, - MatProgressSpinnerModule, - MatCheckboxModule, - MatDialogModule, - MatSnackBarModule - ], - templateUrl: './chat.component.html', - styleUrls: ['./chat.component.scss'] -}) -export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked { - @ViewChild('scrollMe') private myScrollContainer!: ElementRef; - @ViewChild('audioPlayer') private audioPlayer!: ElementRef; - @ViewChild('waveformCanvas') private waveformCanvas!: ElementRef; - - projects: string[] = []; - selectedProject: string | null = null; - useTTS = false; - ttsAvailable = false; - selectedLocale: string = 'tr'; - availableLocales: any[] = []; - - sessionId: string | null = null; - messages: ChatMessage[] = []; - input = this.fb.control('', Validators.required); - - loading = false; - error = ''; - playingAudio = false; - useSTT = false; - sttAvailable = false; - isListening = false; - - // Audio visualization - audioContext?: AudioContext; - analyser?: AnalyserNode; - animationId?: number; - - private destroyed$ = new Subject(); - private shouldScroll = false; - - constructor( - private fb: FormBuilder, - private api: ApiService, - private environmentService: EnvironmentService, - private dialog: MatDialog, - private router: Router, - private snackBar: MatSnackBar - ) {} - - ngOnInit(): void { - this.loadProjects(); - this.loadAvailableLocales(); - this.checkTTSAvailability(); - this.checkSTTAvailability(); - - // Initialize Audio Context with error handling - try { - this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); - } catch (error) { - console.error('Failed to create AudioContext:', error); - } - - // Watch for STT toggle changes - this.watchSTTToggle(); - } - - loadAvailableLocales(): void { - this.api.getAvailableLocales().pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: (response) => { - this.availableLocales = response.locales; - this.selectedLocale = response.default || 'tr'; - }, - error: (err) => { - console.error('Failed to load locales:', err); - // Fallback locales - this.availableLocales = [ - { code: 'tr', name: 'Türkçe' }, - { code: 'en', name: 'English' } - ]; - } - }); - } - - private watchSTTToggle(): void { - // When STT is toggled, provide feedback - // This could be implemented with form control valueChanges if needed - } - - ngAfterViewChecked() { - if (this.shouldScroll) { - this.scrollToBottom(); - this.shouldScroll = false; - } - } - - ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - - // Cleanup audio resources - this.cleanupAudio(); - } - - private cleanupAudio(): void { - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = undefined; - } - - if (this.audioContext && this.audioContext.state !== 'closed') { - this.audioContext.close().catch(err => console.error('Failed to close audio context:', err)); - } - - // Clean up audio URLs - this.messages.forEach(msg => { - if (msg.audioUrl) { - URL.revokeObjectURL(msg.audioUrl); - } - }); - } - - private checkSTTAvailability(): void { - this.api.getEnvironment().pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: (env) => { - this.sttAvailable = env.stt_provider?.name !== 'no_stt'; - if (!this.sttAvailable) { - this.useSTT = false; - } - }, - error: (err) => { - console.error('Failed to check STT availability:', err); - this.sttAvailable = false; - } - }); - } - - async startRealtimeChat(): Promise { - if (!this.selectedProject) { - this.error = 'Please select a project first'; - this.snackBar.open(this.error, 'Close', { duration: 3000 }); - return; - } - - if (!this.sttAvailable || !this.useSTT) { - this.error = 'STT must be enabled for real-time chat'; - this.snackBar.open(this.error, 'Close', { duration: 5000 }); - return; - } - - this.loading = true; - this.error = ''; - - this.api.startChat(this.selectedProject, true, this.selectedLocale).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: res => { - // Store session ID for realtime component - localStorage.setItem('current_session_id', res.session_id); - localStorage.setItem('current_project', this.selectedProject || ''); - localStorage.setItem('current_locale', this.selectedLocale); - localStorage.setItem('use_tts', this.useTTS.toString()); - - // Open realtime chat dialog - this.openRealtimeDialog(res.session_id); - - this.loading = false; - }, - error: (err) => { - this.error = this.getErrorMessage(err); - this.loading = false; - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - }); - } - - private async openRealtimeDialog(sessionId: string): Promise { - try { - const { RealtimeChatComponent } = await import('./realtime-chat.component'); - - const dialogRef = this.dialog.open(RealtimeChatComponent, { - width: '90%', - maxWidth: '900px', - height: '85vh', - maxHeight: '800px', - disableClose: false, - panelClass: 'realtime-chat-dialog', - data: { - sessionId: sessionId, - projectName: this.selectedProject - } - }); - - dialogRef.afterClosed().pipe( - takeUntil(this.destroyed$) - ).subscribe(result => { - // Clean up session data - localStorage.removeItem('current_session_id'); - localStorage.removeItem('current_project'); - localStorage.removeItem('current_locale'); - localStorage.removeItem('use_tts'); - - // If session was active, end it - if (result === 'session_active' && sessionId) { - this.api.endSession(sessionId).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: () => console.log('Session ended'), - error: (err: any) => console.error('Failed to end session:', err) - }); - } - }); - } catch (error) { - console.error('Failed to load realtime chat:', error); - this.snackBar.open('Failed to open realtime chat', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - } - - loadProjects(): void { - this.loading = true; - this.error = ''; - - this.api.getChatProjects().pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: projects => { - this.projects = projects; - this.loading = false; - if (projects.length === 0) { - this.error = 'No enabled projects found. Please enable a project with published version.'; - } - }, - error: (err) => { - this.error = 'Failed to load projects'; - this.loading = false; - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - }); - } - - checkTTSAvailability(): void { - // Subscribe to environment updates - this.environmentService.environment$.pipe( - takeUntil(this.destroyed$) - ).subscribe(env => { - if (env) { - this.ttsAvailable = env.tts_provider?.name !== 'no_tts'; - if (!this.ttsAvailable) { - this.useTTS = false; - } - } - }); - - // Get current environment - this.api.getEnvironment().pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: (env) => { - this.ttsAvailable = env.tts_provider?.name !== 'no_tts'; - if (!this.ttsAvailable) { - this.useTTS = false; - } - } - }); - } - - startChat(): void { - if (!this.selectedProject) { - this.snackBar.open('Please select a project', 'Close', { duration: 3000 }); - return; - } - - if (this.useSTT) { - this.snackBar.open('For voice input, please use Real-time Chat', 'Close', { duration: 3000 }); - return; - } - - this.loading = true; - this.error = ''; - - this.api.startChat(this.selectedProject, false, this.selectedLocale).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: res => { - this.sessionId = res.session_id; - const message: ChatMessage = { - author: 'assistant', - text: res.answer, - timestamp: new Date() - }; - - this.messages = [message]; - this.loading = false; - this.shouldScroll = true; - - // Generate TTS if enabled - if (this.useTTS && this.ttsAvailable) { - this.generateTTS(res.answer, this.messages.length - 1); - } - }, - error: (err) => { - this.error = this.getErrorMessage(err); - this.loading = false; - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - }); - } - - send(): void { - if (!this.sessionId || this.input.invalid || this.loading) return; - - const text = this.input.value!.trim(); - if (!text) return; - - // Add user message - this.messages.push({ - author: 'user', - text, - timestamp: new Date() - }); - - this.input.reset(); - this.loading = true; - this.shouldScroll = true; - - // Send to backend - this.api.chat(this.sessionId, text).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: res => { - const message: ChatMessage = { - author: 'assistant', - text: res.response, - timestamp: new Date() - }; - - this.messages.push(message); - this.loading = false; - this.shouldScroll = true; - - // Generate TTS if enabled - if (this.useTTS && this.ttsAvailable) { - this.generateTTS(res.response, this.messages.length - 1); - } - }, - error: (err) => { - const errorMsg = this.getErrorMessage(err); - this.messages.push({ - author: 'assistant', - text: '⚠️ ' + errorMsg, - timestamp: new Date() - }); - this.loading = false; - this.shouldScroll = true; - } - }); - } - - generateTTS(text: string, messageIndex: number): void { - if (!this.ttsAvailable || messageIndex < 0 || messageIndex >= this.messages.length) return; - - this.api.generateTTS(text).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - next: (audioBlob) => { - const audioUrl = URL.createObjectURL(audioBlob); - - // Clean up old audio URL if exists - if (this.messages[messageIndex].audioUrl) { - URL.revokeObjectURL(this.messages[messageIndex].audioUrl!); - } - - this.messages[messageIndex].audioUrl = audioUrl; - - // Auto-play the latest message - if (messageIndex === this.messages.length - 1) { - setTimeout(() => this.playAudio(audioUrl), 100); - } - }, - error: (err) => { - console.error('TTS generation error:', err); - this.snackBar.open('Failed to generate audio', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - } - }); - } - - playAudio(audioUrl: string): void { - if (!this.audioPlayer || !audioUrl) return; - - const audio = this.audioPlayer.nativeElement; - - // Stop current audio if playing - if (!audio.paused) { - audio.pause(); - audio.currentTime = 0; - } - - audio.src = audioUrl; - - // Set up audio visualization - if (this.audioContext && this.audioContext.state !== 'closed') { - this.setupAudioVisualization(audio); - } - - audio.play().then(() => { - this.playingAudio = true; - }).catch(err => { - console.error('Audio play error:', err); - this.snackBar.open('Failed to play audio', 'Close', { - duration: 3000, - panelClass: 'error-snackbar' - }); - }); - - audio.onended = () => { - this.playingAudio = false; - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = undefined; - this.clearWaveform(); - } - }; - - audio.onerror = () => { - this.playingAudio = false; - console.error('Audio playback error'); - }; - } - - setupAudioVisualization(audio: HTMLAudioElement): void { - if (!this.audioContext || !this.waveformCanvas || this.audioContext.state === 'closed') return; - - try { - // Check if source already exists for this audio element - if (!(audio as any).audioSource) { - const source = this.audioContext.createMediaElementSource(audio); - this.analyser = this.audioContext.createAnalyser(); - this.analyser.fftSize = 256; - - // Connect nodes - source.connect(this.analyser); - this.analyser.connect(this.audioContext.destination); - - // Store reference to prevent recreation - (audio as any).audioSource = source; - } - - // Start visualization - this.drawWaveform(); - } catch (error) { - console.error('Failed to setup audio visualization:', error); - } - } - - drawWaveform(): void { - if (!this.analyser || !this.waveformCanvas) return; - - const canvas = this.waveformCanvas.nativeElement; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const bufferLength = this.analyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - const draw = () => { - if (!this.playingAudio) { - this.clearWaveform(); - return; - } - - this.animationId = requestAnimationFrame(draw); - - this.analyser!.getByteFrequencyData(dataArray); - - ctx.fillStyle = 'rgb(240, 240, 240)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const barWidth = (canvas.width / bufferLength) * 2.5; - let barHeight; - let x = 0; - - for (let i = 0; i < bufferLength; i++) { - barHeight = (dataArray[i] / 255) * canvas.height * 0.8; - - ctx.fillStyle = `rgb(63, 81, 181)`; - ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); - - x += barWidth + 1; - } - }; - - draw(); - } - - clearWaveform(): void { - if (!this.waveformCanvas) return; - - const canvas = this.waveformCanvas.nativeElement; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - ctx.fillStyle = 'rgb(240, 240, 240)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - - endSession(): void { - // Clean up current session - if (this.sessionId) { - this.api.endSession(this.sessionId).pipe( - takeUntil(this.destroyed$) - ).subscribe({ - error: (err) => console.error('Failed to end session:', err) - }); - } - - // Clean up audio URLs - this.messages.forEach(msg => { - if (msg.audioUrl) { - URL.revokeObjectURL(msg.audioUrl); - } - }); - - // Reset state - this.sessionId = null; - this.messages = []; - this.selectedProject = null; - this.input.reset(); - this.error = ''; - - // Clean up audio - if (this.audioPlayer) { - this.audioPlayer.nativeElement.pause(); - this.audioPlayer.nativeElement.src = ''; - } - - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = undefined; - } - - this.clearWaveform(); - } - - private scrollToBottom(): void { - try { - if (this.myScrollContainer?.nativeElement) { - const element = this.myScrollContainer.nativeElement; - element.scrollTop = element.scrollHeight; - } - } catch(err) { - console.error('Scroll error:', err); - } - } - - private getErrorMessage(error: any): string { - if (error.status === 0) { - return 'Unable to connect to server. Please check your connection.'; - } else if (error.status === 401) { - return 'Session expired. Please login again.'; - } else if (error.status === 403) { - return 'You do not have permission to use this feature.'; - } else if (error.status === 404) { - return 'Project or session not found. Please try again.'; - } else if (error.error?.detail) { - return error.error.detail; - } else if (error.message) { - return error.message; - } - return 'An unexpected error occurred. Please try again.'; - } +import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatCardModule } from '@angular/material/card'; +import { MatSelectModule } from '@angular/material/select'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { Subject, takeUntil } from 'rxjs'; + +import { ApiService } from '../../services/api.service'; +import { EnvironmentService } from '../../services/environment.service'; +import { Router } from '@angular/router'; + +interface ChatMessage { + author: 'user' | 'assistant'; + text: string; + timestamp?: Date; + audioUrl?: string; +} + +@Component({ + selector: 'app-chat', + standalone: true, + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatCardModule, + MatSelectModule, + MatDividerModule, + MatTooltipModule, + MatProgressSpinnerModule, + MatCheckboxModule, + MatDialogModule, + MatSnackBarModule + ], + templateUrl: './chat.component.html', + styleUrls: ['./chat.component.scss'] +}) +export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked { + @ViewChild('scrollMe') private myScrollContainer!: ElementRef; + @ViewChild('audioPlayer') private audioPlayer!: ElementRef; + @ViewChild('waveformCanvas') private waveformCanvas!: ElementRef; + + projects: string[] = []; + selectedProject: string | null = null; + useTTS = false; + ttsAvailable = false; + selectedLocale: string = 'tr'; + availableLocales: any[] = []; + + sessionId: string | null = null; + messages: ChatMessage[] = []; + input = this.fb.control('', Validators.required); + + loading = false; + error = ''; + playingAudio = false; + useSTT = false; + sttAvailable = false; + isListening = false; + + // Audio visualization + audioContext?: AudioContext; + analyser?: AnalyserNode; + animationId?: number; + + private destroyed$ = new Subject(); + private shouldScroll = false; + + constructor( + private fb: FormBuilder, + private api: ApiService, + private environmentService: EnvironmentService, + private dialog: MatDialog, + private router: Router, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + this.loadProjects(); + this.loadAvailableLocales(); + this.checkTTSAvailability(); + this.checkSTTAvailability(); + + // Initialize Audio Context with error handling + try { + this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + } catch (error) { + console.error('Failed to create AudioContext:', error); + } + + // Watch for STT toggle changes + this.watchSTTToggle(); + } + + loadAvailableLocales(): void { + this.api.getAvailableLocales().pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: (response) => { + this.availableLocales = response.locales; + this.selectedLocale = response.default || 'tr'; + }, + error: (err) => { + console.error('Failed to load locales:', err); + // Fallback locales + this.availableLocales = [ + { code: 'tr', name: 'Türkçe' }, + { code: 'en', name: 'English' } + ]; + } + }); + } + + private watchSTTToggle(): void { + // When STT is toggled, provide feedback + // This could be implemented with form control valueChanges if needed + } + + ngAfterViewChecked() { + if (this.shouldScroll) { + this.scrollToBottom(); + this.shouldScroll = false; + } + } + + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + + // Cleanup audio resources + this.cleanupAudio(); + } + + private cleanupAudio(): void { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = undefined; + } + + if (this.audioContext && this.audioContext.state !== 'closed') { + this.audioContext.close().catch(err => console.error('Failed to close audio context:', err)); + } + + // Clean up audio URLs + this.messages.forEach(msg => { + if (msg.audioUrl) { + URL.revokeObjectURL(msg.audioUrl); + } + }); + } + + private checkSTTAvailability(): void { + this.api.getEnvironment().pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: (env) => { + this.sttAvailable = env.stt_provider?.name !== 'no_stt'; + if (!this.sttAvailable) { + this.useSTT = false; + } + }, + error: (err) => { + console.error('Failed to check STT availability:', err); + this.sttAvailable = false; + } + }); + } + + async startRealtimeChat(): Promise { + if (!this.selectedProject) { + this.error = 'Please select a project first'; + this.snackBar.open(this.error, 'Close', { duration: 3000 }); + return; + } + + if (!this.sttAvailable || !this.useSTT) { + this.error = 'STT must be enabled for real-time chat'; + this.snackBar.open(this.error, 'Close', { duration: 5000 }); + return; + } + + this.loading = true; + this.error = ''; + + this.api.startChat(this.selectedProject, true, this.selectedLocale).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: res => { + // Store session ID for realtime component + localStorage.setItem('current_session_id', res.session_id); + localStorage.setItem('current_project', this.selectedProject || ''); + localStorage.setItem('current_locale', this.selectedLocale); + localStorage.setItem('use_tts', this.useTTS.toString()); + + // Open realtime chat dialog + this.openRealtimeDialog(res.session_id); + + this.loading = false; + }, + error: (err) => { + this.error = this.getErrorMessage(err); + this.loading = false; + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + }); + } + + private async openRealtimeDialog(sessionId: string): Promise { + try { + const { RealtimeChatComponent } = await import('./realtime-chat.component'); + + const dialogRef = this.dialog.open(RealtimeChatComponent, { + width: '90%', + maxWidth: '900px', + height: '85vh', + maxHeight: '800px', + disableClose: false, + panelClass: 'realtime-chat-dialog', + data: { + sessionId: sessionId, + projectName: this.selectedProject + } + }); + + dialogRef.afterClosed().pipe( + takeUntil(this.destroyed$) + ).subscribe(result => { + // Clean up session data + localStorage.removeItem('current_session_id'); + localStorage.removeItem('current_project'); + localStorage.removeItem('current_locale'); + localStorage.removeItem('use_tts'); + + // If session was active, end it + if (result === 'session_active' && sessionId) { + this.api.endSession(sessionId).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: () => console.log('Session ended'), + error: (err: any) => console.error('Failed to end session:', err) + }); + } + }); + } catch (error) { + console.error('Failed to load realtime chat:', error); + this.snackBar.open('Failed to open realtime chat', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + } + + loadProjects(): void { + this.loading = true; + this.error = ''; + + this.api.getChatProjects().pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: projects => { + this.projects = projects; + this.loading = false; + if (projects.length === 0) { + this.error = 'No enabled projects found. Please enable a project with published version.'; + } + }, + error: (err) => { + this.error = 'Failed to load projects'; + this.loading = false; + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + }); + } + + checkTTSAvailability(): void { + // Subscribe to environment updates + this.environmentService.environment$.pipe( + takeUntil(this.destroyed$) + ).subscribe(env => { + if (env) { + this.ttsAvailable = env.tts_provider?.name !== 'no_tts'; + if (!this.ttsAvailable) { + this.useTTS = false; + } + } + }); + + // Get current environment + this.api.getEnvironment().pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: (env) => { + this.ttsAvailable = env.tts_provider?.name !== 'no_tts'; + if (!this.ttsAvailable) { + this.useTTS = false; + } + } + }); + } + + startChat(): void { + if (!this.selectedProject) { + this.snackBar.open('Please select a project', 'Close', { duration: 3000 }); + return; + } + + if (this.useSTT) { + this.snackBar.open('For voice input, please use Real-time Chat', 'Close', { duration: 3000 }); + return; + } + + this.loading = true; + this.error = ''; + + this.api.startChat(this.selectedProject, false, this.selectedLocale).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: res => { + this.sessionId = res.session_id; + const message: ChatMessage = { + author: 'assistant', + text: res.answer, + timestamp: new Date() + }; + + this.messages = [message]; + this.loading = false; + this.shouldScroll = true; + + // Generate TTS if enabled + if (this.useTTS && this.ttsAvailable) { + this.generateTTS(res.answer, this.messages.length - 1); + } + }, + error: (err) => { + this.error = this.getErrorMessage(err); + this.loading = false; + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + }); + } + + send(): void { + if (!this.sessionId || this.input.invalid || this.loading) return; + + const text = this.input.value!.trim(); + if (!text) return; + + // Add user message + this.messages.push({ + author: 'user', + text, + timestamp: new Date() + }); + + this.input.reset(); + this.loading = true; + this.shouldScroll = true; + + // Send to backend + this.api.chat(this.sessionId, text).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: res => { + const message: ChatMessage = { + author: 'assistant', + text: res.response, + timestamp: new Date() + }; + + this.messages.push(message); + this.loading = false; + this.shouldScroll = true; + + // Generate TTS if enabled + if (this.useTTS && this.ttsAvailable) { + this.generateTTS(res.response, this.messages.length - 1); + } + }, + error: (err) => { + const errorMsg = this.getErrorMessage(err); + this.messages.push({ + author: 'assistant', + text: '⚠️ ' + errorMsg, + timestamp: new Date() + }); + this.loading = false; + this.shouldScroll = true; + } + }); + } + + generateTTS(text: string, messageIndex: number): void { + if (!this.ttsAvailable || messageIndex < 0 || messageIndex >= this.messages.length) return; + + this.api.generateTTS(text).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + next: (audioBlob) => { + const audioUrl = URL.createObjectURL(audioBlob); + + // Clean up old audio URL if exists + if (this.messages[messageIndex].audioUrl) { + URL.revokeObjectURL(this.messages[messageIndex].audioUrl!); + } + + this.messages[messageIndex].audioUrl = audioUrl; + + // Auto-play the latest message + if (messageIndex === this.messages.length - 1) { + setTimeout(() => this.playAudio(audioUrl), 100); + } + }, + error: (err) => { + console.error('TTS generation error:', err); + this.snackBar.open('Failed to generate audio', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + } + }); + } + + playAudio(audioUrl: string): void { + if (!this.audioPlayer || !audioUrl) return; + + const audio = this.audioPlayer.nativeElement; + + // Stop current audio if playing + if (!audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + + audio.src = audioUrl; + + // Set up audio visualization + if (this.audioContext && this.audioContext.state !== 'closed') { + this.setupAudioVisualization(audio); + } + + audio.play().then(() => { + this.playingAudio = true; + }).catch(err => { + console.error('Audio play error:', err); + this.snackBar.open('Failed to play audio', 'Close', { + duration: 3000, + panelClass: 'error-snackbar' + }); + }); + + audio.onended = () => { + this.playingAudio = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = undefined; + this.clearWaveform(); + } + }; + + audio.onerror = () => { + this.playingAudio = false; + console.error('Audio playback error'); + }; + } + + setupAudioVisualization(audio: HTMLAudioElement): void { + if (!this.audioContext || !this.waveformCanvas || this.audioContext.state === 'closed') return; + + try { + // Check if source already exists for this audio element + if (!(audio as any).audioSource) { + const source = this.audioContext.createMediaElementSource(audio); + this.analyser = this.audioContext.createAnalyser(); + this.analyser.fftSize = 256; + + // Connect nodes + source.connect(this.analyser); + this.analyser.connect(this.audioContext.destination); + + // Store reference to prevent recreation + (audio as any).audioSource = source; + } + + // Start visualization + this.drawWaveform(); + } catch (error) { + console.error('Failed to setup audio visualization:', error); + } + } + + drawWaveform(): void { + if (!this.analyser || !this.waveformCanvas) return; + + const canvas = this.waveformCanvas.nativeElement; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const bufferLength = this.analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + const draw = () => { + if (!this.playingAudio) { + this.clearWaveform(); + return; + } + + this.animationId = requestAnimationFrame(draw); + + this.analyser!.getByteFrequencyData(dataArray); + + ctx.fillStyle = 'rgb(240, 240, 240)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const barWidth = (canvas.width / bufferLength) * 2.5; + let barHeight; + let x = 0; + + for (let i = 0; i < bufferLength; i++) { + barHeight = (dataArray[i] / 255) * canvas.height * 0.8; + + ctx.fillStyle = `rgb(63, 81, 181)`; + ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight); + + x += barWidth + 1; + } + }; + + draw(); + } + + clearWaveform(): void { + if (!this.waveformCanvas) return; + + const canvas = this.waveformCanvas.nativeElement; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.fillStyle = 'rgb(240, 240, 240)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + endSession(): void { + // Clean up current session + if (this.sessionId) { + this.api.endSession(this.sessionId).pipe( + takeUntil(this.destroyed$) + ).subscribe({ + error: (err) => console.error('Failed to end session:', err) + }); + } + + // Clean up audio URLs + this.messages.forEach(msg => { + if (msg.audioUrl) { + URL.revokeObjectURL(msg.audioUrl); + } + }); + + // Reset state + this.sessionId = null; + this.messages = []; + this.selectedProject = null; + this.input.reset(); + this.error = ''; + + // Clean up audio + if (this.audioPlayer) { + this.audioPlayer.nativeElement.pause(); + this.audioPlayer.nativeElement.src = ''; + } + + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = undefined; + } + + this.clearWaveform(); + } + + private scrollToBottom(): void { + try { + if (this.myScrollContainer?.nativeElement) { + const element = this.myScrollContainer.nativeElement; + element.scrollTop = element.scrollHeight; + } + } catch(err) { + console.error('Scroll error:', err); + } + } + + private getErrorMessage(error: any): string { + if (error.status === 0) { + return 'Unable to connect to server. Please check your connection.'; + } else if (error.status === 401) { + return 'Session expired. Please login again.'; + } else if (error.status === 403) { + return 'You do not have permission to use this feature.'; + } else if (error.status === 404) { + return 'Project or session not found. Please try again.'; + } else if (error.error?.detail) { + return error.error.detail; + } else if (error.message) { + return error.message; + } + return 'An unexpected error occurred. Please try again.'; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/chat/realtime-chat.component.html b/flare-ui/src/app/components/chat/realtime-chat.component.html index b880430a7d3f15b17d5e64d8b2841a3a02cb09d9..1f3f0f700c9463883ee8a74cda099186c23e61d4 100644 --- a/flare-ui/src/app/components/chat/realtime-chat.component.html +++ b/flare-ui/src/app/components/chat/realtime-chat.component.html @@ -1,97 +1,97 @@ - - - voice_chat - Real-time Conversation - - - - {{ getStateLabel(state) }} - - - - - - - - - - -
- error_outline - {{ error }} - -
- - -
-
- - {{ msg.role === 'user' ? 'person' : msg.role === 'assistant' ? 'smart_toy' : 'info' }} - -
-
{{ msg.text }}
-
{{ msg.timestamp | date:'HH:mm:ss' }}
- -
-
- - -
- mic_off -

Konuşmaya başlamak için aşağıdaki butona tıklayın

-
-
- - - - - -
- - - - - - - - - + + + voice_chat + Real-time Conversation + + + + {{ getStateLabel(state) }} + + + + + + + + + + +
+ error_outline + {{ error }} + +
+ + +
+
+ + {{ msg.role === 'user' ? 'person' : msg.role === 'assistant' ? 'smart_toy' : 'info' }} + +
+
{{ msg.text }}
+
{{ msg.timestamp | date:'HH:mm:ss' }}
+ +
+
+ + +
+ mic_off +

Konuşmaya başlamak için aşağıdaki butona tıklayın

+
+
+ + + + + +
+ + + + + + + + +
\ No newline at end of file diff --git a/flare-ui/src/app/components/chat/realtime-chat.component.scss b/flare-ui/src/app/components/chat/realtime-chat.component.scss index 26231bd68d29ed885f04d6026016c846bb355635..c1c7297ebe5e959c67390f4d98857104b0d4722d 100644 --- a/flare-ui/src/app/components/chat/realtime-chat.component.scss +++ b/flare-ui/src/app/components/chat/realtime-chat.component.scss @@ -1,165 +1,165 @@ -.realtime-chat-container { - max-width: 800px; - margin: 20px auto; - height: 80vh; - display: flex; - flex-direction: column; - position: relative; -} - -mat-card-header { - position: relative; - - .close-button { - position: absolute; - top: 8px; - right: 8px; - } -} - -.error-banner { - background-color: #ffebee; - color: #c62828; - padding: 12px; - border-radius: 4px; - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - - span { - flex: 1; - } -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 16px; - background: #fafafa; - border-radius: 8px; - min-height: 300px; - max-height: 450px; -} - -.message { - display: flex; - align-items: flex-start; - margin-bottom: 16px; - animation: slideIn 0.3s ease-out; -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.message.user { - flex-direction: row-reverse; -} - -.message.system { - justify-content: center; - - .message-content { - background: #e0e0e0; - font-style: italic; - max-width: 80%; - } -} - -.message-icon { - margin: 0 8px; - color: #666; -} - -.message-content { - max-width: 70%; - background: white; - padding: 12px 16px; - border-radius: 12px; - box-shadow: 0 1px 2px rgba(0,0,0,0.1); - position: relative; -} - -.message.user .message-content { - background: #3f51b5; - color: white; -} - -.message-text { - margin-bottom: 4px; -} - -.message-time { - font-size: 11px; - opacity: 0.7; -} - -.audio-button { - margin-top: 8px; -} - -.empty-state { - text-align: center; - padding: 60px 20px; - color: #999; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - margin-bottom: 16px; - } -} - -.audio-visualizer { - width: 100%; - height: 100px; - background: #212121; - border-radius: 8px; - margin-top: 16px; - opacity: 0.3; - transition: all 0.3s ease; - position: relative; - overflow: hidden; - - &.active { - opacity: 1; - background: #1a1a1a; - box-shadow: 0 0 20px rgba(76, 175, 80, 0.3); - } -} - -mat-chip { - font-size: 12px; -} - -mat-chip.active { - background-color: #3f51b5 !important; - color: white !important; -} - -mat-card-actions { - padding: 16px; - display: flex; - gap: 16px; - justify-content: flex-start; - - mat-spinner { - display: inline-block; - margin-right: 8px; - } +.realtime-chat-container { + max-width: 800px; + margin: 20px auto; + height: 80vh; + display: flex; + flex-direction: column; + position: relative; +} + +mat-card-header { + position: relative; + + .close-button { + position: absolute; + top: 8px; + right: 8px; + } +} + +.error-banner { + background-color: #ffebee; + color: #c62828; + padding: 12px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + span { + flex: 1; + } +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + background: #fafafa; + border-radius: 8px; + min-height: 300px; + max-height: 450px; +} + +.message { + display: flex; + align-items: flex-start; + margin-bottom: 16px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.user { + flex-direction: row-reverse; +} + +.message.system { + justify-content: center; + + .message-content { + background: #e0e0e0; + font-style: italic; + max-width: 80%; + } +} + +.message-icon { + margin: 0 8px; + color: #666; +} + +.message-content { + max-width: 70%; + background: white; + padding: 12px 16px; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + position: relative; +} + +.message.user .message-content { + background: #3f51b5; + color: white; +} + +.message-text { + margin-bottom: 4px; +} + +.message-time { + font-size: 11px; + opacity: 0.7; +} + +.audio-button { + margin-top: 8px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: #999; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 16px; + } +} + +.audio-visualizer { + width: 100%; + height: 100px; + background: #212121; + border-radius: 8px; + margin-top: 16px; + opacity: 0.3; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + + &.active { + opacity: 1; + background: #1a1a1a; + box-shadow: 0 0 20px rgba(76, 175, 80, 0.3); + } +} + +mat-chip { + font-size: 12px; +} + +mat-chip.active { + background-color: #3f51b5 !important; + color: white !important; +} + +mat-card-actions { + padding: 16px; + display: flex; + gap: 16px; + justify-content: flex-start; + + mat-spinner { + display: inline-block; + margin-right: 8px; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/chat/realtime-chat.component.ts b/flare-ui/src/app/components/chat/realtime-chat.component.ts index fbb6d8903fa225fd9d2714f2e66b94b809c34909..fca2ce6bfa58a6be12f6b94058a994e2121fdf84 100644 --- a/flare-ui/src/app/components/chat/realtime-chat.component.ts +++ b/flare-ui/src/app/components/chat/realtime-chat.component.ts @@ -1,422 +1,422 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatCardModule } from '@angular/material/card'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { Inject } from '@angular/core'; -import { Subject, Subscription, takeUntil } from 'rxjs'; - -import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service'; -import { AudioStreamService } from '../../services/audio-stream.service'; - -@Component({ - selector: 'app-realtime-chat', - standalone: true, - imports: [ - CommonModule, - MatCardModule, - MatButtonModule, - MatIconModule, - MatProgressSpinnerModule, - MatDividerModule, - MatChipsModule, - MatSnackBarModule - ], - templateUrl: './realtime-chat.component.html', - styleUrls: ['./realtime-chat.component.scss'] -}) -export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked { - @ViewChild('scrollContainer') private scrollContainer!: ElementRef; - @ViewChild('audioVisualizer') private audioVisualizer!: ElementRef; - - sessionId: string | null = null; - projectName: string | null = null; - isConversationActive = false; - isRecording = false; - isPlayingAudio = false; - currentState: ConversationState = 'idle'; - messages: ConversationMessage[] = []; - error = ''; - loading = false; - - conversationStates: ConversationState[] = [ - 'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio' - ]; - - private destroyed$ = new Subject(); - private subscriptions = new Subscription(); - private shouldScrollToBottom = false; - private animationId: number | null = null; - private currentAudio: HTMLAudioElement | null = null; - private volumeUpdateSubscription?: Subscription; - - constructor( - private conversationManager: ConversationManagerService, - private audioService: AudioStreamService, - private snackBar: MatSnackBar, - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string } - ) { - this.sessionId = data.sessionId; - this.projectName = data.projectName || null; - } - - ngOnInit(): void { - console.log('🎤 RealtimeChat component initialized'); - console.log('Session ID:', this.sessionId); - console.log('Project Name:', this.projectName); - - // Subscribe to messages FIRST - before any connection - this.conversationManager.messages$.pipe( - takeUntil(this.destroyed$) - ).subscribe(messages => { - console.log('💬 Messages updated:', messages.length, 'messages'); - this.messages = messages; - this.shouldScrollToBottom = true; - - // Check if we have initial welcome message - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...'); - } - }); - - // Check browser support - if (!AudioStreamService.checkBrowserSupport()) { - this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.'; - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - return; - } - - // Check microphone permission - this.checkMicrophonePermission(); - - // Subscribe to conversation state - this.conversationManager.currentState$.pipe( - takeUntil(this.destroyed$) - ).subscribe(state => { - console.log('📊 Conversation state:', state); - this.currentState = state; - - // Recording state'i conversation active olduğu sürece true tut - // Sadece error state'inde false yap - this.isRecording = this.isConversationActive && state !== 'error'; - }); - - // Subscribe to errors - this.conversationManager.error$.pipe( - takeUntil(this.destroyed$) - ).subscribe(error => { - console.error('Conversation error:', error); - this.error = error.message; - }); - - // Load initial messages from session if available - const initialMessages = this.conversationManager.getMessages(); - console.log('📋 Initial messages:', initialMessages.length); - if (initialMessages.length > 0) { - this.messages = initialMessages; - this.shouldScrollToBottom = true; - } - } - - ngAfterViewChecked(): void { - if (this.shouldScrollToBottom) { - this.scrollToBottom(); - this.shouldScrollToBottom = false; - } - } - - ngOnDestroy(): void { - this.destroyed$.next(); - this.destroyed$.complete(); - this.subscriptions.unsubscribe(); - this.stopVisualization(); - this.cleanupAudio(); - - if (this.isConversationActive) { - this.conversationManager.stopConversation(); - } - } - - async toggleConversation(): Promise { - if (!this.sessionId) return; - - if (this.isConversationActive) { - this.stopConversation(); - } else { - await this.startConversation(); - } - } - - async retryConnection(): Promise { - this.error = ''; - if (!this.isConversationActive && this.sessionId) { - await this.startConversation(); - } - } - - clearChat(): void { - this.conversationManager.clearMessages(); - this.error = ''; - } - - performBargeIn(): void { - // Barge-in özelliği devre dışı - this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', { - duration: 2000 - }); - } - - playAudio(audioUrl?: string): void { - if (!audioUrl) return; - - // Stop current audio if playing - if (this.currentAudio) { - this.currentAudio.pause(); - this.currentAudio = null; - this.isPlayingAudio = false; - return; - } - - this.currentAudio = new Audio(audioUrl); - this.isPlayingAudio = true; - - this.currentAudio.play().catch(error => { - console.error('Audio playback error:', error); - this.isPlayingAudio = false; - this.currentAudio = null; - }); - - this.currentAudio.onended = () => { - this.isPlayingAudio = false; - this.currentAudio = null; - }; - - this.currentAudio.onerror = () => { - this.isPlayingAudio = false; - this.currentAudio = null; - this.snackBar.open('Ses çalınamadı', 'Close', { - duration: 2000, - panelClass: 'error-snackbar' - }); - }; - } - - getStateLabel(state: ConversationState): string { - const labels: Record = { - 'idle': 'Bekliyor', - 'listening': 'Dinliyor', - 'processing_stt': 'Metin Dönüştürme', - 'processing_llm': 'Yanıt Hazırlanıyor', - 'processing_tts': 'Ses Oluşturuluyor', - 'playing_audio': 'Konuşuyor', - 'error': 'Hata' - }; - return labels[state] || state; - } - - closeDialog(): void { - const result = this.isConversationActive ? 'session_active' : 'closed'; - this.dialogRef.close(result); - } - - trackByIndex(index: number): number { - return index; - } - - private async checkMicrophonePermission(): Promise { - try { - const permission = await this.audioService.checkMicrophonePermission(); - if (permission === 'denied') { - this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.'; - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - } catch (error) { - console.error('Failed to check microphone permission:', error); - } - } - - private scrollToBottom(): void { - try { - if (this.scrollContainer?.nativeElement) { - const element = this.scrollContainer.nativeElement; - element.scrollTop = element.scrollHeight; - } - } catch(err) { - console.error('Scroll error:', err); - } - } - - async startConversation(): Promise { - try { - this.loading = true; - this.error = ''; - - // Clear existing messages - welcome will come via WebSocket - this.conversationManager.clearMessages(); - - await this.conversationManager.startConversation(this.sessionId!); - this.isConversationActive = true; - this.isRecording = true; // Konuşma başladığında recording'i aktif et - - // Visualization'ı başlat - this.startVisualization(); - - this.snackBar.open('Konuşma başlatıldı', 'Close', { - duration: 2000 - }); - } catch (error: any) { - console.error('Failed to start conversation:', error); - this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.'; - this.snackBar.open(this.error, 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } finally { - this.loading = false; - } - } - - private stopConversation(): void { - this.conversationManager.stopConversation(); - this.isConversationActive = false; - this.isRecording = false; // Konuşma bittiğinde recording'i kapat - this.stopVisualization(); - - this.snackBar.open('Konuşma sonlandırıldı', 'Close', { - duration: 2000 - }); - } - - private startVisualization(): void { - // Eğer zaten çalışıyorsa tekrar başlatma - if (!this.audioVisualizer || this.animationId) { - return; - } - - const canvas = this.audioVisualizer.nativeElement; - const ctx = canvas.getContext('2d'); - if (!ctx) { - console.warn('Could not get canvas context'); - return; - } - - // Set canvas size - canvas.width = canvas.offsetWidth; - canvas.height = canvas.offsetHeight; - - // Create gradient for bars - const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); - gradient.addColorStop(0, '#4caf50'); - gradient.addColorStop(0.5, '#66bb6a'); - gradient.addColorStop(1, '#4caf50'); - - let lastVolume = 0; - let targetVolume = 0; - const smoothingFactor = 0.8; - - // Subscribe to volume updates - this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => { - targetVolume = volume; - }); - - // Animation loop - const animate = () => { - // isConversationActive kontrolü ile devam et - if (!this.isConversationActive) { - this.clearVisualization(); - return; - } - - // Clear canvas - ctx.fillStyle = '#1a1a1a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Smooth volume transition - lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor); - - // Draw frequency bars - const barCount = 32; - const barWidth = canvas.width / barCount; - const barSpacing = 2; - - for (let i = 0; i < barCount; i++) { - // Create natural wave effect based on volume - const frequencyFactor = Math.sin((i / barCount) * Math.PI); - const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8; - const randomFactor = 0.8 + Math.random() * 0.2; - - const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor; - - const x = i * barWidth; - const y = (canvas.height - barHeight) / 2; - - // Draw bar - ctx.fillStyle = gradient; - ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight); - - // Draw reflection - ctx.fillStyle = 'rgba(76, 175, 80, 0.2)'; - ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3); - } - - // Draw center line - ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, canvas.height / 2); - ctx.lineTo(canvas.width, canvas.height / 2); - ctx.stroke(); - - this.animationId = requestAnimationFrame(animate); - }; - - animate(); - } - - private stopVisualization(): void { - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = null; - } - - if (this.volumeUpdateSubscription) { - this.volumeUpdateSubscription.unsubscribe(); - this.volumeUpdateSubscription = undefined; - } - - this.clearVisualization(); - } - - private clearVisualization(): void { - if (!this.audioVisualizer) return; - - const canvas = this.audioVisualizer.nativeElement; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.fillStyle = '#212121'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - } - } - - - private cleanupAudio(): void { - if (this.currentAudio) { - this.currentAudio.pause(); - this.currentAudio = null; - this.isPlayingAudio = false; - } - } +import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { Inject } from '@angular/core'; +import { Subject, Subscription, takeUntil } from 'rxjs'; + +import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service'; +import { AudioStreamService } from '../../services/audio-stream.service'; + +@Component({ + selector: 'app-realtime-chat', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatDividerModule, + MatChipsModule, + MatSnackBarModule + ], + templateUrl: './realtime-chat.component.html', + styleUrls: ['./realtime-chat.component.scss'] +}) +export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked { + @ViewChild('scrollContainer') private scrollContainer!: ElementRef; + @ViewChild('audioVisualizer') private audioVisualizer!: ElementRef; + + sessionId: string | null = null; + projectName: string | null = null; + isConversationActive = false; + isRecording = false; + isPlayingAudio = false; + currentState: ConversationState = 'idle'; + messages: ConversationMessage[] = []; + error = ''; + loading = false; + + conversationStates: ConversationState[] = [ + 'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio' + ]; + + private destroyed$ = new Subject(); + private subscriptions = new Subscription(); + private shouldScrollToBottom = false; + private animationId: number | null = null; + private currentAudio: HTMLAudioElement | null = null; + private volumeUpdateSubscription?: Subscription; + + constructor( + private conversationManager: ConversationManagerService, + private audioService: AudioStreamService, + private snackBar: MatSnackBar, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string } + ) { + this.sessionId = data.sessionId; + this.projectName = data.projectName || null; + } + + ngOnInit(): void { + console.log('🎤 RealtimeChat component initialized'); + console.log('Session ID:', this.sessionId); + console.log('Project Name:', this.projectName); + + // Subscribe to messages FIRST - before any connection + this.conversationManager.messages$.pipe( + takeUntil(this.destroyed$) + ).subscribe(messages => { + console.log('💬 Messages updated:', messages.length, 'messages'); + this.messages = messages; + this.shouldScrollToBottom = true; + + // Check if we have initial welcome message + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...'); + } + }); + + // Check browser support + if (!AudioStreamService.checkBrowserSupport()) { + this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.'; + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + return; + } + + // Check microphone permission + this.checkMicrophonePermission(); + + // Subscribe to conversation state + this.conversationManager.currentState$.pipe( + takeUntil(this.destroyed$) + ).subscribe(state => { + console.log('📊 Conversation state:', state); + this.currentState = state; + + // Recording state'i conversation active olduğu sürece true tut + // Sadece error state'inde false yap + this.isRecording = this.isConversationActive && state !== 'error'; + }); + + // Subscribe to errors + this.conversationManager.error$.pipe( + takeUntil(this.destroyed$) + ).subscribe(error => { + console.error('Conversation error:', error); + this.error = error.message; + }); + + // Load initial messages from session if available + const initialMessages = this.conversationManager.getMessages(); + console.log('📋 Initial messages:', initialMessages.length); + if (initialMessages.length > 0) { + this.messages = initialMessages; + this.shouldScrollToBottom = true; + } + } + + ngAfterViewChecked(): void { + if (this.shouldScrollToBottom) { + this.scrollToBottom(); + this.shouldScrollToBottom = false; + } + } + + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + this.subscriptions.unsubscribe(); + this.stopVisualization(); + this.cleanupAudio(); + + if (this.isConversationActive) { + this.conversationManager.stopConversation(); + } + } + + async toggleConversation(): Promise { + if (!this.sessionId) return; + + if (this.isConversationActive) { + this.stopConversation(); + } else { + await this.startConversation(); + } + } + + async retryConnection(): Promise { + this.error = ''; + if (!this.isConversationActive && this.sessionId) { + await this.startConversation(); + } + } + + clearChat(): void { + this.conversationManager.clearMessages(); + this.error = ''; + } + + performBargeIn(): void { + // Barge-in özelliği devre dışı + this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', { + duration: 2000 + }); + } + + playAudio(audioUrl?: string): void { + if (!audioUrl) return; + + // Stop current audio if playing + if (this.currentAudio) { + this.currentAudio.pause(); + this.currentAudio = null; + this.isPlayingAudio = false; + return; + } + + this.currentAudio = new Audio(audioUrl); + this.isPlayingAudio = true; + + this.currentAudio.play().catch(error => { + console.error('Audio playback error:', error); + this.isPlayingAudio = false; + this.currentAudio = null; + }); + + this.currentAudio.onended = () => { + this.isPlayingAudio = false; + this.currentAudio = null; + }; + + this.currentAudio.onerror = () => { + this.isPlayingAudio = false; + this.currentAudio = null; + this.snackBar.open('Ses çalınamadı', 'Close', { + duration: 2000, + panelClass: 'error-snackbar' + }); + }; + } + + getStateLabel(state: ConversationState): string { + const labels: Record = { + 'idle': 'Bekliyor', + 'listening': 'Dinliyor', + 'processing_stt': 'Metin Dönüştürme', + 'processing_llm': 'Yanıt Hazırlanıyor', + 'processing_tts': 'Ses Oluşturuluyor', + 'playing_audio': 'Konuşuyor', + 'error': 'Hata' + }; + return labels[state] || state; + } + + closeDialog(): void { + const result = this.isConversationActive ? 'session_active' : 'closed'; + this.dialogRef.close(result); + } + + trackByIndex(index: number): number { + return index; + } + + private async checkMicrophonePermission(): Promise { + try { + const permission = await this.audioService.checkMicrophonePermission(); + if (permission === 'denied') { + this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.'; + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + } catch (error) { + console.error('Failed to check microphone permission:', error); + } + } + + private scrollToBottom(): void { + try { + if (this.scrollContainer?.nativeElement) { + const element = this.scrollContainer.nativeElement; + element.scrollTop = element.scrollHeight; + } + } catch(err) { + console.error('Scroll error:', err); + } + } + + async startConversation(): Promise { + try { + this.loading = true; + this.error = ''; + + // Clear existing messages - welcome will come via WebSocket + this.conversationManager.clearMessages(); + + await this.conversationManager.startConversation(this.sessionId!); + this.isConversationActive = true; + this.isRecording = true; // Konuşma başladığında recording'i aktif et + + // Visualization'ı başlat + this.startVisualization(); + + this.snackBar.open('Konuşma başlatıldı', 'Close', { + duration: 2000 + }); + } catch (error: any) { + console.error('Failed to start conversation:', error); + this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.'; + this.snackBar.open(this.error, 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } finally { + this.loading = false; + } + } + + private stopConversation(): void { + this.conversationManager.stopConversation(); + this.isConversationActive = false; + this.isRecording = false; // Konuşma bittiğinde recording'i kapat + this.stopVisualization(); + + this.snackBar.open('Konuşma sonlandırıldı', 'Close', { + duration: 2000 + }); + } + + private startVisualization(): void { + // Eğer zaten çalışıyorsa tekrar başlatma + if (!this.audioVisualizer || this.animationId) { + return; + } + + const canvas = this.audioVisualizer.nativeElement; + const ctx = canvas.getContext('2d'); + if (!ctx) { + console.warn('Could not get canvas context'); + return; + } + + // Set canvas size + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + + // Create gradient for bars + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + gradient.addColorStop(0, '#4caf50'); + gradient.addColorStop(0.5, '#66bb6a'); + gradient.addColorStop(1, '#4caf50'); + + let lastVolume = 0; + let targetVolume = 0; + const smoothingFactor = 0.8; + + // Subscribe to volume updates + this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => { + targetVolume = volume; + }); + + // Animation loop + const animate = () => { + // isConversationActive kontrolü ile devam et + if (!this.isConversationActive) { + this.clearVisualization(); + return; + } + + // Clear canvas + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Smooth volume transition + lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor); + + // Draw frequency bars + const barCount = 32; + const barWidth = canvas.width / barCount; + const barSpacing = 2; + + for (let i = 0; i < barCount; i++) { + // Create natural wave effect based on volume + const frequencyFactor = Math.sin((i / barCount) * Math.PI); + const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8; + const randomFactor = 0.8 + Math.random() * 0.2; + + const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor; + + const x = i * barWidth; + const y = (canvas.height - barHeight) / 2; + + // Draw bar + ctx.fillStyle = gradient; + ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight); + + // Draw reflection + ctx.fillStyle = 'rgba(76, 175, 80, 0.2)'; + ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3); + } + + // Draw center line + ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, canvas.height / 2); + ctx.lineTo(canvas.width, canvas.height / 2); + ctx.stroke(); + + this.animationId = requestAnimationFrame(animate); + }; + + animate(); + } + + private stopVisualization(): void { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + + if (this.volumeUpdateSubscription) { + this.volumeUpdateSubscription.unsubscribe(); + this.volumeUpdateSubscription = undefined; + } + + this.clearVisualization(); + } + + private clearVisualization(): void { + if (!this.audioVisualizer) return; + + const canvas = this.audioVisualizer.nativeElement; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillStyle = '#212121'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + } + + + private cleanupAudio(): void { + if (this.currentAudio) { + this.currentAudio.pause(); + this.currentAudio = null; + this.isPlayingAudio = false; + } + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/environment/environment.component.html b/flare-ui/src/app/components/environment/environment.component.html index 03ff3f538ef98c3f0ad82f9508d19a19f11e4e57..a2380f36033599a9364570d6ad677f25d699d0a0 100644 --- a/flare-ui/src/app/components/environment/environment.component.html +++ b/flare-ui/src/app/components/environment/environment.component.html @@ -1,286 +1,286 @@ - - - - settings - Environment Configuration - - - - - @if (loading) { -
- -

Loading configuration...

-
- } @else { -
- -
-

- smart_toy - LLM Provider -

- - - Provider - {{ getLLMProviderIcon(currentLLMProviderSafe) }} - - @for (provider of llmProviders; track provider.name) { - - {{ getLLMProviderIcon(provider) }} - {{ provider.display_name }} - - } - - @if (currentLLMProviderSafe?.description) { - {{ currentLLMProviderSafe?.description }} - } - - - @if (currentLLMProviderSafe?.requires_api_key) { - - {{ getApiKeyLabel('llm') }} - key - - - API key is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }} - - - } - - @if (currentLLMProviderSafe?.requires_endpoint) { - - Endpoint URL - link - - - - Endpoint is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }} - - - } - - - @if (currentLLMProviderSafe) { - - - - psychology - Internal System Prompt - - - Configure the internal prompt for intent detection - - - -
-

- This prompt is prepended to all intent detection requests. -

- - Internal Prompt - - Use clear instructions to guide the LLM's behavior - -
-
- - - - - tune - Parameter Collection Configuration - - - Fine-tune how parameters are collected from users - - - -
- - Enable Smart Parameter Collection - - -
- - - - - {{ parameterCollectionConfig.max_params_per_question }} -
- - - Show All Required Parameters - - - - Ask for Optional Parameters - - - - Group Related Parameters - - -
- - - - - {{ parameterCollectionConfig.min_confidence_score }} -
- - - Collection Prompt Template - - - -
-
- } -
- - - - -
-

- record_voice_over - TTS Provider -

- - - Provider - {{ getTTSProviderIcon(currentTTSProviderSafe) }} - - @for (provider of ttsProviders; track provider.name) { - - {{ getTTSProviderIcon(provider) }} - {{ provider.display_name }} - - } - - @if (currentTTSProviderSafe?.description) { - {{ currentTTSProviderSafe?.description }} - } - - - @if (currentTTSProviderSafe?.requires_api_key) { - - API Key - key - - - API key is required for {{ currentTTSProviderSafe?.display_name || 'this provider' }} - - - } - - @if (currentTTSProviderSafe?.requires_endpoint) { - - Endpoint URL - link - - - } -
- - - - -
-

- mic - STT Provider -

- - - Provider - {{ getSTTProviderIcon(currentSTTProviderSafe) }} - - @for (provider of sttProviders; track provider.name) { - - {{ getSTTProviderIcon(provider) }} - {{ provider.display_name }} - - } - - @if (currentSTTProviderSafe?.description) { - {{ currentSTTProviderSafe?.description }} - } - - - @if (currentSTTProviderSafe?.requires_api_key) { - - {{ getApiKeyLabel('stt') }} - key - - - {{ currentSTTProviderSafe?.name === 'google' ? 'Credentials path' : 'API key' }} is required for {{ currentSTTProviderSafe?.display_name || 'this provider' }} - - - } - - @if (currentSTTProviderSafe?.requires_endpoint) { - - Endpoint URL - link - - - } -
- - - - - -
- } -
+ + + + settings + Environment Configuration + + + + + @if (loading) { +
+ +

Loading configuration...

+
+ } @else { +
+ +
+

+ smart_toy + LLM Provider +

+ + + Provider + {{ getLLMProviderIcon(currentLLMProviderSafe) }} + + @for (provider of llmProviders; track provider.name) { + + {{ getLLMProviderIcon(provider) }} + {{ provider.display_name }} + + } + + @if (currentLLMProviderSafe?.description) { + {{ currentLLMProviderSafe?.description }} + } + + + @if (currentLLMProviderSafe?.requires_api_key) { + + {{ getApiKeyLabel('llm') }} + key + + + API key is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }} + + + } + + @if (currentLLMProviderSafe?.requires_endpoint) { + + Endpoint URL + link + + + + Endpoint is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }} + + + } + + + @if (currentLLMProviderSafe) { + + + + psychology + Internal System Prompt + + + Configure the internal prompt for intent detection + + + +
+

+ This prompt is prepended to all intent detection requests. +

+ + Internal Prompt + + Use clear instructions to guide the LLM's behavior + +
+
+ + + + + tune + Parameter Collection Configuration + + + Fine-tune how parameters are collected from users + + + +
+ + Enable Smart Parameter Collection + + +
+ + + + + {{ parameterCollectionConfig.max_params_per_question }} +
+ + + Show All Required Parameters + + + + Ask for Optional Parameters + + + + Group Related Parameters + + +
+ + + + + {{ parameterCollectionConfig.min_confidence_score }} +
+ + + Collection Prompt Template + + + +
+
+ } +
+ + + + +
+

+ record_voice_over + TTS Provider +

+ + + Provider + {{ getTTSProviderIcon(currentTTSProviderSafe) }} + + @for (provider of ttsProviders; track provider.name) { + + {{ getTTSProviderIcon(provider) }} + {{ provider.display_name }} + + } + + @if (currentTTSProviderSafe?.description) { + {{ currentTTSProviderSafe?.description }} + } + + + @if (currentTTSProviderSafe?.requires_api_key) { + + API Key + key + + + API key is required for {{ currentTTSProviderSafe?.display_name || 'this provider' }} + + + } + + @if (currentTTSProviderSafe?.requires_endpoint) { + + Endpoint URL + link + + + } +
+ + + + +
+

+ mic + STT Provider +

+ + + Provider + {{ getSTTProviderIcon(currentSTTProviderSafe) }} + + @for (provider of sttProviders; track provider.name) { + + {{ getSTTProviderIcon(provider) }} + {{ provider.display_name }} + + } + + @if (currentSTTProviderSafe?.description) { + {{ currentSTTProviderSafe?.description }} + } + + + @if (currentSTTProviderSafe?.requires_api_key) { + + {{ getApiKeyLabel('stt') }} + key + + + {{ currentSTTProviderSafe?.name === 'google' ? 'Credentials path' : 'API key' }} is required for {{ currentSTTProviderSafe?.display_name || 'this provider' }} + + + } + + @if (currentSTTProviderSafe?.requires_endpoint) { + + Endpoint URL + link + + + } +
+ + + + + +
+ } +
\ No newline at end of file diff --git a/flare-ui/src/app/components/environment/environment.component.scss b/flare-ui/src/app/components/environment/environment.component.scss index 297ed83ceeac413f0b5398ef6788e9b051fb6b3e..11aac82087eb14b09f57ddaa9927458b230de173 100644 --- a/flare-ui/src/app/components/environment/environment.component.scss +++ b/flare-ui/src/app/components/environment/environment.component.scss @@ -1,168 +1,168 @@ -:host { - display: block; - padding: 24px; - max-width: 1200px; - margin: 0 auto; -} - -mat-card { - mat-card-header { - margin-bottom: 24px; - - mat-card-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 24px; - - mat-icon { - font-size: 28px; - width: 28px; - height: 28px; - } - } - } -} - -.loading-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px; - gap: 16px; - - p { - color: rgba(0, 0, 0, 0.6); - margin: 0; - } -} - -.provider-section { - margin-bottom: 32px; - - h3 { - display: flex; - align-items: center; - gap: 8px; - color: rgba(0, 0, 0, 0.87); - margin-bottom: 16px; - font-size: 18px; - font-weight: 500; - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - } - } -} - -.full-width { - width: 100%; -} - -mat-form-field { - margin-bottom: 16px; - - &.full-width { - width: 100%; - } -} - -mat-divider { - margin: 32px 0; -} - -.settings-panel { - margin-top: 16px; - background: #f5f5f5; - - mat-expansion-panel-header { - mat-panel-title { - display: flex; - align-items: center; - gap: 8px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - } - } - - .panel-content { - padding: 16px; - - .hint-text { - color: rgba(0, 0, 0, 0.6); - font-size: 14px; - margin-bottom: 16px; - } - } -} - -mat-slide-toggle { - display: block; - margin-bottom: 16px; -} - -.config-item { - margin: 24px 0; - - label { - display: block; - color: rgba(0, 0, 0, 0.87); - font-weight: 500; - margin-bottom: 8px; - } - - mat-slider { - width: calc(100% - 60px); - display: inline-block; - } - - .slider-value { - display: inline-block; - width: 50px; - text-align: right; - color: rgba(0, 0, 0, 0.6); - font-weight: 500; - } -} - -mat-card-actions { - padding: 16px 24px; - margin: 0 -24px -24px; - border-top: 1px solid rgba(0, 0, 0, 0.12); - - button { - mat-icon { - margin-right: 4px; - } - } -} - -// Icon styling in select options -mat-option { - mat-icon { - margin-right: 8px; - vertical-align: middle; - } -} - -// Responsive adjustments -@media (max-width: 768px) { - :host { - padding: 16px; - } - - .provider-section { - margin-bottom: 24px; - } - - mat-divider { - margin: 24px 0; - } +:host { + display: block; + padding: 24px; + max-width: 1200px; + margin: 0 auto; +} + +mat-card { + mat-card-header { + margin-bottom: 24px; + + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 24px; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + } + } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + gap: 16px; + + p { + color: rgba(0, 0, 0, 0.6); + margin: 0; + } +} + +.provider-section { + margin-bottom: 32px; + + h3 { + display: flex; + align-items: center; + gap: 8px; + color: rgba(0, 0, 0, 0.87); + margin-bottom: 16px; + font-size: 18px; + font-weight: 500; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } + } +} + +.full-width { + width: 100%; +} + +mat-form-field { + margin-bottom: 16px; + + &.full-width { + width: 100%; + } +} + +mat-divider { + margin: 32px 0; +} + +.settings-panel { + margin-top: 16px; + background: #f5f5f5; + + mat-expansion-panel-header { + mat-panel-title { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + } + + .panel-content { + padding: 16px; + + .hint-text { + color: rgba(0, 0, 0, 0.6); + font-size: 14px; + margin-bottom: 16px; + } + } +} + +mat-slide-toggle { + display: block; + margin-bottom: 16px; +} + +.config-item { + margin: 24px 0; + + label { + display: block; + color: rgba(0, 0, 0, 0.87); + font-weight: 500; + margin-bottom: 8px; + } + + mat-slider { + width: calc(100% - 60px); + display: inline-block; + } + + .slider-value { + display: inline-block; + width: 50px; + text-align: right; + color: rgba(0, 0, 0, 0.6); + font-weight: 500; + } +} + +mat-card-actions { + padding: 16px 24px; + margin: 0 -24px -24px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + button { + mat-icon { + margin-right: 4px; + } + } +} + +// Icon styling in select options +mat-option { + mat-icon { + margin-right: 8px; + vertical-align: middle; + } +} + +// Responsive adjustments +@media (max-width: 768px) { + :host { + padding: 16px; + } + + .provider-section { + margin-bottom: 24px; + } + + mat-divider { + margin: 24px 0; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/environment/environment.component.ts b/flare-ui/src/app/components/environment/environment.component.ts index ce21b350664ca8cf06d56778f4b7979b8664a232..f4002c106d55b7e027ddb2423372eec0dd016479 100644 --- a/flare-ui/src/app/components/environment/environment.component.ts +++ b/flare-ui/src/app/components/environment/environment.component.ts @@ -1,715 +1,715 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; -import { FormsModule } from '@angular/forms'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { ApiService } from '../../services/api.service'; -import { EnvironmentService } from '../../services/environment.service'; -import { CommonModule } from '@angular/common'; -import { MatCardModule } from '@angular/material/card'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSliderModule } from '@angular/material/slider'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatDialogModule } from '@angular/material/dialog'; -import { Subject, takeUntil } from 'rxjs'; - -// Provider interfaces -interface ProviderConfig { - type: string; - name: string; - display_name: string; - requires_endpoint: boolean; - requires_api_key: boolean; - requires_repo_info: boolean; - description?: string; -} - -interface ProviderSettings { - name: string; - api_key?: string; - endpoint?: string; - settings: any; -} - -interface EnvironmentConfig { - llm_provider: ProviderSettings; - tts_provider: ProviderSettings; - stt_provider: ProviderSettings; - providers: ProviderConfig[]; -} - -@Component({ - selector: 'app-environment', - standalone: true, - imports: [ - CommonModule, - ReactiveFormsModule, - FormsModule, - MatCardModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatButtonModule, - MatIconModule, - MatSliderModule, - MatSlideToggleModule, - MatExpansionModule, - MatDividerModule, - MatProgressSpinnerModule, - MatSnackBarModule, - MatTooltipModule, - MatDialogModule - ], - templateUrl: './environment.component.html', - styleUrls: ['./environment.component.scss'] -}) -export class EnvironmentComponent implements OnInit, OnDestroy { - form: FormGroup; - loading = false; - saving = false; - isLoading = false; - - // Provider lists - llmProviders: ProviderConfig[] = []; - ttsProviders: ProviderConfig[] = []; - sttProviders: ProviderConfig[] = []; - - // Current provider configurations - currentLLMProvider?: ProviderConfig; - currentTTSProvider?: ProviderConfig; - currentSTTProvider?: ProviderConfig; - - // Settings for LLM - internalPrompt: string = ''; - parameterCollectionConfig: any = { - enabled: false, - max_params_per_question: 1, - show_all_required: false, - ask_optional_params: false, - group_related_params: false, - min_confidence_score: 0.7, - collection_prompt: 'Please provide the following information:' - }; - - hideSTTKey = true; - sttLanguages = [ - { code: 'tr-TR', name: 'Türkçe' }, - { code: 'en-US', name: 'English (US)' }, - { code: 'en-GB', name: 'English (UK)' }, - { code: 'de-DE', name: 'Deutsch' }, - { code: 'fr-FR', name: 'Français' }, - { code: 'es-ES', name: 'Español' }, - { code: 'it-IT', name: 'Italiano' }, - { code: 'pt-BR', name: 'Português (BR)' }, - { code: 'ja-JP', name: '日本語' }, - { code: 'ko-KR', name: '한국어' }, - { code: 'zh-CN', name: '中文' } - ]; - - sttModels = [ - { value: 'default', name: 'Default' }, - { value: 'latest_short', name: 'Latest Short (Optimized for short audio)' }, - { value: 'latest_long', name: 'Latest Long (Best accuracy)' }, - { value: 'command_and_search', name: 'Command and Search' }, - { value: 'phone_call', name: 'Phone Call (Optimized for telephony)' } - ]; - - // API key visibility tracking - showApiKeys: { [key: string]: boolean } = {}; - - // Memory leak prevention - private destroyed$ = new Subject(); - - constructor( - private fb: FormBuilder, - private apiService: ApiService, - private environmentService: EnvironmentService, - private snackBar: MatSnackBar - ) { - this.form = this.fb.group({ - // LLM Provider - llm_provider_name: ['', Validators.required], - llm_provider_api_key: [''], - llm_provider_endpoint: [''], - - // TTS Provider - tts_provider_name: ['no_tts', Validators.required], - tts_provider_api_key: [''], - tts_provider_endpoint: [''], - - // STT Provider - stt_provider_name: ['no_stt', Validators.required], - stt_provider_api_key: [''], - stt_provider_endpoint: [''], - - // STT Settings - stt_settings: this.fb.group({ - language: ['tr-TR'], - speech_timeout_ms: [2000], - enable_punctuation: [true], - interim_results: [true], - use_enhanced: [true], - model: ['latest_long'], - noise_reduction_level: [2], - vad_sensitivity: [0.5] - }) - }); - } - - ngOnInit() { - this.loadEnvironment(); - } - - ngOnDestroy() { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - // Safe getters for template - get currentLLMProviderSafe(): ProviderConfig | null { - return this.currentLLMProvider || null; - } - - get currentTTSProviderSafe(): ProviderConfig | null { - return this.currentTTSProvider || null; - } - - get currentSTTProviderSafe(): ProviderConfig | null { - return this.currentSTTProvider || null; - } - - // API key masking methods - maskApiKey(key?: string): string { - if (!key) return ''; - if (key.length <= 8) return '••••••••'; - return key.substring(0, 4) + '••••' + key.substring(key.length - 4); - } - - toggleApiKeyVisibility(fieldName: string): void { - this.showApiKeys[fieldName] = !this.showApiKeys[fieldName]; - } - - getApiKeyInputType(fieldName: string): string { - return this.showApiKeys[fieldName] ? 'text' : 'password'; - } - - formatApiKeyForDisplay(fieldName: string, value?: string): string { - if (this.showApiKeys[fieldName]) { - return value || ''; - } - return this.maskApiKey(value); - } - - loadEnvironment(): void { - this.loading = true; - this.isLoading = true; - - this.apiService.getEnvironment() - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (data: any) => { - // Check if it's new format or legacy - if (data.llm_provider) { - this.handleNewFormat(data); - } else { - this.handleLegacyFormat(data); - } - this.loading = false; - this.isLoading = false; - }, - error: (err) => { - console.error('Failed to load environment:', err); - this.snackBar.open('Failed to load environment configuration', 'Close', { - duration: 3000, - panelClass: ['error-snackbar'] - }); - this.loading = false; - this.isLoading = false; - } - }); - } - - handleNewFormat(data: EnvironmentConfig): void { - // Update provider lists - if (data.providers) { - this.llmProviders = data.providers.filter(p => p.type === 'llm'); - this.ttsProviders = data.providers.filter(p => p.type === 'tts'); - this.sttProviders = data.providers.filter(p => p.type === 'stt'); - } - - // Set form values - this.form.patchValue({ - llm_provider_name: data.llm_provider?.name || '', - llm_provider_api_key: data.llm_provider?.api_key || '', - llm_provider_endpoint: data.llm_provider?.endpoint || '', - tts_provider_name: data.tts_provider?.name || 'no_tts', - tts_provider_api_key: data.tts_provider?.api_key || '', - tts_provider_endpoint: data.tts_provider?.endpoint || '', - stt_provider_name: data.stt_provider?.name || 'no_stt', - stt_provider_api_key: data.stt_provider?.api_key || '', - stt_provider_endpoint: data.stt_provider?.endpoint || '' - }); - - // Set internal prompt and parameter collection config - this.internalPrompt = data.llm_provider?.settings?.internal_prompt || ''; - this.parameterCollectionConfig = data.llm_provider?.settings?.parameter_collection_config || this.parameterCollectionConfig; - - // Update current providers - this.updateCurrentProviders(); - - // Notify environment service - if (data.tts_provider?.name !== 'no_tts') { - this.environmentService.setTTSEnabled(true); - } - if (data.stt_provider?.name !== 'no_stt') { - this.environmentService.setSTTEnabled(true); - } - - if (data.stt_provider?.settings) { - this.form.get('stt_settings')?.patchValue(data.stt_provider.settings); - } - } - - handleLegacyFormat(data: any): void { - console.warn('Legacy environment format detected, using defaults'); - - // Set default providers if not present - this.llmProviders = this.getDefaultProviders('llm'); - this.ttsProviders = this.getDefaultProviders('tts'); - this.sttProviders = this.getDefaultProviders('stt'); - - // Map legacy fields - this.form.patchValue({ - llm_provider_name: data.work_mode || 'spark', - llm_provider_api_key: data.cloud_token || '', - llm_provider_endpoint: data.spark_endpoint || '', - tts_provider_name: data.tts_engine || 'no_tts', - tts_provider_api_key: data.tts_engine_api_key || '', - stt_provider_name: data.stt_engine || 'no_stt', - stt_provider_api_key: data.stt_engine_api_key || '' - }); - - this.internalPrompt = data.internal_prompt || ''; - this.parameterCollectionConfig = data.parameter_collection_config || this.parameterCollectionConfig; - - this.updateCurrentProviders(); - - if (data.stt_settings) { - this.form.get('stt_settings')?.patchValue(data.stt_settings); - } - } - - getDefaultProviders(type: string): ProviderConfig[] { - const defaults: { [key: string]: ProviderConfig[] } = { - llm: [ - { - type: 'llm', - name: 'spark', - display_name: 'Spark (YTU Cosmos)', - requires_endpoint: true, - requires_api_key: true, - requires_repo_info: true, - description: 'YTU Cosmos Spark LLM Service' - }, - { - type: 'llm', - name: 'gpt-4o', - display_name: 'GPT-4o', - requires_endpoint: false, - requires_api_key: true, - requires_repo_info: false, - description: 'OpenAI GPT-4o model' - }, - { - type: 'llm', - name: 'gpt-4o-mini', - display_name: 'GPT-4o Mini', - requires_endpoint: false, - requires_api_key: true, - requires_repo_info: false, - description: 'OpenAI GPT-4o Mini model' - } - ], - tts: [ - { - type: 'tts', - name: 'no_tts', - display_name: 'No TTS', - requires_endpoint: false, - requires_api_key: false, - requires_repo_info: false, - description: 'Disable text-to-speech' - }, - { - type: 'tts', - name: 'elevenlabs', - display_name: 'ElevenLabs', - requires_endpoint: false, - requires_api_key: true, - requires_repo_info: false, - description: 'ElevenLabs TTS service' - } - ], - stt: [ - { - type: 'stt', - name: 'no_stt', - display_name: 'No STT', - requires_endpoint: false, - requires_api_key: false, - requires_repo_info: false, - description: 'Disable speech-to-text' - }, - { - type: 'stt', - name: 'google', - display_name: 'Google Cloud Speech', - requires_endpoint: false, - requires_api_key: true, - requires_repo_info: false, - description: 'Google Cloud Speech-to-Text API' - }, - { - type: 'stt', - name: 'azure', - display_name: 'Azure Speech Services', - requires_endpoint: false, - requires_api_key: true, - requires_repo_info: false, - description: 'Azure Cognitive Services Speech' - }, - { - type: 'stt', - name: 'flicker', - display_name: 'Flicker STT', - requires_endpoint: true, - requires_api_key: true, - requires_repo_info: false, - description: 'Flicker Speech Recognition Service' - } - ] - }; - - return defaults[type] || []; - } - - updateCurrentProviders(): void { - const llmName = this.form.get('llm_provider_name')?.value; - const ttsName = this.form.get('tts_provider_name')?.value; - const sttName = this.form.get('stt_provider_name')?.value; - - this.currentLLMProvider = this.llmProviders.find(p => p.name === llmName); - this.currentTTSProvider = this.ttsProviders.find(p => p.name === ttsName); - this.currentSTTProvider = this.sttProviders.find(p => p.name === sttName); - - // Update form validators based on requirements - this.updateFormValidators(); - } - - updateFormValidators(): void { - // LLM validators - if (this.currentLLMProvider?.requires_api_key) { - this.form.get('llm_provider_api_key')?.setValidators(Validators.required); - } else { - this.form.get('llm_provider_api_key')?.clearValidators(); - } - - if (this.currentLLMProvider?.requires_endpoint) { - this.form.get('llm_provider_endpoint')?.setValidators(Validators.required); - } else { - this.form.get('llm_provider_endpoint')?.clearValidators(); - } - - // TTS validators - if (this.currentTTSProvider?.requires_api_key) { - this.form.get('tts_provider_api_key')?.setValidators(Validators.required); - } else { - this.form.get('tts_provider_api_key')?.clearValidators(); - } - - // STT validators - if (this.currentSTTProvider?.requires_api_key) { - this.form.get('stt_provider_api_key')?.setValidators(Validators.required); - } else { - this.form.get('stt_provider_api_key')?.clearValidators(); - } - - // STT endpoint validator - if (this.currentSTTProvider?.requires_endpoint) { - this.form.get('stt_provider_endpoint')?.setValidators(Validators.required); - } else { - this.form.get('stt_provider_endpoint')?.clearValidators(); - } - - // Update validity - this.form.get('llm_provider_api_key')?.updateValueAndValidity(); - this.form.get('llm_provider_endpoint')?.updateValueAndValidity(); - this.form.get('tts_provider_api_key')?.updateValueAndValidity(); - this.form.get('stt_provider_api_key')?.updateValueAndValidity(); - this.form.get('stt_provider_endpoint')?.updateValueAndValidity(); - } - - onLLMProviderChange(value: string): void { - this.currentLLMProvider = this.llmProviders.find(p => p.name === value); - this.updateFormValidators(); - - // Reset fields if provider doesn't require them - if (!this.currentLLMProvider?.requires_api_key) { - this.form.get('llm_provider_api_key')?.setValue(''); - } - if (!this.currentLLMProvider?.requires_endpoint) { - this.form.get('llm_provider_endpoint')?.setValue(''); - } - } - - onTTSProviderChange(value: string): void { - this.currentTTSProvider = this.ttsProviders.find(p => p.name === value); - this.updateFormValidators(); - - if (!this.currentTTSProvider?.requires_api_key) { - this.form.get('tts_provider_api_key')?.setValue(''); - } - - if (value !== this.form.get('stt_provider_name')?.value) { - this.form.get('stt_provider_api_key')?.setValue(''); - } - - // Provider-specific defaults - if (value === 'google') { - this.form.get('stt_settings')?.patchValue({ - model: 'latest_long', - use_enhanced: true - }); - } else if (value === 'azure') { - this.form.get('stt_settings')?.patchValue({ - model: 'default', - use_enhanced: false - }); - } - - // STT endpoint validator - if (this.currentSTTProvider?.requires_endpoint) { - this.form.get('stt_provider_endpoint')?.setValidators(Validators.required); - } else { - this.form.get('stt_provider_endpoint')?.clearValidators(); - } - - this.form.get('stt_provider_endpoint')?.updateValueAndValidity(); - - // Notify environment service - this.environmentService.setTTSEnabled(value !== 'no_tts'); - } - - onSTTProviderChange(value: string): void { - this.currentSTTProvider = this.sttProviders.find(p => p.name === value); - this.updateFormValidators(); - - if (!this.currentSTTProvider?.requires_api_key) { - this.form.get('stt_provider_api_key')?.setValue(''); - } - - // Notify environment service - this.environmentService.setSTTEnabled(value !== 'no_stt'); - } - - saveEnvironment(): void { - if (this.form.invalid || this.saving) { - this.snackBar.open('Please fix validation errors', 'Close', { - duration: 3000, - panelClass: ['error-snackbar'] - }); - return; - } - - this.saving = true; - const formValue = this.form.value; - - const saveData = { - llm_provider: { - name: formValue.llm_provider_name, - api_key: formValue.llm_provider_api_key, - endpoint: formValue.llm_provider_endpoint, - settings: { - internal_prompt: this.internalPrompt, - parameter_collection_config: this.parameterCollectionConfig - } - }, - tts_provider: { - name: formValue.tts_provider_name, - api_key: formValue.tts_provider_api_key, - endpoint: formValue.tts_provider_endpoint, - settings: {} - }, - stt_provider: { - name: formValue.stt_provider_name, - api_key: formValue.stt_provider_api_key, - endpoint: formValue.stt_provider_endpoint, - settings: formValue.stt_settings || {} - } - }; - - this.apiService.updateEnvironment(saveData as any) - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: () => { - this.saving = false; - this.snackBar.open('Environment configuration saved successfully', 'Close', { - duration: 3000, - panelClass: ['success-snackbar'] - }); - - // Update environment service - this.environmentService.updateEnvironment(saveData as any); - - // Clear form dirty state - this.form.markAsPristine(); - }, - error: (error) => { - this.saving = false; - - // Race condition handling - if (error.status === 409) { - const details = error.error?.details || {}; - this.snackBar.open( - `Settings were modified by ${details.last_update_user || 'another user'}. Please reload.`, - 'Reload', - { duration: 0 } - ).onAction().subscribe(() => { - this.loadEnvironment(); - }); - } else { - this.snackBar.open( - error.error?.detail || 'Failed to save environment configuration', - 'Close', - { - duration: 5000, - panelClass: ['error-snackbar'] - } - ); - } - } - }); - } - - // Icon helpers - getLLMProviderIcon(provider: ProviderConfig | null): string { - if (!provider || !provider.name) return 'smart_toy'; - - switch(provider.name) { - case 'gpt-4o': - case 'gpt-4o-mini': - return 'psychology'; - case 'spark': - return 'auto_awesome'; - default: - return 'smart_toy'; - } - } - - getTTSProviderIcon(provider: ProviderConfig | null): string { - if (!provider || !provider.name) return 'record_voice_over'; - - switch(provider.name) { - case 'elevenlabs': - return 'graphic_eq'; - case 'blaze': - return 'volume_up'; - default: - return 'record_voice_over'; - } - } - - getSTTProviderIcon(provider: ProviderConfig | null): string { - if (!provider || !provider.name) return 'mic'; - - switch(provider.name) { - case 'google': - return 'g_translate'; - case 'azure': - return 'cloud'; - case 'flicker': - return 'mic_none'; - default: - return 'mic'; - } - } - - getProviderIcon(provider: ProviderConfig): string { - switch(provider.type) { - case 'llm': - return this.getLLMProviderIcon(provider); - case 'tts': - return this.getTTSProviderIcon(provider); - case 'stt': - return this.getSTTProviderIcon(provider); - default: - return 'settings'; - } - } - - // Helper methods - getApiKeyLabel(type: string): string { - switch(type) { - case 'llm': - return this.currentLLMProvider?.name === 'spark' ? 'API Token' : 'API Key'; - case 'tts': - return 'API Key'; - case 'stt': - return this.currentSTTProvider?.name === 'google' ? 'Credentials JSON Path' : 'API Key'; - default: - return 'API Key'; - } - } - - getApiKeyPlaceholder(type: string): string { - switch(type) { - case 'llm': - if (this.currentLLMProvider?.name === 'spark') return 'Enter Spark token'; - if (this.currentLLMProvider?.name?.includes('gpt')) return 'sk-...'; - return 'Enter API key'; - case 'tts': - return 'Enter TTS API key'; - case 'stt': - if (this.currentSTTProvider?.name === 'google') return '/path/to/credentials.json'; - if (this.currentSTTProvider?.name === 'azure') return 'subscription_key|region'; - return 'Enter STT API key'; - default: - return 'Enter API key'; - } - } - - getEndpointPlaceholder(type: string): string { - switch(type) { - case 'llm': - return 'https://spark-api.example.com'; - case 'tts': - return 'https://tts-api.example.com'; - case 'stt': - return 'https://stt-api.example.com'; - default: - return 'https://api.example.com'; - } - } - - resetCollectionPrompt(): void { - this.parameterCollectionConfig.collection_prompt = 'Please provide the following information:'; - } - - testConnection(): void { - const endpoint = this.form.get('llm_provider_endpoint')?.value; - if (!endpoint) { - this.snackBar.open('Please enter an endpoint URL', 'Close', { duration: 2000 }); - return; - } - - this.snackBar.open('Testing connection...', 'Close', { duration: 2000 }); - // TODO: Implement actual connection test - } +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { ApiService } from '../../services/api.service'; +import { EnvironmentService } from '../../services/environment.service'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSliderModule } from '@angular/material/slider'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDialogModule } from '@angular/material/dialog'; +import { Subject, takeUntil } from 'rxjs'; + +// Provider interfaces +interface ProviderConfig { + type: string; + name: string; + display_name: string; + requires_endpoint: boolean; + requires_api_key: boolean; + requires_repo_info: boolean; + description?: string; +} + +interface ProviderSettings { + name: string; + api_key?: string; + endpoint?: string; + settings: any; +} + +interface EnvironmentConfig { + llm_provider: ProviderSettings; + tts_provider: ProviderSettings; + stt_provider: ProviderSettings; + providers: ProviderConfig[]; +} + +@Component({ + selector: 'app-environment', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + FormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatSliderModule, + MatSlideToggleModule, + MatExpansionModule, + MatDividerModule, + MatProgressSpinnerModule, + MatSnackBarModule, + MatTooltipModule, + MatDialogModule + ], + templateUrl: './environment.component.html', + styleUrls: ['./environment.component.scss'] +}) +export class EnvironmentComponent implements OnInit, OnDestroy { + form: FormGroup; + loading = false; + saving = false; + isLoading = false; + + // Provider lists + llmProviders: ProviderConfig[] = []; + ttsProviders: ProviderConfig[] = []; + sttProviders: ProviderConfig[] = []; + + // Current provider configurations + currentLLMProvider?: ProviderConfig; + currentTTSProvider?: ProviderConfig; + currentSTTProvider?: ProviderConfig; + + // Settings for LLM + internalPrompt: string = ''; + parameterCollectionConfig: any = { + enabled: false, + max_params_per_question: 1, + show_all_required: false, + ask_optional_params: false, + group_related_params: false, + min_confidence_score: 0.7, + collection_prompt: 'Please provide the following information:' + }; + + hideSTTKey = true; + sttLanguages = [ + { code: 'tr-TR', name: 'Türkçe' }, + { code: 'en-US', name: 'English (US)' }, + { code: 'en-GB', name: 'English (UK)' }, + { code: 'de-DE', name: 'Deutsch' }, + { code: 'fr-FR', name: 'Français' }, + { code: 'es-ES', name: 'Español' }, + { code: 'it-IT', name: 'Italiano' }, + { code: 'pt-BR', name: 'Português (BR)' }, + { code: 'ja-JP', name: '日本語' }, + { code: 'ko-KR', name: '한국어' }, + { code: 'zh-CN', name: '中文' } + ]; + + sttModels = [ + { value: 'default', name: 'Default' }, + { value: 'latest_short', name: 'Latest Short (Optimized for short audio)' }, + { value: 'latest_long', name: 'Latest Long (Best accuracy)' }, + { value: 'command_and_search', name: 'Command and Search' }, + { value: 'phone_call', name: 'Phone Call (Optimized for telephony)' } + ]; + + // API key visibility tracking + showApiKeys: { [key: string]: boolean } = {}; + + // Memory leak prevention + private destroyed$ = new Subject(); + + constructor( + private fb: FormBuilder, + private apiService: ApiService, + private environmentService: EnvironmentService, + private snackBar: MatSnackBar + ) { + this.form = this.fb.group({ + // LLM Provider + llm_provider_name: ['', Validators.required], + llm_provider_api_key: [''], + llm_provider_endpoint: [''], + + // TTS Provider + tts_provider_name: ['no_tts', Validators.required], + tts_provider_api_key: [''], + tts_provider_endpoint: [''], + + // STT Provider + stt_provider_name: ['no_stt', Validators.required], + stt_provider_api_key: [''], + stt_provider_endpoint: [''], + + // STT Settings + stt_settings: this.fb.group({ + language: ['tr-TR'], + speech_timeout_ms: [2000], + enable_punctuation: [true], + interim_results: [true], + use_enhanced: [true], + model: ['latest_long'], + noise_reduction_level: [2], + vad_sensitivity: [0.5] + }) + }); + } + + ngOnInit() { + this.loadEnvironment(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + // Safe getters for template + get currentLLMProviderSafe(): ProviderConfig | null { + return this.currentLLMProvider || null; + } + + get currentTTSProviderSafe(): ProviderConfig | null { + return this.currentTTSProvider || null; + } + + get currentSTTProviderSafe(): ProviderConfig | null { + return this.currentSTTProvider || null; + } + + // API key masking methods + maskApiKey(key?: string): string { + if (!key) return ''; + if (key.length <= 8) return '••••••••'; + return key.substring(0, 4) + '••••' + key.substring(key.length - 4); + } + + toggleApiKeyVisibility(fieldName: string): void { + this.showApiKeys[fieldName] = !this.showApiKeys[fieldName]; + } + + getApiKeyInputType(fieldName: string): string { + return this.showApiKeys[fieldName] ? 'text' : 'password'; + } + + formatApiKeyForDisplay(fieldName: string, value?: string): string { + if (this.showApiKeys[fieldName]) { + return value || ''; + } + return this.maskApiKey(value); + } + + loadEnvironment(): void { + this.loading = true; + this.isLoading = true; + + this.apiService.getEnvironment() + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (data: any) => { + // Check if it's new format or legacy + if (data.llm_provider) { + this.handleNewFormat(data); + } else { + this.handleLegacyFormat(data); + } + this.loading = false; + this.isLoading = false; + }, + error: (err) => { + console.error('Failed to load environment:', err); + this.snackBar.open('Failed to load environment configuration', 'Close', { + duration: 3000, + panelClass: ['error-snackbar'] + }); + this.loading = false; + this.isLoading = false; + } + }); + } + + handleNewFormat(data: EnvironmentConfig): void { + // Update provider lists + if (data.providers) { + this.llmProviders = data.providers.filter(p => p.type === 'llm'); + this.ttsProviders = data.providers.filter(p => p.type === 'tts'); + this.sttProviders = data.providers.filter(p => p.type === 'stt'); + } + + // Set form values + this.form.patchValue({ + llm_provider_name: data.llm_provider?.name || '', + llm_provider_api_key: data.llm_provider?.api_key || '', + llm_provider_endpoint: data.llm_provider?.endpoint || '', + tts_provider_name: data.tts_provider?.name || 'no_tts', + tts_provider_api_key: data.tts_provider?.api_key || '', + tts_provider_endpoint: data.tts_provider?.endpoint || '', + stt_provider_name: data.stt_provider?.name || 'no_stt', + stt_provider_api_key: data.stt_provider?.api_key || '', + stt_provider_endpoint: data.stt_provider?.endpoint || '' + }); + + // Set internal prompt and parameter collection config + this.internalPrompt = data.llm_provider?.settings?.internal_prompt || ''; + this.parameterCollectionConfig = data.llm_provider?.settings?.parameter_collection_config || this.parameterCollectionConfig; + + // Update current providers + this.updateCurrentProviders(); + + // Notify environment service + if (data.tts_provider?.name !== 'no_tts') { + this.environmentService.setTTSEnabled(true); + } + if (data.stt_provider?.name !== 'no_stt') { + this.environmentService.setSTTEnabled(true); + } + + if (data.stt_provider?.settings) { + this.form.get('stt_settings')?.patchValue(data.stt_provider.settings); + } + } + + handleLegacyFormat(data: any): void { + console.warn('Legacy environment format detected, using defaults'); + + // Set default providers if not present + this.llmProviders = this.getDefaultProviders('llm'); + this.ttsProviders = this.getDefaultProviders('tts'); + this.sttProviders = this.getDefaultProviders('stt'); + + // Map legacy fields + this.form.patchValue({ + llm_provider_name: data.work_mode || 'spark', + llm_provider_api_key: data.cloud_token || '', + llm_provider_endpoint: data.spark_endpoint || '', + tts_provider_name: data.tts_engine || 'no_tts', + tts_provider_api_key: data.tts_engine_api_key || '', + stt_provider_name: data.stt_engine || 'no_stt', + stt_provider_api_key: data.stt_engine_api_key || '' + }); + + this.internalPrompt = data.internal_prompt || ''; + this.parameterCollectionConfig = data.parameter_collection_config || this.parameterCollectionConfig; + + this.updateCurrentProviders(); + + if (data.stt_settings) { + this.form.get('stt_settings')?.patchValue(data.stt_settings); + } + } + + getDefaultProviders(type: string): ProviderConfig[] { + const defaults: { [key: string]: ProviderConfig[] } = { + llm: [ + { + type: 'llm', + name: 'spark', + display_name: 'Spark (YTU Cosmos)', + requires_endpoint: true, + requires_api_key: true, + requires_repo_info: true, + description: 'YTU Cosmos Spark LLM Service' + }, + { + type: 'llm', + name: 'gpt-4o', + display_name: 'GPT-4o', + requires_endpoint: false, + requires_api_key: true, + requires_repo_info: false, + description: 'OpenAI GPT-4o model' + }, + { + type: 'llm', + name: 'gpt-4o-mini', + display_name: 'GPT-4o Mini', + requires_endpoint: false, + requires_api_key: true, + requires_repo_info: false, + description: 'OpenAI GPT-4o Mini model' + } + ], + tts: [ + { + type: 'tts', + name: 'no_tts', + display_name: 'No TTS', + requires_endpoint: false, + requires_api_key: false, + requires_repo_info: false, + description: 'Disable text-to-speech' + }, + { + type: 'tts', + name: 'elevenlabs', + display_name: 'ElevenLabs', + requires_endpoint: false, + requires_api_key: true, + requires_repo_info: false, + description: 'ElevenLabs TTS service' + } + ], + stt: [ + { + type: 'stt', + name: 'no_stt', + display_name: 'No STT', + requires_endpoint: false, + requires_api_key: false, + requires_repo_info: false, + description: 'Disable speech-to-text' + }, + { + type: 'stt', + name: 'google', + display_name: 'Google Cloud Speech', + requires_endpoint: false, + requires_api_key: true, + requires_repo_info: false, + description: 'Google Cloud Speech-to-Text API' + }, + { + type: 'stt', + name: 'azure', + display_name: 'Azure Speech Services', + requires_endpoint: false, + requires_api_key: true, + requires_repo_info: false, + description: 'Azure Cognitive Services Speech' + }, + { + type: 'stt', + name: 'flicker', + display_name: 'Flicker STT', + requires_endpoint: true, + requires_api_key: true, + requires_repo_info: false, + description: 'Flicker Speech Recognition Service' + } + ] + }; + + return defaults[type] || []; + } + + updateCurrentProviders(): void { + const llmName = this.form.get('llm_provider_name')?.value; + const ttsName = this.form.get('tts_provider_name')?.value; + const sttName = this.form.get('stt_provider_name')?.value; + + this.currentLLMProvider = this.llmProviders.find(p => p.name === llmName); + this.currentTTSProvider = this.ttsProviders.find(p => p.name === ttsName); + this.currentSTTProvider = this.sttProviders.find(p => p.name === sttName); + + // Update form validators based on requirements + this.updateFormValidators(); + } + + updateFormValidators(): void { + // LLM validators + if (this.currentLLMProvider?.requires_api_key) { + this.form.get('llm_provider_api_key')?.setValidators(Validators.required); + } else { + this.form.get('llm_provider_api_key')?.clearValidators(); + } + + if (this.currentLLMProvider?.requires_endpoint) { + this.form.get('llm_provider_endpoint')?.setValidators(Validators.required); + } else { + this.form.get('llm_provider_endpoint')?.clearValidators(); + } + + // TTS validators + if (this.currentTTSProvider?.requires_api_key) { + this.form.get('tts_provider_api_key')?.setValidators(Validators.required); + } else { + this.form.get('tts_provider_api_key')?.clearValidators(); + } + + // STT validators + if (this.currentSTTProvider?.requires_api_key) { + this.form.get('stt_provider_api_key')?.setValidators(Validators.required); + } else { + this.form.get('stt_provider_api_key')?.clearValidators(); + } + + // STT endpoint validator + if (this.currentSTTProvider?.requires_endpoint) { + this.form.get('stt_provider_endpoint')?.setValidators(Validators.required); + } else { + this.form.get('stt_provider_endpoint')?.clearValidators(); + } + + // Update validity + this.form.get('llm_provider_api_key')?.updateValueAndValidity(); + this.form.get('llm_provider_endpoint')?.updateValueAndValidity(); + this.form.get('tts_provider_api_key')?.updateValueAndValidity(); + this.form.get('stt_provider_api_key')?.updateValueAndValidity(); + this.form.get('stt_provider_endpoint')?.updateValueAndValidity(); + } + + onLLMProviderChange(value: string): void { + this.currentLLMProvider = this.llmProviders.find(p => p.name === value); + this.updateFormValidators(); + + // Reset fields if provider doesn't require them + if (!this.currentLLMProvider?.requires_api_key) { + this.form.get('llm_provider_api_key')?.setValue(''); + } + if (!this.currentLLMProvider?.requires_endpoint) { + this.form.get('llm_provider_endpoint')?.setValue(''); + } + } + + onTTSProviderChange(value: string): void { + this.currentTTSProvider = this.ttsProviders.find(p => p.name === value); + this.updateFormValidators(); + + if (!this.currentTTSProvider?.requires_api_key) { + this.form.get('tts_provider_api_key')?.setValue(''); + } + + if (value !== this.form.get('stt_provider_name')?.value) { + this.form.get('stt_provider_api_key')?.setValue(''); + } + + // Provider-specific defaults + if (value === 'google') { + this.form.get('stt_settings')?.patchValue({ + model: 'latest_long', + use_enhanced: true + }); + } else if (value === 'azure') { + this.form.get('stt_settings')?.patchValue({ + model: 'default', + use_enhanced: false + }); + } + + // STT endpoint validator + if (this.currentSTTProvider?.requires_endpoint) { + this.form.get('stt_provider_endpoint')?.setValidators(Validators.required); + } else { + this.form.get('stt_provider_endpoint')?.clearValidators(); + } + + this.form.get('stt_provider_endpoint')?.updateValueAndValidity(); + + // Notify environment service + this.environmentService.setTTSEnabled(value !== 'no_tts'); + } + + onSTTProviderChange(value: string): void { + this.currentSTTProvider = this.sttProviders.find(p => p.name === value); + this.updateFormValidators(); + + if (!this.currentSTTProvider?.requires_api_key) { + this.form.get('stt_provider_api_key')?.setValue(''); + } + + // Notify environment service + this.environmentService.setSTTEnabled(value !== 'no_stt'); + } + + saveEnvironment(): void { + if (this.form.invalid || this.saving) { + this.snackBar.open('Please fix validation errors', 'Close', { + duration: 3000, + panelClass: ['error-snackbar'] + }); + return; + } + + this.saving = true; + const formValue = this.form.value; + + const saveData = { + llm_provider: { + name: formValue.llm_provider_name, + api_key: formValue.llm_provider_api_key, + endpoint: formValue.llm_provider_endpoint, + settings: { + internal_prompt: this.internalPrompt, + parameter_collection_config: this.parameterCollectionConfig + } + }, + tts_provider: { + name: formValue.tts_provider_name, + api_key: formValue.tts_provider_api_key, + endpoint: formValue.tts_provider_endpoint, + settings: {} + }, + stt_provider: { + name: formValue.stt_provider_name, + api_key: formValue.stt_provider_api_key, + endpoint: formValue.stt_provider_endpoint, + settings: formValue.stt_settings || {} + } + }; + + this.apiService.updateEnvironment(saveData as any) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: () => { + this.saving = false; + this.snackBar.open('Environment configuration saved successfully', 'Close', { + duration: 3000, + panelClass: ['success-snackbar'] + }); + + // Update environment service + this.environmentService.updateEnvironment(saveData as any); + + // Clear form dirty state + this.form.markAsPristine(); + }, + error: (error) => { + this.saving = false; + + // Race condition handling + if (error.status === 409) { + const details = error.error?.details || {}; + this.snackBar.open( + `Settings were modified by ${details.last_update_user || 'another user'}. Please reload.`, + 'Reload', + { duration: 0 } + ).onAction().subscribe(() => { + this.loadEnvironment(); + }); + } else { + this.snackBar.open( + error.error?.detail || 'Failed to save environment configuration', + 'Close', + { + duration: 5000, + panelClass: ['error-snackbar'] + } + ); + } + } + }); + } + + // Icon helpers + getLLMProviderIcon(provider: ProviderConfig | null): string { + if (!provider || !provider.name) return 'smart_toy'; + + switch(provider.name) { + case 'gpt-4o': + case 'gpt-4o-mini': + return 'psychology'; + case 'spark': + return 'auto_awesome'; + default: + return 'smart_toy'; + } + } + + getTTSProviderIcon(provider: ProviderConfig | null): string { + if (!provider || !provider.name) return 'record_voice_over'; + + switch(provider.name) { + case 'elevenlabs': + return 'graphic_eq'; + case 'blaze': + return 'volume_up'; + default: + return 'record_voice_over'; + } + } + + getSTTProviderIcon(provider: ProviderConfig | null): string { + if (!provider || !provider.name) return 'mic'; + + switch(provider.name) { + case 'google': + return 'g_translate'; + case 'azure': + return 'cloud'; + case 'flicker': + return 'mic_none'; + default: + return 'mic'; + } + } + + getProviderIcon(provider: ProviderConfig): string { + switch(provider.type) { + case 'llm': + return this.getLLMProviderIcon(provider); + case 'tts': + return this.getTTSProviderIcon(provider); + case 'stt': + return this.getSTTProviderIcon(provider); + default: + return 'settings'; + } + } + + // Helper methods + getApiKeyLabel(type: string): string { + switch(type) { + case 'llm': + return this.currentLLMProvider?.name === 'spark' ? 'API Token' : 'API Key'; + case 'tts': + return 'API Key'; + case 'stt': + return this.currentSTTProvider?.name === 'google' ? 'Credentials JSON Path' : 'API Key'; + default: + return 'API Key'; + } + } + + getApiKeyPlaceholder(type: string): string { + switch(type) { + case 'llm': + if (this.currentLLMProvider?.name === 'spark') return 'Enter Spark token'; + if (this.currentLLMProvider?.name?.includes('gpt')) return 'sk-...'; + return 'Enter API key'; + case 'tts': + return 'Enter TTS API key'; + case 'stt': + if (this.currentSTTProvider?.name === 'google') return '/path/to/credentials.json'; + if (this.currentSTTProvider?.name === 'azure') return 'subscription_key|region'; + return 'Enter STT API key'; + default: + return 'Enter API key'; + } + } + + getEndpointPlaceholder(type: string): string { + switch(type) { + case 'llm': + return 'https://spark-api.example.com'; + case 'tts': + return 'https://tts-api.example.com'; + case 'stt': + return 'https://stt-api.example.com'; + default: + return 'https://api.example.com'; + } + } + + resetCollectionPrompt(): void { + this.parameterCollectionConfig.collection_prompt = 'Please provide the following information:'; + } + + testConnection(): void { + const endpoint = this.form.get('llm_provider_endpoint')?.value; + if (!endpoint) { + this.snackBar.open('Please enter an endpoint URL', 'Close', { duration: 2000 }); + return; + } + + this.snackBar.open('Testing connection...', 'Close', { duration: 2000 }); + // TODO: Implement actual connection test + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/login/login.component.ts b/flare-ui/src/app/components/login/login.component.ts index 3a78506d436ad66f82599bea8065d6cf119d1412..e6a22a1d4bfc6798b130a2e9c08c0b6ffc2fc0df 100644 --- a/flare-ui/src/app/components/login/login.component.ts +++ b/flare-ui/src/app/components/login/login.component.ts @@ -1,209 +1,209 @@ -import { Component, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; -import { MatCardModule } from '@angular/material/card'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatButtonModule } from '@angular/material/button'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatIconModule } from '@angular/material/icon'; -import { AuthService } from '../../services/auth.service'; - -@Component({ - selector: 'app-login', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatCardModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatProgressSpinnerModule, - MatIconModule - ], - template: ` - - `, - styles: [` - .login-container { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background-color: #f5f5f5; - } - - .login-card { - width: 100%; - max-width: 400px; - padding: 20px; - - mat-card-header { - display: flex; - justify-content: center; - margin-bottom: 30px; - - mat-card-title { - font-size: 24px; - font-weight: 500; - color: #333; - } - } - } - - .full-width { - width: 100%; - } - - mat-form-field { - margin-bottom: 20px; - } - - .error-message { - display: flex; - align-items: center; - gap: 8px; - color: #f44336; - font-size: 14px; - margin-bottom: 20px; - padding: 12px; - background-color: #ffebee; - border-radius: 4px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - } - - .submit-button { - height: 48px; - font-size: 16px; - margin-top: 10px; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - - mat-icon { - margin-right: 4px; - } - } - - .button-spinner { - display: inline-block; - margin-right: 8px; - } - - ::ng-deep { - .mat-mdc-card { - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.16) !important; - } - - .mat-mdc-form-field-icon-prefix, - .mat-mdc-form-field-icon-suffix { - padding: 0 4px; - } - - .mat-mdc-progress-spinner { - --mdc-circular-progress-active-indicator-color: white; - } - - .mat-mdc-form-field-error { - font-size: 12px; - } - } - `] -}) -export class LoginComponent { - private authService = inject(AuthService); - private router = inject(Router); - - username = ''; - password = ''; - loading = false; - error = ''; - hidePassword = true; - - async login() { - this.loading = true; - this.error = ''; - - try { - await this.authService.login(this.username, this.password).toPromise(); - this.router.navigate(['/']); - } catch (err: any) { - this.error = err.error?.detail || 'Invalid credentials'; - } finally { - this.loading = false; - } - } +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatIconModule } from '@angular/material/icon'; +import { AuthService } from '../../services/auth.service'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatProgressSpinnerModule, + MatIconModule + ], + template: ` + + `, + styles: [` + .login-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background-color: #f5f5f5; + } + + .login-card { + width: 100%; + max-width: 400px; + padding: 20px; + + mat-card-header { + display: flex; + justify-content: center; + margin-bottom: 30px; + + mat-card-title { + font-size: 24px; + font-weight: 500; + color: #333; + } + } + } + + .full-width { + width: 100%; + } + + mat-form-field { + margin-bottom: 20px; + } + + .error-message { + display: flex; + align-items: center; + gap: 8px; + color: #f44336; + font-size: 14px; + margin-bottom: 20px; + padding: 12px; + background-color: #ffebee; + border-radius: 4px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + .submit-button { + height: 48px; + font-size: 16px; + margin-top: 10px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + mat-icon { + margin-right: 4px; + } + } + + .button-spinner { + display: inline-block; + margin-right: 8px; + } + + ::ng-deep { + .mat-mdc-card { + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.16) !important; + } + + .mat-mdc-form-field-icon-prefix, + .mat-mdc-form-field-icon-suffix { + padding: 0 4px; + } + + .mat-mdc-progress-spinner { + --mdc-circular-progress-active-indicator-color: white; + } + + .mat-mdc-form-field-error { + font-size: 12px; + } + } + `] +}) +export class LoginComponent { + private authService = inject(AuthService); + private router = inject(Router); + + username = ''; + password = ''; + loading = false; + error = ''; + hidePassword = true; + + async login() { + this.loading = true; + this.error = ''; + + try { + await this.authService.login(this.username, this.password).toPromise(); + this.router.navigate(['/']); + } catch (err: any) { + this.error = err.error?.detail || 'Invalid credentials'; + } finally { + this.loading = false; + } + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/main/main.component.scss b/flare-ui/src/app/components/main/main.component.scss index 92b572b65acd75bdcdd122f2004c8cf1a1c84963..b1fc1f8de262ac8b2baf882dfff1e79d6d9abce7 100644 --- a/flare-ui/src/app/components/main/main.component.scss +++ b/flare-ui/src/app/components/main/main.component.scss @@ -1,145 +1,145 @@ -.main-layout { - display: flex; - flex-direction: column; - height: 100vh; - background-color: #fafafa; - - .header-toolbar { - position: sticky; - top: 0; - z-index: 100; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - - mat-toolbar-row { - padding: 0 16px; - } - - .logo { - display: flex; - align-items: center; - gap: 8px; - font-size: 20px; - font-weight: 500; - - mat-icon { - vertical-align: middle; - } - } - - .spacer { - flex: 1; - } - - .header-actions { - display: flex; - align-items: center; - gap: 16px; - - .username { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - vertical-align: middle; - } - } - - .activity-button { - position: relative; - - mat-icon { - vertical-align: middle; - } - } - } - } - - nav { - background-color: white; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - position: sticky; - top: 64px; - z-index: 99; - - .mat-mdc-tab-nav-bar { - padding: 0 16px; - } - - .mat-mdc-tab-link { - height: 48px; - opacity: 0.7; - font-weight: 500; - - &.mdc-tab--active { - opacity: 1; - } - - .tab-content { - display: flex; - align-items: center; - gap: 8px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - vertical-align: middle; - } - - span { - vertical-align: middle; - } - } - } - } - - .main-content { - flex: 1; - overflow: auto; - background-color: #fafafa; - } -} - -// Material overrides -::ng-deep { - .mat-toolbar-single-row { - height: 64px; - } - - .mat-mdc-menu-panel { - margin-top: 8px; - } - - .mat-mdc-tab-header { - border-bottom: none; - } - - .mat-mdc-tab-labels { - gap: 8px; - } -} - -// Responsive -@media (max-width: 768px) { - .main-layout { - nav { - .mat-mdc-tab-nav-bar { - padding: 0 8px; - } - - .mat-mdc-tab-link { - min-width: auto; - padding: 0 12px; - - .tab-content span { - display: none; - } - } - } - } +.main-layout { + display: flex; + flex-direction: column; + height: 100vh; + background-color: #fafafa; + + .header-toolbar { + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + mat-toolbar-row { + padding: 0 16px; + } + + .logo { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 500; + + mat-icon { + vertical-align: middle; + } + } + + .spacer { + flex: 1; + } + + .header-actions { + display: flex; + align-items: center; + gap: 16px; + + .username { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + vertical-align: middle; + } + } + + .activity-button { + position: relative; + + mat-icon { + vertical-align: middle; + } + } + } + } + + nav { + background-color: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + position: sticky; + top: 64px; + z-index: 99; + + .mat-mdc-tab-nav-bar { + padding: 0 16px; + } + + .mat-mdc-tab-link { + height: 48px; + opacity: 0.7; + font-weight: 500; + + &.mdc-tab--active { + opacity: 1; + } + + .tab-content { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + vertical-align: middle; + } + + span { + vertical-align: middle; + } + } + } + } + + .main-content { + flex: 1; + overflow: auto; + background-color: #fafafa; + } +} + +// Material overrides +::ng-deep { + .mat-toolbar-single-row { + height: 64px; + } + + .mat-mdc-menu-panel { + margin-top: 8px; + } + + .mat-mdc-tab-header { + border-bottom: none; + } + + .mat-mdc-tab-labels { + gap: 8px; + } +} + +// Responsive +@media (max-width: 768px) { + .main-layout { + nav { + .mat-mdc-tab-nav-bar { + padding: 0 8px; + } + + .mat-mdc-tab-link { + min-width: auto; + padding: 0 12px; + + .tab-content span { + display: none; + } + } + } + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/main/main.component.ts b/flare-ui/src/app/components/main/main.component.ts index 2c61df05ad28bdbaa84bc1063f98d29ad15a6ec8..de4b93f465f229e1d96372213f6b9f494fbc3b19 100644 --- a/flare-ui/src/app/components/main/main.component.ts +++ b/flare-ui/src/app/components/main/main.component.ts @@ -1,302 +1,302 @@ -import { Component, inject, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; -import { MatToolbarModule } from '@angular/material/toolbar'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatBadgeModule } from '@angular/material/badge'; -import { MatDividerModule } from '@angular/material/divider'; -import { Subject, takeUntil } from 'rxjs'; -import { AuthService } from '../../services/auth.service'; -import { ActivityLogComponent } from '../activity-log/activity-log.component'; -import { ApiService } from '../../services/api.service'; -import { EnvironmentService } from '../../services/environment.service'; - -@Component({ - selector: 'app-main', - standalone: true, - imports: [ - CommonModule, - RouterLink, - RouterLinkActive, - RouterOutlet, - MatToolbarModule, - MatTabsModule, - MatButtonModule, - MatIconModule, - MatMenuModule, - MatBadgeModule, - MatDividerModule, - ActivityLogComponent - ], - template: ` -
- - - - - - -
- - person - {{ username }} - - - - - @if (showActivityLog) { -
- -
- } - - - - - - - - -
-
-
- - - - -
- -
-
- -
- `, - styles: [` - .main-layout { - height: 100vh; - display: flex; - flex-direction: column; - background-color: #fafafa; - } - - .header-toolbar { - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - z-index: 100; - position: relative; - - .logo { - display: flex; - align-items: center; - gap: 8px; - font-size: 20px; - font-weight: 500; - - mat-icon { - font-size: 28px; - width: 28px; - height: 28px; - } - } - - .spacer { - flex: 1 1 auto; - } - - .header-actions { - display: flex; - align-items: center; - gap: 8px; - position: relative; - - .username { - display: flex; - align-items: center; - gap: 4px; - margin-right: 16px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - } - - .activity-log-wrapper { - position: absolute; - top: 56px; - right: 0; - z-index: 1000; - } - } - } - - .nav-tabs { - background-color: white; - box-shadow: 0 2px 4px rgba(0,0,0,0.08); - - ::ng-deep { - .mat-mdc-tab-link { - min-width: 120px; - opacity: 0.8; - - mat-icon { - margin-right: 8px; - } - - &.mdc-tab--active { - opacity: 1; - } - } - } - } - - .content { - flex: 1; - overflow-y: auto; - padding: 24px; - } - `] -}) -export class MainComponent implements OnInit, OnDestroy { - private authService = inject(AuthService); - private apiService = inject(ApiService); - private environmentService = inject(EnvironmentService); - - username = this.authService.getUsername() || ''; - showActivityLog = false; - isGPTMode = false; - - // Memory leak prevention - private destroyed$ = new Subject(); - - ngOnInit() { - // Environment değişikliklerini dinle - this.environmentService.environment$ - .pipe(takeUntil(this.destroyed$)) - .subscribe(env => { - if (env) { - // work_mode yerine llm_provider.name kullan - this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false; - this.updateProviderInfo(env); - } - }); - - // Environment bilgisini al - this.loadEnvironment(); - } - - ngOnDestroy() { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - loadEnvironment() { - this.apiService.getEnvironment() - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (env) => { - this.environmentService.updateEnvironment(env); - this.updateProviderInfo(env); - }, - error: (error) => { - console.error('Failed to load environment:', error); - // Show snackbar if needed - } - }); - } - - updateProviderInfo(env: any) { - // Update TTS/STT availability - zaten doğru - this.environmentService.setTTSEnabled(!!env.tts_provider?.name && env.tts_provider.name !== 'no_tts'); - this.environmentService.setSTTEnabled(!!env.stt_provider?.name && env.stt_provider.name !== 'no_stt'); - - // GPT mode'u da burada güncelleyebiliriz - this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false; - } - - logout() { - // Cleanup before logout - this.destroyed$.next(); - this.authService.logout(); - } - - toggleActivityLog() { - this.showActivityLog = !this.showActivityLog; - } +import { Component, inject, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatDividerModule } from '@angular/material/divider'; +import { Subject, takeUntil } from 'rxjs'; +import { AuthService } from '../../services/auth.service'; +import { ActivityLogComponent } from '../activity-log/activity-log.component'; +import { ApiService } from '../../services/api.service'; +import { EnvironmentService } from '../../services/environment.service'; + +@Component({ + selector: 'app-main', + standalone: true, + imports: [ + CommonModule, + RouterLink, + RouterLinkActive, + RouterOutlet, + MatToolbarModule, + MatTabsModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatBadgeModule, + MatDividerModule, + ActivityLogComponent + ], + template: ` +
+ + + + + + +
+ + person + {{ username }} + + + + + @if (showActivityLog) { +
+ +
+ } + + + + + + + + +
+
+
+ + + + +
+ +
+
+ +
+ `, + styles: [` + .main-layout { + height: 100vh; + display: flex; + flex-direction: column; + background-color: #fafafa; + } + + .header-toolbar { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + z-index: 100; + position: relative; + + .logo { + display: flex; + align-items: center; + gap: 8px; + font-size: 20px; + font-weight: 500; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + } + + .spacer { + flex: 1 1 auto; + } + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + position: relative; + + .username { + display: flex; + align-items: center; + gap: 4px; + margin-right: 16px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + .activity-log-wrapper { + position: absolute; + top: 56px; + right: 0; + z-index: 1000; + } + } + } + + .nav-tabs { + background-color: white; + box-shadow: 0 2px 4px rgba(0,0,0,0.08); + + ::ng-deep { + .mat-mdc-tab-link { + min-width: 120px; + opacity: 0.8; + + mat-icon { + margin-right: 8px; + } + + &.mdc-tab--active { + opacity: 1; + } + } + } + } + + .content { + flex: 1; + overflow-y: auto; + padding: 24px; + } + `] +}) +export class MainComponent implements OnInit, OnDestroy { + private authService = inject(AuthService); + private apiService = inject(ApiService); + private environmentService = inject(EnvironmentService); + + username = this.authService.getUsername() || ''; + showActivityLog = false; + isGPTMode = false; + + // Memory leak prevention + private destroyed$ = new Subject(); + + ngOnInit() { + // Environment değişikliklerini dinle + this.environmentService.environment$ + .pipe(takeUntil(this.destroyed$)) + .subscribe(env => { + if (env) { + // work_mode yerine llm_provider.name kullan + this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false; + this.updateProviderInfo(env); + } + }); + + // Environment bilgisini al + this.loadEnvironment(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + loadEnvironment() { + this.apiService.getEnvironment() + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (env) => { + this.environmentService.updateEnvironment(env); + this.updateProviderInfo(env); + }, + error: (error) => { + console.error('Failed to load environment:', error); + // Show snackbar if needed + } + }); + } + + updateProviderInfo(env: any) { + // Update TTS/STT availability - zaten doğru + this.environmentService.setTTSEnabled(!!env.tts_provider?.name && env.tts_provider.name !== 'no_tts'); + this.environmentService.setSTTEnabled(!!env.stt_provider?.name && env.stt_provider.name !== 'no_stt'); + + // GPT mode'u da burada güncelleyebiliriz + this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false; + } + + logout() { + // Cleanup before logout + this.destroyed$.next(); + this.authService.logout(); + } + + toggleActivityLog() { + this.showActivityLog = !this.showActivityLog; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/projects/projects.component.html b/flare-ui/src/app/components/projects/projects.component.html index 8fdf17cb3bcd61a719b4087cca16c1b6c602646f..e2fcf9784b8a96c445221a45a46a75d0894ec519 100644 --- a/flare-ui/src/app/components/projects/projects.component.html +++ b/flare-ui/src/app/components/projects/projects.component.html @@ -1,185 +1,185 @@ -
-
-
-

Projects

-
-
- - - - Search projects - - search - - - Display Deleted - - - - view_module - - - view_list - - -
-
- - - -
- -
- folder_open -

No projects found.

- -
- - -
- - -
- {{ project.icon || 'flight_takeoff' }} -
- {{ project.name }} - {{ project.caption || 'No description' }} -
- - -
-
- layers - {{ project.versions.length || 0 }} versions ({{ getPublishedCount(project) }} published) -
-
- {{ project.enabled ? 'check_circle' : 'cancel' }} - {{ project.enabled ? 'Enabled' : 'Disabled' }} -
-
- update - {{ getRelativeTime(project.last_update_date) }} -
-
-
- - - - - - - -
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Name -
- {{ project.icon || 'flight_takeoff' }} - {{ project.name }} - delete -
-
Caption{{ project.caption || '-' }}Versions - - {{ project.versions?.length || 0 }} total - {{ getPublishedCount(project) }} published - - Status - check_circle - cancel - Last Update{{ getRelativeTime(project.last_update_date) }}Actions - - - - - - - - - -
-
-
- - -
- - - {{ isError ? 'error' : 'check_circle' }} - {{ message }} - - -
+
+
+
+

Projects

+
+
+ + + + Search projects + + search + + + Display Deleted + + + + view_module + + + view_list + + +
+
+ + + +
+ +
+ folder_open +

No projects found.

+ +
+ + +
+ + +
+ {{ project.icon || 'flight_takeoff' }} +
+ {{ project.name }} + {{ project.caption || 'No description' }} +
+ + +
+
+ layers + {{ project.versions.length || 0 }} versions ({{ getPublishedCount(project) }} published) +
+
+ {{ project.enabled ? 'check_circle' : 'cancel' }} + {{ project.enabled ? 'Enabled' : 'Disabled' }} +
+
+ update + {{ getRelativeTime(project.last_update_date) }} +
+
+
+ + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name +
+ {{ project.icon || 'flight_takeoff' }} + {{ project.name }} + delete +
+
Caption{{ project.caption || '-' }}Versions + + {{ project.versions?.length || 0 }} total + {{ getPublishedCount(project) }} published + + Status + check_circle + cancel + Last Update{{ getRelativeTime(project.last_update_date) }}Actions + + + + + + + + + +
+
+
+ + +
+ + + {{ isError ? 'error' : 'check_circle' }} + {{ message }} + + +
\ No newline at end of file diff --git a/flare-ui/src/app/components/projects/projects.component.scss b/flare-ui/src/app/components/projects/projects.component.scss index 7c84f837b16650d3984a020e7f232023f1c97bdd..a054bbae1dd5a6499e9e010706af3808537470e6 100644 --- a/flare-ui/src/app/components/projects/projects.component.scss +++ b/flare-ui/src/app/components/projects/projects.component.scss @@ -1,275 +1,275 @@ -.projects-container { - display: flex; - flex-direction: column; - height: 100%; - padding: 20px; - - .toolbar { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - gap: 20px; - flex-wrap: wrap; - - .toolbar-left { - display: flex; - align-items: center; - gap: 16px; - } - - .toolbar-right { - display: flex; - align-items: center; - gap: 16px; - } - - .search-field { - width: 300px; - } - - .view-toggle { - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - } - } - - mat-progress-bar { - margin-bottom: 20px; - } - - .content { - flex: 1; - overflow: auto; - } - - .projects-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); - gap: 20px; - padding-bottom: 20px; - - .project-card { - transition: all 0.3s ease; - cursor: pointer; - - &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.15); - } - - &.disabled { - opacity: 0.7; - - .project-icon { - background-color: #999 !important; - } - } - - &.deleted { - opacity: 0.5; - background-color: #fafafa; - } - - .project-icon { - background-color: #3f51b5; - color: white; - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 50%; - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - } - } - - mat-card-title { - font-size: 18px; - font-weight: 500; - } - - mat-card-subtitle { - margin-top: 4px; - } - - .project-info { - margin-top: 16px; - - .info-item { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - color: #666; - - &:last-child { - margin-bottom: 0; - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: #999; - vertical-align: middle; - } - - .info-label { - font-size: 13px; - vertical-align: middle; - line-height: 18px; - } - - mat-checkbox { - margin-left: 4px; - vertical-align: middle; - - ::ng-deep .mat-mdc-checkbox-touch-target { - height: 18px; - } - } - - .time-text { - font-size: 12px; - color: #999; - } - } - } - } - } - - .projects-table { - overflow: auto; - - mat-checkbox { - vertical-align: middle; - } - - .name-with-icon { - display: flex; - align-items: center; - gap: 8px; - - .project-table-icon { - color: #3f51b5; - font-size: 20px; - width: 20px; - height: 20px; - } - - .deleted-icon { - margin-left: auto; - color: #f44336; - } - } - - .action-buttons { - display: flex; - gap: 8px; - - button { - min-width: auto; - } - } - } - - .empty-state { - text-align: center; - padding: 60px 20px; - - mat-icon { - font-size: 64px; - width: 64px; - height: 64px; - color: #ccc; - margin-bottom: 16px; - } - - h3 { - color: #666; - margin: 0 0 24px 0; - } - } - - .message-container { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - z-index: 1000; - - mat-card { - min-width: 300px; - - &.success mat-card-content { - color: #4caf50; - } - - &.error mat-card-content { - color: #f44336; - } - - mat-card-content { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - margin: 0; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - } - } - } -} - -// Material overrides for this component -::ng-deep { - .mat-mdc-button-toggle-appearance-standard .mat-button-toggle-label-content { - line-height: 36px; - padding: 0 12px; - } - - .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-infix { - padding: 12px 0; - } - - .mat-mdc-text-field-wrapper.mdc-text-field--outlined { - .mat-mdc-form-field-infix { - min-height: auto; - } - } - - .mat-mdc-card { - --mdc-elevated-card-container-color: white; - --mdc-elevated-card-container-elevation: 0 2px 4px rgba(0,0,0,0.1); - } -} - -// Responsive adjustments -@media (max-width: 768px) { - .projects-container { - .toolbar { - .toolbar-left, .toolbar-right { - width: 100%; - justify-content: center; - } - - .search-field { - width: 100%; - } - } - - .projects-grid { - grid-template-columns: 1fr; - } - } +.projects-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 20px; + + .toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + gap: 20px; + flex-wrap: wrap; + + .toolbar-left { + display: flex; + align-items: center; + gap: 16px; + } + + .toolbar-right { + display: flex; + align-items: center; + gap: 16px; + } + + .search-field { + width: 300px; + } + + .view-toggle { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + } + } + + mat-progress-bar { + margin-bottom: 20px; + } + + .content { + flex: 1; + overflow: auto; + } + + .projects-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 20px; + padding-bottom: 20px; + + .project-card { + transition: all 0.3s ease; + cursor: pointer; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + } + + &.disabled { + opacity: 0.7; + + .project-icon { + background-color: #999 !important; + } + } + + &.deleted { + opacity: 0.5; + background-color: #fafafa; + } + + .project-icon { + background-color: #3f51b5; + color: white; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } + } + + mat-card-title { + font-size: 18px; + font-weight: 500; + } + + mat-card-subtitle { + margin-top: 4px; + } + + .project-info { + margin-top: 16px; + + .info-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: #666; + + &:last-child { + margin-bottom: 0; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #999; + vertical-align: middle; + } + + .info-label { + font-size: 13px; + vertical-align: middle; + line-height: 18px; + } + + mat-checkbox { + margin-left: 4px; + vertical-align: middle; + + ::ng-deep .mat-mdc-checkbox-touch-target { + height: 18px; + } + } + + .time-text { + font-size: 12px; + color: #999; + } + } + } + } + } + + .projects-table { + overflow: auto; + + mat-checkbox { + vertical-align: middle; + } + + .name-with-icon { + display: flex; + align-items: center; + gap: 8px; + + .project-table-icon { + color: #3f51b5; + font-size: 20px; + width: 20px; + height: 20px; + } + + .deleted-icon { + margin-left: auto; + color: #f44336; + } + } + + .action-buttons { + display: flex; + gap: 8px; + + button { + min-width: auto; + } + } + } + + .empty-state { + text-align: center; + padding: 60px 20px; + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: #ccc; + margin-bottom: 16px; + } + + h3 { + color: #666; + margin: 0 0 24px 0; + } + } + + .message-container { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + + mat-card { + min-width: 300px; + + &.success mat-card-content { + color: #4caf50; + } + + &.error mat-card-content { + color: #f44336; + } + + mat-card-content { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin: 0; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + } + } +} + +// Material overrides for this component +::ng-deep { + .mat-mdc-button-toggle-appearance-standard .mat-button-toggle-label-content { + line-height: 36px; + padding: 0 12px; + } + + .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-infix { + padding: 12px 0; + } + + .mat-mdc-text-field-wrapper.mdc-text-field--outlined { + .mat-mdc-form-field-infix { + min-height: auto; + } + } + + .mat-mdc-card { + --mdc-elevated-card-container-color: white; + --mdc-elevated-card-container-elevation: 0 2px 4px rgba(0,0,0,0.1); + } +} + +// Responsive adjustments +@media (max-width: 768px) { + .projects-container { + .toolbar { + .toolbar-left, .toolbar-right { + width: 100%; + justify-content: center; + } + + .search-field { + width: 100%; + } + } + + .projects-grid { + grid-template-columns: 1fr; + } + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/projects/projects.component.ts b/flare-ui/src/app/components/projects/projects.component.ts index 22d9c90de963363c1bb582983d4c97a575897078..5c173ff2d037ca969bccb4225544c5f0278953cd 100644 --- a/flare-ui/src/app/components/projects/projects.component.ts +++ b/flare-ui/src/app/components/projects/projects.component.ts @@ -1,449 +1,449 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatTableModule } from '@angular/material/table'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatCardModule } from '@angular/material/card'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatIconModule } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatDividerModule } from '@angular/material/divider'; -import { ApiService, Project } from '../../services/api.service'; -import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; -import { authInterceptor } from '../../interceptors/auth.interceptor'; -import { Subject, takeUntil } from 'rxjs'; - -// Dynamic imports for dialogs -const loadProjectEditDialog = () => import('../../dialogs/project-edit-dialog/project-edit-dialog.component'); -const loadVersionEditDialog = () => import('../../dialogs/version-edit-dialog/version-edit-dialog.component'); -const loadConfirmDialog = () => import('../../dialogs/confirm-dialog/confirm-dialog.component'); - -@Component({ - selector: 'app-projects', - standalone: true, - imports: [ - CommonModule, - FormsModule, - HttpClientModule, - MatTableModule, - MatProgressBarModule, - MatButtonModule, - MatCheckboxModule, - MatFormFieldModule, - MatInputModule, - MatButtonToggleModule, - MatCardModule, - MatChipsModule, - MatIconModule, - MatMenuModule, - MatDividerModule, - MatDialogModule, - MatSnackBarModule - ], - providers: [ - ApiService - ], - templateUrl: './projects.component.html', - styleUrls: ['./projects.component.scss'] -}) -export class ProjectsComponent implements OnInit, OnDestroy { - projects: Project[] = []; - filteredProjects: Project[] = []; - searchTerm = ''; - showDeleted = false; - viewMode: 'list' | 'card' = 'card'; - loading = false; - message = ''; - isError = false; - - // For table view - displayedColumns: string[] = ['name', 'caption', 'versions', 'status', 'lastUpdate', 'actions']; - - // Memory leak prevention - private destroyed$ = new Subject(); - - constructor( - private apiService: ApiService, - private dialog: MatDialog, - private snackBar: MatSnackBar - ) {} - - ngOnInit() { - this.loadProjects(); - this.loadEnvironment(); - } - - ngOnDestroy() { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - isSparkTabVisible(): boolean { - // Environment bilgisini cache'ten al (eğer varsa) - const env = localStorage.getItem('flare_environment'); - if (env) { - const config = JSON.parse(env); - return !config.work_mode?.startsWith('gpt4o'); - } - return true; // Default olarak göster - } - - loadProjects() { - this.loading = true; - this.apiService.getProjects(this.showDeleted) - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (projects) => { - this.projects = projects || []; - this.applyFilter(); - this.loading = false; - }, - error: (error) => { - this.loading = false; - this.showMessage('Failed to load projects', true); - console.error('Load projects error:', error); - } - }); - } - - private loadEnvironment() { - this.apiService.getEnvironment() - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (env) => { - localStorage.setItem('flare_environment', JSON.stringify(env)); - }, - error: (err) => { - console.error('Failed to load environment:', err); - } - }); - } - - applyFilter() { - this.filteredProjects = this.projects.filter(project => { - const matchesSearch = !this.searchTerm || - project.name.toLowerCase().includes(this.searchTerm.toLowerCase()) || - (project.caption || '').toLowerCase().includes(this.searchTerm.toLowerCase()); - - const matchesDeleted = this.showDeleted || !project.deleted; - - return matchesSearch && matchesDeleted; - }); - } - - filterProjects() { - this.applyFilter(); - } - - onSearchChange() { - this.applyFilter(); - } - - onShowDeletedChange() { - this.loadProjects(); - } - - async createProject() { - try { - const { default: ProjectEditDialogComponent } = await loadProjectEditDialog(); - - const dialogRef = this.dialog.open(ProjectEditDialogComponent, { - width: '500px', - data: { mode: 'create' } - }); - - dialogRef.afterClosed() - .pipe(takeUntil(this.destroyed$)) - .subscribe(result => { - if (result) { - this.loadProjects(); - this.showMessage('Project created successfully', false); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.showMessage('Failed to open dialog', true); - } - } - - async editProject(project: Project, event?: Event) { - if (event) { - event.stopPropagation(); - } - - try { - const { default: ProjectEditDialogComponent } = await loadProjectEditDialog(); - - const dialogRef = this.dialog.open(ProjectEditDialogComponent, { - width: '500px', - data: { mode: 'edit', project: { ...project } } - }); - - dialogRef.afterClosed() - .pipe(takeUntil(this.destroyed$)) - .subscribe(result => { - if (result) { - // Listeyi güncelle - const index = this.projects.findIndex(p => p.id === result.id); - if (index !== -1) { - this.projects[index] = result; - this.applyFilter(); // Filtreyi yeniden uygula - } else { - this.loadProjects(); // Bulunamazsa tüm listeyi yenile - } - this.showMessage('Project updated successfully', false); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.showMessage('Failed to open dialog', true); - } - } - - toggleProject(project: Project, event?: Event) { - if (event) { - event.stopPropagation(); - } - - const action = project.enabled ? 'disable' : 'enable'; - const confirmMessage = `Are you sure you want to ${action} "${project.caption}"?`; - - this.confirmAction( - `${action.charAt(0).toUpperCase() + action.slice(1)} Project`, - confirmMessage, - action.charAt(0).toUpperCase() + action.slice(1), - !project.enabled - ).then(confirmed => { - if (confirmed) { - this.apiService.toggleProject(project.id) - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (result) => { - project.enabled = result.enabled; - this.showMessage( - `Project ${project.enabled ? 'enabled' : 'disabled'} successfully`, - false - ); - }, - error: (error) => this.handleUpdateError(error, project.caption) - }); - } - }); - } - - async manageVersions(project: Project, event?: Event) { - if (event) { - event.stopPropagation(); - } - - try { - const { default: VersionEditDialogComponent } = await loadVersionEditDialog(); - - const dialogRef = this.dialog.open(VersionEditDialogComponent, { - width: '90vw', - maxWidth: '1200px', - height: '90vh', - data: { project } - }); - - dialogRef.afterClosed() - .pipe(takeUntil(this.destroyed$)) - .subscribe(result => { - if (result) { - this.loadProjects(); - } - }); - } catch (error) { - console.error('Failed to load dialog:', error); - this.showMessage('Failed to open dialog', true); - } - } - - deleteProject(project: Project, event?: Event) { - if (event) { - event.stopPropagation(); - } - - const hasVersions = project.versions && project.versions.length > 0; - const message = hasVersions ? - `Project "${project.name}" has ${project.versions.length} version(s). Are you sure you want to delete it?` : - `Are you sure you want to delete project "${project.name}"?`; - - this.confirmAction('Delete Project', message, 'Delete', true).then(confirmed => { - if (confirmed) { - this.apiService.deleteProject(project.id) - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: () => { - this.showMessage('Project deleted successfully', false); - this.loadProjects(); - }, - error: (error) => { - const message = error.error?.detail || 'Failed to delete project'; - this.showMessage(message, true); - } - }); - } - }); - } - - exportProject(project: Project, event?: Event) { - if (event) { - event.stopPropagation(); - } - - this.apiService.exportProject(project.id) - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (data) => { - // Create and download file - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${project.name}_export_${new Date().getTime()}.json`; - link.click(); - window.URL.revokeObjectURL(url); - - this.showMessage('Project exported successfully', false); - }, - error: (error) => { - this.showMessage('Failed to export project', true); - console.error('Export error:', error); - } - }); - } - - importProject() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - - input.onchange = async (event: any) => { - const file = event.target.files[0]; - if (!file) return; - - try { - const text = await file.text(); - const data = JSON.parse(text); - - this.apiService.importProject(data) - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: () => { - this.showMessage('Project imported successfully', false); - this.loadProjects(); - }, - error: (error) => { - const message = error.error?.detail || 'Failed to import project'; - this.showMessage(message, true); - } - }); - } catch (error) { - this.showMessage('Invalid file format', true); - } - }; - - input.click(); - } - - getPublishedCount(project: Project): number { - return project.versions?.filter(v => v.published).length || 0; - } - - getRelativeTime(timestamp: string | undefined): string { - if (!timestamp) return 'Never'; - - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 60) return `${diffMins} minutes ago`; - if (diffHours < 24) return `${diffHours} hours ago`; - if (diffDays < 7) return `${diffDays} days ago`; - - return date.toLocaleDateString(); - } - - trackByProjectId(index: number, project: Project): number { - return project.id; - } - - handleUpdateError(error: any, projectName?: string): void { - if (error.status === 409 || error.raceCondition) { - const details = error.error?.details || error; - const lastUpdateUser = details.last_update_user || error.lastUpdateUser || 'another user'; - const lastUpdateDate = details.last_update_date || error.lastUpdateDate; - - const message = projectName - ? `Project "${projectName}" was modified by ${lastUpdateUser}. Please reload.` - : `Project was modified by ${lastUpdateUser}. Please reload.`; - - this.snackBar.open( - message, - 'Reload', - { - duration: 0, - panelClass: ['error-snackbar', 'race-condition-snackbar'] - } - ).onAction().subscribe(() => { - this.loadProjects(); - }); - - // Log additional info if available - if (lastUpdateDate) { - console.info(`Last updated at: ${lastUpdateDate}`); - } - } else { - // Generic error handling - this.snackBar.open( - error.error?.detail || error.message || 'Operation failed', - 'Close', - { - duration: 5000, - panelClass: ['error-snackbar'] - } - ); - } - } - - private async confirmAction(title: string, message: string, confirmText: string, dangerous: boolean): Promise { - try { - const { default: ConfirmDialogComponent } = await loadConfirmDialog(); - - const dialogRef = this.dialog.open(ConfirmDialogComponent, { - width: '400px', - data: { - title, - message, - confirmText, - confirmColor: dangerous ? 'warn' : 'primary' - } - }); - - return await dialogRef.afterClosed().toPromise() || false; - } catch (error) { - console.error('Failed to load confirm dialog:', error); - return false; - } - } - - private showMessage(message: string, isError: boolean) { - this.message = message; - this.isError = isError; - - setTimeout(() => { - this.message = ''; - }, 5000); - } +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTableModule } from '@angular/material/table'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatDividerModule } from '@angular/material/divider'; +import { ApiService, Project } from '../../services/api.service'; +import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { authInterceptor } from '../../interceptors/auth.interceptor'; +import { Subject, takeUntil } from 'rxjs'; + +// Dynamic imports for dialogs +const loadProjectEditDialog = () => import('../../dialogs/project-edit-dialog/project-edit-dialog.component'); +const loadVersionEditDialog = () => import('../../dialogs/version-edit-dialog/version-edit-dialog.component'); +const loadConfirmDialog = () => import('../../dialogs/confirm-dialog/confirm-dialog.component'); + +@Component({ + selector: 'app-projects', + standalone: true, + imports: [ + CommonModule, + FormsModule, + HttpClientModule, + MatTableModule, + MatProgressBarModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatButtonToggleModule, + MatCardModule, + MatChipsModule, + MatIconModule, + MatMenuModule, + MatDividerModule, + MatDialogModule, + MatSnackBarModule + ], + providers: [ + ApiService + ], + templateUrl: './projects.component.html', + styleUrls: ['./projects.component.scss'] +}) +export class ProjectsComponent implements OnInit, OnDestroy { + projects: Project[] = []; + filteredProjects: Project[] = []; + searchTerm = ''; + showDeleted = false; + viewMode: 'list' | 'card' = 'card'; + loading = false; + message = ''; + isError = false; + + // For table view + displayedColumns: string[] = ['name', 'caption', 'versions', 'status', 'lastUpdate', 'actions']; + + // Memory leak prevention + private destroyed$ = new Subject(); + + constructor( + private apiService: ApiService, + private dialog: MatDialog, + private snackBar: MatSnackBar + ) {} + + ngOnInit() { + this.loadProjects(); + this.loadEnvironment(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + isSparkTabVisible(): boolean { + // Environment bilgisini cache'ten al (eğer varsa) + const env = localStorage.getItem('flare_environment'); + if (env) { + const config = JSON.parse(env); + return !config.work_mode?.startsWith('gpt4o'); + } + return true; // Default olarak göster + } + + loadProjects() { + this.loading = true; + this.apiService.getProjects(this.showDeleted) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (projects) => { + this.projects = projects || []; + this.applyFilter(); + this.loading = false; + }, + error: (error) => { + this.loading = false; + this.showMessage('Failed to load projects', true); + console.error('Load projects error:', error); + } + }); + } + + private loadEnvironment() { + this.apiService.getEnvironment() + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (env) => { + localStorage.setItem('flare_environment', JSON.stringify(env)); + }, + error: (err) => { + console.error('Failed to load environment:', err); + } + }); + } + + applyFilter() { + this.filteredProjects = this.projects.filter(project => { + const matchesSearch = !this.searchTerm || + project.name.toLowerCase().includes(this.searchTerm.toLowerCase()) || + (project.caption || '').toLowerCase().includes(this.searchTerm.toLowerCase()); + + const matchesDeleted = this.showDeleted || !project.deleted; + + return matchesSearch && matchesDeleted; + }); + } + + filterProjects() { + this.applyFilter(); + } + + onSearchChange() { + this.applyFilter(); + } + + onShowDeletedChange() { + this.loadProjects(); + } + + async createProject() { + try { + const { default: ProjectEditDialogComponent } = await loadProjectEditDialog(); + + const dialogRef = this.dialog.open(ProjectEditDialogComponent, { + width: '500px', + data: { mode: 'create' } + }); + + dialogRef.afterClosed() + .pipe(takeUntil(this.destroyed$)) + .subscribe(result => { + if (result) { + this.loadProjects(); + this.showMessage('Project created successfully', false); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.showMessage('Failed to open dialog', true); + } + } + + async editProject(project: Project, event?: Event) { + if (event) { + event.stopPropagation(); + } + + try { + const { default: ProjectEditDialogComponent } = await loadProjectEditDialog(); + + const dialogRef = this.dialog.open(ProjectEditDialogComponent, { + width: '500px', + data: { mode: 'edit', project: { ...project } } + }); + + dialogRef.afterClosed() + .pipe(takeUntil(this.destroyed$)) + .subscribe(result => { + if (result) { + // Listeyi güncelle + const index = this.projects.findIndex(p => p.id === result.id); + if (index !== -1) { + this.projects[index] = result; + this.applyFilter(); // Filtreyi yeniden uygula + } else { + this.loadProjects(); // Bulunamazsa tüm listeyi yenile + } + this.showMessage('Project updated successfully', false); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.showMessage('Failed to open dialog', true); + } + } + + toggleProject(project: Project, event?: Event) { + if (event) { + event.stopPropagation(); + } + + const action = project.enabled ? 'disable' : 'enable'; + const confirmMessage = `Are you sure you want to ${action} "${project.caption}"?`; + + this.confirmAction( + `${action.charAt(0).toUpperCase() + action.slice(1)} Project`, + confirmMessage, + action.charAt(0).toUpperCase() + action.slice(1), + !project.enabled + ).then(confirmed => { + if (confirmed) { + this.apiService.toggleProject(project.id) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (result) => { + project.enabled = result.enabled; + this.showMessage( + `Project ${project.enabled ? 'enabled' : 'disabled'} successfully`, + false + ); + }, + error: (error) => this.handleUpdateError(error, project.caption) + }); + } + }); + } + + async manageVersions(project: Project, event?: Event) { + if (event) { + event.stopPropagation(); + } + + try { + const { default: VersionEditDialogComponent } = await loadVersionEditDialog(); + + const dialogRef = this.dialog.open(VersionEditDialogComponent, { + width: '90vw', + maxWidth: '1200px', + height: '90vh', + data: { project } + }); + + dialogRef.afterClosed() + .pipe(takeUntil(this.destroyed$)) + .subscribe(result => { + if (result) { + this.loadProjects(); + } + }); + } catch (error) { + console.error('Failed to load dialog:', error); + this.showMessage('Failed to open dialog', true); + } + } + + deleteProject(project: Project, event?: Event) { + if (event) { + event.stopPropagation(); + } + + const hasVersions = project.versions && project.versions.length > 0; + const message = hasVersions ? + `Project "${project.name}" has ${project.versions.length} version(s). Are you sure you want to delete it?` : + `Are you sure you want to delete project "${project.name}"?`; + + this.confirmAction('Delete Project', message, 'Delete', true).then(confirmed => { + if (confirmed) { + this.apiService.deleteProject(project.id) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: () => { + this.showMessage('Project deleted successfully', false); + this.loadProjects(); + }, + error: (error) => { + const message = error.error?.detail || 'Failed to delete project'; + this.showMessage(message, true); + } + }); + } + }); + } + + exportProject(project: Project, event?: Event) { + if (event) { + event.stopPropagation(); + } + + this.apiService.exportProject(project.id) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (data) => { + // Create and download file + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${project.name}_export_${new Date().getTime()}.json`; + link.click(); + window.URL.revokeObjectURL(url); + + this.showMessage('Project exported successfully', false); + }, + error: (error) => { + this.showMessage('Failed to export project', true); + console.error('Export error:', error); + } + }); + } + + importProject() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = async (event: any) => { + const file = event.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const data = JSON.parse(text); + + this.apiService.importProject(data) + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: () => { + this.showMessage('Project imported successfully', false); + this.loadProjects(); + }, + error: (error) => { + const message = error.error?.detail || 'Failed to import project'; + this.showMessage(message, true); + } + }); + } catch (error) { + this.showMessage('Invalid file format', true); + } + }; + + input.click(); + } + + getPublishedCount(project: Project): number { + return project.versions?.filter(v => v.published).length || 0; + } + + getRelativeTime(timestamp: string | undefined): string { + if (!timestamp) return 'Never'; + + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 60) return `${diffMins} minutes ago`; + if (diffHours < 24) return `${diffHours} hours ago`; + if (diffDays < 7) return `${diffDays} days ago`; + + return date.toLocaleDateString(); + } + + trackByProjectId(index: number, project: Project): number { + return project.id; + } + + handleUpdateError(error: any, projectName?: string): void { + if (error.status === 409 || error.raceCondition) { + const details = error.error?.details || error; + const lastUpdateUser = details.last_update_user || error.lastUpdateUser || 'another user'; + const lastUpdateDate = details.last_update_date || error.lastUpdateDate; + + const message = projectName + ? `Project "${projectName}" was modified by ${lastUpdateUser}. Please reload.` + : `Project was modified by ${lastUpdateUser}. Please reload.`; + + this.snackBar.open( + message, + 'Reload', + { + duration: 0, + panelClass: ['error-snackbar', 'race-condition-snackbar'] + } + ).onAction().subscribe(() => { + this.loadProjects(); + }); + + // Log additional info if available + if (lastUpdateDate) { + console.info(`Last updated at: ${lastUpdateDate}`); + } + } else { + // Generic error handling + this.snackBar.open( + error.error?.detail || error.message || 'Operation failed', + 'Close', + { + duration: 5000, + panelClass: ['error-snackbar'] + } + ); + } + } + + private async confirmAction(title: string, message: string, confirmText: string, dangerous: boolean): Promise { + try { + const { default: ConfirmDialogComponent } = await loadConfirmDialog(); + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title, + message, + confirmText, + confirmColor: dangerous ? 'warn' : 'primary' + } + }); + + return await dialogRef.afterClosed().toPromise() || false; + } catch (error) { + console.error('Failed to load confirm dialog:', error); + return false; + } + } + + private showMessage(message: string, isError: boolean) { + this.message = message; + this.isError = isError; + + setTimeout(() => { + this.message = ''; + }, 5000); + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/spark/spark.component.ts b/flare-ui/src/app/components/spark/spark.component.ts index 509296e198ffcdcf794ac32b8ede47261fa09bb6..42634afbda98c5e112ba65a7c97d6efaa9dae858 100644 --- a/flare-ui/src/app/components/spark/spark.component.ts +++ b/flare-ui/src/app/components/spark/spark.component.ts @@ -1,550 +1,550 @@ -import { Component, OnInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatCardModule } from '@angular/material/card'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatTableModule } from '@angular/material/table'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatDividerModule } from '@angular/material/divider'; -import { ApiService } from '../../services/api.service'; -import { MatSnackBar } from '@angular/material/snack-bar'; - -interface SparkResponse { - type: string; - timestamp: Date; - request?: any; - response?: any; - error?: string; -} - -interface SparkProject { - project_name: string; - version: number; - enabled: boolean; - status: string; - last_accessed: string; - base_model: string; - has_adapter: boolean; -} - -@Component({ - selector: 'app-spark', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatCardModule, - MatFormFieldModule, - MatSelectModule, - MatButtonModule, - MatIconModule, - MatProgressSpinnerModule, - MatExpansionModule, - MatTableModule, - MatChipsModule, - MatDividerModule - ], - template: ` -
- - - - flash_on - Spark Integration - - - Manage Spark LLM service integration - - - - - - Select Project - - - {{ project.name }} {{ project.caption ? '- ' + project.caption : '' }} - - - folder - - -
- - - - - - - - - -
- - @if (loading) { -
- -

Processing request...

-
- } - - @if (responses.length > 0) { - - -

Response History

- -
- @for (response of responses; track response.timestamp) { - - - - - {{ response.type }} - - {{ response.timestamp | date:'HH:mm:ss' }} - - - - @if (response.request) { -
-

Request:

-
{{ response.request | json }}
-
- } - - @if (response.response) { -
-

Response:

- @if (response.type === 'Get Project Status' && response.response.projects) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Project{{ project.project_name }}Versionv{{ project.version }}Status - - {{ project.status }} - - Enabled - - {{ project.enabled ? 'check_circle' : 'cancel' }} - - Base Model - {{ project.base_model }} - Last Accessed{{ project.last_accessed }}
- } @else { -
{{ response.response | json }}
- } -
- } - - @if (response.error) { -
-

Error:

-
{{ response.error }}
-
- } -
- } -
- } -
-
-
- `, - styles: [` - .spark-container { - max-width: 1200px; - margin: 0 auto; - } - - mat-card-header { - margin-bottom: 24px; - - mat-card-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 24px; - - mat-icon { - font-size: 28px; - width: 28px; - height: 28px; - } - } - } - - .project-select { - width: 100%; - max-width: 400px; - margin-bottom: 24px; - } - - .action-buttons { - display: flex; - gap: 16px; - flex-wrap: wrap; - margin-bottom: 24px; - - button { - display: flex; - align-items: center; - gap: 8px; - } - } - - .loading-indicator { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - padding: 32px; - - p { - color: #666; - font-size: 14px; - } - } - - .section-divider { - margin: 32px 0; - } - - .response-list { - margin-top: 16px; - - mat-expansion-panel { - margin-bottom: 16px; - } - - mat-panel-title { - display: flex; - align-items: center; - gap: 12px; - - .timestamp { - margin-left: auto; - color: #666; - font-size: 14px; - } - } - } - - .response-section { - margin: 16px 0; - - h4 { - margin-bottom: 8px; - color: #666; - } - - &.error { - h4 { - color: #f44336; - } - } - } - - .json-display { - background-color: #f5f5f5; - padding: 16px; - border-radius: 4px; - font-family: 'Consolas', 'Monaco', monospace; - font-size: 13px; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-word; - - &.error-text { - background-color: #ffebee; - color: #c62828; - } - } - - .projects-table { - width: 100%; - background: #fafafa; - - .model-cell { - font-size: 12px; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - - mat-chip { - font-size: 12px; - min-height: 24px; - padding: 4px 12px; - - &.success-chip { - background-color: #4caf50; - color: white; - } - - &.error-chip { - background-color: #f44336; - color: white; - } - } - - ::ng-deep { - .mat-mdc-progress-spinner { - --mdc-circular-progress-active-indicator-color: #3f51b5; - } - } - `] -}) -export class SparkComponent implements OnInit { - projects: any[] = []; - selectedProject: string = ''; - loading = false; - responses: SparkResponse[] = []; - displayedColumns: string[] = ['project_name', 'version', 'status', 'enabled', 'base_model', 'last_accessed']; - - constructor( - private apiService: ApiService, - private snackBar: MatSnackBar - ) {} - - ngOnInit() { - this.loadProjects(); - } - - loadProjects() { - this.apiService.getProjects().subscribe({ - next: (projects) => { - this.projects = projects.filter((p: any) => p.enabled && !p.deleted); - }, - error: (err) => { - this.snackBar.open('Failed to load projects', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - } - }); - } - - onProjectChange() { - // Clear previous responses when project changes - this.responses = []; - } - - private addResponse(type: string, request?: any, response?: any, error?: string) { - this.responses.unshift({ - type, - timestamp: new Date(), - request, - response, - error - }); - - // Keep only last 10 responses - if (this.responses.length > 10) { - this.responses.pop(); - } - } - - projectStartup() { - if (!this.selectedProject) return; - - this.loading = true; - const request = { project_name: this.selectedProject }; - - this.apiService.sparkStartup(this.selectedProject).subscribe({ - next: (response) => { - this.addResponse('Project Startup', request, response); - this.snackBar.open(response.message || 'Startup initiated', 'Close', { - duration: 3000 - }); - this.loading = false; - }, - error: (err) => { - this.addResponse('Project Startup', request, null, err.error?.detail || err.message); - this.snackBar.open(err.error?.detail || 'Startup failed', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.loading = false; - } - }); - } - - getProjectStatus() { - this.loading = true; - - this.apiService.sparkGetProjects().subscribe({ - next: (response) => { - this.addResponse('Get Project Status', null, response); - this.loading = false; - }, - error: (err) => { - this.addResponse('Get Project Status', null, null, err.error?.detail || err.message); - this.snackBar.open(err.error?.detail || 'Failed to get status', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.loading = false; - } - }); - } - - enableProject() { - if (!this.selectedProject) return; - - this.loading = true; - const request = { project_name: this.selectedProject }; - - this.apiService.sparkEnableProject(this.selectedProject).subscribe({ - next: (response) => { - this.addResponse('Enable Project', request, response); - this.snackBar.open(response.message || 'Project enabled', 'Close', { - duration: 3000 - }); - this.loading = false; - }, - error: (err) => { - this.addResponse('Enable Project', request, null, err.error?.detail || err.message); - this.snackBar.open(err.error?.detail || 'Enable failed', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.loading = false; - } - }); - } - - disableProject() { - if (!this.selectedProject) return; - - this.loading = true; - const request = { project_name: this.selectedProject }; - - this.apiService.sparkDisableProject(this.selectedProject).subscribe({ - next: (response) => { - this.addResponse('Disable Project', request, response); - this.snackBar.open(response.message || 'Project disabled', 'Close', { - duration: 3000 - }); - this.loading = false; - }, - error: (err) => { - this.addResponse('Disable Project', request, null, err.error?.detail || err.message); - this.snackBar.open(err.error?.detail || 'Disable failed', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.loading = false; - } - }); - } - - deleteProject() { - if (!this.selectedProject) return; - - if (!confirm(`Are you sure you want to delete "${this.selectedProject}" from Spark?`)) { - return; - } - - this.loading = true; - const request = { project_name: this.selectedProject }; - - this.apiService.sparkDeleteProject(this.selectedProject).subscribe({ - next: (response) => { - this.addResponse('Delete Project', request, response); - this.snackBar.open(response.message || 'Project deleted', 'Close', { - duration: 3000 - }); - this.loading = false; - this.selectedProject = ''; - }, - error: (err) => { - this.addResponse('Delete Project', request, null, err.error?.detail || err.message); - this.snackBar.open(err.error?.detail || 'Delete failed', 'Close', { - duration: 5000, - panelClass: 'error-snackbar' - }); - this.loading = false; - } - }); - } - - getStatusClass(status: string): string { - switch (status) { - case 'ready': - return 'status-ready'; - case 'loading': - return 'status-loading'; - case 'error': - return 'status-error'; - case 'unloaded': - return 'status-unloaded'; - default: - return ''; - } - } - - trackByTimestamp(index: number, response: SparkResponse): Date { - return response.timestamp; - } +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatTableModule } from '@angular/material/table'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatDividerModule } from '@angular/material/divider'; +import { ApiService } from '../../services/api.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +interface SparkResponse { + type: string; + timestamp: Date; + request?: any; + response?: any; + error?: string; +} + +interface SparkProject { + project_name: string; + version: number; + enabled: boolean; + status: string; + last_accessed: string; + base_model: string; + has_adapter: boolean; +} + +@Component({ + selector: 'app-spark', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCardModule, + MatFormFieldModule, + MatSelectModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatExpansionModule, + MatTableModule, + MatChipsModule, + MatDividerModule + ], + template: ` +
+ + + + flash_on + Spark Integration + + + Manage Spark LLM service integration + + + + + + Select Project + + + {{ project.name }} {{ project.caption ? '- ' + project.caption : '' }} + + + folder + + +
+ + + + + + + + + +
+ + @if (loading) { +
+ +

Processing request...

+
+ } + + @if (responses.length > 0) { + + +

Response History

+ +
+ @for (response of responses; track response.timestamp) { + + + + + {{ response.type }} + + {{ response.timestamp | date:'HH:mm:ss' }} + + + + @if (response.request) { +
+

Request:

+
{{ response.request | json }}
+
+ } + + @if (response.response) { +
+

Response:

+ @if (response.type === 'Get Project Status' && response.response.projects) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project{{ project.project_name }}Versionv{{ project.version }}Status + + {{ project.status }} + + Enabled + + {{ project.enabled ? 'check_circle' : 'cancel' }} + + Base Model + {{ project.base_model }} + Last Accessed{{ project.last_accessed }}
+ } @else { +
{{ response.response | json }}
+ } +
+ } + + @if (response.error) { +
+

Error:

+
{{ response.error }}
+
+ } +
+ } +
+ } +
+
+
+ `, + styles: [` + .spark-container { + max-width: 1200px; + margin: 0 auto; + } + + mat-card-header { + margin-bottom: 24px; + + mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 24px; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + } + } + + .project-select { + width: 100%; + max-width: 400px; + margin-bottom: 24px; + } + + .action-buttons { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 24px; + + button { + display: flex; + align-items: center; + gap: 8px; + } + } + + .loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 32px; + + p { + color: #666; + font-size: 14px; + } + } + + .section-divider { + margin: 32px 0; + } + + .response-list { + margin-top: 16px; + + mat-expansion-panel { + margin-bottom: 16px; + } + + mat-panel-title { + display: flex; + align-items: center; + gap: 12px; + + .timestamp { + margin-left: auto; + color: #666; + font-size: 14px; + } + } + } + + .response-section { + margin: 16px 0; + + h4 { + margin-bottom: 8px; + color: #666; + } + + &.error { + h4 { + color: #f44336; + } + } + } + + .json-display { + background-color: #f5f5f5; + padding: 16px; + border-radius: 4px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 13px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + + &.error-text { + background-color: #ffebee; + color: #c62828; + } + } + + .projects-table { + width: 100%; + background: #fafafa; + + .model-cell { + font-size: 12px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + mat-chip { + font-size: 12px; + min-height: 24px; + padding: 4px 12px; + + &.success-chip { + background-color: #4caf50; + color: white; + } + + &.error-chip { + background-color: #f44336; + color: white; + } + } + + ::ng-deep { + .mat-mdc-progress-spinner { + --mdc-circular-progress-active-indicator-color: #3f51b5; + } + } + `] +}) +export class SparkComponent implements OnInit { + projects: any[] = []; + selectedProject: string = ''; + loading = false; + responses: SparkResponse[] = []; + displayedColumns: string[] = ['project_name', 'version', 'status', 'enabled', 'base_model', 'last_accessed']; + + constructor( + private apiService: ApiService, + private snackBar: MatSnackBar + ) {} + + ngOnInit() { + this.loadProjects(); + } + + loadProjects() { + this.apiService.getProjects().subscribe({ + next: (projects) => { + this.projects = projects.filter((p: any) => p.enabled && !p.deleted); + }, + error: (err) => { + this.snackBar.open('Failed to load projects', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + } + }); + } + + onProjectChange() { + // Clear previous responses when project changes + this.responses = []; + } + + private addResponse(type: string, request?: any, response?: any, error?: string) { + this.responses.unshift({ + type, + timestamp: new Date(), + request, + response, + error + }); + + // Keep only last 10 responses + if (this.responses.length > 10) { + this.responses.pop(); + } + } + + projectStartup() { + if (!this.selectedProject) return; + + this.loading = true; + const request = { project_name: this.selectedProject }; + + this.apiService.sparkStartup(this.selectedProject).subscribe({ + next: (response) => { + this.addResponse('Project Startup', request, response); + this.snackBar.open(response.message || 'Startup initiated', 'Close', { + duration: 3000 + }); + this.loading = false; + }, + error: (err) => { + this.addResponse('Project Startup', request, null, err.error?.detail || err.message); + this.snackBar.open(err.error?.detail || 'Startup failed', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.loading = false; + } + }); + } + + getProjectStatus() { + this.loading = true; + + this.apiService.sparkGetProjects().subscribe({ + next: (response) => { + this.addResponse('Get Project Status', null, response); + this.loading = false; + }, + error: (err) => { + this.addResponse('Get Project Status', null, null, err.error?.detail || err.message); + this.snackBar.open(err.error?.detail || 'Failed to get status', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.loading = false; + } + }); + } + + enableProject() { + if (!this.selectedProject) return; + + this.loading = true; + const request = { project_name: this.selectedProject }; + + this.apiService.sparkEnableProject(this.selectedProject).subscribe({ + next: (response) => { + this.addResponse('Enable Project', request, response); + this.snackBar.open(response.message || 'Project enabled', 'Close', { + duration: 3000 + }); + this.loading = false; + }, + error: (err) => { + this.addResponse('Enable Project', request, null, err.error?.detail || err.message); + this.snackBar.open(err.error?.detail || 'Enable failed', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.loading = false; + } + }); + } + + disableProject() { + if (!this.selectedProject) return; + + this.loading = true; + const request = { project_name: this.selectedProject }; + + this.apiService.sparkDisableProject(this.selectedProject).subscribe({ + next: (response) => { + this.addResponse('Disable Project', request, response); + this.snackBar.open(response.message || 'Project disabled', 'Close', { + duration: 3000 + }); + this.loading = false; + }, + error: (err) => { + this.addResponse('Disable Project', request, null, err.error?.detail || err.message); + this.snackBar.open(err.error?.detail || 'Disable failed', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.loading = false; + } + }); + } + + deleteProject() { + if (!this.selectedProject) return; + + if (!confirm(`Are you sure you want to delete "${this.selectedProject}" from Spark?`)) { + return; + } + + this.loading = true; + const request = { project_name: this.selectedProject }; + + this.apiService.sparkDeleteProject(this.selectedProject).subscribe({ + next: (response) => { + this.addResponse('Delete Project', request, response); + this.snackBar.open(response.message || 'Project deleted', 'Close', { + duration: 3000 + }); + this.loading = false; + this.selectedProject = ''; + }, + error: (err) => { + this.addResponse('Delete Project', request, null, err.error?.detail || err.message); + this.snackBar.open(err.error?.detail || 'Delete failed', 'Close', { + duration: 5000, + panelClass: 'error-snackbar' + }); + this.loading = false; + } + }); + } + + getStatusClass(status: string): string { + switch (status) { + case 'ready': + return 'status-ready'; + case 'loading': + return 'status-loading'; + case 'error': + return 'status-error'; + case 'unloaded': + return 'status-unloaded'; + default: + return ''; + } + } + + trackByTimestamp(index: number, response: SparkResponse): Date { + return response.timestamp; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/test/test.component.html b/flare-ui/src/app/components/test/test.component.html index 302a0852345f5d807c3ed8eb02e52ec58a7342aa..b9250e3454093295df7a6df6f1a616664d208b11 100644 --- a/flare-ui/src/app/components/test/test.component.html +++ b/flare-ui/src/app/components/test/test.component.html @@ -1,116 +1,116 @@ -
-

System Tests

- -
- - - -
- - - - All Tests ({{ totalTests }} tests) - - - - - - - - All Tests ({{ totalTests }} tests) - - - -
- - - {{ getCategoryResults(category).passed }} passed - - - {{ getCategoryResults(category).failed }} failed - - -
-
-
- - - - - {{ getTestResult(test.name)?.status === 'PASS' ? 'check_circle' : - getTestResult(test.name)?.status === 'FAIL' ? 'cancel' : - getTestResult(test.name)?.status === 'RUNNING' ? 'hourglass_empty' : - 'radio_button_unchecked' }} - -
{{ test.name }}
-
- - {{ getTestResult(test.name)?.duration_ms }}ms - - - • {{ getTestResult(test.name)?.details }} - - - • {{ getTestResult(test.name)?.error }} - -
- - sync - -
-
-
-
-
- - - - Test Progress - - - - - - -
-
- check_circle - Passed: {{ passedTests }} -
-
- cancel - Failed: {{ failedTests }} -
-
- timer - Total: {{ testResults.length }}/{{ selectedTests.length }} -
-
- -
- sync - Running: {{ currentTest }} -
-
-
- -
- assignment_turned_in -

No test results yet. Select tests and click "Run Selected" to start.

-
+
+

System Tests

+ +
+ + + +
+ + + + All Tests ({{ totalTests }} tests) + + + + + + + + All Tests ({{ totalTests }} tests) + + + +
+ + + {{ getCategoryResults(category).passed }} passed + + + {{ getCategoryResults(category).failed }} failed + + +
+
+
+ + + + + {{ getTestResult(test.name)?.status === 'PASS' ? 'check_circle' : + getTestResult(test.name)?.status === 'FAIL' ? 'cancel' : + getTestResult(test.name)?.status === 'RUNNING' ? 'hourglass_empty' : + 'radio_button_unchecked' }} + +
{{ test.name }}
+
+ + {{ getTestResult(test.name)?.duration_ms }}ms + + + • {{ getTestResult(test.name)?.details }} + + + • {{ getTestResult(test.name)?.error }} + +
+ + sync + +
+
+
+
+
+ + + + Test Progress + + + + + + +
+
+ check_circle + Passed: {{ passedTests }} +
+
+ cancel + Failed: {{ failedTests }} +
+
+ timer + Total: {{ testResults.length }}/{{ selectedTests.length }} +
+
+ +
+ sync + Running: {{ currentTest }} +
+
+
+ +
+ assignment_turned_in +

No test results yet. Select tests and click "Run Selected" to start.

+
\ No newline at end of file diff --git a/flare-ui/src/app/components/test/test.component.scss b/flare-ui/src/app/components/test/test.component.scss index 35bdb1a65a47a51ea104ae960f5d4a6c33f0b903..60aaa160cf2849a7cfff18c8424e2a913f2162be 100644 --- a/flare-ui/src/app/components/test/test.component.scss +++ b/flare-ui/src/app/components/test/test.component.scss @@ -1,258 +1,258 @@ -.test-container { - padding: 24px; - max-width: 1200px; - margin: 0 auto; - - .header { - margin-bottom: 32px; - - h2 { - margin: 0 0 8px 0; - display: flex; - align-items: center; - gap: 12px; - - mat-icon { - color: #666; - vertical-align: middle; - } - } - - p { - color: #666; - margin: 0; - } - } - - .actions { - display: flex; - gap: 16px; - align-items: center; - margin-bottom: 24px; - - .run-buttons { - display: flex; - gap: 12px; - align-items: center; - - .selected-count { - color: #666; - font-size: 14px; - margin-left: 8px; - } - } - - .select-all { - margin-left: auto; - display: flex; - align-items: center; - - mat-checkbox { - vertical-align: middle; - } - } - } - - .test-progress { - margin-bottom: 32px; - - mat-progress-bar { - margin-bottom: 8px; - } - - .progress-info { - display: flex; - justify-content: space-between; - align-items: center; - - .current-test { - color: #666; - font-size: 14px; - } - - .test-stats { - display: flex; - gap: 16px; - font-size: 14px; - - .stat { - display: flex; - align-items: center; - gap: 4px; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - vertical-align: middle; - } - - &.passed { - color: #4caf50; - } - - &.failed { - color: #f44336; - } - } - } - } - } - - .test-categories { - mat-expansion-panel { - margin-bottom: 8px; - - mat-expansion-panel-header { - padding: 0 24px; - - .category-header { - display: flex; - align-items: center; - width: 100%; - - mat-checkbox { - margin-right: 16px; - vertical-align: middle; - } - - .category-info { - flex: 1; - - .category-name { - font-weight: 500; - } - } - - .category-stats { - display: flex; - gap: 12px; - align-items: center; - - mat-chip { - min-height: 24px; - font-size: 12px; - } - } - } - } - - .test-list { - padding: 0 24px 16px 24px; - - mat-list-item { - height: auto; - padding: 8px 0; - - .test-item { - display: flex; - align-items: center; - width: 100%; - gap: 16px; - - mat-checkbox { - flex-shrink: 0; - vertical-align: middle; - } - - .test-name { - flex: 1; - font-size: 14px; - } - - .test-result { - display: flex; - align-items: center; - gap: 8px; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - vertical-align: middle; - } - - .duration { - font-size: 12px; - color: #666; - } - - &.pass mat-icon { - color: #4caf50; - } - - &.fail { - mat-icon { - color: #f44336; - } - - .error-details { - margin-left: 8px; - font-size: 12px; - color: #f44336; - max-width: 300px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - &.running mat-icon { - color: #2196f3; - animation: spin 1s linear infinite; - } - } - } - } - } - } - } - - .empty-state { - text-align: center; - padding: 60px 20px; - color: #666; - - mat-icon { - font-size: 64px; - width: 64px; - height: 64px; - margin-bottom: 16px; - opacity: 0.3; - } - - h3 { - margin: 0 0 8px 0; - font-weight: normal; - } - - p { - margin: 0; - font-size: 14px; - } - } -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -// Material overrides -::ng-deep { - .mat-mdc-list-item { - height: auto !important; - } - - .mat-expansion-panel-header { - height: 64px; - } - - .mat-expansion-panel-header-title { - align-items: center; - } +.test-container { + padding: 24px; + max-width: 1200px; + margin: 0 auto; + + .header { + margin-bottom: 32px; + + h2 { + margin: 0 0 8px 0; + display: flex; + align-items: center; + gap: 12px; + + mat-icon { + color: #666; + vertical-align: middle; + } + } + + p { + color: #666; + margin: 0; + } + } + + .actions { + display: flex; + gap: 16px; + align-items: center; + margin-bottom: 24px; + + .run-buttons { + display: flex; + gap: 12px; + align-items: center; + + .selected-count { + color: #666; + font-size: 14px; + margin-left: 8px; + } + } + + .select-all { + margin-left: auto; + display: flex; + align-items: center; + + mat-checkbox { + vertical-align: middle; + } + } + } + + .test-progress { + margin-bottom: 32px; + + mat-progress-bar { + margin-bottom: 8px; + } + + .progress-info { + display: flex; + justify-content: space-between; + align-items: center; + + .current-test { + color: #666; + font-size: 14px; + } + + .test-stats { + display: flex; + gap: 16px; + font-size: 14px; + + .stat { + display: flex; + align-items: center; + gap: 4px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + vertical-align: middle; + } + + &.passed { + color: #4caf50; + } + + &.failed { + color: #f44336; + } + } + } + } + } + + .test-categories { + mat-expansion-panel { + margin-bottom: 8px; + + mat-expansion-panel-header { + padding: 0 24px; + + .category-header { + display: flex; + align-items: center; + width: 100%; + + mat-checkbox { + margin-right: 16px; + vertical-align: middle; + } + + .category-info { + flex: 1; + + .category-name { + font-weight: 500; + } + } + + .category-stats { + display: flex; + gap: 12px; + align-items: center; + + mat-chip { + min-height: 24px; + font-size: 12px; + } + } + } + } + + .test-list { + padding: 0 24px 16px 24px; + + mat-list-item { + height: auto; + padding: 8px 0; + + .test-item { + display: flex; + align-items: center; + width: 100%; + gap: 16px; + + mat-checkbox { + flex-shrink: 0; + vertical-align: middle; + } + + .test-name { + flex: 1; + font-size: 14px; + } + + .test-result { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + vertical-align: middle; + } + + .duration { + font-size: 12px; + color: #666; + } + + &.pass mat-icon { + color: #4caf50; + } + + &.fail { + mat-icon { + color: #f44336; + } + + .error-details { + margin-left: 8px; + font-size: 12px; + color: #f44336; + max-width: 300px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &.running mat-icon { + color: #2196f3; + animation: spin 1s linear infinite; + } + } + } + } + } + } + } + + .empty-state { + text-align: center; + padding: 60px 20px; + color: #666; + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + margin-bottom: 16px; + opacity: 0.3; + } + + h3 { + margin: 0 0 8px 0; + font-weight: normal; + } + + p { + margin: 0; + font-size: 14px; + } + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +// Material overrides +::ng-deep { + .mat-mdc-list-item { + height: auto !important; + } + + .mat-expansion-panel-header { + height: 64px; + } + + .mat-expansion-panel-header-title { + align-items: center; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/test/test.component.ts b/flare-ui/src/app/components/test/test.component.ts index bd8a31704bca6c68b3b6837b11da9d0056a487f9..4225a74d8c2bd206db8662b697421ce195b9f7b5 100644 --- a/flare-ui/src/app/components/test/test.component.ts +++ b/flare-ui/src/app/components/test/test.component.ts @@ -1,710 +1,710 @@ -import { Component, inject, OnInit, OnDestroy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatListModule } from '@angular/material/list'; -import { MatChipsModule } from '@angular/material/chips'; -import { MatCardModule } from '@angular/material/card'; -import { ApiService } from '../../services/api.service'; -import { AuthService } from '../../services/auth.service'; -import { HttpClient } from '@angular/common/http'; -import { Subject, takeUntil } from 'rxjs'; - -interface TestResult { - name: string; - status: 'PASS' | 'FAIL' | 'RUNNING' | 'SKIP'; - duration_ms?: number; - error?: string; - details?: string; -} - -interface TestCategory { - name: string; - displayName: string; - tests: TestCase[]; - selected: boolean; - expanded: boolean; -} - -interface TestCase { - name: string; - category: string; - selected: boolean; - testFn: () => Promise; -} - -@Component({ - selector: 'app-test', - standalone: true, - imports: [ - CommonModule, - FormsModule, - MatProgressBarModule, - MatCheckboxModule, - MatButtonModule, - MatIconModule, - MatExpansionModule, - MatListModule, - MatChipsModule, - MatCardModule - ], - templateUrl: './test.component.html', - styleUrls: ['./test.component.scss'] -}) -export class TestComponent implements OnInit, OnDestroy { - private apiService = inject(ApiService); - private authService = inject(AuthService); - private http = inject(HttpClient); - private destroyed$ = new Subject(); - - running = false; - currentTest: string = ''; - testResults: TestResult[] = []; - - categories: TestCategory[] = [ - { - name: 'auth', - displayName: 'Authentication Tests', - tests: [], - selected: true, - expanded: false - }, - { - name: 'api', - displayName: 'API Endpoint Tests', - tests: [], - selected: true, - expanded: false - }, - { - name: 'validation', - displayName: 'Validation Tests', - tests: [], - selected: true, - expanded: false - }, - { - name: 'integration', - displayName: 'Integration Tests', - tests: [], - selected: true, - expanded: false - } - ]; - - allSelected = false; - - get selectedTests(): TestCase[] { - return this.categories - .filter(c => c.selected) - .flatMap(c => c.tests); - } - - get totalTests(): number { - return this.categories.reduce((sum, c) => sum + c.tests.length, 0); - } - - get passedTests(): number { - return this.testResults.filter(r => r.status === 'PASS').length; - } - - get failedTests(): number { - return this.testResults.filter(r => r.status === 'FAIL').length; - } - - get progress(): number { - if (this.testResults.length === 0) return 0; - return (this.testResults.length / this.selectedTests.length) * 100; - } - - ngOnInit() { - this.initializeTests(); - this.updateAllSelected(); - } - - ngOnDestroy() { - this.destroyed$.next(); - this.destroyed$.complete(); - } - - updateAllSelected() { - this.allSelected = this.categories.length > 0 && this.categories.every(c => c.selected); - } - - onCategorySelectionChange() { - this.updateAllSelected(); - } - - // Helper method to ensure authentication - private ensureAuth(): Promise { - return new Promise((resolve) => { - try { - // Check if we already have a valid token - const token = this.authService.getToken(); - if (token) { - // Try to make a simple authenticated request to verify token is still valid - this.apiService.getEnvironment() - .pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: () => resolve(true), - error: (error: any) => { - if (error.status === 401) { - // Token expired, need to re-login - this.authService.logout(); - resolve(false); - } else { - // Other error, assume auth is ok - resolve(true); - } - } - }); - } else { - // Login with test credentials - this.http.post('/api/admin/login', { - username: 'admin', - password: 'admin' - }).pipe(takeUntil(this.destroyed$)) - .subscribe({ - next: (response: any) => { - if (response?.token) { - this.authService.setToken(response.token); - this.authService.setUsername(response.username); - resolve(true); - } else { - resolve(false); - } - }, - error: () => resolve(false) - }); - } - } catch { - resolve(false); - } - }); - } - - initializeTests() { - // Authentication Tests - this.addTest('auth', 'Login with valid credentials', async () => { - const start = Date.now(); - try { - const response = await this.http.post('/api/login', { - username: 'admin', - password: 'admin' - }).toPromise() as any; - - return { - name: 'Login with valid credentials', - status: response?.token ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: response?.token ? 'Successfully authenticated' : 'No token received' - }; - } catch (error) { - return { - name: 'Login with valid credentials', - status: 'FAIL', - error: 'Login failed', - duration_ms: Date.now() - start - }; - } - }); - - this.addTest('auth', 'Login with invalid credentials', async () => { - const start = Date.now(); - try { - await this.http.post('/api/login', { - username: 'admin', - password: 'wrong_password_12345' - }).toPromise(); - - return { - name: 'Login with invalid credentials', - status: 'FAIL', - error: 'Expected 401 but got success', - duration_ms: Date.now() - start - }; - } catch (error: any) { - return { - name: 'Login with invalid credentials', - status: error.status === 401 ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: error.status === 401 ? 'Correctly rejected invalid credentials' : `Unexpected status: ${error.status}` - }; - } - }); - - // API Endpoint Tests - this.addTest('api', 'GET /api/environment', async () => { - const start = Date.now(); - try { - if (!await this.ensureAuth()) { - return { - name: 'GET /api/environment', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - const response = await this.apiService.getEnvironment().toPromise(); - return { - name: 'GET /api/environment', - status: response?.work_mode ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: response?.work_mode ? `Work mode: ${response.work_mode}` : 'No work mode returned' - }; - } catch (error) { - return { - name: 'GET /api/environment', - status: 'FAIL', - error: 'Failed to get environment', - duration_ms: Date.now() - start - }; - } - }); - - this.addTest('api', 'GET /api/projects', async () => { - const start = Date.now(); - try { - if (!await this.ensureAuth()) { - return { - name: 'GET /api/projects', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - const response = await this.apiService.getProjects().toPromise(); - return { - name: 'GET /api/projects', - status: Array.isArray(response) ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: Array.isArray(response) ? `Retrieved ${response.length} projects` : 'Invalid response format' - }; - } catch (error) { - return { - name: 'GET /api/projects', - status: 'FAIL', - error: 'Failed to get projects', - duration_ms: Date.now() - start - }; - } - }); - - this.addTest('api', 'GET /api/apis', async () => { - const start = Date.now(); - try { - if (!await this.ensureAuth()) { - return { - name: 'GET /api/apis', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - const response = await this.apiService.getAPIs().toPromise(); - return { - name: 'GET /api/apis', - status: Array.isArray(response) ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: Array.isArray(response) ? `Retrieved ${response.length} APIs` : 'Invalid response format' - }; - } catch (error) { - return { - name: 'GET /api/apis', - status: 'FAIL', - error: 'Failed to get APIs', - duration_ms: Date.now() - start - }; - } - }); - - // Integration Tests - this.addTest('integration', 'Create and delete project', async () => { - const start = Date.now(); - let projectId: number | undefined = undefined; - - try { - // Ensure we're authenticated - if (!await this.ensureAuth()) { - return { - name: 'Create and delete project', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - // Create test project - const testProjectName = `test_project_${Date.now()}`; - const createResponse = await this.apiService.createProject({ - name: testProjectName, - caption: 'Test Project for Integration Test', - icon: 'folder', - description: 'This is a test project', - default_language: 'Turkish', - supported_languages: ['tr'], - timezone: 'Europe/Istanbul', - region: 'tr-TR' - }).toPromise() as any; - - if (!createResponse?.id) { - throw new Error('Project creation failed - no ID returned'); - } - - projectId = createResponse.id; - - // Verify project was created - const projects = await this.apiService.getProjects().toPromise() as any[]; - const createdProject = projects.find(p => p.id === projectId); - - if (!createdProject) { - throw new Error('Created project not found in project list'); - } - - // Delete project - await this.apiService.deleteProject(projectId!).toPromise(); - - // Verify project was soft deleted - const projectsAfterDelete = await this.apiService.getProjects().toPromise() as any[]; - const deletedProject = projectsAfterDelete.find(p => p.id === projectId); - - if (deletedProject) { - throw new Error('Project still visible after deletion'); - } - - return { - name: 'Create and delete project', - status: 'PASS', - duration_ms: Date.now() - start, - details: `Successfully created and deleted project: ${testProjectName}` - }; - } catch (error: any) { - // Try to clean up if project was created - if (projectId !== undefined) { - try { - await this.apiService.deleteProject(projectId).toPromise(); - } catch {} - } - - return { - name: 'Create and delete project', - status: 'FAIL', - error: error.message || 'Test failed', - duration_ms: Date.now() - start - }; - } - }); - - this.addTest('integration', 'API used in intent cannot be deleted', async () => { - const start = Date.now(); - let testApiName: string | undefined; - let testProjectId: number | undefined; - - try { - // Ensure we're authenticated - if (!await this.ensureAuth()) { - return { - name: 'API used in intent cannot be deleted', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - // 1. Create test API - testApiName = `test_api_${Date.now()}`; - await this.apiService.createAPI({ - name: testApiName, - url: 'https://test.example.com/api', - method: 'POST', - timeout_seconds: 10, - headers: { 'Content-Type': 'application/json' }, - body_template: {}, - retry: { - retry_count: 3, - backoff_seconds: 2, - strategy: 'static' - } - }).toPromise(); - - // 2. Create test project - const testProjectName = `test_project_${Date.now()}`; - const createProjectResponse = await this.apiService.createProject({ - name: testProjectName, - caption: 'Test Project', - icon: 'folder', - description: 'Test project for API deletion test', - default_language: 'Turkish', - supported_languages: ['tr'], - timezone: 'Europe/Istanbul', - region: 'tr-TR' - }).toPromise() as any; - - if (!createProjectResponse?.id) { - throw new Error('Project creation failed'); - } - - testProjectId = createProjectResponse.id; - - // 3. Get the first version - const version = createProjectResponse.versions[0]; - if (!version) { - throw new Error('No version found in created project'); - } - - // 4. Update the version to add an intent that uses our API - // testProjectId is guaranteed to be a number here - await this.apiService.updateVersion(testProjectId!, version.id, { - caption: version.caption, - general_prompt: 'Test prompt', - llm: version.llm, - intents: [{ - name: 'test-intent', - caption: 'Test Intent', - locale: 'tr-TR', - detection_prompt: 'Test detection', - examples: ['test example'], - parameters: [], - action: testApiName, - fallback_timeout_prompt: 'Timeout', - fallback_error_prompt: 'Error' - }], - last_update_date: version.last_update_date - }).toPromise(); - - // 5. Try to delete the API - this should fail with 400 - try { - await this.apiService.deleteAPI(testApiName).toPromise(); - - // If deletion succeeded, test failed - return { - name: 'API used in intent cannot be deleted', - status: 'FAIL', - error: 'API was deleted even though it was in use', - duration_ms: Date.now() - start - }; - } catch (deleteError: any) { - // Check if we got the expected 400 error - const errorMessage = deleteError.error?.detail || deleteError.message || ''; - const isExpectedError = deleteError.status === 400 && - errorMessage.includes('API is used'); - - if (!isExpectedError) { - console.error('Delete API Error Details:', { - status: deleteError.status, - error: deleteError.error, - message: errorMessage - }); - } - - return { - name: 'API used in intent cannot be deleted', - status: isExpectedError ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: isExpectedError - ? 'Correctly prevented deletion of API in use' - : `Unexpected error: Status ${deleteError.status}, Message: ${errorMessage}` - }; - } - } catch (setupError: any) { - return { - name: 'API used in intent cannot be deleted', - status: 'FAIL', - error: `Test setup failed: ${setupError.message || setupError}`, - duration_ms: Date.now() - start - }; - } finally { - // Cleanup: first delete project, then API - try { - if (testProjectId !== undefined) { - await this.apiService.deleteProject(testProjectId).toPromise(); - } - } catch {} - - try { - if (testApiName) { - await this.apiService.deleteAPI(testApiName).toPromise(); - } - } catch {} - } - }); - - // Validation Tests - this.addTest('validation', 'Regex validation - valid pattern', async () => { - const start = Date.now(); - try { - if (!await this.ensureAuth()) { - return { - name: 'Regex validation - valid pattern', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - const response = await this.apiService.validateRegex('^[A-Z]{3}$', 'ABC').toPromise() as any; - return { - name: 'Regex validation - valid pattern', - status: response?.valid && response?.matches ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: response?.valid && response?.matches - ? 'Pattern matched successfully' - : 'Pattern did not match or validation failed' - }; - } catch (error) { - return { - name: 'Regex validation - valid pattern', - status: 'FAIL', - error: 'Validation endpoint failed', - duration_ms: Date.now() - start - }; - } - }); - - this.addTest('validation', 'Regex validation - invalid pattern', async () => { - const start = Date.now(); - try { - if (!await this.ensureAuth()) { - return { - name: 'Regex validation - invalid pattern', - status: 'SKIP', - error: 'Authentication failed', - duration_ms: Date.now() - start - }; - } - - const response = await this.apiService.validateRegex('[invalid', 'test').toPromise() as any; - return { - name: 'Regex validation - invalid pattern', - status: !response?.valid ? 'PASS' : 'FAIL', - duration_ms: Date.now() - start, - details: !response?.valid - ? 'Correctly identified invalid regex' - : 'Failed to identify invalid regex' - }; - } catch (error: any) { - // Some errors are expected for invalid regex - return { - name: 'Regex validation - invalid pattern', - status: 'PASS', - duration_ms: Date.now() - start, - details: 'Correctly rejected invalid regex' - }; - } - }); - - // Update test counts - this.categories.forEach(cat => { - const originalName = cat.displayName.split(' (')[0]; - cat.displayName = `${originalName} (${cat.tests.length} tests)`; - }); - } - - private addTest(category: string, name: string, testFn: () => Promise) { - const cat = this.categories.find(c => c.name === category); - if (cat) { - cat.tests.push({ - name, - category, - selected: true, - testFn - }); - } - } - - toggleAll() { - this.allSelected = !this.allSelected; - this.categories.forEach(c => c.selected = this.allSelected); - } - - async runAllTests() { - this.categories.forEach(c => c.selected = true); - await this.runTests(); - } - - async runSelectedTests() { - await this.runTests(); - } - - async runTests() { - if (this.running || this.selectedTests.length === 0) return; - - this.running = true; - this.testResults = []; - this.currentTest = ''; - - try { - // Ensure we're authenticated before running tests - const authOk = await this.ensureAuth(); - if (!authOk) { - this.testResults.push({ - name: 'Authentication', - status: 'FAIL', - error: 'Failed to authenticate for tests', - duration_ms: 0 - }); - this.running = false; - return; - } - - // Run selected tests - for (const test of this.selectedTests) { - if (!this.running) break; // Allow cancellation - - this.currentTest = test.name; - - try { - const result = await test.testFn(); - this.testResults.push(result); - } catch (error: any) { - // Catch any uncaught errors from test - this.testResults.push({ - name: test.name, - status: 'FAIL', - error: error.message || 'Test threw an exception', - duration_ms: 0 - }); - } - } - } catch (error: any) { - console.error('Test runner error:', error); - this.testResults.push({ - name: 'Test Runner', - status: 'FAIL', - error: 'Test runner encountered an error', - duration_ms: 0 - }); - } finally { - this.running = false; - this.currentTest = ''; - } - } - - stopTests() { - this.running = false; - this.currentTest = ''; - } - - getTestResult(testName: string): TestResult | undefined { - return this.testResults.find(r => r.name === testName); - } - - getCategoryResults(category: TestCategory): { passed: number; failed: number; total: number } { - const categoryResults = this.testResults.filter(r => - category.tests.some(t => t.name === r.name) - ); - - return { - passed: categoryResults.filter(r => r.status === 'PASS').length, - failed: categoryResults.filter(r => r.status === 'FAIL').length, - total: category.tests.length - }; - } +import { Component, inject, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatListModule } from '@angular/material/list'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatCardModule } from '@angular/material/card'; +import { ApiService } from '../../services/api.service'; +import { AuthService } from '../../services/auth.service'; +import { HttpClient } from '@angular/common/http'; +import { Subject, takeUntil } from 'rxjs'; + +interface TestResult { + name: string; + status: 'PASS' | 'FAIL' | 'RUNNING' | 'SKIP'; + duration_ms?: number; + error?: string; + details?: string; +} + +interface TestCategory { + name: string; + displayName: string; + tests: TestCase[]; + selected: boolean; + expanded: boolean; +} + +interface TestCase { + name: string; + category: string; + selected: boolean; + testFn: () => Promise; +} + +@Component({ + selector: 'app-test', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatProgressBarModule, + MatCheckboxModule, + MatButtonModule, + MatIconModule, + MatExpansionModule, + MatListModule, + MatChipsModule, + MatCardModule + ], + templateUrl: './test.component.html', + styleUrls: ['./test.component.scss'] +}) +export class TestComponent implements OnInit, OnDestroy { + private apiService = inject(ApiService); + private authService = inject(AuthService); + private http = inject(HttpClient); + private destroyed$ = new Subject(); + + running = false; + currentTest: string = ''; + testResults: TestResult[] = []; + + categories: TestCategory[] = [ + { + name: 'auth', + displayName: 'Authentication Tests', + tests: [], + selected: true, + expanded: false + }, + { + name: 'api', + displayName: 'API Endpoint Tests', + tests: [], + selected: true, + expanded: false + }, + { + name: 'validation', + displayName: 'Validation Tests', + tests: [], + selected: true, + expanded: false + }, + { + name: 'integration', + displayName: 'Integration Tests', + tests: [], + selected: true, + expanded: false + } + ]; + + allSelected = false; + + get selectedTests(): TestCase[] { + return this.categories + .filter(c => c.selected) + .flatMap(c => c.tests); + } + + get totalTests(): number { + return this.categories.reduce((sum, c) => sum + c.tests.length, 0); + } + + get passedTests(): number { + return this.testResults.filter(r => r.status === 'PASS').length; + } + + get failedTests(): number { + return this.testResults.filter(r => r.status === 'FAIL').length; + } + + get progress(): number { + if (this.testResults.length === 0) return 0; + return (this.testResults.length / this.selectedTests.length) * 100; + } + + ngOnInit() { + this.initializeTests(); + this.updateAllSelected(); + } + + ngOnDestroy() { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + updateAllSelected() { + this.allSelected = this.categories.length > 0 && this.categories.every(c => c.selected); + } + + onCategorySelectionChange() { + this.updateAllSelected(); + } + + // Helper method to ensure authentication + private ensureAuth(): Promise { + return new Promise((resolve) => { + try { + // Check if we already have a valid token + const token = this.authService.getToken(); + if (token) { + // Try to make a simple authenticated request to verify token is still valid + this.apiService.getEnvironment() + .pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: () => resolve(true), + error: (error: any) => { + if (error.status === 401) { + // Token expired, need to re-login + this.authService.logout(); + resolve(false); + } else { + // Other error, assume auth is ok + resolve(true); + } + } + }); + } else { + // Login with test credentials + this.http.post('/api/admin/login', { + username: 'admin', + password: 'admin' + }).pipe(takeUntil(this.destroyed$)) + .subscribe({ + next: (response: any) => { + if (response?.token) { + this.authService.setToken(response.token); + this.authService.setUsername(response.username); + resolve(true); + } else { + resolve(false); + } + }, + error: () => resolve(false) + }); + } + } catch { + resolve(false); + } + }); + } + + initializeTests() { + // Authentication Tests + this.addTest('auth', 'Login with valid credentials', async () => { + const start = Date.now(); + try { + const response = await this.http.post('/api/login', { + username: 'admin', + password: 'admin' + }).toPromise() as any; + + return { + name: 'Login with valid credentials', + status: response?.token ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: response?.token ? 'Successfully authenticated' : 'No token received' + }; + } catch (error) { + return { + name: 'Login with valid credentials', + status: 'FAIL', + error: 'Login failed', + duration_ms: Date.now() - start + }; + } + }); + + this.addTest('auth', 'Login with invalid credentials', async () => { + const start = Date.now(); + try { + await this.http.post('/api/login', { + username: 'admin', + password: 'wrong_password_12345' + }).toPromise(); + + return { + name: 'Login with invalid credentials', + status: 'FAIL', + error: 'Expected 401 but got success', + duration_ms: Date.now() - start + }; + } catch (error: any) { + return { + name: 'Login with invalid credentials', + status: error.status === 401 ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: error.status === 401 ? 'Correctly rejected invalid credentials' : `Unexpected status: ${error.status}` + }; + } + }); + + // API Endpoint Tests + this.addTest('api', 'GET /api/environment', async () => { + const start = Date.now(); + try { + if (!await this.ensureAuth()) { + return { + name: 'GET /api/environment', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + const response = await this.apiService.getEnvironment().toPromise(); + return { + name: 'GET /api/environment', + status: response?.work_mode ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: response?.work_mode ? `Work mode: ${response.work_mode}` : 'No work mode returned' + }; + } catch (error) { + return { + name: 'GET /api/environment', + status: 'FAIL', + error: 'Failed to get environment', + duration_ms: Date.now() - start + }; + } + }); + + this.addTest('api', 'GET /api/projects', async () => { + const start = Date.now(); + try { + if (!await this.ensureAuth()) { + return { + name: 'GET /api/projects', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + const response = await this.apiService.getProjects().toPromise(); + return { + name: 'GET /api/projects', + status: Array.isArray(response) ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: Array.isArray(response) ? `Retrieved ${response.length} projects` : 'Invalid response format' + }; + } catch (error) { + return { + name: 'GET /api/projects', + status: 'FAIL', + error: 'Failed to get projects', + duration_ms: Date.now() - start + }; + } + }); + + this.addTest('api', 'GET /api/apis', async () => { + const start = Date.now(); + try { + if (!await this.ensureAuth()) { + return { + name: 'GET /api/apis', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + const response = await this.apiService.getAPIs().toPromise(); + return { + name: 'GET /api/apis', + status: Array.isArray(response) ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: Array.isArray(response) ? `Retrieved ${response.length} APIs` : 'Invalid response format' + }; + } catch (error) { + return { + name: 'GET /api/apis', + status: 'FAIL', + error: 'Failed to get APIs', + duration_ms: Date.now() - start + }; + } + }); + + // Integration Tests + this.addTest('integration', 'Create and delete project', async () => { + const start = Date.now(); + let projectId: number | undefined = undefined; + + try { + // Ensure we're authenticated + if (!await this.ensureAuth()) { + return { + name: 'Create and delete project', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + // Create test project + const testProjectName = `test_project_${Date.now()}`; + const createResponse = await this.apiService.createProject({ + name: testProjectName, + caption: 'Test Project for Integration Test', + icon: 'folder', + description: 'This is a test project', + default_language: 'Turkish', + supported_languages: ['tr'], + timezone: 'Europe/Istanbul', + region: 'tr-TR' + }).toPromise() as any; + + if (!createResponse?.id) { + throw new Error('Project creation failed - no ID returned'); + } + + projectId = createResponse.id; + + // Verify project was created + const projects = await this.apiService.getProjects().toPromise() as any[]; + const createdProject = projects.find(p => p.id === projectId); + + if (!createdProject) { + throw new Error('Created project not found in project list'); + } + + // Delete project + await this.apiService.deleteProject(projectId!).toPromise(); + + // Verify project was soft deleted + const projectsAfterDelete = await this.apiService.getProjects().toPromise() as any[]; + const deletedProject = projectsAfterDelete.find(p => p.id === projectId); + + if (deletedProject) { + throw new Error('Project still visible after deletion'); + } + + return { + name: 'Create and delete project', + status: 'PASS', + duration_ms: Date.now() - start, + details: `Successfully created and deleted project: ${testProjectName}` + }; + } catch (error: any) { + // Try to clean up if project was created + if (projectId !== undefined) { + try { + await this.apiService.deleteProject(projectId).toPromise(); + } catch {} + } + + return { + name: 'Create and delete project', + status: 'FAIL', + error: error.message || 'Test failed', + duration_ms: Date.now() - start + }; + } + }); + + this.addTest('integration', 'API used in intent cannot be deleted', async () => { + const start = Date.now(); + let testApiName: string | undefined; + let testProjectId: number | undefined; + + try { + // Ensure we're authenticated + if (!await this.ensureAuth()) { + return { + name: 'API used in intent cannot be deleted', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + // 1. Create test API + testApiName = `test_api_${Date.now()}`; + await this.apiService.createAPI({ + name: testApiName, + url: 'https://test.example.com/api', + method: 'POST', + timeout_seconds: 10, + headers: { 'Content-Type': 'application/json' }, + body_template: {}, + retry: { + retry_count: 3, + backoff_seconds: 2, + strategy: 'static' + } + }).toPromise(); + + // 2. Create test project + const testProjectName = `test_project_${Date.now()}`; + const createProjectResponse = await this.apiService.createProject({ + name: testProjectName, + caption: 'Test Project', + icon: 'folder', + description: 'Test project for API deletion test', + default_language: 'Turkish', + supported_languages: ['tr'], + timezone: 'Europe/Istanbul', + region: 'tr-TR' + }).toPromise() as any; + + if (!createProjectResponse?.id) { + throw new Error('Project creation failed'); + } + + testProjectId = createProjectResponse.id; + + // 3. Get the first version + const version = createProjectResponse.versions[0]; + if (!version) { + throw new Error('No version found in created project'); + } + + // 4. Update the version to add an intent that uses our API + // testProjectId is guaranteed to be a number here + await this.apiService.updateVersion(testProjectId!, version.id, { + caption: version.caption, + general_prompt: 'Test prompt', + llm: version.llm, + intents: [{ + name: 'test-intent', + caption: 'Test Intent', + locale: 'tr-TR', + detection_prompt: 'Test detection', + examples: ['test example'], + parameters: [], + action: testApiName, + fallback_timeout_prompt: 'Timeout', + fallback_error_prompt: 'Error' + }], + last_update_date: version.last_update_date + }).toPromise(); + + // 5. Try to delete the API - this should fail with 400 + try { + await this.apiService.deleteAPI(testApiName).toPromise(); + + // If deletion succeeded, test failed + return { + name: 'API used in intent cannot be deleted', + status: 'FAIL', + error: 'API was deleted even though it was in use', + duration_ms: Date.now() - start + }; + } catch (deleteError: any) { + // Check if we got the expected 400 error + const errorMessage = deleteError.error?.detail || deleteError.message || ''; + const isExpectedError = deleteError.status === 400 && + errorMessage.includes('API is used'); + + if (!isExpectedError) { + console.error('Delete API Error Details:', { + status: deleteError.status, + error: deleteError.error, + message: errorMessage + }); + } + + return { + name: 'API used in intent cannot be deleted', + status: isExpectedError ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: isExpectedError + ? 'Correctly prevented deletion of API in use' + : `Unexpected error: Status ${deleteError.status}, Message: ${errorMessage}` + }; + } + } catch (setupError: any) { + return { + name: 'API used in intent cannot be deleted', + status: 'FAIL', + error: `Test setup failed: ${setupError.message || setupError}`, + duration_ms: Date.now() - start + }; + } finally { + // Cleanup: first delete project, then API + try { + if (testProjectId !== undefined) { + await this.apiService.deleteProject(testProjectId).toPromise(); + } + } catch {} + + try { + if (testApiName) { + await this.apiService.deleteAPI(testApiName).toPromise(); + } + } catch {} + } + }); + + // Validation Tests + this.addTest('validation', 'Regex validation - valid pattern', async () => { + const start = Date.now(); + try { + if (!await this.ensureAuth()) { + return { + name: 'Regex validation - valid pattern', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + const response = await this.apiService.validateRegex('^[A-Z]{3}$', 'ABC').toPromise() as any; + return { + name: 'Regex validation - valid pattern', + status: response?.valid && response?.matches ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: response?.valid && response?.matches + ? 'Pattern matched successfully' + : 'Pattern did not match or validation failed' + }; + } catch (error) { + return { + name: 'Regex validation - valid pattern', + status: 'FAIL', + error: 'Validation endpoint failed', + duration_ms: Date.now() - start + }; + } + }); + + this.addTest('validation', 'Regex validation - invalid pattern', async () => { + const start = Date.now(); + try { + if (!await this.ensureAuth()) { + return { + name: 'Regex validation - invalid pattern', + status: 'SKIP', + error: 'Authentication failed', + duration_ms: Date.now() - start + }; + } + + const response = await this.apiService.validateRegex('[invalid', 'test').toPromise() as any; + return { + name: 'Regex validation - invalid pattern', + status: !response?.valid ? 'PASS' : 'FAIL', + duration_ms: Date.now() - start, + details: !response?.valid + ? 'Correctly identified invalid regex' + : 'Failed to identify invalid regex' + }; + } catch (error: any) { + // Some errors are expected for invalid regex + return { + name: 'Regex validation - invalid pattern', + status: 'PASS', + duration_ms: Date.now() - start, + details: 'Correctly rejected invalid regex' + }; + } + }); + + // Update test counts + this.categories.forEach(cat => { + const originalName = cat.displayName.split(' (')[0]; + cat.displayName = `${originalName} (${cat.tests.length} tests)`; + }); + } + + private addTest(category: string, name: string, testFn: () => Promise) { + const cat = this.categories.find(c => c.name === category); + if (cat) { + cat.tests.push({ + name, + category, + selected: true, + testFn + }); + } + } + + toggleAll() { + this.allSelected = !this.allSelected; + this.categories.forEach(c => c.selected = this.allSelected); + } + + async runAllTests() { + this.categories.forEach(c => c.selected = true); + await this.runTests(); + } + + async runSelectedTests() { + await this.runTests(); + } + + async runTests() { + if (this.running || this.selectedTests.length === 0) return; + + this.running = true; + this.testResults = []; + this.currentTest = ''; + + try { + // Ensure we're authenticated before running tests + const authOk = await this.ensureAuth(); + if (!authOk) { + this.testResults.push({ + name: 'Authentication', + status: 'FAIL', + error: 'Failed to authenticate for tests', + duration_ms: 0 + }); + this.running = false; + return; + } + + // Run selected tests + for (const test of this.selectedTests) { + if (!this.running) break; // Allow cancellation + + this.currentTest = test.name; + + try { + const result = await test.testFn(); + this.testResults.push(result); + } catch (error: any) { + // Catch any uncaught errors from test + this.testResults.push({ + name: test.name, + status: 'FAIL', + error: error.message || 'Test threw an exception', + duration_ms: 0 + }); + } + } + } catch (error: any) { + console.error('Test runner error:', error); + this.testResults.push({ + name: 'Test Runner', + status: 'FAIL', + error: 'Test runner encountered an error', + duration_ms: 0 + }); + } finally { + this.running = false; + this.currentTest = ''; + } + } + + stopTests() { + this.running = false; + this.currentTest = ''; + } + + getTestResult(testName: string): TestResult | undefined { + return this.testResults.find(r => r.name === testName); + } + + getCategoryResults(category: TestCategory): { passed: number; failed: number; total: number } { + const categoryResults = this.testResults.filter(r => + category.tests.some(t => t.name === r.name) + ); + + return { + passed: categoryResults.filter(r => r.status === 'PASS').length, + failed: categoryResults.filter(r => r.status === 'FAIL').length, + total: category.tests.length + }; + } } \ No newline at end of file diff --git a/flare-ui/src/app/components/user-info/user-info.component.html b/flare-ui/src/app/components/user-info/user-info.component.html index 0e1b1cf2436f9825f4f4acab677642645155de31..b4179e7ba7897511fd745509a55510213ef79ced 100644 --- a/flare-ui/src/app/components/user-info/user-info.component.html +++ b/flare-ui/src/app/components/user-info/user-info.component.html @@ -1,83 +1,83 @@ -