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: `
-
- `,
- 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: `
+
+ `,
+ 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
-
-
- close
-
-
-
-
- @if (loading && activities.length === 0) {
-
-
-
- } @else if (error && activities.length === 0) {
-
-
error_outline
-
{{ error }}
-
- refresh
- Retry
-
-
- } @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) {
-
- }
- }
-
- }
-
-
- pageSize">
-
-
-
-
- 0">
-
- open_in_new
- View All Activities
-
-
-
- `,
- 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
+
+
+ close
+
+
+
+
+ @if (loading && activities.length === 0) {
+
+
+
+ } @else if (error && activities.length === 0) {
+
+
error_outline
+
{{ error }}
+
+ refresh
+ Retry
+
+
+ } @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) {
+
+ }
+ }
+
+ }
+
+
+ pageSize">
+
+
+
+
+ 0">
+
+ open_in_new
+ View All Activities
+
+
+
+ `,
+ 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: `
-
-
-
-
-
- @if (!loading && error) {
-
-
error_outline
-
{{ error }}
-
- refresh
- Retry
-
-
- } @else if (!loading && filteredAPIs.length === 0 && !searchTerm) {
-
-
api
-
No APIs found.
-
- Create your first API
-
-
- } @else if (!loading && filteredAPIs.length === 0 && searchTerm) {
-
-
search_off
-
No APIs match your search.
-
- Clear search
-
-
- } @else if (!loading) {
-
-
-
- Name
- {{ api.name }}
-
-
-
-
- URL
-
- {{ api.url }}
-
-
-
-
-
- Method
-
-
- {{ api.method }}
-
-
-
-
-
-
- Timeout
- {{ api.timeout_seconds }}s
-
-
-
-
- Auth
-
-
- {{ api.auth?.enabled ? 'lock' : 'lock_open' }}
-
-
-
-
-
-
- Status
-
- @if (api.deleted) {
- delete
- } @else {
- check_circle
- }
-
-
-
-
-
- Actions
-
-
- @if (actionLoading[api.name]) {
-
- } @else {
- more_vert
- }
-
-
-
- edit
- Edit
-
-
- play_arrow
- Test
-
-
- content_copy
- Duplicate
-
- @if (!api.deleted) {
-
-
- delete
- Delete
-
- } @else {
-
-
- restore
- Restore
-
- }
-
-
-
-
-
-
-
- }
-
- `,
- 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: `
+
+
+
+
+
+ @if (!loading && error) {
+
+
error_outline
+
{{ error }}
+
+ refresh
+ Retry
+
+
+ } @else if (!loading && filteredAPIs.length === 0 && !searchTerm) {
+
+
api
+
No APIs found.
+
+ Create your first API
+
+
+ } @else if (!loading && filteredAPIs.length === 0 && searchTerm) {
+
+
search_off
+
No APIs match your search.
+
+ Clear search
+
+
+ } @else if (!loading) {
+
+
+
+ Name
+ {{ api.name }}
+
+
+
+
+ URL
+
+ {{ api.url }}
+
+
+
+
+
+ Method
+
+
+ {{ api.method }}
+
+
+
+
+
+
+ Timeout
+ {{ api.timeout_seconds }}s
+
+
+
+
+ Auth
+
+
+ {{ api.auth?.enabled ? 'lock' : 'lock_open' }}
+
+
+
+
+
+
+ Status
+
+ @if (api.deleted) {
+ delete
+ } @else {
+ check_circle
+ }
+
+
+
+
+
+ Actions
+
+
+ @if (actionLoading[api.name]) {
+
+ } @else {
+ more_vert
+ }
+
+
+
+ edit
+ Edit
+
+
+ play_arrow
+ Test
+
+
+ content_copy
+ Duplicate
+
+ @if (!api.deleted) {
+
+
+ delete
+ Delete
+
+ } @else {
+
+
+ restore
+ Restore
+
+ }
+
+
+
+
+
+
+
+ }
+
+ `,
+ 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.
-
-
-
-
-
- chat
- Start Chat
-
-
-
- mic
- Real-time Chat
-
-
-
-
-
-
-
-
-
- smart_toy
- {{ selectedProject }}
- Session: {{ sessionId.substring(0, 8) }}...
-
- record_voice_over
-
- close
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ msg.author === 'user' ? 'person' : 'smart_toy' }}
-
-
- {{ msg.text }}
-
- play_arrow
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ 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.
+
+
+
+
+
+ chat
+ Start Chat
+
+
+
+ mic
+ Real-time Chat
+
+
+
+
+
+
+
+
+
+ smart_toy
+ {{ selectedProject }}
+ Session: {{ sessionId.substring(0, 8) }}...
+
+ record_voice_over
+
+ close
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ msg.author === 'user' ? 'person' : 'smart_toy' }}
+
+
+ {{ msg.text }}
+
+ play_arrow
+
+
+
+
+
+
+
+
+
+
+
+
\ 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) }}
-
-
-
-
- close
-
-
-
-
-
-
-
-
- error_outline
- {{ error }}
-
- refresh
-
-
-
-
-
-
-
- {{ msg.role === 'user' ? 'person' : msg.role === 'assistant' ? 'smart_toy' : 'info' }}
-
-
-
{{ msg.text }}
-
{{ msg.timestamp | date:'HH:mm:ss' }}
-
- {{ isPlayingAudio ? 'stop' : 'volume_up' }}
-
-
-
-
-
-
-
mic_off
-
Konuşmaya başlamak için aşağıdaki butona tıklayın
-
-
-
-
-
-
-
-
-
-
-
- @if (loading) {
-
- } @else {
- {{ isConversationActive ? 'stop' : 'mic' }}
- {{ isConversationActive ? 'Konuşmayı Bitir' : 'Konuşmaya Başla' }}
- }
-
-
-
- clear
- Temizle
-
-
-
-
-
+
+
+ voice_chat
+ Real-time Conversation
+
+
+
+ {{ getStateLabel(state) }}
+
+
+
+
+ close
+
+
+
+
+
+
+
+
+ error_outline
+ {{ error }}
+
+ refresh
+
+
+
+
+
+
+
+ {{ msg.role === 'user' ? 'person' : msg.role === 'assistant' ? 'smart_toy' : 'info' }}
+
+
+
{{ msg.text }}
+
{{ msg.timestamp | date:'HH:mm:ss' }}
+
+ {{ isPlayingAudio ? 'stop' : 'volume_up' }}
+
+
+
+
+
+
+
mic_off
+
Konuşmaya başlamak için aşağıdaki butona tıklayın
+
+
+
+
+
+
+
+
+
+
+
+ @if (loading) {
+
+ } @else {
+ {{ isConversationActive ? 'stop' : 'mic' }}
+ {{ isConversationActive ? 'Konuşmayı Bitir' : 'Konuşmaya Başla' }}
+ }
+
+
+
+ clear
+ Temizle
+
+
+
+
+
\ 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 {
-
- }
-
+
+
+
+ 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
+
+
+ wifi_tethering
+
+
+ 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
+
+
+
+ Max Parameters per Question
+
+
+
+ {{ parameterCollectionConfig.max_params_per_question }}
+
+
+
+ Show All Required Parameters
+
+
+
+ Ask for Optional Parameters
+
+
+
+ Group Related Parameters
+
+
+
+ Minimum Confidence Score
+
+
+
+ {{ parameterCollectionConfig.min_confidence_score }}
+
+
+
+ Collection Prompt Template
+
+
+ refresh
+
+
+
+
+ }
+
+
+
+
+
+
+
+ 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
+
+
+ }
+
+
+
+
+ save
+ {{ saving ? 'Saving...' : 'Save Configuration' }}
+
+
+
+
+ }
+
\ 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: `
-
- `,
- 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: `
+
+ `,
+ 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 @@
-
-
-
-
-
-
-
-
-
folder_open
-
No projects found.
-
- Create your first project
-
-
-
-
-
0">
-
-
-
- {{ 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) }}
-
-
-
-
-
- EDIT
- VERSIONS
- EXPORT
-
- {{ project.enabled ? 'DISABLE' : 'ENABLE' }}
-
-
-
-
-
-
-
0">
-
-
-
-
- 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
-
-
- more_vert
-
-
-
- edit
- Edit
-
-
- layers
- Manage Versions
-
-
- download
- Export
-
-
- {{ project.enabled ? 'block' : 'check_circle' }}
- {{ project.enabled ? 'Disable' : 'Enable' }}
-
-
-
- delete
- Delete
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ isError ? 'error' : 'check_circle' }}
- {{ message }}
-
-
-
+
+
+
+
+
+
+
+
+
folder_open
+
No projects found.
+
+ Create your first project
+
+
+
+
+
0">
+
+
+
+ {{ 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) }}
+
+
+
+
+
+ EDIT
+ VERSIONS
+ EXPORT
+
+ {{ project.enabled ? 'DISABLE' : 'ENABLE' }}
+
+
+
+
+
+
+
0">
+
+
+
+
+ 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
+
+
+ more_vert
+
+
+
+ edit
+ Edit
+
+
+ layers
+ Manage Versions
+
+
+ download
+ Export
+
+
+ {{ project.enabled ? 'block' : 'check_circle' }}
+ {{ project.enabled ? 'Disable' : 'Enable' }}
+
+
+
+ delete
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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
-
-
-
-
- rocket_launch
- Project Startup
-
-
-
- info
- Get Project Status
-
-
-
- power
- Enable Project
-
-
-
- power_off
- Disable Project
-
-
-
- delete
- Delete Project
-
-
-
- @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 }}
-
-
-
- Version
- v{{ 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
+
+
+
+
+ rocket_launch
+ Project Startup
+
+
+
+ info
+ Get Project Status
+
+
+
+ power
+ Enable Project
+
+
+
+ power_off
+ Disable Project
+
+
+
+ delete
+ Delete Project
+
+
+
+ @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 }}
+
+
+
+ Version
+ v{{ 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
-
-
-
- play_arrow
- Run All Tests
-
-
- play_circle_outline
- Run Selected ({{ selectedTests.length }})
-
-
- stop
- Stop
-
-
-
-
-
- All Tests ({{ totalTests }} tests)
-
-
-
-
-
-
-
- All Tests ({{ totalTests }} tests)
-
-
-
- 0">
-
- 0"
- class="success-chip">
- {{ getCategoryResults(category).passed }} passed
-
- 0"
- class="error-chip">
- {{ 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
-
-
-
-
-
-
-
-
0 || running">
-
- Test Progress
-
-
-
- 0 ? 'warn' : 'primary'">
-
-
-
-
- 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
+
+
+
+ play_arrow
+ Run All Tests
+
+
+ play_circle_outline
+ Run Selected ({{ selectedTests.length }})
+
+
+ stop
+ Stop
+
+
+
+
+
+ All Tests ({{ totalTests }} tests)
+
+
+
+
+
+
+
+ All Tests ({{ totalTests }} tests)
+
+
+
+ 0">
+
+ 0"
+ class="success-chip">
+ {{ getCategoryResults(category).passed }} passed
+
+ 0"
+ class="error-chip">
+ {{ 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
+
+
+
+
+
+
+
+
0 || running">
+
+ Test Progress
+
+
+
+ 0 ? 'warn' : 'primary'">
+
+
+
+
+ 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 @@
-
-
User Information
-
-
-
- Change Password
- User: {{ username }}
-
-
-
-
-
- Current Password
-
-
- {{ showCurrentPassword ? 'visibility_off' : 'visibility' }}
-
-
-
-
- New Password
-
-
- {{ showNewPassword ? 'visibility_off' : 'visibility' }}
-
- At least 8 characters with uppercase, lowercase and numbers
-
-
-
-
- Password Strength:
-
- {{ passwordStrength.text }}
-
-
-
-
-
-
-
- Confirm New Password
-
-
- {{ showConfirmPassword ? 'visibility_off' : 'visibility' }}
-
-
- Passwords do not match
-
-
-
-
-
- save
-
- {{ saving ? 'Saving...' : 'Change Password' }}
-
-
-
-
-
+
\ No newline at end of file
diff --git a/flare-ui/src/app/components/user-info/user-info.component.ts b/flare-ui/src/app/components/user-info/user-info.component.ts
index 90ae7f7b3b9a2dc94f876a3e839ef9c6e25df38a..7b0029d73e5f8e43da0ec244f32a8c6e7ec7d36d 100644
--- a/flare-ui/src/app/components/user-info/user-info.component.ts
+++ b/flare-ui/src/app/components/user-info/user-info.component.ts
@@ -1,175 +1,175 @@
-import { Component, inject, OnDestroy } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { Router } from '@angular/router';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatCardModule } from '@angular/material/card';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { ApiService } from '../../services/api.service';
-import { AuthService } from '../../services/auth.service';
-import { Subject, takeUntil } from 'rxjs';
-
-@Component({
- selector: 'app-user-info',
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatFormFieldModule,
- MatInputModule,
- MatButtonModule,
- MatIconModule,
- MatProgressBarModule,
- MatCardModule,
- MatSnackBarModule,
- MatProgressSpinnerModule
- ],
- templateUrl: './user-info.component.html',
- styleUrls: ['./user-info.component.scss']
-})
-export class UserInfoComponent implements OnDestroy {
- private apiService = inject(ApiService);
- private authService = inject(AuthService);
- private snackBar = inject(MatSnackBar);
- private router = inject(Router);
-
- username = this.authService.getUsername() || '';
- currentPassword = '';
- newPassword = '';
- confirmPassword = '';
- saving = false;
- showCurrentPassword = false;
- showNewPassword = false;
- showConfirmPassword = false;
-
- // Memory leak prevention
- private destroyed$ = new Subject
();
-
- ngOnDestroy() {
- this.destroyed$.next();
- this.destroyed$.complete();
- }
-
- get passwordStrength(): { level: number; text: string; color: string } {
- if (!this.newPassword) {
- return { level: 0, text: '', color: '' };
- }
-
- let strength = 0;
-
- // Length check
- if (this.newPassword.length >= 8) strength++;
- if (this.newPassword.length >= 12) strength++;
-
- // Character variety
- if (/[a-z]/.test(this.newPassword)) strength++;
- if (/[A-Z]/.test(this.newPassword)) strength++;
- if (/[0-9]/.test(this.newPassword)) strength++;
- if (/[^a-zA-Z0-9]/.test(this.newPassword)) strength++;
-
- if (strength <= 2) {
- return { level: 33, text: 'Weak', color: 'warn' };
- } else if (strength <= 4) {
- return { level: 66, text: 'Medium', color: 'accent' };
- } else {
- return { level: 100, text: 'Strong', color: 'primary' };
- }
- }
-
- get isFormValid(): boolean {
- return !!this.currentPassword &&
- !!this.newPassword &&
- this.newPassword === this.confirmPassword &&
- this.newPassword.length >= 8;
- }
-
- changePassword() {
- if (!this.isFormValid || this.saving) return;
-
- this.saving = true;
-
- this.apiService.changePassword(this.currentPassword, this.newPassword)
- .pipe(takeUntil(this.destroyed$))
- .subscribe({
- next: () => {
- this.saving = false;
-
- // Clear form
- this.currentPassword = '';
- this.newPassword = '';
- this.confirmPassword = '';
-
- // Show success message
- this.snackBar.open(
- 'Password changed successfully. Please login again.',
- 'OK',
- {
- duration: 5000,
- panelClass: ['success-snackbar']
- }
- ).afterDismissed().subscribe(() => {
- // Logout after password change
- this.authService.logout();
- });
- },
- error: (error) => {
- this.saving = false;
-
- // Handle validation errors
- if (error.status === 422 && error.error?.details) {
- // Field-level errors
- const fieldErrors = error.error.details;
- if (fieldErrors.some((e: any) => e.field === 'current_password')) {
- this.snackBar.open(
- 'Current password is incorrect',
- 'Close',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- );
- } else if (fieldErrors.some((e: any) => e.field === 'new_password')) {
- const pwError = fieldErrors.find((e: any) => e.field === 'new_password');
- this.snackBar.open(
- pwError.message || 'New password does not meet requirements',
- 'Close',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- );
- }
- } else {
- // Generic error
- this.snackBar.open(
- error.error?.detail || 'Failed to change password',
- 'Close',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- );
- }
- }
- });
- }
-
- togglePasswordVisibility(field: 'current' | 'new' | 'confirm') {
- switch(field) {
- case 'current':
- this.showCurrentPassword = !this.showCurrentPassword;
- break;
- case 'new':
- this.showNewPassword = !this.showNewPassword;
- break;
- case 'confirm':
- this.showConfirmPassword = !this.showConfirmPassword;
- break;
- }
- }
+import { Component, inject, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatProgressBarModule } from '@angular/material/progress-bar';
+import { MatCardModule } from '@angular/material/card';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { ApiService } from '../../services/api.service';
+import { AuthService } from '../../services/auth.service';
+import { Subject, takeUntil } from 'rxjs';
+
+@Component({
+ selector: 'app-user-info',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatButtonModule,
+ MatIconModule,
+ MatProgressBarModule,
+ MatCardModule,
+ MatSnackBarModule,
+ MatProgressSpinnerModule
+ ],
+ templateUrl: './user-info.component.html',
+ styleUrls: ['./user-info.component.scss']
+})
+export class UserInfoComponent implements OnDestroy {
+ private apiService = inject(ApiService);
+ private authService = inject(AuthService);
+ private snackBar = inject(MatSnackBar);
+ private router = inject(Router);
+
+ username = this.authService.getUsername() || '';
+ currentPassword = '';
+ newPassword = '';
+ confirmPassword = '';
+ saving = false;
+ showCurrentPassword = false;
+ showNewPassword = false;
+ showConfirmPassword = false;
+
+ // Memory leak prevention
+ private destroyed$ = new Subject();
+
+ ngOnDestroy() {
+ this.destroyed$.next();
+ this.destroyed$.complete();
+ }
+
+ get passwordStrength(): { level: number; text: string; color: string } {
+ if (!this.newPassword) {
+ return { level: 0, text: '', color: '' };
+ }
+
+ let strength = 0;
+
+ // Length check
+ if (this.newPassword.length >= 8) strength++;
+ if (this.newPassword.length >= 12) strength++;
+
+ // Character variety
+ if (/[a-z]/.test(this.newPassword)) strength++;
+ if (/[A-Z]/.test(this.newPassword)) strength++;
+ if (/[0-9]/.test(this.newPassword)) strength++;
+ if (/[^a-zA-Z0-9]/.test(this.newPassword)) strength++;
+
+ if (strength <= 2) {
+ return { level: 33, text: 'Weak', color: 'warn' };
+ } else if (strength <= 4) {
+ return { level: 66, text: 'Medium', color: 'accent' };
+ } else {
+ return { level: 100, text: 'Strong', color: 'primary' };
+ }
+ }
+
+ get isFormValid(): boolean {
+ return !!this.currentPassword &&
+ !!this.newPassword &&
+ this.newPassword === this.confirmPassword &&
+ this.newPassword.length >= 8;
+ }
+
+ changePassword() {
+ if (!this.isFormValid || this.saving) return;
+
+ this.saving = true;
+
+ this.apiService.changePassword(this.currentPassword, this.newPassword)
+ .pipe(takeUntil(this.destroyed$))
+ .subscribe({
+ next: () => {
+ this.saving = false;
+
+ // Clear form
+ this.currentPassword = '';
+ this.newPassword = '';
+ this.confirmPassword = '';
+
+ // Show success message
+ this.snackBar.open(
+ 'Password changed successfully. Please login again.',
+ 'OK',
+ {
+ duration: 5000,
+ panelClass: ['success-snackbar']
+ }
+ ).afterDismissed().subscribe(() => {
+ // Logout after password change
+ this.authService.logout();
+ });
+ },
+ error: (error) => {
+ this.saving = false;
+
+ // Handle validation errors
+ if (error.status === 422 && error.error?.details) {
+ // Field-level errors
+ const fieldErrors = error.error.details;
+ if (fieldErrors.some((e: any) => e.field === 'current_password')) {
+ this.snackBar.open(
+ 'Current password is incorrect',
+ 'Close',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ } else if (fieldErrors.some((e: any) => e.field === 'new_password')) {
+ const pwError = fieldErrors.find((e: any) => e.field === 'new_password');
+ this.snackBar.open(
+ pwError.message || 'New password does not meet requirements',
+ 'Close',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ }
+ } else {
+ // Generic error
+ this.snackBar.open(
+ error.error?.detail || 'Failed to change password',
+ 'Close',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ }
+ }
+ });
+ }
+
+ togglePasswordVisibility(field: 'current' | 'new' | 'confirm') {
+ switch(field) {
+ case 'current':
+ this.showCurrentPassword = !this.showCurrentPassword;
+ break;
+ case 'new':
+ this.showNewPassword = !this.showNewPassword;
+ break;
+ case 'confirm':
+ this.showConfirmPassword = !this.showConfirmPassword;
+ break;
+ }
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html b/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html
index 5c34f69bfa707ec554206ea938b5f1d6170686cc..18667a22c38ac45ea4126ae38c7ca3f6e50b4812 100644
--- a/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html
+++ b/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html
@@ -1,482 +1,482 @@
-
- @if (data.mode === 'create') {
- Create New API
- } @else if (data.mode === 'duplicate') {
- Duplicate API
- } @else if (data.mode === 'test') {
- Test API: {{ data.api.name }}
- } @else {
- Edit API: {{ data.api.name }}
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Enable Authentication
-
-
- @if (form.get('auth.enabled')?.value) {
-
-
-
-
Token Configuration
-
-
- Token Endpoint
-
- URL to obtain authentication token
- @if (form.get('auth.token_endpoint')?.hasError('required') && form.get('auth.token_endpoint')?.touched) {
- Token endpoint is required when auth is enabled
- }
-
-
-
- Token Response Path
-
- JSON path to extract token from response
- @if (form.get('auth.response_token_path')?.hasError('required') && form.get('auth.response_token_path')?.touched) {
- Token path is required when auth is enabled
- }
-
-
-
-
-
-
-
-
Token Refresh (Optional)
-
-
- Refresh Endpoint
-
- URL to refresh expired token
-
-
-
-
-
- }
-
-
-
-
-
-
-
- Response Prompt
-
- Instructions for AI to process the response (optional)
-
-
-
-
-
-
-
- @if (responseMappings.length === 0) {
-
No response mappings configured.
- }
-
- @for (mapping of responseMappings.controls; track mapping; let i = $index) {
-
-
-
- {{ mapping.get('variable_name')?.value || 'New Mapping' }}
-
-
- {{ mapping.get('json_path')?.value || 'Configure mapping' }}
-
-
-
-
-
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Test API Call
-
-
-
- @if (testing) {
-
- sync
- Testing...
-
- } @else {
-
- play_arrow
- Test API
-
- }
-
-
-
- refresh
- Generate Test Data
-
-
-
-
-
-
- @if (testResult) {
-
-
-
-
Test Result
-
- @if (testResult.success) {
-
- check_circle
- Success ({{ testResult.status_code }})
-
- } @else {
-
- error
- Failed: {{ testResult.error }}
-
- }
-
- @if (testResult.response_time) {
-
Response Time: {{ testResult.response_time }}ms
- }
-
- @if (testResult.response_headers) {
-
-
- Response Headers
-
-
- Headers
-
-
-
- }
-
- @if (testResult.response_body) {
-
-
- Response Body
-
-
- Response
-
-
-
- }
-
- @if (testResult.request_body) {
-
-
- Request Details
-
-
- Actual Request Sent
-
-
-
- @if (testResult.request_headers) {
-
- Request Headers
-
-
- }
-
- }
-
- @if (testResult.extracted_values && testResult.extracted_values.length > 0) {
-
-
- Extracted Values
-
-
-
- Variable
- {{ element.variable_name }}
-
-
-
- Value
- {{ element.value }}
-
-
-
- Type
- {{ element.type }}
-
-
-
-
-
-
- }
-
- }
-
-
-
-
-
-
-
-
-
- @if (data.mode === 'test') {
- Close
- } @else {
- Cancel
- }
-
- @if (data.mode !== 'test') {
-
- @if (saving) {
-
- sync
- Saving...
-
- } @else {
- @if (data.mode === 'create' || data.mode === 'duplicate') {
- Create
- } @else {
- Update
- }
- }
-
- }
+
+ @if (data.mode === 'create') {
+ Create New API
+ } @else if (data.mode === 'duplicate') {
+ Duplicate API
+ } @else if (data.mode === 'test') {
+ Test API: {{ data.api.name }}
+ } @else {
+ Edit API: {{ data.api.name }}
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Enable Authentication
+
+
+ @if (form.get('auth.enabled')?.value) {
+
+
+
+
Token Configuration
+
+
+ Token Endpoint
+
+ URL to obtain authentication token
+ @if (form.get('auth.token_endpoint')?.hasError('required') && form.get('auth.token_endpoint')?.touched) {
+ Token endpoint is required when auth is enabled
+ }
+
+
+
+ Token Response Path
+
+ JSON path to extract token from response
+ @if (form.get('auth.response_token_path')?.hasError('required') && form.get('auth.response_token_path')?.touched) {
+ Token path is required when auth is enabled
+ }
+
+
+
+
+
+
+
+
Token Refresh (Optional)
+
+
+ Refresh Endpoint
+
+ URL to refresh expired token
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+ Response Prompt
+
+ Instructions for AI to process the response (optional)
+
+
+
+
+
+
+
+ @if (responseMappings.length === 0) {
+
No response mappings configured.
+ }
+
+ @for (mapping of responseMappings.controls; track mapping; let i = $index) {
+
+
+
+ {{ mapping.get('variable_name')?.value || 'New Mapping' }}
+
+
+ {{ mapping.get('json_path')?.value || 'Configure mapping' }}
+
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Test API Call
+
+
+
+ @if (testing) {
+
+ sync
+ Testing...
+
+ } @else {
+
+ play_arrow
+ Test API
+
+ }
+
+
+
+ refresh
+ Generate Test Data
+
+
+
+
+
+
+ @if (testResult) {
+
+
+
+
Test Result
+
+ @if (testResult.success) {
+
+ check_circle
+ Success ({{ testResult.status_code }})
+
+ } @else {
+
+ error
+ Failed: {{ testResult.error }}
+
+ }
+
+ @if (testResult.response_time) {
+
Response Time: {{ testResult.response_time }}ms
+ }
+
+ @if (testResult.response_headers) {
+
+
+ Response Headers
+
+
+ Headers
+
+
+
+ }
+
+ @if (testResult.response_body) {
+
+
+ Response Body
+
+
+ Response
+
+
+
+ }
+
+ @if (testResult.request_body) {
+
+
+ Request Details
+
+
+ Actual Request Sent
+
+
+
+ @if (testResult.request_headers) {
+
+ Request Headers
+
+
+ }
+
+ }
+
+ @if (testResult.extracted_values && testResult.extracted_values.length > 0) {
+
+
+ Extracted Values
+
+
+
+ Variable
+ {{ element.variable_name }}
+
+
+
+ Value
+ {{ element.value }}
+
+
+
+ Type
+ {{ element.type }}
+
+
+
+
+
+
+ }
+
+ }
+
+
+
+
+
+
+
+
+
+ @if (data.mode === 'test') {
+ Close
+ } @else {
+ Cancel
+ }
+
+ @if (data.mode !== 'test') {
+
+ @if (saving) {
+
+ sync
+ Saving...
+
+ } @else {
+ @if (data.mode === 'create' || data.mode === 'duplicate') {
+ Create
+ } @else {
+ Update
+ }
+ }
+
+ }
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss b/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss
index bc76929024976f3553bc86dd5ec51e4a3fe5dea9..824c46bf5d8779fa1467c4c6a10d9ef8662bce23 100644
--- a/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss
+++ b/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss
@@ -1,232 +1,232 @@
-.tab-content {
- padding: 24px 0;
-
- mat-form-field {
- display: block;
- margin-bottom: 16px;
- }
-
- h3 {
- margin-top: 10px;
- margin-bottom: 10px;
- }
-}
-
-.full-width {
- width: 100%;
-}
-
-// Row layout
-.row {
- display: flex;
- gap: 12px;
- align-items: flex-start;
-
- mat-form-field {
- flex: 1;
- }
-
- .method-field {
- flex: 0 0 150px;
- }
-
- .timeout-field {
- flex: 1;
- }
-
- .type-field {
- flex: 0 0 150px;
- }
-
- .path-field {
- flex: 1;
- }
-}
-
-// Headers array section
-.array-section {
- .section-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
-
- h3 {
- margin: 0;
- font-size: 16px;
- }
- }
-
- .array-item {
- display: flex;
- gap: 12px;
- align-items: flex-start;
- margin-bottom: 16px;
-
- .key-field {
- flex: 1;
- min-width: 150px;
- }
-
- .value-field {
- flex: 2;
- min-width: 200px;
- }
-
- > button[mat-icon-button] {
- margin-top: 8px;
- }
- }
-
- .empty-message {
- text-align: center;
- color: #666;
- padding: 20px;
- background-color: #f5f5f5;
- border-radius: 4px;
- margin: 16px 0;
- }
-}
-
-// Response mappings
-.mapping-content {
- padding: 16px 0;
-
- mat-form-field {
- display: block;
- width: 100%;
- margin-bottom: 16px;
- }
-}
-
-// Retry section
-.retry-section {
- margin-top: 24px;
-
- h3 {
- margin-bottom: 16px;
- font-size: 16px;
- }
-}
-
-.test-section {
- h3 {
- margin-bottom: 16px;
- }
-
- .test-controls {
- display: flex;
- gap: 12px;
- margin-bottom: 16px;
- }
-
- .test-result {
- margin-top: 24px;
- padding: 16px;
- border-radius: 4px;
-
- &.success {
- background-color: #e8f5e9;
- border: 1px solid #4caf50;
- }
-
- &.error {
- background-color: #ffebee;
- border: 1px solid #f44336;
- }
-
- h4 {
- margin-top: 0;
- }
-
- .result-status {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 16px;
-
- mat-icon {
- &.mat-icon {
- color: inherit;
- }
- }
- }
-
- mat-expansion-panel {
- margin-top: 16px;
-
- &:first-of-type {
- margin-top: 24px;
- }
- }
-
- table {
- margin-top: 8px;
- }
- }
-}
-
-// Auth section
-.auth-section {
- margin-top: 16px;
-
- h3 {
- margin: 24px 0 16px;
- font-size: 16px;
- }
-}
-
-// Info text
-.info-text {
- color: #666;
- font-size: 14px;
- margin-bottom: 16px;
-}
-
-// Spinning icon animation
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-.spin {
- animation: spin 1s linear infinite;
-}
-
-// Dialog actions
-mat-dialog-actions {
- padding: 16px 24px !important;
- margin: 0 !important;
-}
-
-// Responsive
-@media (max-width: 768px) {
- .row {
- flex-wrap: wrap;
-
- mat-form-field {
- flex: 1 1 100%;
- }
-
- .method-field,
- .type-field {
- flex: 1 1 100%;
- }
- }
-
- .array-section {
- .array-item {
- flex-wrap: wrap;
-
- .key-field,
- .value-field {
- flex: 1 1 100%;
- min-width: unset;
- }
- }
- }
+.tab-content {
+ padding: 24px 0;
+
+ mat-form-field {
+ display: block;
+ margin-bottom: 16px;
+ }
+
+ h3 {
+ margin-top: 10px;
+ margin-bottom: 10px;
+ }
+}
+
+.full-width {
+ width: 100%;
+}
+
+// Row layout
+.row {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+
+ mat-form-field {
+ flex: 1;
+ }
+
+ .method-field {
+ flex: 0 0 150px;
+ }
+
+ .timeout-field {
+ flex: 1;
+ }
+
+ .type-field {
+ flex: 0 0 150px;
+ }
+
+ .path-field {
+ flex: 1;
+ }
+}
+
+// Headers array section
+.array-section {
+ .section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+
+ h3 {
+ margin: 0;
+ font-size: 16px;
+ }
+ }
+
+ .array-item {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ margin-bottom: 16px;
+
+ .key-field {
+ flex: 1;
+ min-width: 150px;
+ }
+
+ .value-field {
+ flex: 2;
+ min-width: 200px;
+ }
+
+ > button[mat-icon-button] {
+ margin-top: 8px;
+ }
+ }
+
+ .empty-message {
+ text-align: center;
+ color: #666;
+ padding: 20px;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ margin: 16px 0;
+ }
+}
+
+// Response mappings
+.mapping-content {
+ padding: 16px 0;
+
+ mat-form-field {
+ display: block;
+ width: 100%;
+ margin-bottom: 16px;
+ }
+}
+
+// Retry section
+.retry-section {
+ margin-top: 24px;
+
+ h3 {
+ margin-bottom: 16px;
+ font-size: 16px;
+ }
+}
+
+.test-section {
+ h3 {
+ margin-bottom: 16px;
+ }
+
+ .test-controls {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ .test-result {
+ margin-top: 24px;
+ padding: 16px;
+ border-radius: 4px;
+
+ &.success {
+ background-color: #e8f5e9;
+ border: 1px solid #4caf50;
+ }
+
+ &.error {
+ background-color: #ffebee;
+ border: 1px solid #f44336;
+ }
+
+ h4 {
+ margin-top: 0;
+ }
+
+ .result-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+
+ mat-icon {
+ &.mat-icon {
+ color: inherit;
+ }
+ }
+ }
+
+ mat-expansion-panel {
+ margin-top: 16px;
+
+ &:first-of-type {
+ margin-top: 24px;
+ }
+ }
+
+ table {
+ margin-top: 8px;
+ }
+ }
+}
+
+// Auth section
+.auth-section {
+ margin-top: 16px;
+
+ h3 {
+ margin: 24px 0 16px;
+ font-size: 16px;
+ }
+}
+
+// Info text
+.info-text {
+ color: #666;
+ font-size: 14px;
+ margin-bottom: 16px;
+}
+
+// Spinning icon animation
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.spin {
+ animation: spin 1s linear infinite;
+}
+
+// Dialog actions
+mat-dialog-actions {
+ padding: 16px 24px !important;
+ margin: 0 !important;
+}
+
+// Responsive
+@media (max-width: 768px) {
+ .row {
+ flex-wrap: wrap;
+
+ mat-form-field {
+ flex: 1 1 100%;
+ }
+
+ .method-field,
+ .type-field {
+ flex: 1 1 100%;
+ }
+ }
+
+ .array-section {
+ .array-item {
+ flex-wrap: wrap;
+
+ .key-field,
+ .value-field {
+ flex: 1 1 100%;
+ min-width: unset;
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts b/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts
index b4292ad115959a81980bde5a6694ed40e2292cce..46878e6424e708c9a3a74636004caa02351adac8 100644
--- a/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts
+++ b/flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts
@@ -1,578 +1,578 @@
-import { Component, Inject, OnInit } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
-import { FormsModule } from '@angular/forms';
-import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
-import { MatTabsModule } from '@angular/material/tabs';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatDividerModule } from '@angular/material/divider';
-import { MatExpansionModule } from '@angular/material/expansion';
-import { MatChipsModule } from '@angular/material/chips';
-import { MatMenuModule } from '@angular/material/menu';
-import { MatTableModule } from '@angular/material/table';
-import { ApiService } from '../../services/api.service';
-import { JsonEditorComponent } from '../../shared/json-editor/json-editor.component';
-
-@Component({
- selector: 'app-api-edit-dialog',
- standalone: true,
- imports: [
- CommonModule,
- ReactiveFormsModule,
- FormsModule,
- MatDialogModule,
- MatTabsModule,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
- MatCheckboxModule,
- MatButtonModule,
- MatIconModule,
- MatSnackBarModule,
- MatDividerModule,
- MatExpansionModule,
- MatChipsModule,
- MatMenuModule,
- MatTableModule,
- JsonEditorComponent
- ],
- templateUrl: './api-edit-dialog.component.html',
- styleUrls: ['./api-edit-dialog.component.scss']
-})
-export default class ApiEditDialogComponent implements OnInit {
- form!: FormGroup;
- saving = false;
- testing = false;
- testResult: any = null;
- testRequestJson = '{}';
- allIntentParameters: string[] = [];
- responseMappingVariables: string[] = [];
- activeTabIndex = 0;
-
- httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
- retryStrategies = ['static', 'exponential'];
- variableTypes = ['str', 'int', 'float', 'bool', 'date'];
-
- constructor(
- private fb: FormBuilder,
- private apiService: ApiService,
- private snackBar: MatSnackBar,
- public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: any
- ) {}
-
- ngOnInit() {
- this.initializeForm();
- this.loadIntentParameters();
-
- // Aktif tab'ı ayarla
- if (this.data.activeTab !== undefined) {
- this.activeTabIndex = this.data.activeTab;
- }
-
- if ((this.data.mode === 'edit' || this.data.mode === 'test') && this.data.api) {
- this.populateForm(this.data.api);
- } else if (this.data.mode === 'duplicate' && this.data.api) {
- const duplicateData = { ...this.data.api };
- duplicateData.name = duplicateData.name + '_copy';
- delete duplicateData.last_update_date;
- this.populateForm(duplicateData);
- }
-
- // Test modunda açıldıysa test JSON'ını hazırla
- if (this.data.mode === 'test') {
- setTimeout(() => {
- this.updateTestRequestJson();
- }, 100);
- }
-
- // Watch response mappings changes
- this.form.get('response_mappings')?.valueChanges.subscribe(() => {
- this.updateResponseMappingVariables();
- });
- }
-
- initializeForm() {
- this.form = this.fb.group({
- // General Tab
- name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_]+$/)]],
- url: ['', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]],
- method: ['POST', Validators.required],
- body_template: ['{}'],
- timeout_seconds: [10, [Validators.required, Validators.min(1), Validators.max(300)]],
- response_prompt: [''],
- response_mappings: this.fb.array([]),
-
- // Headers Tab
- headers: this.fb.array([]),
-
- // Retry Settings
- retry: this.fb.group({
- retry_count: [3, [Validators.required, Validators.min(0), Validators.max(10)]],
- backoff_seconds: [2, [Validators.required, Validators.min(1), Validators.max(60)]],
- strategy: ['static', Validators.required]
- }),
-
- // Auth Tab
- auth: this.fb.group({
- enabled: [false],
- token_endpoint: [''],
- response_token_path: ['token'],
- token_request_body: ['{}'],
- token_refresh_endpoint: [''],
- token_refresh_body: ['{}']
- }),
-
- // Proxy (optional)
- proxy: [''],
-
- // For race condition handling
- last_update_date: ['']
- });
-
- // Watch for auth enabled changes
- this.form.get('auth.enabled')?.valueChanges.subscribe(enabled => {
- const authGroup = this.form.get('auth');
- if (enabled) {
- authGroup?.get('token_endpoint')?.setValidators([Validators.required]);
- authGroup?.get('response_token_path')?.setValidators([Validators.required]);
- } else {
- authGroup?.get('token_endpoint')?.clearValidators();
- authGroup?.get('response_token_path')?.clearValidators();
- }
- authGroup?.get('token_endpoint')?.updateValueAndValidity();
- authGroup?.get('response_token_path')?.updateValueAndValidity();
- });
- }
-
- populateForm(api: any) {
- console.log('Populating form with API:', api);
-
- // Convert headers object to FormArray
- const headersArray = this.form.get('headers') as FormArray;
- headersArray.clear();
-
- if (api.headers) {
- if (Array.isArray(api.headers)) {
- api.headers.forEach((header: any) => {
- headersArray.push(this.createHeaderFormGroup(header.key || '', header.value || ''));
- });
- } else if (typeof api.headers === 'object') {
- Object.entries(api.headers).forEach(([key, value]) => {
- headersArray.push(this.createHeaderFormGroup(key, value as string));
- });
- }
- }
-
- // Convert response_mappings to FormArray
- const responseMappingsArray = this.form.get('response_mappings') as FormArray;
- responseMappingsArray.clear();
-
- if (api.response_mappings && Array.isArray(api.response_mappings)) {
- api.response_mappings.forEach((mapping: any) => {
- responseMappingsArray.push(this.createResponseMappingFormGroup(mapping));
- });
- }
-
- // Convert body_template to JSON string if it's an object
- if (api.body_template && typeof api.body_template === 'object') {
- api.body_template = JSON.stringify(api.body_template, null, 2);
- }
-
- // Convert auth bodies to JSON strings
- if (api.auth) {
- if (api.auth.token_request_body && typeof api.auth.token_request_body === 'object') {
- api.auth.token_request_body = JSON.stringify(api.auth.token_request_body, null, 2);
- }
- if (api.auth.token_refresh_body && typeof api.auth.token_refresh_body === 'object') {
- api.auth.token_refresh_body = JSON.stringify(api.auth.token_refresh_body, null, 2);
- }
- }
-
- const formData = { ...api };
-
- // headers array'ini kaldır çünkü zaten FormArray'e ekledik
- delete formData.headers;
- delete formData.response_mappings;
-
- // Patch form values
- this.form.patchValue(formData);
-
- // Disable name field if editing or testing
- if (this.data.mode === 'edit' || this.data.mode === 'test') {
- this.form.get('name')?.disable();
- }
- }
-
- get headers() {
- return this.form.get('headers') as FormArray;
- }
-
- get responseMappings() {
- return this.form.get('response_mappings') as FormArray;
- }
-
- createHeaderFormGroup(key = '', value = ''): FormGroup {
- return this.fb.group({
- key: [key, Validators.required],
- value: [value, Validators.required]
- });
- }
-
- createResponseMappingFormGroup(data: any = {}): FormGroup {
- return this.fb.group({
- variable_name: [data.variable_name || '', [Validators.required, Validators.pattern(/^[a-z_][a-z0-9_]*$/)]],
- type: [data.type || 'str', Validators.required],
- json_path: [data.json_path || '', Validators.required],
- caption: [data.caption || '', Validators.required]
- });
- }
-
- addHeader() {
- this.headers.push(this.createHeaderFormGroup());
- }
-
- removeHeader(index: number) {
- this.headers.removeAt(index);
- }
-
- addResponseMapping() {
- this.responseMappings.push(this.createResponseMappingFormGroup());
- }
-
- removeResponseMapping(index: number) {
- this.responseMappings.removeAt(index);
- }
-
- insertHeaderValue(index: number, variable: string) {
- const headerGroup = this.headers.at(index);
- if (headerGroup) {
- const valueControl = headerGroup.get('value');
- if (valueControl) {
- const currentValue = valueControl.value || '';
- const newValue = currentValue + `{{${variable}}}`;
- valueControl.setValue(newValue);
- }
- }
- }
-
- getTemplateVariables(includeResponseMappings = true): string[] {
- const variables = new Set();
-
- // Intent parameters
- this.allIntentParameters.forEach(param => {
- variables.add(`variables.${param}`);
- });
-
- // Auth tokens
- const apiName = this.form.get('name')?.value || 'api_name';
- variables.add(`auth_tokens.${apiName}.token`);
-
- // Response mappings
- if (includeResponseMappings) {
- this.responseMappingVariables.forEach(varName => {
- variables.add(`variables.${varName}`);
- });
- }
-
- // Config variables
- variables.add('config.work_mode');
- variables.add('config.cloud_token');
-
- return Array.from(variables).sort();
- }
-
- updateResponseMappingVariables() {
- this.responseMappingVariables = [];
- const mappings = this.responseMappings.value;
- mappings.forEach((mapping: any) => {
- if (mapping.variable_name) {
- this.responseMappingVariables.push(mapping.variable_name);
- }
- });
- }
-
- async loadIntentParameters() {
- try {
- const projects = await this.apiService.getProjects(false).toPromise();
- const params = new Set();
-
- projects?.forEach(project => {
- project.versions?.forEach(version => {
- version.intents?.forEach(intent => {
- intent.parameters?.forEach((param: any) => {
- if (param.variable_name) {
- params.add(param.variable_name);
- }
- });
- });
- });
- });
-
- this.allIntentParameters = Array.from(params).sort();
- } catch (error) {
- console.error('Failed to load intent parameters:', error);
- }
- }
-
- // JSON validation için replacer fonksiyonu
- replaceVariablesForValidation = (jsonStr: string): string => {
- let processed = jsonStr;
-
- processed = processed.replace(/\{\{([^}]+)\}\}/g, (match, variablePath) => {
- if (variablePath.includes('variables.')) {
- const varName = variablePath.split('.').pop()?.toLowerCase() || '';
-
- const numericVars = ['count', 'passenger_count', 'timeout_seconds', 'retry_count', 'amount', 'price', 'quantity', 'age', 'id'];
- const booleanVars = ['enabled', 'published', 'is_active', 'confirmed', 'canceled', 'deleted', 'required'];
-
- if (numericVars.some(v => varName.includes(v))) {
- return '1';
- } else if (booleanVars.some(v => varName.includes(v))) {
- return 'true';
- } else {
- return '"placeholder"';
- }
- }
-
- return '"placeholder"';
- });
-
- return processed;
- }
-
- async testAPI() {
- const generalValid = this.form.get('url')?.valid && this.form.get('method')?.valid;
- if (!generalValid) {
- this.snackBar.open('Please fill in required fields first', 'Close', { duration: 3000 });
- return;
- }
-
- this.testing = true;
- this.testResult = null;
-
- try {
- const testData = this.prepareAPIData();
-
- let testRequestData = {};
- try {
- testRequestData = JSON.parse(this.testRequestJson);
- } catch (e) {
- this.snackBar.open('Invalid test request JSON', 'Close', {
- duration: 3000,
- panelClass: 'error-snackbar'
- });
- this.testing = false;
- return;
- }
-
- testData.test_request = testRequestData;
-
- const result = await this.apiService.testAPI(testData).toPromise();
-
- // Response headers'ı obje olarak sakla
- if (result.response_headers && typeof result.response_headers === 'string') {
- try {
- result.response_headers = JSON.parse(result.response_headers);
- } catch {
- // Headers parse edilemezse string olarak bırak
- }
- }
-
- this.testResult = result;
-
- if (result.success) {
- this.snackBar.open(`API test successful! (${result.status_code})`, 'Close', {
- duration: 3000
- });
- } else {
- const errorMsg = result.error || `API returned status ${result.status_code}`;
- this.snackBar.open(`API test failed: ${errorMsg}`, 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- }
- } catch (error: any) {
- this.testResult = {
- success: false,
- error: error.message || 'Test failed'
- };
- this.snackBar.open('API test failed', 'Close', {
- duration: 3000,
- panelClass: 'error-snackbar'
- });
- } finally {
- this.testing = false;
- }
- }
-
- updateTestRequestJson() {
- const formValue = this.form.getRawValue();
- let bodyTemplate = {};
-
- try {
- bodyTemplate = JSON.parse(formValue.body_template);
- } catch {
- bodyTemplate = {};
- }
-
- const testData = this.replacePlaceholdersForTest(bodyTemplate);
- this.testRequestJson = JSON.stringify(testData, null, 2);
- }
-
- replacePlaceholdersForTest(obj: any): any {
- if (typeof obj === 'string') {
- let result = obj;
-
- result = result.replace(/\{\{variables\.origin\}\}/g, 'Istanbul');
- result = result.replace(/\{\{variables\.destination\}\}/g, 'Ankara');
- result = result.replace(/\{\{variables\.flight_date\}\}/g, '2025-06-15');
- result = result.replace(/\{\{variables\.passenger_count\}\}/g, '2');
- result = result.replace(/\{\{variables\.flight_number\}\}/g, 'TK123');
- result = result.replace(/\{\{variables\.pnr\}\}/g, 'ABC12');
- result = result.replace(/\{\{variables\.surname\}\}/g, 'Test');
-
- result = result.replace(/\{\{auth_tokens\.[^}]+\.token\}\}/g, 'test_token_123');
-
- result = result.replace(/\{\{config\.work_mode\}\}/g, 'hfcloud');
-
- result = result.replace(/\{\{[^}]+\}\}/g, 'test_value');
-
- return result;
- } else if (typeof obj === 'object' && obj !== null) {
- const result: any = Array.isArray(obj) ? [] : {};
- for (const key in obj) {
- result[key] = this.replacePlaceholdersForTest(obj[key]);
- }
- return result;
- }
- return obj;
- }
-
- prepareAPIData(): any {
- const formValue = this.form.getRawValue();
-
- const headers: any = {};
- formValue.headers.forEach((h: any) => {
- if (h.key && h.value) {
- headers[h.key] = h.value;
- }
- });
-
- let body_template = {};
- let auth_token_request_body = {};
- let auth_token_refresh_body = {};
-
- try {
- body_template = formValue.body_template ? JSON.parse(formValue.body_template) : {};
- } catch (e) {
- console.error('Invalid body_template JSON:', e);
- }
-
- try {
- auth_token_request_body = formValue.auth.token_request_body ? JSON.parse(formValue.auth.token_request_body) : {};
- } catch (e) {
- console.error('Invalid auth token_request_body JSON:', e);
- }
-
- try {
- auth_token_refresh_body = formValue.auth.token_refresh_body ? JSON.parse(formValue.auth.token_refresh_body) : {};
- } catch (e) {
- console.error('Invalid auth token_refresh_body JSON:', e);
- }
-
- const apiData: any = {
- name: formValue.name,
- url: formValue.url,
- method: formValue.method,
- headers,
- body_template,
- timeout_seconds: formValue.timeout_seconds,
- retry: formValue.retry,
- response_prompt: formValue.response_prompt,
- response_mappings: formValue.response_mappings || []
- };
-
- // Proxy - null olarak gönder boşsa
- apiData.proxy = formValue.proxy || null;
-
- if (formValue.proxy) {
- apiData.proxy = formValue.proxy;
- }
-
- if (formValue.auth.enabled) {
- apiData.auth = {
- enabled: true,
- token_endpoint: formValue.auth.token_endpoint,
- response_token_path: formValue.auth.response_token_path,
- token_request_body: auth_token_request_body
- };
-
- if (formValue.auth.token_refresh_endpoint) {
- apiData.auth.token_refresh_endpoint = formValue.auth.token_refresh_endpoint;
- apiData.auth.token_refresh_body = auth_token_refresh_body;
- }
- }else {
- // Auth disabled olsa bile null olarak gönder
- apiData.auth = null;
- }
-
- // Edit modunda last_update_date'i ekle
- if (this.data.mode === 'edit' && formValue.last_update_date) {
- apiData.last_update_date = formValue.last_update_date;
- }
-
- console.log('Prepared API data:', apiData);
- return apiData;
- }
-
- async save() {
- if (this.data.mode === 'test') {
- this.cancel();
- return;
- }
-
- if (this.form.invalid) {
- Object.keys(this.form.controls).forEach(key => {
- this.form.get(key)?.markAsTouched();
- });
-
- this.snackBar.open('Please fix validation errors', 'Close', { duration: 3000 });
- return;
- }
-
- this.saving = true;
- try {
- const apiData = this.prepareAPIData();
-
- if (this.data.mode === 'create' || this.data.mode === 'duplicate') {
- await this.apiService.createAPI(apiData).toPromise();
- this.snackBar.open('API created successfully', 'Close', { duration: 3000 });
- } else {
- await this.apiService.updateAPI(this.data.api.name, apiData).toPromise();
- this.snackBar.open('API updated successfully', 'Close', { duration: 3000 });
- }
-
- this.dialogRef.close(true);
- } catch (error: any) {
- const message = error.error?.detail ||
- (this.data.mode === 'create' ? 'Failed to create API' : 'Failed to update API');
- this.snackBar.open(message, 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- } finally {
- this.saving = false;
- }
- }
-
- cancel() {
- this.dialogRef.close(false);
- }
+import { Component, Inject, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
+import { FormsModule } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatDividerModule } from '@angular/material/divider';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatTableModule } from '@angular/material/table';
+import { ApiService } from '../../services/api.service';
+import { JsonEditorComponent } from '../../shared/json-editor/json-editor.component';
+
+@Component({
+ selector: 'app-api-edit-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ FormsModule,
+ MatDialogModule,
+ MatTabsModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule,
+ MatCheckboxModule,
+ MatButtonModule,
+ MatIconModule,
+ MatSnackBarModule,
+ MatDividerModule,
+ MatExpansionModule,
+ MatChipsModule,
+ MatMenuModule,
+ MatTableModule,
+ JsonEditorComponent
+ ],
+ templateUrl: './api-edit-dialog.component.html',
+ styleUrls: ['./api-edit-dialog.component.scss']
+})
+export default class ApiEditDialogComponent implements OnInit {
+ form!: FormGroup;
+ saving = false;
+ testing = false;
+ testResult: any = null;
+ testRequestJson = '{}';
+ allIntentParameters: string[] = [];
+ responseMappingVariables: string[] = [];
+ activeTabIndex = 0;
+
+ httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
+ retryStrategies = ['static', 'exponential'];
+ variableTypes = ['str', 'int', 'float', 'bool', 'date'];
+
+ constructor(
+ private fb: FormBuilder,
+ private apiService: ApiService,
+ private snackBar: MatSnackBar,
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: any
+ ) {}
+
+ ngOnInit() {
+ this.initializeForm();
+ this.loadIntentParameters();
+
+ // Aktif tab'ı ayarla
+ if (this.data.activeTab !== undefined) {
+ this.activeTabIndex = this.data.activeTab;
+ }
+
+ if ((this.data.mode === 'edit' || this.data.mode === 'test') && this.data.api) {
+ this.populateForm(this.data.api);
+ } else if (this.data.mode === 'duplicate' && this.data.api) {
+ const duplicateData = { ...this.data.api };
+ duplicateData.name = duplicateData.name + '_copy';
+ delete duplicateData.last_update_date;
+ this.populateForm(duplicateData);
+ }
+
+ // Test modunda açıldıysa test JSON'ını hazırla
+ if (this.data.mode === 'test') {
+ setTimeout(() => {
+ this.updateTestRequestJson();
+ }, 100);
+ }
+
+ // Watch response mappings changes
+ this.form.get('response_mappings')?.valueChanges.subscribe(() => {
+ this.updateResponseMappingVariables();
+ });
+ }
+
+ initializeForm() {
+ this.form = this.fb.group({
+ // General Tab
+ name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_]+$/)]],
+ url: ['', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]],
+ method: ['POST', Validators.required],
+ body_template: ['{}'],
+ timeout_seconds: [10, [Validators.required, Validators.min(1), Validators.max(300)]],
+ response_prompt: [''],
+ response_mappings: this.fb.array([]),
+
+ // Headers Tab
+ headers: this.fb.array([]),
+
+ // Retry Settings
+ retry: this.fb.group({
+ retry_count: [3, [Validators.required, Validators.min(0), Validators.max(10)]],
+ backoff_seconds: [2, [Validators.required, Validators.min(1), Validators.max(60)]],
+ strategy: ['static', Validators.required]
+ }),
+
+ // Auth Tab
+ auth: this.fb.group({
+ enabled: [false],
+ token_endpoint: [''],
+ response_token_path: ['token'],
+ token_request_body: ['{}'],
+ token_refresh_endpoint: [''],
+ token_refresh_body: ['{}']
+ }),
+
+ // Proxy (optional)
+ proxy: [''],
+
+ // For race condition handling
+ last_update_date: ['']
+ });
+
+ // Watch for auth enabled changes
+ this.form.get('auth.enabled')?.valueChanges.subscribe(enabled => {
+ const authGroup = this.form.get('auth');
+ if (enabled) {
+ authGroup?.get('token_endpoint')?.setValidators([Validators.required]);
+ authGroup?.get('response_token_path')?.setValidators([Validators.required]);
+ } else {
+ authGroup?.get('token_endpoint')?.clearValidators();
+ authGroup?.get('response_token_path')?.clearValidators();
+ }
+ authGroup?.get('token_endpoint')?.updateValueAndValidity();
+ authGroup?.get('response_token_path')?.updateValueAndValidity();
+ });
+ }
+
+ populateForm(api: any) {
+ console.log('Populating form with API:', api);
+
+ // Convert headers object to FormArray
+ const headersArray = this.form.get('headers') as FormArray;
+ headersArray.clear();
+
+ if (api.headers) {
+ if (Array.isArray(api.headers)) {
+ api.headers.forEach((header: any) => {
+ headersArray.push(this.createHeaderFormGroup(header.key || '', header.value || ''));
+ });
+ } else if (typeof api.headers === 'object') {
+ Object.entries(api.headers).forEach(([key, value]) => {
+ headersArray.push(this.createHeaderFormGroup(key, value as string));
+ });
+ }
+ }
+
+ // Convert response_mappings to FormArray
+ const responseMappingsArray = this.form.get('response_mappings') as FormArray;
+ responseMappingsArray.clear();
+
+ if (api.response_mappings && Array.isArray(api.response_mappings)) {
+ api.response_mappings.forEach((mapping: any) => {
+ responseMappingsArray.push(this.createResponseMappingFormGroup(mapping));
+ });
+ }
+
+ // Convert body_template to JSON string if it's an object
+ if (api.body_template && typeof api.body_template === 'object') {
+ api.body_template = JSON.stringify(api.body_template, null, 2);
+ }
+
+ // Convert auth bodies to JSON strings
+ if (api.auth) {
+ if (api.auth.token_request_body && typeof api.auth.token_request_body === 'object') {
+ api.auth.token_request_body = JSON.stringify(api.auth.token_request_body, null, 2);
+ }
+ if (api.auth.token_refresh_body && typeof api.auth.token_refresh_body === 'object') {
+ api.auth.token_refresh_body = JSON.stringify(api.auth.token_refresh_body, null, 2);
+ }
+ }
+
+ const formData = { ...api };
+
+ // headers array'ini kaldır çünkü zaten FormArray'e ekledik
+ delete formData.headers;
+ delete formData.response_mappings;
+
+ // Patch form values
+ this.form.patchValue(formData);
+
+ // Disable name field if editing or testing
+ if (this.data.mode === 'edit' || this.data.mode === 'test') {
+ this.form.get('name')?.disable();
+ }
+ }
+
+ get headers() {
+ return this.form.get('headers') as FormArray;
+ }
+
+ get responseMappings() {
+ return this.form.get('response_mappings') as FormArray;
+ }
+
+ createHeaderFormGroup(key = '', value = ''): FormGroup {
+ return this.fb.group({
+ key: [key, Validators.required],
+ value: [value, Validators.required]
+ });
+ }
+
+ createResponseMappingFormGroup(data: any = {}): FormGroup {
+ return this.fb.group({
+ variable_name: [data.variable_name || '', [Validators.required, Validators.pattern(/^[a-z_][a-z0-9_]*$/)]],
+ type: [data.type || 'str', Validators.required],
+ json_path: [data.json_path || '', Validators.required],
+ caption: [data.caption || '', Validators.required]
+ });
+ }
+
+ addHeader() {
+ this.headers.push(this.createHeaderFormGroup());
+ }
+
+ removeHeader(index: number) {
+ this.headers.removeAt(index);
+ }
+
+ addResponseMapping() {
+ this.responseMappings.push(this.createResponseMappingFormGroup());
+ }
+
+ removeResponseMapping(index: number) {
+ this.responseMappings.removeAt(index);
+ }
+
+ insertHeaderValue(index: number, variable: string) {
+ const headerGroup = this.headers.at(index);
+ if (headerGroup) {
+ const valueControl = headerGroup.get('value');
+ if (valueControl) {
+ const currentValue = valueControl.value || '';
+ const newValue = currentValue + `{{${variable}}}`;
+ valueControl.setValue(newValue);
+ }
+ }
+ }
+
+ getTemplateVariables(includeResponseMappings = true): string[] {
+ const variables = new Set();
+
+ // Intent parameters
+ this.allIntentParameters.forEach(param => {
+ variables.add(`variables.${param}`);
+ });
+
+ // Auth tokens
+ const apiName = this.form.get('name')?.value || 'api_name';
+ variables.add(`auth_tokens.${apiName}.token`);
+
+ // Response mappings
+ if (includeResponseMappings) {
+ this.responseMappingVariables.forEach(varName => {
+ variables.add(`variables.${varName}`);
+ });
+ }
+
+ // Config variables
+ variables.add('config.work_mode');
+ variables.add('config.cloud_token');
+
+ return Array.from(variables).sort();
+ }
+
+ updateResponseMappingVariables() {
+ this.responseMappingVariables = [];
+ const mappings = this.responseMappings.value;
+ mappings.forEach((mapping: any) => {
+ if (mapping.variable_name) {
+ this.responseMappingVariables.push(mapping.variable_name);
+ }
+ });
+ }
+
+ async loadIntentParameters() {
+ try {
+ const projects = await this.apiService.getProjects(false).toPromise();
+ const params = new Set();
+
+ projects?.forEach(project => {
+ project.versions?.forEach(version => {
+ version.intents?.forEach(intent => {
+ intent.parameters?.forEach((param: any) => {
+ if (param.variable_name) {
+ params.add(param.variable_name);
+ }
+ });
+ });
+ });
+ });
+
+ this.allIntentParameters = Array.from(params).sort();
+ } catch (error) {
+ console.error('Failed to load intent parameters:', error);
+ }
+ }
+
+ // JSON validation için replacer fonksiyonu
+ replaceVariablesForValidation = (jsonStr: string): string => {
+ let processed = jsonStr;
+
+ processed = processed.replace(/\{\{([^}]+)\}\}/g, (match, variablePath) => {
+ if (variablePath.includes('variables.')) {
+ const varName = variablePath.split('.').pop()?.toLowerCase() || '';
+
+ const numericVars = ['count', 'passenger_count', 'timeout_seconds', 'retry_count', 'amount', 'price', 'quantity', 'age', 'id'];
+ const booleanVars = ['enabled', 'published', 'is_active', 'confirmed', 'canceled', 'deleted', 'required'];
+
+ if (numericVars.some(v => varName.includes(v))) {
+ return '1';
+ } else if (booleanVars.some(v => varName.includes(v))) {
+ return 'true';
+ } else {
+ return '"placeholder"';
+ }
+ }
+
+ return '"placeholder"';
+ });
+
+ return processed;
+ }
+
+ async testAPI() {
+ const generalValid = this.form.get('url')?.valid && this.form.get('method')?.valid;
+ if (!generalValid) {
+ this.snackBar.open('Please fill in required fields first', 'Close', { duration: 3000 });
+ return;
+ }
+
+ this.testing = true;
+ this.testResult = null;
+
+ try {
+ const testData = this.prepareAPIData();
+
+ let testRequestData = {};
+ try {
+ testRequestData = JSON.parse(this.testRequestJson);
+ } catch (e) {
+ this.snackBar.open('Invalid test request JSON', 'Close', {
+ duration: 3000,
+ panelClass: 'error-snackbar'
+ });
+ this.testing = false;
+ return;
+ }
+
+ testData.test_request = testRequestData;
+
+ const result = await this.apiService.testAPI(testData).toPromise();
+
+ // Response headers'ı obje olarak sakla
+ if (result.response_headers && typeof result.response_headers === 'string') {
+ try {
+ result.response_headers = JSON.parse(result.response_headers);
+ } catch {
+ // Headers parse edilemezse string olarak bırak
+ }
+ }
+
+ this.testResult = result;
+
+ if (result.success) {
+ this.snackBar.open(`API test successful! (${result.status_code})`, 'Close', {
+ duration: 3000
+ });
+ } else {
+ const errorMsg = result.error || `API returned status ${result.status_code}`;
+ this.snackBar.open(`API test failed: ${errorMsg}`, 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ }
+ } catch (error: any) {
+ this.testResult = {
+ success: false,
+ error: error.message || 'Test failed'
+ };
+ this.snackBar.open('API test failed', 'Close', {
+ duration: 3000,
+ panelClass: 'error-snackbar'
+ });
+ } finally {
+ this.testing = false;
+ }
+ }
+
+ updateTestRequestJson() {
+ const formValue = this.form.getRawValue();
+ let bodyTemplate = {};
+
+ try {
+ bodyTemplate = JSON.parse(formValue.body_template);
+ } catch {
+ bodyTemplate = {};
+ }
+
+ const testData = this.replacePlaceholdersForTest(bodyTemplate);
+ this.testRequestJson = JSON.stringify(testData, null, 2);
+ }
+
+ replacePlaceholdersForTest(obj: any): any {
+ if (typeof obj === 'string') {
+ let result = obj;
+
+ result = result.replace(/\{\{variables\.origin\}\}/g, 'Istanbul');
+ result = result.replace(/\{\{variables\.destination\}\}/g, 'Ankara');
+ result = result.replace(/\{\{variables\.flight_date\}\}/g, '2025-06-15');
+ result = result.replace(/\{\{variables\.passenger_count\}\}/g, '2');
+ result = result.replace(/\{\{variables\.flight_number\}\}/g, 'TK123');
+ result = result.replace(/\{\{variables\.pnr\}\}/g, 'ABC12');
+ result = result.replace(/\{\{variables\.surname\}\}/g, 'Test');
+
+ result = result.replace(/\{\{auth_tokens\.[^}]+\.token\}\}/g, 'test_token_123');
+
+ result = result.replace(/\{\{config\.work_mode\}\}/g, 'hfcloud');
+
+ result = result.replace(/\{\{[^}]+\}\}/g, 'test_value');
+
+ return result;
+ } else if (typeof obj === 'object' && obj !== null) {
+ const result: any = Array.isArray(obj) ? [] : {};
+ for (const key in obj) {
+ result[key] = this.replacePlaceholdersForTest(obj[key]);
+ }
+ return result;
+ }
+ return obj;
+ }
+
+ prepareAPIData(): any {
+ const formValue = this.form.getRawValue();
+
+ const headers: any = {};
+ formValue.headers.forEach((h: any) => {
+ if (h.key && h.value) {
+ headers[h.key] = h.value;
+ }
+ });
+
+ let body_template = {};
+ let auth_token_request_body = {};
+ let auth_token_refresh_body = {};
+
+ try {
+ body_template = formValue.body_template ? JSON.parse(formValue.body_template) : {};
+ } catch (e) {
+ console.error('Invalid body_template JSON:', e);
+ }
+
+ try {
+ auth_token_request_body = formValue.auth.token_request_body ? JSON.parse(formValue.auth.token_request_body) : {};
+ } catch (e) {
+ console.error('Invalid auth token_request_body JSON:', e);
+ }
+
+ try {
+ auth_token_refresh_body = formValue.auth.token_refresh_body ? JSON.parse(formValue.auth.token_refresh_body) : {};
+ } catch (e) {
+ console.error('Invalid auth token_refresh_body JSON:', e);
+ }
+
+ const apiData: any = {
+ name: formValue.name,
+ url: formValue.url,
+ method: formValue.method,
+ headers,
+ body_template,
+ timeout_seconds: formValue.timeout_seconds,
+ retry: formValue.retry,
+ response_prompt: formValue.response_prompt,
+ response_mappings: formValue.response_mappings || []
+ };
+
+ // Proxy - null olarak gönder boşsa
+ apiData.proxy = formValue.proxy || null;
+
+ if (formValue.proxy) {
+ apiData.proxy = formValue.proxy;
+ }
+
+ if (formValue.auth.enabled) {
+ apiData.auth = {
+ enabled: true,
+ token_endpoint: formValue.auth.token_endpoint,
+ response_token_path: formValue.auth.response_token_path,
+ token_request_body: auth_token_request_body
+ };
+
+ if (formValue.auth.token_refresh_endpoint) {
+ apiData.auth.token_refresh_endpoint = formValue.auth.token_refresh_endpoint;
+ apiData.auth.token_refresh_body = auth_token_refresh_body;
+ }
+ }else {
+ // Auth disabled olsa bile null olarak gönder
+ apiData.auth = null;
+ }
+
+ // Edit modunda last_update_date'i ekle
+ if (this.data.mode === 'edit' && formValue.last_update_date) {
+ apiData.last_update_date = formValue.last_update_date;
+ }
+
+ console.log('Prepared API data:', apiData);
+ return apiData;
+ }
+
+ async save() {
+ if (this.data.mode === 'test') {
+ this.cancel();
+ return;
+ }
+
+ if (this.form.invalid) {
+ Object.keys(this.form.controls).forEach(key => {
+ this.form.get(key)?.markAsTouched();
+ });
+
+ this.snackBar.open('Please fix validation errors', 'Close', { duration: 3000 });
+ return;
+ }
+
+ this.saving = true;
+ try {
+ const apiData = this.prepareAPIData();
+
+ if (this.data.mode === 'create' || this.data.mode === 'duplicate') {
+ await this.apiService.createAPI(apiData).toPromise();
+ this.snackBar.open('API created successfully', 'Close', { duration: 3000 });
+ } else {
+ await this.apiService.updateAPI(this.data.api.name, apiData).toPromise();
+ this.snackBar.open('API updated successfully', 'Close', { duration: 3000 });
+ }
+
+ this.dialogRef.close(true);
+ } catch (error: any) {
+ const message = error.error?.detail ||
+ (this.data.mode === 'create' ? 'Failed to create API' : 'Failed to update API');
+ this.snackBar.open(message, 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ } finally {
+ this.saving = false;
+ }
+ }
+
+ cancel() {
+ this.dialogRef.close(false);
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts b/flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts
index 518b88a50da5a6a963f3e21479ec98731ef1622b..84679676ace0b25d6eb0a0e388d0eacd745d65a0 100644
--- a/flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts
+++ b/flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts
@@ -1,137 +1,137 @@
-import { Component, Inject } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
-import { MatButtonModule } from '@angular/material/button';
-import { MatSelectModule } from '@angular/material/select';
-import { MatFormFieldModule } from '@angular/material/form-field';
-
-export interface ConfirmDialogData {
- title: string;
- message: string;
- confirmText?: string;
- cancelText?: string;
- confirmColor?: 'primary' | 'accent' | 'warn';
- showVersionSelect?: boolean;
- versions?: any[];
- showDropdown?: boolean;
- dropdownOptions?: Array<{value: any, label: string}>;
- dropdownPlaceholder?: string;
-}
-
-@Component({
- selector: 'app-confirm-dialog',
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatDialogModule,
- MatButtonModule,
- MatSelectModule,
- MatFormFieldModule
- ],
- template: `
- {{ data.title }}
-
- {{ data.message }}
-
- @if (data.showVersionSelect && data.versions) {
-
- Select Source Version
-
- @for (version of data.versions; track version.id) {
-
- Version {{ version.id }} - {{ version.caption }}
- @if (version.published) {
- (Published)
- }
-
- }
-
-
- }
-
- @if (data.showDropdown && data.dropdownOptions) {
-
- {{ data.dropdownPlaceholder || 'Select an option' }}
-
- @for (option of data.dropdownOptions; track option.value) {
-
- {{ option.label }}
-
- }
-
-
- }
-
-
-
- {{ data.cancelText || 'Cancel' }}
-
- {{ data.confirmText || 'Confirm' }}
-
-
- `,
- styles: [`
- mat-dialog-content {
- padding: 20px 24px;
- min-width: 400px;
- }
-
- p {
- margin: 0 0 16px 0;
- color: rgba(0,0,0,0.87);
- line-height: 1.5;
- }
-
- .full-width {
- width: 100%;
- }
-
- .published-badge {
- color: #4caf50;
- font-weight: 500;
- margin-left: 8px;
- }
-
- mat-dialog-actions {
- padding: 16px 24px;
- }
- `]
-})
-export default class ConfirmDialogComponent {
- selectedVersionId: number | null = null;
- selectedValue: any = undefined;
-
- constructor(
- public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
- ) {
- // Pre-select first version if available
- if (data.showVersionSelect && data.versions && data.versions.length > 0) {
- this.selectedVersionId = data.versions[0].id;
- }
-
- // Dropdown için başlangıç değeri undefined olsun (seçim yapılmamış)
- if (data.showDropdown) {
- this.selectedValue = undefined;
- }
- }
-
- onConfirm(): void {
- if (this.data.showVersionSelect) {
- this.dialogRef.close(this.selectedVersionId);
- } else if (this.data.showDropdown) {
- this.dialogRef.close({ confirmed: true, selectedValue: this.selectedValue });
- } else {
- this.dialogRef.close(true);
- }
- }
-
- onCancel(): void {
- this.dialogRef.close(false);
- }
+import { Component, Inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatSelectModule } from '@angular/material/select';
+import { MatFormFieldModule } from '@angular/material/form-field';
+
+export interface ConfirmDialogData {
+ title: string;
+ message: string;
+ confirmText?: string;
+ cancelText?: string;
+ confirmColor?: 'primary' | 'accent' | 'warn';
+ showVersionSelect?: boolean;
+ versions?: any[];
+ showDropdown?: boolean;
+ dropdownOptions?: Array<{value: any, label: string}>;
+ dropdownPlaceholder?: string;
+}
+
+@Component({
+ selector: 'app-confirm-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatSelectModule,
+ MatFormFieldModule
+ ],
+ template: `
+ {{ data.title }}
+
+ {{ data.message }}
+
+ @if (data.showVersionSelect && data.versions) {
+
+ Select Source Version
+
+ @for (version of data.versions; track version.id) {
+
+ Version {{ version.id }} - {{ version.caption }}
+ @if (version.published) {
+ (Published)
+ }
+
+ }
+
+
+ }
+
+ @if (data.showDropdown && data.dropdownOptions) {
+
+ {{ data.dropdownPlaceholder || 'Select an option' }}
+
+ @for (option of data.dropdownOptions; track option.value) {
+
+ {{ option.label }}
+
+ }
+
+
+ }
+
+
+
+ {{ data.cancelText || 'Cancel' }}
+
+ {{ data.confirmText || 'Confirm' }}
+
+
+ `,
+ styles: [`
+ mat-dialog-content {
+ padding: 20px 24px;
+ min-width: 400px;
+ }
+
+ p {
+ margin: 0 0 16px 0;
+ color: rgba(0,0,0,0.87);
+ line-height: 1.5;
+ }
+
+ .full-width {
+ width: 100%;
+ }
+
+ .published-badge {
+ color: #4caf50;
+ font-weight: 500;
+ margin-left: 8px;
+ }
+
+ mat-dialog-actions {
+ padding: 16px 24px;
+ }
+ `]
+})
+export default class ConfirmDialogComponent {
+ selectedVersionId: number | null = null;
+ selectedValue: any = undefined;
+
+ constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
+ ) {
+ // Pre-select first version if available
+ if (data.showVersionSelect && data.versions && data.versions.length > 0) {
+ this.selectedVersionId = data.versions[0].id;
+ }
+
+ // Dropdown için başlangıç değeri undefined olsun (seçim yapılmamış)
+ if (data.showDropdown) {
+ this.selectedValue = undefined;
+ }
+ }
+
+ onConfirm(): void {
+ if (this.data.showVersionSelect) {
+ this.dialogRef.close(this.selectedVersionId);
+ } else if (this.data.showDropdown) {
+ this.dialogRef.close({ confirmed: true, selectedValue: this.selectedValue });
+ } else {
+ this.dialogRef.close(true);
+ }
+ }
+
+ onCancel(): void {
+ this.dialogRef.close(false);
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html b/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html
index 45833a55548f2f1c2ca27ca16d7d2003eef4edc5..e08962ee0ca20e3bb9ee93e6b8642c40818f3806 100644
--- a/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html
+++ b/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html
@@ -1,243 +1,243 @@
-
- {{ data.intent ? 'Edit Intent' : 'Create Intent' }}
-
-
-
-
-
-
-
-
-
-
- Intent Name*
-
- Use lowercase with hyphens, no spaces
- Name is required
- Invalid format
-
-
-
- Caption*
-
- Caption is required
-
-
-
- Detection Prompt*
-
- Explain to the LLM when to detect this intent
-
-
-
- API Action*
-
-
- {{ api.name }} - {{ api.method }} {{ api.url }}
-
-
- Select the API to call when this intent is triggered
-
-
-
-
-
Fallback Messages
-
-
- Timeout Message
-
-
-
-
- Error Message
-
-
-
-
-
-
-
-
-
-
-
-
-
- New Example
-
-
-
- add
- Add Example
-
-
-
-
0">
-
- {{ example.example }}
-
- delete
-
-
-
-
-
-
format_list_bulleted
-
No examples for {{ getLocaleName(selectedExampleLocale) }} yet.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ param.get('name')?.value || 'New Parameter' }}
-
-
-
- {{ param.get('type')?.value }}
- Required
- Optional
-
-
-
-
-
-
-
-
-
-
input
-
No parameters defined. Add parameters that need to be extracted from user input.
-
-
-
-
-
-
-
-
- Cancel
-
- Save
-
+
+ {{ data.intent ? 'Edit Intent' : 'Create Intent' }}
+
+
+
+
+
+
+
+
+
+
+ Intent Name*
+
+ Use lowercase with hyphens, no spaces
+ Name is required
+ Invalid format
+
+
+
+ Caption*
+
+ Caption is required
+
+
+
+ Detection Prompt*
+
+ Explain to the LLM when to detect this intent
+
+
+
+ API Action*
+
+
+ {{ api.name }} - {{ api.method }} {{ api.url }}
+
+
+ Select the API to call when this intent is triggered
+
+
+
+
+
Fallback Messages
+
+
+ Timeout Message
+
+
+
+
+ Error Message
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Example
+
+
+
+ add
+ Add Example
+
+
+
+
0">
+
+ {{ example.example }}
+
+ delete
+
+
+
+
+
+
format_list_bulleted
+
No examples for {{ getLocaleName(selectedExampleLocale) }} yet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ param.get('name')?.value || 'New Parameter' }}
+
+
+
+ {{ param.get('type')?.value }}
+ Required
+ Optional
+
+
+
+
+
+
+
+
+
+
input
+
No parameters defined. Add parameters that need to be extracted from user input.
+
+
+
+
+
+
+
+
+ Cancel
+
+ Save
+
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss b/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss
index f917d69b09f35d4c2515d44d0a7051483f472025..223d79d58f59525f19a2734f971fbdfc71b5b06c 100644
--- a/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss
+++ b/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss
@@ -1,149 +1,149 @@
-mat-dialog-content {
- min-width: 700px;
- max-width: 900px;
- max-height: 70vh;
- padding: 0;
-}
-
-.tab-content {
- padding: 24px;
-}
-
-.full-width {
- width: 100%;
- margin-bottom: 16px;
-}
-
-h4 {
- margin: 24px 0 16px 0;
- color: rgba(0, 0, 0, 0.87);
-}
-
-mat-divider {
- margin: 24px 0;
-}
-
-// Examples Tab
-.examples-section {
- .examples-header {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-bottom: 16px;
-
- h4 {
- margin: 0;
- }
-
- .locale-selector {
- width: 150px;
- }
- }
-
- .add-example {
- display: flex;
- gap: 16px;
- align-items: flex-start;
- margin-bottom: 24px;
-
- .example-input {
- flex: 1;
- }
- }
-
- .examples-list {
- border: 1px solid #e0e0e0;
- border-radius: 4px;
- padding: 0;
-
- mat-list-item {
- border-bottom: 1px solid #f5f5f5;
-
- &:last-child {
- border-bottom: none;
- }
-
- &:hover {
- background-color: #f5f5f5;
- }
- }
- }
-}
-
-// Parameters Tab
-.parameters-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
-
- h4 {
- margin: 0;
- }
-}
-
-.parameters-list {
- mat-expansion-panel {
- margin-bottom: 8px;
-
- mat-chip-listbox {
- margin-left: 16px;
-
- mat-chip {
- font-size: 11px;
- min-height: 20px;
- padding: 2px 8px;
- }
- }
- }
-
- .parameter-content {
- padding: 16px;
-
- .parameter-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 16px;
- margin-bottom: 16px;
- }
-
- mat-checkbox {
- margin-bottom: 16px;
- }
-
- .parameter-actions {
- display: flex;
- align-items: center;
- margin-top: 16px;
- padding-top: 16px;
- border-top: 1px solid #e0e0e0;
-
- .spacer {
- flex: 1;
- }
- }
- }
-}
-
-.empty-state {
- text-align: center;
- padding: 40px 20px;
-
- mat-icon {
- font-size: 48px;
- width: 48px;
- height: 48px;
- color: #e0e0e0;
- margin-bottom: 16px;
- }
-
- p {
- color: #666;
- margin: 0;
- }
-}
-
-mat-dialog-actions {
- padding: 16px 24px;
- margin: 0;
+mat-dialog-content {
+ min-width: 700px;
+ max-width: 900px;
+ max-height: 70vh;
+ padding: 0;
+}
+
+.tab-content {
+ padding: 24px;
+}
+
+.full-width {
+ width: 100%;
+ margin-bottom: 16px;
+}
+
+h4 {
+ margin: 24px 0 16px 0;
+ color: rgba(0, 0, 0, 0.87);
+}
+
+mat-divider {
+ margin: 24px 0;
+}
+
+// Examples Tab
+.examples-section {
+ .examples-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 16px;
+
+ h4 {
+ margin: 0;
+ }
+
+ .locale-selector {
+ width: 150px;
+ }
+ }
+
+ .add-example {
+ display: flex;
+ gap: 16px;
+ align-items: flex-start;
+ margin-bottom: 24px;
+
+ .example-input {
+ flex: 1;
+ }
+ }
+
+ .examples-list {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ padding: 0;
+
+ mat-list-item {
+ border-bottom: 1px solid #f5f5f5;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:hover {
+ background-color: #f5f5f5;
+ }
+ }
+ }
+}
+
+// Parameters Tab
+.parameters-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+
+ h4 {
+ margin: 0;
+ }
+}
+
+.parameters-list {
+ mat-expansion-panel {
+ margin-bottom: 8px;
+
+ mat-chip-listbox {
+ margin-left: 16px;
+
+ mat-chip {
+ font-size: 11px;
+ min-height: 20px;
+ padding: 2px 8px;
+ }
+ }
+ }
+
+ .parameter-content {
+ padding: 16px;
+
+ .parameter-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ margin-bottom: 16px;
+ }
+
+ mat-checkbox {
+ margin-bottom: 16px;
+ }
+
+ .parameter-actions {
+ display: flex;
+ align-items: center;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 1px solid #e0e0e0;
+
+ .spacer {
+ flex: 1;
+ }
+ }
+ }
+}
+
+.empty-state {
+ text-align: center;
+ padding: 40px 20px;
+
+ mat-icon {
+ font-size: 48px;
+ width: 48px;
+ height: 48px;
+ color: #e0e0e0;
+ margin-bottom: 16px;
+ }
+
+ p {
+ color: #666;
+ margin: 0;
+ }
+}
+
+mat-dialog-actions {
+ padding: 16px 24px;
+ margin: 0;
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts b/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts
index ac5c7e8f68d9d26912659c222e1f0ddccae58bb6..ed31ccabe78f0db62693e76f60665d0f5a288d0c 100644
--- a/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts
+++ b/flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts
@@ -1,341 +1,341 @@
-import { Component, Inject, OnInit } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
-import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-import { MatChipsModule } from '@angular/material/chips';
-import { MatTableModule } from '@angular/material/table';
-import { MatTabsModule } from '@angular/material/tabs';
-import { MatExpansionModule } from '@angular/material/expansion';
-import { MatListModule } from '@angular/material/list';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatDialog } from '@angular/material/dialog';
-
-// Interfaces for multi-language support
-interface LocalizedExample {
- locale_code: string;
- example: string;
-}
-
-interface LocalizedCaption {
- locale_code: string;
- caption: string;
-}
-
-interface ParameterWithLocalizedCaption {
- name: string;
- caption: LocalizedCaption[];
- type: string;
- required: boolean;
- variable_name: string;
- extraction_prompt?: string;
- validation_regex?: string;
- invalid_prompt?: string;
- type_error_prompt?: string;
-}
-
-@Component({
- selector: 'app-intent-edit-dialog',
- standalone: true,
- imports: [
- CommonModule,
- ReactiveFormsModule,
- FormsModule,
- MatDialogModule,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
- MatCheckboxModule,
- MatButtonModule,
- MatIconModule,
- MatChipsModule,
- MatTableModule,
- MatTabsModule,
- MatExpansionModule,
- MatListModule,
- MatSnackBarModule
- ],
- templateUrl: './intent-edit-dialog.component.html',
- styleUrls: ['./intent-edit-dialog.component.scss']
-})
-export default class IntentEditDialogComponent implements OnInit {
- form!: FormGroup;
- availableAPIs: any[] = [];
- parameterTypes = ['str', 'int', 'float', 'bool', 'date'];
-
- // Multi-language support
- supportedLocales: string[] = [];
- selectedExampleLocale: string = '';
- examples: LocalizedExample[] = [];
-
- newExample = '';
-
- constructor(
- private fb: FormBuilder,
- private snackBar: MatSnackBar,
- private dialog: MatDialog,
- public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: any
- ) {
- this.availableAPIs = data.apis || [];
- this.supportedLocales = data.project?.supported_locales || data.supportedLocales || ['tr'];
- this.selectedExampleLocale = data.project?.default_locale || data.defaultLocale || this.supportedLocales[0] || 'tr';
- }
-
- ngOnInit() {
- this.initializeForm();
- if (this.data.intent) {
- this.populateForm(this.data.intent);
- }
- }
-
- initializeForm() {
- this.form = this.fb.group({
- name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9-]+$/)]],
- caption: ['', Validators.required],
- detection_prompt: ['', Validators.required],
- parameters: this.fb.array([]),
- action: ['', Validators.required],
- fallback_timeout_prompt: [''],
- fallback_error_prompt: ['']
- });
- }
-
- populateForm(intent: any) {
- // Populate basic fields
- this.form.patchValue({
- name: intent.name || '',
- caption: intent.caption || '',
- detection_prompt: intent.detection_prompt || '',
- action: intent.action || '',
- fallback_timeout_prompt: intent.fallback_timeout_prompt || '',
- fallback_error_prompt: intent.fallback_error_prompt || ''
- });
-
- // Populate localized examples
- if (intent.examples && Array.isArray(intent.examples)) {
- if (intent.examples.length > 0 && typeof intent.examples[0] === 'object' && 'locale_code' in intent.examples[0]) {
- // New format with LocalizedExample
- this.examples = [...intent.examples];
- } else if (typeof intent.examples[0] === 'string') {
- // Old format - convert to new format using default locale
- this.examples = intent.examples.map((ex: string) => ({
- locale_code: this.selectedExampleLocale,
- example: ex
- }));
- }
- }
-
- // Populate parameters with localized captions
- if (intent.parameters && Array.isArray(intent.parameters)) {
- const paramsArray = this.form.get('parameters') as FormArray;
- paramsArray.clear();
-
- intent.parameters.forEach((param: any) => {
- paramsArray.push(this.createParameterFormGroup(param));
- });
- }
- }
-
- createParameterFormGroup(param?: any): FormGroup {
- // Convert old caption format to new if needed
- let captionArray: LocalizedCaption[] = [];
- if (param?.caption) {
- if (Array.isArray(param.caption)) {
- captionArray = param.caption;
- } else if (typeof param.caption === 'string') {
- // Old format - convert to new
- captionArray = [{
- locale_code: this.selectedExampleLocale,
- caption: param.caption
- }];
- }
- }
-
- return this.fb.group({
- name: [param?.name || '', Validators.required],
- caption: [captionArray],
- type: [param?.type || 'str', Validators.required],
- required: [param?.required !== false],
- variable_name: [param?.variable_name || '', Validators.required],
- extraction_prompt: [param?.extraction_prompt || ''],
- validation_regex: [param?.validation_regex || ''],
- invalid_prompt: [param?.invalid_prompt || ''],
- type_error_prompt: [param?.type_error_prompt || '']
- });
- }
-
- get parameters() {
- return this.form.get('parameters') as FormArray;
- }
-
- addParameter() {
- this.parameters.push(this.createParameterFormGroup());
- }
-
- removeParameter(index: number) {
- this.parameters.removeAt(index);
- }
-
- // Multi-language example management
- getExamplesForCurrentLocale(): LocalizedExample[] {
- return this.examples.filter(ex => ex.locale_code === this.selectedExampleLocale);
- }
-
- addExample() {
- if (this.newExample.trim()) {
- const existingIndex = this.examples.findIndex(
- ex => ex.locale_code === this.selectedExampleLocale && ex.example === this.newExample.trim()
- );
-
- if (existingIndex === -1) {
- this.examples.push({
- locale_code: this.selectedExampleLocale,
- example: this.newExample.trim()
- });
- this.newExample = '';
- } else {
- this.snackBar.open('This example already exists for this locale', 'Close', { duration: 3000 });
- }
- }
- }
-
- removeExample(example: LocalizedExample) {
- const index = this.examples.findIndex(
- ex => ex.locale_code === example.locale_code && ex.example === example.example
- );
- if (index !== -1) {
- this.examples.splice(index, 1);
- }
- }
-
- // Test regex functionality
- testRegex(paramIndex: number) {
- const param = this.parameters.at(paramIndex);
- const regex = param.get('validation_regex')?.value;
-
- if (!regex) {
- this.snackBar.open('No regex pattern to test', 'Close', { duration: 2000 });
- return;
- }
-
- // Simple test implementation
- const testValue = prompt('Enter a test value:');
- if (testValue !== null) {
- try {
- const pattern = new RegExp(regex);
- const matches = pattern.test(testValue);
- this.snackBar.open(
- matches ? '✓ Pattern matches!' : '✗ Pattern does not match',
- 'Close',
- { duration: 3000 }
- );
- } catch (e) {
- this.snackBar.open('Invalid regex pattern', 'Close', { duration: 3000 });
- }
- }
- }
-
- // Move parameter up or down
- moveParameter(index: number, direction: 'up' | 'down') {
- const newIndex = direction === 'up' ? index - 1 : index + 1;
-
- if (newIndex < 0 || newIndex >= this.parameters.length) {
- return;
- }
-
- const currentItem = this.parameters.at(index);
- this.parameters.removeAt(index);
- this.parameters.insert(newIndex, currentItem);
- }
-
- // Parameter caption management
- getCaptionDisplay(captions: LocalizedCaption[]): string {
- if (!captions || captions.length === 0) return '(No caption)';
-
- // Try to find caption for default locale
- const defaultCaption = captions.find(c => c.locale_code === (this.data.project?.default_locale || this.data.defaultLocale || 'tr'));
- if (defaultCaption) return defaultCaption.caption;
-
- // Return first available caption
- return captions[0].caption;
- }
-
- async openCaptionDialog(paramIndex: number) {
- const param = this.parameters.at(paramIndex);
- const currentCaptions = param.get('caption')?.value || [];
-
- // Import and open caption dialog
- const { default: CaptionDialogComponent } = await import('../caption-dialog/caption-dialog.component');
-
- const dialogRef = this.dialog.open(CaptionDialogComponent, {
- width: '600px',
- data: {
- captions: [...currentCaptions],
- supportedLocales: this.supportedLocales,
- defaultLocale: this.data.project?.default_locale || this.data.defaultLocale
- }
- });
-
- dialogRef.afterClosed().subscribe(result => {
- if (result) {
- param.patchValue({ caption: result });
- }
- });
- }
-
- // Locale helpers
- getLocaleName(localeCode: string): string {
- const localeNames: { [key: string]: string } = {
- 'tr': 'Türkçe',
- 'en': 'English',
- 'de': 'Deutsch',
- 'fr': 'Français',
- 'es': 'Español',
- 'ar': 'العربية',
- 'ru': 'Русский',
- 'zh': '中文',
- 'ja': '日本語',
- 'ko': '한국어'
- };
- return localeNames[localeCode] || localeCode;
- }
-
- onSubmit() {
- if (this.form.valid) {
- const formValue = this.form.value;
-
- // Add examples to the result
- formValue.examples = this.examples;
-
- // Ensure all parameters have captions
- formValue.parameters = formValue.parameters.map((param: any) => {
- if (!param.caption || param.caption.length === 0) {
- // Create default caption if missing
- param.caption = [{
- locale_code: this.data.project?.default_locale || this.data.defaultLocale || 'tr',
- caption: param.name
- }];
- }
- return param;
- });
-
- this.dialogRef.close(formValue);
- } else {
- this.snackBar.open('Please fill all required fields', 'Close', { duration: 3000 });
- }
- }
-
- save() {
- this.onSubmit();
- }
-
- cancel() {
- this.dialogRef.close();
- }
+import { Component, Inject, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatTableModule } from '@angular/material/table';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatListModule } from '@angular/material/list';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatDialog } from '@angular/material/dialog';
+
+// Interfaces for multi-language support
+interface LocalizedExample {
+ locale_code: string;
+ example: string;
+}
+
+interface LocalizedCaption {
+ locale_code: string;
+ caption: string;
+}
+
+interface ParameterWithLocalizedCaption {
+ name: string;
+ caption: LocalizedCaption[];
+ type: string;
+ required: boolean;
+ variable_name: string;
+ extraction_prompt?: string;
+ validation_regex?: string;
+ invalid_prompt?: string;
+ type_error_prompt?: string;
+}
+
+@Component({
+ selector: 'app-intent-edit-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ FormsModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule,
+ MatCheckboxModule,
+ MatButtonModule,
+ MatIconModule,
+ MatChipsModule,
+ MatTableModule,
+ MatTabsModule,
+ MatExpansionModule,
+ MatListModule,
+ MatSnackBarModule
+ ],
+ templateUrl: './intent-edit-dialog.component.html',
+ styleUrls: ['./intent-edit-dialog.component.scss']
+})
+export default class IntentEditDialogComponent implements OnInit {
+ form!: FormGroup;
+ availableAPIs: any[] = [];
+ parameterTypes = ['str', 'int', 'float', 'bool', 'date'];
+
+ // Multi-language support
+ supportedLocales: string[] = [];
+ selectedExampleLocale: string = '';
+ examples: LocalizedExample[] = [];
+
+ newExample = '';
+
+ constructor(
+ private fb: FormBuilder,
+ private snackBar: MatSnackBar,
+ private dialog: MatDialog,
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: any
+ ) {
+ this.availableAPIs = data.apis || [];
+ this.supportedLocales = data.project?.supported_locales || data.supportedLocales || ['tr'];
+ this.selectedExampleLocale = data.project?.default_locale || data.defaultLocale || this.supportedLocales[0] || 'tr';
+ }
+
+ ngOnInit() {
+ this.initializeForm();
+ if (this.data.intent) {
+ this.populateForm(this.data.intent);
+ }
+ }
+
+ initializeForm() {
+ this.form = this.fb.group({
+ name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9-]+$/)]],
+ caption: ['', Validators.required],
+ detection_prompt: ['', Validators.required],
+ parameters: this.fb.array([]),
+ action: ['', Validators.required],
+ fallback_timeout_prompt: [''],
+ fallback_error_prompt: ['']
+ });
+ }
+
+ populateForm(intent: any) {
+ // Populate basic fields
+ this.form.patchValue({
+ name: intent.name || '',
+ caption: intent.caption || '',
+ detection_prompt: intent.detection_prompt || '',
+ action: intent.action || '',
+ fallback_timeout_prompt: intent.fallback_timeout_prompt || '',
+ fallback_error_prompt: intent.fallback_error_prompt || ''
+ });
+
+ // Populate localized examples
+ if (intent.examples && Array.isArray(intent.examples)) {
+ if (intent.examples.length > 0 && typeof intent.examples[0] === 'object' && 'locale_code' in intent.examples[0]) {
+ // New format with LocalizedExample
+ this.examples = [...intent.examples];
+ } else if (typeof intent.examples[0] === 'string') {
+ // Old format - convert to new format using default locale
+ this.examples = intent.examples.map((ex: string) => ({
+ locale_code: this.selectedExampleLocale,
+ example: ex
+ }));
+ }
+ }
+
+ // Populate parameters with localized captions
+ if (intent.parameters && Array.isArray(intent.parameters)) {
+ const paramsArray = this.form.get('parameters') as FormArray;
+ paramsArray.clear();
+
+ intent.parameters.forEach((param: any) => {
+ paramsArray.push(this.createParameterFormGroup(param));
+ });
+ }
+ }
+
+ createParameterFormGroup(param?: any): FormGroup {
+ // Convert old caption format to new if needed
+ let captionArray: LocalizedCaption[] = [];
+ if (param?.caption) {
+ if (Array.isArray(param.caption)) {
+ captionArray = param.caption;
+ } else if (typeof param.caption === 'string') {
+ // Old format - convert to new
+ captionArray = [{
+ locale_code: this.selectedExampleLocale,
+ caption: param.caption
+ }];
+ }
+ }
+
+ return this.fb.group({
+ name: [param?.name || '', Validators.required],
+ caption: [captionArray],
+ type: [param?.type || 'str', Validators.required],
+ required: [param?.required !== false],
+ variable_name: [param?.variable_name || '', Validators.required],
+ extraction_prompt: [param?.extraction_prompt || ''],
+ validation_regex: [param?.validation_regex || ''],
+ invalid_prompt: [param?.invalid_prompt || ''],
+ type_error_prompt: [param?.type_error_prompt || '']
+ });
+ }
+
+ get parameters() {
+ return this.form.get('parameters') as FormArray;
+ }
+
+ addParameter() {
+ this.parameters.push(this.createParameterFormGroup());
+ }
+
+ removeParameter(index: number) {
+ this.parameters.removeAt(index);
+ }
+
+ // Multi-language example management
+ getExamplesForCurrentLocale(): LocalizedExample[] {
+ return this.examples.filter(ex => ex.locale_code === this.selectedExampleLocale);
+ }
+
+ addExample() {
+ if (this.newExample.trim()) {
+ const existingIndex = this.examples.findIndex(
+ ex => ex.locale_code === this.selectedExampleLocale && ex.example === this.newExample.trim()
+ );
+
+ if (existingIndex === -1) {
+ this.examples.push({
+ locale_code: this.selectedExampleLocale,
+ example: this.newExample.trim()
+ });
+ this.newExample = '';
+ } else {
+ this.snackBar.open('This example already exists for this locale', 'Close', { duration: 3000 });
+ }
+ }
+ }
+
+ removeExample(example: LocalizedExample) {
+ const index = this.examples.findIndex(
+ ex => ex.locale_code === example.locale_code && ex.example === example.example
+ );
+ if (index !== -1) {
+ this.examples.splice(index, 1);
+ }
+ }
+
+ // Test regex functionality
+ testRegex(paramIndex: number) {
+ const param = this.parameters.at(paramIndex);
+ const regex = param.get('validation_regex')?.value;
+
+ if (!regex) {
+ this.snackBar.open('No regex pattern to test', 'Close', { duration: 2000 });
+ return;
+ }
+
+ // Simple test implementation
+ const testValue = prompt('Enter a test value:');
+ if (testValue !== null) {
+ try {
+ const pattern = new RegExp(regex);
+ const matches = pattern.test(testValue);
+ this.snackBar.open(
+ matches ? '✓ Pattern matches!' : '✗ Pattern does not match',
+ 'Close',
+ { duration: 3000 }
+ );
+ } catch (e) {
+ this.snackBar.open('Invalid regex pattern', 'Close', { duration: 3000 });
+ }
+ }
+ }
+
+ // Move parameter up or down
+ moveParameter(index: number, direction: 'up' | 'down') {
+ const newIndex = direction === 'up' ? index - 1 : index + 1;
+
+ if (newIndex < 0 || newIndex >= this.parameters.length) {
+ return;
+ }
+
+ const currentItem = this.parameters.at(index);
+ this.parameters.removeAt(index);
+ this.parameters.insert(newIndex, currentItem);
+ }
+
+ // Parameter caption management
+ getCaptionDisplay(captions: LocalizedCaption[]): string {
+ if (!captions || captions.length === 0) return '(No caption)';
+
+ // Try to find caption for default locale
+ const defaultCaption = captions.find(c => c.locale_code === (this.data.project?.default_locale || this.data.defaultLocale || 'tr'));
+ if (defaultCaption) return defaultCaption.caption;
+
+ // Return first available caption
+ return captions[0].caption;
+ }
+
+ async openCaptionDialog(paramIndex: number) {
+ const param = this.parameters.at(paramIndex);
+ const currentCaptions = param.get('caption')?.value || [];
+
+ // Import and open caption dialog
+ const { default: CaptionDialogComponent } = await import('../caption-dialog/caption-dialog.component');
+
+ const dialogRef = this.dialog.open(CaptionDialogComponent, {
+ width: '600px',
+ data: {
+ captions: [...currentCaptions],
+ supportedLocales: this.supportedLocales,
+ defaultLocale: this.data.project?.default_locale || this.data.defaultLocale
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(result => {
+ if (result) {
+ param.patchValue({ caption: result });
+ }
+ });
+ }
+
+ // Locale helpers
+ getLocaleName(localeCode: string): string {
+ const localeNames: { [key: string]: string } = {
+ 'tr': 'Türkçe',
+ 'en': 'English',
+ 'de': 'Deutsch',
+ 'fr': 'Français',
+ 'es': 'Español',
+ 'ar': 'العربية',
+ 'ru': 'Русский',
+ 'zh': '中文',
+ 'ja': '日本語',
+ 'ko': '한국어'
+ };
+ return localeNames[localeCode] || localeCode;
+ }
+
+ onSubmit() {
+ if (this.form.valid) {
+ const formValue = this.form.value;
+
+ // Add examples to the result
+ formValue.examples = this.examples;
+
+ // Ensure all parameters have captions
+ formValue.parameters = formValue.parameters.map((param: any) => {
+ if (!param.caption || param.caption.length === 0) {
+ // Create default caption if missing
+ param.caption = [{
+ locale_code: this.data.project?.default_locale || this.data.defaultLocale || 'tr',
+ caption: param.name
+ }];
+ }
+ return param;
+ });
+
+ this.dialogRef.close(formValue);
+ } else {
+ this.snackBar.open('Please fill all required fields', 'Close', { duration: 3000 });
+ }
+ }
+
+ save() {
+ this.onSubmit();
+ }
+
+ cancel() {
+ this.dialogRef.close();
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss b/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss
index 73cf030b5c759420bdb3635955aa202884314a85..3e6308d93b590f3934eeccc80a57b76808429360 100644
--- a/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss
+++ b/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss
@@ -1,92 +1,92 @@
-mat-dialog-content {
- padding: 20px 24px;
- min-width: 500px;
- max-width: 600px;
-}
-
-.full-width {
- width: 100%;
- margin-bottom: 16px;
-}
-
-.test-users-section {
- margin-top: 24px;
-
- h4 {
- margin-bottom: 16px;
- color: rgba(0, 0, 0, 0.87);
- }
-
- .test-user-row {
- display: flex;
- gap: 8px;
- align-items: flex-start;
- margin-bottom: 8px;
-
- .flex-1 {
- flex: 1;
- }
-
- button {
- margin-top: 8px;
- }
- }
-}
-
-mat-dialog-actions {
- padding: 16px 24px;
- margin: 0;
- border-top: 1px solid #e0e0e0;
-}
-
-// Locale specific styles
-.locale-code {
- color: #666;
- font-size: 0.85em;
- margin-left: 8px;
- font-family: 'Courier New', monospace;
-}
-
-.selected-languages {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
- align-items: center;
-
- span {
- white-space: nowrap;
- }
-}
-
-mat-option {
- &:hover .locale-code {
- color: #333;
- }
-}
-
-// Multi-select için özel stil
-.mat-mdc-select-trigger {
- min-height: 56px;
- display: flex;
- align-items: center;
- padding: 0 16px;
-}
-
-// Loading spinner in select
-mat-spinner {
- margin: 0 auto;
-}
-
-// Material form field density
-::ng-deep {
- .mat-mdc-form-field {
- margin-bottom: 4px;
- }
-
- .mat-mdc-option {
- .mat-icon {
- margin-right: 8px;
- vertical-align: middle;
- }
- }
+mat-dialog-content {
+ padding: 20px 24px;
+ min-width: 500px;
+ max-width: 600px;
+}
+
+.full-width {
+ width: 100%;
+ margin-bottom: 16px;
+}
+
+.test-users-section {
+ margin-top: 24px;
+
+ h4 {
+ margin-bottom: 16px;
+ color: rgba(0, 0, 0, 0.87);
+ }
+
+ .test-user-row {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+ margin-bottom: 8px;
+
+ .flex-1 {
+ flex: 1;
+ }
+
+ button {
+ margin-top: 8px;
+ }
+ }
+}
+
+mat-dialog-actions {
+ padding: 16px 24px;
+ margin: 0;
+ border-top: 1px solid #e0e0e0;
+}
+
+// Locale specific styles
+.locale-code {
+ color: #666;
+ font-size: 0.85em;
+ margin-left: 8px;
+ font-family: 'Courier New', monospace;
+}
+
+.selected-languages {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ align-items: center;
+
+ span {
+ white-space: nowrap;
+ }
+}
+
+mat-option {
+ &:hover .locale-code {
+ color: #333;
+ }
+}
+
+// Multi-select için özel stil
+.mat-mdc-select-trigger {
+ min-height: 56px;
+ display: flex;
+ align-items: center;
+ padding: 0 16px;
+}
+
+// Loading spinner in select
+mat-spinner {
+ margin: 0 auto;
+}
+
+// Material form field density
+::ng-deep {
+ .mat-mdc-form-field {
+ margin-bottom: 4px;
+ }
+
+ .mat-mdc-option {
+ .mat-icon {
+ margin-right: 8px;
+ vertical-align: middle;
+ }
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts b/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts
index 5cb767bf09a3a6cb20008b79b307b3da539c1f6d..954b08d8d0ac6aa05eaa1c8e1fe70e1539535e00 100644
--- a/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts
+++ b/flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts
@@ -1,486 +1,486 @@
-// project-edit-dialog.component.ts
-import { Component, Inject, OnInit, OnDestroy } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
-import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-import { MatChipsModule } from '@angular/material/chips';
-import { MatDividerModule } from '@angular/material/divider';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { ApiService } from '../../services/api.service';
-import { LocaleManagerService, Locale } from '../../services/locale-manager.service';
-import { Subject, takeUntil } from 'rxjs';
-import { HttpErrorResponse } from '@angular/common/http';
-
-export interface ProjectDialogData {
- mode: 'create' | 'edit';
- project?: any;
-}
-
-@Component({
- selector: 'app-project-edit-dialog',
- standalone: true,
- imports: [
- CommonModule,
- ReactiveFormsModule,
- MatDialogModule,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
- MatCheckboxModule,
- MatButtonModule,
- MatIconModule,
- MatChipsModule,
- MatDividerModule,
- MatSnackBarModule,
- MatProgressSpinnerModule
- ],
- template: `
- {{ data.mode === 'create' ? 'Create New Project' : 'Edit Project' }}
-
-
-
-
- Name*
-
- Use only letters, numbers, and underscores
- {{ getErrorMessage('name') }}
-
-
-
- Caption*
-
- {{ getErrorMessage('caption') }}
-
-
-
- Icon
-
- @for (icon of projectIcons; track icon) {
-
- {{ icon }}
- {{ icon }}
-
- }
-
-
-
-
- Description
-
-
-
-
-
- Default Locale
-
- @if (loadingLocales) {
-
-
- Loading Locales...
-
- }
- @for (locale of availableLocales; track locale.code) {
-
- {{ locale.name }}
- {{ locale.code }}
-
- }
-
- translate
- Primary Locale for this project
-
-
-
-
- Supported Locales
-
-
-
- @for (lang of form.get('supportedLocales')?.value || []; track lang; let last = $last) {
- {{ getLocaleName(lang) }}@if (!last) {, }
- }
-
-
- @for (locale of availableLocales; track locale.code) {
-
- {{ locale.name }}
- {{ locale.code }}
-
- }
-
- locale
- Locales available in this project
-
-
-
- Timezone
-
- @for (tz of timezones; track tz) {
- {{ tz }}
- }
-
-
-
-
- Region
-
-
-
-
-
-
- Cancel
-
- {{ saving ? 'Saving...' : (data.mode === 'create' ? 'Create' : 'Save') }}
-
-
- `,
- styleUrls: ['./project-edit-dialog.component.scss']
-})
-export default class ProjectEditDialogComponent implements OnInit, OnDestroy {
- form!: FormGroup;
- saving = false;
- loadingLocales = true;
- availableLocales: Locale[] = [];
-
- // Memory leak prevention
- private destroyed$ = new Subject();
-
- projectIcons = ['folder', 'work', 'shopping_cart', 'school', 'local_hospital', 'restaurant', 'home', 'business'];
-
- timezones = [
- 'Europe/Istanbul',
- 'Europe/London',
- 'Europe/Berlin',
- 'America/New_York',
- 'America/Los_Angeles',
- 'Asia/Tokyo'
- ];
-
- constructor(
- private fb: FormBuilder,
- private apiService: ApiService,
- private localeManager: LocaleManagerService,
- private snackBar: MatSnackBar,
- public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: ProjectDialogData
- ) {}
-
- ngOnInit() {
- this.initializeForm();
- this.loadAvailableLocales();
- }
-
- ngOnDestroy() {
- this.destroyed$.next();
- this.destroyed$.complete();
- }
-
- initializeForm() {
- const defaultValues = this.data.mode === 'edit' && this.data.project ? {
- name: this.data.project.name,
- caption: this.data.project.caption || '',
- icon: this.data.project.icon || 'folder',
- description: this.data.project.description || '',
- defaultLocale: this.data.project.default_locale || 'tr',
- supportedLocales: this.data.project.supported_locales || ['tr'], // Düzeltildi: supportedLolcales -> supportedLocales
- timezone: this.data.project.timezone || 'Europe/Istanbul',
- region: this.data.project.region || 'tr-TR'
- } : {
- name: '',
- caption: '',
- icon: 'folder',
- description: '',
- defaultLocale: 'tr',
- supportedLocales: ['tr'],
- timezone: 'Europe/Istanbul',
- region: 'tr-TR'
- };
-
- this.form = this.fb.group({
- name: [defaultValues.name, [Validators.required, Validators.pattern(/^[a-z0-9_]+$/)]],
- caption: [defaultValues.caption, Validators.required],
- icon: [defaultValues.icon],
- description: [defaultValues.description],
- defaultLocale: [defaultValues.defaultLocale],
- supportedLocales: [defaultValues.supportedLocales],
- timezone: [defaultValues.timezone],
- region: [defaultValues.region]
- });
-
- // Disable name field in edit mode
- if (this.data.mode === 'edit') {
- this.form.get('name')?.disable();
- }
- }
-
- loadAvailableLocales() {
- this.loadingLocales = true;
- this.localeManager.getAvailableLocales()
- .pipe(takeUntil(this.destroyed$))
- .subscribe({
- next: (locales) => {
- this.availableLocales = locales;
- this.loadingLocales = false;
- this.validateSelectedLocales();
- },
- error: (err) => {
- this.showMessage('Failed to load available locales', 'error');
- this.loadingLocales = false;
- // Use fallback locales
- this.availableLocales = [
- { code: 'tr', name: 'Türkçe', english_name: 'Turkish' },
- { code: 'en', name: 'English', english_name: 'English' }
- ];
- }
- });
- }
-
- validateSelectedLocales() {
- const availableCodes = this.availableLocales.map(l => l.code);
- const currentSupported = this.form.get('supportedLocales')?.value || [];
- const currentDefault = this.form.get('defaultLocale')?.value;
-
- // Filter out any unsupported Locales
- const validSupported = currentSupported.filter((lang: string) =>
- availableCodes.includes(lang)
- );
-
- // Update form if any Locales were removed
- if (validSupported.length !== currentSupported.length) {
- this.form.patchValue({ supportedLocales: validSupported });
- }
-
- // Ensure default Locale is valid
- if (!availableCodes.includes(currentDefault)) {
- const newDefault = availableCodes[0] || 'tr-TR';
- this.form.patchValue({
- defaultLocale: newDefault,
- supportedLocales: [...validSupported, newDefault]
- });
- }
- }
-
- onDefaultLocaleChange() {
- // Default Locale değiştiğinde bir şey yapmaya gerek yok
- // Çünkü default_locale (Türkçe) ve supported_locales (tr-TR) farklı tipte
- }
-
- onSupportedLocalesChange() {
- // Supported locales değiştiğinde de bir şey yapmaya gerek yok
- // En az bir dil seçili olduğu sürece sorun yok
- const supportedLocales = this.form.get('supportedLocales')?.value || [];
- if (supportedLocales.length === 0) {
- // En az bir dil seçilmeli
- this.form.patchValue({
- supportedLocales: ['tr-TR']
- });
- }
- }
-
- getLocaleName(code: string): string {
- // Önce availableLocales'da ara
- const locale = this.availableLocales.find(l => l.code === code);
- if (locale) {
- return locale.name;
- }
-
- // Bulamazsan fallback locale isimleri kullan
- const localeNames: { [key: string]: string } = {
- 'tr': 'Türkçe',
- 'tr-TR': 'Türkçe',
- 'en': 'English',
- 'en-US': 'English',
- 'en-GB': 'English (UK)',
- 'de': 'Deutsch',
- 'de-DE': 'Deutsch',
- 'fr': 'Français',
- 'fr-FR': 'Français',
- 'es': 'Español',
- 'es-ES': 'Español',
- 'ar': 'العربية',
- 'ar-SA': 'العربية',
- 'ru': 'Русский',
- 'ru-RU': 'Русский',
- 'zh': '中文',
- 'zh-CN': '中文',
- 'ja': '日本語',
- 'ja-JP': '日本語',
- 'ko': '한국어',
- 'ko-KR': '한국어'
- };
-
- return localeNames[code] || code;
- }
-
- getErrorMessage(fieldName: string): string {
- const control = this.form.get(fieldName);
- if (!control) return '';
-
- if (control.hasError('required')) {
- return `${this.getFieldLabel(fieldName)} is required`;
- }
- if (control.hasError('pattern')) {
- return `${this.getFieldLabel(fieldName)} contains invalid characters`;
- }
- if (control.hasError('server')) {
- return control.errors?.['server'];
- }
- return '';
- }
-
- private getFieldLabel(fieldName: string): string {
- const labels: { [key: string]: string } = {
- 'name': 'Project Name',
- 'caption': 'Caption',
- 'description': 'Description',
- 'defaultLocale': 'Default Locale',
- 'supportedLocales': 'Supported Locales',
- 'timezone': 'Timezone',
- 'region': 'Region',
- 'icon': 'Icon'
- };
- return labels[fieldName] || fieldName;
- }
-
- handleValidationError(error: HttpErrorResponse): void {
- if (error.status === 422 && error.error?.details) {
- // Show specific field errors
- error.error.details.forEach((detail: any) => {
- const control = this.form.get(detail.field);
- if (control) {
- control.setErrors({ server: detail.message });
- control.markAsTouched();
- }
- });
-
- this.snackBar.open(
- 'Please fix the validation errors',
- 'Close',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- );
- } else {
- // Generic error handling
- this.showMessage(
- error.error?.detail || error.message || 'Operation failed',
- 'error'
- );
- }
- }
-
- save() {
- console.log('Save clicked - Form valid:', this.form.valid, 'Saving:', this.saving);
- console.log('Form errors:', this.form.errors);
- console.log('Form value:', this.form.value);
-
- if (this.form.invalid || this.saving) {
- // Mark all fields as touched to show validation errors
- Object.keys(this.form.controls).forEach(key => {
- const control = this.form.get(key);
- if (control) {
- control.markAsTouched();
- if (control.errors) {
- console.log(`Field ${key} errors:`, control.errors);
- }
- }
- });
-
- if (this.form.invalid) {
- this.showMessage('Please fill all required fields correctly', 'error');
- }
- return;
- }
-
- this.saving = true;
-
- const formValue = this.form.getRawValue(); // getRawValue to include disabled fields
-
- // Project data format matching backend expectations
- const projectData = {
- name: formValue.name,
- caption: formValue.caption,
- icon: formValue.icon,
- description: formValue.description,
- default_locale: formValue.defaultLocale,
- supported_locales: formValue.supportedLocales,
- timezone: formValue.timezone,
- region: formValue.region
- };
-
- const saveOperation = this.data.mode === 'create'
- ? this.apiService.createProject(projectData)
- : this.apiService.updateProject(this.data.project.id, {
- ...projectData,
- last_update_date: this.data.project.last_update_date || ''
- });
-
- saveOperation
- .pipe(takeUntil(this.destroyed$))
- .subscribe({
- next: (result) => {
- this.saving = false;
- this.showMessage(
- this.data.mode === 'create'
- ? 'Project created successfully!'
- : 'Project updated successfully!'
- );
- this.dialogRef.close(result);
- },
- error: (error: HttpErrorResponse) => {
- this.saving = false;
-
- // Race condition handling
- if (error.status === 409) {
- const details = error.error?.details || {};
- this.snackBar.open(
- `Project was modified by ${details.last_update_user || 'another user'}. Please reload.`,
- 'Reload',
- { duration: 0 }
- ).onAction().subscribe(() => {
- this.dialogRef.close('reload');
- });
- } else if (error.status === 422) {
- this.handleValidationError(error);
- } else {
- this.showMessage(
- error.error?.detail || 'Operation failed',
- 'error'
- );
- }
- }
- });
- }
-
- close() {
- this.dialogRef.close();
- }
-
- private showMessage(message: string, type: 'success' | 'error' = 'success') {
- this.snackBar.open(message, 'Close', {
- duration: 5000,
- panelClass: type === 'error' ? ['error-snackbar'] : ['success-snackbar'],
- horizontalPosition: 'right',
- verticalPosition: 'top'
- });
- }
+// project-edit-dialog.component.ts
+import { Component, Inject, OnInit, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatDividerModule } from '@angular/material/divider';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { ApiService } from '../../services/api.service';
+import { LocaleManagerService, Locale } from '../../services/locale-manager.service';
+import { Subject, takeUntil } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+
+export interface ProjectDialogData {
+ mode: 'create' | 'edit';
+ project?: any;
+}
+
+@Component({
+ selector: 'app-project-edit-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ MatDialogModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule,
+ MatCheckboxModule,
+ MatButtonModule,
+ MatIconModule,
+ MatChipsModule,
+ MatDividerModule,
+ MatSnackBarModule,
+ MatProgressSpinnerModule
+ ],
+ template: `
+ {{ data.mode === 'create' ? 'Create New Project' : 'Edit Project' }}
+
+
+
+
+ Name*
+
+ Use only letters, numbers, and underscores
+ {{ getErrorMessage('name') }}
+
+
+
+ Caption*
+
+ {{ getErrorMessage('caption') }}
+
+
+
+ Icon
+
+ @for (icon of projectIcons; track icon) {
+
+ {{ icon }}
+ {{ icon }}
+
+ }
+
+
+
+
+ Description
+
+
+
+
+
+ Default Locale
+
+ @if (loadingLocales) {
+
+
+ Loading Locales...
+
+ }
+ @for (locale of availableLocales; track locale.code) {
+
+ {{ locale.name }}
+ {{ locale.code }}
+
+ }
+
+ translate
+ Primary Locale for this project
+
+
+
+
+ Supported Locales
+
+
+
+ @for (lang of form.get('supportedLocales')?.value || []; track lang; let last = $last) {
+ {{ getLocaleName(lang) }}@if (!last) {, }
+ }
+
+
+ @for (locale of availableLocales; track locale.code) {
+
+ {{ locale.name }}
+ {{ locale.code }}
+
+ }
+
+ locale
+ Locales available in this project
+
+
+
+ Timezone
+
+ @for (tz of timezones; track tz) {
+ {{ tz }}
+ }
+
+
+
+
+ Region
+
+
+
+
+
+
+ Cancel
+
+ {{ saving ? 'Saving...' : (data.mode === 'create' ? 'Create' : 'Save') }}
+
+
+ `,
+ styleUrls: ['./project-edit-dialog.component.scss']
+})
+export default class ProjectEditDialogComponent implements OnInit, OnDestroy {
+ form!: FormGroup;
+ saving = false;
+ loadingLocales = true;
+ availableLocales: Locale[] = [];
+
+ // Memory leak prevention
+ private destroyed$ = new Subject();
+
+ projectIcons = ['folder', 'work', 'shopping_cart', 'school', 'local_hospital', 'restaurant', 'home', 'business'];
+
+ timezones = [
+ 'Europe/Istanbul',
+ 'Europe/London',
+ 'Europe/Berlin',
+ 'America/New_York',
+ 'America/Los_Angeles',
+ 'Asia/Tokyo'
+ ];
+
+ constructor(
+ private fb: FormBuilder,
+ private apiService: ApiService,
+ private localeManager: LocaleManagerService,
+ private snackBar: MatSnackBar,
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: ProjectDialogData
+ ) {}
+
+ ngOnInit() {
+ this.initializeForm();
+ this.loadAvailableLocales();
+ }
+
+ ngOnDestroy() {
+ this.destroyed$.next();
+ this.destroyed$.complete();
+ }
+
+ initializeForm() {
+ const defaultValues = this.data.mode === 'edit' && this.data.project ? {
+ name: this.data.project.name,
+ caption: this.data.project.caption || '',
+ icon: this.data.project.icon || 'folder',
+ description: this.data.project.description || '',
+ defaultLocale: this.data.project.default_locale || 'tr',
+ supportedLocales: this.data.project.supported_locales || ['tr'], // Düzeltildi: supportedLolcales -> supportedLocales
+ timezone: this.data.project.timezone || 'Europe/Istanbul',
+ region: this.data.project.region || 'tr-TR'
+ } : {
+ name: '',
+ caption: '',
+ icon: 'folder',
+ description: '',
+ defaultLocale: 'tr',
+ supportedLocales: ['tr'],
+ timezone: 'Europe/Istanbul',
+ region: 'tr-TR'
+ };
+
+ this.form = this.fb.group({
+ name: [defaultValues.name, [Validators.required, Validators.pattern(/^[a-z0-9_]+$/)]],
+ caption: [defaultValues.caption, Validators.required],
+ icon: [defaultValues.icon],
+ description: [defaultValues.description],
+ defaultLocale: [defaultValues.defaultLocale],
+ supportedLocales: [defaultValues.supportedLocales],
+ timezone: [defaultValues.timezone],
+ region: [defaultValues.region]
+ });
+
+ // Disable name field in edit mode
+ if (this.data.mode === 'edit') {
+ this.form.get('name')?.disable();
+ }
+ }
+
+ loadAvailableLocales() {
+ this.loadingLocales = true;
+ this.localeManager.getAvailableLocales()
+ .pipe(takeUntil(this.destroyed$))
+ .subscribe({
+ next: (locales) => {
+ this.availableLocales = locales;
+ this.loadingLocales = false;
+ this.validateSelectedLocales();
+ },
+ error: (err) => {
+ this.showMessage('Failed to load available locales', 'error');
+ this.loadingLocales = false;
+ // Use fallback locales
+ this.availableLocales = [
+ { code: 'tr', name: 'Türkçe', english_name: 'Turkish' },
+ { code: 'en', name: 'English', english_name: 'English' }
+ ];
+ }
+ });
+ }
+
+ validateSelectedLocales() {
+ const availableCodes = this.availableLocales.map(l => l.code);
+ const currentSupported = this.form.get('supportedLocales')?.value || [];
+ const currentDefault = this.form.get('defaultLocale')?.value;
+
+ // Filter out any unsupported Locales
+ const validSupported = currentSupported.filter((lang: string) =>
+ availableCodes.includes(lang)
+ );
+
+ // Update form if any Locales were removed
+ if (validSupported.length !== currentSupported.length) {
+ this.form.patchValue({ supportedLocales: validSupported });
+ }
+
+ // Ensure default Locale is valid
+ if (!availableCodes.includes(currentDefault)) {
+ const newDefault = availableCodes[0] || 'tr-TR';
+ this.form.patchValue({
+ defaultLocale: newDefault,
+ supportedLocales: [...validSupported, newDefault]
+ });
+ }
+ }
+
+ onDefaultLocaleChange() {
+ // Default Locale değiştiğinde bir şey yapmaya gerek yok
+ // Çünkü default_locale (Türkçe) ve supported_locales (tr-TR) farklı tipte
+ }
+
+ onSupportedLocalesChange() {
+ // Supported locales değiştiğinde de bir şey yapmaya gerek yok
+ // En az bir dil seçili olduğu sürece sorun yok
+ const supportedLocales = this.form.get('supportedLocales')?.value || [];
+ if (supportedLocales.length === 0) {
+ // En az bir dil seçilmeli
+ this.form.patchValue({
+ supportedLocales: ['tr-TR']
+ });
+ }
+ }
+
+ getLocaleName(code: string): string {
+ // Önce availableLocales'da ara
+ const locale = this.availableLocales.find(l => l.code === code);
+ if (locale) {
+ return locale.name;
+ }
+
+ // Bulamazsan fallback locale isimleri kullan
+ const localeNames: { [key: string]: string } = {
+ 'tr': 'Türkçe',
+ 'tr-TR': 'Türkçe',
+ 'en': 'English',
+ 'en-US': 'English',
+ 'en-GB': 'English (UK)',
+ 'de': 'Deutsch',
+ 'de-DE': 'Deutsch',
+ 'fr': 'Français',
+ 'fr-FR': 'Français',
+ 'es': 'Español',
+ 'es-ES': 'Español',
+ 'ar': 'العربية',
+ 'ar-SA': 'العربية',
+ 'ru': 'Русский',
+ 'ru-RU': 'Русский',
+ 'zh': '中文',
+ 'zh-CN': '中文',
+ 'ja': '日本語',
+ 'ja-JP': '日本語',
+ 'ko': '한국어',
+ 'ko-KR': '한국어'
+ };
+
+ return localeNames[code] || code;
+ }
+
+ getErrorMessage(fieldName: string): string {
+ const control = this.form.get(fieldName);
+ if (!control) return '';
+
+ if (control.hasError('required')) {
+ return `${this.getFieldLabel(fieldName)} is required`;
+ }
+ if (control.hasError('pattern')) {
+ return `${this.getFieldLabel(fieldName)} contains invalid characters`;
+ }
+ if (control.hasError('server')) {
+ return control.errors?.['server'];
+ }
+ return '';
+ }
+
+ private getFieldLabel(fieldName: string): string {
+ const labels: { [key: string]: string } = {
+ 'name': 'Project Name',
+ 'caption': 'Caption',
+ 'description': 'Description',
+ 'defaultLocale': 'Default Locale',
+ 'supportedLocales': 'Supported Locales',
+ 'timezone': 'Timezone',
+ 'region': 'Region',
+ 'icon': 'Icon'
+ };
+ return labels[fieldName] || fieldName;
+ }
+
+ handleValidationError(error: HttpErrorResponse): void {
+ if (error.status === 422 && error.error?.details) {
+ // Show specific field errors
+ error.error.details.forEach((detail: any) => {
+ const control = this.form.get(detail.field);
+ if (control) {
+ control.setErrors({ server: detail.message });
+ control.markAsTouched();
+ }
+ });
+
+ this.snackBar.open(
+ 'Please fix the validation errors',
+ 'Close',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ } else {
+ // Generic error handling
+ this.showMessage(
+ error.error?.detail || error.message || 'Operation failed',
+ 'error'
+ );
+ }
+ }
+
+ save() {
+ console.log('Save clicked - Form valid:', this.form.valid, 'Saving:', this.saving);
+ console.log('Form errors:', this.form.errors);
+ console.log('Form value:', this.form.value);
+
+ if (this.form.invalid || this.saving) {
+ // Mark all fields as touched to show validation errors
+ Object.keys(this.form.controls).forEach(key => {
+ const control = this.form.get(key);
+ if (control) {
+ control.markAsTouched();
+ if (control.errors) {
+ console.log(`Field ${key} errors:`, control.errors);
+ }
+ }
+ });
+
+ if (this.form.invalid) {
+ this.showMessage('Please fill all required fields correctly', 'error');
+ }
+ return;
+ }
+
+ this.saving = true;
+
+ const formValue = this.form.getRawValue(); // getRawValue to include disabled fields
+
+ // Project data format matching backend expectations
+ const projectData = {
+ name: formValue.name,
+ caption: formValue.caption,
+ icon: formValue.icon,
+ description: formValue.description,
+ default_locale: formValue.defaultLocale,
+ supported_locales: formValue.supportedLocales,
+ timezone: formValue.timezone,
+ region: formValue.region
+ };
+
+ const saveOperation = this.data.mode === 'create'
+ ? this.apiService.createProject(projectData)
+ : this.apiService.updateProject(this.data.project.id, {
+ ...projectData,
+ last_update_date: this.data.project.last_update_date || ''
+ });
+
+ saveOperation
+ .pipe(takeUntil(this.destroyed$))
+ .subscribe({
+ next: (result) => {
+ this.saving = false;
+ this.showMessage(
+ this.data.mode === 'create'
+ ? 'Project created successfully!'
+ : 'Project updated successfully!'
+ );
+ this.dialogRef.close(result);
+ },
+ error: (error: HttpErrorResponse) => {
+ this.saving = false;
+
+ // Race condition handling
+ if (error.status === 409) {
+ const details = error.error?.details || {};
+ this.snackBar.open(
+ `Project was modified by ${details.last_update_user || 'another user'}. Please reload.`,
+ 'Reload',
+ { duration: 0 }
+ ).onAction().subscribe(() => {
+ this.dialogRef.close('reload');
+ });
+ } else if (error.status === 422) {
+ this.handleValidationError(error);
+ } else {
+ this.showMessage(
+ error.error?.detail || 'Operation failed',
+ 'error'
+ );
+ }
+ }
+ });
+ }
+
+ close() {
+ this.dialogRef.close();
+ }
+
+ private showMessage(message: string, type: 'success' | 'error' = 'success') {
+ this.snackBar.open(message, 'Close', {
+ duration: 5000,
+ panelClass: type === 'error' ? ['error-snackbar'] : ['success-snackbar'],
+ horizontalPosition: 'right',
+ verticalPosition: 'top'
+ });
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts b/flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts
index d9bf4eedd12e9e4f565e964142b2de481c77daa4..fbbed501001ccf1d441b30cada1d129d195730c1 100644
--- a/flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts
+++ b/flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts
@@ -1,611 +1,611 @@
-import { Component, Inject } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
-import { MatSelectModule } from '@angular/material/select';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-import { MatChipsModule } from '@angular/material/chips';
-import { MatExpansionModule } from '@angular/material/expansion';
-import { MatDividerModule } from '@angular/material/divider';
-import { MatListModule } from '@angular/material/list';
-import { FormsModule } from '@angular/forms';
-import { Version } from '../../services/api.service';
-
-interface Difference {
- field: string;
- label: string;
- v1Value: any;
- v2Value: any;
- type: 'added' | 'removed' | 'modified' | 'unchanged';
-}
-
-@Component({
- selector: 'app-version-compare-dialog',
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatDialogModule,
- MatSelectModule,
- MatButtonModule,
- MatIconModule,
- MatChipsModule,
- MatExpansionModule,
- MatDividerModule,
- MatListModule
- ],
- template: `
- Compare Versions
-
-
-
-
-
-
- Version 1
-
-
- Version {{ v.no }} - {{ v.caption }}
- (Published)
-
-
-
-
- compare_arrows
-
-
- Version 2
-
-
- Version {{ v.no }} - {{ v.caption }}
- (Published)
-
-
-
-
-
-
-
0">
-
-
-
-
-
- add_circle
- {{ addedCount }} Added
-
-
- remove_circle
- {{ removedCount }} Removed
-
-
- edit
- {{ modifiedCount }} Modified
-
-
-
-
-
-
-
-
- General Configuration
-
-
- {{ generalDifferences.length }} differences
-
-
-
-
-
-
- {{ getDiffIcon(diff.type) }}
-
- {{ diff.label }}
-
- {{ formatValue(diff.v1Value) }}
- arrow_forward
- {{ formatValue(diff.v2Value) }}
-
-
-
-
-
-
-
-
-
- LLM Configuration
-
-
- {{ llmDifferences.length }} differences
-
-
-
-
-
-
- {{ getDiffIcon(diff.type) }}
-
- {{ diff.label }}
-
- {{ formatValue(diff.v1Value) }}
- arrow_forward
- {{ formatValue(diff.v2Value) }}
-
-
-
-
-
-
-
-
-
- Intents
-
-
- {{ intentDifferences.added.length + intentDifferences.removed.length + intentDifferences.modified.length }} differences
-
-
-
-
-
-
0">
-
add_circle Added Intents
-
-
- add
- {{ intent.name }}
- {{ intent.caption || 'No description' }}
-
-
-
-
-
-
0">
-
remove_circle Removed Intents
-
-
- remove
- {{ intent.name }}
- {{ intent.caption || 'No description' }}
-
-
-
-
-
-
0">
-
edit Modified Intents
-
-
- {{ intent.name }}
- {{ intent.changes.length }} changes
-
-
-
-
-
- {{ getDiffIcon(change.type) }}
-
- {{ change.label }}
-
- {{ formatValue(change.v1Value) }}
- arrow_forward
- {{ formatValue(change.v2Value) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
compare
-
Select two versions to compare
-
-
-
-
-
info
-
Please select different versions to compare
-
-
-
-
-
check_circle
-
These versions are identical
-
-
-
-
-
- Close
-
- `,
- styles: [`
- .compare-container {
- min-width: 800px;
- max-width: 1000px;
- }
-
- .version-selectors {
- display: flex;
- gap: 24px;
- align-items: center;
- justify-content: center;
- margin-bottom: 32px;
-
- mat-form-field {
- flex: 1;
- max-width: 350px;
- }
-
- .compare-icon {
- font-size: 32px;
- width: 32px;
- height: 32px;
- color: #666;
- }
-
- .published-marker {
- color: #4caf50;
- font-weight: 500;
- margin-left: 8px;
- }
- }
-
- .summary-chips {
- margin-bottom: 24px;
- display: flex;
- justify-content: center;
-
- mat-chip {
- margin: 0 4px;
-
- mat-icon {
- margin-right: 4px;
- }
- }
- }
-
- .comparison-results {
- mat-expansion-panel {
- margin-bottom: 16px;
- }
- }
-
- .diff-values {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 4px;
-
- .old-value {
- color: #d32f2f;
- text-decoration: line-through;
- }
-
- .new-value {
- color: #388e3c;
- font-weight: 500;
- }
-
- mat-icon {
- font-size: 16px;
- width: 16px;
- height: 16px;
- color: #666;
- }
- }
-
- .diff-added {
- color: #388e3c;
- }
-
- .diff-removed {
- color: #d32f2f;
- }
-
- .diff-modified {
- color: #1976d2;
- }
-
- .intents-comparison {
- .intent-group {
- margin-bottom: 24px;
-
- h4 {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 12px;
- color: #666;
-
- mat-icon {
- font-size: 20px;
- width: 20px;
- height: 20px;
- }
- }
-
- mat-expansion-panel {
- margin-bottom: 8px;
- }
- }
- }
-
- .empty-state {
- text-align: center;
- padding: 60px 20px;
-
- mat-icon {
- font-size: 64px;
- width: 64px;
- height: 64px;
- color: #e0e0e0;
- margin-bottom: 16px;
- }
-
- p {
- color: #666;
- font-size: 16px;
- }
- }
- `]
-})
-export default class VersionCompareDialogComponent {
- versions: Version[];
- version1: Version | null = null;
- version2: Version | null = null;
-
- differences: Difference[] = [];
- generalDifferences: Difference[] = [];
- llmDifferences: Difference[] = [];
- intentDifferences = {
- added: [] as any[],
- removed: [] as any[],
- modified: [] as any[]
- };
-
- constructor(
- public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: { versions: Version[], selectedVersion?: Version }
- ) {
- this.versions = data.versions;
-
- // Pre-select versions
- if (data.selectedVersion) {
- this.version1 = data.selectedVersion;
- // Select the next most recent version as version2
- const otherVersions = this.versions.filter(v => v.no !== data.selectedVersion!.no);
- if (otherVersions.length > 0) {
- this.version2 = otherVersions[0];
- this.compareVersions();
- }
- }
- }
-
- get addedCount(): number {
- return this.differences.filter(d => d.type === 'added').length + this.intentDifferences.added.length;
- }
-
- get removedCount(): number {
- return this.differences.filter(d => d.type === 'removed').length + this.intentDifferences.removed.length;
- }
-
- get modifiedCount(): number {
- return this.differences.filter(d => d.type === 'modified').length + this.intentDifferences.modified.length;
- }
-
- get hasGeneralDifferences(): boolean {
- return this.generalDifferences.length > 0;
- }
-
- get hasLLMDifferences(): boolean {
- return this.llmDifferences.length > 0;
- }
-
- get hasIntentDifferences(): boolean {
- return this.intentDifferences.added.length > 0 ||
- this.intentDifferences.removed.length > 0 ||
- this.intentDifferences.modified.length > 0;
- }
-
- compareVersions() {
- if (!this.version1 || !this.version2 || this.version1.no === this.version2.no) {
- this.differences = [];
- this.generalDifferences = [];
- this.llmDifferences = [];
- this.intentDifferences = { added: [], removed: [], modified: [] };
- return;
- }
-
- this.differences = [];
-
- // Compare general fields
- this.compareField('caption', 'Caption', this.version1.caption, this.version2.caption);
- this.compareField('general_prompt', 'General Prompt',
- (this.version1 as any).general_prompt,
- (this.version2 as any).general_prompt);
- this.compareField('published', 'Published Status', this.version1.published, this.version2.published);
-
- // Compare LLM configuration
- if (this.version1.llm && this.version2.llm) {
- this.compareField('llm.repo_id', 'Model Repository',
- this.version1.llm.repo_id,
- this.version2.llm.repo_id);
- this.compareField('llm.use_fine_tune', 'Use Fine-Tune',
- this.version1.llm.use_fine_tune,
- this.version2.llm.use_fine_tune);
-
- if (this.version1.llm.use_fine_tune || this.version2.llm.use_fine_tune) {
- this.compareField('llm.fine_tune_zip', 'Fine-Tune ZIP',
- this.version1.llm.fine_tune_zip,
- this.version2.llm.fine_tune_zip);
- }
-
- // Compare generation config
- const gc1 = this.version1.llm.generation_config;
- const gc2 = this.version2.llm.generation_config;
- if (gc1 && gc2) {
- this.compareField('llm.generation_config.max_new_tokens', 'Max Tokens',
- gc1.max_new_tokens, gc2.max_new_tokens);
- this.compareField('llm.generation_config.temperature', 'Temperature',
- gc1.temperature, gc2.temperature);
- this.compareField('llm.generation_config.top_p', 'Top P',
- gc1.top_p, gc2.top_p);
- this.compareField('llm.generation_config.repetition_penalty', 'Repetition Penalty',
- gc1.repetition_penalty, gc2.repetition_penalty);
- }
- }
-
- // Compare intents
- this.compareIntents();
-
- // Categorize differences
- this.generalDifferences = this.differences.filter(d =>
- !d.field.startsWith('llm.') && !d.field.startsWith('intent.'));
- this.llmDifferences = this.differences.filter(d => d.field.startsWith('llm.'));
- }
-
- private compareField(field: string, label: string, v1Value: any, v2Value: any) {
- if (v1Value === v2Value) {
- return;
- }
-
- let type: 'added' | 'removed' | 'modified';
- if (v1Value === undefined || v1Value === null || v1Value === '') {
- type = 'added';
- } else if (v2Value === undefined || v2Value === null || v2Value === '') {
- type = 'removed';
- } else {
- type = 'modified';
- }
-
- this.differences.push({
- field,
- label,
- v1Value,
- v2Value,
- type
- });
- }
-
- private compareIntents() {
- const intents1 = this.version1?.intents || [];
- const intents2 = this.version2?.intents || [];
-
- const intents1Map = new Map(intents1.map(i => [i.name, i]));
- const intents2Map = new Map(intents2.map(i => [i.name, i]));
-
- // Find added intents
- this.intentDifferences.added = intents2.filter(i => !intents1Map.has(i.name));
-
- // Find removed intents
- this.intentDifferences.removed = intents1.filter(i => !intents2Map.has(i.name));
-
- // Find modified intents
- this.intentDifferences.modified = [];
- for (const [name, intent1] of intents1Map) {
- const intent2 = intents2Map.get(name);
- if (intent2) {
- const changes = this.compareIntentDetails(intent1, intent2);
- if (changes.length > 0) {
- this.intentDifferences.modified.push({
- name,
- changes
- });
- }
- }
- }
- }
-
- private compareIntentDetails(intent1: any, intent2: any): Difference[] {
- const changes: Difference[] = [];
-
- // Compare basic fields
- if (intent1.caption !== intent2.caption) {
- changes.push({
- field: `intent.${intent1.name}.caption`,
- label: 'Caption',
- v1Value: intent1.caption,
- v2Value: intent2.caption,
- type: 'modified'
- });
- }
-
- if (intent1.detection_prompt !== intent2.detection_prompt) {
- changes.push({
- field: `intent.${intent1.name}.detection_prompt`,
- label: 'Detection Prompt',
- v1Value: intent1.detection_prompt,
- v2Value: intent2.detection_prompt,
- type: 'modified'
- });
- }
-
- if (intent1.action !== intent2.action) {
- changes.push({
- field: `intent.${intent1.name}.action`,
- label: 'API Action',
- v1Value: intent1.action,
- v2Value: intent2.action,
- type: 'modified'
- });
- }
-
- // Compare examples
- const examples1 = intent1.examples || [];
- const examples2 = intent2.examples || [];
- if (JSON.stringify(examples1) !== JSON.stringify(examples2)) {
- changes.push({
- field: `intent.${intent1.name}.examples`,
- label: 'Examples',
- v1Value: `${examples1.length} examples`,
- v2Value: `${examples2.length} examples`,
- type: 'modified'
- });
- }
-
- // Compare parameters
- const params1 = intent1.parameters || [];
- const params2 = intent2.parameters || [];
- if (JSON.stringify(params1) !== JSON.stringify(params2)) {
- changes.push({
- field: `intent.${intent1.name}.parameters`,
- label: 'Parameters',
- v1Value: `${params1.length} parameters`,
- v2Value: `${params2.length} parameters`,
- type: 'modified'
- });
- }
-
- return changes;
- }
-
- getDiffIcon(type: string): string {
- switch (type) {
- case 'added': return 'add_circle';
- case 'removed': return 'remove_circle';
- case 'modified': return 'edit';
- default: return 'circle';
- }
- }
-
- formatValue(value: any): string {
- if (value === null || value === undefined) return 'Not set';
- if (typeof value === 'boolean') return value ? 'Yes' : 'No';
- if (typeof value === 'string' && value.length > 100) {
- return value.substring(0, 100) + '...';
- }
- return String(value);
- }
-
- close() {
- this.dialogRef.close();
- }
+import { Component, Inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatSelectModule } from '@angular/material/select';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatDividerModule } from '@angular/material/divider';
+import { MatListModule } from '@angular/material/list';
+import { FormsModule } from '@angular/forms';
+import { Version } from '../../services/api.service';
+
+interface Difference {
+ field: string;
+ label: string;
+ v1Value: any;
+ v2Value: any;
+ type: 'added' | 'removed' | 'modified' | 'unchanged';
+}
+
+@Component({
+ selector: 'app-version-compare-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatDialogModule,
+ MatSelectModule,
+ MatButtonModule,
+ MatIconModule,
+ MatChipsModule,
+ MatExpansionModule,
+ MatDividerModule,
+ MatListModule
+ ],
+ template: `
+ Compare Versions
+
+
+
+
+
+
+ Version 1
+
+
+ Version {{ v.no }} - {{ v.caption }}
+ (Published)
+
+
+
+
+ compare_arrows
+
+
+ Version 2
+
+
+ Version {{ v.no }} - {{ v.caption }}
+ (Published)
+
+
+
+
+
+
+
0">
+
+
+
+
+
+ add_circle
+ {{ addedCount }} Added
+
+
+ remove_circle
+ {{ removedCount }} Removed
+
+
+ edit
+ {{ modifiedCount }} Modified
+
+
+
+
+
+
+
+
+ General Configuration
+
+
+ {{ generalDifferences.length }} differences
+
+
+
+
+
+
+ {{ getDiffIcon(diff.type) }}
+
+ {{ diff.label }}
+
+ {{ formatValue(diff.v1Value) }}
+ arrow_forward
+ {{ formatValue(diff.v2Value) }}
+
+
+
+
+
+
+
+
+
+ LLM Configuration
+
+
+ {{ llmDifferences.length }} differences
+
+
+
+
+
+
+ {{ getDiffIcon(diff.type) }}
+
+ {{ diff.label }}
+
+ {{ formatValue(diff.v1Value) }}
+ arrow_forward
+ {{ formatValue(diff.v2Value) }}
+
+
+
+
+
+
+
+
+
+ Intents
+
+
+ {{ intentDifferences.added.length + intentDifferences.removed.length + intentDifferences.modified.length }} differences
+
+
+
+
+
+
0">
+
add_circle Added Intents
+
+
+ add
+ {{ intent.name }}
+ {{ intent.caption || 'No description' }}
+
+
+
+
+
+
0">
+
remove_circle Removed Intents
+
+
+ remove
+ {{ intent.name }}
+ {{ intent.caption || 'No description' }}
+
+
+
+
+
+
0">
+
edit Modified Intents
+
+
+ {{ intent.name }}
+ {{ intent.changes.length }} changes
+
+
+
+
+
+ {{ getDiffIcon(change.type) }}
+
+ {{ change.label }}
+
+ {{ formatValue(change.v1Value) }}
+ arrow_forward
+ {{ formatValue(change.v2Value) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
compare
+
Select two versions to compare
+
+
+
+
+
info
+
Please select different versions to compare
+
+
+
+
+
check_circle
+
These versions are identical
+
+
+
+
+
+ Close
+
+ `,
+ styles: [`
+ .compare-container {
+ min-width: 800px;
+ max-width: 1000px;
+ }
+
+ .version-selectors {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 32px;
+
+ mat-form-field {
+ flex: 1;
+ max-width: 350px;
+ }
+
+ .compare-icon {
+ font-size: 32px;
+ width: 32px;
+ height: 32px;
+ color: #666;
+ }
+
+ .published-marker {
+ color: #4caf50;
+ font-weight: 500;
+ margin-left: 8px;
+ }
+ }
+
+ .summary-chips {
+ margin-bottom: 24px;
+ display: flex;
+ justify-content: center;
+
+ mat-chip {
+ margin: 0 4px;
+
+ mat-icon {
+ margin-right: 4px;
+ }
+ }
+ }
+
+ .comparison-results {
+ mat-expansion-panel {
+ margin-bottom: 16px;
+ }
+ }
+
+ .diff-values {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 4px;
+
+ .old-value {
+ color: #d32f2f;
+ text-decoration: line-through;
+ }
+
+ .new-value {
+ color: #388e3c;
+ font-weight: 500;
+ }
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #666;
+ }
+ }
+
+ .diff-added {
+ color: #388e3c;
+ }
+
+ .diff-removed {
+ color: #d32f2f;
+ }
+
+ .diff-modified {
+ color: #1976d2;
+ }
+
+ .intents-comparison {
+ .intent-group {
+ margin-bottom: 24px;
+
+ h4 {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+ color: #666;
+
+ mat-icon {
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ }
+ }
+
+ mat-expansion-panel {
+ margin-bottom: 8px;
+ }
+ }
+ }
+
+ .empty-state {
+ text-align: center;
+ padding: 60px 20px;
+
+ mat-icon {
+ font-size: 64px;
+ width: 64px;
+ height: 64px;
+ color: #e0e0e0;
+ margin-bottom: 16px;
+ }
+
+ p {
+ color: #666;
+ font-size: 16px;
+ }
+ }
+ `]
+})
+export default class VersionCompareDialogComponent {
+ versions: Version[];
+ version1: Version | null = null;
+ version2: Version | null = null;
+
+ differences: Difference[] = [];
+ generalDifferences: Difference[] = [];
+ llmDifferences: Difference[] = [];
+ intentDifferences = {
+ added: [] as any[],
+ removed: [] as any[],
+ modified: [] as any[]
+ };
+
+ constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: { versions: Version[], selectedVersion?: Version }
+ ) {
+ this.versions = data.versions;
+
+ // Pre-select versions
+ if (data.selectedVersion) {
+ this.version1 = data.selectedVersion;
+ // Select the next most recent version as version2
+ const otherVersions = this.versions.filter(v => v.no !== data.selectedVersion!.no);
+ if (otherVersions.length > 0) {
+ this.version2 = otherVersions[0];
+ this.compareVersions();
+ }
+ }
+ }
+
+ get addedCount(): number {
+ return this.differences.filter(d => d.type === 'added').length + this.intentDifferences.added.length;
+ }
+
+ get removedCount(): number {
+ return this.differences.filter(d => d.type === 'removed').length + this.intentDifferences.removed.length;
+ }
+
+ get modifiedCount(): number {
+ return this.differences.filter(d => d.type === 'modified').length + this.intentDifferences.modified.length;
+ }
+
+ get hasGeneralDifferences(): boolean {
+ return this.generalDifferences.length > 0;
+ }
+
+ get hasLLMDifferences(): boolean {
+ return this.llmDifferences.length > 0;
+ }
+
+ get hasIntentDifferences(): boolean {
+ return this.intentDifferences.added.length > 0 ||
+ this.intentDifferences.removed.length > 0 ||
+ this.intentDifferences.modified.length > 0;
+ }
+
+ compareVersions() {
+ if (!this.version1 || !this.version2 || this.version1.no === this.version2.no) {
+ this.differences = [];
+ this.generalDifferences = [];
+ this.llmDifferences = [];
+ this.intentDifferences = { added: [], removed: [], modified: [] };
+ return;
+ }
+
+ this.differences = [];
+
+ // Compare general fields
+ this.compareField('caption', 'Caption', this.version1.caption, this.version2.caption);
+ this.compareField('general_prompt', 'General Prompt',
+ (this.version1 as any).general_prompt,
+ (this.version2 as any).general_prompt);
+ this.compareField('published', 'Published Status', this.version1.published, this.version2.published);
+
+ // Compare LLM configuration
+ if (this.version1.llm && this.version2.llm) {
+ this.compareField('llm.repo_id', 'Model Repository',
+ this.version1.llm.repo_id,
+ this.version2.llm.repo_id);
+ this.compareField('llm.use_fine_tune', 'Use Fine-Tune',
+ this.version1.llm.use_fine_tune,
+ this.version2.llm.use_fine_tune);
+
+ if (this.version1.llm.use_fine_tune || this.version2.llm.use_fine_tune) {
+ this.compareField('llm.fine_tune_zip', 'Fine-Tune ZIP',
+ this.version1.llm.fine_tune_zip,
+ this.version2.llm.fine_tune_zip);
+ }
+
+ // Compare generation config
+ const gc1 = this.version1.llm.generation_config;
+ const gc2 = this.version2.llm.generation_config;
+ if (gc1 && gc2) {
+ this.compareField('llm.generation_config.max_new_tokens', 'Max Tokens',
+ gc1.max_new_tokens, gc2.max_new_tokens);
+ this.compareField('llm.generation_config.temperature', 'Temperature',
+ gc1.temperature, gc2.temperature);
+ this.compareField('llm.generation_config.top_p', 'Top P',
+ gc1.top_p, gc2.top_p);
+ this.compareField('llm.generation_config.repetition_penalty', 'Repetition Penalty',
+ gc1.repetition_penalty, gc2.repetition_penalty);
+ }
+ }
+
+ // Compare intents
+ this.compareIntents();
+
+ // Categorize differences
+ this.generalDifferences = this.differences.filter(d =>
+ !d.field.startsWith('llm.') && !d.field.startsWith('intent.'));
+ this.llmDifferences = this.differences.filter(d => d.field.startsWith('llm.'));
+ }
+
+ private compareField(field: string, label: string, v1Value: any, v2Value: any) {
+ if (v1Value === v2Value) {
+ return;
+ }
+
+ let type: 'added' | 'removed' | 'modified';
+ if (v1Value === undefined || v1Value === null || v1Value === '') {
+ type = 'added';
+ } else if (v2Value === undefined || v2Value === null || v2Value === '') {
+ type = 'removed';
+ } else {
+ type = 'modified';
+ }
+
+ this.differences.push({
+ field,
+ label,
+ v1Value,
+ v2Value,
+ type
+ });
+ }
+
+ private compareIntents() {
+ const intents1 = this.version1?.intents || [];
+ const intents2 = this.version2?.intents || [];
+
+ const intents1Map = new Map(intents1.map(i => [i.name, i]));
+ const intents2Map = new Map(intents2.map(i => [i.name, i]));
+
+ // Find added intents
+ this.intentDifferences.added = intents2.filter(i => !intents1Map.has(i.name));
+
+ // Find removed intents
+ this.intentDifferences.removed = intents1.filter(i => !intents2Map.has(i.name));
+
+ // Find modified intents
+ this.intentDifferences.modified = [];
+ for (const [name, intent1] of intents1Map) {
+ const intent2 = intents2Map.get(name);
+ if (intent2) {
+ const changes = this.compareIntentDetails(intent1, intent2);
+ if (changes.length > 0) {
+ this.intentDifferences.modified.push({
+ name,
+ changes
+ });
+ }
+ }
+ }
+ }
+
+ private compareIntentDetails(intent1: any, intent2: any): Difference[] {
+ const changes: Difference[] = [];
+
+ // Compare basic fields
+ if (intent1.caption !== intent2.caption) {
+ changes.push({
+ field: `intent.${intent1.name}.caption`,
+ label: 'Caption',
+ v1Value: intent1.caption,
+ v2Value: intent2.caption,
+ type: 'modified'
+ });
+ }
+
+ if (intent1.detection_prompt !== intent2.detection_prompt) {
+ changes.push({
+ field: `intent.${intent1.name}.detection_prompt`,
+ label: 'Detection Prompt',
+ v1Value: intent1.detection_prompt,
+ v2Value: intent2.detection_prompt,
+ type: 'modified'
+ });
+ }
+
+ if (intent1.action !== intent2.action) {
+ changes.push({
+ field: `intent.${intent1.name}.action`,
+ label: 'API Action',
+ v1Value: intent1.action,
+ v2Value: intent2.action,
+ type: 'modified'
+ });
+ }
+
+ // Compare examples
+ const examples1 = intent1.examples || [];
+ const examples2 = intent2.examples || [];
+ if (JSON.stringify(examples1) !== JSON.stringify(examples2)) {
+ changes.push({
+ field: `intent.${intent1.name}.examples`,
+ label: 'Examples',
+ v1Value: `${examples1.length} examples`,
+ v2Value: `${examples2.length} examples`,
+ type: 'modified'
+ });
+ }
+
+ // Compare parameters
+ const params1 = intent1.parameters || [];
+ const params2 = intent2.parameters || [];
+ if (JSON.stringify(params1) !== JSON.stringify(params2)) {
+ changes.push({
+ field: `intent.${intent1.name}.parameters`,
+ label: 'Parameters',
+ v1Value: `${params1.length} parameters`,
+ v2Value: `${params2.length} parameters`,
+ type: 'modified'
+ });
+ }
+
+ return changes;
+ }
+
+ getDiffIcon(type: string): string {
+ switch (type) {
+ case 'added': return 'add_circle';
+ case 'removed': return 'remove_circle';
+ case 'modified': return 'edit';
+ default: return 'circle';
+ }
+ }
+
+ formatValue(value: any): string {
+ if (value === null || value === undefined) return 'Not set';
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
+ if (typeof value === 'string' && value.length > 100) {
+ return value.substring(0, 100) + '...';
+ }
+ return String(value);
+ }
+
+ close() {
+ this.dialogRef.close();
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html b/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html
index bcbcba0d4fee169709ffe0c63e60bf261f42b9ab..1f200fb7284bd02000575dbdfd7480e6c552c70a 100644
--- a/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html
+++ b/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html
@@ -1,336 +1,336 @@
-
-
- Manage Versions - {{ project.name }}
-
-
- layers
- {{ versions.length }} versions
-
-
-
-
-
-
-
-
- Select Version
-
-
- Version {{ version.no }} - {{ version.caption || 'No description' }}
- [Published]
-
-
-
-
-
-
- add
- New Version
-
-
- compare_arrows
- Compare
-
-
-
-
-
-
-
-
- info
- This version is published and cannot be edited. Create a new version or unpublish to make changes.
-
-
-
-
-
-
-
-
-
-
-
- Version {{ selectedVersion.no }}
- Published
- Draft
-
- Last updated: {{ selectedVersion.last_update_date | date:'short' }}
-
-
-
-
-
-
- Caption
-
-
-
-
- General System Prompt
-
- This prompt defines the overall behavior of your assistant
-
-
-
- Welcome Prompt
-
- Initial greeting message (use {{ '{{user_name}}' }} for personalization)
-
-
-
-
- delete
- Delete Version
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Example Language
-
-
- {{ locale.name }}
-
-
-
-
-
-
-
-
- {{ intent.get('name')?.value || 'New Intent' }}
-
-
- {{ intent.get('caption')?.value || 'No description' }}
-
- {{ getIntentParameters(i).length }} params
- {{ intent.get('action')?.value || 'No API' }}
-
-
-
-
-
-
-
- edit
- Edit Details
-
-
- delete
- Delete
-
-
-
-
-
-
-
Detection Prompt:
-
{{ intent.get('detection_prompt')?.value || 'Not set' }}
-
-
-
0">
-
Examples ({{ getLocaleName(selectedExampleLocale) }}):
-
-
- {{ ex.example }}
-
-
-
-
-
0">
-
Parameters:
-
-
-
- {{ param.get('required')?.value ? 'check_box' : 'check_box_outline_blank' }}
-
- {{ param.get('name')?.value }}
-
- {{ getParameterCaptionDisplay(param.get('caption')?.value) }}
- ({{ param.get('type')?.value }})
-
-
-
-
-
-
-
-
-
-
-
psychology
-
No intents defined yet.
-
- Add First Intent
-
-
-
-
-
-
-
-
-
Test Intent Detection
-
Enter a user message to test which intent would be detected.
-
-
- User Message
-
-
-
-
- play_arrow
- {{ testing ? 'Testing...' : 'Test Intent Detection' }}
-
-
-
-
Test Result:
-
-
-
-
-
-
- Confidence: {{ (testResult.confidence * 100).toFixed(0) }}%
-
-
-
-
0">
-
Parameters that would be extracted:
-
-
-
- {{ param.extracted ? 'check_circle' : 'radio_button_unchecked' }}
-
- {{ param.name }}
-
- {{ param.extracted ? 'Value: ' + param.value : 'Missing - would ask user' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
layers
-
No version selected. Create a new version to get started.
-
- Create First Version
-
-
-
-
-
-
- Close
-
- {{ saving ? 'Saving...' : 'Save Changes' }}
-
-
- {{ publishing ? 'Publishing...' : 'Publish Version' }}
-
+
+
+ Manage Versions - {{ project.name }}
+
+
+ layers
+ {{ versions.length }} versions
+
+
+
+
+
+
+
+
+ Select Version
+
+
+ Version {{ version.no }} - {{ version.caption || 'No description' }}
+ [Published]
+
+
+
+
+
+
+ add
+ New Version
+
+
+ compare_arrows
+ Compare
+
+
+
+
+
+
+
+
+ info
+ This version is published and cannot be edited. Create a new version or unpublish to make changes.
+
+
+
+
+
+
+
+
+
+
+
+ Version {{ selectedVersion.no }}
+ Published
+ Draft
+
+ Last updated: {{ selectedVersion.last_update_date | date:'short' }}
+
+
+
+
+
+
+ Caption
+
+
+
+
+ General System Prompt
+
+ This prompt defines the overall behavior of your assistant
+
+
+
+ Welcome Prompt
+
+ Initial greeting message (use {{ '{{user_name}}' }} for personalization)
+
+
+
+
+ delete
+ Delete Version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Example Language
+
+
+ {{ locale.name }}
+
+
+
+
+
+
+
+
+ {{ intent.get('name')?.value || 'New Intent' }}
+
+
+ {{ intent.get('caption')?.value || 'No description' }}
+
+ {{ getIntentParameters(i).length }} params
+ {{ intent.get('action')?.value || 'No API' }}
+
+
+
+
+
+
+
+ edit
+ Edit Details
+
+
+ delete
+ Delete
+
+
+
+
+
+
+
Detection Prompt:
+
{{ intent.get('detection_prompt')?.value || 'Not set' }}
+
+
+
0">
+
Examples ({{ getLocaleName(selectedExampleLocale) }}):
+
+
+ {{ ex.example }}
+
+
+
+
+
0">
+
Parameters:
+
+
+
+ {{ param.get('required')?.value ? 'check_box' : 'check_box_outline_blank' }}
+
+ {{ param.get('name')?.value }}
+
+ {{ getParameterCaptionDisplay(param.get('caption')?.value) }}
+ ({{ param.get('type')?.value }})
+
+
+
+
+
+
+
+
+
+
+
psychology
+
No intents defined yet.
+
+ Add First Intent
+
+
+
+
+
+
+
+
+
Test Intent Detection
+
Enter a user message to test which intent would be detected.
+
+
+ User Message
+
+
+
+
+ play_arrow
+ {{ testing ? 'Testing...' : 'Test Intent Detection' }}
+
+
+
+
Test Result:
+
+
+
+
+
+
+ Confidence: {{ (testResult.confidence * 100).toFixed(0) }}%
+
+
+
+
0">
+
Parameters that would be extracted:
+
+
+
+ {{ param.extracted ? 'check_circle' : 'radio_button_unchecked' }}
+
+ {{ param.name }}
+
+ {{ param.extracted ? 'Value: ' + param.value : 'Missing - would ask user' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
layers
+
No version selected. Create a new version to get started.
+
+ Create First Version
+
+
+
+
+
+
+ Close
+
+ {{ saving ? 'Saving...' : 'Save Changes' }}
+
+
+ {{ publishing ? 'Publishing...' : 'Publish Version' }}
+
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.scss b/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.scss
index 8c425ef2a679149fa51508c43931762bc160a9c6..9549ef8611af312530319ecba75fcb267b74fe91 100644
--- a/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.scss
+++ b/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.scss
@@ -1,288 +1,288 @@
-.version-management-container {
- min-height: 500px;
-
- .title-chips {
- float: right;
- margin-top: -8px;
-
- mat-chip {
- font-size: 12px;
- margin: 0 2px;
- }
- }
-
- .version-selector {
- display: flex;
- gap: 16px;
- align-items: center;
- margin-bottom: 24px;
- flex-wrap: wrap;
-
- .version-select {
- flex: 1;
- max-width: 400px;
- min-width: 250px;
- }
-
- .version-actions {
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
- }
-
- .version-status {
- color: #4caf50;
- font-weight: 500;
- margin-left: 8px;
- }
- }
-
- .alert.alert-warning {
- background-color: #fff3cd;
- border: 1px solid #ffeaa7;
- color: #856404;
- padding: 12px 20px;
- border-radius: 4px;
- margin-bottom: 16px;
- display: flex;
- align-items: center;
- gap: 8px;
-
- mat-icon {
- color: #856404;
- }
- }
-
- .locale-selector {
- max-width: 200px;
- margin-bottom: 16px;
- }
-
- .version-editor {
- mat-tab-group {
- min-height: 400px;
- }
- }
-
- .tab-content {
- padding: 24px;
- }
-
- .full-width {
- width: 100%;
- margin-bottom: 16px;
- }
-
- .metadata-info {
- margin-bottom: 24px;
-
- mat-chip {
- font-size: 12px;
- margin: 2px;
- }
- }
-
- .generation-config {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 16px;
- margin-bottom: 24px;
- padding: 16px;
- background-color: #f5f5f5;
- border-radius: 4px;
- }
-
- .fine-tune-section {
- margin-top: 24px;
-
- mat-checkbox {
- margin-bottom: 16px;
- }
- }
-
- .intents-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
-
- h3 {
- margin: 0;
- }
- }
-
- .intents-list {
- mat-expansion-panel {
- margin-bottom: 8px;
-
- .intent-chips {
- margin-left: 16px;
-
- mat-chip {
- font-size: 11px;
- min-height: 20px;
- padding: 2px 8px;
- }
- }
- }
-
- .intent-content {
- padding: 16px;
- }
-
- .intent-actions {
- display: flex;
- gap: 8px;
- margin-bottom: 16px;
- padding-bottom: 16px;
- border-bottom: 1px solid #e0e0e0;
- }
-
- .intent-summary {
- .summary-item {
- margin-bottom: 16px;
-
- strong {
- display: block;
- margin-bottom: 8px;
- color: rgba(0, 0, 0, 0.87);
- }
-
- p {
- margin: 0;
- color: rgba(0, 0, 0, 0.6);
- }
-
- mat-chip {
- margin: 2px;
- font-size: 12px;
- }
-
- mat-list {
- padding-top: 0;
- }
-
- .examples-display {
- mat-chip-row {
- margin: 4px;
- }
- }
- }
- }
- }
-
- .test-result {
- margin-top: 24px;
-
- h4 {
- margin-bottom: 16px;
- }
-
- .result-card {
- border: 1px solid #e0e0e0;
- border-radius: 4px;
- padding: 16px;
-
- &.success {
- background-color: #e8f5e9;
- border-color: #4caf50;
-
- .result-header {
- color: #2e7d32;
-
- mat-icon {
- color: #4caf50;
- }
- }
- }
-
- &.no-match {
- background-color: #fff3e0;
- border-color: #ff9800;
-
- .result-header {
- color: #e65100;
-
- mat-icon {
- color: #ff9800;
- }
- }
- }
-
- .result-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 16px;
- font-size: 16px;
-
- mat-icon {
- font-size: 24px;
- width: 24px;
- height: 24px;
- }
- }
-
- .confidence {
- margin-bottom: 16px;
-
- mat-progress-bar {
- margin-top: 8px;
- }
- }
-
- .parameters {
- h5 {
- margin-bottom: 8px;
- }
-
- mat-list {
- background: white;
- border-radius: 4px;
- }
- }
- }
- }
-
- .empty-state {
- text-align: center;
- padding: 60px 20px;
-
- mat-icon {
- font-size: 64px;
- width: 64px;
- height: 64px;
- color: #e0e0e0;
- margin-bottom: 16px;
- }
-
- p {
- color: #666;
- margin-bottom: 24px;
- }
- }
-
- .action-buttons {
- margin-top: 24px;
- padding-top: 24px;
- border-top: 1px solid #e0e0e0;
- }
-}
-
-mat-dialog-content {
- max-width: 1000px;
- min-width: 800px;
- max-height: 80vh;
- padding: 0;
-}
-
-mat-dialog-actions {
- padding: 16px 24px;
- margin: 0;
- border-top: 1px solid #e0e0e0;
- gap: 8px;
-
- button {
- margin: 0 !important;
- }
+.version-management-container {
+ min-height: 500px;
+
+ .title-chips {
+ float: right;
+ margin-top: -8px;
+
+ mat-chip {
+ font-size: 12px;
+ margin: 0 2px;
+ }
+ }
+
+ .version-selector {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+
+ .version-select {
+ flex: 1;
+ max-width: 400px;
+ min-width: 250px;
+ }
+
+ .version-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .version-status {
+ color: #4caf50;
+ font-weight: 500;
+ margin-left: 8px;
+ }
+ }
+
+ .alert.alert-warning {
+ background-color: #fff3cd;
+ border: 1px solid #ffeaa7;
+ color: #856404;
+ padding: 12px 20px;
+ border-radius: 4px;
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ mat-icon {
+ color: #856404;
+ }
+ }
+
+ .locale-selector {
+ max-width: 200px;
+ margin-bottom: 16px;
+ }
+
+ .version-editor {
+ mat-tab-group {
+ min-height: 400px;
+ }
+ }
+
+ .tab-content {
+ padding: 24px;
+ }
+
+ .full-width {
+ width: 100%;
+ margin-bottom: 16px;
+ }
+
+ .metadata-info {
+ margin-bottom: 24px;
+
+ mat-chip {
+ font-size: 12px;
+ margin: 2px;
+ }
+ }
+
+ .generation-config {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+ padding: 16px;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ }
+
+ .fine-tune-section {
+ margin-top: 24px;
+
+ mat-checkbox {
+ margin-bottom: 16px;
+ }
+ }
+
+ .intents-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+
+ h3 {
+ margin: 0;
+ }
+ }
+
+ .intents-list {
+ mat-expansion-panel {
+ margin-bottom: 8px;
+
+ .intent-chips {
+ margin-left: 16px;
+
+ mat-chip {
+ font-size: 11px;
+ min-height: 20px;
+ padding: 2px 8px;
+ }
+ }
+ }
+
+ .intent-content {
+ padding: 16px;
+ }
+
+ .intent-actions {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #e0e0e0;
+ }
+
+ .intent-summary {
+ .summary-item {
+ margin-bottom: 16px;
+
+ strong {
+ display: block;
+ margin-bottom: 8px;
+ color: rgba(0, 0, 0, 0.87);
+ }
+
+ p {
+ margin: 0;
+ color: rgba(0, 0, 0, 0.6);
+ }
+
+ mat-chip {
+ margin: 2px;
+ font-size: 12px;
+ }
+
+ mat-list {
+ padding-top: 0;
+ }
+
+ .examples-display {
+ mat-chip-row {
+ margin: 4px;
+ }
+ }
+ }
+ }
+ }
+
+ .test-result {
+ margin-top: 24px;
+
+ h4 {
+ margin-bottom: 16px;
+ }
+
+ .result-card {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ padding: 16px;
+
+ &.success {
+ background-color: #e8f5e9;
+ border-color: #4caf50;
+
+ .result-header {
+ color: #2e7d32;
+
+ mat-icon {
+ color: #4caf50;
+ }
+ }
+ }
+
+ &.no-match {
+ background-color: #fff3e0;
+ border-color: #ff9800;
+
+ .result-header {
+ color: #e65100;
+
+ mat-icon {
+ color: #ff9800;
+ }
+ }
+ }
+
+ .result-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+ font-size: 16px;
+
+ mat-icon {
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ }
+ }
+
+ .confidence {
+ margin-bottom: 16px;
+
+ mat-progress-bar {
+ margin-top: 8px;
+ }
+ }
+
+ .parameters {
+ h5 {
+ margin-bottom: 8px;
+ }
+
+ mat-list {
+ background: white;
+ border-radius: 4px;
+ }
+ }
+ }
+ }
+
+ .empty-state {
+ text-align: center;
+ padding: 60px 20px;
+
+ mat-icon {
+ font-size: 64px;
+ width: 64px;
+ height: 64px;
+ color: #e0e0e0;
+ margin-bottom: 16px;
+ }
+
+ p {
+ color: #666;
+ margin-bottom: 24px;
+ }
+ }
+
+ .action-buttons {
+ margin-top: 24px;
+ padding-top: 24px;
+ border-top: 1px solid #e0e0e0;
+ }
+}
+
+mat-dialog-content {
+ max-width: 1000px;
+ min-width: 800px;
+ max-height: 80vh;
+ padding: 0;
+}
+
+mat-dialog-actions {
+ padding: 16px 24px;
+ margin: 0;
+ border-top: 1px solid #e0e0e0;
+ gap: 8px;
+
+ button {
+ margin: 0 !important;
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.ts b/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.ts
index 6b479ff1e20272b503bd325209cf8927eb379455..68236ce6a9392eadc959df7838bbf35d2a811e2c 100644
--- a/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.ts
+++ b/flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.ts
@@ -1,946 +1,946 @@
-import { Component, Inject, OnInit } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray, FormsModule } from '@angular/forms';
-import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule, MatDialog } from '@angular/material/dialog';
-import { MatTabsModule } from '@angular/material/tabs';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-import { MatButtonModule } from '@angular/material/button';
-import { MatIconModule } from '@angular/material/icon';
-import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
-import { MatTableModule } from '@angular/material/table';
-import { MatChipsModule } from '@angular/material/chips';
-import { MatExpansionModule } from '@angular/material/expansion';
-import { MatDividerModule } from '@angular/material/divider';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatListModule } from '@angular/material/list';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatBadgeModule } from '@angular/material/badge';
-import { ApiService, Project, Version } from '../../services/api.service';
-import { LocaleManagerService } from '../../services/locale-manager.service';
-import ConfirmDialogComponent from '../confirm-dialog/confirm-dialog.component';
-
-// Interfaces for multi-language support
-interface LocalizedExample {
- locale_code: string;
- example: string;
-}
-
-interface LocalizedCaption {
- locale_code: string;
- caption: string;
-}
-
-interface Locale {
- code: string;
- name: string;
-}
-
-@Component({
- selector: 'app-version-edit-dialog',
- standalone: true,
- imports: [
- CommonModule,
- ReactiveFormsModule,
- FormsModule,
- MatDialogModule,
- MatTabsModule,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
- MatCheckboxModule,
- MatButtonModule,
- MatIconModule,
- MatSnackBarModule,
- MatTableModule,
- MatChipsModule,
- MatExpansionModule,
- MatDividerModule,
- MatProgressBarModule,
- MatListModule,
- MatProgressSpinnerModule,
- MatBadgeModule
- ],
- templateUrl: './version-edit-dialog.component.html',
- styleUrls: ['./version-edit-dialog.component.scss']
-})
-export default class VersionEditDialogComponent implements OnInit {
- project: Project;
- versions: Version[] = [];
- selectedVersion: Version | null = null;
- versionForm!: FormGroup;
-
- loading = false;
- saving = false;
- publishing = false;
- creating = false;
- isDirty = false;
- testing = false;
-
- selectedTabIndex = 0;
- testUserMessage = '';
- testResult: any = null;
-
- // Multi-language support
- selectedExampleLocale: string = 'tr';
- availableLocales: Locale[] = [];
-
- constructor(
- private fb: FormBuilder,
- private apiService: ApiService,
- private localeService: LocaleManagerService,
- private snackBar: MatSnackBar,
- private dialog: MatDialog,
- public dialogRef: MatDialogRef,
- @Inject(MAT_DIALOG_DATA) public data: any
- ) {
- this.project = data.project;
- this.versions = [...this.project.versions].sort((a, b) => b.no - a.no);
- this.selectedExampleLocale = this.project.default_locale || 'tr';
- }
-
- ngOnInit() {
- this.initializeForm();
- this.loadAvailableLocales();
-
- // Select the latest unpublished version or the latest version
- const unpublished = this.versions.find(v => !v.published);
- this.selectedVersion = unpublished || this.versions[0] || null;
-
- if (this.selectedVersion) {
- this.loadVersion(this.selectedVersion);
- }
-
- this.versionForm.valueChanges.subscribe(() => {
- this.isDirty = true;
- });
- }
-
- initializeForm() {
- this.versionForm = this.fb.group({
- no: [{value: '', disabled: true}],
- caption: ['', Validators.required],
- published: [{value: false, disabled: true}],
- general_prompt: ['', Validators.required],
- welcome_prompt: [''], // Added welcome_prompt field
- llm: this.fb.group({
- repo_id: ['', Validators.required],
- generation_config: this.fb.group({
- max_new_tokens: [256, [Validators.required, Validators.min(1), Validators.max(2048)]],
- temperature: [0.2, [Validators.required, Validators.min(0), Validators.max(2)]],
- top_p: [0.8, [Validators.required, Validators.min(0), Validators.max(1)]],
- repetition_penalty: [1.1, [Validators.required, Validators.min(1), Validators.max(2)]]
- }),
- use_fine_tune: [false],
- fine_tune_zip: ['']
- }),
- intents: this.fb.array([]),
- last_update_date: ['']
- });
-
- // Watch for fine-tune toggle
- this.versionForm.get('llm.use_fine_tune')?.valueChanges.subscribe(useFineTune => {
- const fineTuneControl = this.versionForm.get('llm.fine_tune_zip');
- if (useFineTune) {
- fineTuneControl?.setValidators([Validators.required]);
- } else {
- fineTuneControl?.clearValidators();
- fineTuneControl?.setValue('');
- }
- fineTuneControl?.updateValueAndValidity();
- });
- }
-
- async loadAvailableLocales() {
- // Get supported locales from project
- const supportedCodes = [
- this.project.default_locale,
- ...(this.project.supported_locales || [])
- ].filter(Boolean);
-
- // Get locale details
- for (const code of supportedCodes) {
- if (!code) continue; // Skip undefined/null values
-
- try {
- const localeInfo = await this.localeService.getLocaleDetails(code).toPromise();
- if (localeInfo) {
- this.availableLocales.push({
- code: localeInfo.code,
- name: localeInfo.name
- });
- }
- } catch (error) {
- // Use fallback for known locales
- const fallbackNames: { [key: string]: string } = {
- 'tr': 'Türkçe',
- 'en': 'English',
- 'de': 'Deutsch',
- 'fr': 'Français',
- 'es': 'Español'
- };
- if (code && fallbackNames[code]) {
- this.availableLocales.push({
- code: code,
- name: fallbackNames[code]
- });
- }
- }
- }
- }
-
- getAvailableLocales(): Locale[] {
- return this.availableLocales;
- }
-
- getLocaleName(localeCode: string): string {
- const locale = this.availableLocales.find(l => l.code === localeCode);
- return locale?.name || localeCode;
- }
-
- loadVersion(version: Version) {
- this.selectedVersion = version;
-
- // Debug published status
- console.log('Loading version:', version.no, 'Published:', version.published);
-
- // Form değerlerini set et
- this.versionForm.patchValue({
- no: version.no,
- caption: version.caption || '',
- published: version.published || false,
- general_prompt: (version as any).general_prompt || '',
- welcome_prompt: (version as any).welcome_prompt || '', // Added welcome_prompt
- last_update_date: version.last_update_date || ''
- });
-
- // LLM config'i ayrı set et
- if ((version as any).llm) {
- this.versionForm.patchValue({
- llm: {
- repo_id: (version as any).llm.repo_id || '',
- generation_config: (version as any).llm.generation_config || {
- max_new_tokens: 512,
- temperature: 0.7,
- top_p: 0.95,
- repetition_penalty: 1.1
- },
- use_fine_tune: (version as any).llm.use_fine_tune || false,
- fine_tune_zip: (version as any).llm.fine_tune_zip || ''
- }
- });
- }
-
- // Clear and rebuild intents
- this.intents.clear();
- ((version as any).intents || []).forEach((intent: any) => {
- this.intents.push(this.createIntentFormGroup(intent));
- });
-
- this.isDirty = false;
- }
-
- async loadVersions() {
- this.loading = true;
- try {
- const project = await this.apiService.getProject(this.project.id).toPromise();
- if (project) {
- this.project = project;
- this.versions = [...project.versions].sort((a, b) => b.no - a.no);
-
- // Re-select current version if it still exists
- if (this.selectedVersion) {
- const currentVersion = this.versions.find(v => v.no === this.selectedVersion!.no);
- if (currentVersion) {
- this.loadVersion(currentVersion);
- } else if (this.versions.length > 0) {
- this.loadVersion(this.versions[0]);
- }
- } else if (this.versions.length > 0) {
- this.loadVersion(this.versions[0]);
- }
- }
- } catch (error) {
- this.snackBar.open('Failed to reload versions', 'Close', { duration: 3000 });
- } finally {
- this.loading = false;
- }
- }
-
- createIntentFormGroup(intent: any = {}): FormGroup {
- const group = this.fb.group({
- name: [intent.name || '', [Validators.required, Validators.pattern(/^[a-zA-Z0-9-]+$/)]],
- caption: [intent.caption || ''],
- detection_prompt: [intent.detection_prompt || '', Validators.required],
- examples: [intent.examples || []], // Store as array, not FormArray
- parameters: this.fb.array([]),
- action: [intent.action || '', Validators.required],
- fallback_timeout_prompt: [intent.fallback_timeout_prompt || ''],
- fallback_error_prompt: [intent.fallback_error_prompt || '']
- });
-
- // Parameters'ı ayrı olarak ekle
- if (intent.parameters && Array.isArray(intent.parameters)) {
- const parametersArray = group.get('parameters') as FormArray;
- intent.parameters.forEach((param: any) => {
- parametersArray.push(this.createParameterFormGroup(param));
- });
- }
-
- return group;
- }
-
- createParameterFormGroup(param: any = {}): FormGroup {
- return this.fb.group({
- name: [param.name || '', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_]+$/)]],
- caption: [param.caption || []],
- type: [param.type || 'str', Validators.required],
- required: [param.required !== false],
- variable_name: [param.variable_name || '', Validators.required],
- extraction_prompt: [param.extraction_prompt || ''],
- validation_regex: [param.validation_regex || ''],
- invalid_prompt: [param.invalid_prompt || ''],
- type_error_prompt: [param.type_error_prompt || '']
- });
- }
-
- get intents() {
- return this.versionForm.get('intents') as FormArray;
- }
-
- getIntentParameters(intentIndex: number): FormArray {
- return this.intents.at(intentIndex).get('parameters') as FormArray;
- }
-
- // LocalizedExample support methods
- getLocalizedExamples(examples: any[], locale: string): LocalizedExample[] {
- if (!examples || !Array.isArray(examples)) return [];
-
- // Check if examples are in new format
- if (examples.length > 0 && typeof examples[0] === 'object' && 'locale_code' in examples[0]) {
- return examples.filter(ex => ex.locale_code === locale);
- }
-
- // Old format - convert to new
- if (typeof examples[0] === 'string') {
- return examples.map(ex => ({ locale_code: locale, example: ex }));
- }
-
- return [];
- }
-
- getParameterCaptionDisplay(captions: LocalizedCaption[]): string {
- if (!captions || !Array.isArray(captions) || captions.length === 0) {
- return '(No caption)';
- }
-
- // Try to find caption for selected locale
- const selectedCaption = captions.find(c => c.locale_code === this.selectedExampleLocale);
- if (selectedCaption) return selectedCaption.caption;
-
- // Try default locale
- const defaultCaption = captions.find(c => c.locale_code === this.project.default_locale);
- if (defaultCaption) return defaultCaption.caption;
-
- // Return first available caption
- return captions[0].caption;
- }
-
- addLocalizedExample(intentIndex: number, example: string) {
- if (!example.trim()) return;
-
- const intent = this.intents.at(intentIndex);
- const currentExamples = intent.get('examples')?.value || [];
-
- // Check if already exists
- const exists = currentExamples.some((ex: any) =>
- ex.locale_code === this.selectedExampleLocale && ex.example === example.trim()
- );
-
- if (!exists) {
- const newExamples = [...currentExamples, {
- locale_code: this.selectedExampleLocale,
- example: example.trim()
- }];
- intent.patchValue({ examples: newExamples });
- this.isDirty = true;
- }
- }
-
- removeLocalizedExample(intentIndex: number, exampleToRemove: LocalizedExample) {
- const intent = this.intents.at(intentIndex);
- const currentExamples = intent.get('examples')?.value || [];
-
- const newExamples = currentExamples.filter((ex: any) =>
- !(ex.locale_code === exampleToRemove.locale_code && ex.example === exampleToRemove.example)
- );
-
- intent.patchValue({ examples: newExamples });
- this.isDirty = true;
- }
-
- addParameter(intentIndex: number) {
- const parameters = this.getIntentParameters(intentIndex);
- parameters.push(this.createParameterFormGroup());
- this.isDirty = true;
- }
-
- removeParameter(intentIndex: number, paramIndex: number) {
- const parameters = this.getIntentParameters(intentIndex);
- parameters.removeAt(paramIndex);
- this.isDirty = true;
- }
-
- // Check if version can be edited
- get canEdit(): boolean {
- const canEditResult = !this.selectedVersion?.published;
- console.log('Can edit check:', 'Version:', this.selectedVersion?.no, 'Published:', this.selectedVersion?.published, 'Result:', canEditResult);
- return canEditResult;
- }
-
- addIntent() {
- this.intents.push(this.createIntentFormGroup());
- this.isDirty = true;
- }
-
- removeIntent(index: number) {
- const intent = this.intents.at(index).value;
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
- width: '400px',
- data: {
- title: 'Delete Intent',
- message: `Are you sure you want to delete intent "${intent.name}"?`,
- confirmText: 'Delete',
- confirmColor: 'warn'
- }
- });
-
- dialogRef.afterClosed().subscribe(confirmed => {
- if (confirmed) {
- this.intents.removeAt(index);
- this.isDirty = true;
- }
- });
- }
-
- async editIntent(intentIndex: number) {
- const { default: IntentEditDialogComponent } = await import('../intent-edit-dialog/intent-edit-dialog.component');
-
- const intent = this.intents.at(intentIndex);
- const currentValue = intent.value;
-
- // Intent verilerini dialog'a gönder
- const dialogRef = this.dialog.open(IntentEditDialogComponent, {
- width: '90vw',
- maxWidth: '1000px',
- data: {
- intent: {
- ...currentValue,
- examples: currentValue.examples || [],
- parameters: currentValue.parameters || []
- },
- project: this.project,
- apis: await this.getAvailableAPIs()
- }
- });
-
- dialogRef.afterClosed().subscribe(result => {
- if (result) {
- // Update intent with result
- intent.patchValue({
- name: result.name,
- caption: result.caption,
- detection_prompt: result.detection_prompt,
- examples: result.examples || [],
- action: result.action,
- fallback_timeout_prompt: result.fallback_timeout_prompt,
- fallback_error_prompt: result.fallback_error_prompt
- });
-
- // Update parameters
- const parametersArray = intent.get('parameters') as FormArray;
- parametersArray.clear();
- (result.parameters || []).forEach((param: any) => {
- parametersArray.push(this.createParameterFormGroup(param));
- });
-
- this.isDirty = true;
- }
- });
- }
-
- async getAvailableAPIs(): Promise {
- try {
- return await this.apiService.getAPIs().toPromise() || [];
- } catch {
- return [];
- }
- }
-
- async createVersion() {
- const publishedVersions = this.versions.filter(v => v.published);
-
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
- width: '500px',
- data: {
- title: 'Create New Version',
- message: 'Which published version would you like to use as a base for the new version?',
- showDropdown: true,
- dropdownOptions: publishedVersions.map(v => ({
- value: v.no,
- label: `Version ${v.no} - ${v.caption || 'No description'}`
- })),
- dropdownPlaceholder: 'Select published version (or leave empty for blank)',
- confirmText: 'Create',
- cancelText: 'Cancel'
- }
- });
-
- dialogRef.afterClosed().subscribe(async (result) => {
- if (result?.confirmed) {
- this.creating = true;
- try {
- let newVersionData;
-
- if (result.selectedValue) {
- // Copy from selected version - we need to get the full version data
- const sourceVersion = this.versions.find(v => v.no === result.selectedValue);
- if (sourceVersion) {
- // Load the full version data from the current form if it's the selected version
- if (sourceVersion.no === this.selectedVersion?.no) {
- const formValue = this.versionForm.getRawValue();
- newVersionData = {
- ...formValue,
- no: undefined,
- published: false,
- last_update_date: undefined,
- caption: `Copy of ${sourceVersion.caption || `version ${sourceVersion.no}`}`
- };
- } else {
- // For other versions, we only have basic info, so create minimal copy
- newVersionData = {
- caption: `Copy of ${sourceVersion.caption || `version ${sourceVersion.no}`}`,
- general_prompt: '',
- llm: {
- repo_id: '',
- generation_config: {
- max_new_tokens: 512,
- temperature: 0.7,
- top_p: 0.95,
- repetition_penalty: 1.1
- },
- use_fine_tune: false,
- fine_tune_zip: ''
- },
- intents: []
- };
- }
- }
- } else {
- // Create blank version
- newVersionData = {
- caption: `Version ${this.versions.length + 1}`,
- general_prompt: '',
- llm: {
- repo_id: '',
- generation_config: {
- max_new_tokens: 512,
- temperature: 0.7,
- top_p: 0.95,
- repetition_penalty: 1.1
- },
- use_fine_tune: false,
- fine_tune_zip: ''
- },
- intents: []
- };
- }
-
- if (newVersionData) {
- await this.apiService.createVersion(this.project.id, newVersionData).toPromise();
- await this.loadVersions();
- this.snackBar.open('Version created successfully!', 'Close', { duration: 3000 });
- }
- } catch (error) {
- this.snackBar.open('Failed to create version', 'Close', { duration: 3000 });
- } finally {
- this.creating = false;
- }
- }
- });
- }
-
- async saveVersion() {
- console.log('Save button clicked - canEdit:', this.canEdit, 'published:', this.selectedVersion?.published);
-
- if (!this.selectedVersion || !this.canEdit) {
- this.snackBar.open('Cannot save published version', 'Close', { duration: 3000 });
- return;
- }
-
- if (this.versionForm.invalid) {
- const invalidFields: string[] = [];
- Object.keys(this.versionForm.controls).forEach(key => {
- const control = this.versionForm.get(key);
- if (control && control.invalid) {
- invalidFields.push(key);
- }
- });
-
- this.intents.controls.forEach((intent, index) => {
- if (intent.invalid) {
- invalidFields.push(`Intent ${index + 1}`);
- }
- });
-
- this.snackBar.open(`Please fix validation errors in: ${invalidFields.join(', ')}`, 'Close', {
- duration: 5000
- });
- return;
- }
-
- const currentVersion = this.selectedVersion!;
-
- this.saving = true;
-
- try {
- const formValue = this.versionForm.getRawValue();
-
- // updateData'yı backend'in beklediği formatta hazırla
- const updateData = {
- caption: formValue.caption,
- general_prompt: formValue.general_prompt || '',
- welcome_prompt: formValue.welcome_prompt || '', // Added welcome_prompt
- llm: formValue.llm,
- intents: formValue.intents.map((intent: any) => ({
- name: intent.name,
- caption: intent.caption,
- detection_prompt: intent.detection_prompt,
- examples: Array.isArray(intent.examples) ? intent.examples : [],
- parameters: Array.isArray(intent.parameters) ? intent.parameters.map((param: any) => ({
- name: param.name,
- caption: param.caption,
- type: param.type,
- required: param.required,
- variable_name: param.variable_name,
- extraction_prompt: param.extraction_prompt,
- validation_regex: param.validation_regex,
- invalid_prompt: param.invalid_prompt,
- type_error_prompt: param.type_error_prompt
- })) : [],
- action: intent.action,
- fallback_timeout_prompt: intent.fallback_timeout_prompt,
- fallback_error_prompt: intent.fallback_error_prompt
- })),
- last_update_date: currentVersion.last_update_date || ''
- };
-
- console.log('Saving version data:', JSON.stringify(updateData, null, 2));
-
- const result = await this.apiService.updateVersion(
- this.project.id,
- currentVersion.no,
- updateData
- ).toPromise();
-
- this.snackBar.open('Version saved successfully', 'Close', { duration: 3000 });
-
- this.isDirty = false;
-
- if (result) {
- this.selectedVersion = result;
- this.versionForm.patchValue({
- last_update_date: result.last_update_date
- });
- }
-
- await this.loadVersions();
-
- } catch (error: any) {
- console.error('Save error:', error);
-
- if (error.status === 409) {
- // Race condition handling
- await this.handleRaceCondition(currentVersion);
- } else if (error.status === 400 && error.error?.detail?.includes('Published versions')) {
- this.snackBar.open('Published versions cannot be modified. Create a new version instead.', 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- } else {
- const errorMessage = error.error?.detail || error.message || 'Failed to save version';
- this.snackBar.open(errorMessage, 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- }
- } finally {
- this.saving = false;
- }
- }
-
- // Race condition handling
- private async handleRaceCondition(currentVersion: Version) {
- const formValue = this.versionForm.getRawValue();
-
- const retryUpdateData = {
- caption: formValue.caption,
- general_prompt: formValue.general_prompt || '',
- welcome_prompt: formValue.welcome_prompt || '',
- llm: formValue.llm,
- intents: formValue.intents.map((intent: any) => ({
- name: intent.name,
- caption: intent.caption,
- detection_prompt: intent.detection_prompt,
- examples: Array.isArray(intent.examples) ? intent.examples : [],
- parameters: Array.isArray(intent.parameters) ? intent.parameters : [],
- action: intent.action,
- fallback_timeout_prompt: intent.fallback_timeout_prompt,
- fallback_error_prompt: intent.fallback_error_prompt
- })),
- last_update_date: currentVersion.last_update_date || ''
- };
-
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
- width: '500px',
- data: {
- title: 'Version Modified',
- message: 'This version was modified by another user. Do you want to reload and lose your changes, or force save?',
- confirmText: 'Force Save',
- cancelText: 'Reload',
- confirmColor: 'warn'
- }
- });
-
- dialogRef.afterClosed().subscribe(async (forceSave) => {
- if (forceSave) {
- try {
- await this.apiService.updateVersion(
- this.project.id,
- currentVersion.no,
- retryUpdateData,
- true
- ).toPromise();
- this.snackBar.open('Version force saved', 'Close', { duration: 3000 });
- await this.loadVersions();
- } catch (err: any) {
- this.snackBar.open(err.error?.detail || 'Force save failed', 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- }
- } else {
- await this.loadVersions();
- }
- });
- }
-
- async publishVersion() {
- if (!this.selectedVersion) return;
-
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
- width: '500px',
- data: {
- title: 'Publish Version',
- message: `Are you sure you want to publish version "${this.selectedVersion.caption}"? This will unpublish all other versions.`,
- confirmText: 'Publish',
- confirmColor: 'primary'
- }
- });
-
- dialogRef.afterClosed().subscribe(async (confirmed) => {
- if (confirmed && this.selectedVersion) {
- this.publishing = true;
- try {
- await this.apiService.publishVersion(
- this.project.id,
- this.selectedVersion.no
- ).toPromise();
-
- this.snackBar.open('Version published successfully', 'Close', { duration: 3000 });
-
- // Reload to get updated data
- await this.reloadProject();
-
- } catch (error: any) {
- this.snackBar.open(error.error?.detail || 'Failed to publish version', 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- } finally {
- this.publishing = false;
- }
- }
- });
- }
-
- async deleteVersion() {
- if (!this.selectedVersion || this.selectedVersion.published) return;
-
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
- width: '400px',
- data: {
- title: 'Delete Version',
- message: `Are you sure you want to delete version "${this.selectedVersion.caption}"?`,
- confirmText: 'Delete',
- confirmColor: 'warn'
- }
- });
-
- dialogRef.afterClosed().subscribe(async (confirmed) => {
- if (confirmed && this.selectedVersion) {
- try {
- await this.apiService.deleteVersion(
- this.project.id,
- this.selectedVersion.no
- ).toPromise();
-
- this.snackBar.open('Version deleted successfully', 'Close', { duration: 3000 });
-
- // Reload and select another version
- await this.reloadProject();
-
- if (this.versions.length > 0) {
- this.loadVersion(this.versions[0]);
- } else {
- this.selectedVersion = null;
- }
-
- } catch (error: any) {
- this.snackBar.open(error.error?.detail || 'Failed to delete version', 'Close', {
- duration: 5000,
- panelClass: 'error-snackbar'
- });
- }
- }
- });
- }
-
- async testIntentDetection() {
- if (!this.testUserMessage.trim()) {
- this.snackBar.open('Please enter a test message', 'Close', { duration: 3000 });
- return;
- }
-
- this.testing = true;
- this.testResult = null;
-
- // Simulate intent detection test
- setTimeout(() => {
- // This is a mock - in real implementation, this would call the Spark service
- const intents = this.versionForm.get('intents')?.value || [];
-
- // Simple matching for demo
- let detectedIntent = null;
- let confidence = 0;
-
- for (const intent of intents) {
- // Check examples in all locales
- const allExamples = intent.examples || [];
- for (const example of allExamples) {
- const exampleText = typeof example === 'string' ? example : example.example;
- if (this.testUserMessage.toLowerCase().includes(exampleText.toLowerCase())) {
- detectedIntent = intent.name;
- confidence = 0.95;
- break;
- }
- }
- if (detectedIntent) break;
- }
-
- // Random detection for demo
- if (!detectedIntent && intents.length > 0) {
- const randomIntent = intents[Math.floor(Math.random() * intents.length)];
- detectedIntent = randomIntent.name;
- confidence = 0.65;
- }
-
- this.testResult = {
- success: true,
- intent: detectedIntent,
- confidence: confidence,
- parameters: detectedIntent ? this.extractTestParameters(detectedIntent) : []
- };
-
- this.testing = false;
- }, 1500);
- }
-
- private extractTestParameters(intentName: string): any[] {
- // Mock parameter extraction
- const intent = this.intents.value.find((i: any) => i.name === intentName);
- if (!intent) return [];
-
- return intent.parameters.map((param: any) => ({
- name: param.name,
- value: param.type === 'date' ? '2025-06-15' : 'test_value',
- extracted: Math.random() > 0.3
- }));
- }
-
- private async reloadProject() {
- this.loading = true;
- try {
- const projects = await this.apiService.getProjects().toPromise() || [];
- const updatedProject = projects.find(p => p.id === this.project.id);
-
- if (updatedProject) {
- this.project = updatedProject;
- this.versions = [...updatedProject.versions].sort((a, b) => b.no - a.no);
- }
- } catch (error) {
- console.error('Failed to reload project:', error);
- } finally {
- this.loading = false;
- }
- }
-
- async compareVersions() {
- if (this.versions.length < 2) {
- this.snackBar.open('Need at least 2 versions to compare', 'Close', { duration: 3000 });
- return;
- }
-
- const { default: VersionCompareDialogComponent } = await import('../version-compare-dialog/version-compare-dialog.component');
-
- this.dialog.open(VersionCompareDialogComponent, {
- width: '90vw',
- maxWidth: '1000px',
- maxHeight: '80vh',
- data: {
- versions: this.versions,
- selectedVersion: this.selectedVersion
- }
- });
- }
-
- close() {
- if (this.isDirty) {
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
- width: '400px',
- data: {
- title: 'Unsaved Changes',
- message: 'You have unsaved changes. Do you want to save before closing?',
- confirmText: 'Save & Close',
- cancelText: 'Discard Changes',
- showThirdOption: true,
- thirdOptionText: 'Cancel'
- }
- });
-
- dialogRef.afterClosed().subscribe(result => {
- if (result === 'confirm') {
- this.saveVersion();
- this.dialogRef.close();
- } else if (result === 'cancel') {
- this.dialogRef.close();
- }
- // If result is null or 'third', don't close
- });
- } else {
- this.dialogRef.close();
- }
- }
+import { Component, Inject, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray, FormsModule } from '@angular/forms';
+import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule, MatDialog } from '@angular/material/dialog';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatSelectModule } from '@angular/material/select';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { MatTableModule } from '@angular/material/table';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatDividerModule } from '@angular/material/divider';
+import { MatProgressBarModule } from '@angular/material/progress-bar';
+import { MatListModule } from '@angular/material/list';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatBadgeModule } from '@angular/material/badge';
+import { ApiService, Project, Version } from '../../services/api.service';
+import { LocaleManagerService } from '../../services/locale-manager.service';
+import ConfirmDialogComponent from '../confirm-dialog/confirm-dialog.component';
+
+// Interfaces for multi-language support
+interface LocalizedExample {
+ locale_code: string;
+ example: string;
+}
+
+interface LocalizedCaption {
+ locale_code: string;
+ caption: string;
+}
+
+interface Locale {
+ code: string;
+ name: string;
+}
+
+@Component({
+ selector: 'app-version-edit-dialog',
+ standalone: true,
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ FormsModule,
+ MatDialogModule,
+ MatTabsModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatSelectModule,
+ MatCheckboxModule,
+ MatButtonModule,
+ MatIconModule,
+ MatSnackBarModule,
+ MatTableModule,
+ MatChipsModule,
+ MatExpansionModule,
+ MatDividerModule,
+ MatProgressBarModule,
+ MatListModule,
+ MatProgressSpinnerModule,
+ MatBadgeModule
+ ],
+ templateUrl: './version-edit-dialog.component.html',
+ styleUrls: ['./version-edit-dialog.component.scss']
+})
+export default class VersionEditDialogComponent implements OnInit {
+ project: Project;
+ versions: Version[] = [];
+ selectedVersion: Version | null = null;
+ versionForm!: FormGroup;
+
+ loading = false;
+ saving = false;
+ publishing = false;
+ creating = false;
+ isDirty = false;
+ testing = false;
+
+ selectedTabIndex = 0;
+ testUserMessage = '';
+ testResult: any = null;
+
+ // Multi-language support
+ selectedExampleLocale: string = 'tr';
+ availableLocales: Locale[] = [];
+
+ constructor(
+ private fb: FormBuilder,
+ private apiService: ApiService,
+ private localeService: LocaleManagerService,
+ private snackBar: MatSnackBar,
+ private dialog: MatDialog,
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: any
+ ) {
+ this.project = data.project;
+ this.versions = [...this.project.versions].sort((a, b) => b.no - a.no);
+ this.selectedExampleLocale = this.project.default_locale || 'tr';
+ }
+
+ ngOnInit() {
+ this.initializeForm();
+ this.loadAvailableLocales();
+
+ // Select the latest unpublished version or the latest version
+ const unpublished = this.versions.find(v => !v.published);
+ this.selectedVersion = unpublished || this.versions[0] || null;
+
+ if (this.selectedVersion) {
+ this.loadVersion(this.selectedVersion);
+ }
+
+ this.versionForm.valueChanges.subscribe(() => {
+ this.isDirty = true;
+ });
+ }
+
+ initializeForm() {
+ this.versionForm = this.fb.group({
+ no: [{value: '', disabled: true}],
+ caption: ['', Validators.required],
+ published: [{value: false, disabled: true}],
+ general_prompt: ['', Validators.required],
+ welcome_prompt: [''], // Added welcome_prompt field
+ llm: this.fb.group({
+ repo_id: ['', Validators.required],
+ generation_config: this.fb.group({
+ max_new_tokens: [256, [Validators.required, Validators.min(1), Validators.max(2048)]],
+ temperature: [0.2, [Validators.required, Validators.min(0), Validators.max(2)]],
+ top_p: [0.8, [Validators.required, Validators.min(0), Validators.max(1)]],
+ repetition_penalty: [1.1, [Validators.required, Validators.min(1), Validators.max(2)]]
+ }),
+ use_fine_tune: [false],
+ fine_tune_zip: ['']
+ }),
+ intents: this.fb.array([]),
+ last_update_date: ['']
+ });
+
+ // Watch for fine-tune toggle
+ this.versionForm.get('llm.use_fine_tune')?.valueChanges.subscribe(useFineTune => {
+ const fineTuneControl = this.versionForm.get('llm.fine_tune_zip');
+ if (useFineTune) {
+ fineTuneControl?.setValidators([Validators.required]);
+ } else {
+ fineTuneControl?.clearValidators();
+ fineTuneControl?.setValue('');
+ }
+ fineTuneControl?.updateValueAndValidity();
+ });
+ }
+
+ async loadAvailableLocales() {
+ // Get supported locales from project
+ const supportedCodes = [
+ this.project.default_locale,
+ ...(this.project.supported_locales || [])
+ ].filter(Boolean);
+
+ // Get locale details
+ for (const code of supportedCodes) {
+ if (!code) continue; // Skip undefined/null values
+
+ try {
+ const localeInfo = await this.localeService.getLocaleDetails(code).toPromise();
+ if (localeInfo) {
+ this.availableLocales.push({
+ code: localeInfo.code,
+ name: localeInfo.name
+ });
+ }
+ } catch (error) {
+ // Use fallback for known locales
+ const fallbackNames: { [key: string]: string } = {
+ 'tr': 'Türkçe',
+ 'en': 'English',
+ 'de': 'Deutsch',
+ 'fr': 'Français',
+ 'es': 'Español'
+ };
+ if (code && fallbackNames[code]) {
+ this.availableLocales.push({
+ code: code,
+ name: fallbackNames[code]
+ });
+ }
+ }
+ }
+ }
+
+ getAvailableLocales(): Locale[] {
+ return this.availableLocales;
+ }
+
+ getLocaleName(localeCode: string): string {
+ const locale = this.availableLocales.find(l => l.code === localeCode);
+ return locale?.name || localeCode;
+ }
+
+ loadVersion(version: Version) {
+ this.selectedVersion = version;
+
+ // Debug published status
+ console.log('Loading version:', version.no, 'Published:', version.published);
+
+ // Form değerlerini set et
+ this.versionForm.patchValue({
+ no: version.no,
+ caption: version.caption || '',
+ published: version.published || false,
+ general_prompt: (version as any).general_prompt || '',
+ welcome_prompt: (version as any).welcome_prompt || '', // Added welcome_prompt
+ last_update_date: version.last_update_date || ''
+ });
+
+ // LLM config'i ayrı set et
+ if ((version as any).llm) {
+ this.versionForm.patchValue({
+ llm: {
+ repo_id: (version as any).llm.repo_id || '',
+ generation_config: (version as any).llm.generation_config || {
+ max_new_tokens: 512,
+ temperature: 0.7,
+ top_p: 0.95,
+ repetition_penalty: 1.1
+ },
+ use_fine_tune: (version as any).llm.use_fine_tune || false,
+ fine_tune_zip: (version as any).llm.fine_tune_zip || ''
+ }
+ });
+ }
+
+ // Clear and rebuild intents
+ this.intents.clear();
+ ((version as any).intents || []).forEach((intent: any) => {
+ this.intents.push(this.createIntentFormGroup(intent));
+ });
+
+ this.isDirty = false;
+ }
+
+ async loadVersions() {
+ this.loading = true;
+ try {
+ const project = await this.apiService.getProject(this.project.id).toPromise();
+ if (project) {
+ this.project = project;
+ this.versions = [...project.versions].sort((a, b) => b.no - a.no);
+
+ // Re-select current version if it still exists
+ if (this.selectedVersion) {
+ const currentVersion = this.versions.find(v => v.no === this.selectedVersion!.no);
+ if (currentVersion) {
+ this.loadVersion(currentVersion);
+ } else if (this.versions.length > 0) {
+ this.loadVersion(this.versions[0]);
+ }
+ } else if (this.versions.length > 0) {
+ this.loadVersion(this.versions[0]);
+ }
+ }
+ } catch (error) {
+ this.snackBar.open('Failed to reload versions', 'Close', { duration: 3000 });
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ createIntentFormGroup(intent: any = {}): FormGroup {
+ const group = this.fb.group({
+ name: [intent.name || '', [Validators.required, Validators.pattern(/^[a-zA-Z0-9-]+$/)]],
+ caption: [intent.caption || ''],
+ detection_prompt: [intent.detection_prompt || '', Validators.required],
+ examples: [intent.examples || []], // Store as array, not FormArray
+ parameters: this.fb.array([]),
+ action: [intent.action || '', Validators.required],
+ fallback_timeout_prompt: [intent.fallback_timeout_prompt || ''],
+ fallback_error_prompt: [intent.fallback_error_prompt || '']
+ });
+
+ // Parameters'ı ayrı olarak ekle
+ if (intent.parameters && Array.isArray(intent.parameters)) {
+ const parametersArray = group.get('parameters') as FormArray;
+ intent.parameters.forEach((param: any) => {
+ parametersArray.push(this.createParameterFormGroup(param));
+ });
+ }
+
+ return group;
+ }
+
+ createParameterFormGroup(param: any = {}): FormGroup {
+ return this.fb.group({
+ name: [param.name || '', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_]+$/)]],
+ caption: [param.caption || []],
+ type: [param.type || 'str', Validators.required],
+ required: [param.required !== false],
+ variable_name: [param.variable_name || '', Validators.required],
+ extraction_prompt: [param.extraction_prompt || ''],
+ validation_regex: [param.validation_regex || ''],
+ invalid_prompt: [param.invalid_prompt || ''],
+ type_error_prompt: [param.type_error_prompt || '']
+ });
+ }
+
+ get intents() {
+ return this.versionForm.get('intents') as FormArray;
+ }
+
+ getIntentParameters(intentIndex: number): FormArray {
+ return this.intents.at(intentIndex).get('parameters') as FormArray;
+ }
+
+ // LocalizedExample support methods
+ getLocalizedExamples(examples: any[], locale: string): LocalizedExample[] {
+ if (!examples || !Array.isArray(examples)) return [];
+
+ // Check if examples are in new format
+ if (examples.length > 0 && typeof examples[0] === 'object' && 'locale_code' in examples[0]) {
+ return examples.filter(ex => ex.locale_code === locale);
+ }
+
+ // Old format - convert to new
+ if (typeof examples[0] === 'string') {
+ return examples.map(ex => ({ locale_code: locale, example: ex }));
+ }
+
+ return [];
+ }
+
+ getParameterCaptionDisplay(captions: LocalizedCaption[]): string {
+ if (!captions || !Array.isArray(captions) || captions.length === 0) {
+ return '(No caption)';
+ }
+
+ // Try to find caption for selected locale
+ const selectedCaption = captions.find(c => c.locale_code === this.selectedExampleLocale);
+ if (selectedCaption) return selectedCaption.caption;
+
+ // Try default locale
+ const defaultCaption = captions.find(c => c.locale_code === this.project.default_locale);
+ if (defaultCaption) return defaultCaption.caption;
+
+ // Return first available caption
+ return captions[0].caption;
+ }
+
+ addLocalizedExample(intentIndex: number, example: string) {
+ if (!example.trim()) return;
+
+ const intent = this.intents.at(intentIndex);
+ const currentExamples = intent.get('examples')?.value || [];
+
+ // Check if already exists
+ const exists = currentExamples.some((ex: any) =>
+ ex.locale_code === this.selectedExampleLocale && ex.example === example.trim()
+ );
+
+ if (!exists) {
+ const newExamples = [...currentExamples, {
+ locale_code: this.selectedExampleLocale,
+ example: example.trim()
+ }];
+ intent.patchValue({ examples: newExamples });
+ this.isDirty = true;
+ }
+ }
+
+ removeLocalizedExample(intentIndex: number, exampleToRemove: LocalizedExample) {
+ const intent = this.intents.at(intentIndex);
+ const currentExamples = intent.get('examples')?.value || [];
+
+ const newExamples = currentExamples.filter((ex: any) =>
+ !(ex.locale_code === exampleToRemove.locale_code && ex.example === exampleToRemove.example)
+ );
+
+ intent.patchValue({ examples: newExamples });
+ this.isDirty = true;
+ }
+
+ addParameter(intentIndex: number) {
+ const parameters = this.getIntentParameters(intentIndex);
+ parameters.push(this.createParameterFormGroup());
+ this.isDirty = true;
+ }
+
+ removeParameter(intentIndex: number, paramIndex: number) {
+ const parameters = this.getIntentParameters(intentIndex);
+ parameters.removeAt(paramIndex);
+ this.isDirty = true;
+ }
+
+ // Check if version can be edited
+ get canEdit(): boolean {
+ const canEditResult = !this.selectedVersion?.published;
+ console.log('Can edit check:', 'Version:', this.selectedVersion?.no, 'Published:', this.selectedVersion?.published, 'Result:', canEditResult);
+ return canEditResult;
+ }
+
+ addIntent() {
+ this.intents.push(this.createIntentFormGroup());
+ this.isDirty = true;
+ }
+
+ removeIntent(index: number) {
+ const intent = this.intents.at(index).value;
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '400px',
+ data: {
+ title: 'Delete Intent',
+ message: `Are you sure you want to delete intent "${intent.name}"?`,
+ confirmText: 'Delete',
+ confirmColor: 'warn'
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(confirmed => {
+ if (confirmed) {
+ this.intents.removeAt(index);
+ this.isDirty = true;
+ }
+ });
+ }
+
+ async editIntent(intentIndex: number) {
+ const { default: IntentEditDialogComponent } = await import('../intent-edit-dialog/intent-edit-dialog.component');
+
+ const intent = this.intents.at(intentIndex);
+ const currentValue = intent.value;
+
+ // Intent verilerini dialog'a gönder
+ const dialogRef = this.dialog.open(IntentEditDialogComponent, {
+ width: '90vw',
+ maxWidth: '1000px',
+ data: {
+ intent: {
+ ...currentValue,
+ examples: currentValue.examples || [],
+ parameters: currentValue.parameters || []
+ },
+ project: this.project,
+ apis: await this.getAvailableAPIs()
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(result => {
+ if (result) {
+ // Update intent with result
+ intent.patchValue({
+ name: result.name,
+ caption: result.caption,
+ detection_prompt: result.detection_prompt,
+ examples: result.examples || [],
+ action: result.action,
+ fallback_timeout_prompt: result.fallback_timeout_prompt,
+ fallback_error_prompt: result.fallback_error_prompt
+ });
+
+ // Update parameters
+ const parametersArray = intent.get('parameters') as FormArray;
+ parametersArray.clear();
+ (result.parameters || []).forEach((param: any) => {
+ parametersArray.push(this.createParameterFormGroup(param));
+ });
+
+ this.isDirty = true;
+ }
+ });
+ }
+
+ async getAvailableAPIs(): Promise {
+ try {
+ return await this.apiService.getAPIs().toPromise() || [];
+ } catch {
+ return [];
+ }
+ }
+
+ async createVersion() {
+ const publishedVersions = this.versions.filter(v => v.published);
+
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '500px',
+ data: {
+ title: 'Create New Version',
+ message: 'Which published version would you like to use as a base for the new version?',
+ showDropdown: true,
+ dropdownOptions: publishedVersions.map(v => ({
+ value: v.no,
+ label: `Version ${v.no} - ${v.caption || 'No description'}`
+ })),
+ dropdownPlaceholder: 'Select published version (or leave empty for blank)',
+ confirmText: 'Create',
+ cancelText: 'Cancel'
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(async (result) => {
+ if (result?.confirmed) {
+ this.creating = true;
+ try {
+ let newVersionData;
+
+ if (result.selectedValue) {
+ // Copy from selected version - we need to get the full version data
+ const sourceVersion = this.versions.find(v => v.no === result.selectedValue);
+ if (sourceVersion) {
+ // Load the full version data from the current form if it's the selected version
+ if (sourceVersion.no === this.selectedVersion?.no) {
+ const formValue = this.versionForm.getRawValue();
+ newVersionData = {
+ ...formValue,
+ no: undefined,
+ published: false,
+ last_update_date: undefined,
+ caption: `Copy of ${sourceVersion.caption || `version ${sourceVersion.no}`}`
+ };
+ } else {
+ // For other versions, we only have basic info, so create minimal copy
+ newVersionData = {
+ caption: `Copy of ${sourceVersion.caption || `version ${sourceVersion.no}`}`,
+ general_prompt: '',
+ llm: {
+ repo_id: '',
+ generation_config: {
+ max_new_tokens: 512,
+ temperature: 0.7,
+ top_p: 0.95,
+ repetition_penalty: 1.1
+ },
+ use_fine_tune: false,
+ fine_tune_zip: ''
+ },
+ intents: []
+ };
+ }
+ }
+ } else {
+ // Create blank version
+ newVersionData = {
+ caption: `Version ${this.versions.length + 1}`,
+ general_prompt: '',
+ llm: {
+ repo_id: '',
+ generation_config: {
+ max_new_tokens: 512,
+ temperature: 0.7,
+ top_p: 0.95,
+ repetition_penalty: 1.1
+ },
+ use_fine_tune: false,
+ fine_tune_zip: ''
+ },
+ intents: []
+ };
+ }
+
+ if (newVersionData) {
+ await this.apiService.createVersion(this.project.id, newVersionData).toPromise();
+ await this.loadVersions();
+ this.snackBar.open('Version created successfully!', 'Close', { duration: 3000 });
+ }
+ } catch (error) {
+ this.snackBar.open('Failed to create version', 'Close', { duration: 3000 });
+ } finally {
+ this.creating = false;
+ }
+ }
+ });
+ }
+
+ async saveVersion() {
+ console.log('Save button clicked - canEdit:', this.canEdit, 'published:', this.selectedVersion?.published);
+
+ if (!this.selectedVersion || !this.canEdit) {
+ this.snackBar.open('Cannot save published version', 'Close', { duration: 3000 });
+ return;
+ }
+
+ if (this.versionForm.invalid) {
+ const invalidFields: string[] = [];
+ Object.keys(this.versionForm.controls).forEach(key => {
+ const control = this.versionForm.get(key);
+ if (control && control.invalid) {
+ invalidFields.push(key);
+ }
+ });
+
+ this.intents.controls.forEach((intent, index) => {
+ if (intent.invalid) {
+ invalidFields.push(`Intent ${index + 1}`);
+ }
+ });
+
+ this.snackBar.open(`Please fix validation errors in: ${invalidFields.join(', ')}`, 'Close', {
+ duration: 5000
+ });
+ return;
+ }
+
+ const currentVersion = this.selectedVersion!;
+
+ this.saving = true;
+
+ try {
+ const formValue = this.versionForm.getRawValue();
+
+ // updateData'yı backend'in beklediği formatta hazırla
+ const updateData = {
+ caption: formValue.caption,
+ general_prompt: formValue.general_prompt || '',
+ welcome_prompt: formValue.welcome_prompt || '', // Added welcome_prompt
+ llm: formValue.llm,
+ intents: formValue.intents.map((intent: any) => ({
+ name: intent.name,
+ caption: intent.caption,
+ detection_prompt: intent.detection_prompt,
+ examples: Array.isArray(intent.examples) ? intent.examples : [],
+ parameters: Array.isArray(intent.parameters) ? intent.parameters.map((param: any) => ({
+ name: param.name,
+ caption: param.caption,
+ type: param.type,
+ required: param.required,
+ variable_name: param.variable_name,
+ extraction_prompt: param.extraction_prompt,
+ validation_regex: param.validation_regex,
+ invalid_prompt: param.invalid_prompt,
+ type_error_prompt: param.type_error_prompt
+ })) : [],
+ action: intent.action,
+ fallback_timeout_prompt: intent.fallback_timeout_prompt,
+ fallback_error_prompt: intent.fallback_error_prompt
+ })),
+ last_update_date: currentVersion.last_update_date || ''
+ };
+
+ console.log('Saving version data:', JSON.stringify(updateData, null, 2));
+
+ const result = await this.apiService.updateVersion(
+ this.project.id,
+ currentVersion.no,
+ updateData
+ ).toPromise();
+
+ this.snackBar.open('Version saved successfully', 'Close', { duration: 3000 });
+
+ this.isDirty = false;
+
+ if (result) {
+ this.selectedVersion = result;
+ this.versionForm.patchValue({
+ last_update_date: result.last_update_date
+ });
+ }
+
+ await this.loadVersions();
+
+ } catch (error: any) {
+ console.error('Save error:', error);
+
+ if (error.status === 409) {
+ // Race condition handling
+ await this.handleRaceCondition(currentVersion);
+ } else if (error.status === 400 && error.error?.detail?.includes('Published versions')) {
+ this.snackBar.open('Published versions cannot be modified. Create a new version instead.', 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ } else {
+ const errorMessage = error.error?.detail || error.message || 'Failed to save version';
+ this.snackBar.open(errorMessage, 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ }
+ } finally {
+ this.saving = false;
+ }
+ }
+
+ // Race condition handling
+ private async handleRaceCondition(currentVersion: Version) {
+ const formValue = this.versionForm.getRawValue();
+
+ const retryUpdateData = {
+ caption: formValue.caption,
+ general_prompt: formValue.general_prompt || '',
+ welcome_prompt: formValue.welcome_prompt || '',
+ llm: formValue.llm,
+ intents: formValue.intents.map((intent: any) => ({
+ name: intent.name,
+ caption: intent.caption,
+ detection_prompt: intent.detection_prompt,
+ examples: Array.isArray(intent.examples) ? intent.examples : [],
+ parameters: Array.isArray(intent.parameters) ? intent.parameters : [],
+ action: intent.action,
+ fallback_timeout_prompt: intent.fallback_timeout_prompt,
+ fallback_error_prompt: intent.fallback_error_prompt
+ })),
+ last_update_date: currentVersion.last_update_date || ''
+ };
+
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '500px',
+ data: {
+ title: 'Version Modified',
+ message: 'This version was modified by another user. Do you want to reload and lose your changes, or force save?',
+ confirmText: 'Force Save',
+ cancelText: 'Reload',
+ confirmColor: 'warn'
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(async (forceSave) => {
+ if (forceSave) {
+ try {
+ await this.apiService.updateVersion(
+ this.project.id,
+ currentVersion.no,
+ retryUpdateData,
+ true
+ ).toPromise();
+ this.snackBar.open('Version force saved', 'Close', { duration: 3000 });
+ await this.loadVersions();
+ } catch (err: any) {
+ this.snackBar.open(err.error?.detail || 'Force save failed', 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ }
+ } else {
+ await this.loadVersions();
+ }
+ });
+ }
+
+ async publishVersion() {
+ if (!this.selectedVersion) return;
+
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '500px',
+ data: {
+ title: 'Publish Version',
+ message: `Are you sure you want to publish version "${this.selectedVersion.caption}"? This will unpublish all other versions.`,
+ confirmText: 'Publish',
+ confirmColor: 'primary'
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(async (confirmed) => {
+ if (confirmed && this.selectedVersion) {
+ this.publishing = true;
+ try {
+ await this.apiService.publishVersion(
+ this.project.id,
+ this.selectedVersion.no
+ ).toPromise();
+
+ this.snackBar.open('Version published successfully', 'Close', { duration: 3000 });
+
+ // Reload to get updated data
+ await this.reloadProject();
+
+ } catch (error: any) {
+ this.snackBar.open(error.error?.detail || 'Failed to publish version', 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ } finally {
+ this.publishing = false;
+ }
+ }
+ });
+ }
+
+ async deleteVersion() {
+ if (!this.selectedVersion || this.selectedVersion.published) return;
+
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '400px',
+ data: {
+ title: 'Delete Version',
+ message: `Are you sure you want to delete version "${this.selectedVersion.caption}"?`,
+ confirmText: 'Delete',
+ confirmColor: 'warn'
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(async (confirmed) => {
+ if (confirmed && this.selectedVersion) {
+ try {
+ await this.apiService.deleteVersion(
+ this.project.id,
+ this.selectedVersion.no
+ ).toPromise();
+
+ this.snackBar.open('Version deleted successfully', 'Close', { duration: 3000 });
+
+ // Reload and select another version
+ await this.reloadProject();
+
+ if (this.versions.length > 0) {
+ this.loadVersion(this.versions[0]);
+ } else {
+ this.selectedVersion = null;
+ }
+
+ } catch (error: any) {
+ this.snackBar.open(error.error?.detail || 'Failed to delete version', 'Close', {
+ duration: 5000,
+ panelClass: 'error-snackbar'
+ });
+ }
+ }
+ });
+ }
+
+ async testIntentDetection() {
+ if (!this.testUserMessage.trim()) {
+ this.snackBar.open('Please enter a test message', 'Close', { duration: 3000 });
+ return;
+ }
+
+ this.testing = true;
+ this.testResult = null;
+
+ // Simulate intent detection test
+ setTimeout(() => {
+ // This is a mock - in real implementation, this would call the Spark service
+ const intents = this.versionForm.get('intents')?.value || [];
+
+ // Simple matching for demo
+ let detectedIntent = null;
+ let confidence = 0;
+
+ for (const intent of intents) {
+ // Check examples in all locales
+ const allExamples = intent.examples || [];
+ for (const example of allExamples) {
+ const exampleText = typeof example === 'string' ? example : example.example;
+ if (this.testUserMessage.toLowerCase().includes(exampleText.toLowerCase())) {
+ detectedIntent = intent.name;
+ confidence = 0.95;
+ break;
+ }
+ }
+ if (detectedIntent) break;
+ }
+
+ // Random detection for demo
+ if (!detectedIntent && intents.length > 0) {
+ const randomIntent = intents[Math.floor(Math.random() * intents.length)];
+ detectedIntent = randomIntent.name;
+ confidence = 0.65;
+ }
+
+ this.testResult = {
+ success: true,
+ intent: detectedIntent,
+ confidence: confidence,
+ parameters: detectedIntent ? this.extractTestParameters(detectedIntent) : []
+ };
+
+ this.testing = false;
+ }, 1500);
+ }
+
+ private extractTestParameters(intentName: string): any[] {
+ // Mock parameter extraction
+ const intent = this.intents.value.find((i: any) => i.name === intentName);
+ if (!intent) return [];
+
+ return intent.parameters.map((param: any) => ({
+ name: param.name,
+ value: param.type === 'date' ? '2025-06-15' : 'test_value',
+ extracted: Math.random() > 0.3
+ }));
+ }
+
+ private async reloadProject() {
+ this.loading = true;
+ try {
+ const projects = await this.apiService.getProjects().toPromise() || [];
+ const updatedProject = projects.find(p => p.id === this.project.id);
+
+ if (updatedProject) {
+ this.project = updatedProject;
+ this.versions = [...updatedProject.versions].sort((a, b) => b.no - a.no);
+ }
+ } catch (error) {
+ console.error('Failed to reload project:', error);
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ async compareVersions() {
+ if (this.versions.length < 2) {
+ this.snackBar.open('Need at least 2 versions to compare', 'Close', { duration: 3000 });
+ return;
+ }
+
+ const { default: VersionCompareDialogComponent } = await import('../version-compare-dialog/version-compare-dialog.component');
+
+ this.dialog.open(VersionCompareDialogComponent, {
+ width: '90vw',
+ maxWidth: '1000px',
+ maxHeight: '80vh',
+ data: {
+ versions: this.versions,
+ selectedVersion: this.selectedVersion
+ }
+ });
+ }
+
+ close() {
+ if (this.isDirty) {
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
+ width: '400px',
+ data: {
+ title: 'Unsaved Changes',
+ message: 'You have unsaved changes. Do you want to save before closing?',
+ confirmText: 'Save & Close',
+ cancelText: 'Discard Changes',
+ showThirdOption: true,
+ thirdOptionText: 'Cancel'
+ }
+ });
+
+ dialogRef.afterClosed().subscribe(result => {
+ if (result === 'confirm') {
+ this.saveVersion();
+ this.dialogRef.close();
+ } else if (result === 'cancel') {
+ this.dialogRef.close();
+ }
+ // If result is null or 'third', don't close
+ });
+ } else {
+ this.dialogRef.close();
+ }
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/interceptors/auth.interceptor.ts b/flare-ui/src/app/interceptors/auth.interceptor.ts
index 0f114bf963545b350d86440c0733a47b07096777..72174e1983ee21616c4059773500962b76609b37 100644
--- a/flare-ui/src/app/interceptors/auth.interceptor.ts
+++ b/flare-ui/src/app/interceptors/auth.interceptor.ts
@@ -1,38 +1,38 @@
-import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
-import { inject } from '@angular/core';
-import { Router } from '@angular/router';
-import { catchError, throwError } from 'rxjs';
-import { AuthService } from '../services/auth.service';
-
-export const authInterceptor: HttpInterceptorFn = (req, next) => {
- const authService = inject(AuthService);
- const router = inject(Router);
-
- // Skip auth for login endpoint
- if (req.url.includes('/api/login')) {
- return next(req);
- }
-
- // Add auth token to requests
- const token = authService.getToken();
- if (token) {
- req = req.clone({
- setHeaders: {
- Authorization: `Bearer ${token}`
- }
- });
- }
-
- return next(req).pipe(
- catchError((error: HttpErrorResponse) => {
- if (error.status === 401) {
- authService.logout();
- router.navigate(['/login']);
- } else if (error.status === 409) {
- // Race condition - let components handle this
- console.warn('Race condition detected:', error.error?.detail);
- }
- return throwError(() => error);
- })
- );
+import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
+import { inject } from '@angular/core';
+import { Router } from '@angular/router';
+import { catchError, throwError } from 'rxjs';
+import { AuthService } from '../services/auth.service';
+
+export const authInterceptor: HttpInterceptorFn = (req, next) => {
+ const authService = inject(AuthService);
+ const router = inject(Router);
+
+ // Skip auth for login endpoint
+ if (req.url.includes('/api/login')) {
+ return next(req);
+ }
+
+ // Add auth token to requests
+ const token = authService.getToken();
+ if (token) {
+ req = req.clone({
+ setHeaders: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+ }
+
+ return next(req).pipe(
+ catchError((error: HttpErrorResponse) => {
+ if (error.status === 401) {
+ authService.logout();
+ router.navigate(['/login']);
+ } else if (error.status === 409) {
+ // Race condition - let components handle this
+ console.warn('Race condition detected:', error.error?.detail);
+ }
+ return throwError(() => error);
+ })
+ );
};
\ No newline at end of file
diff --git a/flare-ui/src/app/interceptors/loading.interceptor.ts b/flare-ui/src/app/interceptors/loading.interceptor.ts
index 5750a5b15dc2e6bfc25342d18caee3a383f72b19..f9688e5aee644dfd4537ea2ff9869e19238c2ce6 100644
--- a/flare-ui/src/app/interceptors/loading.interceptor.ts
+++ b/flare-ui/src/app/interceptors/loading.interceptor.ts
@@ -1,27 +1,27 @@
-import { Injectable } from '@angular/core';
-import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
-import { Observable } from 'rxjs';
-import { finalize } from 'rxjs/operators';
-import { LoadingService } from '../services/loading.service';
-
-@Injectable()
-export class LoadingInterceptor implements HttpInterceptor {
- constructor(private loadingService: LoadingService) {}
-
- intercept(req: HttpRequest, next: HttpHandler): Observable> {
- // Skip loading for certain requests
- const skipLoading = req.headers.has('X-Skip-Loading');
-
- if (!skipLoading) {
- this.loadingService.show();
- }
-
- return next.handle(req).pipe(
- finalize(() => {
- if (!skipLoading) {
- this.loadingService.hide();
- }
- })
- );
- }
+import { Injectable } from '@angular/core';
+import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { finalize } from 'rxjs/operators';
+import { LoadingService } from '../services/loading.service';
+
+@Injectable()
+export class LoadingInterceptor implements HttpInterceptor {
+ constructor(private loadingService: LoadingService) {}
+
+ intercept(req: HttpRequest, next: HttpHandler): Observable> {
+ // Skip loading for certain requests
+ const skipLoading = req.headers.has('X-Skip-Loading');
+
+ if (!skipLoading) {
+ this.loadingService.show();
+ }
+
+ return next.handle(req).pipe(
+ finalize(() => {
+ if (!skipLoading) {
+ this.loadingService.hide();
+ }
+ })
+ );
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/api.service.ts b/flare-ui/src/app/services/api.service.ts
index b0783d53582f4b1753afb8907a85ed08c669dc6c..5e586f410855e580fae419897f94e3855b261016 100644
--- a/flare-ui/src/app/services/api.service.ts
+++ b/flare-ui/src/app/services/api.service.ts
@@ -1,631 +1,631 @@
-import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { Observable, throwError } from 'rxjs';
-import { catchError, tap } from 'rxjs/operators';
-import { Router } from '@angular/router';
-import { AuthService } from './auth.service';
-
-// Interfaces
-export interface API {
- name: string;
- url: string;
- method: string;
- headers?: any;
- body_template?: any;
- timeout_seconds: number;
- retry?: {
- retry_count: number;
- backoff_seconds: number;
- strategy: string;
- };
- auth?: {
- enabled: boolean;
- token_endpoint?: string;
- response_token_path?: string;
- token_request_body?: any;
- token_refresh_endpoint?: string;
- token_refresh_body?: any;
- };
- response_prompt?: string;
- response_mappings?: ResponseMapping[]; // Yeni alan
- deleted?: boolean;
- last_update_date?: string;
- last_update_user?: string;
-}
-
-export interface LocalizedCaption {
- locale_code: string;
- caption: string;
-}
-
-export interface LocalizedExample {
- locale_code: string;
- example: string;
-}
-
-export interface ResponseMapping {
- variable_name: string;
- type: 'str' | 'int' | 'float' | 'bool' | 'date';
- json_path: string;
- caption: LocalizedCaption[];
-}
-
-export interface IntentParameter {
- name: string;
- caption: LocalizedCaption[];
- type: 'str' | 'int' | 'float' | 'bool' | 'date';
- required: boolean;
- variable_name: string;
- extraction_prompt?: string;
- validation_regex?: string;
- invalid_prompt?: string;
- type_error_prompt?: string;
-}
-
-export interface Intent {
- name: string;
- caption: string;
- detection_prompt: string;
- examples: LocalizedExample[];
- parameters: IntentParameter[];
- action: string;
- fallback_timeout_prompt?: string;
- fallback_error_prompt?: string;
-}
-
-export interface GenerationConfig {
- max_new_tokens: number;
- temperature: number;
- top_p: number;
- top_k?: number;
- repetition_penalty?: number;
- do_sample?: boolean;
- num_beams?: number;
- length_penalty?: number;
- early_stopping?: boolean;
-}
-
-export interface LLMConfig {
- repo_id: string;
- generation_config: GenerationConfig; // any yerine GenerationConfig
- use_fine_tune: boolean;
- fine_tune_zip: string;
-}
-
-export interface Version {
- no: number;
- caption?: string;
- description?: string;
- published: boolean;
- general_prompt?: string;
- llm: LLMConfig; // inline yerine LLMConfig
- intents: Intent[];
- parameters: any[];
- last_update_date?: string;
-}
-
-export interface Project {
- id: number;
- name: string;
- caption: string;
- enabled: boolean;
- icon?: string;
- description?: string;
- default_locale?: string;
- supported_locales?: string[];
- timezone?: string;
- region?: string;
- versions: Version[];
- last_update_date?: string;
- deleted?: boolean;
- created_date?: string;
- created_by?: string;
- last_update_user?: string;
-}
-
-export interface ParameterCollectionConfig {
- max_params_per_question: number;
- smart_grouping: boolean;
- retry_unanswered: boolean;
- collection_prompt: string;
-}
-
-export interface ProviderConfig {
- type: 'llm' | 'tts' | 'stt';
- name: string;
- display_name: string;
- requires_endpoint: boolean;
- requires_api_key: boolean;
- requires_repo_info?: boolean;
- description: string;
-}
-
-export interface ProviderSettings {
- name: string;
- api_key?: string;
- endpoint?: string | null;
- settings?: any;
-}
-
-export interface Environment {
- llm_provider: ProviderSettings;
- tts_provider: ProviderSettings;
- stt_provider: ProviderSettings;
- providers: ProviderConfig[];
- parameter_collection_config?: ParameterCollectionConfig;
-}
-
-export interface STTSettings {
- speech_timeout_ms: number;
- noise_reduction_level: number;
- vad_sensitivity: number;
- language: string;
- model: string;
- use_enhanced: boolean;
- enable_punctuation: boolean;
- interim_results: boolean;
-}
-
-export interface TTSSettings {
- use_ssml: boolean;
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class ApiService {
- private apiUrl = '/api';
- private adminUrl = `${this.apiUrl}/admin`;
-
- constructor(
- private http: HttpClient,
- private router: Router,
- private authService: AuthService
- ) {}
-
- // ===================== Utils =====================
- private normalizeTimestamp(timestamp: string | null | undefined): string {
- if (!timestamp) return '';
-
- // Remove milliseconds for comparison
- if (timestamp.includes('.') && timestamp.endsWith('Z')) {
- return timestamp.split('.')[0] + 'Z';
- }
-
- return timestamp;
- }
-
- // ===================== Auth =====================
- login(username: string, password: string): Observable {
- return this.http.post(`${this.adminUrl}/login`, { username, password }).pipe(
- tap((response: any) => {
- this.authService.setToken(response.token);
- this.authService.setUsername(response.username);
- })
- );
- }
-
- logout(): void {
- this.authService.logout();
- }
-
- private getAuthHeaders(): HttpHeaders {
- try {
- const token = this.authService.getToken();
- if (!token) {
- console.warn('No auth token available');
- // Token yoksa boş header dön veya login'e yönlendir
- return new HttpHeaders({
- 'Content-Type': 'application/json'
- });
- }
-
- return new HttpHeaders({
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json'
- });
- } catch (error) {
- console.error('Error getting auth headers:', error);
- return new HttpHeaders({
- 'Content-Type': 'application/json'
- });
- }
- }
-
- // ===================== User =====================
- changePassword(currentPassword: string, newPassword: string): Observable {
- return this.http.post(
- `${this.adminUrl}/change-password`,
- { current_password: currentPassword, new_password: newPassword },
- { headers: this.getAuthHeaders() }
- ).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Environment =====================
- getEnvironment(): Observable {
- return this.http.get(`${this.adminUrl}/environment`, {
- headers: this.getAuthHeaders()
- });
- }
-
- updateEnvironment(data: Environment): Observable {
- return this.http.put(`${this.adminUrl}/environment`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Projects =====================
- getProjects(includeDeleted = false): Observable {
- return this.http.get(`${this.adminUrl}/projects`, {
- headers: this.getAuthHeaders(),
- params: { include_deleted: includeDeleted.toString() }
- }).pipe(
- catchError(error => {
- // Race condition check
- if (error.status === 409) {
- console.warn('Race condition detected in getProjects:', error.error);
- // Component'ler bu hatayı handle edecek
- }
- return throwError(() => error);
- })
- );
- }
-
- getProject(id: number): Observable {
- return this.http.get(`${this.adminUrl}/projects/${id}`);
- }
-
- createProject(data: any): Observable {
- console.log('createProject called with data:', data);
-
- let headers;
- try {
- headers = this.getAuthHeaders();
- console.log('Headers obtained successfully');
- } catch (error) {
- console.error('Error getting headers:', error);
- return throwError(() => ({ message: 'Authentication error' }));
- }
-
- console.log('Making POST request to:', `${this.adminUrl}/projects`);
-
- return this.http.post(`${this.adminUrl}/projects`, data, { headers }).pipe(
- tap(response => {
- console.log('Project creation successful:', response);
- }),
- catchError(error => {
- console.error('Project creation failed:', error);
- return this.handleError(error);
- })
- );
- }
-
- updateProject(id: number, data: any): Observable {
- // Normalize the timestamp before sending
- if (data.last_update_date) {
- data.last_update_date = this.normalizeTimestamp(data.last_update_date);
- }
-
- return this.http.put(`${this.adminUrl}/projects/${id}`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(error => {
- // Race condition özel handling
- if (error.status === 409) {
- const details = error.error?.details || {};
- return throwError(() => ({
- ...error,
- raceCondition: true,
- lastUpdateUser: details.last_update_user,
- lastUpdateDate: details.last_update_date
- }));
- }
- return throwError(() => error);
- })
- );
- }
-
- deleteProject(id: number): Observable {
- return this.http.delete(`${this.adminUrl}/projects/${id}`, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- toggleProject(id: number): Observable {
- return this.http.patch(`${this.adminUrl}/projects/${id}/toggle`, {}, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- exportProject(id: number): Observable {
- return this.http.get(`${this.adminUrl}/projects/${id}/export`, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- importProject(data: any): Observable {
- return this.http.post(`${this.adminUrl}/projects/import`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Versions =====================
- createVersion(projectId: number, data: any): Observable {
- return this.http.post(`${this.adminUrl}/projects/${projectId}/versions`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(error => {
- if (error.status === 409) {
- console.warn('Race condition in createVersion:', error.error);
- }
- return throwError(() => error);
- })
- );
- }
-
- updateVersion(projectId: number, versionNo: number, data: any, force: boolean = false): Observable {
- // Normalize the timestamp before sending
- if (data.last_update_date) {
- data.last_update_date = this.normalizeTimestamp(data.last_update_date);
- }
-
- return this.http.put(
- `${this.adminUrl}/projects/${projectId}/versions/${versionNo}${force ? '?force=true' : ''}`,
- data,
- { headers: this.getAuthHeaders() }
- ).pipe(
- catchError(this.handleError)
- );
- }
-
- deleteVersion(projectId: number, versionNo: number): Observable {
- return this.http.delete(`${this.adminUrl}/projects/${projectId}/versions/${versionNo}`, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- publishVersion(projectId: number, versionNo: number): Observable {
- return this.http.post(`${this.adminUrl}/projects/${projectId}/versions/${versionNo}/publish`, {}, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== APIs =====================
- getAPIs(includeDeleted = false): Observable {
- return this.http.get(`${this.adminUrl}/apis`, {
- headers: this.getAuthHeaders(),
- params: { include_deleted: includeDeleted.toString() }
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- createAPI(data: any): Observable {
- return this.http.post(`${this.adminUrl}/apis`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- updateAPI(name: string, data: any): Observable {
- // Normalize the timestamp before sending
- if (data.last_update_date) {
- data.last_update_date = this.normalizeTimestamp(data.last_update_date);
- }
-
- return this.http.put(`${this.adminUrl}/apis/${name}`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(error => {
- if (error.status === 409) {
- const details = error.error?.details || {};
- return throwError(() => ({
- ...error,
- raceCondition: true,
- lastUpdateUser: details.last_update_user,
- lastUpdateDate: details.last_update_date
- }));
- }
- return throwError(() => error);
- })
- );
- }
- deleteAPI(name: string): Observable {
- return this.http.delete(`${this.adminUrl}/apis/${name}`, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- testAPI(data: any): Observable {
- return this.http.post(`${this.adminUrl}/apis/test`, data, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Spark Integration =====================
- sparkStartup(projectName: string): Observable {
- return this.http.post(`${this.adminUrl}/spark/startup`,
- { project_name: projectName },
- { headers: this.getAuthHeaders() }
- ).pipe(
- catchError(this.handleError)
- );
- }
-
- sparkGetProjects(): Observable {
- return this.http.get(`${this.adminUrl}/spark/projects`, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- sparkEnableProject(projectName: string): Observable {
- return this.http.post(`${this.adminUrl}/spark/project/enable`,
- { project_name: projectName },
- { headers: this.getAuthHeaders() }
- ).pipe(
- catchError(this.handleError)
- );
- }
-
- sparkDisableProject(projectName: string): Observable {
- return this.http.post(`${this.adminUrl}/spark/project/disable`,
- { project_name: projectName },
- { headers: this.getAuthHeaders() }
- ).pipe(
- catchError(this.handleError)
- );
- }
-
- sparkDeleteProject(projectName: string): Observable {
- return this.http.delete(`${this.adminUrl}/spark/project/${projectName}`, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Tests =====================
- runTests(testType: string): Observable {
- return this.http.post(`${this.adminUrl}/test/run-all`, { test_type: testType }, {
- headers: this.getAuthHeaders()
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Activity Log =====================
- getActivityLog(limit = 50): Observable {
- return this.http.get(`${this.adminUrl}/activity-log`, {
- headers: this.getAuthHeaders(),
- params: { limit: limit.toString() }
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Validation =====================
- validateRegex(pattern: string, testValue: string): Observable {
- return this.http.post(`${this.adminUrl}/validate/regex`,
- { pattern, test_value: testValue },
- { headers: this.getAuthHeaders() }
- ).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Chat =====================
- /* 1️⃣ Proje isimleri (combo’yu doldurmak için) */
- getChatProjects() {
- return this.http.get(`${this.adminUrl}/projects/names`);
- }
-
- /* 2️⃣ Oturum başlat */
- startChat(projectName: string, isRealtime: boolean, locale?: string) {
- return this.http.post<{
- session_id: string;
- answer: string;
- }>(`${this.apiUrl}/start_session`, {
- project_name: projectName,
- is_realtime: isRealtime,
- locale: locale || 'tr'
- });
- }
-
- /* 3️⃣ Mesaj gönder/al */
- chat(sessionId: string, text: string) {
- const headers = new HttpHeaders().set('X-Session-ID', sessionId);
- return this.http.post<{
- response: string;
- intent?: string;
- state: string;
- }>(
- `${this.apiUrl}/chat`,
- { message: text },
- { headers }
- );
- }
-
- endSession(sessionId: string): Observable {
- return this.http.post(`${this.apiUrl}/end-session`,
- { session_id: sessionId },
- { headers: this.getAuthHeaders() }
- );
- }
-
- // ===================== TTS =====================
- generateTTS(text: string, voiceId?: string, modelId?: string, outputFormat: string = 'mp3_44100_128'): Observable {
- const body = {
- text,
- voice_id: voiceId,
- model_id: modelId,
- output_format: outputFormat
- };
-
- return this.http.post(`${this.apiUrl}/tts/generate`, body, {
- headers: this.getAuthHeaders(),
- responseType: 'blob'
- }).pipe(
- catchError(this.handleError)
- );
- }
-
- // ===================== Locale =====================
- getAvailableLocales(): Observable {
- return this.http.get(`${this.adminUrl}/locales`, { headers: this.getAuthHeaders() });
- }
-
- getLocaleDetails(code: string): Observable {
- return this.http.get(`${this.adminUrl}/locales/${code}`, { headers: this.getAuthHeaders() });
- }
-
- // ===================== Error Handler =====================
- private handleError(error: any) {
- console.error('API Error:', error);
-
- if (error.status === 401) {
- // Token expired or invalid
- this.authService.logout();
- } else if (error.status === 409) {
- // Race condition error
- const message = error.error?.detail || 'Resource was modified by another user';
-
- return throwError(() => ({
- ...error,
- userMessage: message,
- requiresReload: true
- }));
- }
-
- // Ensure error object has proper structure
- const errorResponse = {
- status: error.status,
- error: error.error || { detail: error.message || 'Unknown error' },
- message: error.error?.detail || error.error?.message || error.message || 'Unknown error'
- };
-
- return throwError(() => errorResponse);
- }
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { Observable, throwError } from 'rxjs';
+import { catchError, tap } from 'rxjs/operators';
+import { Router } from '@angular/router';
+import { AuthService } from './auth.service';
+
+// Interfaces
+export interface API {
+ name: string;
+ url: string;
+ method: string;
+ headers?: any;
+ body_template?: any;
+ timeout_seconds: number;
+ retry?: {
+ retry_count: number;
+ backoff_seconds: number;
+ strategy: string;
+ };
+ auth?: {
+ enabled: boolean;
+ token_endpoint?: string;
+ response_token_path?: string;
+ token_request_body?: any;
+ token_refresh_endpoint?: string;
+ token_refresh_body?: any;
+ };
+ response_prompt?: string;
+ response_mappings?: ResponseMapping[]; // Yeni alan
+ deleted?: boolean;
+ last_update_date?: string;
+ last_update_user?: string;
+}
+
+export interface LocalizedCaption {
+ locale_code: string;
+ caption: string;
+}
+
+export interface LocalizedExample {
+ locale_code: string;
+ example: string;
+}
+
+export interface ResponseMapping {
+ variable_name: string;
+ type: 'str' | 'int' | 'float' | 'bool' | 'date';
+ json_path: string;
+ caption: LocalizedCaption[];
+}
+
+export interface IntentParameter {
+ name: string;
+ caption: LocalizedCaption[];
+ type: 'str' | 'int' | 'float' | 'bool' | 'date';
+ required: boolean;
+ variable_name: string;
+ extraction_prompt?: string;
+ validation_regex?: string;
+ invalid_prompt?: string;
+ type_error_prompt?: string;
+}
+
+export interface Intent {
+ name: string;
+ caption: string;
+ detection_prompt: string;
+ examples: LocalizedExample[];
+ parameters: IntentParameter[];
+ action: string;
+ fallback_timeout_prompt?: string;
+ fallback_error_prompt?: string;
+}
+
+export interface GenerationConfig {
+ max_new_tokens: number;
+ temperature: number;
+ top_p: number;
+ top_k?: number;
+ repetition_penalty?: number;
+ do_sample?: boolean;
+ num_beams?: number;
+ length_penalty?: number;
+ early_stopping?: boolean;
+}
+
+export interface LLMConfig {
+ repo_id: string;
+ generation_config: GenerationConfig; // any yerine GenerationConfig
+ use_fine_tune: boolean;
+ fine_tune_zip: string;
+}
+
+export interface Version {
+ no: number;
+ caption?: string;
+ description?: string;
+ published: boolean;
+ general_prompt?: string;
+ llm: LLMConfig; // inline yerine LLMConfig
+ intents: Intent[];
+ parameters: any[];
+ last_update_date?: string;
+}
+
+export interface Project {
+ id: number;
+ name: string;
+ caption: string;
+ enabled: boolean;
+ icon?: string;
+ description?: string;
+ default_locale?: string;
+ supported_locales?: string[];
+ timezone?: string;
+ region?: string;
+ versions: Version[];
+ last_update_date?: string;
+ deleted?: boolean;
+ created_date?: string;
+ created_by?: string;
+ last_update_user?: string;
+}
+
+export interface ParameterCollectionConfig {
+ max_params_per_question: number;
+ smart_grouping: boolean;
+ retry_unanswered: boolean;
+ collection_prompt: string;
+}
+
+export interface ProviderConfig {
+ type: 'llm' | 'tts' | 'stt';
+ name: string;
+ display_name: string;
+ requires_endpoint: boolean;
+ requires_api_key: boolean;
+ requires_repo_info?: boolean;
+ description: string;
+}
+
+export interface ProviderSettings {
+ name: string;
+ api_key?: string;
+ endpoint?: string | null;
+ settings?: any;
+}
+
+export interface Environment {
+ llm_provider: ProviderSettings;
+ tts_provider: ProviderSettings;
+ stt_provider: ProviderSettings;
+ providers: ProviderConfig[];
+ parameter_collection_config?: ParameterCollectionConfig;
+}
+
+export interface STTSettings {
+ speech_timeout_ms: number;
+ noise_reduction_level: number;
+ vad_sensitivity: number;
+ language: string;
+ model: string;
+ use_enhanced: boolean;
+ enable_punctuation: boolean;
+ interim_results: boolean;
+}
+
+export interface TTSSettings {
+ use_ssml: boolean;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ApiService {
+ private apiUrl = '/api';
+ private adminUrl = `${this.apiUrl}/admin`;
+
+ constructor(
+ private http: HttpClient,
+ private router: Router,
+ private authService: AuthService
+ ) {}
+
+ // ===================== Utils =====================
+ private normalizeTimestamp(timestamp: string | null | undefined): string {
+ if (!timestamp) return '';
+
+ // Remove milliseconds for comparison
+ if (timestamp.includes('.') && timestamp.endsWith('Z')) {
+ return timestamp.split('.')[0] + 'Z';
+ }
+
+ return timestamp;
+ }
+
+ // ===================== Auth =====================
+ login(username: string, password: string): Observable {
+ return this.http.post(`${this.adminUrl}/login`, { username, password }).pipe(
+ tap((response: any) => {
+ this.authService.setToken(response.token);
+ this.authService.setUsername(response.username);
+ })
+ );
+ }
+
+ logout(): void {
+ this.authService.logout();
+ }
+
+ private getAuthHeaders(): HttpHeaders {
+ try {
+ const token = this.authService.getToken();
+ if (!token) {
+ console.warn('No auth token available');
+ // Token yoksa boş header dön veya login'e yönlendir
+ return new HttpHeaders({
+ 'Content-Type': 'application/json'
+ });
+ }
+
+ return new HttpHeaders({
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ });
+ } catch (error) {
+ console.error('Error getting auth headers:', error);
+ return new HttpHeaders({
+ 'Content-Type': 'application/json'
+ });
+ }
+ }
+
+ // ===================== User =====================
+ changePassword(currentPassword: string, newPassword: string): Observable {
+ return this.http.post(
+ `${this.adminUrl}/change-password`,
+ { current_password: currentPassword, new_password: newPassword },
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Environment =====================
+ getEnvironment(): Observable {
+ return this.http.get(`${this.adminUrl}/environment`, {
+ headers: this.getAuthHeaders()
+ });
+ }
+
+ updateEnvironment(data: Environment): Observable {
+ return this.http.put(`${this.adminUrl}/environment`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Projects =====================
+ getProjects(includeDeleted = false): Observable {
+ return this.http.get(`${this.adminUrl}/projects`, {
+ headers: this.getAuthHeaders(),
+ params: { include_deleted: includeDeleted.toString() }
+ }).pipe(
+ catchError(error => {
+ // Race condition check
+ if (error.status === 409) {
+ console.warn('Race condition detected in getProjects:', error.error);
+ // Component'ler bu hatayı handle edecek
+ }
+ return throwError(() => error);
+ })
+ );
+ }
+
+ getProject(id: number): Observable {
+ return this.http.get(`${this.adminUrl}/projects/${id}`);
+ }
+
+ createProject(data: any): Observable {
+ console.log('createProject called with data:', data);
+
+ let headers;
+ try {
+ headers = this.getAuthHeaders();
+ console.log('Headers obtained successfully');
+ } catch (error) {
+ console.error('Error getting headers:', error);
+ return throwError(() => ({ message: 'Authentication error' }));
+ }
+
+ console.log('Making POST request to:', `${this.adminUrl}/projects`);
+
+ return this.http.post(`${this.adminUrl}/projects`, data, { headers }).pipe(
+ tap(response => {
+ console.log('Project creation successful:', response);
+ }),
+ catchError(error => {
+ console.error('Project creation failed:', error);
+ return this.handleError(error);
+ })
+ );
+ }
+
+ updateProject(id: number, data: any): Observable {
+ // Normalize the timestamp before sending
+ if (data.last_update_date) {
+ data.last_update_date = this.normalizeTimestamp(data.last_update_date);
+ }
+
+ return this.http.put(`${this.adminUrl}/projects/${id}`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(error => {
+ // Race condition özel handling
+ if (error.status === 409) {
+ const details = error.error?.details || {};
+ return throwError(() => ({
+ ...error,
+ raceCondition: true,
+ lastUpdateUser: details.last_update_user,
+ lastUpdateDate: details.last_update_date
+ }));
+ }
+ return throwError(() => error);
+ })
+ );
+ }
+
+ deleteProject(id: number): Observable {
+ return this.http.delete(`${this.adminUrl}/projects/${id}`, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ toggleProject(id: number): Observable {
+ return this.http.patch(`${this.adminUrl}/projects/${id}/toggle`, {}, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ exportProject(id: number): Observable {
+ return this.http.get(`${this.adminUrl}/projects/${id}/export`, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ importProject(data: any): Observable {
+ return this.http.post(`${this.adminUrl}/projects/import`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Versions =====================
+ createVersion(projectId: number, data: any): Observable {
+ return this.http.post(`${this.adminUrl}/projects/${projectId}/versions`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(error => {
+ if (error.status === 409) {
+ console.warn('Race condition in createVersion:', error.error);
+ }
+ return throwError(() => error);
+ })
+ );
+ }
+
+ updateVersion(projectId: number, versionNo: number, data: any, force: boolean = false): Observable {
+ // Normalize the timestamp before sending
+ if (data.last_update_date) {
+ data.last_update_date = this.normalizeTimestamp(data.last_update_date);
+ }
+
+ return this.http.put(
+ `${this.adminUrl}/projects/${projectId}/versions/${versionNo}${force ? '?force=true' : ''}`,
+ data,
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ deleteVersion(projectId: number, versionNo: number): Observable {
+ return this.http.delete(`${this.adminUrl}/projects/${projectId}/versions/${versionNo}`, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ publishVersion(projectId: number, versionNo: number): Observable {
+ return this.http.post(`${this.adminUrl}/projects/${projectId}/versions/${versionNo}/publish`, {}, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== APIs =====================
+ getAPIs(includeDeleted = false): Observable {
+ return this.http.get(`${this.adminUrl}/apis`, {
+ headers: this.getAuthHeaders(),
+ params: { include_deleted: includeDeleted.toString() }
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ createAPI(data: any): Observable {
+ return this.http.post(`${this.adminUrl}/apis`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ updateAPI(name: string, data: any): Observable {
+ // Normalize the timestamp before sending
+ if (data.last_update_date) {
+ data.last_update_date = this.normalizeTimestamp(data.last_update_date);
+ }
+
+ return this.http.put(`${this.adminUrl}/apis/${name}`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(error => {
+ if (error.status === 409) {
+ const details = error.error?.details || {};
+ return throwError(() => ({
+ ...error,
+ raceCondition: true,
+ lastUpdateUser: details.last_update_user,
+ lastUpdateDate: details.last_update_date
+ }));
+ }
+ return throwError(() => error);
+ })
+ );
+ }
+ deleteAPI(name: string): Observable {
+ return this.http.delete(`${this.adminUrl}/apis/${name}`, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ testAPI(data: any): Observable {
+ return this.http.post(`${this.adminUrl}/apis/test`, data, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Spark Integration =====================
+ sparkStartup(projectName: string): Observable {
+ return this.http.post(`${this.adminUrl}/spark/startup`,
+ { project_name: projectName },
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ sparkGetProjects(): Observable {
+ return this.http.get(`${this.adminUrl}/spark/projects`, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ sparkEnableProject(projectName: string): Observable {
+ return this.http.post(`${this.adminUrl}/spark/project/enable`,
+ { project_name: projectName },
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ sparkDisableProject(projectName: string): Observable {
+ return this.http.post(`${this.adminUrl}/spark/project/disable`,
+ { project_name: projectName },
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ sparkDeleteProject(projectName: string): Observable {
+ return this.http.delete(`${this.adminUrl}/spark/project/${projectName}`, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Tests =====================
+ runTests(testType: string): Observable {
+ return this.http.post(`${this.adminUrl}/test/run-all`, { test_type: testType }, {
+ headers: this.getAuthHeaders()
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Activity Log =====================
+ getActivityLog(limit = 50): Observable {
+ return this.http.get(`${this.adminUrl}/activity-log`, {
+ headers: this.getAuthHeaders(),
+ params: { limit: limit.toString() }
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Validation =====================
+ validateRegex(pattern: string, testValue: string): Observable {
+ return this.http.post(`${this.adminUrl}/validate/regex`,
+ { pattern, test_value: testValue },
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Chat =====================
+ /* 1️⃣ Proje isimleri (combo’yu doldurmak için) */
+ getChatProjects() {
+ return this.http.get(`${this.adminUrl}/projects/names`);
+ }
+
+ /* 2️⃣ Oturum başlat */
+ startChat(projectName: string, isRealtime: boolean, locale?: string) {
+ return this.http.post<{
+ session_id: string;
+ answer: string;
+ }>(`${this.apiUrl}/start_session`, {
+ project_name: projectName,
+ is_realtime: isRealtime,
+ locale: locale || 'tr'
+ });
+ }
+
+ /* 3️⃣ Mesaj gönder/al */
+ chat(sessionId: string, text: string) {
+ const headers = new HttpHeaders().set('X-Session-ID', sessionId);
+ return this.http.post<{
+ response: string;
+ intent?: string;
+ state: string;
+ }>(
+ `${this.apiUrl}/chat`,
+ { message: text },
+ { headers }
+ );
+ }
+
+ endSession(sessionId: string): Observable {
+ return this.http.post(`${this.apiUrl}/end-session`,
+ { session_id: sessionId },
+ { headers: this.getAuthHeaders() }
+ );
+ }
+
+ // ===================== TTS =====================
+ generateTTS(text: string, voiceId?: string, modelId?: string, outputFormat: string = 'mp3_44100_128'): Observable {
+ const body = {
+ text,
+ voice_id: voiceId,
+ model_id: modelId,
+ output_format: outputFormat
+ };
+
+ return this.http.post(`${this.apiUrl}/tts/generate`, body, {
+ headers: this.getAuthHeaders(),
+ responseType: 'blob'
+ }).pipe(
+ catchError(this.handleError)
+ );
+ }
+
+ // ===================== Locale =====================
+ getAvailableLocales(): Observable {
+ return this.http.get(`${this.adminUrl}/locales`, { headers: this.getAuthHeaders() });
+ }
+
+ getLocaleDetails(code: string): Observable {
+ return this.http.get(`${this.adminUrl}/locales/${code}`, { headers: this.getAuthHeaders() });
+ }
+
+ // ===================== Error Handler =====================
+ private handleError(error: any) {
+ console.error('API Error:', error);
+
+ if (error.status === 401) {
+ // Token expired or invalid
+ this.authService.logout();
+ } else if (error.status === 409) {
+ // Race condition error
+ const message = error.error?.detail || 'Resource was modified by another user';
+
+ return throwError(() => ({
+ ...error,
+ userMessage: message,
+ requiresReload: true
+ }));
+ }
+
+ // Ensure error object has proper structure
+ const errorResponse = {
+ status: error.status,
+ error: error.error || { detail: error.message || 'Unknown error' },
+ message: error.error?.detail || error.error?.message || error.message || 'Unknown error'
+ };
+
+ return throwError(() => errorResponse);
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/audio-stream.service.ts b/flare-ui/src/app/services/audio-stream.service.ts
index f29f09af13db0fa239c0f4bcad391a3dea44a58a..68ba3b72fc4dcdc6c4df3681b93fa95fbf0d6579 100644
--- a/flare-ui/src/app/services/audio-stream.service.ts
+++ b/flare-ui/src/app/services/audio-stream.service.ts
@@ -1,394 +1,394 @@
-// audio-stream.service.ts
-// Path: /flare-ui/src/app/services/audio-stream.service.ts
-
-import { Injectable, OnDestroy } from '@angular/core';
-import { Subject, Observable, throwError } from 'rxjs';
-
-export interface AudioChunk {
- data: string; // Base64 encoded audio
- timestamp: number;
-}
-
-export interface AudioStreamError {
- type: 'permission' | 'device' | 'browser' | 'unknown';
- message: string;
- originalError?: any;
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class AudioStreamService implements OnDestroy {
- private mediaRecorder: MediaRecorder | null = null;
- private audioStream: MediaStream | null = null;
- private audioChunkSubject = new Subject();
- private recordingStateSubject = new Subject();
- private errorSubject = new Subject();
- private volumeLevelSubject = new Subject();
-
- public audioChunk$ = this.audioChunkSubject.asObservable();
- public recordingState$ = this.recordingStateSubject.asObservable();
- public error$ = this.errorSubject.asObservable();
- public volumeLevel$ = this.volumeLevelSubject.asObservable();
-
- // Audio analysis
- private audioContext: AudioContext | null = null;
- private analyser: AnalyserNode | null = null;
- private volumeInterval: any;
-
- // Audio constraints
- private constraints = {
- audio: {
- channelCount: 1,
- sampleRate: 16000,
- echoCancellation: true,
- noiseSuppression: true,
- autoGainControl: true
- }
- };
-
- ngOnDestroy(): void {
- this.cleanup();
- }
-
- static checkBrowserSupport(): boolean {
- return !!(
- navigator.mediaDevices &&
- typeof navigator.mediaDevices.getUserMedia === 'function' &&
- window.MediaRecorder
- );
- }
-
- async startRecording(): Promise {
- try {
- console.log('🎤 [AudioStream] startRecording called', {
- isAlreadyRecording: this.isRecording(),
- hasStream: !!this.audioStream,
- state: this.mediaRecorder?.state,
- timestamp: new Date().toISOString()
- });
-
- if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
- console.warn('Recording already in progress');
- return;
- }
-
- // Check browser support
- if (!AudioStreamService.checkBrowserSupport()) {
- const error = this.createError('browser', 'Browser does not support audio recording');
- this.errorSubject.next(error);
- throw error;
- }
-
- try {
- // Get audio stream
- this.audioStream = await navigator.mediaDevices.getUserMedia(this.constraints);
- console.log('✅ [AudioStream] Got media stream');
-
- // Create MediaRecorder with optimal MIME type
- const mimeType = this.getPreferredMimeType();
- const options: MediaRecorderOptions = {};
- if (mimeType) {
- options.mimeType = mimeType;
- }
-
- this.mediaRecorder = new MediaRecorder(this.audioStream, options);
- console.log(`✅ [AudioStream] MediaRecorder created with MIME type: ${mimeType || 'default'}`);
-
- // Set up handlers
- this.setupMediaRecorderHandlers();
-
- // Start recording with timeslice for regular data chunks
- // 100ms timeslice = 10 chunks per second
- this.mediaRecorder.start(100);
-
- this.recordingStateSubject.next(true);
- console.log('✅ [AudioStream] Recording started successfully');
-
- // Start volume monitoring
- this.startVolumeMonitoring();
-
- } catch (error: any) {
- console.error('❌ [AudioStream] getUserMedia error:', error);
-
- let audioError: AudioStreamError;
-
- if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
- audioError = this.createError('permission', 'Microphone permission denied');
- } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
- audioError = this.createError('device', 'No microphone found');
- } else {
- audioError = this.createError('unknown', `Failed to access microphone: ${error.message}`, error);
- }
-
- this.errorSubject.next(audioError);
- throw audioError;
- }
- } catch (error) {
- console.error('❌ [AudioStream] startRecording error:', error);
- this.cleanup();
- throw error;
- }
- }
-
- stopRecording(): void {
- try {
- console.log('🛑 [AudioStream] stopRecording called', {
- hasMediaRecorder: !!this.mediaRecorder,
- state: this.mediaRecorder?.state,
- timestamp: new Date().toISOString()
- });
-
- if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
- this.mediaRecorder.stop();
- }
-
- this.cleanup();
- this.recordingStateSubject.next(false);
- console.log('🛑 [AudioStream] Audio recording stopped successfully');
- } catch (error) {
- console.error('❌ [AudioStream] Error stopping recording:', error);
- this.cleanup();
- }
- }
-
- private setupMediaRecorderHandlers(): void {
- if (!this.mediaRecorder) return;
-
- // Handle data available
- this.mediaRecorder.ondataavailable = async (event) => {
- try {
- if (event.data && event.data.size > 0) {
- const base64Data = await this.blobToBase64(event.data);
- this.audioChunkSubject.next({
- data: base64Data,
- timestamp: Date.now()
- });
- }
- } catch (error) {
- console.error('Error processing audio chunk:', error);
- this.errorSubject.next(this.createError('unknown', 'Failed to process audio chunk', error));
- }
- };
-
- // Handle recording stop
- this.mediaRecorder.onstop = () => {
- console.log('MediaRecorder stopped');
- this.cleanup();
- };
-
- // Handle errors
- this.mediaRecorder.onerror = (event: any) => {
- console.error('MediaRecorder error:', event);
- const error = this.createError('unknown', `Recording error: ${event.error?.message || 'Unknown error'}`, event.error);
- this.errorSubject.next(error);
- this.stopRecording();
- };
- }
-
- private getPreferredMimeType(): string {
- const types = [
- 'audio/webm;codecs=opus',
- 'audio/webm',
- 'audio/ogg;codecs=opus',
- 'audio/ogg',
- 'audio/mp4'
- ];
-
- for (const type of types) {
- if (MediaRecorder.isTypeSupported(type)) {
- console.log(`Using MIME type: ${type}`);
- return type;
- }
- }
-
- // Return empty to use browser default
- console.warn('No supported MIME types found, using browser default');
- return '';
- }
-
- private async blobToBase64(blob: Blob): Promise {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onloadend = () => {
- if (reader.result && typeof reader.result === 'string') {
- // Remove data URL prefix
- const base64 = reader.result.split(',')[1];
- resolve(base64);
- } else {
- reject(new Error('Failed to convert blob to base64'));
- }
- };
- reader.onerror = () => {
- reject(new Error('FileReader error'));
- };
- reader.readAsDataURL(blob);
- });
- }
-
- // Volume level monitoring
- private startVolumeMonitoring(): void {
- if (!this.audioStream) return;
-
- try {
- this.audioContext = new AudioContext();
- this.analyser = this.audioContext.createAnalyser();
- const source = this.audioContext.createMediaStreamSource(this.audioStream);
-
- source.connect(this.analyser);
- this.analyser.fftSize = 256;
-
- const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
-
- // Monitor volume every 100ms
- this.volumeInterval = setInterval(() => {
- if (this.analyser) {
- this.analyser.getByteFrequencyData(dataArray);
-
- // Calculate average volume
- const sum = dataArray.reduce((acc, val) => acc + val, 0);
- const average = sum / dataArray.length;
- const normalizedVolume = average / 255; // Normalize to 0-1
-
- this.volumeLevelSubject.next(normalizedVolume);
- }
- }, 100);
- } catch (error) {
- console.warn('Failed to start volume monitoring:', error);
- }
- }
-
- private stopVolumeMonitoring(): void {
- if (this.volumeInterval) {
- clearInterval(this.volumeInterval);
- this.volumeInterval = null;
- }
-
- if (this.audioContext) {
- try {
- this.audioContext.close();
- } catch (error) {
- console.warn('Error closing audio context:', error);
- }
- this.audioContext = null;
- this.analyser = null;
- }
- }
-
- async getVolumeLevel(): Promise {
- if (!this.audioStream || !this.analyser) return 0;
-
- try {
- const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
- this.analyser.getByteFrequencyData(dataArray);
-
- // Calculate average volume
- const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
-
- return average / 255; // Normalize to 0-1
- } catch (error) {
- console.error('Error getting volume level:', error);
- return 0;
- }
- }
-
- // Check microphone permissions
- async checkMicrophonePermission(): Promise {
- try {
- // First check if Permissions API is available
- if (!navigator.permissions || !navigator.permissions.query) {
- console.warn('Permissions API not supported');
- // Try to check by attempting getUserMedia with video disabled
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
- stream.getTracks().forEach(track => track.stop());
- return 'granted';
- } catch (error: any) {
- if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
- return 'denied';
- }
- return 'prompt';
- }
- }
-
- // Use Permissions API
- const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
- return result.state;
- } catch (error) {
- console.warn('Error checking microphone permission:', error);
- // Assume prompt state if we can't determine
- return 'prompt';
- }
- }
-
- private cleanup(): void {
- try {
- // Stop media recorder
- if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
- this.mediaRecorder.stop();
- }
- this.mediaRecorder = null;
-
- // Stop all tracks
- if (this.audioStream) {
- this.audioStream.getTracks().forEach(track => {
- track.stop();
- });
- this.audioStream = null;
- }
-
- // Stop volume monitoring
- this.stopVolumeMonitoring();
-
- } catch (error) {
- console.error('Error during cleanup:', error);
- }
- }
-
- private createError(type: AudioStreamError['type'], message: string, originalError?: any): AudioStreamError {
- return {
- type,
- message,
- originalError
- };
- }
-
- // Get recording state
- isRecording(): boolean {
- return this.mediaRecorder !== null && this.mediaRecorder.state === 'recording';
- }
-
- // Get available audio devices
- async getAudioDevices(): Promise {
- try {
- const devices = await navigator.mediaDevices.enumerateDevices();
- return devices.filter(device => device.kind === 'audioinput');
- } catch (error) {
- console.error('Error enumerating devices:', error);
- return [];
- }
- }
-
- // Switch audio device
- async switchAudioDevice(deviceId: string): Promise {
- if (this.isRecording()) {
- // Stop current recording
- this.stopRecording();
-
- // Update constraints with new device
- this.constraints.audio = {
- ...this.constraints.audio,
- deviceId: { exact: deviceId }
- } as any;
-
- // Restart recording with new device
- await this.startRecording();
- } else {
- // Just update constraints for next recording
- this.constraints.audio = {
- ...this.constraints.audio,
- deviceId: { exact: deviceId }
- } as any;
- }
- }
+// audio-stream.service.ts
+// Path: /flare-ui/src/app/services/audio-stream.service.ts
+
+import { Injectable, OnDestroy } from '@angular/core';
+import { Subject, Observable, throwError } from 'rxjs';
+
+export interface AudioChunk {
+ data: string; // Base64 encoded audio
+ timestamp: number;
+}
+
+export interface AudioStreamError {
+ type: 'permission' | 'device' | 'browser' | 'unknown';
+ message: string;
+ originalError?: any;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AudioStreamService implements OnDestroy {
+ private mediaRecorder: MediaRecorder | null = null;
+ private audioStream: MediaStream | null = null;
+ private audioChunkSubject = new Subject();
+ private recordingStateSubject = new Subject();
+ private errorSubject = new Subject();
+ private volumeLevelSubject = new Subject();
+
+ public audioChunk$ = this.audioChunkSubject.asObservable();
+ public recordingState$ = this.recordingStateSubject.asObservable();
+ public error$ = this.errorSubject.asObservable();
+ public volumeLevel$ = this.volumeLevelSubject.asObservable();
+
+ // Audio analysis
+ private audioContext: AudioContext | null = null;
+ private analyser: AnalyserNode | null = null;
+ private volumeInterval: any;
+
+ // Audio constraints
+ private constraints = {
+ audio: {
+ channelCount: 1,
+ sampleRate: 16000,
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true
+ }
+ };
+
+ ngOnDestroy(): void {
+ this.cleanup();
+ }
+
+ static checkBrowserSupport(): boolean {
+ return !!(
+ navigator.mediaDevices &&
+ typeof navigator.mediaDevices.getUserMedia === 'function' &&
+ window.MediaRecorder
+ );
+ }
+
+ async startRecording(): Promise {
+ try {
+ console.log('🎤 [AudioStream] startRecording called', {
+ isAlreadyRecording: this.isRecording(),
+ hasStream: !!this.audioStream,
+ state: this.mediaRecorder?.state,
+ timestamp: new Date().toISOString()
+ });
+
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+ console.warn('Recording already in progress');
+ return;
+ }
+
+ // Check browser support
+ if (!AudioStreamService.checkBrowserSupport()) {
+ const error = this.createError('browser', 'Browser does not support audio recording');
+ this.errorSubject.next(error);
+ throw error;
+ }
+
+ try {
+ // Get audio stream
+ this.audioStream = await navigator.mediaDevices.getUserMedia(this.constraints);
+ console.log('✅ [AudioStream] Got media stream');
+
+ // Create MediaRecorder with optimal MIME type
+ const mimeType = this.getPreferredMimeType();
+ const options: MediaRecorderOptions = {};
+ if (mimeType) {
+ options.mimeType = mimeType;
+ }
+
+ this.mediaRecorder = new MediaRecorder(this.audioStream, options);
+ console.log(`✅ [AudioStream] MediaRecorder created with MIME type: ${mimeType || 'default'}`);
+
+ // Set up handlers
+ this.setupMediaRecorderHandlers();
+
+ // Start recording with timeslice for regular data chunks
+ // 100ms timeslice = 10 chunks per second
+ this.mediaRecorder.start(100);
+
+ this.recordingStateSubject.next(true);
+ console.log('✅ [AudioStream] Recording started successfully');
+
+ // Start volume monitoring
+ this.startVolumeMonitoring();
+
+ } catch (error: any) {
+ console.error('❌ [AudioStream] getUserMedia error:', error);
+
+ let audioError: AudioStreamError;
+
+ if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
+ audioError = this.createError('permission', 'Microphone permission denied');
+ } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
+ audioError = this.createError('device', 'No microphone found');
+ } else {
+ audioError = this.createError('unknown', `Failed to access microphone: ${error.message}`, error);
+ }
+
+ this.errorSubject.next(audioError);
+ throw audioError;
+ }
+ } catch (error) {
+ console.error('❌ [AudioStream] startRecording error:', error);
+ this.cleanup();
+ throw error;
+ }
+ }
+
+ stopRecording(): void {
+ try {
+ console.log('🛑 [AudioStream] stopRecording called', {
+ hasMediaRecorder: !!this.mediaRecorder,
+ state: this.mediaRecorder?.state,
+ timestamp: new Date().toISOString()
+ });
+
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+ this.mediaRecorder.stop();
+ }
+
+ this.cleanup();
+ this.recordingStateSubject.next(false);
+ console.log('🛑 [AudioStream] Audio recording stopped successfully');
+ } catch (error) {
+ console.error('❌ [AudioStream] Error stopping recording:', error);
+ this.cleanup();
+ }
+ }
+
+ private setupMediaRecorderHandlers(): void {
+ if (!this.mediaRecorder) return;
+
+ // Handle data available
+ this.mediaRecorder.ondataavailable = async (event) => {
+ try {
+ if (event.data && event.data.size > 0) {
+ const base64Data = await this.blobToBase64(event.data);
+ this.audioChunkSubject.next({
+ data: base64Data,
+ timestamp: Date.now()
+ });
+ }
+ } catch (error) {
+ console.error('Error processing audio chunk:', error);
+ this.errorSubject.next(this.createError('unknown', 'Failed to process audio chunk', error));
+ }
+ };
+
+ // Handle recording stop
+ this.mediaRecorder.onstop = () => {
+ console.log('MediaRecorder stopped');
+ this.cleanup();
+ };
+
+ // Handle errors
+ this.mediaRecorder.onerror = (event: any) => {
+ console.error('MediaRecorder error:', event);
+ const error = this.createError('unknown', `Recording error: ${event.error?.message || 'Unknown error'}`, event.error);
+ this.errorSubject.next(error);
+ this.stopRecording();
+ };
+ }
+
+ private getPreferredMimeType(): string {
+ const types = [
+ 'audio/webm;codecs=opus',
+ 'audio/webm',
+ 'audio/ogg;codecs=opus',
+ 'audio/ogg',
+ 'audio/mp4'
+ ];
+
+ for (const type of types) {
+ if (MediaRecorder.isTypeSupported(type)) {
+ console.log(`Using MIME type: ${type}`);
+ return type;
+ }
+ }
+
+ // Return empty to use browser default
+ console.warn('No supported MIME types found, using browser default');
+ return '';
+ }
+
+ private async blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ if (reader.result && typeof reader.result === 'string') {
+ // Remove data URL prefix
+ const base64 = reader.result.split(',')[1];
+ resolve(base64);
+ } else {
+ reject(new Error('Failed to convert blob to base64'));
+ }
+ };
+ reader.onerror = () => {
+ reject(new Error('FileReader error'));
+ };
+ reader.readAsDataURL(blob);
+ });
+ }
+
+ // Volume level monitoring
+ private startVolumeMonitoring(): void {
+ if (!this.audioStream) return;
+
+ try {
+ this.audioContext = new AudioContext();
+ this.analyser = this.audioContext.createAnalyser();
+ const source = this.audioContext.createMediaStreamSource(this.audioStream);
+
+ source.connect(this.analyser);
+ this.analyser.fftSize = 256;
+
+ const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
+
+ // Monitor volume every 100ms
+ this.volumeInterval = setInterval(() => {
+ if (this.analyser) {
+ this.analyser.getByteFrequencyData(dataArray);
+
+ // Calculate average volume
+ const sum = dataArray.reduce((acc, val) => acc + val, 0);
+ const average = sum / dataArray.length;
+ const normalizedVolume = average / 255; // Normalize to 0-1
+
+ this.volumeLevelSubject.next(normalizedVolume);
+ }
+ }, 100);
+ } catch (error) {
+ console.warn('Failed to start volume monitoring:', error);
+ }
+ }
+
+ private stopVolumeMonitoring(): void {
+ if (this.volumeInterval) {
+ clearInterval(this.volumeInterval);
+ this.volumeInterval = null;
+ }
+
+ if (this.audioContext) {
+ try {
+ this.audioContext.close();
+ } catch (error) {
+ console.warn('Error closing audio context:', error);
+ }
+ this.audioContext = null;
+ this.analyser = null;
+ }
+ }
+
+ async getVolumeLevel(): Promise {
+ if (!this.audioStream || !this.analyser) return 0;
+
+ try {
+ const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
+ this.analyser.getByteFrequencyData(dataArray);
+
+ // Calculate average volume
+ const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
+
+ return average / 255; // Normalize to 0-1
+ } catch (error) {
+ console.error('Error getting volume level:', error);
+ return 0;
+ }
+ }
+
+ // Check microphone permissions
+ async checkMicrophonePermission(): Promise {
+ try {
+ // First check if Permissions API is available
+ if (!navigator.permissions || !navigator.permissions.query) {
+ console.warn('Permissions API not supported');
+ // Try to check by attempting getUserMedia with video disabled
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
+ stream.getTracks().forEach(track => track.stop());
+ return 'granted';
+ } catch (error: any) {
+ if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
+ return 'denied';
+ }
+ return 'prompt';
+ }
+ }
+
+ // Use Permissions API
+ const result = await navigator.permissions.query({ name: 'microphone' as PermissionName });
+ return result.state;
+ } catch (error) {
+ console.warn('Error checking microphone permission:', error);
+ // Assume prompt state if we can't determine
+ return 'prompt';
+ }
+ }
+
+ private cleanup(): void {
+ try {
+ // Stop media recorder
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+ this.mediaRecorder.stop();
+ }
+ this.mediaRecorder = null;
+
+ // Stop all tracks
+ if (this.audioStream) {
+ this.audioStream.getTracks().forEach(track => {
+ track.stop();
+ });
+ this.audioStream = null;
+ }
+
+ // Stop volume monitoring
+ this.stopVolumeMonitoring();
+
+ } catch (error) {
+ console.error('Error during cleanup:', error);
+ }
+ }
+
+ private createError(type: AudioStreamError['type'], message: string, originalError?: any): AudioStreamError {
+ return {
+ type,
+ message,
+ originalError
+ };
+ }
+
+ // Get recording state
+ isRecording(): boolean {
+ return this.mediaRecorder !== null && this.mediaRecorder.state === 'recording';
+ }
+
+ // Get available audio devices
+ async getAudioDevices(): Promise {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ return devices.filter(device => device.kind === 'audioinput');
+ } catch (error) {
+ console.error('Error enumerating devices:', error);
+ return [];
+ }
+ }
+
+ // Switch audio device
+ async switchAudioDevice(deviceId: string): Promise {
+ if (this.isRecording()) {
+ // Stop current recording
+ this.stopRecording();
+
+ // Update constraints with new device
+ this.constraints.audio = {
+ ...this.constraints.audio,
+ deviceId: { exact: deviceId }
+ } as any;
+
+ // Restart recording with new device
+ await this.startRecording();
+ } else {
+ // Just update constraints for next recording
+ this.constraints.audio = {
+ ...this.constraints.audio,
+ deviceId: { exact: deviceId }
+ } as any;
+ }
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/auth.service.ts b/flare-ui/src/app/services/auth.service.ts
index 44d337a95063ec0d9421199b8c6865eaeb6c8ca2..f2ecbed6f848f1b3c6fbc344fad12fd625c56c8d 100644
--- a/flare-ui/src/app/services/auth.service.ts
+++ b/flare-ui/src/app/services/auth.service.ts
@@ -1,344 +1,344 @@
-// auth.service.ts
-// Path: /flare-ui/src/app/services/auth.service.ts
-
-import { Injectable, inject } from '@angular/core';
-import { HttpClient, HttpErrorResponse } from '@angular/common/http';
-import { Router } from '@angular/router';
-import { BehaviorSubject, Observable, throwError, of } from 'rxjs';
-import { tap, catchError, map, timeout, retry } from 'rxjs/operators';
-
-interface LoginResponse {
- token: string;
- username: string;
- expires_at?: string;
- refresh_token?: string;
-}
-
-interface AuthError {
- message: string;
- code?: string;
- details?: any;
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class AuthService {
- private http = inject(HttpClient);
- private router = inject(Router);
-
- private tokenKey = 'flare_token';
- private usernameKey = 'flare_username';
- private refreshTokenKey = 'flare_refresh_token';
- private tokenExpiryKey = 'flare_token_expiry';
-
- private loggedInSubject = new BehaviorSubject(this.hasValidToken());
- public loggedIn$ = this.loggedInSubject.asObservable();
-
- private readonly REQUEST_TIMEOUT = 30000; // 30 seconds
- private tokenRefreshInProgress = false;
- private tokenRefreshSubject = new BehaviorSubject(null);
-
- login(username: string, password: string): Observable {
- // Validate input
- if (!username || !password) {
- return throwError(() => ({
- message: 'Username and password are required',
- code: 'VALIDATION_ERROR'
- } as AuthError));
- }
-
- return this.http.post('/api/admin/login', { username, password })
- .pipe(
- timeout(this.REQUEST_TIMEOUT),
- retry({ count: 2, delay: 1000 }),
- tap(response => {
- this.handleLoginSuccess(response);
- }),
- catchError(error => this.handleAuthError(error, 'login'))
- );
- }
-
- logout(): void {
- try {
- // Clear all auth data
- this.clearAuthData();
-
- // Update logged in state
- this.loggedInSubject.next(false);
-
- // Optional: Call logout endpoint
- this.http.post('/api/logout', {}).pipe(
- catchError(() => of(null)) // Ignore logout errors
- ).subscribe();
-
- // Navigate to login
- this.router.navigate(['/login']);
-
- console.log('✅ User logged out successfully');
- } catch (error) {
- console.error('Error during logout:', error);
- // Still navigate to login even if error occurs
- this.router.navigate(['/login']);
- }
- }
-
- refreshToken(): Observable {
- const refreshToken = this.getRefreshToken();
-
- if (!refreshToken) {
- return throwError(() => ({
- message: 'No refresh token available',
- code: 'NO_REFRESH_TOKEN'
- } as AuthError));
- }
-
- // Prevent multiple simultaneous refresh requests
- if (this.tokenRefreshInProgress) {
- return this.tokenRefreshSubject.asObservable().pipe(
- map(token => {
- if (token) {
- return { token, username: this.getUsername() || '' } as LoginResponse;
- }
- throw new Error('Token refresh failed');
- })
- );
- }
-
- this.tokenRefreshInProgress = true;
-
- return this.http.post('/api/refresh', { refresh_token: refreshToken })
- .pipe(
- timeout(this.REQUEST_TIMEOUT),
- tap(response => {
- this.handleLoginSuccess(response);
- this.tokenRefreshSubject.next(response.token);
- this.tokenRefreshInProgress = false;
- }),
- catchError(error => {
- this.tokenRefreshInProgress = false;
- this.tokenRefreshSubject.next(null);
-
- // If refresh fails, logout user
- if (error.status === 401 || error.status === 403) {
- this.logout();
- }
-
- return this.handleAuthError(error, 'refresh');
- })
- );
- }
-
- getToken(): string | null {
- try {
- // Check if token is expired
- if (this.isTokenExpired()) {
- this.clearAuthData();
- return null;
- }
-
- return localStorage.getItem(this.tokenKey);
- } catch (error) {
- console.error('Error getting token:', error);
- return null;
- }
- }
-
- getUsername(): string | null {
- try {
- return localStorage.getItem(this.usernameKey);
- } catch (error) {
- console.error('Error getting username:', error);
- return null;
- }
- }
-
- getRefreshToken(): string | null {
- try {
- return localStorage.getItem(this.refreshTokenKey);
- } catch (error) {
- console.error('Error getting refresh token:', error);
- return null;
- }
- }
-
- setToken(token: string): void {
- try {
- localStorage.setItem(this.tokenKey, token);
- } catch (error) {
- console.error('Error setting token:', error);
- throw new Error('Failed to save authentication token');
- }
- }
-
- setUsername(username: string): void {
- try {
- localStorage.setItem(this.usernameKey, username);
- } catch (error) {
- console.error('Error setting username:', error);
- }
- }
-
- hasToken(): boolean {
- return !!this.getToken();
- }
-
- hasValidToken(): boolean {
- return this.hasToken() && !this.isTokenExpired();
- }
-
- isLoggedIn(): boolean {
- return this.hasValidToken();
- }
-
- isTokenExpired(): boolean {
- try {
- const expiryStr = localStorage.getItem(this.tokenExpiryKey);
- if (!expiryStr) {
- return false; // No expiry means token doesn't expire
- }
-
- const expiry = new Date(expiryStr);
- return expiry <= new Date();
- } catch (error) {
- console.error('Error checking token expiry:', error);
- return true; // Assume expired on error
- }
- }
-
- getTokenExpiry(): Date | null {
- try {
- const expiryStr = localStorage.getItem(this.tokenExpiryKey);
- return expiryStr ? new Date(expiryStr) : null;
- } catch (error) {
- console.error('Error getting token expiry:', error);
- return null;
- }
- }
-
- private handleLoginSuccess(response: LoginResponse): void {
- try {
- // Save auth data
- this.setToken(response.token);
- this.setUsername(response.username);
-
- if (response.refresh_token) {
- localStorage.setItem(this.refreshTokenKey, response.refresh_token);
- }
-
- if (response.expires_at) {
- localStorage.setItem(this.tokenExpiryKey, response.expires_at);
- }
-
- // Update logged in state
- this.loggedInSubject.next(true);
-
- console.log('✅ Login successful for user:', response.username);
- } catch (error) {
- console.error('Error handling login success:', error);
- throw new Error('Failed to save authentication data');
- }
- }
-
- private clearAuthData(): void {
- try {
- localStorage.removeItem(this.tokenKey);
- localStorage.removeItem(this.usernameKey);
- localStorage.removeItem(this.refreshTokenKey);
- localStorage.removeItem(this.tokenExpiryKey);
- } catch (error) {
- console.error('Error clearing auth data:', error);
- }
- }
-
- private handleAuthError(error: HttpErrorResponse, operation: string): Observable {
- console.error(`Auth error during ${operation}:`, error);
-
- let authError: AuthError;
-
- // Handle different error types
- if (error.status === 0) {
- // Network error
- authError = {
- message: 'Network error. Please check your connection.',
- code: 'NETWORK_ERROR'
- };
- } else if (error.status === 401) {
- authError = {
- message: error.error?.message || 'Invalid credentials',
- code: 'UNAUTHORIZED'
- };
- } else if (error.status === 403) {
- authError = {
- message: error.error?.message || 'Access forbidden',
- code: 'FORBIDDEN'
- };
- } else if (error.status === 409) {
- // Race condition
- authError = {
- message: error.error?.message || 'Request conflict. Please try again.',
- code: 'CONFLICT',
- details: error.error?.details
- };
- } else if (error.status === 422) {
- // Validation error
- authError = {
- message: error.error?.message || 'Validation error',
- code: 'VALIDATION_ERROR',
- details: error.error?.details
- };
- } else if (error.status >= 500) {
- authError = {
- message: 'Server error. Please try again later.',
- code: 'SERVER_ERROR'
- };
- } else {
- authError = {
- message: error.error?.message || error.message || 'Authentication failed',
- code: 'UNKNOWN_ERROR'
- };
- }
-
- return throwError(() => authError);
- }
-
- // Validate current session
- validateSession(): Observable {
- if (!this.hasToken()) {
- return of(false);
- }
-
- return this.http.get<{ valid: boolean }>('/api/validate')
- .pipe(
- timeout(this.REQUEST_TIMEOUT),
- map(response => response.valid),
- catchError(error => {
- if (error.status === 401) {
- this.clearAuthData();
- this.loggedInSubject.next(false);
- }
- return of(false);
- })
- );
- }
-
- // Get user profile
- getUserProfile(): Observable {
- return this.http.get('/api/user/profile')
- .pipe(
- timeout(this.REQUEST_TIMEOUT),
- catchError(error => this.handleAuthError(error, 'getUserProfile'))
- );
- }
-
- // Update password
- updatePassword(currentPassword: string, newPassword: string): Observable {
- return this.http.post('/api/user/password', {
- current_password: currentPassword,
- new_password: newPassword
- }).pipe(
- timeout(this.REQUEST_TIMEOUT),
- catchError(error => this.handleAuthError(error, 'updatePassword'))
- );
- }
+// auth.service.ts
+// Path: /flare-ui/src/app/services/auth.service.ts
+
+import { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
+import { Router } from '@angular/router';
+import { BehaviorSubject, Observable, throwError, of } from 'rxjs';
+import { tap, catchError, map, timeout, retry } from 'rxjs/operators';
+
+interface LoginResponse {
+ token: string;
+ username: string;
+ expires_at?: string;
+ refresh_token?: string;
+}
+
+interface AuthError {
+ message: string;
+ code?: string;
+ details?: any;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AuthService {
+ private http = inject(HttpClient);
+ private router = inject(Router);
+
+ private tokenKey = 'flare_token';
+ private usernameKey = 'flare_username';
+ private refreshTokenKey = 'flare_refresh_token';
+ private tokenExpiryKey = 'flare_token_expiry';
+
+ private loggedInSubject = new BehaviorSubject(this.hasValidToken());
+ public loggedIn$ = this.loggedInSubject.asObservable();
+
+ private readonly REQUEST_TIMEOUT = 30000; // 30 seconds
+ private tokenRefreshInProgress = false;
+ private tokenRefreshSubject = new BehaviorSubject(null);
+
+ login(username: string, password: string): Observable {
+ // Validate input
+ if (!username || !password) {
+ return throwError(() => ({
+ message: 'Username and password are required',
+ code: 'VALIDATION_ERROR'
+ } as AuthError));
+ }
+
+ return this.http.post('/api/admin/login', { username, password })
+ .pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ retry({ count: 2, delay: 1000 }),
+ tap(response => {
+ this.handleLoginSuccess(response);
+ }),
+ catchError(error => this.handleAuthError(error, 'login'))
+ );
+ }
+
+ logout(): void {
+ try {
+ // Clear all auth data
+ this.clearAuthData();
+
+ // Update logged in state
+ this.loggedInSubject.next(false);
+
+ // Optional: Call logout endpoint
+ this.http.post('/api/logout', {}).pipe(
+ catchError(() => of(null)) // Ignore logout errors
+ ).subscribe();
+
+ // Navigate to login
+ this.router.navigate(['/login']);
+
+ console.log('✅ User logged out successfully');
+ } catch (error) {
+ console.error('Error during logout:', error);
+ // Still navigate to login even if error occurs
+ this.router.navigate(['/login']);
+ }
+ }
+
+ refreshToken(): Observable {
+ const refreshToken = this.getRefreshToken();
+
+ if (!refreshToken) {
+ return throwError(() => ({
+ message: 'No refresh token available',
+ code: 'NO_REFRESH_TOKEN'
+ } as AuthError));
+ }
+
+ // Prevent multiple simultaneous refresh requests
+ if (this.tokenRefreshInProgress) {
+ return this.tokenRefreshSubject.asObservable().pipe(
+ map(token => {
+ if (token) {
+ return { token, username: this.getUsername() || '' } as LoginResponse;
+ }
+ throw new Error('Token refresh failed');
+ })
+ );
+ }
+
+ this.tokenRefreshInProgress = true;
+
+ return this.http.post('/api/refresh', { refresh_token: refreshToken })
+ .pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ tap(response => {
+ this.handleLoginSuccess(response);
+ this.tokenRefreshSubject.next(response.token);
+ this.tokenRefreshInProgress = false;
+ }),
+ catchError(error => {
+ this.tokenRefreshInProgress = false;
+ this.tokenRefreshSubject.next(null);
+
+ // If refresh fails, logout user
+ if (error.status === 401 || error.status === 403) {
+ this.logout();
+ }
+
+ return this.handleAuthError(error, 'refresh');
+ })
+ );
+ }
+
+ getToken(): string | null {
+ try {
+ // Check if token is expired
+ if (this.isTokenExpired()) {
+ this.clearAuthData();
+ return null;
+ }
+
+ return localStorage.getItem(this.tokenKey);
+ } catch (error) {
+ console.error('Error getting token:', error);
+ return null;
+ }
+ }
+
+ getUsername(): string | null {
+ try {
+ return localStorage.getItem(this.usernameKey);
+ } catch (error) {
+ console.error('Error getting username:', error);
+ return null;
+ }
+ }
+
+ getRefreshToken(): string | null {
+ try {
+ return localStorage.getItem(this.refreshTokenKey);
+ } catch (error) {
+ console.error('Error getting refresh token:', error);
+ return null;
+ }
+ }
+
+ setToken(token: string): void {
+ try {
+ localStorage.setItem(this.tokenKey, token);
+ } catch (error) {
+ console.error('Error setting token:', error);
+ throw new Error('Failed to save authentication token');
+ }
+ }
+
+ setUsername(username: string): void {
+ try {
+ localStorage.setItem(this.usernameKey, username);
+ } catch (error) {
+ console.error('Error setting username:', error);
+ }
+ }
+
+ hasToken(): boolean {
+ return !!this.getToken();
+ }
+
+ hasValidToken(): boolean {
+ return this.hasToken() && !this.isTokenExpired();
+ }
+
+ isLoggedIn(): boolean {
+ return this.hasValidToken();
+ }
+
+ isTokenExpired(): boolean {
+ try {
+ const expiryStr = localStorage.getItem(this.tokenExpiryKey);
+ if (!expiryStr) {
+ return false; // No expiry means token doesn't expire
+ }
+
+ const expiry = new Date(expiryStr);
+ return expiry <= new Date();
+ } catch (error) {
+ console.error('Error checking token expiry:', error);
+ return true; // Assume expired on error
+ }
+ }
+
+ getTokenExpiry(): Date | null {
+ try {
+ const expiryStr = localStorage.getItem(this.tokenExpiryKey);
+ return expiryStr ? new Date(expiryStr) : null;
+ } catch (error) {
+ console.error('Error getting token expiry:', error);
+ return null;
+ }
+ }
+
+ private handleLoginSuccess(response: LoginResponse): void {
+ try {
+ // Save auth data
+ this.setToken(response.token);
+ this.setUsername(response.username);
+
+ if (response.refresh_token) {
+ localStorage.setItem(this.refreshTokenKey, response.refresh_token);
+ }
+
+ if (response.expires_at) {
+ localStorage.setItem(this.tokenExpiryKey, response.expires_at);
+ }
+
+ // Update logged in state
+ this.loggedInSubject.next(true);
+
+ console.log('✅ Login successful for user:', response.username);
+ } catch (error) {
+ console.error('Error handling login success:', error);
+ throw new Error('Failed to save authentication data');
+ }
+ }
+
+ private clearAuthData(): void {
+ try {
+ localStorage.removeItem(this.tokenKey);
+ localStorage.removeItem(this.usernameKey);
+ localStorage.removeItem(this.refreshTokenKey);
+ localStorage.removeItem(this.tokenExpiryKey);
+ } catch (error) {
+ console.error('Error clearing auth data:', error);
+ }
+ }
+
+ private handleAuthError(error: HttpErrorResponse, operation: string): Observable {
+ console.error(`Auth error during ${operation}:`, error);
+
+ let authError: AuthError;
+
+ // Handle different error types
+ if (error.status === 0) {
+ // Network error
+ authError = {
+ message: 'Network error. Please check your connection.',
+ code: 'NETWORK_ERROR'
+ };
+ } else if (error.status === 401) {
+ authError = {
+ message: error.error?.message || 'Invalid credentials',
+ code: 'UNAUTHORIZED'
+ };
+ } else if (error.status === 403) {
+ authError = {
+ message: error.error?.message || 'Access forbidden',
+ code: 'FORBIDDEN'
+ };
+ } else if (error.status === 409) {
+ // Race condition
+ authError = {
+ message: error.error?.message || 'Request conflict. Please try again.',
+ code: 'CONFLICT',
+ details: error.error?.details
+ };
+ } else if (error.status === 422) {
+ // Validation error
+ authError = {
+ message: error.error?.message || 'Validation error',
+ code: 'VALIDATION_ERROR',
+ details: error.error?.details
+ };
+ } else if (error.status >= 500) {
+ authError = {
+ message: 'Server error. Please try again later.',
+ code: 'SERVER_ERROR'
+ };
+ } else {
+ authError = {
+ message: error.error?.message || error.message || 'Authentication failed',
+ code: 'UNKNOWN_ERROR'
+ };
+ }
+
+ return throwError(() => authError);
+ }
+
+ // Validate current session
+ validateSession(): Observable {
+ if (!this.hasToken()) {
+ return of(false);
+ }
+
+ return this.http.get<{ valid: boolean }>('/api/validate')
+ .pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ map(response => response.valid),
+ catchError(error => {
+ if (error.status === 401) {
+ this.clearAuthData();
+ this.loggedInSubject.next(false);
+ }
+ return of(false);
+ })
+ );
+ }
+
+ // Get user profile
+ getUserProfile(): Observable {
+ return this.http.get('/api/user/profile')
+ .pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ catchError(error => this.handleAuthError(error, 'getUserProfile'))
+ );
+ }
+
+ // Update password
+ updatePassword(currentPassword: string, newPassword: string): Observable {
+ return this.http.post('/api/user/password', {
+ current_password: currentPassword,
+ new_password: newPassword
+ }).pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ catchError(error => this.handleAuthError(error, 'updatePassword'))
+ );
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/conversation-manager.service.ts b/flare-ui/src/app/services/conversation-manager.service.ts
index 176aea26c62bb887d224d2df1a663142d96df062..43859c70721a75ff50cc05ed236be67b4cbe4e3f 100644
--- a/flare-ui/src/app/services/conversation-manager.service.ts
+++ b/flare-ui/src/app/services/conversation-manager.service.ts
@@ -1,830 +1,830 @@
-// conversation-manager.service.ts
-// Path: /flare-ui/src/app/services/conversation-manager.service.ts
-
-import { Injectable, OnDestroy } from '@angular/core';
-import { Subject, Subscription, BehaviorSubject, throwError } from 'rxjs';
-import { catchError, retry } from 'rxjs/operators';
-import { WebSocketService } from './websocket.service';
-import { AudioStreamService } from './audio-stream.service';
-
-export type ConversationState =
- | 'idle'
- | 'listening'
- | 'processing_stt'
- | 'processing_llm'
- | 'processing_tts'
- | 'playing_audio'
- | 'error';
-
-export interface ConversationMessage {
- role: 'user' | 'assistant' | 'system';
- text: string;
- timestamp: Date;
- audioUrl?: string;
- error?: boolean;
-}
-
-export interface ConversationConfig {
- language?: string;
- stt_engine?: string;
- tts_engine?: string;
- enable_barge_in?: boolean;
- max_silence_duration?: number;
-}
-
-export interface ConversationError {
- type: 'websocket' | 'audio' | 'permission' | 'network' | 'unknown';
- message: string;
- details?: any;
- timestamp: Date;
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class ConversationManagerService implements OnDestroy {
- private subscriptions = new Subscription();
- private audioQueue: string[] = [];
- private isInterrupting = false;
- private sessionId: string | null = null;
- private conversationConfig: ConversationConfig = {
- language: 'tr-TR',
- stt_engine: 'google',
- enable_barge_in: true
- };
-
- // State management
- private currentStateSubject = new BehaviorSubject('idle');
- public currentState$ = this.currentStateSubject.asObservable();
-
- // Message history
- private messagesSubject = new BehaviorSubject([]);
- public messages$ = this.messagesSubject.asObservable();
-
- // Current transcription
- private transcriptionSubject = new BehaviorSubject('');
- public transcription$ = this.transcriptionSubject.asObservable();
-
- // Error handling
- private errorSubject = new Subject();
- public error$ = this.errorSubject.asObservable();
-
- private sttReadySubject = new Subject();
-
- // Audio player reference
- private audioPlayer: HTMLAudioElement | null = null;
- private audioPlayerPromise: Promise | null = null;
-
- constructor(
- private wsService: WebSocketService,
- private audioService: AudioStreamService
- ) {}
-
- ngOnDestroy(): void {
- this.cleanup();
- }
-
- async startConversation(sessionId: string, config?: ConversationConfig): Promise {
- try {
- if (!sessionId) {
- throw new Error('Session ID is required');
- }
-
- // Update configuration
- if (config) {
- this.conversationConfig = { ...this.conversationConfig, ...config };
- }
-
- this.sessionId = sessionId;
-
- // Start in listening state
- this.currentStateSubject.next('listening');
- console.log('🎤 Starting conversation in continuous listening mode');
-
- // Connect WebSocket first
- await this.wsService.connect(sessionId).catch(error => {
- throw new Error(`WebSocket connection failed: ${error.message}`);
- });
-
- // Set up subscriptions BEFORE sending any messages
- this.setupSubscriptions();
-
- // Send start signal with configuration
- this.wsService.sendControl('start_session', {
- ...this.conversationConfig,
- continuous_listening: true
- });
-
- console.log('✅ [ConversationManager] Conversation started - waiting for welcome TTS');
-
- } catch (error: any) {
- console.error('Failed to start conversation:', error);
-
- const conversationError: ConversationError = {
- type: this.determineErrorType(error),
- message: error.message || 'Failed to start conversation',
- details: error,
- timestamp: new Date()
- };
-
- this.errorSubject.next(conversationError);
- this.currentStateSubject.next('error');
- this.cleanup();
-
- throw error;
- }
- }
-
- stopConversation(): void {
- try {
- // First stop audio recording
- this.audioService.stopRecording();
-
- // Then send stop signal if connected
- if (this.wsService.isConnected()) {
- this.wsService.sendControl('stop_session');
- }
-
- // Small delay before disconnecting
- setTimeout(() => {
- this.cleanup();
- this.addSystemMessage('Conversation ended');
- }, 100);
-
- } catch (error) {
- console.error('Error stopping conversation:', error);
- this.cleanup();
- }
- }
-
- private setupSubscriptions(): void {
- // Audio chunks from microphone
- this.subscriptions.add(
- this.audioService.audioChunk$.subscribe({
- next: (chunk) => {
- if (!this.isInterrupting && this.wsService.isConnected()) {
- try {
- this.wsService.sendAudioChunk(chunk.data);
- } catch (error) {
- console.error('Failed to send audio chunk:', error);
- }
- }
- },
- error: (error) => {
- console.error('Audio stream error:', error);
- this.handleAudioError(error);
- }
- })
- );
-
- // Audio stream errors
- this.subscriptions.add(
- this.audioService.error$.subscribe(error => {
- this.handleAudioError(error);
- })
- );
-
- // WebSocket messages
- this.subscriptions.add(
- this.wsService.message$.subscribe({
- next: (message) => {
- this.handleMessage(message);
- },
- error: (error) => {
- console.error('WebSocket message error:', error);
- this.handleWebSocketError(error);
- }
- })
- );
-
- // Subscribe to transcription updates - SADECE FINAL RESULTS
- this.subscriptions.add(
- this.wsService.transcription$.subscribe(result => {
- // SADECE final transcription'ları işle
- if (result.is_final) {
- console.log('📝 Final transcription received:', result);
- const messages = this.messagesSubject.value;
- const lastMessage = messages[messages.length - 1];
- if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== result.text) {
- this.addMessage('user', result.text);
- }
- }
- })
- );
-
- // State changes
- this.subscriptions.add(
- this.wsService.stateChange$.subscribe(change => {
- this.currentStateSubject.next(change.to as ConversationState);
- this.handleStateChange(change.from, change.to);
- })
- );
-
- // WebSocket errors
- this.subscriptions.add(
- this.wsService.error$.subscribe(error => {
- console.error('WebSocket error:', error);
- this.handleWebSocketError({ message: error });
- })
- );
-
- // WebSocket connection state
- this.subscriptions.add(
- this.wsService.connection$.subscribe(connected => {
- if (!connected && this.currentStateSubject.value !== 'idle') {
- this.addSystemMessage('Connection lost. Attempting to reconnect...');
- }
- })
- );
- }
-
- private handleMessage(message: any): void {
- try {
- switch (message.type) {
- case 'transcription':
- // SADECE final transcription'ları işle
- if (message['is_final']) {
- const messages = this.messagesSubject.value;
- const lastMessage = messages[messages.length - 1];
- if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== message['text']) {
- this.addMessage('user', message['text']);
- }
- }
- // Interim transcription'ları artık işlemiyoruz
- break;
-
- case 'assistant_response':
- // Welcome mesajı veya normal yanıt
- const isWelcome = message['is_welcome'] || false;
- this.addMessage('assistant', message['text']);
-
- if (isWelcome) {
- console.log('📢 Welcome message received:', message['text']);
- }
- break;
-
- case 'tts_audio':
- this.handleTTSAudio(message);
- break;
-
- case 'tts_error':
- // TTS hatası durumunda kullanıcıya bilgi ver
- console.error('TTS Error:', message['message']);
- this.addSystemMessage(message['message']);
- break;
-
- case 'control':
- if (message['action'] === 'stop_playback') {
- this.stopAudioPlayback();
- }
- break;
-
- case 'error':
- this.handleServerError(message);
- break;
-
- case 'session_config':
- // Update configuration from server
- if (message['config']) {
- this.conversationConfig = { ...this.conversationConfig, ...message['config'] };
- }
- break;
-
- case 'session_started':
- // Session başladı, STT durumunu kontrol et
- console.log('📢 Session started:', message);
- if (!message['stt_initialized']) {
- this.addSystemMessage('Speech recognition failed to initialize. Voice input will not be available.');
- }
- break;
-
- case 'stt_ready':
- console.log('✅ [ConversationManager] Backend STT ready signal received');
- this.sttReadySubject.next(true);
- break;
- }
- } catch (error) {
- console.error('Error handling message:', error);
- this.errorSubject.next({
- type: 'unknown',
- message: 'Failed to process message',
- details: error,
- timestamp: new Date()
- });
- }
- }
-
- private handleStateChange(from: string, to: string): void {
- console.log(`📊 State: ${from} → ${to}`);
-
- // State değişimlerinde transcription'ı temizleme
- // Sadece error durumunda temizle
- if (to === 'error') {
- this.transcriptionSubject.next('');
- }
-
- // Log state changes for debugging
- console.log(`🎤 Continuous listening mode - state: ${to}`);
- }
-
- private playQueuedAudio(): void {
- const messages = this.messagesSubject.value;
- const lastMessage = messages[messages.length - 1];
-
- if (lastMessage?.audioUrl && lastMessage.role === 'assistant') {
- this.playAudio(lastMessage.audioUrl);
- }
- }
-
- private async playAudio(audioUrl: string): Promise {
- try {
- console.log('🎵 [ConversationManager] playAudio called', {
- hasAudioPlayer: !!this.audioPlayer,
- audioUrl: audioUrl,
- timestamp: new Date().toISOString()
- });
-
- // Her seferinde yeni audio player oluştur ve handler'ları set et
- if (this.audioPlayer) {
- // Eski player'ı temizle
- this.audioPlayer.pause();
- this.audioPlayer.src = '';
- this.audioPlayer = null;
- }
-
- // Yeni player oluştur
- this.audioPlayer = new Audio();
- this.setupAudioPlayerHandlers(); // HER SEFERINDE handler'ları set et
-
- this.audioPlayer.src = audioUrl;
-
- // Store the play promise to handle interruptions properly
- this.audioPlayerPromise = this.audioPlayer.play();
-
- await this.audioPlayerPromise;
-
- } catch (error: any) {
- // Check if error is due to interruption
- if (error.name === 'AbortError') {
- console.log('Audio playback interrupted');
- } else {
- console.error('Audio playback error:', error);
- this.errorSubject.next({
- type: 'audio',
- message: 'Failed to play audio response',
- details: error,
- timestamp: new Date()
- });
- }
- } finally {
- this.audioPlayerPromise = null;
- }
- }
-
- private setupAudioPlayerHandlers(): void {
- if (!this.audioPlayer) return;
-
- this.audioPlayer.onended = async () => {
- console.log('🎵 [ConversationManager] Audio playback ended', {
- currentState: this.currentStateSubject.value,
- isRecording: this.audioService.isRecording(),
- timestamp: new Date().toISOString()
- });
-
- try {
- // Backend'e audio bittiğini bildir
- if (this.wsService.isConnected()) {
- console.log('📤 [ConversationManager] Sending audio_ended to backend');
- this.wsService.sendControl('audio_ended');
-
- // Backend'den STT ready sinyalini bekle
- console.log('⏳ [ConversationManager] Waiting for STT ready signal...');
-
- // STT ready handler'ı kur
- const sttReadyPromise = new Promise((resolve) => {
- const subscription = this.wsService.message$.subscribe(message => {
- if (message.type === 'stt_ready') {
- console.log('✅ [ConversationManager] STT ready signal received');
- subscription.unsubscribe();
- resolve(true);
- }
- });
-
- // 10 saniye timeout
- setTimeout(() => {
- subscription.unsubscribe();
- resolve(false);
- }, 10000);
- });
-
- const sttReady = await sttReadyPromise;
-
- if (sttReady) {
- console.log('🎤 [ConversationManager] Starting audio recording');
-
- // Recording'i başlat
- if (!this.audioService.isRecording()) {
- await this.audioService.startRecording();
- console.log('✅ [ConversationManager] Audio recording started');
- }
- } else {
- console.error('❌ [ConversationManager] STT ready timeout');
- this.addSystemMessage('Speech recognition initialization timeout. Please try again.');
- }
- }
-
- } catch (error) {
- console.error('❌ [ConversationManager] Failed to handle audio end:', error);
- this.handleAudioError(error);
- }
- };
-
- this.audioPlayer.onerror = (error) => {
- console.error('Audio player error:', error);
- };
- }
-
- private stopAudioPlayback(): void {
- try {
- if (this.audioPlayer) {
- this.audioPlayer.pause();
- this.audioPlayer.currentTime = 0;
-
- // Cancel any pending play promise
- if (this.audioPlayerPromise) {
- this.audioPlayerPromise.catch(() => {
- // Ignore abort errors
- });
- this.audioPlayerPromise = null;
- }
- }
- } catch (error) {
- console.error('Error stopping audio playback:', error);
- }
- }
-
- // Barge-in handling - DEVRE DIŞI
- performBargeIn(): void {
- // Barge-in özelliği devre dışı bırakıldı
- console.log('⚠️ Barge-in is currently disabled');
-
- // Kullanıcıya bilgi ver
- this.addSystemMessage('Barge-in feature is currently disabled.');
- }
-
- private addMessage(role: 'user' | 'assistant', text: string, error: boolean = false): void {
- if (!text || text.trim().length === 0) {
- return;
- }
-
- const messages = this.messagesSubject.value;
- messages.push({
- role,
- text,
- timestamp: new Date(),
- error
- });
- this.messagesSubject.next([...messages]);
- }
-
- private addSystemMessage(text: string): void {
- console.log(`📢 System: ${text}`);
- const messages = this.messagesSubject.value;
- messages.push({
- role: 'system',
- text,
- timestamp: new Date()
- });
- this.messagesSubject.next([...messages]);
- }
-
- private handleTTSAudio(message: any): void {
- try {
- // Validate audio data
- if (!message['data']) {
- console.warn('❌ TTS audio message missing data');
- return;
- }
-
- // Detailed log
- console.log('🎵 TTS chunk received:', {
- chunkIndex: message['chunk_index'],
- totalChunks: message['total_chunks'],
- dataLength: message['data'].length,
- dataPreview: message['data'].substring(0, 50) + '...',
- isLast: message['is_last'],
- mimeType: message['mime_type']
- });
-
- // Accumulate audio chunks (already base64)
- this.audioQueue.push(message['data']);
- console.log(`📦 Audio queue size: ${this.audioQueue.length} chunks`);
-
- if (message['is_last']) {
- console.log('🔧 Processing final audio chunk...');
-
- try {
- // All chunks received, combine and create audio blob
- const combinedBase64 = this.audioQueue.join('');
- console.log('✅ Combined audio data:', {
- totalLength: combinedBase64.length,
- queueSize: this.audioQueue.length,
- preview: combinedBase64.substring(0, 100) + '...'
- });
-
- // Validate base64
- console.log('🔍 Validating base64...');
- if (!this.isValidBase64(combinedBase64)) {
- throw new Error('Invalid base64 data received');
- }
- console.log('✅ Base64 validation passed');
-
- const audioBlob = this.base64ToBlob(combinedBase64, message['mime_type'] || 'audio/mpeg');
- const audioUrl = URL.createObjectURL(audioBlob);
- console.log('🎧 Audio URL created:', audioUrl);
-
- // Update last message with audio URL
- const messages = this.messagesSubject.value;
- if (messages.length > 0) {
- const lastAssistantMessageIndex = this.findLastAssistantMessageIndex(messages);
- if (lastAssistantMessageIndex >= 0) {
- messages[lastAssistantMessageIndex].audioUrl = audioUrl;
- this.messagesSubject.next([...messages]);
- console.log('✅ Audio URL attached to assistant message at index:', lastAssistantMessageIndex);
-
- // Auto-play if it's welcome message or if in playing_audio state
- const isWelcomeMessage = messages[lastAssistantMessageIndex].text &&
- messages[lastAssistantMessageIndex].timestamp &&
- (new Date().getTime() - messages[lastAssistantMessageIndex].timestamp.getTime()) < 10000; // 10 saniye içinde
-
- if (isWelcomeMessage || this.currentStateSubject.value === 'playing_audio') {
- setTimeout(() => {
- console.log('🎵 Auto-playing audio for welcome message');
- this.playAudio(audioUrl);
- }, 500);
- }
- } else {
- console.warn('⚠️ No assistant message found to attach audio');
- }
- }
-
- // Clear queue
- this.audioQueue = [];
- console.log('🧹 Audio queue cleared');
-
- console.log('✅ Audio processing completed successfully');
- } catch (error) {
- console.error('❌ Error creating audio blob:', error);
- console.error('Queue size was:', this.audioQueue.length);
- this.audioQueue = [];
- }
- }
- } catch (error) {
- console.error('❌ Error handling TTS audio:', error);
- this.audioQueue = []; // Clear queue on error
- }
- }
-
- private findLastAssistantMessageIndex(messages: ConversationMessage[]): number {
- for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].role === 'assistant') {
- return i;
- }
- }
- return -1;
- }
-
- private isValidBase64(str: string): boolean {
- try {
- console.log(`🔍 Checking base64 validity for ${str.length} chars`);
-
- // Check if string contains only valid base64 characters
- const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
- if (!base64Regex.test(str)) {
- console.error('❌ Base64 regex test failed');
- return false;
- }
-
- // Try to decode to verify
- const decoded = atob(str);
- console.log(`✅ Base64 decode successful, decoded length: ${decoded.length}`);
- return true;
- } catch (e) {
- console.error('❌ Base64 validation error:', e);
- return false;
- }
- }
-
- private base64ToBlob(base64: string, mimeType: string): Blob {
- try {
- console.log('🔄 Converting base64 to blob:', {
- base64Length: base64.length,
- mimeType: mimeType
- });
-
- const byteCharacters = atob(base64);
- console.log(`📊 Decoded to ${byteCharacters.length} bytes`);
-
- const byteNumbers = new Array(byteCharacters.length);
-
- for (let i = 0; i < byteCharacters.length; i++) {
- byteNumbers[i] = byteCharacters.charCodeAt(i);
- }
-
- const byteArray = new Uint8Array(byteNumbers);
- const blob = new Blob([byteArray], { type: mimeType });
-
- console.log('✅ Blob created:', {
- size: blob.size,
- type: blob.type,
- sizeKB: (blob.size / 1024).toFixed(2) + ' KB'
- });
-
- return blob;
- } catch (error) {
- console.error('❌ Error converting base64 to blob:', error);
- console.error('Input details:', {
- base64Length: base64.length,
- base64Preview: base64.substring(0, 100) + '...',
- mimeType: mimeType
- });
- throw new Error('Failed to convert audio data');
- }
- }
-
- private handleAudioError(error: any): void {
- const conversationError: ConversationError = {
- type: error.type || 'audio',
- message: error.message || 'Audio error occurred',
- details: error,
- timestamp: new Date()
- };
-
- this.errorSubject.next(conversationError);
-
- // Add user-friendly message
- if (error.type === 'permission') {
- this.addSystemMessage('Microphone permission denied. Please allow microphone access.');
- } else if (error.type === 'device') {
- this.addSystemMessage('Microphone not found or not accessible.');
- } else {
- this.addSystemMessage('Audio error occurred. Please check your microphone.');
- }
-
- // Update state
- this.currentStateSubject.next('error');
- }
-
- private handleWebSocketError(error: any): void {
- const conversationError: ConversationError = {
- type: 'websocket',
- message: error.message || 'WebSocket error occurred',
- details: error,
- timestamp: new Date()
- };
-
- this.errorSubject.next(conversationError);
- this.addSystemMessage('Connection error. Please check your internet connection.');
-
- // Don't set error state for temporary connection issues
- if (this.wsService.getReconnectionInfo().isReconnecting) {
- this.addSystemMessage('Attempting to reconnect...');
- } else {
- this.currentStateSubject.next('error');
- }
- }
-
- private handleServerError(message: any): void {
- const errorType = message['error_type'] || 'unknown';
- const errorMessage = message['message'] || 'Server error occurred';
-
- const conversationError: ConversationError = {
- type: errorType === 'race_condition' ? 'network' : 'unknown',
- message: errorMessage,
- details: message,
- timestamp: new Date()
- };
-
- this.errorSubject.next(conversationError);
-
- // STT initialization hatası için özel handling
- if (errorType === 'stt_init_failed') {
- this.addSystemMessage('Speech recognition service failed to initialize. Please check your configuration.');
- // Konuşmayı durdur
- this.stopConversation();
- } else if (errorType === 'race_condition') {
- this.addSystemMessage('Session conflict detected. Please restart the conversation.');
- } else if (errorType === 'stt_error') {
- this.addSystemMessage('Speech recognition error. Please try speaking again.');
- // STT hatası durumunda yeniden başlatmayı dene
- if (errorMessage.includes('Streaming not started')) {
- this.addSystemMessage('Restarting speech recognition...');
- // WebSocket'e restart sinyali gönder
- if (this.wsService.isConnected()) {
- this.wsService.sendControl('restart_stt');
- }
- }
- } else if (errorType === 'tts_error') {
- this.addSystemMessage('Text-to-speech error. Response will be shown as text only.');
- } else {
- this.addSystemMessage(`Error: ${errorMessage}`);
- }
- }
-
- private determineErrorType(error: any): ConversationError['type'] {
- if (error.type) {
- return error.type;
- }
-
- if (error.message?.includes('WebSocket') || error.message?.includes('connection')) {
- return 'websocket';
- }
-
- if (error.message?.includes('microphone') || error.message?.includes('audio')) {
- return 'audio';
- }
-
- if (error.message?.includes('permission')) {
- return 'permission';
- }
-
- if (error.message?.includes('network') || error.status === 0) {
- return 'network';
- }
-
- return 'unknown';
- }
-
- private cleanup(): void {
- try {
- this.subscriptions.unsubscribe();
- this.subscriptions = new Subscription();
-
- // Audio recording'i kesinlikle durdur
- if (this.audioService.isRecording()) {
- this.audioService.stopRecording();
- }
-
- this.wsService.disconnect();
- this.stopAudioPlayback();
-
- if (this.audioPlayer) {
- this.audioPlayer = null;
- }
-
- this.audioQueue = [];
- this.isInterrupting = false;
- this.currentStateSubject.next('idle');
- this.sttReadySubject.complete();
-
- console.log('🧹 Conversation cleaned up');
- } catch (error) {
- console.error('Error during cleanup:', error);
- }
- }
-
- // Public methods for UI
- getCurrentState(): ConversationState {
- return this.currentStateSubject.value;
- }
-
- getMessages(): ConversationMessage[] {
- return this.messagesSubject.value;
- }
-
- clearMessages(): void {
- this.messagesSubject.next([]);
- this.transcriptionSubject.next('');
- }
-
- updateConfig(config: Partial): void {
- this.conversationConfig = { ...this.conversationConfig, ...config };
-
- // Send config update if connected
- if (this.wsService.isConnected()) {
- try {
- this.wsService.sendControl('update_config', config);
- } catch (error) {
- console.error('Failed to update config:', error);
- }
- }
- }
-
- getConfig(): ConversationConfig {
- return { ...this.conversationConfig };
- }
-
- isConnected(): boolean {
- return this.wsService.isConnected();
- }
-
- // Retry connection
- async retryConnection(): Promise {
- if (!this.sessionId) {
- throw new Error('No session ID available for retry');
- }
-
- this.currentStateSubject.next('idle');
- await this.startConversation(this.sessionId, this.conversationConfig);
- }
+// conversation-manager.service.ts
+// Path: /flare-ui/src/app/services/conversation-manager.service.ts
+
+import { Injectable, OnDestroy } from '@angular/core';
+import { Subject, Subscription, BehaviorSubject, throwError } from 'rxjs';
+import { catchError, retry } from 'rxjs/operators';
+import { WebSocketService } from './websocket.service';
+import { AudioStreamService } from './audio-stream.service';
+
+export type ConversationState =
+ | 'idle'
+ | 'listening'
+ | 'processing_stt'
+ | 'processing_llm'
+ | 'processing_tts'
+ | 'playing_audio'
+ | 'error';
+
+export interface ConversationMessage {
+ role: 'user' | 'assistant' | 'system';
+ text: string;
+ timestamp: Date;
+ audioUrl?: string;
+ error?: boolean;
+}
+
+export interface ConversationConfig {
+ language?: string;
+ stt_engine?: string;
+ tts_engine?: string;
+ enable_barge_in?: boolean;
+ max_silence_duration?: number;
+}
+
+export interface ConversationError {
+ type: 'websocket' | 'audio' | 'permission' | 'network' | 'unknown';
+ message: string;
+ details?: any;
+ timestamp: Date;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ConversationManagerService implements OnDestroy {
+ private subscriptions = new Subscription();
+ private audioQueue: string[] = [];
+ private isInterrupting = false;
+ private sessionId: string | null = null;
+ private conversationConfig: ConversationConfig = {
+ language: 'tr-TR',
+ stt_engine: 'google',
+ enable_barge_in: true
+ };
+
+ // State management
+ private currentStateSubject = new BehaviorSubject('idle');
+ public currentState$ = this.currentStateSubject.asObservable();
+
+ // Message history
+ private messagesSubject = new BehaviorSubject([]);
+ public messages$ = this.messagesSubject.asObservable();
+
+ // Current transcription
+ private transcriptionSubject = new BehaviorSubject('');
+ public transcription$ = this.transcriptionSubject.asObservable();
+
+ // Error handling
+ private errorSubject = new Subject();
+ public error$ = this.errorSubject.asObservable();
+
+ private sttReadySubject = new Subject();
+
+ // Audio player reference
+ private audioPlayer: HTMLAudioElement | null = null;
+ private audioPlayerPromise: Promise | null = null;
+
+ constructor(
+ private wsService: WebSocketService,
+ private audioService: AudioStreamService
+ ) {}
+
+ ngOnDestroy(): void {
+ this.cleanup();
+ }
+
+ async startConversation(sessionId: string, config?: ConversationConfig): Promise {
+ try {
+ if (!sessionId) {
+ throw new Error('Session ID is required');
+ }
+
+ // Update configuration
+ if (config) {
+ this.conversationConfig = { ...this.conversationConfig, ...config };
+ }
+
+ this.sessionId = sessionId;
+
+ // Start in listening state
+ this.currentStateSubject.next('listening');
+ console.log('🎤 Starting conversation in continuous listening mode');
+
+ // Connect WebSocket first
+ await this.wsService.connect(sessionId).catch(error => {
+ throw new Error(`WebSocket connection failed: ${error.message}`);
+ });
+
+ // Set up subscriptions BEFORE sending any messages
+ this.setupSubscriptions();
+
+ // Send start signal with configuration
+ this.wsService.sendControl('start_session', {
+ ...this.conversationConfig,
+ continuous_listening: true
+ });
+
+ console.log('✅ [ConversationManager] Conversation started - waiting for welcome TTS');
+
+ } catch (error: any) {
+ console.error('Failed to start conversation:', error);
+
+ const conversationError: ConversationError = {
+ type: this.determineErrorType(error),
+ message: error.message || 'Failed to start conversation',
+ details: error,
+ timestamp: new Date()
+ };
+
+ this.errorSubject.next(conversationError);
+ this.currentStateSubject.next('error');
+ this.cleanup();
+
+ throw error;
+ }
+ }
+
+ stopConversation(): void {
+ try {
+ // First stop audio recording
+ this.audioService.stopRecording();
+
+ // Then send stop signal if connected
+ if (this.wsService.isConnected()) {
+ this.wsService.sendControl('stop_session');
+ }
+
+ // Small delay before disconnecting
+ setTimeout(() => {
+ this.cleanup();
+ this.addSystemMessage('Conversation ended');
+ }, 100);
+
+ } catch (error) {
+ console.error('Error stopping conversation:', error);
+ this.cleanup();
+ }
+ }
+
+ private setupSubscriptions(): void {
+ // Audio chunks from microphone
+ this.subscriptions.add(
+ this.audioService.audioChunk$.subscribe({
+ next: (chunk) => {
+ if (!this.isInterrupting && this.wsService.isConnected()) {
+ try {
+ this.wsService.sendAudioChunk(chunk.data);
+ } catch (error) {
+ console.error('Failed to send audio chunk:', error);
+ }
+ }
+ },
+ error: (error) => {
+ console.error('Audio stream error:', error);
+ this.handleAudioError(error);
+ }
+ })
+ );
+
+ // Audio stream errors
+ this.subscriptions.add(
+ this.audioService.error$.subscribe(error => {
+ this.handleAudioError(error);
+ })
+ );
+
+ // WebSocket messages
+ this.subscriptions.add(
+ this.wsService.message$.subscribe({
+ next: (message) => {
+ this.handleMessage(message);
+ },
+ error: (error) => {
+ console.error('WebSocket message error:', error);
+ this.handleWebSocketError(error);
+ }
+ })
+ );
+
+ // Subscribe to transcription updates - SADECE FINAL RESULTS
+ this.subscriptions.add(
+ this.wsService.transcription$.subscribe(result => {
+ // SADECE final transcription'ları işle
+ if (result.is_final) {
+ console.log('📝 Final transcription received:', result);
+ const messages = this.messagesSubject.value;
+ const lastMessage = messages[messages.length - 1];
+ if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== result.text) {
+ this.addMessage('user', result.text);
+ }
+ }
+ })
+ );
+
+ // State changes
+ this.subscriptions.add(
+ this.wsService.stateChange$.subscribe(change => {
+ this.currentStateSubject.next(change.to as ConversationState);
+ this.handleStateChange(change.from, change.to);
+ })
+ );
+
+ // WebSocket errors
+ this.subscriptions.add(
+ this.wsService.error$.subscribe(error => {
+ console.error('WebSocket error:', error);
+ this.handleWebSocketError({ message: error });
+ })
+ );
+
+ // WebSocket connection state
+ this.subscriptions.add(
+ this.wsService.connection$.subscribe(connected => {
+ if (!connected && this.currentStateSubject.value !== 'idle') {
+ this.addSystemMessage('Connection lost. Attempting to reconnect...');
+ }
+ })
+ );
+ }
+
+ private handleMessage(message: any): void {
+ try {
+ switch (message.type) {
+ case 'transcription':
+ // SADECE final transcription'ları işle
+ if (message['is_final']) {
+ const messages = this.messagesSubject.value;
+ const lastMessage = messages[messages.length - 1];
+ if (!lastMessage || lastMessage.role !== 'user' || lastMessage.text !== message['text']) {
+ this.addMessage('user', message['text']);
+ }
+ }
+ // Interim transcription'ları artık işlemiyoruz
+ break;
+
+ case 'assistant_response':
+ // Welcome mesajı veya normal yanıt
+ const isWelcome = message['is_welcome'] || false;
+ this.addMessage('assistant', message['text']);
+
+ if (isWelcome) {
+ console.log('📢 Welcome message received:', message['text']);
+ }
+ break;
+
+ case 'tts_audio':
+ this.handleTTSAudio(message);
+ break;
+
+ case 'tts_error':
+ // TTS hatası durumunda kullanıcıya bilgi ver
+ console.error('TTS Error:', message['message']);
+ this.addSystemMessage(message['message']);
+ break;
+
+ case 'control':
+ if (message['action'] === 'stop_playback') {
+ this.stopAudioPlayback();
+ }
+ break;
+
+ case 'error':
+ this.handleServerError(message);
+ break;
+
+ case 'session_config':
+ // Update configuration from server
+ if (message['config']) {
+ this.conversationConfig = { ...this.conversationConfig, ...message['config'] };
+ }
+ break;
+
+ case 'session_started':
+ // Session başladı, STT durumunu kontrol et
+ console.log('📢 Session started:', message);
+ if (!message['stt_initialized']) {
+ this.addSystemMessage('Speech recognition failed to initialize. Voice input will not be available.');
+ }
+ break;
+
+ case 'stt_ready':
+ console.log('✅ [ConversationManager] Backend STT ready signal received');
+ this.sttReadySubject.next(true);
+ break;
+ }
+ } catch (error) {
+ console.error('Error handling message:', error);
+ this.errorSubject.next({
+ type: 'unknown',
+ message: 'Failed to process message',
+ details: error,
+ timestamp: new Date()
+ });
+ }
+ }
+
+ private handleStateChange(from: string, to: string): void {
+ console.log(`📊 State: ${from} → ${to}`);
+
+ // State değişimlerinde transcription'ı temizleme
+ // Sadece error durumunda temizle
+ if (to === 'error') {
+ this.transcriptionSubject.next('');
+ }
+
+ // Log state changes for debugging
+ console.log(`🎤 Continuous listening mode - state: ${to}`);
+ }
+
+ private playQueuedAudio(): void {
+ const messages = this.messagesSubject.value;
+ const lastMessage = messages[messages.length - 1];
+
+ if (lastMessage?.audioUrl && lastMessage.role === 'assistant') {
+ this.playAudio(lastMessage.audioUrl);
+ }
+ }
+
+ private async playAudio(audioUrl: string): Promise {
+ try {
+ console.log('🎵 [ConversationManager] playAudio called', {
+ hasAudioPlayer: !!this.audioPlayer,
+ audioUrl: audioUrl,
+ timestamp: new Date().toISOString()
+ });
+
+ // Her seferinde yeni audio player oluştur ve handler'ları set et
+ if (this.audioPlayer) {
+ // Eski player'ı temizle
+ this.audioPlayer.pause();
+ this.audioPlayer.src = '';
+ this.audioPlayer = null;
+ }
+
+ // Yeni player oluştur
+ this.audioPlayer = new Audio();
+ this.setupAudioPlayerHandlers(); // HER SEFERINDE handler'ları set et
+
+ this.audioPlayer.src = audioUrl;
+
+ // Store the play promise to handle interruptions properly
+ this.audioPlayerPromise = this.audioPlayer.play();
+
+ await this.audioPlayerPromise;
+
+ } catch (error: any) {
+ // Check if error is due to interruption
+ if (error.name === 'AbortError') {
+ console.log('Audio playback interrupted');
+ } else {
+ console.error('Audio playback error:', error);
+ this.errorSubject.next({
+ type: 'audio',
+ message: 'Failed to play audio response',
+ details: error,
+ timestamp: new Date()
+ });
+ }
+ } finally {
+ this.audioPlayerPromise = null;
+ }
+ }
+
+ private setupAudioPlayerHandlers(): void {
+ if (!this.audioPlayer) return;
+
+ this.audioPlayer.onended = async () => {
+ console.log('🎵 [ConversationManager] Audio playback ended', {
+ currentState: this.currentStateSubject.value,
+ isRecording: this.audioService.isRecording(),
+ timestamp: new Date().toISOString()
+ });
+
+ try {
+ // Backend'e audio bittiğini bildir
+ if (this.wsService.isConnected()) {
+ console.log('📤 [ConversationManager] Sending audio_ended to backend');
+ this.wsService.sendControl('audio_ended');
+
+ // Backend'den STT ready sinyalini bekle
+ console.log('⏳ [ConversationManager] Waiting for STT ready signal...');
+
+ // STT ready handler'ı kur
+ const sttReadyPromise = new Promise((resolve) => {
+ const subscription = this.wsService.message$.subscribe(message => {
+ if (message.type === 'stt_ready') {
+ console.log('✅ [ConversationManager] STT ready signal received');
+ subscription.unsubscribe();
+ resolve(true);
+ }
+ });
+
+ // 10 saniye timeout
+ setTimeout(() => {
+ subscription.unsubscribe();
+ resolve(false);
+ }, 10000);
+ });
+
+ const sttReady = await sttReadyPromise;
+
+ if (sttReady) {
+ console.log('🎤 [ConversationManager] Starting audio recording');
+
+ // Recording'i başlat
+ if (!this.audioService.isRecording()) {
+ await this.audioService.startRecording();
+ console.log('✅ [ConversationManager] Audio recording started');
+ }
+ } else {
+ console.error('❌ [ConversationManager] STT ready timeout');
+ this.addSystemMessage('Speech recognition initialization timeout. Please try again.');
+ }
+ }
+
+ } catch (error) {
+ console.error('❌ [ConversationManager] Failed to handle audio end:', error);
+ this.handleAudioError(error);
+ }
+ };
+
+ this.audioPlayer.onerror = (error) => {
+ console.error('Audio player error:', error);
+ };
+ }
+
+ private stopAudioPlayback(): void {
+ try {
+ if (this.audioPlayer) {
+ this.audioPlayer.pause();
+ this.audioPlayer.currentTime = 0;
+
+ // Cancel any pending play promise
+ if (this.audioPlayerPromise) {
+ this.audioPlayerPromise.catch(() => {
+ // Ignore abort errors
+ });
+ this.audioPlayerPromise = null;
+ }
+ }
+ } catch (error) {
+ console.error('Error stopping audio playback:', error);
+ }
+ }
+
+ // Barge-in handling - DEVRE DIŞI
+ performBargeIn(): void {
+ // Barge-in özelliği devre dışı bırakıldı
+ console.log('⚠️ Barge-in is currently disabled');
+
+ // Kullanıcıya bilgi ver
+ this.addSystemMessage('Barge-in feature is currently disabled.');
+ }
+
+ private addMessage(role: 'user' | 'assistant', text: string, error: boolean = false): void {
+ if (!text || text.trim().length === 0) {
+ return;
+ }
+
+ const messages = this.messagesSubject.value;
+ messages.push({
+ role,
+ text,
+ timestamp: new Date(),
+ error
+ });
+ this.messagesSubject.next([...messages]);
+ }
+
+ private addSystemMessage(text: string): void {
+ console.log(`📢 System: ${text}`);
+ const messages = this.messagesSubject.value;
+ messages.push({
+ role: 'system',
+ text,
+ timestamp: new Date()
+ });
+ this.messagesSubject.next([...messages]);
+ }
+
+ private handleTTSAudio(message: any): void {
+ try {
+ // Validate audio data
+ if (!message['data']) {
+ console.warn('❌ TTS audio message missing data');
+ return;
+ }
+
+ // Detailed log
+ console.log('🎵 TTS chunk received:', {
+ chunkIndex: message['chunk_index'],
+ totalChunks: message['total_chunks'],
+ dataLength: message['data'].length,
+ dataPreview: message['data'].substring(0, 50) + '...',
+ isLast: message['is_last'],
+ mimeType: message['mime_type']
+ });
+
+ // Accumulate audio chunks (already base64)
+ this.audioQueue.push(message['data']);
+ console.log(`📦 Audio queue size: ${this.audioQueue.length} chunks`);
+
+ if (message['is_last']) {
+ console.log('🔧 Processing final audio chunk...');
+
+ try {
+ // All chunks received, combine and create audio blob
+ const combinedBase64 = this.audioQueue.join('');
+ console.log('✅ Combined audio data:', {
+ totalLength: combinedBase64.length,
+ queueSize: this.audioQueue.length,
+ preview: combinedBase64.substring(0, 100) + '...'
+ });
+
+ // Validate base64
+ console.log('🔍 Validating base64...');
+ if (!this.isValidBase64(combinedBase64)) {
+ throw new Error('Invalid base64 data received');
+ }
+ console.log('✅ Base64 validation passed');
+
+ const audioBlob = this.base64ToBlob(combinedBase64, message['mime_type'] || 'audio/mpeg');
+ const audioUrl = URL.createObjectURL(audioBlob);
+ console.log('🎧 Audio URL created:', audioUrl);
+
+ // Update last message with audio URL
+ const messages = this.messagesSubject.value;
+ if (messages.length > 0) {
+ const lastAssistantMessageIndex = this.findLastAssistantMessageIndex(messages);
+ if (lastAssistantMessageIndex >= 0) {
+ messages[lastAssistantMessageIndex].audioUrl = audioUrl;
+ this.messagesSubject.next([...messages]);
+ console.log('✅ Audio URL attached to assistant message at index:', lastAssistantMessageIndex);
+
+ // Auto-play if it's welcome message or if in playing_audio state
+ const isWelcomeMessage = messages[lastAssistantMessageIndex].text &&
+ messages[lastAssistantMessageIndex].timestamp &&
+ (new Date().getTime() - messages[lastAssistantMessageIndex].timestamp.getTime()) < 10000; // 10 saniye içinde
+
+ if (isWelcomeMessage || this.currentStateSubject.value === 'playing_audio') {
+ setTimeout(() => {
+ console.log('🎵 Auto-playing audio for welcome message');
+ this.playAudio(audioUrl);
+ }, 500);
+ }
+ } else {
+ console.warn('⚠️ No assistant message found to attach audio');
+ }
+ }
+
+ // Clear queue
+ this.audioQueue = [];
+ console.log('🧹 Audio queue cleared');
+
+ console.log('✅ Audio processing completed successfully');
+ } catch (error) {
+ console.error('❌ Error creating audio blob:', error);
+ console.error('Queue size was:', this.audioQueue.length);
+ this.audioQueue = [];
+ }
+ }
+ } catch (error) {
+ console.error('❌ Error handling TTS audio:', error);
+ this.audioQueue = []; // Clear queue on error
+ }
+ }
+
+ private findLastAssistantMessageIndex(messages: ConversationMessage[]): number {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === 'assistant') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private isValidBase64(str: string): boolean {
+ try {
+ console.log(`🔍 Checking base64 validity for ${str.length} chars`);
+
+ // Check if string contains only valid base64 characters
+ const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
+ if (!base64Regex.test(str)) {
+ console.error('❌ Base64 regex test failed');
+ return false;
+ }
+
+ // Try to decode to verify
+ const decoded = atob(str);
+ console.log(`✅ Base64 decode successful, decoded length: ${decoded.length}`);
+ return true;
+ } catch (e) {
+ console.error('❌ Base64 validation error:', e);
+ return false;
+ }
+ }
+
+ private base64ToBlob(base64: string, mimeType: string): Blob {
+ try {
+ console.log('🔄 Converting base64 to blob:', {
+ base64Length: base64.length,
+ mimeType: mimeType
+ });
+
+ const byteCharacters = atob(base64);
+ console.log(`📊 Decoded to ${byteCharacters.length} bytes`);
+
+ const byteNumbers = new Array(byteCharacters.length);
+
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+
+ const byteArray = new Uint8Array(byteNumbers);
+ const blob = new Blob([byteArray], { type: mimeType });
+
+ console.log('✅ Blob created:', {
+ size: blob.size,
+ type: blob.type,
+ sizeKB: (blob.size / 1024).toFixed(2) + ' KB'
+ });
+
+ return blob;
+ } catch (error) {
+ console.error('❌ Error converting base64 to blob:', error);
+ console.error('Input details:', {
+ base64Length: base64.length,
+ base64Preview: base64.substring(0, 100) + '...',
+ mimeType: mimeType
+ });
+ throw new Error('Failed to convert audio data');
+ }
+ }
+
+ private handleAudioError(error: any): void {
+ const conversationError: ConversationError = {
+ type: error.type || 'audio',
+ message: error.message || 'Audio error occurred',
+ details: error,
+ timestamp: new Date()
+ };
+
+ this.errorSubject.next(conversationError);
+
+ // Add user-friendly message
+ if (error.type === 'permission') {
+ this.addSystemMessage('Microphone permission denied. Please allow microphone access.');
+ } else if (error.type === 'device') {
+ this.addSystemMessage('Microphone not found or not accessible.');
+ } else {
+ this.addSystemMessage('Audio error occurred. Please check your microphone.');
+ }
+
+ // Update state
+ this.currentStateSubject.next('error');
+ }
+
+ private handleWebSocketError(error: any): void {
+ const conversationError: ConversationError = {
+ type: 'websocket',
+ message: error.message || 'WebSocket error occurred',
+ details: error,
+ timestamp: new Date()
+ };
+
+ this.errorSubject.next(conversationError);
+ this.addSystemMessage('Connection error. Please check your internet connection.');
+
+ // Don't set error state for temporary connection issues
+ if (this.wsService.getReconnectionInfo().isReconnecting) {
+ this.addSystemMessage('Attempting to reconnect...');
+ } else {
+ this.currentStateSubject.next('error');
+ }
+ }
+
+ private handleServerError(message: any): void {
+ const errorType = message['error_type'] || 'unknown';
+ const errorMessage = message['message'] || 'Server error occurred';
+
+ const conversationError: ConversationError = {
+ type: errorType === 'race_condition' ? 'network' : 'unknown',
+ message: errorMessage,
+ details: message,
+ timestamp: new Date()
+ };
+
+ this.errorSubject.next(conversationError);
+
+ // STT initialization hatası için özel handling
+ if (errorType === 'stt_init_failed') {
+ this.addSystemMessage('Speech recognition service failed to initialize. Please check your configuration.');
+ // Konuşmayı durdur
+ this.stopConversation();
+ } else if (errorType === 'race_condition') {
+ this.addSystemMessage('Session conflict detected. Please restart the conversation.');
+ } else if (errorType === 'stt_error') {
+ this.addSystemMessage('Speech recognition error. Please try speaking again.');
+ // STT hatası durumunda yeniden başlatmayı dene
+ if (errorMessage.includes('Streaming not started')) {
+ this.addSystemMessage('Restarting speech recognition...');
+ // WebSocket'e restart sinyali gönder
+ if (this.wsService.isConnected()) {
+ this.wsService.sendControl('restart_stt');
+ }
+ }
+ } else if (errorType === 'tts_error') {
+ this.addSystemMessage('Text-to-speech error. Response will be shown as text only.');
+ } else {
+ this.addSystemMessage(`Error: ${errorMessage}`);
+ }
+ }
+
+ private determineErrorType(error: any): ConversationError['type'] {
+ if (error.type) {
+ return error.type;
+ }
+
+ if (error.message?.includes('WebSocket') || error.message?.includes('connection')) {
+ return 'websocket';
+ }
+
+ if (error.message?.includes('microphone') || error.message?.includes('audio')) {
+ return 'audio';
+ }
+
+ if (error.message?.includes('permission')) {
+ return 'permission';
+ }
+
+ if (error.message?.includes('network') || error.status === 0) {
+ return 'network';
+ }
+
+ return 'unknown';
+ }
+
+ private cleanup(): void {
+ try {
+ this.subscriptions.unsubscribe();
+ this.subscriptions = new Subscription();
+
+ // Audio recording'i kesinlikle durdur
+ if (this.audioService.isRecording()) {
+ this.audioService.stopRecording();
+ }
+
+ this.wsService.disconnect();
+ this.stopAudioPlayback();
+
+ if (this.audioPlayer) {
+ this.audioPlayer = null;
+ }
+
+ this.audioQueue = [];
+ this.isInterrupting = false;
+ this.currentStateSubject.next('idle');
+ this.sttReadySubject.complete();
+
+ console.log('🧹 Conversation cleaned up');
+ } catch (error) {
+ console.error('Error during cleanup:', error);
+ }
+ }
+
+ // Public methods for UI
+ getCurrentState(): ConversationState {
+ return this.currentStateSubject.value;
+ }
+
+ getMessages(): ConversationMessage[] {
+ return this.messagesSubject.value;
+ }
+
+ clearMessages(): void {
+ this.messagesSubject.next([]);
+ this.transcriptionSubject.next('');
+ }
+
+ updateConfig(config: Partial): void {
+ this.conversationConfig = { ...this.conversationConfig, ...config };
+
+ // Send config update if connected
+ if (this.wsService.isConnected()) {
+ try {
+ this.wsService.sendControl('update_config', config);
+ } catch (error) {
+ console.error('Failed to update config:', error);
+ }
+ }
+ }
+
+ getConfig(): ConversationConfig {
+ return { ...this.conversationConfig };
+ }
+
+ isConnected(): boolean {
+ return this.wsService.isConnected();
+ }
+
+ // Retry connection
+ async retryConnection(): Promise {
+ if (!this.sessionId) {
+ throw new Error('No session ID available for retry');
+ }
+
+ this.currentStateSubject.next('idle');
+ await this.startConversation(this.sessionId, this.conversationConfig);
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/environment.service.ts b/flare-ui/src/app/services/environment.service.ts
index 70525efb37844c381d27a2ff4f1fb18133c12566..492d3920c4fae6afdb39aa8381238c868f2e5b40 100644
--- a/flare-ui/src/app/services/environment.service.ts
+++ b/flare-ui/src/app/services/environment.service.ts
@@ -1,286 +1,286 @@
-// environment.service.ts
-// Path: /flare-ui/src/app/services/environment.service.ts
-
-import { Injectable } from '@angular/core';
-import { BehaviorSubject, Observable } from 'rxjs';
-import { Environment, ProviderSettings } from './api.service';
-
-export interface EnvironmentError {
- type: 'validation' | 'update' | 'unknown';
- message: string;
- details?: any;
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class EnvironmentService {
- private environmentSubject = new BehaviorSubject(null);
- public environment$ = this.environmentSubject.asObservable();
-
- private ttsEnabledSource = new BehaviorSubject(false);
- private sttEnabledSource = new BehaviorSubject(false);
-
- private errorSubject = new BehaviorSubject(null);
- public error$ = this.errorSubject.asObservable();
-
- // Local storage keys
- private readonly TTS_KEY = 'flare_tts_enabled';
- private readonly STT_KEY = 'flare_stt_enabled';
-
- ttsEnabled$ = this.ttsEnabledSource.asObservable();
- sttEnabled$ = this.sttEnabledSource.asObservable();
-
- constructor() {
- this.loadPreferences();
- }
-
- private loadPreferences(): void {
- try {
- const savedTTS = localStorage.getItem(this.TTS_KEY);
- if (savedTTS !== null) {
- this.ttsEnabledSource.next(savedTTS === 'true');
- }
-
- const savedSTT = localStorage.getItem(this.STT_KEY);
- if (savedSTT !== null) {
- this.sttEnabledSource.next(savedSTT === 'true');
- }
- } catch (error) {
- console.error('Error loading preferences:', error);
- this.ttsEnabledSource.next(false);
- this.sttEnabledSource.next(false);
- }
- }
-
- setTTSEnabled(enabled: boolean): void {
- try {
- if (typeof enabled !== 'boolean') {
- throw new Error('TTS enabled must be a boolean value');
- }
-
- this.ttsEnabledSource.next(enabled);
-
- try {
- localStorage.setItem(this.TTS_KEY, enabled.toString());
- } catch (error) {
- console.warn('Failed to save TTS preference:', error);
- }
-
- console.log(`TTS ${enabled ? 'enabled' : 'disabled'}`);
- } catch (error) {
- this.handleError('validation', 'Invalid TTS setting', error);
- }
- }
-
- setSTTEnabled(enabled: boolean): void {
- try {
- if (typeof enabled !== 'boolean') {
- throw new Error('STT enabled must be a boolean value');
- }
-
- this.sttEnabledSource.next(enabled);
-
- try {
- localStorage.setItem(this.STT_KEY, enabled.toString());
- } catch (error) {
- console.warn('Failed to save STT preference:', error);
- }
-
- console.log(`STT ${enabled ? 'enabled' : 'disabled'}`);
- } catch (error) {
- this.handleError('validation', 'Invalid STT setting', error);
- }
- }
-
- isTTSEnabled(): boolean {
- return this.ttsEnabledSource.value;
- }
-
- isSTTEnabled(): boolean {
- return this.sttEnabledSource.value;
- }
-
- updateEnvironment(env: Environment | null): void {
- try {
- this.environmentSubject.next(env);
- this.errorSubject.next(null);
-
- if (env) {
- console.log('Environment updated:', {
- llm_provider: env.llm_provider.name,
- tts_provider: env.tts_provider.name,
- stt_provider: env.stt_provider.name
- });
-
- // Update TTS/STT enabled states based on provider
- if (env.tts_provider.name !== 'no_tts') {
- this.setTTSEnabled(true);
- }
- if (env.stt_provider.name !== 'no_stt') {
- this.setSTTEnabled(true);
- }
- }
- } catch (error: any) {
- this.handleError('update', error.message || 'Failed to update environment', error);
- }
- }
-
- getEnvironment(): Environment | null {
- return this.environmentSubject.value;
- }
-
- getCurrentLLMProvider(): ProviderSettings | null {
- const env = this.environmentSubject.value;
- return env?.llm_provider || null;
- }
-
- getCurrentTTSProvider(): ProviderSettings | null {
- const env = this.environmentSubject.value;
- return env?.tts_provider || null;
- }
-
- getCurrentSTTProvider(): ProviderSettings | null {
- const env = this.environmentSubject.value;
- return env?.stt_provider || null;
- }
-
- isGPTMode(): boolean {
- try {
- const env = this.environmentSubject.value;
- const llmName = env?.llm_provider?.name?.toLowerCase();
- return llmName?.startsWith('gpt4o') || false;
- } catch (error) {
- console.error('Error checking GPT mode:', error);
- return false;
- }
- }
-
- getWorkMode(): string | null {
- const env = this.environmentSubject.value;
- return env?.llm_provider?.name || null;
- }
-
- isTTSAvailable(): boolean {
- const env = this.environmentSubject.value;
- return env?.tts_provider?.name !== 'no_tts' && env?.tts_provider?.name !== undefined;
- }
-
- isSTTAvailable(): boolean {
- const env = this.environmentSubject.value;
- return env?.stt_provider?.name !== 'no_stt' && env?.stt_provider?.name !== undefined;
- }
-
- // Check if environment supports a specific feature
- supportsFeature(feature: 'tts' | 'stt' | 'realtime' | 'streaming'): boolean {
- const env = this.environmentSubject.value;
- if (!env) return false;
-
- switch (feature) {
- case 'tts':
- return this.isTTSAvailable() && this.isTTSEnabled();
- case 'stt':
- return this.isSTTAvailable() && this.isSTTEnabled();
- case 'realtime':
- return this.isGPTMode();
- case 'streaming':
- return true;
- default:
- return false;
- }
- }
-
- // Get available providers
- getAvailableProviders(type: 'llm' | 'tts' | 'stt'): any[] {
- const env = this.environmentSubject.value;
- if (!env?.providers) return [];
-
- return env.providers.filter(p => p.type === type);
- }
-
- // Reset all settings
- reset(): void {
- try {
- this.environmentSubject.next(null);
- this.setTTSEnabled(false);
- this.setSTTEnabled(false);
- this.errorSubject.next(null);
-
- try {
- localStorage.removeItem(this.TTS_KEY);
- localStorage.removeItem(this.STT_KEY);
- } catch (error) {
- console.warn('Failed to clear preferences:', error);
- }
-
- console.log('Environment service reset');
- } catch (error) {
- this.handleError('unknown', 'Failed to reset environment', error);
- }
- }
-
- // Get configuration summary
- getConfigSummary(): {
- llmProvider: string | null;
- ttsProvider: string | null;
- sttProvider: string | null;
- ttsEnabled: boolean;
- sttEnabled: boolean;
- features: string[];
- } {
- const env = this.environmentSubject.value;
- const features: string[] = [];
-
- if (this.supportsFeature('tts')) features.push('TTS');
- if (this.supportsFeature('stt')) features.push('STT');
- if (this.supportsFeature('realtime')) features.push('Realtime');
- if (this.supportsFeature('streaming')) features.push('Streaming');
-
- return {
- llmProvider: env?.llm_provider?.name || null,
- ttsProvider: env?.tts_provider?.name || null,
- sttProvider: env?.stt_provider?.name || null,
- ttsEnabled: this.isTTSEnabled(),
- sttEnabled: this.isSTTEnabled(),
- features
- };
- }
-
- private handleError(type: EnvironmentError['type'], message: string, details?: any): void {
- const error: EnvironmentError = {
- type,
- message,
- details
- };
-
- console.error(`Environment error [${type}]:`, message, details);
- this.errorSubject.next(error);
- }
-
- // Observable to check if environment is ready
- isReady(): Observable {
- return new Observable(subscriber => {
- const sub = this.environment$.subscribe(env => {
- // Environment is ready if we have all providers configured
- const isReady = !!(
- env &&
- env.llm_provider &&
- env.tts_provider &&
- env.stt_provider
- );
- subscriber.next(isReady);
- });
-
- return () => sub.unsubscribe();
- });
- }
-
- // Get error state
- hasError(): boolean {
- return this.errorSubject.value !== null;
- }
-
- clearError(): void {
- this.errorSubject.next(null);
- }
+// environment.service.ts
+// Path: /flare-ui/src/app/services/environment.service.ts
+
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { Environment, ProviderSettings } from './api.service';
+
+export interface EnvironmentError {
+ type: 'validation' | 'update' | 'unknown';
+ message: string;
+ details?: any;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class EnvironmentService {
+ private environmentSubject = new BehaviorSubject(null);
+ public environment$ = this.environmentSubject.asObservable();
+
+ private ttsEnabledSource = new BehaviorSubject(false);
+ private sttEnabledSource = new BehaviorSubject(false);
+
+ private errorSubject = new BehaviorSubject(null);
+ public error$ = this.errorSubject.asObservable();
+
+ // Local storage keys
+ private readonly TTS_KEY = 'flare_tts_enabled';
+ private readonly STT_KEY = 'flare_stt_enabled';
+
+ ttsEnabled$ = this.ttsEnabledSource.asObservable();
+ sttEnabled$ = this.sttEnabledSource.asObservable();
+
+ constructor() {
+ this.loadPreferences();
+ }
+
+ private loadPreferences(): void {
+ try {
+ const savedTTS = localStorage.getItem(this.TTS_KEY);
+ if (savedTTS !== null) {
+ this.ttsEnabledSource.next(savedTTS === 'true');
+ }
+
+ const savedSTT = localStorage.getItem(this.STT_KEY);
+ if (savedSTT !== null) {
+ this.sttEnabledSource.next(savedSTT === 'true');
+ }
+ } catch (error) {
+ console.error('Error loading preferences:', error);
+ this.ttsEnabledSource.next(false);
+ this.sttEnabledSource.next(false);
+ }
+ }
+
+ setTTSEnabled(enabled: boolean): void {
+ try {
+ if (typeof enabled !== 'boolean') {
+ throw new Error('TTS enabled must be a boolean value');
+ }
+
+ this.ttsEnabledSource.next(enabled);
+
+ try {
+ localStorage.setItem(this.TTS_KEY, enabled.toString());
+ } catch (error) {
+ console.warn('Failed to save TTS preference:', error);
+ }
+
+ console.log(`TTS ${enabled ? 'enabled' : 'disabled'}`);
+ } catch (error) {
+ this.handleError('validation', 'Invalid TTS setting', error);
+ }
+ }
+
+ setSTTEnabled(enabled: boolean): void {
+ try {
+ if (typeof enabled !== 'boolean') {
+ throw new Error('STT enabled must be a boolean value');
+ }
+
+ this.sttEnabledSource.next(enabled);
+
+ try {
+ localStorage.setItem(this.STT_KEY, enabled.toString());
+ } catch (error) {
+ console.warn('Failed to save STT preference:', error);
+ }
+
+ console.log(`STT ${enabled ? 'enabled' : 'disabled'}`);
+ } catch (error) {
+ this.handleError('validation', 'Invalid STT setting', error);
+ }
+ }
+
+ isTTSEnabled(): boolean {
+ return this.ttsEnabledSource.value;
+ }
+
+ isSTTEnabled(): boolean {
+ return this.sttEnabledSource.value;
+ }
+
+ updateEnvironment(env: Environment | null): void {
+ try {
+ this.environmentSubject.next(env);
+ this.errorSubject.next(null);
+
+ if (env) {
+ console.log('Environment updated:', {
+ llm_provider: env.llm_provider.name,
+ tts_provider: env.tts_provider.name,
+ stt_provider: env.stt_provider.name
+ });
+
+ // Update TTS/STT enabled states based on provider
+ if (env.tts_provider.name !== 'no_tts') {
+ this.setTTSEnabled(true);
+ }
+ if (env.stt_provider.name !== 'no_stt') {
+ this.setSTTEnabled(true);
+ }
+ }
+ } catch (error: any) {
+ this.handleError('update', error.message || 'Failed to update environment', error);
+ }
+ }
+
+ getEnvironment(): Environment | null {
+ return this.environmentSubject.value;
+ }
+
+ getCurrentLLMProvider(): ProviderSettings | null {
+ const env = this.environmentSubject.value;
+ return env?.llm_provider || null;
+ }
+
+ getCurrentTTSProvider(): ProviderSettings | null {
+ const env = this.environmentSubject.value;
+ return env?.tts_provider || null;
+ }
+
+ getCurrentSTTProvider(): ProviderSettings | null {
+ const env = this.environmentSubject.value;
+ return env?.stt_provider || null;
+ }
+
+ isGPTMode(): boolean {
+ try {
+ const env = this.environmentSubject.value;
+ const llmName = env?.llm_provider?.name?.toLowerCase();
+ return llmName?.startsWith('gpt4o') || false;
+ } catch (error) {
+ console.error('Error checking GPT mode:', error);
+ return false;
+ }
+ }
+
+ getWorkMode(): string | null {
+ const env = this.environmentSubject.value;
+ return env?.llm_provider?.name || null;
+ }
+
+ isTTSAvailable(): boolean {
+ const env = this.environmentSubject.value;
+ return env?.tts_provider?.name !== 'no_tts' && env?.tts_provider?.name !== undefined;
+ }
+
+ isSTTAvailable(): boolean {
+ const env = this.environmentSubject.value;
+ return env?.stt_provider?.name !== 'no_stt' && env?.stt_provider?.name !== undefined;
+ }
+
+ // Check if environment supports a specific feature
+ supportsFeature(feature: 'tts' | 'stt' | 'realtime' | 'streaming'): boolean {
+ const env = this.environmentSubject.value;
+ if (!env) return false;
+
+ switch (feature) {
+ case 'tts':
+ return this.isTTSAvailable() && this.isTTSEnabled();
+ case 'stt':
+ return this.isSTTAvailable() && this.isSTTEnabled();
+ case 'realtime':
+ return this.isGPTMode();
+ case 'streaming':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ // Get available providers
+ getAvailableProviders(type: 'llm' | 'tts' | 'stt'): any[] {
+ const env = this.environmentSubject.value;
+ if (!env?.providers) return [];
+
+ return env.providers.filter(p => p.type === type);
+ }
+
+ // Reset all settings
+ reset(): void {
+ try {
+ this.environmentSubject.next(null);
+ this.setTTSEnabled(false);
+ this.setSTTEnabled(false);
+ this.errorSubject.next(null);
+
+ try {
+ localStorage.removeItem(this.TTS_KEY);
+ localStorage.removeItem(this.STT_KEY);
+ } catch (error) {
+ console.warn('Failed to clear preferences:', error);
+ }
+
+ console.log('Environment service reset');
+ } catch (error) {
+ this.handleError('unknown', 'Failed to reset environment', error);
+ }
+ }
+
+ // Get configuration summary
+ getConfigSummary(): {
+ llmProvider: string | null;
+ ttsProvider: string | null;
+ sttProvider: string | null;
+ ttsEnabled: boolean;
+ sttEnabled: boolean;
+ features: string[];
+ } {
+ const env = this.environmentSubject.value;
+ const features: string[] = [];
+
+ if (this.supportsFeature('tts')) features.push('TTS');
+ if (this.supportsFeature('stt')) features.push('STT');
+ if (this.supportsFeature('realtime')) features.push('Realtime');
+ if (this.supportsFeature('streaming')) features.push('Streaming');
+
+ return {
+ llmProvider: env?.llm_provider?.name || null,
+ ttsProvider: env?.tts_provider?.name || null,
+ sttProvider: env?.stt_provider?.name || null,
+ ttsEnabled: this.isTTSEnabled(),
+ sttEnabled: this.isSTTEnabled(),
+ features
+ };
+ }
+
+ private handleError(type: EnvironmentError['type'], message: string, details?: any): void {
+ const error: EnvironmentError = {
+ type,
+ message,
+ details
+ };
+
+ console.error(`Environment error [${type}]:`, message, details);
+ this.errorSubject.next(error);
+ }
+
+ // Observable to check if environment is ready
+ isReady(): Observable {
+ return new Observable(subscriber => {
+ const sub = this.environment$.subscribe(env => {
+ // Environment is ready if we have all providers configured
+ const isReady = !!(
+ env &&
+ env.llm_provider &&
+ env.tts_provider &&
+ env.stt_provider
+ );
+ subscriber.next(isReady);
+ });
+
+ return () => sub.unsubscribe();
+ });
+ }
+
+ // Get error state
+ hasError(): boolean {
+ return this.errorSubject.value !== null;
+ }
+
+ clearError(): void {
+ this.errorSubject.next(null);
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/error-handler.service.ts b/flare-ui/src/app/services/error-handler.service.ts
index a943564a1dc08c88640ace0165f53341ac3894a6..e255f2f9d33e850055c01434773790500200b851 100644
--- a/flare-ui/src/app/services/error-handler.service.ts
+++ b/flare-ui/src/app/services/error-handler.service.ts
@@ -1,298 +1,298 @@
-// error-handler.service.ts
-// Path: /flare-ui/src/app/services/error-handler.service.ts
-
-import { ErrorHandler, Injectable, Injector } from '@angular/core';
-import { MatSnackBar } from '@angular/material/snack-bar';
-import { Router } from '@angular/router';
-import { HttpErrorResponse } from '@angular/common/http';
-
-interface FlareError {
- error: string;
- message: string;
- details?: any;
- request_id?: string;
- timestamp?: string;
- user_action?: string;
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class GlobalErrorHandler implements ErrorHandler {
- constructor(private injector: Injector) {}
-
- handleError(error: Error | HttpErrorResponse): void {
- try {
- // Get services lazily to avoid circular dependency
- const snackBar = this.injector.get(MatSnackBar);
- const router = this.injector.get(Router);
-
- console.error('Global error caught:', error);
-
- // Handle HTTP errors
- if (error instanceof HttpErrorResponse) {
- this.handleHttpError(error, snackBar, router);
- } else {
- // Handle client-side errors
- this.handleClientError(error, snackBar);
- }
- } catch (handlerError) {
- // Fallback if error handler itself fails
- console.error('Error in error handler:', handlerError);
- console.error('Original error:', error);
- }
- }
-
- private handleHttpError(error: HttpErrorResponse, snackBar: MatSnackBar, router: Router): void {
- try {
- const flareError = error.error as FlareError;
-
- // Race condition error (409)
- if (error.status === 409) {
- const isRaceCondition = flareError?.error === 'RaceConditionError' ||
- error.error?.type === 'race_condition';
-
- if (isRaceCondition) {
- const snackBarRef = snackBar.open(
- flareError?.message || 'The data was modified by another user. Please refresh and try again.',
- 'Refresh',
- {
- duration: 0,
- panelClass: ['error-snackbar', 'race-condition-snackbar']
- }
- );
-
- snackBarRef.onAction().subscribe(() => {
- window.location.reload();
- });
-
- // Show additional info if available
- if (flareError?.details?.last_update_user) {
- console.info(`Last updated by: ${flareError.details.last_update_user} at ${flareError.details.last_update_date}`);
- }
- return;
- }
- }
-
- // Authentication error (401)
- if (error.status === 401) {
- snackBar.open(
- 'Your session has expired. Please login again.',
- 'Login',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- ).onAction().subscribe(() => {
- router.navigate(['/login']);
- });
- return;
- }
-
- // Validation error (422)
- if (error.status === 422 && flareError?.details) {
- const fieldErrors = Array.isArray(flareError.details)
- ? flareError.details.map((d: any) => `${d.field}: ${d.message}`).join('\n')
- : 'Validation error occurred';
-
- snackBar.open(
- flareError.message || 'Validation failed. Please check your input.',
- 'Close',
- {
- duration: 8000,
- panelClass: ['error-snackbar', 'validation-snackbar']
- }
- );
-
- console.error('Validation errors:', flareError.details);
- return;
- }
-
- // Not found error (404)
- if (error.status === 404) {
- snackBar.open(
- flareError?.message || 'The requested resource was not found.',
- 'Close',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- );
- return;
- }
-
- // Server errors (5xx)
- if (error.status >= 500) {
- const message = flareError?.message || 'A server error occurred. Please try again later.';
- const requestId = flareError?.request_id || error.headers?.get('X-Request-ID');
-
- snackBar.open(
- requestId ? `${message} (Request ID: ${requestId})` : message,
- 'Close',
- {
- duration: 8000,
- panelClass: ['error-snackbar', 'server-error-snackbar']
- }
- );
- return;
- }
-
- // Network error (0 status usually indicates network issues)
- if (error.status === 0) {
- snackBar.open(
- 'Network connection error. Please check your internet connection.',
- 'Retry',
- {
- duration: 0,
- panelClass: ['error-snackbar', 'network-error-snackbar']
- }
- ).onAction().subscribe(() => {
- window.location.reload();
- });
- return;
- }
-
- // Generic HTTP error
- const errorMessage = flareError?.message || error.message || `HTTP Error ${error.status}: ${error.statusText}`;
- snackBar.open(
- errorMessage,
- 'Close',
- {
- duration: 6000,
- panelClass: ['error-snackbar']
- }
- );
- } catch (err) {
- console.error('Error in handleHttpError:', err);
- this.showGenericError(snackBar);
- }
- }
-
- private handleClientError(error: Error, snackBar: MatSnackBar): void {
- try {
- // Check if it's a network error
- if (error.message?.includes('NetworkError') || error.message?.includes('Failed to fetch')) {
- snackBar.open(
- 'Network connection error. Please check your internet connection.',
- 'Retry',
- {
- duration: 0,
- panelClass: ['error-snackbar', 'network-error-snackbar']
- }
- ).onAction().subscribe(() => {
- window.location.reload();
- });
- return;
- }
-
- // Check for specific Angular errors
- if (error.name === 'HttpErrorResponse') {
- // This might be an HTTP error that wasn't caught properly
- this.handleHttpError(error as any, snackBar, this.injector.get(Router));
- return;
- }
-
- // Generic client error
- snackBar.open(
- 'An unexpected error occurred. Please refresh the page.',
- 'Refresh',
- {
- duration: 6000,
- panelClass: ['error-snackbar']
- }
- ).onAction().subscribe(() => {
- window.location.reload();
- });
- } catch (err) {
- console.error('Error in handleClientError:', err);
- this.showGenericError(snackBar);
- }
- }
-
- private showGenericError(snackBar: MatSnackBar): void {
- snackBar.open(
- 'An error occurred. Please try again.',
- 'Close',
- {
- duration: 5000,
- panelClass: ['error-snackbar']
- }
- );
- }
-}
-
-// Error interceptor for consistent error format
-import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
-import { Observable, throwError } from 'rxjs';
-import { catchError, finalize } from 'rxjs/operators';
-
-@Injectable()
-export class ErrorInterceptor implements HttpInterceptor {
- private activeRequests = new Map();
-
- intercept(req: HttpRequest, next: HttpHandler): Observable> {
- // Create abort controller for request cancellation
- const requestId = this.generateRequestId();
- const abortController = new AbortController();
- this.activeRequests.set(requestId, abortController);
-
- // Clone request with additional headers
- const clonedReq = req.clone({
- setHeaders: {
- 'X-Request-ID': requestId
- }
- });
-
- return next.handle(clonedReq).pipe(
- catchError((error: HttpErrorResponse) => {
- // Log request details for debugging
- console.error('HTTP Error:', {
- url: req.url,
- method: req.method,
- status: error.status,
- statusText: error.statusText,
- error: error.error,
- requestId: requestId,
- headers: error.headers?.keys()
- });
-
- // Enhanced error object
- const enhancedError = {
- ...error,
- requestId: requestId,
- timestamp: new Date().toISOString(),
- url: req.url,
- method: req.method
- };
-
- // Re-throw to be handled by global error handler
- return throwError(() => enhancedError);
- }),
- finalize(() => {
- // Clean up abort controller
- this.activeRequests.delete(requestId);
- })
- );
- }
-
- private generateRequestId(): string {
- return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
- }
-
- // Method to cancel a specific request
- cancelRequest(requestId: string): void {
- const controller = this.activeRequests.get(requestId);
- if (controller) {
- controller.abort();
- this.activeRequests.delete(requestId);
- }
- }
-
- // Method to cancel all active requests
- cancelAllRequests(): void {
- this.activeRequests.forEach((controller) => {
- controller.abort();
- });
- this.activeRequests.clear();
- }
+// error-handler.service.ts
+// Path: /flare-ui/src/app/services/error-handler.service.ts
+
+import { ErrorHandler, Injectable, Injector } from '@angular/core';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { Router } from '@angular/router';
+import { HttpErrorResponse } from '@angular/common/http';
+
+interface FlareError {
+ error: string;
+ message: string;
+ details?: any;
+ request_id?: string;
+ timestamp?: string;
+ user_action?: string;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class GlobalErrorHandler implements ErrorHandler {
+ constructor(private injector: Injector) {}
+
+ handleError(error: Error | HttpErrorResponse): void {
+ try {
+ // Get services lazily to avoid circular dependency
+ const snackBar = this.injector.get(MatSnackBar);
+ const router = this.injector.get(Router);
+
+ console.error('Global error caught:', error);
+
+ // Handle HTTP errors
+ if (error instanceof HttpErrorResponse) {
+ this.handleHttpError(error, snackBar, router);
+ } else {
+ // Handle client-side errors
+ this.handleClientError(error, snackBar);
+ }
+ } catch (handlerError) {
+ // Fallback if error handler itself fails
+ console.error('Error in error handler:', handlerError);
+ console.error('Original error:', error);
+ }
+ }
+
+ private handleHttpError(error: HttpErrorResponse, snackBar: MatSnackBar, router: Router): void {
+ try {
+ const flareError = error.error as FlareError;
+
+ // Race condition error (409)
+ if (error.status === 409) {
+ const isRaceCondition = flareError?.error === 'RaceConditionError' ||
+ error.error?.type === 'race_condition';
+
+ if (isRaceCondition) {
+ const snackBarRef = snackBar.open(
+ flareError?.message || 'The data was modified by another user. Please refresh and try again.',
+ 'Refresh',
+ {
+ duration: 0,
+ panelClass: ['error-snackbar', 'race-condition-snackbar']
+ }
+ );
+
+ snackBarRef.onAction().subscribe(() => {
+ window.location.reload();
+ });
+
+ // Show additional info if available
+ if (flareError?.details?.last_update_user) {
+ console.info(`Last updated by: ${flareError.details.last_update_user} at ${flareError.details.last_update_date}`);
+ }
+ return;
+ }
+ }
+
+ // Authentication error (401)
+ if (error.status === 401) {
+ snackBar.open(
+ 'Your session has expired. Please login again.',
+ 'Login',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ ).onAction().subscribe(() => {
+ router.navigate(['/login']);
+ });
+ return;
+ }
+
+ // Validation error (422)
+ if (error.status === 422 && flareError?.details) {
+ const fieldErrors = Array.isArray(flareError.details)
+ ? flareError.details.map((d: any) => `${d.field}: ${d.message}`).join('\n')
+ : 'Validation error occurred';
+
+ snackBar.open(
+ flareError.message || 'Validation failed. Please check your input.',
+ 'Close',
+ {
+ duration: 8000,
+ panelClass: ['error-snackbar', 'validation-snackbar']
+ }
+ );
+
+ console.error('Validation errors:', flareError.details);
+ return;
+ }
+
+ // Not found error (404)
+ if (error.status === 404) {
+ snackBar.open(
+ flareError?.message || 'The requested resource was not found.',
+ 'Close',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ return;
+ }
+
+ // Server errors (5xx)
+ if (error.status >= 500) {
+ const message = flareError?.message || 'A server error occurred. Please try again later.';
+ const requestId = flareError?.request_id || error.headers?.get('X-Request-ID');
+
+ snackBar.open(
+ requestId ? `${message} (Request ID: ${requestId})` : message,
+ 'Close',
+ {
+ duration: 8000,
+ panelClass: ['error-snackbar', 'server-error-snackbar']
+ }
+ );
+ return;
+ }
+
+ // Network error (0 status usually indicates network issues)
+ if (error.status === 0) {
+ snackBar.open(
+ 'Network connection error. Please check your internet connection.',
+ 'Retry',
+ {
+ duration: 0,
+ panelClass: ['error-snackbar', 'network-error-snackbar']
+ }
+ ).onAction().subscribe(() => {
+ window.location.reload();
+ });
+ return;
+ }
+
+ // Generic HTTP error
+ const errorMessage = flareError?.message || error.message || `HTTP Error ${error.status}: ${error.statusText}`;
+ snackBar.open(
+ errorMessage,
+ 'Close',
+ {
+ duration: 6000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ } catch (err) {
+ console.error('Error in handleHttpError:', err);
+ this.showGenericError(snackBar);
+ }
+ }
+
+ private handleClientError(error: Error, snackBar: MatSnackBar): void {
+ try {
+ // Check if it's a network error
+ if (error.message?.includes('NetworkError') || error.message?.includes('Failed to fetch')) {
+ snackBar.open(
+ 'Network connection error. Please check your internet connection.',
+ 'Retry',
+ {
+ duration: 0,
+ panelClass: ['error-snackbar', 'network-error-snackbar']
+ }
+ ).onAction().subscribe(() => {
+ window.location.reload();
+ });
+ return;
+ }
+
+ // Check for specific Angular errors
+ if (error.name === 'HttpErrorResponse') {
+ // This might be an HTTP error that wasn't caught properly
+ this.handleHttpError(error as any, snackBar, this.injector.get(Router));
+ return;
+ }
+
+ // Generic client error
+ snackBar.open(
+ 'An unexpected error occurred. Please refresh the page.',
+ 'Refresh',
+ {
+ duration: 6000,
+ panelClass: ['error-snackbar']
+ }
+ ).onAction().subscribe(() => {
+ window.location.reload();
+ });
+ } catch (err) {
+ console.error('Error in handleClientError:', err);
+ this.showGenericError(snackBar);
+ }
+ }
+
+ private showGenericError(snackBar: MatSnackBar): void {
+ snackBar.open(
+ 'An error occurred. Please try again.',
+ 'Close',
+ {
+ duration: 5000,
+ panelClass: ['error-snackbar']
+ }
+ );
+ }
+}
+
+// Error interceptor for consistent error format
+import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
+import { Observable, throwError } from 'rxjs';
+import { catchError, finalize } from 'rxjs/operators';
+
+@Injectable()
+export class ErrorInterceptor implements HttpInterceptor {
+ private activeRequests = new Map();
+
+ intercept(req: HttpRequest, next: HttpHandler): Observable> {
+ // Create abort controller for request cancellation
+ const requestId = this.generateRequestId();
+ const abortController = new AbortController();
+ this.activeRequests.set(requestId, abortController);
+
+ // Clone request with additional headers
+ const clonedReq = req.clone({
+ setHeaders: {
+ 'X-Request-ID': requestId
+ }
+ });
+
+ return next.handle(clonedReq).pipe(
+ catchError((error: HttpErrorResponse) => {
+ // Log request details for debugging
+ console.error('HTTP Error:', {
+ url: req.url,
+ method: req.method,
+ status: error.status,
+ statusText: error.statusText,
+ error: error.error,
+ requestId: requestId,
+ headers: error.headers?.keys()
+ });
+
+ // Enhanced error object
+ const enhancedError = {
+ ...error,
+ requestId: requestId,
+ timestamp: new Date().toISOString(),
+ url: req.url,
+ method: req.method
+ };
+
+ // Re-throw to be handled by global error handler
+ return throwError(() => enhancedError);
+ }),
+ finalize(() => {
+ // Clean up abort controller
+ this.activeRequests.delete(requestId);
+ })
+ );
+ }
+
+ private generateRequestId(): string {
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+ }
+
+ // Method to cancel a specific request
+ cancelRequest(requestId: string): void {
+ const controller = this.activeRequests.get(requestId);
+ if (controller) {
+ controller.abort();
+ this.activeRequests.delete(requestId);
+ }
+ }
+
+ // Method to cancel all active requests
+ cancelAllRequests(): void {
+ this.activeRequests.forEach((controller) => {
+ controller.abort();
+ });
+ this.activeRequests.clear();
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/loading.service.ts b/flare-ui/src/app/services/loading.service.ts
index 676deda651d354ce2742e6d0fd2ee8de81933245..db1379f64fdf7983d4b8590332bff39d19cd6445 100644
--- a/flare-ui/src/app/services/loading.service.ts
+++ b/flare-ui/src/app/services/loading.service.ts
@@ -1,35 +1,35 @@
-import { Injectable } from '@angular/core';
-import { BehaviorSubject } from 'rxjs';
-
-@Injectable({
- providedIn: 'root'
-})
-export class LoadingService {
- private loadingSubject = new BehaviorSubject(false);
- public loading$ = this.loadingSubject.asObservable();
-
- private activeRequests = 0;
-
- show() {
- this.activeRequests++;
- this.loadingSubject.next(true);
- }
-
- hide() {
- this.activeRequests--;
- if (this.activeRequests <= 0) {
- this.activeRequests = 0;
- // Small delay to prevent flicker
- setTimeout(() => {
- if (this.activeRequests === 0) {
- this.loadingSubject.next(false);
- }
- }, 300);
- }
- }
-
- forceHide() {
- this.activeRequests = 0;
- this.loadingSubject.next(false);
- }
+import { Injectable } from '@angular/core';
+import { BehaviorSubject } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LoadingService {
+ private loadingSubject = new BehaviorSubject(false);
+ public loading$ = this.loadingSubject.asObservable();
+
+ private activeRequests = 0;
+
+ show() {
+ this.activeRequests++;
+ this.loadingSubject.next(true);
+ }
+
+ hide() {
+ this.activeRequests--;
+ if (this.activeRequests <= 0) {
+ this.activeRequests = 0;
+ // Small delay to prevent flicker
+ setTimeout(() => {
+ if (this.activeRequests === 0) {
+ this.loadingSubject.next(false);
+ }
+ }, 300);
+ }
+ }
+
+ forceHide() {
+ this.activeRequests = 0;
+ this.loadingSubject.next(false);
+ }
}
\ No newline at end of file
diff --git a/flare-ui/src/app/services/locale-manager.service.ts b/flare-ui/src/app/services/locale-manager.service.ts
index 7c1dfc9ed8fe2b64c4d8a566abb03ab414119bca..db511b625359f41530ea13abe00127e0b5082ec8 100644
--- a/flare-ui/src/app/services/locale-manager.service.ts
+++ b/flare-ui/src/app/services/locale-manager.service.ts
@@ -1,212 +1,212 @@
-// locale-manager.service.ts
-// Path: /flare-ui/src/app/services/locale-manager.service.ts
-
-import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
-import { Observable, of, throwError } from 'rxjs';
-import { map, catchError, retry, timeout } from 'rxjs/operators';
-import { AuthService } from './auth.service';
-
-export interface Locale {
- code: string;
- name: string;
- english_name: string;
-}
-
-export interface LocaleDetails extends Locale {
- native_name?: string;
- direction: string;
- date_format: string;
- time_format: string;
- datetime_format: string;
- currency: string;
- currency_symbol: string;
- decimal_separator: string;
- thousands_separator: string;
- week_starts_on: number;
- months?: string[];
- days?: string[];
- am_pm?: string[];
- common_phrases?: { [key: string]: string };
-}
-
-@Injectable({
- providedIn: 'root'
-})
-export class LocaleManagerService {
- private apiUrl = '/api';
- private adminUrl = `${this.apiUrl}/admin`;
- private localesCache?: Locale[];
- private cacheTimestamp?: number;
- private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
- private readonly REQUEST_TIMEOUT = 10000; // 10 seconds
-
- constructor(
- private http: HttpClient,
- private authService: AuthService
- ) {}
-
- private getAuthHeaders(): HttpHeaders {
- const token = this.authService.getToken();
- if (!token) {
- throw new Error('No authentication token available');
- }
-
- return new HttpHeaders({
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json'
- });
- }
-
- getAvailableLocales(): Observable {
- try {
- // Check cache validity
- if (this.localesCache && this.cacheTimestamp) {
- const now = Date.now();
- if (now - this.cacheTimestamp < this.CACHE_DURATION) {
- return of(this.localesCache);
- }
- }
-
- return this.http.get<{ locales: Locale[], default: string }>(
- `${this.adminUrl}/locales`,
- { headers: this.getAuthHeaders() }
- ).pipe(
- timeout(this.REQUEST_TIMEOUT),
- retry({ count: 2, delay: 1000 }),
- map(response => {
- this.localesCache = response.locales;
- this.cacheTimestamp = Date.now();
- return response.locales;
- }),
- catchError(error => this.handleError(error, 'getAvailableLocales'))
- );
- } catch (error) {
- return this.handleError(error, 'getAvailableLocales');
- }
- }
-
- getLocaleDetails(code: string): Observable {
- if (!code) {
- return throwError(() => new Error('Locale code is required'));
- }
-
- try {
- return this.http.get(
- `${this.adminUrl}/locales/${encodeURIComponent(code)}`,
- { headers: this.getAuthHeaders() }
- ).pipe(
- timeout(this.REQUEST_TIMEOUT),
- retry({ count: 2, delay: 1000 }),
- catchError(error => {
- // For 404, return null instead of throwing
- if (error.status === 404) {
- console.warn(`Locale '${code}' not found`);
- return of(null);
- }
- return this.handleError(error, 'getLocaleDetails');
- })
- );
- } catch (error) {
- return this.handleError(error, 'getLocaleDetails');
- }
- }
-
- validateLanguages(languages: string[]): Observable {
- if (!languages || languages.length === 0) {
- return of([]);
- }
-
- try {
- return this.getAvailableLocales().pipe(
- map(locales => {
- const availableCodes = locales.map(l => l.code);
- const invalidLanguages = languages.filter(lang => !availableCodes.includes(lang));
-
- if (invalidLanguages.length > 0) {
- console.warn('Invalid languages detected:', invalidLanguages);
- }
-
- return invalidLanguages;
- }),
- catchError(error => {
- console.error('Error validating languages:', error);
- // Return all languages as invalid if validation fails
- return of(languages);
- })
- );
- } catch (error) {
- return this.handleError(error, 'validateLanguages');
- }
- }
-
- clearCache(): void {
- this.localesCache = undefined;
- this.cacheTimestamp = undefined;
- }
-
- private handleError(error: any, operation: string): Observable {
- console.error(`LocaleManagerService.${operation} error:`, error);
-
- // Handle authentication errors
- if (error?.status === 401) {
- this.authService.logout();
- return throwError(() => ({
- ...error,
- message: 'Authentication required'
- }));
- }
-
- // Handle race condition errors
- if (error?.status === 409) {
- return throwError(() => ({
- ...error,
- message: error.error?.message || 'Resource was modified by another user',
- isRaceCondition: true
- }));
- }
-
- // Handle network errors
- if (error?.status === 0 || error?.name === 'TimeoutError') {
- return throwError(() => ({
- ...error,
- message: 'Network connection error',
- isNetworkError: true
- }));
- }
-
- // For specific operations, provide fallback data
- if (operation === 'getAvailableLocales' && !error?.status) {
- // Fallback locales if API fails
- const fallback = [
- { code: 'tr-TR', name: 'Türkçe', english_name: 'Turkish' },
- { code: 'en-US', name: 'English', english_name: 'English (US)' }
- ];
- this.localesCache = fallback;
- this.cacheTimestamp = Date.now();
- console.warn('Using fallback locales due to error');
- return of(fallback);
- }
-
- // Default error handling
- const errorMessage = error?.error?.message || error?.message || 'Unknown error occurred';
- return throwError(() => ({
- ...error,
- message: errorMessage,
- operation: operation,
- timestamp: new Date().toISOString()
- }));
- }
-
- // Helper method to check if cache is stale
- isCacheStale(): boolean {
- if (!this.cacheTimestamp) return true;
- return Date.now() - this.cacheTimestamp > this.CACHE_DURATION;
- }
-
- // Force refresh locales
- refreshLocales(): Observable {
- this.clearCache();
- return this.getAvailableLocales();
- }
+// locale-manager.service.ts
+// Path: /flare-ui/src/app/services/locale-manager.service.ts
+
+import { Injectable } from '@angular/core';
+import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
+import { Observable, of, throwError } from 'rxjs';
+import { map, catchError, retry, timeout } from 'rxjs/operators';
+import { AuthService } from './auth.service';
+
+export interface Locale {
+ code: string;
+ name: string;
+ english_name: string;
+}
+
+export interface LocaleDetails extends Locale {
+ native_name?: string;
+ direction: string;
+ date_format: string;
+ time_format: string;
+ datetime_format: string;
+ currency: string;
+ currency_symbol: string;
+ decimal_separator: string;
+ thousands_separator: string;
+ week_starts_on: number;
+ months?: string[];
+ days?: string[];
+ am_pm?: string[];
+ common_phrases?: { [key: string]: string };
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LocaleManagerService {
+ private apiUrl = '/api';
+ private adminUrl = `${this.apiUrl}/admin`;
+ private localesCache?: Locale[];
+ private cacheTimestamp?: number;
+ private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
+ private readonly REQUEST_TIMEOUT = 10000; // 10 seconds
+
+ constructor(
+ private http: HttpClient,
+ private authService: AuthService
+ ) {}
+
+ private getAuthHeaders(): HttpHeaders {
+ const token = this.authService.getToken();
+ if (!token) {
+ throw new Error('No authentication token available');
+ }
+
+ return new HttpHeaders({
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json'
+ });
+ }
+
+ getAvailableLocales(): Observable {
+ try {
+ // Check cache validity
+ if (this.localesCache && this.cacheTimestamp) {
+ const now = Date.now();
+ if (now - this.cacheTimestamp < this.CACHE_DURATION) {
+ return of(this.localesCache);
+ }
+ }
+
+ return this.http.get<{ locales: Locale[], default: string }>(
+ `${this.adminUrl}/locales`,
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ retry({ count: 2, delay: 1000 }),
+ map(response => {
+ this.localesCache = response.locales;
+ this.cacheTimestamp = Date.now();
+ return response.locales;
+ }),
+ catchError(error => this.handleError(error, 'getAvailableLocales'))
+ );
+ } catch (error) {
+ return this.handleError(error, 'getAvailableLocales');
+ }
+ }
+
+ getLocaleDetails(code: string): Observable {
+ if (!code) {
+ return throwError(() => new Error('Locale code is required'));
+ }
+
+ try {
+ return this.http.get(
+ `${this.adminUrl}/locales/${encodeURIComponent(code)}`,
+ { headers: this.getAuthHeaders() }
+ ).pipe(
+ timeout(this.REQUEST_TIMEOUT),
+ retry({ count: 2, delay: 1000 }),
+ catchError(error => {
+ // For 404, return null instead of throwing
+ if (error.status === 404) {
+ console.warn(`Locale '${code}' not found`);
+ return of(null);
+ }
+ return this.handleError(error, 'getLocaleDetails');
+ })
+ );
+ } catch (error) {
+ return this.handleError(error, 'getLocaleDetails');
+ }
+ }
+
+ validateLanguages(languages: string[]): Observable {
+ if (!languages || languages.length === 0) {
+ return of([]);
+ }
+
+ try {
+ return this.getAvailableLocales().pipe(
+ map(locales => {
+ const availableCodes = locales.map(l => l.code);
+ const invalidLanguages = languages.filter(lang => !availableCodes.includes(lang));
+
+ if (invalidLanguages.length > 0) {
+ console.warn('Invalid languages detected:', invalidLanguages);
+ }
+
+ return invalidLanguages;
+ }),
+ catchError(error => {
+ console.error('Error validating languages:', error);
+ // Return all languages as invalid if validation fails
+ return of(languages);
+ })
+ );
+ } catch (error) {
+ return this.handleError(error, 'validateLanguages');
+ }
+ }
+
+ clearCache(): void {
+ this.localesCache = undefined;
+ this.cacheTimestamp = undefined;
+ }
+
+ private handleError(error: any, operation: string): Observable