ciyidogan commited on
Commit
9f79da5
·
verified ·
1 Parent(s): 815a822

Upload 118 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +33 -33
  2. .env.example +33 -33
  3. .gitignore +31 -31
  4. Dockerfile +87 -87
  5. api_connector.py +99 -99
  6. api_executor.py +425 -425
  7. app.py +387 -387
  8. config/config_provider.py +156 -156
  9. config/locale_manager.py +29 -29
  10. credentials/google-service-account.json +13 -13
  11. flare-ui/angular.json +116 -116
  12. flare-ui/package.json +42 -42
  13. flare-ui/src/app/app.component.ts +76 -76
  14. flare-ui/src/app/app.config.ts +16 -16
  15. flare-ui/src/app/app.routes.ts +59 -59
  16. flare-ui/src/app/components/activity-log/activity-log.component.ts +429 -429
  17. flare-ui/src/app/components/apis/apis.component.ts +741 -741
  18. flare-ui/src/app/components/chat/chat.component.html +156 -156
  19. flare-ui/src/app/components/chat/chat.component.scss +289 -289
  20. flare-ui/src/app/components/chat/chat.component.ts +630 -630
  21. flare-ui/src/app/components/chat/realtime-chat.component.html +96 -96
  22. flare-ui/src/app/components/chat/realtime-chat.component.scss +164 -164
  23. flare-ui/src/app/components/chat/realtime-chat.component.ts +421 -421
  24. flare-ui/src/app/components/environment/environment.component.html +285 -285
  25. flare-ui/src/app/components/environment/environment.component.scss +167 -167
  26. flare-ui/src/app/components/environment/environment.component.ts +714 -714
  27. flare-ui/src/app/components/login/login.component.ts +208 -208
  28. flare-ui/src/app/components/main/main.component.scss +144 -144
  29. flare-ui/src/app/components/main/main.component.ts +301 -301
  30. flare-ui/src/app/components/projects/projects.component.html +184 -184
  31. flare-ui/src/app/components/projects/projects.component.scss +274 -274
  32. flare-ui/src/app/components/projects/projects.component.ts +448 -448
  33. flare-ui/src/app/components/spark/spark.component.ts +549 -549
  34. flare-ui/src/app/components/test/test.component.html +115 -115
  35. flare-ui/src/app/components/test/test.component.scss +257 -257
  36. flare-ui/src/app/components/test/test.component.ts +709 -709
  37. flare-ui/src/app/components/user-info/user-info.component.html +82 -82
  38. flare-ui/src/app/components/user-info/user-info.component.ts +174 -174
  39. flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html +481 -481
  40. flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss +231 -231
  41. flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts +577 -577
  42. flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts +136 -136
  43. flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html +242 -242
  44. flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss +148 -148
  45. flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts +340 -340
  46. flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss +91 -91
  47. flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts +485 -485
  48. flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts +610 -610
  49. flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html +335 -335
  50. flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.scss +287 -287
.env CHANGED
@@ -1,34 +1,34 @@
1
- # Flare Environment Configuration
2
-
3
- # JWT Configuration
4
- JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4
5
- JWT_ALGORITHM=HS256
6
- JWT_EXPIRATION_HOURS=24
7
-
8
- # Encryption Key for Cloud Tokens
9
- FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA=
10
-
11
- # Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
12
- LOG_LEVEL=DEBUG
13
-
14
- # CORS allowed origins (comma-separated)
15
- ALLOWED_ORIGINS=http://localhost:4200
16
-
17
- # Environment mode
18
- ENVIRONMENT=development
19
-
20
- # Encryption key for sensitive data (32-byte base64 key)
21
- FERNET_KEY=your-32-byte-base64-key
22
-
23
- # Session configuration
24
- SESSION_TIMEOUT_MINUTES=30
25
- MAX_CONCURRENT_SESSIONS=1000
26
-
27
- # Elasticsearch configuration (optional)
28
- ELASTICSEARCH_URL=
29
-
30
- # Database configuration (future use)
31
- DATABASE_URL=
32
-
33
- # Redis configuration (future use)
34
  REDIS_URL=
 
1
+ # Flare Environment Configuration
2
+
3
+ # JWT Configuration
4
+ JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4
5
+ JWT_ALGORITHM=HS256
6
+ JWT_EXPIRATION_HOURS=24
7
+
8
+ # Encryption Key for Cloud Tokens
9
+ FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA=
10
+
11
+ # Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
12
+ LOG_LEVEL=DEBUG
13
+
14
+ # CORS allowed origins (comma-separated)
15
+ ALLOWED_ORIGINS=http://localhost:4200
16
+
17
+ # Environment mode
18
+ ENVIRONMENT=development
19
+
20
+ # Encryption key for sensitive data (32-byte base64 key)
21
+ FERNET_KEY=your-32-byte-base64-key
22
+
23
+ # Session configuration
24
+ SESSION_TIMEOUT_MINUTES=30
25
+ MAX_CONCURRENT_SESSIONS=1000
26
+
27
+ # Elasticsearch configuration (optional)
28
+ ELASTICSEARCH_URL=
29
+
30
+ # Database configuration (future use)
31
+ DATABASE_URL=
32
+
33
+ # Redis configuration (future use)
34
  REDIS_URL=
.env.example CHANGED
@@ -1,34 +1,34 @@
1
- # Flare Environment Configuration
2
-
3
- # JWT Configuration
4
- JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4
5
- JWT_ALGORITHM=HS256
6
- JWT_EXPIRATION_HOURS=24
7
-
8
- # Encryption Key for Cloud Tokens
9
- FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA=
10
-
11
- # Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
12
- LOG_LEVEL=INFO
13
-
14
- # CORS allowed origins (comma-separated)
15
- ALLOWED_ORIGINS=http://localhost:4200
16
-
17
- # Environment mode
18
- ENVIRONMENT=development
19
-
20
- # Encryption key for sensitive data (32-byte base64 key)
21
- FERNET_KEY=your-32-byte-base64-key
22
-
23
- # Session configuration
24
- SESSION_TIMEOUT_MINUTES=30
25
- MAX_CONCURRENT_SESSIONS=1000
26
-
27
- # Elasticsearch configuration (optional)
28
- ELASTICSEARCH_URL=
29
-
30
- # Database configuration (future use)
31
- DATABASE_URL=
32
-
33
- # Redis configuration (future use)
34
  REDIS_URL=
 
1
+ # Flare Environment Configuration
2
+
3
+ # JWT Configuration
4
+ JWT_SECRET=klsdf8734hjksfhjk3h4jkh3jk4h3jkh4jk3h4jkh3k4jhkj3h4
5
+ JWT_ALGORITHM=HS256
6
+ JWT_EXPIRATION_HOURS=24
7
+
8
+ # Encryption Key for Cloud Tokens
9
+ FLARE_TOKEN_KEY=8rSihw0d3Sh_ceyDYobMHNPrgSg0riwGSK4Vco3O5qA=
10
+
11
+ # Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
12
+ LOG_LEVEL=INFO
13
+
14
+ # CORS allowed origins (comma-separated)
15
+ ALLOWED_ORIGINS=http://localhost:4200
16
+
17
+ # Environment mode
18
+ ENVIRONMENT=development
19
+
20
+ # Encryption key for sensitive data (32-byte base64 key)
21
+ FERNET_KEY=your-32-byte-base64-key
22
+
23
+ # Session configuration
24
+ SESSION_TIMEOUT_MINUTES=30
25
+ MAX_CONCURRENT_SESSIONS=1000
26
+
27
+ # Elasticsearch configuration (optional)
28
+ ELASTICSEARCH_URL=
29
+
30
+ # Database configuration (future use)
31
+ DATABASE_URL=
32
+
33
+ # Redis configuration (future use)
34
  REDIS_URL=
.gitignore CHANGED
@@ -1,32 +1,32 @@
1
- # Environment variables
2
- .env
3
- .env.local
4
- .env.production
5
-
6
- # Python
7
- __pycache__/
8
- *.py[cod]
9
- *$py.class
10
- *.so
11
- .Python
12
- env/
13
- venv/
14
- ENV/
15
-
16
- # IDE
17
- .vscode/
18
- .idea/
19
- *.swp
20
- *.swo
21
-
22
- # OS
23
- .DS_Store
24
- Thumbs.db
25
-
26
- # Logs
27
- *.log
28
-
29
- # Angular
30
- flare-ui/node_modules/
31
- flare-ui/dist/
32
  static/
 
1
+ # Environment variables
2
+ .env
3
+ .env.local
4
+ .env.production
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ env/
13
+ venv/
14
+ ENV/
15
+
16
+ # IDE
17
+ .vscode/
18
+ .idea/
19
+ *.swp
20
+ *.swo
21
+
22
+ # OS
23
+ .DS_Store
24
+ Thumbs.db
25
+
26
+ # Logs
27
+ *.log
28
+
29
+ # Angular
30
+ flare-ui/node_modules/
31
+ flare-ui/dist/
32
  static/
Dockerfile CHANGED
@@ -1,88 +1,88 @@
1
- # ============================== BASE IMAGE ==============================
2
- # Build Angular UI
3
- FROM node:18-slim AS angular-build
4
-
5
- # Build argument: production/development
6
- ARG BUILD_ENV=development
7
-
8
- WORKDIR /app
9
-
10
- # Copy package files first for better caching
11
- COPY flare-ui/package*.json ./flare-ui/
12
- WORKDIR /app/flare-ui
13
-
14
- # Clean npm cache and install with legacy peer deps
15
- RUN npm cache clean --force && npm install --legacy-peer-deps
16
-
17
- # Copy the entire flare-ui directory
18
- COPY flare-ui/ ./
19
-
20
- # ✅ Clean Angular cache before build
21
- RUN rm -rf .angular/ dist/ node_modules/.cache/
22
-
23
- # Build the Angular app based on BUILD_ENV
24
- RUN if [ "$BUILD_ENV" = "development" ] ; then \
25
- echo "🔧 Building for DEVELOPMENT..." && \
26
- npm run build:dev -- --output-path=dist/flare-ui ; \
27
- else \
28
- echo "🚀 Building for PRODUCTION..." && \
29
- npm run build:prod -- --output-path=dist/flare-ui ; \
30
- fi
31
-
32
- # Add environment info to container
33
- ENV BUILD_ENVIRONMENT=$BUILD_ENV
34
-
35
- # Debug: List directories to see where the build output is
36
- RUN ls -la /app/flare-ui/ && ls -la /app/flare-ui/dist/ || true
37
-
38
- # Python runtime
39
- FROM python:3.10-slim
40
-
41
- # ====================== SYSTEM-LEVEL DEPENDENCIES ======================
42
- # gcc & friends → bcrypt / uvicorn[standard] gibi C eklentilerini derlemek için
43
- RUN apt-get update \
44
- && apt-get install -y --no-install-recommends gcc g++ make libffi-dev \
45
- && rm -rf /var/lib/apt/lists/*
46
-
47
- # ============================== WORKDIR ================================
48
- WORKDIR /app
49
-
50
- # ===================== HF CACHE & WRITE PERMS ==========================
51
- # Hugging Face Spaces özel dizinleri – yazma izni 777
52
- RUN mkdir -p /app/.cache /tmp/.triton /tmp/torchinductor_cache \
53
- && chmod -R 777 /app/.cache /tmp/.triton /tmp/torchinductor_cache
54
-
55
- ENV HF_HOME=/app/.cache \
56
- HF_DATASETS_CACHE=/app/.cache \
57
- HF_HUB_CACHE=/app/.cache \
58
- TRITON_CACHE_DIR=/tmp/.triton \
59
- TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache
60
-
61
- # ============================ REQUIREMENTS =============================
62
- COPY requirements.txt ./
63
- RUN pip install --no-cache-dir -r requirements.txt
64
-
65
- # ============================== APP CODE ===============================
66
- COPY . .
67
-
68
- # ✅ Config dizini ve dosya izinleri - HF Spaces için özel ayarlar
69
- # Tüm app dizinine ve config dosyasına herkesin yazabilmesi için 777
70
- RUN chmod -R 777 /app && \
71
- touch /app/service_config.jsonc && \
72
- chmod 777 /app/service_config.jsonc && \
73
- # Ayrıca bir .tmp uzantılı dosya da oluştur izinlerle
74
- touch /app/service_config.tmp && \
75
- chmod 777 /app/service_config.tmp
76
-
77
- # ✅ Angular build output'u kopyalanıyor
78
- COPY --from=angular-build /app/flare-ui/dist/flare-ui ./static
79
-
80
- # Create assets directory if it doesn't exist
81
- RUN mkdir -p ./static/assets
82
-
83
- # Debug: Check if static files exist
84
- RUN ls -la ./static/ || echo "No static directory"
85
- RUN ls -la ./static/index.html || echo "No index.html"
86
-
87
- # ============================== START CMD ==============================
88
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # ============================== BASE IMAGE ==============================
2
+ # Build Angular UI
3
+ FROM node:18-slim AS angular-build
4
+
5
+ # Build argument: production/development
6
+ ARG BUILD_ENV=development
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy package files first for better caching
11
+ COPY flare-ui/package*.json ./flare-ui/
12
+ WORKDIR /app/flare-ui
13
+
14
+ # Clean npm cache and install with legacy peer deps
15
+ RUN npm cache clean --force && npm install --legacy-peer-deps
16
+
17
+ # Copy the entire flare-ui directory
18
+ COPY flare-ui/ ./
19
+
20
+ # ✅ Clean Angular cache before build
21
+ RUN rm -rf .angular/ dist/ node_modules/.cache/
22
+
23
+ # Build the Angular app based on BUILD_ENV
24
+ RUN if [ "$BUILD_ENV" = "development" ] ; then \
25
+ echo "🔧 Building for DEVELOPMENT..." && \
26
+ npm run build:dev -- --output-path=dist/flare-ui ; \
27
+ else \
28
+ echo "🚀 Building for PRODUCTION..." && \
29
+ npm run build:prod -- --output-path=dist/flare-ui ; \
30
+ fi
31
+
32
+ # Add environment info to container
33
+ ENV BUILD_ENVIRONMENT=$BUILD_ENV
34
+
35
+ # Debug: List directories to see where the build output is
36
+ RUN ls -la /app/flare-ui/ && ls -la /app/flare-ui/dist/ || true
37
+
38
+ # Python runtime
39
+ FROM python:3.10-slim
40
+
41
+ # ====================== SYSTEM-LEVEL DEPENDENCIES ======================
42
+ # gcc & friends → bcrypt / uvicorn[standard] gibi C eklentilerini derlemek için
43
+ RUN apt-get update \
44
+ && apt-get install -y --no-install-recommends gcc g++ make libffi-dev \
45
+ && rm -rf /var/lib/apt/lists/*
46
+
47
+ # ============================== WORKDIR ================================
48
+ WORKDIR /app
49
+
50
+ # ===================== HF CACHE & WRITE PERMS ==========================
51
+ # Hugging Face Spaces özel dizinleri – yazma izni 777
52
+ RUN mkdir -p /app/.cache /tmp/.triton /tmp/torchinductor_cache \
53
+ && chmod -R 777 /app/.cache /tmp/.triton /tmp/torchinductor_cache
54
+
55
+ ENV HF_HOME=/app/.cache \
56
+ HF_DATASETS_CACHE=/app/.cache \
57
+ HF_HUB_CACHE=/app/.cache \
58
+ TRITON_CACHE_DIR=/tmp/.triton \
59
+ TORCHINDUCTOR_CACHE_DIR=/tmp/torchinductor_cache
60
+
61
+ # ============================ REQUIREMENTS =============================
62
+ COPY requirements.txt ./
63
+ RUN pip install --no-cache-dir -r requirements.txt
64
+
65
+ # ============================== APP CODE ===============================
66
+ COPY . .
67
+
68
+ # ✅ Config dizini ve dosya izinleri - HF Spaces için özel ayarlar
69
+ # Tüm app dizinine ve config dosyasına herkesin yazabilmesi için 777
70
+ RUN chmod -R 777 /app && \
71
+ touch /app/config/service_config.jsonc && \
72
+ chmod 777 /app/config/service_config.jsonc && \
73
+ # Ayrıca bir .tmp uzantılı dosya da oluştur izinlerle
74
+ touch /app/config/service_config.tmp && \
75
+ chmod 777 /app/config/service_config.tmp
76
+
77
+ # ✅ Angular build output'u kopyalanıyor
78
+ COPY --from=angular-build /app/flare-ui/dist/flare-ui ./static
79
+
80
+ # Create assets directory if it doesn't exist
81
+ RUN mkdir -p ./static/assets
82
+
83
+ # Debug: Check if static files exist
84
+ RUN ls -la ./static/ || echo "No static directory"
85
+ RUN ls -la ./static/index.html || echo "No index.html"
86
+
87
+ # ============================== START CMD ==============================
88
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
api_connector.py CHANGED
@@ -1,99 +1,99 @@
1
- import requests
2
- from logger import log_info, log_error, log_warning, log_debug
3
-
4
- class APIConnector:
5
- def __init__(self, service_config):
6
- self.service_config = service_config
7
-
8
- def resolve_placeholders(self, template, session):
9
- resolved = template
10
- for key, value in session.variables.items():
11
- resolved = resolved.replace(f"{{variables.{key}}}", str(value))
12
- for api, tokens in session.auth_tokens.items():
13
- resolved = resolved.replace(f"{{auth_tokens.{api}.token}}", tokens.get("token", ""))
14
- return resolved
15
-
16
- def get_auth_token(self, api_name, auth_config, session):
17
- auth_endpoint = auth_config.get("auth_endpoint")
18
- auth_body = {
19
- k: self.resolve_placeholders(str(v), session)
20
- for k, v in auth_config.get("auth_body", {}).items()
21
- }
22
- token_path = auth_config.get("auth_token_path")
23
-
24
- response = requests.post(auth_endpoint, json=auth_body, timeout=5)
25
- response.raise_for_status()
26
- json_resp = response.json()
27
-
28
- token = json_resp
29
- for part in token_path.split("."):
30
- token = token.get(part)
31
- if token is None:
32
- raise Exception(f"Could not resolve token path: {token_path}")
33
-
34
- refresh_token = json_resp.get("refresh_token")
35
- session.auth_tokens[api_name] = {"token": token, "refresh_token": refresh_token}
36
-
37
- log(f"🔑 Retrieved auth token for {api_name}")
38
- return token
39
-
40
- def refresh_auth_token(self, api_name, auth_config, session):
41
- refresh_endpoint = auth_config.get("auth_refresh_endpoint")
42
- refresh_body = {
43
- k: self.resolve_placeholders(str(v), session)
44
- for k, v in auth_config.get("refresh_body", {}).items()
45
- }
46
- token_path = auth_config.get("auth_token_path")
47
-
48
- response = requests.post(refresh_endpoint, json=refresh_body, timeout=5)
49
- response.raise_for_status()
50
- json_resp = response.json()
51
-
52
- token = json_resp
53
- for part in token_path.split("."):
54
- token = token.get(part)
55
- if token is None:
56
- raise Exception(f"Could not resolve token path: {token_path}")
57
-
58
- new_refresh_token = json_resp.get("refresh_token", session.auth_tokens[api_name].get("refresh_token"))
59
- session.auth_tokens[api_name] = {"token": token, "refresh_token": new_refresh_token}
60
-
61
- log_info(f"🔁 Refreshed auth token for {api_name}")
62
- return token
63
-
64
- def call_api(self, intent_def, session):
65
- api_name = intent_def.get("action")
66
- api_def = self.service_config.get_api_config(api_name)
67
- if not api_def:
68
- raise Exception(f"API config not found: {api_name}")
69
-
70
- url = api_def["url"]
71
- method = api_def.get("method", "POST")
72
- headers = {h["key"]: self.resolve_placeholders(h["value"], session) for h in api_def.get("headers", [])}
73
- body = {k: self.resolve_placeholders(str(v), session) for k, v in api_def.get("body", {}).items()}
74
- timeout = api_def.get("timeout", 5)
75
- retry_count = api_def.get("retry_count", 0)
76
- auth_config = api_def.get("auth")
77
-
78
- # Get auth token if needed
79
- if auth_config and api_name not in session.auth_tokens:
80
- self.get_auth_token(api_name, auth_config, session)
81
-
82
- for attempt in range(retry_count + 1):
83
- try:
84
- response = requests.request(method, url, headers=headers, json=body, timeout=timeout)
85
- if response.status_code == 401 and auth_config and attempt < retry_count:
86
- log_info(f"🔁 Token expired for {api_name}, refreshing...")
87
- self.refresh_auth_token(api_name, auth_config, session)
88
- continue
89
- response.raise_for_status()
90
- log_info(f"✅ API call successful: {api_name}")
91
- return response.json()
92
- except requests.Timeout:
93
- fallback = intent_def.get("fallback_timeout_message", "This operation is currently unavailable.")
94
- log_warning(f"⚠️ API timeout for {api_name} → {fallback}")
95
- return {"fallback": fallback}
96
- except Exception as e:
97
- log_error(f"❌ API call error for {api_name}", e)
98
- fallback = intent_def.get("fallback_error_message", "An error occurred during the operation.")
99
- return {"fallback": fallback}
 
1
+ import requests
2
+ from utils.logger import log_info, log_error, log_warning, log_debug
3
+
4
+ class APIConnector:
5
+ def __init__(self, service_config):
6
+ self.service_config = service_config
7
+
8
+ def resolve_placeholders(self, template, session):
9
+ resolved = template
10
+ for key, value in session.variables.items():
11
+ resolved = resolved.replace(f"{{variables.{key}}}", str(value))
12
+ for api, tokens in session.auth_tokens.items():
13
+ resolved = resolved.replace(f"{{auth_tokens.{api}.token}}", tokens.get("token", ""))
14
+ return resolved
15
+
16
+ def get_auth_token(self, api_name, auth_config, session):
17
+ auth_endpoint = auth_config.get("auth_endpoint")
18
+ auth_body = {
19
+ k: self.resolve_placeholders(str(v), session)
20
+ for k, v in auth_config.get("auth_body", {}).items()
21
+ }
22
+ token_path = auth_config.get("auth_token_path")
23
+
24
+ response = requests.post(auth_endpoint, json=auth_body, timeout=5)
25
+ response.raise_for_status()
26
+ json_resp = response.json()
27
+
28
+ token = json_resp
29
+ for part in token_path.split("."):
30
+ token = token.get(part)
31
+ if token is None:
32
+ raise Exception(f"Could not resolve token path: {token_path}")
33
+
34
+ refresh_token = json_resp.get("refresh_token")
35
+ session.auth_tokens[api_name] = {"token": token, "refresh_token": refresh_token}
36
+
37
+ log(f"🔑 Retrieved auth token for {api_name}")
38
+ return token
39
+
40
+ def refresh_auth_token(self, api_name, auth_config, session):
41
+ refresh_endpoint = auth_config.get("auth_refresh_endpoint")
42
+ refresh_body = {
43
+ k: self.resolve_placeholders(str(v), session)
44
+ for k, v in auth_config.get("refresh_body", {}).items()
45
+ }
46
+ token_path = auth_config.get("auth_token_path")
47
+
48
+ response = requests.post(refresh_endpoint, json=refresh_body, timeout=5)
49
+ response.raise_for_status()
50
+ json_resp = response.json()
51
+
52
+ token = json_resp
53
+ for part in token_path.split("."):
54
+ token = token.get(part)
55
+ if token is None:
56
+ raise Exception(f"Could not resolve token path: {token_path}")
57
+
58
+ new_refresh_token = json_resp.get("refresh_token", session.auth_tokens[api_name].get("refresh_token"))
59
+ session.auth_tokens[api_name] = {"token": token, "refresh_token": new_refresh_token}
60
+
61
+ log_info(f"🔁 Refreshed auth token for {api_name}")
62
+ return token
63
+
64
+ def call_api(self, intent_def, session):
65
+ api_name = intent_def.get("action")
66
+ api_def = self.service_config.get_api_config(api_name)
67
+ if not api_def:
68
+ raise Exception(f"API config not found: {api_name}")
69
+
70
+ url = api_def["url"]
71
+ method = api_def.get("method", "POST")
72
+ headers = {h["key"]: self.resolve_placeholders(h["value"], session) for h in api_def.get("headers", [])}
73
+ body = {k: self.resolve_placeholders(str(v), session) for k, v in api_def.get("body", {}).items()}
74
+ timeout = api_def.get("timeout", 5)
75
+ retry_count = api_def.get("retry_count", 0)
76
+ auth_config = api_def.get("auth")
77
+
78
+ # Get auth token if needed
79
+ if auth_config and api_name not in session.auth_tokens:
80
+ self.get_auth_token(api_name, auth_config, session)
81
+
82
+ for attempt in range(retry_count + 1):
83
+ try:
84
+ response = requests.request(method, url, headers=headers, json=body, timeout=timeout)
85
+ if response.status_code == 401 and auth_config and attempt < retry_count:
86
+ log_info(f"🔁 Token expired for {api_name}, refreshing...")
87
+ self.refresh_auth_token(api_name, auth_config, session)
88
+ continue
89
+ response.raise_for_status()
90
+ log_info(f"✅ API call successful: {api_name}")
91
+ return response.json()
92
+ except requests.Timeout:
93
+ fallback = intent_def.get("fallback_timeout_message", "This operation is currently unavailable.")
94
+ log_warning(f"⚠️ API timeout for {api_name} → {fallback}")
95
+ return {"fallback": fallback}
96
+ except Exception as e:
97
+ log_error(f"❌ API call error for {api_name}", e)
98
+ fallback = intent_def.get("fallback_error_message", "An error occurred during the operation.")
99
+ return {"fallback": fallback}
api_executor.py CHANGED
@@ -1,426 +1,426 @@
1
- """
2
- Flare – API Executor (v2.0 · session-aware token management)
3
- """
4
-
5
- from __future__ import annotations
6
- import json, re, time, requests
7
- from typing import Any, Dict, Optional, Union
8
- from logger import log_info, log_error, log_warning, log_debug, LogTimer
9
- from config_provider import ConfigProvider, APIConfig
10
- from session import Session
11
- import os
12
-
13
- MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB
14
- DEFAULT_TIMEOUT = int(os.getenv("API_TIMEOUT_SECONDS", "30"))
15
-
16
- _placeholder = re.compile(r"\{\{\s*([^\}]+?)\s*\}\}")
17
-
18
- def _get_variable_value(session: Session, var_path: str) -> Any:
19
- cfg = ConfigProvider.get()
20
-
21
- """Get variable value with proper type from session"""
22
- if var_path.startswith("variables."):
23
- var_name = var_path.split(".", 1)[1]
24
- return session.variables.get(var_name)
25
- elif var_path.startswith("auth_tokens."):
26
- parts = var_path.split(".")
27
- if len(parts) >= 3:
28
- token_api = parts[1]
29
- token_field = parts[2]
30
- token_data = session._auth_tokens.get(token_api, {})
31
- return token_data.get(token_field)
32
- elif var_path.startswith("config."):
33
- attr_name = var_path.split(".", 1)[1]
34
- return getattr(cfg.global_config, attr_name, None)
35
- return None
36
-
37
- def _render_value(value: Any) -> Union[str, int, float, bool, None]:
38
- """Convert value to appropriate JSON type"""
39
- if value is None:
40
- return None
41
- elif isinstance(value, bool):
42
- return value
43
- elif isinstance(value, (int, float)):
44
- return value
45
- elif isinstance(value, str):
46
- # Check if it's a number string
47
- if value.isdigit():
48
- return int(value)
49
- try:
50
- return float(value)
51
- except ValueError:
52
- pass
53
- # Check if it's a boolean string
54
- if value.lower() in ('true', 'false'):
55
- return value.lower() == 'true'
56
- # Return as string
57
- return value
58
- else:
59
- return str(value)
60
-
61
- def _render_json(obj: Any, session: Session, api_name: str) -> Any:
62
- """Render JSON preserving types"""
63
- if isinstance(obj, str):
64
- # Check if entire string is a template
65
- template_match = _placeholder.fullmatch(obj.strip())
66
- if template_match:
67
- # This is a pure template like {{variables.pnr}}
68
- var_path = template_match.group(1).strip()
69
- value = _get_variable_value(session, var_path)
70
- return _render_value(value)
71
- else:
72
- # String with embedded templates or regular string
73
- def replacer(match):
74
- var_path = match.group(1).strip()
75
- value = _get_variable_value(session, var_path)
76
- return str(value) if value is not None else ""
77
-
78
- return _placeholder.sub(replacer, obj)
79
-
80
- elif isinstance(obj, dict):
81
- return {k: _render_json(v, session, api_name) for k, v in obj.items()}
82
-
83
- elif isinstance(obj, list):
84
- return [_render_json(v, session, api_name) for v in obj]
85
-
86
- else:
87
- # Return as-is for numbers, booleans, None
88
- return obj
89
-
90
- def _render(obj: Any, session: Session, api_name: str) -> Any:
91
- """Render template with session variables and tokens"""
92
- # For headers and other string-only contexts
93
- if isinstance(obj, str):
94
- def replacer(match):
95
- var_path = match.group(1).strip()
96
- value = _get_variable_value(session, var_path)
97
- return str(value) if value is not None else ""
98
-
99
- return _placeholder.sub(replacer, obj)
100
-
101
- elif isinstance(obj, dict):
102
- return {k: _render(v, session, api_name) for k, v in obj.items()}
103
-
104
- elif isinstance(obj, list):
105
- return [_render(v, session, api_name) for v in obj]
106
-
107
- return obj
108
-
109
- def _fetch_token(api: APIConfig, session: Session) -> None:
110
- """Fetch new auth token"""
111
- if not api.auth or not api.auth.enabled:
112
- return
113
-
114
- log_info(f"🔑 Fetching token for {api.name}")
115
-
116
- try:
117
- # Use _render_json for body to preserve types
118
- body = _render_json(api.auth.token_request_body, session, api.name)
119
- headers = {"Content-Type": "application/json"}
120
-
121
- response = requests.post(
122
- str(api.auth.token_endpoint),
123
- json=body,
124
- headers=headers,
125
- timeout=api.timeout_seconds
126
- )
127
- response.raise_for_status()
128
-
129
- json_data = response.json()
130
-
131
- # Extract token using path
132
- token = json_data
133
- for path_part in api.auth.response_token_path.split("."):
134
- token = token.get(path_part)
135
- if token is None:
136
- raise ValueError(f"Token path {api.auth.response_token_path} not found in response")
137
-
138
- # Store in session
139
- session._auth_tokens[api.name] = {
140
- "token": token,
141
- "expiry": time.time() + 3500, # ~1 hour
142
- "refresh_token": json_data.get("refresh_token")
143
- }
144
-
145
- log_info(f"✅ Token obtained for {api.name}")
146
-
147
- except Exception as e:
148
- log_error(f"❌ Token fetch failed for {api.name}", e)
149
- raise
150
-
151
- def _refresh_token(api: APIConfig, session: Session) -> bool:
152
- """Refresh existing token"""
153
- if not api.auth or not api.auth.token_refresh_endpoint:
154
- return False
155
-
156
- token_info = session._auth_tokens.get(api.name, {})
157
- if not token_info.get("refresh_token"):
158
- return False
159
-
160
- log_info(f"🔄 Refreshing token for {api.name}")
161
-
162
- try:
163
- body = _render_json(api.auth.token_refresh_body or {}, session, api.name)
164
- body["refresh_token"] = token_info["refresh_token"]
165
-
166
- response = requests.post(
167
- str(api.auth.token_refresh_endpoint),
168
- json=body,
169
- timeout=api.timeout_seconds
170
- )
171
- response.raise_for_status()
172
-
173
- json_data = response.json()
174
-
175
- # Extract new token
176
- token = json_data
177
- for path_part in api.auth.response_token_path.split("."):
178
- token = token.get(path_part)
179
- if token is None:
180
- raise ValueError(f"Token path {api.auth.response_token_path} not found in refresh response")
181
-
182
- # Update session
183
- session._auth_tokens[api.name] = {
184
- "token": token,
185
- "expiry": time.time() + 3500,
186
- "refresh_token": json_data.get("refresh_token", token_info["refresh_token"])
187
- }
188
-
189
- log_info(f"✅ Token refreshed for {api.name}")
190
- return True
191
-
192
- except Exception as e:
193
- log_error(f"❌ Token refresh failed for {api.name}", e)
194
- return False
195
-
196
- def _ensure_token(api: APIConfig, session: Session) -> None:
197
- """Ensure valid token exists for API"""
198
- if not api.auth or not api.auth.enabled:
199
- return
200
-
201
- token_info = session._auth_tokens.get(api.name)
202
-
203
- # No token yet
204
- if not token_info:
205
- _fetch_token(api, session)
206
- return
207
-
208
- # Token still valid
209
- if token_info.get("expiry", 0) > time.time():
210
- return
211
-
212
- # Try refresh first
213
- if _refresh_token(api, session):
214
- return
215
-
216
- # Refresh failed, get new token
217
- _fetch_token(api, session)
218
-
219
- def call_api(api: APIConfig, session: Session) -> requests.Response:
220
- """Execute API call with automatic token management and better error handling"""
221
-
222
- # Ensure valid token
223
- _ensure_token(api, session)
224
-
225
- # Prepare request
226
- headers = _render(api.headers, session, api.name)
227
- body = _render_json(api.body_template, session, api.name)
228
-
229
- # Get timeout with fallback
230
- timeout = api.timeout_seconds if api.timeout_seconds else DEFAULT_TIMEOUT
231
-
232
- # Handle proxy
233
- proxies = None
234
- if api.proxy:
235
- if isinstance(api.proxy, str):
236
- proxies = {"http": api.proxy, "https": api.proxy}
237
- elif hasattr(api.proxy, "enabled") and api.proxy.enabled:
238
- proxy_url = str(api.proxy.url)
239
- proxies = {"http": proxy_url, "https": proxy_url}
240
-
241
- # Prepare request parameters
242
- request_params = {
243
- "method": api.method,
244
- "url": str(api.url),
245
- "headers": headers,
246
- "timeout": timeout, # Use configured timeout
247
- "stream": True # Enable streaming for large responses
248
- }
249
-
250
- # Add body based on method
251
- if api.method in ("POST", "PUT", "PATCH"):
252
- request_params["json"] = body
253
- elif api.method == "GET" and body:
254
- request_params["params"] = body
255
-
256
- if proxies:
257
- request_params["proxies"] = proxies
258
-
259
- # Execute with retry
260
- retry_count = api.retry.retry_count if api.retry else 0
261
- last_error = None
262
- response = None
263
-
264
- for attempt in range(retry_count + 1):
265
- try:
266
- # Use LogTimer for performance tracking
267
- with LogTimer(f"API call {api.name}", attempt=attempt + 1):
268
- log_info(
269
- f"🌐 API call starting",
270
- api=api.name,
271
- method=api.method,
272
- url=api.url,
273
- attempt=f"{attempt + 1}/{retry_count + 1}",
274
- timeout=timeout
275
- )
276
-
277
- if body:
278
- log_debug(f"📋 Request body", body=json.dumps(body, ensure_ascii=False)[:500])
279
-
280
- # Make request with streaming
281
- response = requests.request(**request_params)
282
-
283
- # Check response size from headers
284
- content_length = response.headers.get('content-length')
285
- if content_length and int(content_length) > MAX_RESPONSE_SIZE:
286
- response.close()
287
- raise ValueError(f"Response too large: {int(content_length)} bytes (max: {MAX_RESPONSE_SIZE})")
288
-
289
- # Handle 401 Unauthorized
290
- if response.status_code == 401 and api.auth and api.auth.enabled and attempt < retry_count:
291
- log_warning(f"🔒 Got 401, refreshing token", api=api.name)
292
- _fetch_token(api, session)
293
- headers = _render(api.headers, session, api.name)
294
- request_params["headers"] = headers
295
- response.close()
296
- continue
297
-
298
- # Read response with size limit
299
- content_size = 0
300
- chunks = []
301
-
302
- for chunk in response.iter_content(chunk_size=8192):
303
- chunks.append(chunk)
304
- content_size += len(chunk)
305
-
306
- if content_size > MAX_RESPONSE_SIZE:
307
- response.close()
308
- raise ValueError(f"Response exceeded size limit: {content_size} bytes")
309
-
310
- # Reconstruct response content
311
- response._content = b''.join(chunks)
312
- response._content_consumed = True
313
-
314
- # Check status
315
- response.raise_for_status()
316
-
317
- log_info(
318
- f"✅ API call successful",
319
- api=api.name,
320
- status_code=response.status_code,
321
- response_size=content_size,
322
- duration_ms=f"{response.elapsed.total_seconds() * 1000:.2f}"
323
- )
324
-
325
- # Mevcut response mapping işlemi korunacak
326
- if response.status_code in (200, 201, 202, 204) and hasattr(api, 'response_mappings') and api.response_mappings:
327
- try:
328
- if response.status_code != 204 and response.content:
329
- response_json = response.json()
330
-
331
- for mapping in api.response_mappings:
332
- var_name = mapping.get('variable_name')
333
- var_type = mapping.get('type', 'str')
334
- json_path = mapping.get('json_path')
335
-
336
- if not all([var_name, json_path]):
337
- continue
338
-
339
- # JSON path'ten değeri al
340
- value = response_json
341
- for path_part in json_path.split('.'):
342
- if isinstance(value, dict):
343
- value = value.get(path_part)
344
- if value is None:
345
- break
346
-
347
- if value is not None:
348
- # Type conversion
349
- if var_type == 'int':
350
- value = int(value)
351
- elif var_type == 'float':
352
- value = float(value)
353
- elif var_type == 'bool':
354
- value = bool(value)
355
- elif var_type == 'date':
356
- value = str(value)
357
- else: # str
358
- value = str(value)
359
-
360
- # Session'a kaydet
361
- session.variables[var_name] = value
362
- log_info(f"📝 Mapped response", variable=var_name, value=value)
363
-
364
- except Exception as e:
365
- log_error("⚠️ Response mapping error", error=str(e))
366
-
367
- return response
368
-
369
- except requests.exceptions.Timeout as e:
370
- last_error = e
371
- log_warning(
372
- f"⏱️ API timeout",
373
- api=api.name,
374
- attempt=attempt + 1,
375
- timeout=timeout
376
- )
377
-
378
- except requests.exceptions.RequestException as e:
379
- last_error = e
380
- log_error(
381
- f"❌ API request error",
382
- api=api.name,
383
- error=str(e),
384
- attempt=attempt + 1
385
- )
386
-
387
- except ValueError as e: # Size limit exceeded
388
- log_error(
389
- f"❌ Response size error",
390
- api=api.name,
391
- error=str(e)
392
- )
393
- raise # Don't retry for size errors
394
-
395
- except Exception as e:
396
- last_error = e
397
- log_error(
398
- f"❌ Unexpected API error",
399
- api=api.name,
400
- error=str(e),
401
- attempt=attempt + 1
402
- )
403
-
404
- # Retry backoff
405
- if attempt < retry_count:
406
- backoff = api.retry.backoff_seconds if api.retry else 2
407
- if api.retry and api.retry.strategy == "exponential":
408
- backoff = backoff * (2 ** attempt)
409
- log_info(f"⏳ Retry backoff", wait_seconds=backoff, next_attempt=attempt + 2)
410
- time.sleep(backoff)
411
-
412
- # All retries failed
413
- error_msg = f"API call failed after {retry_count + 1} attempts"
414
- log_error(error_msg, api=api.name, last_error=str(last_error))
415
-
416
- if last_error:
417
- raise last_error
418
- raise requests.exceptions.RequestException(error_msg)
419
-
420
- def format_size(size_bytes: int) -> str:
421
- """Format bytes to human readable format"""
422
- for unit in ['B', 'KB', 'MB', 'GB']:
423
- if size_bytes < 1024.0:
424
- return f"{size_bytes:.2f} {unit}"
425
- size_bytes /= 1024.0
426
  return f"{size_bytes:.2f} TB"
 
1
+ """
2
+ Flare – API Executor (v2.0 · session-aware token management)
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import json, re, time, requests
7
+ from typing import Any, Dict, Optional, Union
8
+ from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer
9
+ from config.config_provider import ConfigProvider, APIConfig
10
+ from session import Session
11
+ import os
12
+
13
+ MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB
14
+ DEFAULT_TIMEOUT = int(os.getenv("API_TIMEOUT_SECONDS", "30"))
15
+
16
+ _placeholder = re.compile(r"\{\{\s*([^\}]+?)\s*\}\}")
17
+
18
+ def _get_variable_value(session: Session, var_path: str) -> Any:
19
+ cfg = ConfigProvider.get()
20
+
21
+ """Get variable value with proper type from session"""
22
+ if var_path.startswith("variables."):
23
+ var_name = var_path.split(".", 1)[1]
24
+ return session.variables.get(var_name)
25
+ elif var_path.startswith("auth_tokens."):
26
+ parts = var_path.split(".")
27
+ if len(parts) >= 3:
28
+ token_api = parts[1]
29
+ token_field = parts[2]
30
+ token_data = session._auth_tokens.get(token_api, {})
31
+ return token_data.get(token_field)
32
+ elif var_path.startswith("config."):
33
+ attr_name = var_path.split(".", 1)[1]
34
+ return getattr(cfg.global_config, attr_name, None)
35
+ return None
36
+
37
+ def _render_value(value: Any) -> Union[str, int, float, bool, None]:
38
+ """Convert value to appropriate JSON type"""
39
+ if value is None:
40
+ return None
41
+ elif isinstance(value, bool):
42
+ return value
43
+ elif isinstance(value, (int, float)):
44
+ return value
45
+ elif isinstance(value, str):
46
+ # Check if it's a number string
47
+ if value.isdigit():
48
+ return int(value)
49
+ try:
50
+ return float(value)
51
+ except ValueError:
52
+ pass
53
+ # Check if it's a boolean string
54
+ if value.lower() in ('true', 'false'):
55
+ return value.lower() == 'true'
56
+ # Return as string
57
+ return value
58
+ else:
59
+ return str(value)
60
+
61
+ def _render_json(obj: Any, session: Session, api_name: str) -> Any:
62
+ """Render JSON preserving types"""
63
+ if isinstance(obj, str):
64
+ # Check if entire string is a template
65
+ template_match = _placeholder.fullmatch(obj.strip())
66
+ if template_match:
67
+ # This is a pure template like {{variables.pnr}}
68
+ var_path = template_match.group(1).strip()
69
+ value = _get_variable_value(session, var_path)
70
+ return _render_value(value)
71
+ else:
72
+ # String with embedded templates or regular string
73
+ def replacer(match):
74
+ var_path = match.group(1).strip()
75
+ value = _get_variable_value(session, var_path)
76
+ return str(value) if value is not None else ""
77
+
78
+ return _placeholder.sub(replacer, obj)
79
+
80
+ elif isinstance(obj, dict):
81
+ return {k: _render_json(v, session, api_name) for k, v in obj.items()}
82
+
83
+ elif isinstance(obj, list):
84
+ return [_render_json(v, session, api_name) for v in obj]
85
+
86
+ else:
87
+ # Return as-is for numbers, booleans, None
88
+ return obj
89
+
90
+ def _render(obj: Any, session: Session, api_name: str) -> Any:
91
+ """Render template with session variables and tokens"""
92
+ # For headers and other string-only contexts
93
+ if isinstance(obj, str):
94
+ def replacer(match):
95
+ var_path = match.group(1).strip()
96
+ value = _get_variable_value(session, var_path)
97
+ return str(value) if value is not None else ""
98
+
99
+ return _placeholder.sub(replacer, obj)
100
+
101
+ elif isinstance(obj, dict):
102
+ return {k: _render(v, session, api_name) for k, v in obj.items()}
103
+
104
+ elif isinstance(obj, list):
105
+ return [_render(v, session, api_name) for v in obj]
106
+
107
+ return obj
108
+
109
+ def _fetch_token(api: APIConfig, session: Session) -> None:
110
+ """Fetch new auth token"""
111
+ if not api.auth or not api.auth.enabled:
112
+ return
113
+
114
+ log_info(f"🔑 Fetching token for {api.name}")
115
+
116
+ try:
117
+ # Use _render_json for body to preserve types
118
+ body = _render_json(api.auth.token_request_body, session, api.name)
119
+ headers = {"Content-Type": "application/json"}
120
+
121
+ response = requests.post(
122
+ str(api.auth.token_endpoint),
123
+ json=body,
124
+ headers=headers,
125
+ timeout=api.timeout_seconds
126
+ )
127
+ response.raise_for_status()
128
+
129
+ json_data = response.json()
130
+
131
+ # Extract token using path
132
+ token = json_data
133
+ for path_part in api.auth.response_token_path.split("."):
134
+ token = token.get(path_part)
135
+ if token is None:
136
+ raise ValueError(f"Token path {api.auth.response_token_path} not found in response")
137
+
138
+ # Store in session
139
+ session._auth_tokens[api.name] = {
140
+ "token": token,
141
+ "expiry": time.time() + 3500, # ~1 hour
142
+ "refresh_token": json_data.get("refresh_token")
143
+ }
144
+
145
+ log_info(f"✅ Token obtained for {api.name}")
146
+
147
+ except Exception as e:
148
+ log_error(f"❌ Token fetch failed for {api.name}", e)
149
+ raise
150
+
151
+ def _refresh_token(api: APIConfig, session: Session) -> bool:
152
+ """Refresh existing token"""
153
+ if not api.auth or not api.auth.token_refresh_endpoint:
154
+ return False
155
+
156
+ token_info = session._auth_tokens.get(api.name, {})
157
+ if not token_info.get("refresh_token"):
158
+ return False
159
+
160
+ log_info(f"🔄 Refreshing token for {api.name}")
161
+
162
+ try:
163
+ body = _render_json(api.auth.token_refresh_body or {}, session, api.name)
164
+ body["refresh_token"] = token_info["refresh_token"]
165
+
166
+ response = requests.post(
167
+ str(api.auth.token_refresh_endpoint),
168
+ json=body,
169
+ timeout=api.timeout_seconds
170
+ )
171
+ response.raise_for_status()
172
+
173
+ json_data = response.json()
174
+
175
+ # Extract new token
176
+ token = json_data
177
+ for path_part in api.auth.response_token_path.split("."):
178
+ token = token.get(path_part)
179
+ if token is None:
180
+ raise ValueError(f"Token path {api.auth.response_token_path} not found in refresh response")
181
+
182
+ # Update session
183
+ session._auth_tokens[api.name] = {
184
+ "token": token,
185
+ "expiry": time.time() + 3500,
186
+ "refresh_token": json_data.get("refresh_token", token_info["refresh_token"])
187
+ }
188
+
189
+ log_info(f"✅ Token refreshed for {api.name}")
190
+ return True
191
+
192
+ except Exception as e:
193
+ log_error(f"❌ Token refresh failed for {api.name}", e)
194
+ return False
195
+
196
+ def _ensure_token(api: APIConfig, session: Session) -> None:
197
+ """Ensure valid token exists for API"""
198
+ if not api.auth or not api.auth.enabled:
199
+ return
200
+
201
+ token_info = session._auth_tokens.get(api.name)
202
+
203
+ # No token yet
204
+ if not token_info:
205
+ _fetch_token(api, session)
206
+ return
207
+
208
+ # Token still valid
209
+ if token_info.get("expiry", 0) > time.time():
210
+ return
211
+
212
+ # Try refresh first
213
+ if _refresh_token(api, session):
214
+ return
215
+
216
+ # Refresh failed, get new token
217
+ _fetch_token(api, session)
218
+
219
+ def call_api(api: APIConfig, session: Session) -> requests.Response:
220
+ """Execute API call with automatic token management and better error handling"""
221
+
222
+ # Ensure valid token
223
+ _ensure_token(api, session)
224
+
225
+ # Prepare request
226
+ headers = _render(api.headers, session, api.name)
227
+ body = _render_json(api.body_template, session, api.name)
228
+
229
+ # Get timeout with fallback
230
+ timeout = api.timeout_seconds if api.timeout_seconds else DEFAULT_TIMEOUT
231
+
232
+ # Handle proxy
233
+ proxies = None
234
+ if api.proxy:
235
+ if isinstance(api.proxy, str):
236
+ proxies = {"http": api.proxy, "https": api.proxy}
237
+ elif hasattr(api.proxy, "enabled") and api.proxy.enabled:
238
+ proxy_url = str(api.proxy.url)
239
+ proxies = {"http": proxy_url, "https": proxy_url}
240
+
241
+ # Prepare request parameters
242
+ request_params = {
243
+ "method": api.method,
244
+ "url": str(api.url),
245
+ "headers": headers,
246
+ "timeout": timeout, # Use configured timeout
247
+ "stream": True # Enable streaming for large responses
248
+ }
249
+
250
+ # Add body based on method
251
+ if api.method in ("POST", "PUT", "PATCH"):
252
+ request_params["json"] = body
253
+ elif api.method == "GET" and body:
254
+ request_params["params"] = body
255
+
256
+ if proxies:
257
+ request_params["proxies"] = proxies
258
+
259
+ # Execute with retry
260
+ retry_count = api.retry.retry_count if api.retry else 0
261
+ last_error = None
262
+ response = None
263
+
264
+ for attempt in range(retry_count + 1):
265
+ try:
266
+ # Use LogTimer for performance tracking
267
+ with LogTimer(f"API call {api.name}", attempt=attempt + 1):
268
+ log_info(
269
+ f"🌐 API call starting",
270
+ api=api.name,
271
+ method=api.method,
272
+ url=api.url,
273
+ attempt=f"{attempt + 1}/{retry_count + 1}",
274
+ timeout=timeout
275
+ )
276
+
277
+ if body:
278
+ log_debug(f"📋 Request body", body=json.dumps(body, ensure_ascii=False)[:500])
279
+
280
+ # Make request with streaming
281
+ response = requests.request(**request_params)
282
+
283
+ # Check response size from headers
284
+ content_length = response.headers.get('content-length')
285
+ if content_length and int(content_length) > MAX_RESPONSE_SIZE:
286
+ response.close()
287
+ raise ValueError(f"Response too large: {int(content_length)} bytes (max: {MAX_RESPONSE_SIZE})")
288
+
289
+ # Handle 401 Unauthorized
290
+ if response.status_code == 401 and api.auth and api.auth.enabled and attempt < retry_count:
291
+ log_warning(f"🔒 Got 401, refreshing token", api=api.name)
292
+ _fetch_token(api, session)
293
+ headers = _render(api.headers, session, api.name)
294
+ request_params["headers"] = headers
295
+ response.close()
296
+ continue
297
+
298
+ # Read response with size limit
299
+ content_size = 0
300
+ chunks = []
301
+
302
+ for chunk in response.iter_content(chunk_size=8192):
303
+ chunks.append(chunk)
304
+ content_size += len(chunk)
305
+
306
+ if content_size > MAX_RESPONSE_SIZE:
307
+ response.close()
308
+ raise ValueError(f"Response exceeded size limit: {content_size} bytes")
309
+
310
+ # Reconstruct response content
311
+ response._content = b''.join(chunks)
312
+ response._content_consumed = True
313
+
314
+ # Check status
315
+ response.raise_for_status()
316
+
317
+ log_info(
318
+ f"✅ API call successful",
319
+ api=api.name,
320
+ status_code=response.status_code,
321
+ response_size=content_size,
322
+ duration_ms=f"{response.elapsed.total_seconds() * 1000:.2f}"
323
+ )
324
+
325
+ # Mevcut response mapping işlemi korunacak
326
+ if response.status_code in (200, 201, 202, 204) and hasattr(api, 'response_mappings') and api.response_mappings:
327
+ try:
328
+ if response.status_code != 204 and response.content:
329
+ response_json = response.json()
330
+
331
+ for mapping in api.response_mappings:
332
+ var_name = mapping.get('variable_name')
333
+ var_type = mapping.get('type', 'str')
334
+ json_path = mapping.get('json_path')
335
+
336
+ if not all([var_name, json_path]):
337
+ continue
338
+
339
+ # JSON path'ten değeri al
340
+ value = response_json
341
+ for path_part in json_path.split('.'):
342
+ if isinstance(value, dict):
343
+ value = value.get(path_part)
344
+ if value is None:
345
+ break
346
+
347
+ if value is not None:
348
+ # Type conversion
349
+ if var_type == 'int':
350
+ value = int(value)
351
+ elif var_type == 'float':
352
+ value = float(value)
353
+ elif var_type == 'bool':
354
+ value = bool(value)
355
+ elif var_type == 'date':
356
+ value = str(value)
357
+ else: # str
358
+ value = str(value)
359
+
360
+ # Session'a kaydet
361
+ session.variables[var_name] = value
362
+ log_info(f"📝 Mapped response", variable=var_name, value=value)
363
+
364
+ except Exception as e:
365
+ log_error("⚠️ Response mapping error", error=str(e))
366
+
367
+ return response
368
+
369
+ except requests.exceptions.Timeout as e:
370
+ last_error = e
371
+ log_warning(
372
+ f"⏱️ API timeout",
373
+ api=api.name,
374
+ attempt=attempt + 1,
375
+ timeout=timeout
376
+ )
377
+
378
+ except requests.exceptions.RequestException as e:
379
+ last_error = e
380
+ log_error(
381
+ f"❌ API request error",
382
+ api=api.name,
383
+ error=str(e),
384
+ attempt=attempt + 1
385
+ )
386
+
387
+ except ValueError as e: # Size limit exceeded
388
+ log_error(
389
+ f"❌ Response size error",
390
+ api=api.name,
391
+ error=str(e)
392
+ )
393
+ raise # Don't retry for size errors
394
+
395
+ except Exception as e:
396
+ last_error = e
397
+ log_error(
398
+ f"❌ Unexpected API error",
399
+ api=api.name,
400
+ error=str(e),
401
+ attempt=attempt + 1
402
+ )
403
+
404
+ # Retry backoff
405
+ if attempt < retry_count:
406
+ backoff = api.retry.backoff_seconds if api.retry else 2
407
+ if api.retry and api.retry.strategy == "exponential":
408
+ backoff = backoff * (2 ** attempt)
409
+ log_info(f"⏳ Retry backoff", wait_seconds=backoff, next_attempt=attempt + 2)
410
+ time.sleep(backoff)
411
+
412
+ # All retries failed
413
+ error_msg = f"API call failed after {retry_count + 1} attempts"
414
+ log_error(error_msg, api=api.name, last_error=str(last_error))
415
+
416
+ if last_error:
417
+ raise last_error
418
+ raise requests.exceptions.RequestException(error_msg)
419
+
420
+ def format_size(size_bytes: int) -> str:
421
+ """Format bytes to human readable format"""
422
+ for unit in ['B', 'KB', 'MB', 'GB']:
423
+ if size_bytes < 1024.0:
424
+ return f"{size_bytes:.2f} {unit}"
425
+ size_bytes /= 1024.0
426
  return f"{size_bytes:.2f} TB"
app.py CHANGED
@@ -1,388 +1,388 @@
1
- """
2
- Flare – Main Application (Refactored)
3
- =====================================
4
- """
5
- # FastAPI imports
6
- from fastapi import FastAPI, WebSocket, Request, status
7
- from fastapi.staticfiles import StaticFiles
8
- from fastapi.responses import FileResponse, JSONResponse
9
- from fastapi.middleware.cors import CORSMiddleware
10
- from fastapi.encoders import jsonable_encoder
11
-
12
- # Standard library
13
- import uvicorn
14
- import os
15
- from pathlib import Path
16
- import mimetypes
17
- import uuid
18
- import traceback
19
- from datetime import datetime
20
- from pydantic import ValidationError
21
- from dotenv import load_dotenv
22
-
23
- # Project imports
24
- from websocket_handler import websocket_endpoint
25
- from admin_routes import router as admin_router, start_cleanup_task
26
- from llm_startup import run_in_thread
27
- from session import session_store, start_session_cleanup
28
- from config_provider import ConfigProvider
29
-
30
- # Logger imports (utils.log yerine)
31
- from logger import log_error, log_info, log_warning
32
-
33
- # Exception imports
34
- from exceptions import (
35
- DuplicateResourceError,
36
- RaceConditionError,
37
- ValidationError,
38
- ResourceNotFoundError,
39
- AuthenticationError,
40
- AuthorizationError,
41
- ConfigurationError,
42
- get_http_status_code
43
- )
44
-
45
- # Load .env file if exists
46
- load_dotenv()
47
-
48
- ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200").split(",")
49
-
50
- # ===================== Environment Setup =====================
51
- def setup_environment():
52
- """Setup environment based on deployment mode"""
53
- cfg = ConfigProvider.get()
54
-
55
- log_info("=" * 60)
56
- log_info("🚀 Flare Starting", version="2.0.0")
57
- log_info(f"🔌 LLM Provider: {cfg.global_config.llm_provider.name}")
58
- log_info(f"🎤 TTS Provider: {cfg.global_config.tts_provider.name}")
59
- log_info(f"🎧 STT Provider: {cfg.global_config.stt_provider.name}")
60
- log_info("=" * 60)
61
-
62
- if cfg.global_config.is_cloud_mode():
63
- log_info("☁️ Cloud Mode: Using HuggingFace Secrets")
64
- log_info("📌 Required secrets: JWT_SECRET, FLARE_TOKEN_KEY")
65
-
66
- # Check for provider-specific tokens
67
- llm_config = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
68
- if llm_config and llm_config.requires_repo_info:
69
- log_info("📌 LLM requires SPARK_TOKEN for repository operations")
70
- else:
71
- log_info("🏢 On-Premise Mode: Using .env file")
72
- if not Path(".env").exists():
73
- log_warning("⚠️ WARNING: .env file not found!")
74
- log_info("📌 Copy .env.example to .env and configure it")
75
-
76
- # Run setup
77
- setup_environment()
78
-
79
- # Fix MIME types for JavaScript files
80
- mimetypes.add_type("application/javascript", ".js")
81
- mimetypes.add_type("text/css", ".css")
82
-
83
- app = FastAPI(
84
- title="Flare Orchestration Service",
85
- version="2.0.0",
86
- description="LLM-driven intent & API flow engine with multi-provider support",
87
- )
88
-
89
- # CORS for development
90
- if os.getenv("ENVIRONMENT", "development") == "development":
91
- app.add_middleware(
92
- CORSMiddleware,
93
- allow_origins=ALLOWED_ORIGINS,
94
- allow_credentials=True,
95
- allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
96
- allow_headers=["*"],
97
- max_age=3600,
98
- expose_headers=["X-Request-ID"]
99
- )
100
- log_info(f"🔧 CORS enabled for origins: {ALLOWED_ORIGINS}")
101
-
102
- # Request ID middleware
103
- @app.middleware("http")
104
- async def add_request_id(request: Request, call_next):
105
- """Add request ID for tracking"""
106
- request_id = str(uuid.uuid4())
107
- request.state.request_id = request_id
108
-
109
- # Log request start
110
- log_info(
111
- "Request started",
112
- request_id=request_id,
113
- method=request.method,
114
- path=request.url.path,
115
- client=request.client.host if request.client else "unknown"
116
- )
117
-
118
- try:
119
- response = await call_next(request)
120
-
121
- # Add request ID to response headers
122
- response.headers["X-Request-ID"] = request_id
123
-
124
- # Log request completion
125
- log_info(
126
- "Request completed",
127
- request_id=request_id,
128
- status_code=response.status_code,
129
- method=request.method,
130
- path=request.url.path
131
- )
132
-
133
- return response
134
- except Exception as e:
135
- log_error(
136
- "Request failed",
137
- request_id=request_id,
138
- error=str(e),
139
- traceback=traceback.format_exc()
140
- )
141
- raise
142
-
143
- run_in_thread() # Start LLM startup notifier if needed
144
- start_cleanup_task() # Activity log cleanup
145
- start_session_cleanup() # Session cleanup
146
-
147
- # ---------------- Core chat/session routes --------------------------
148
- from chat_handler import router as chat_router
149
- app.include_router(chat_router, prefix="/api")
150
-
151
- # ---------------- Audio (TTS/STT) routes ------------------------------
152
- from audio_routes import router as audio_router
153
- app.include_router(audio_router, prefix="/api")
154
-
155
- # ---------------- Admin API routes ----------------------------------
156
- app.include_router(admin_router, prefix="/api/admin")
157
-
158
- # ---------------- Exception Handlers ----------------------------------
159
- # Add global exception handler
160
- @app.exception_handler(Exception)
161
- async def global_exception_handler(request: Request, exc: Exception):
162
- """Handle all unhandled exceptions"""
163
- request_id = getattr(request.state, 'request_id', 'unknown')
164
-
165
- # Log the full exception
166
- log_error(
167
- "Unhandled exception",
168
- request_id=request_id,
169
- endpoint=str(request.url),
170
- method=request.method,
171
- error=str(exc),
172
- error_type=type(exc).__name__,
173
- traceback=traceback.format_exc()
174
- )
175
-
176
- # Special handling for FlareExceptions
177
- if isinstance(exc, FlareException):
178
- status_code = get_http_status_code(exc)
179
- response_body = format_error_response(exc, request_id)
180
-
181
- # Special message for race conditions
182
- if isinstance(exc, RaceConditionError):
183
- response_body["user_action"] = "Please reload the data and try again"
184
-
185
- return JSONResponse(
186
- status_code=status_code,
187
- content=jsonable_encoder(response_body)
188
- )
189
-
190
- # Generic error response
191
- return JSONResponse(
192
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
193
- content=jsonable_encoder({
194
- "error": "InternalServerError",
195
- "message": "An unexpected error occurred. Please try again later.",
196
- "request_id": request_id,
197
- "timestamp": datetime.utcnow().isoformat()
198
- })
199
- )
200
-
201
- # Add custom exception handlers
202
- @app.exception_handler(DuplicateResourceError)
203
- async def duplicate_resource_handler(request: Request, exc: DuplicateResourceError):
204
- """Handle duplicate resource errors"""
205
- return JSONResponse(
206
- status_code=409,
207
- content={
208
- "detail": str(exc),
209
- "error_type": "duplicate_resource",
210
- "resource_type": exc.details.get("resource_type"),
211
- "identifier": exc.details.get("identifier")
212
- }
213
- )
214
-
215
- @app.exception_handler(RaceConditionError)
216
- async def race_condition_handler(request: Request, exc: RaceConditionError):
217
- """Handle race condition errors"""
218
- return JSONResponse(
219
- status_code=409,
220
- content=exc.to_http_detail()
221
- )
222
-
223
- @app.exception_handler(ValidationError)
224
- async def validation_error_handler(request: Request, exc: ValidationError):
225
- """Handle validation errors"""
226
- return JSONResponse(
227
- status_code=422,
228
- content={
229
- "detail": str(exc),
230
- "error_type": "validation_error",
231
- "details": exc.details
232
- }
233
- )
234
-
235
- @app.exception_handler(ResourceNotFoundError)
236
- async def resource_not_found_handler(request: Request, exc: ResourceNotFoundError):
237
- """Handle resource not found errors"""
238
- return JSONResponse(
239
- status_code=404,
240
- content={
241
- "detail": str(exc),
242
- "error_type": "resource_not_found",
243
- "resource_type": exc.details.get("resource_type"),
244
- "identifier": exc.details.get("identifier")
245
- }
246
- )
247
-
248
- @app.exception_handler(AuthenticationError)
249
- async def authentication_error_handler(request: Request, exc: AuthenticationError):
250
- """Handle authentication errors"""
251
- return JSONResponse(
252
- status_code=401,
253
- content={
254
- "detail": str(exc),
255
- "error_type": "authentication_error"
256
- }
257
- )
258
-
259
- @app.exception_handler(AuthorizationError)
260
- async def authorization_error_handler(request: Request, exc: AuthorizationError):
261
- """Handle authorization errors"""
262
- return JSONResponse(
263
- status_code=403,
264
- content={
265
- "detail": str(exc),
266
- "error_type": "authorization_error"
267
- }
268
- )
269
-
270
- @app.exception_handler(ConfigurationError)
271
- async def configuration_error_handler(request: Request, exc: ConfigurationError):
272
- """Handle configuration errors"""
273
- return JSONResponse(
274
- status_code=500,
275
- content={
276
- "detail": str(exc),
277
- "error_type": "configuration_error",
278
- "config_key": exc.details.get("config_key")
279
- }
280
- )
281
-
282
- # ---------------- Metrics endpoint -----------------
283
- @app.get("/metrics")
284
- async def get_metrics():
285
- """Get system metrics"""
286
- import psutil
287
- import gc
288
-
289
- # Memory info
290
- process = psutil.Process()
291
- memory_info = process.memory_info()
292
-
293
- # Session stats
294
- session_stats = session_store.get_session_stats()
295
-
296
- metrics = {
297
- "memory": {
298
- "rss_mb": memory_info.rss / 1024 / 1024,
299
- "vms_mb": memory_info.vms / 1024 / 1024,
300
- "percent": process.memory_percent()
301
- },
302
- "cpu": {
303
- "percent": process.cpu_percent(interval=0.1),
304
- "num_threads": process.num_threads()
305
- },
306
- "sessions": session_stats,
307
- "gc": {
308
- "collections": gc.get_count(),
309
- "objects": len(gc.get_objects())
310
- },
311
- "uptime_seconds": time.time() - process.create_time()
312
- }
313
-
314
- return metrics
315
-
316
- # ---------------- Health probe (HF Spaces watchdog) -----------------
317
- @app.get("/api/health")
318
- def health_check():
319
- """Health check endpoint - moved to /api/health"""
320
- return {
321
- "status": "ok",
322
- "version": "2.0.0",
323
- "timestamp": datetime.utcnow().isoformat(),
324
- "environment": os.getenv("ENVIRONMENT", "development")
325
- }
326
-
327
- # ---------------- WebSocket route for real-time STT ------------------
328
- @app.websocket("/ws/conversation/{session_id}")
329
- async def conversation_websocket(websocket: WebSocket, session_id: str):
330
- await websocket_endpoint(websocket, session_id)
331
-
332
- # ---------------- Serve static files ------------------------------------
333
- # UI static files (production build)
334
- static_path = Path(__file__).parent / "static"
335
- if static_path.exists():
336
- app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
337
-
338
- # Serve index.html for all non-API routes (SPA support)
339
- @app.get("/", response_class=FileResponse)
340
- async def serve_index():
341
- """Serve Angular app"""
342
- index_path = static_path / "index.html"
343
- if index_path.exists():
344
- return FileResponse(str(index_path))
345
- else:
346
- return JSONResponse(
347
- status_code=404,
348
- content={"error": "UI not found. Please build the Angular app first."}
349
- )
350
-
351
- # Catch-all route for SPA
352
- @app.get("/{full_path:path}")
353
- async def serve_spa(full_path: str):
354
- """Serve Angular app for all routes"""
355
- # Skip API routes
356
- if full_path.startswith("api/"):
357
- return JSONResponse(status_code=404, content={"error": "Not found"})
358
-
359
- # Serve static files
360
- file_path = static_path / full_path
361
- if file_path.exists() and file_path.is_file():
362
- return FileResponse(str(file_path))
363
-
364
- # Fallback to index.html for SPA routing
365
- index_path = static_path / "index.html"
366
- if index_path.exists():
367
- return FileResponse(str(index_path))
368
-
369
- return JSONResponse(status_code=404, content={"error": "Not found"})
370
- else:
371
- log_warning(f"⚠️ Static files directory not found at {static_path}")
372
- log_warning(" Run 'npm run build' in flare-ui directory to build the UI")
373
-
374
- @app.get("/")
375
- async def no_ui():
376
- """No UI available"""
377
- return JSONResponse(
378
- status_code=503,
379
- content={
380
- "error": "UI not available",
381
- "message": "Please build the Angular UI first. Run: cd flare-ui && npm run build",
382
- "api_docs": "/docs"
383
- }
384
- )
385
-
386
- if __name__ == "__main__":
387
- log_info("🌐 Starting Flare backend on port 7860...")
388
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ """
2
+ Flare – Main Application (Refactored)
3
+ =====================================
4
+ """
5
+ # FastAPI imports
6
+ from fastapi import FastAPI, WebSocket, Request, status
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi.responses import FileResponse, JSONResponse
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.encoders import jsonable_encoder
11
+
12
+ # Standard library
13
+ import uvicorn
14
+ import os
15
+ from pathlib import Path
16
+ import mimetypes
17
+ import uuid
18
+ import traceback
19
+ from datetime import datetime
20
+ from pydantic import ValidationError
21
+ from dotenv import load_dotenv
22
+
23
+ # Project imports
24
+ from routes.websocket_handler import websocket_endpoint
25
+ from routes.admin_routes import router as admin_router, start_cleanup_task
26
+ from llm.llm_startup import run_in_thread
27
+ from session import session_store, start_session_cleanup
28
+ from config.config_provider import ConfigProvider
29
+
30
+ # Logger imports (utils.log yerine)
31
+ from utils.logger import log_error, log_info, log_warning
32
+
33
+ # Exception imports
34
+ from utils.exceptions import (
35
+ DuplicateResourceError,
36
+ RaceConditionError,
37
+ ValidationError,
38
+ ResourceNotFoundError,
39
+ AuthenticationError,
40
+ AuthorizationError,
41
+ ConfigurationError,
42
+ get_http_status_code
43
+ )
44
+
45
+ # Load .env file if exists
46
+ load_dotenv()
47
+
48
+ ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200").split(",")
49
+
50
+ # ===================== Environment Setup =====================
51
+ def setup_environment():
52
+ """Setup environment based on deployment mode"""
53
+ cfg = ConfigProvider.get()
54
+
55
+ log_info("=" * 60)
56
+ log_info("🚀 Flare Starting", version="2.0.0")
57
+ log_info(f"🔌 LLM Provider: {cfg.global_config.llm_provider.name}")
58
+ log_info(f"🎤 TTS Provider: {cfg.global_config.tts_provider.name}")
59
+ log_info(f"🎧 STT Provider: {cfg.global_config.stt_provider.name}")
60
+ log_info("=" * 60)
61
+
62
+ if cfg.global_config.is_cloud_mode():
63
+ log_info("☁️ Cloud Mode: Using HuggingFace Secrets")
64
+ log_info("📌 Required secrets: JWT_SECRET, FLARE_TOKEN_KEY")
65
+
66
+ # Check for provider-specific tokens
67
+ llm_config = cfg.global_config.get_provider_config("llm", cfg.global_config.llm_provider.name)
68
+ if llm_config and llm_config.requires_repo_info:
69
+ log_info("📌 LLM requires SPARK_TOKEN for repository operations")
70
+ else:
71
+ log_info("🏢 On-Premise Mode: Using .env file")
72
+ if not Path(".env").exists():
73
+ log_warning("⚠️ WARNING: .env file not found!")
74
+ log_info("📌 Copy .env.example to .env and configure it")
75
+
76
+ # Run setup
77
+ setup_environment()
78
+
79
+ # Fix MIME types for JavaScript files
80
+ mimetypes.add_type("application/javascript", ".js")
81
+ mimetypes.add_type("text/css", ".css")
82
+
83
+ app = FastAPI(
84
+ title="Flare Orchestration Service",
85
+ version="2.0.0",
86
+ description="LLM-driven intent & API flow engine with multi-provider support",
87
+ )
88
+
89
+ # CORS for development
90
+ if os.getenv("ENVIRONMENT", "development") == "development":
91
+ app.add_middleware(
92
+ CORSMiddleware,
93
+ allow_origins=ALLOWED_ORIGINS,
94
+ allow_credentials=True,
95
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
96
+ allow_headers=["*"],
97
+ max_age=3600,
98
+ expose_headers=["X-Request-ID"]
99
+ )
100
+ log_info(f"🔧 CORS enabled for origins: {ALLOWED_ORIGINS}")
101
+
102
+ # Request ID middleware
103
+ @app.middleware("http")
104
+ async def add_request_id(request: Request, call_next):
105
+ """Add request ID for tracking"""
106
+ request_id = str(uuid.uuid4())
107
+ request.state.request_id = request_id
108
+
109
+ # Log request start
110
+ log_info(
111
+ "Request started",
112
+ request_id=request_id,
113
+ method=request.method,
114
+ path=request.url.path,
115
+ client=request.client.host if request.client else "unknown"
116
+ )
117
+
118
+ try:
119
+ response = await call_next(request)
120
+
121
+ # Add request ID to response headers
122
+ response.headers["X-Request-ID"] = request_id
123
+
124
+ # Log request completion
125
+ log_info(
126
+ "Request completed",
127
+ request_id=request_id,
128
+ status_code=response.status_code,
129
+ method=request.method,
130
+ path=request.url.path
131
+ )
132
+
133
+ return response
134
+ except Exception as e:
135
+ log_error(
136
+ "Request failed",
137
+ request_id=request_id,
138
+ error=str(e),
139
+ traceback=traceback.format_exc()
140
+ )
141
+ raise
142
+
143
+ run_in_thread() # Start LLM startup notifier if needed
144
+ start_cleanup_task() # Activity log cleanup
145
+ start_session_cleanup() # Session cleanup
146
+
147
+ # ---------------- Core chat/session routes --------------------------
148
+ from routes.chat_handler import router as chat_router
149
+ app.include_router(chat_router, prefix="/api")
150
+
151
+ # ---------------- Audio (TTS/STT) routes ------------------------------
152
+ from routes.audio_routes import router as audio_router
153
+ app.include_router(audio_router, prefix="/api")
154
+
155
+ # ---------------- Admin API routes ----------------------------------
156
+ app.include_router(admin_router, prefix="/api/admin")
157
+
158
+ # ---------------- Exception Handlers ----------------------------------
159
+ # Add global exception handler
160
+ @app.exception_handler(Exception)
161
+ async def global_exception_handler(request: Request, exc: Exception):
162
+ """Handle all unhandled exceptions"""
163
+ request_id = getattr(request.state, 'request_id', 'unknown')
164
+
165
+ # Log the full exception
166
+ log_error(
167
+ "Unhandled exception",
168
+ request_id=request_id,
169
+ endpoint=str(request.url),
170
+ method=request.method,
171
+ error=str(exc),
172
+ error_type=type(exc).__name__,
173
+ traceback=traceback.format_exc()
174
+ )
175
+
176
+ # Special handling for FlareExceptions
177
+ if isinstance(exc, FlareException):
178
+ status_code = get_http_status_code(exc)
179
+ response_body = format_error_response(exc, request_id)
180
+
181
+ # Special message for race conditions
182
+ if isinstance(exc, RaceConditionError):
183
+ response_body["user_action"] = "Please reload the data and try again"
184
+
185
+ return JSONResponse(
186
+ status_code=status_code,
187
+ content=jsonable_encoder(response_body)
188
+ )
189
+
190
+ # Generic error response
191
+ return JSONResponse(
192
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
193
+ content=jsonable_encoder({
194
+ "error": "InternalServerError",
195
+ "message": "An unexpected error occurred. Please try again later.",
196
+ "request_id": request_id,
197
+ "timestamp": datetime.utcnow().isoformat()
198
+ })
199
+ )
200
+
201
+ # Add custom exception handlers
202
+ @app.exception_handler(DuplicateResourceError)
203
+ async def duplicate_resource_handler(request: Request, exc: DuplicateResourceError):
204
+ """Handle duplicate resource errors"""
205
+ return JSONResponse(
206
+ status_code=409,
207
+ content={
208
+ "detail": str(exc),
209
+ "error_type": "duplicate_resource",
210
+ "resource_type": exc.details.get("resource_type"),
211
+ "identifier": exc.details.get("identifier")
212
+ }
213
+ )
214
+
215
+ @app.exception_handler(RaceConditionError)
216
+ async def race_condition_handler(request: Request, exc: RaceConditionError):
217
+ """Handle race condition errors"""
218
+ return JSONResponse(
219
+ status_code=409,
220
+ content=exc.to_http_detail()
221
+ )
222
+
223
+ @app.exception_handler(ValidationError)
224
+ async def validation_error_handler(request: Request, exc: ValidationError):
225
+ """Handle validation errors"""
226
+ return JSONResponse(
227
+ status_code=422,
228
+ content={
229
+ "detail": str(exc),
230
+ "error_type": "validation_error",
231
+ "details": exc.details
232
+ }
233
+ )
234
+
235
+ @app.exception_handler(ResourceNotFoundError)
236
+ async def resource_not_found_handler(request: Request, exc: ResourceNotFoundError):
237
+ """Handle resource not found errors"""
238
+ return JSONResponse(
239
+ status_code=404,
240
+ content={
241
+ "detail": str(exc),
242
+ "error_type": "resource_not_found",
243
+ "resource_type": exc.details.get("resource_type"),
244
+ "identifier": exc.details.get("identifier")
245
+ }
246
+ )
247
+
248
+ @app.exception_handler(AuthenticationError)
249
+ async def authentication_error_handler(request: Request, exc: AuthenticationError):
250
+ """Handle authentication errors"""
251
+ return JSONResponse(
252
+ status_code=401,
253
+ content={
254
+ "detail": str(exc),
255
+ "error_type": "authentication_error"
256
+ }
257
+ )
258
+
259
+ @app.exception_handler(AuthorizationError)
260
+ async def authorization_error_handler(request: Request, exc: AuthorizationError):
261
+ """Handle authorization errors"""
262
+ return JSONResponse(
263
+ status_code=403,
264
+ content={
265
+ "detail": str(exc),
266
+ "error_type": "authorization_error"
267
+ }
268
+ )
269
+
270
+ @app.exception_handler(ConfigurationError)
271
+ async def configuration_error_handler(request: Request, exc: ConfigurationError):
272
+ """Handle configuration errors"""
273
+ return JSONResponse(
274
+ status_code=500,
275
+ content={
276
+ "detail": str(exc),
277
+ "error_type": "configuration_error",
278
+ "config_key": exc.details.get("config_key")
279
+ }
280
+ )
281
+
282
+ # ---------------- Metrics endpoint -----------------
283
+ @app.get("/metrics")
284
+ async def get_metrics():
285
+ """Get system metrics"""
286
+ import psutil
287
+ import gc
288
+
289
+ # Memory info
290
+ process = psutil.Process()
291
+ memory_info = process.memory_info()
292
+
293
+ # Session stats
294
+ session_stats = session_store.get_session_stats()
295
+
296
+ metrics = {
297
+ "memory": {
298
+ "rss_mb": memory_info.rss / 1024 / 1024,
299
+ "vms_mb": memory_info.vms / 1024 / 1024,
300
+ "percent": process.memory_percent()
301
+ },
302
+ "cpu": {
303
+ "percent": process.cpu_percent(interval=0.1),
304
+ "num_threads": process.num_threads()
305
+ },
306
+ "sessions": session_stats,
307
+ "gc": {
308
+ "collections": gc.get_count(),
309
+ "objects": len(gc.get_objects())
310
+ },
311
+ "uptime_seconds": time.time() - process.create_time()
312
+ }
313
+
314
+ return metrics
315
+
316
+ # ---------------- Health probe (HF Spaces watchdog) -----------------
317
+ @app.get("/api/health")
318
+ def health_check():
319
+ """Health check endpoint - moved to /api/health"""
320
+ return {
321
+ "status": "ok",
322
+ "version": "2.0.0",
323
+ "timestamp": datetime.utcnow().isoformat(),
324
+ "environment": os.getenv("ENVIRONMENT", "development")
325
+ }
326
+
327
+ # ---------------- WebSocket route for real-time STT ------------------
328
+ @app.websocket("/ws/conversation/{session_id}")
329
+ async def conversation_websocket(websocket: WebSocket, session_id: str):
330
+ await websocket_endpoint(websocket, session_id)
331
+
332
+ # ---------------- Serve static files ------------------------------------
333
+ # UI static files (production build)
334
+ static_path = Path(__file__).parent / "static"
335
+ if static_path.exists():
336
+ app.mount("/static", StaticFiles(directory=str(static_path)), name="static")
337
+
338
+ # Serve index.html for all non-API routes (SPA support)
339
+ @app.get("/", response_class=FileResponse)
340
+ async def serve_index():
341
+ """Serve Angular app"""
342
+ index_path = static_path / "index.html"
343
+ if index_path.exists():
344
+ return FileResponse(str(index_path))
345
+ else:
346
+ return JSONResponse(
347
+ status_code=404,
348
+ content={"error": "UI not found. Please build the Angular app first."}
349
+ )
350
+
351
+ # Catch-all route for SPA
352
+ @app.get("/{full_path:path}")
353
+ async def serve_spa(full_path: str):
354
+ """Serve Angular app for all routes"""
355
+ # Skip API routes
356
+ if full_path.startswith("api/"):
357
+ return JSONResponse(status_code=404, content={"error": "Not found"})
358
+
359
+ # Serve static files
360
+ file_path = static_path / full_path
361
+ if file_path.exists() and file_path.is_file():
362
+ return FileResponse(str(file_path))
363
+
364
+ # Fallback to index.html for SPA routing
365
+ index_path = static_path / "index.html"
366
+ if index_path.exists():
367
+ return FileResponse(str(index_path))
368
+
369
+ return JSONResponse(status_code=404, content={"error": "Not found"})
370
+ else:
371
+ log_warning(f"⚠️ Static files directory not found at {static_path}")
372
+ log_warning(" Run 'npm run build' in flare-ui directory to build the UI")
373
+
374
+ @app.get("/")
375
+ async def no_ui():
376
+ """No UI available"""
377
+ return JSONResponse(
378
+ status_code=503,
379
+ content={
380
+ "error": "UI not available",
381
+ "message": "Please build the Angular UI first. Run: cd flare-ui && npm run build",
382
+ "api_docs": "/docs"
383
+ }
384
+ )
385
+
386
+ if __name__ == "__main__":
387
+ log_info("🌐 Starting Flare backend on port 7860...")
388
  uvicorn.run(app, host="0.0.0.0", port=7860)
config/config_provider.py CHANGED
@@ -13,24 +13,24 @@ import shutil
13
  from utils import get_current_timestamp, normalize_timestamp, timestamps_equal
14
 
15
  from config_models import (
16
- ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig,
17
  IntentConfig, APIConfig, ActivityLogEntry, ParameterConfig,
18
  LLMConfiguration, GenerationConfig
19
  )
20
- from logger import log_info, log_error, log_warning, log_debug, LogTimer
21
- from exceptions import (
22
  RaceConditionError, ConfigurationError, ResourceNotFoundError,
23
  DuplicateResourceError, ValidationError
24
  )
25
- from encryption_utils import encrypt, decrypt
26
 
27
  class ConfigProvider:
28
  """Thread-safe singleton configuration provider"""
29
-
30
  _instance: Optional[ServiceConfig] = None
31
  _lock = threading.RLock() # Reentrant lock for nested calls
32
  _file_lock = threading.Lock() # Separate lock for file operations
33
- _CONFIG_PATH = Path(__file__).parent / "service_config.jsonc"
34
 
35
  @staticmethod
36
  def _normalize_date(date_str: Optional[str]) -> str:
@@ -51,7 +51,7 @@ class ConfigProvider:
51
  cls._instance.build_index()
52
  log_info("Configuration loaded successfully")
53
  return cls._instance
54
-
55
  @classmethod
56
  def reload(cls) -> ServiceConfig:
57
  """Force reload configuration from file"""
@@ -59,7 +59,7 @@ class ConfigProvider:
59
  log_info("Reloading configuration...")
60
  cls._instance = None
61
  return cls.get()
62
-
63
  @classmethod
64
  def _load(cls) -> ServiceConfig:
65
  """Load configuration from file"""
@@ -69,15 +69,15 @@ class ConfigProvider:
69
  f"Config file not found: {cls._CONFIG_PATH}",
70
  config_key="service_config.jsonc"
71
  )
72
-
73
  with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f:
74
  config_data = commentjson.load(f)
75
-
76
  # Debug: İlk project'in tarihini kontrol et
77
  if 'projects' in config_data and len(config_data['projects']) > 0:
78
  first_project = config_data['projects'][0]
79
  log_debug(f"🔍 Raw project data - last_update_date: {first_project.get('last_update_date')}")
80
-
81
  # Ensure required fields
82
  if 'config' not in config_data:
83
  config_data['config'] = {}
@@ -88,10 +88,10 @@ class ConfigProvider:
88
  # Parse API configs (handle JSON strings)
89
  if 'apis' in config_data:
90
  cls._parse_api_configs(config_data['apis'])
91
-
92
  # Validate and create model
93
  cfg = ServiceConfig.model_validate(config_data)
94
-
95
  # Debug: Model'e dönüştükten sonra kontrol et
96
  if cfg.projects and len(cfg.projects) > 0:
97
  log_debug(f"🔍 Parsed project - last_update_date: {cfg.projects[0].last_update_date}")
@@ -100,20 +100,20 @@ class ConfigProvider:
100
  # Log versions published status after parsing
101
  for version in cfg.projects[0].versions:
102
  log_debug(f"🔍 Parsed version {version.no} - published: {version.published} (type: {type(version.published)})")
103
-
104
  log_debug(
105
  "Configuration loaded",
106
  projects=len(cfg.projects),
107
  apis=len(cfg.apis),
108
  users=len(cfg.global_config.users)
109
  )
110
-
111
  return cfg
112
-
113
  except Exception as e:
114
  log_error(f"Error loading config", error=str(e), path=str(cls._CONFIG_PATH))
115
  raise ConfigurationError(f"Failed to load configuration: {e}")
116
-
117
  @classmethod
118
  def _parse_api_configs(cls, apis: List[Dict[str, Any]]) -> None:
119
  """Parse JSON string fields in API configs"""
@@ -124,18 +124,18 @@ class ConfigProvider:
124
  api['headers'] = json.loads(api['headers'])
125
  except json.JSONDecodeError:
126
  api['headers'] = {}
127
-
128
  # Parse body_template
129
  if 'body_template' in api and isinstance(api['body_template'], str):
130
  try:
131
  api['body_template'] = json.loads(api['body_template'])
132
  except json.JSONDecodeError:
133
  api['body_template'] = {}
134
-
135
  # Parse auth configs
136
  if 'auth' in api and api['auth']:
137
  cls._parse_auth_config(api['auth'])
138
-
139
  @classmethod
140
  def _parse_auth_config(cls, auth: Dict[str, Any]) -> None:
141
  """Parse auth configuration"""
@@ -145,14 +145,14 @@ class ConfigProvider:
145
  auth['token_request_body'] = json.loads(auth['token_request_body'])
146
  except json.JSONDecodeError:
147
  auth['token_request_body'] = {}
148
-
149
  # Parse token_refresh_body
150
  if 'token_refresh_body' in auth and isinstance(auth['token_refresh_body'], str):
151
  try:
152
  auth['token_refresh_body'] = json.loads(auth['token_refresh_body'])
153
  except json.JSONDecodeError:
154
  auth['token_refresh_body'] = {}
155
-
156
  @classmethod
157
  def save(cls, config: ServiceConfig, username: str) -> None:
158
  """Thread-safe configuration save with optimistic locking"""
@@ -160,11 +160,11 @@ class ConfigProvider:
160
  try:
161
  # Convert to dict for JSON serialization
162
  config_dict = config.model_dump()
163
-
164
  # Load current config for race condition check
165
  try:
166
  current_config = cls._load()
167
-
168
  # Check for race condition
169
  if config.last_update_date and current_config.last_update_date:
170
  if not timestamps_equal(config.last_update_date, current_config.last_update_date):
@@ -179,89 +179,89 @@ class ConfigProvider:
179
  # Eğer mevcut config yüklenemiyorsa, race condition kontrolünü atla
180
  log_warning(f"Could not load current config for race condition check: {e}")
181
  current_config = None
182
-
183
  # Update metadata
184
  config.last_update_date = get_current_timestamp()
185
  config.last_update_user = username
186
-
187
  # Convert to JSON - Pydantic v2 kullanımı
188
  data = config.model_dump(mode='json')
189
  json_str = json.dumps(data, ensure_ascii=False, indent=2)
190
-
191
  # Backup current file if exists
192
  backup_path = None
193
  if cls._CONFIG_PATH.exists():
194
  backup_path = cls._CONFIG_PATH.with_suffix('.backup')
195
  shutil.copy2(str(cls._CONFIG_PATH), str(backup_path))
196
  log_debug(f"Created backup at {backup_path}")
197
-
198
  try:
199
  # Write to temporary file first
200
  temp_path = cls._CONFIG_PATH.with_suffix('.tmp')
201
  with open(temp_path, 'w', encoding='utf-8') as f:
202
  f.write(json_str)
203
-
204
  # Validate the temp file by trying to load it
205
  with open(temp_path, 'r', encoding='utf-8') as f:
206
  test_data = commentjson.load(f)
207
  ServiceConfig.model_validate(test_data)
208
-
209
  # If validation passes, replace the original
210
  shutil.move(str(temp_path), str(cls._CONFIG_PATH))
211
-
212
  # Delete backup if save successful
213
  if backup_path and backup_path.exists():
214
  backup_path.unlink()
215
-
216
  except Exception as e:
217
  # Restore from backup if something went wrong
218
  if backup_path and backup_path.exists():
219
  shutil.move(str(backup_path), str(cls._CONFIG_PATH))
220
  log_error(f"Restored configuration from backup due to error: {e}")
221
  raise
222
-
223
  # Update cached instance
224
  with cls._lock:
225
  cls._instance = config
226
-
227
  log_info(
228
  "Configuration saved successfully",
229
  user=username,
230
  last_update=config.last_update_date
231
  )
232
-
233
  except Exception as e:
234
  log_error(f"Failed to save config", error=str(e))
235
  raise ConfigurationError(
236
  f"Failed to save configuration: {str(e)}",
237
  config_key="service_config.jsonc"
238
  )
239
-
240
  # ===================== Environment Methods =====================
241
-
242
  @classmethod
243
  def update_environment(cls, update_data: dict, username: str) -> None:
244
  """Update environment configuration"""
245
  with cls._lock:
246
  config = cls.get()
247
-
248
  # Update providers
249
  if 'llm_provider' in update_data:
250
  config.global_config.llm_provider = update_data['llm_provider']
251
-
252
  if 'tts_provider' in update_data:
253
  config.global_config.tts_provider = update_data['tts_provider']
254
-
255
  if 'stt_provider' in update_data:
256
  config.global_config.stt_provider = update_data['stt_provider']
257
-
258
  # Log activity
259
  cls._add_activity(
260
  config, username, "UPDATE_ENVIRONMENT",
261
  "environment", None,
262
  f"Updated providers"
263
  )
264
-
265
  # Save
266
  cls.save(config, username)
267
 
@@ -270,9 +270,9 @@ class ConfigProvider:
270
  """Ensure config has required provider structure"""
271
  if 'config' not in config_data:
272
  config_data['config'] = {}
273
-
274
  config = config_data['config']
275
-
276
  # Ensure provider settings exist
277
  if 'llm_provider' not in config:
278
  config['llm_provider'] = {
@@ -281,7 +281,7 @@ class ConfigProvider:
281
  'endpoint': 'http://localhost:8080',
282
  'settings': {}
283
  }
284
-
285
  if 'tts_provider' not in config:
286
  config['tts_provider'] = {
287
  'name': 'no_tts',
@@ -289,7 +289,7 @@ class ConfigProvider:
289
  'endpoint': None,
290
  'settings': {}
291
  }
292
-
293
  if 'stt_provider' not in config:
294
  config['stt_provider'] = {
295
  'name': 'no_stt',
@@ -297,7 +297,7 @@ class ConfigProvider:
297
  'endpoint': None,
298
  'settings': {}
299
  }
300
-
301
  # Ensure providers list exists
302
  if 'providers' not in config:
303
  config['providers'] = [
@@ -329,27 +329,27 @@ class ConfigProvider:
329
  "description": "Speech-to-Text disabled"
330
  }
331
  ]
332
-
333
  # ===================== Project Methods =====================
334
-
335
  @classmethod
336
  def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
337
  """Get project by ID"""
338
  config = cls.get()
339
  return next((p for p in config.projects if p.id == project_id), None)
340
-
341
  @classmethod
342
  def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
343
  """Create new project with initial version"""
344
  with cls._lock:
345
  config = cls.get()
346
-
347
  # Check for duplicate name
348
  existing_project = next((p for p in config.projects if p.name == project_data['name'] and not p.deleted), None)
349
  if existing_project:
350
  raise DuplicateResourceError("Project", project_data['name'])
351
 
352
-
353
  # Create project
354
  project = ProjectConfig(
355
  id=config.project_id_counter,
@@ -359,7 +359,7 @@ class ConfigProvider:
359
  versions=[], # Boş başla
360
  **project_data
361
  )
362
-
363
  # Create initial version with proper models
364
  initial_version = VersionConfig(
365
  no=1,
@@ -387,46 +387,46 @@ class ConfigProvider:
387
  last_update_date=None,
388
  last_update_user=None,
389
  publish_date=None,
390
- published_by=None
391
  )
392
-
393
  # Add initial version to project
394
  project.versions.append(initial_version)
395
  project.version_id_counter = 2 # Next version will be 2
396
-
397
  # Update config
398
  config.projects.append(project)
399
  config.project_id_counter += 1
400
-
401
  # Log activity
402
  cls._add_activity(
403
  config, username, "CREATE_PROJECT",
404
  "project", project.name,
405
  f"Created with initial version"
406
  )
407
-
408
  # Save
409
  cls.save(config, username)
410
-
411
  log_info(
412
  "Project created with initial version",
413
  project_id=project.id,
414
  name=project.name,
415
  user=username
416
  )
417
-
418
  return project
419
-
420
  @classmethod
421
  def update_project(cls, project_id: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> ProjectConfig:
422
  """Update project with optimistic locking"""
423
  with cls._lock:
424
  config = cls.get()
425
  project = cls.get_project(project_id)
426
-
427
  if not project:
428
  raise ResourceNotFoundError("project", project_id)
429
-
430
  # Check race condition
431
  if expected_last_update is not None and expected_last_update != '':
432
  if project.last_update_date and not timestamps_equal(expected_last_update, project.last_update_date):
@@ -438,104 +438,104 @@ class ConfigProvider:
438
  entity_type="project",
439
  entity_id=project_id
440
  )
441
-
442
  # Update fields
443
  for key, value in update_data.items():
444
  if hasattr(project, key) and key not in ['id', 'created_date', 'created_by', 'last_update_date', 'last_update_user']:
445
  setattr(project, key, value)
446
-
447
  project.last_update_date = get_current_timestamp()
448
  project.last_update_user = username
449
-
450
  cls._add_activity(
451
  config, username, "UPDATE_PROJECT",
452
  "project", project.name
453
  )
454
-
455
  # Save
456
  cls.save(config, username)
457
-
458
  log_info(
459
  "Project updated",
460
  project_id=project.id,
461
  user=username
462
  )
463
-
464
  return project
465
-
466
  @classmethod
467
  def delete_project(cls, project_id: int, username: str) -> None:
468
  """Soft delete project"""
469
  with cls._lock:
470
  config = cls.get()
471
  project = cls.get_project(project_id)
472
-
473
  if not project:
474
  raise ResourceNotFoundError("project", project_id)
475
-
476
  project.deleted = True
477
  project.last_update_date = get_current_timestamp()
478
  project.last_update_user = username
479
-
480
  cls._add_activity(
481
  config, username, "DELETE_PROJECT",
482
  "project", project.name
483
  )
484
-
485
  # Save
486
  cls.save(config, username)
487
-
488
  log_info(
489
  "Project deleted",
490
  project_id=project.id,
491
  user=username
492
  )
493
-
494
  @classmethod
495
  def toggle_project(cls, project_id: int, username: str) -> bool:
496
  """Toggle project enabled status"""
497
  with cls._lock:
498
  config = cls.get()
499
  project = cls.get_project(project_id)
500
-
501
  if not project:
502
  raise ResourceNotFoundError("project", project_id)
503
-
504
  project.enabled = not project.enabled
505
  project.last_update_date = get_current_timestamp()
506
  project.last_update_user = username
507
-
508
  # Log activity
509
  cls._add_activity(
510
  config, username, "TOGGLE_PROJECT",
511
  "project", project.name,
512
  f"{'Enabled' if project.enabled else 'Disabled'}"
513
  )
514
-
515
  # Save
516
  cls.save(config, username)
517
-
518
  log_info(
519
  "Project toggled",
520
  project_id=project.id,
521
  enabled=project.enabled,
522
  user=username
523
  )
524
-
525
  return project.enabled
526
-
527
  # ===================== Version Methods =====================
528
-
529
  @classmethod
530
  def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig:
531
  """Create new version"""
532
  with cls._lock:
533
  config = cls.get()
534
  project = cls.get_project(project_id)
535
-
536
  if not project:
537
  raise ResourceNotFoundError("project", project_id)
538
-
539
  # Handle source version copy
540
  if 'source_version_no' in version_data and version_data['source_version_no']:
541
  source_version = next((v for v in project.versions if v.no == version_data['source_version_no']), None)
@@ -543,7 +543,7 @@ class ConfigProvider:
543
  # Copy from source version
544
  version_dict = source_version.model_dump()
545
  # Remove fields that shouldn't be copied
546
- for field in ['no', 'created_date', 'created_by', 'published', 'publish_date',
547
  'published_by', 'last_update_date', 'last_update_user']:
548
  version_dict.pop(field, None)
549
  # Override with provided data
@@ -586,7 +586,7 @@ class ConfigProvider:
586
  },
587
  'intents': []
588
  }
589
-
590
  # Create version
591
  version = VersionConfig(
592
  no=project.version_id_counter,
@@ -600,60 +600,60 @@ class ConfigProvider:
600
  published_by=None,
601
  **version_dict
602
  )
603
-
604
  # Update project
605
  project.versions.append(version)
606
  project.version_id_counter += 1
607
  project.last_update_date = get_current_timestamp()
608
  project.last_update_user = username
609
-
610
  # Log activity
611
  cls._add_activity(
612
  config, username, "CREATE_VERSION",
613
  "version", version.no, f"{project.name} v{version.no}",
614
  f"Project: {project.name}"
615
  )
616
-
617
  # Save
618
  cls.save(config, username)
619
-
620
  log_info(
621
  "Version created",
622
  project_id=project.id,
623
  version_no=version.no,
624
  user=username
625
  )
626
-
627
  return version
628
-
629
  @classmethod
630
  def publish_version(cls, project_id: int, version_no: int, username: str) -> tuple[ProjectConfig, VersionConfig]:
631
  """Publish a version"""
632
  with cls._lock:
633
  config = cls.get()
634
  project = cls.get_project(project_id)
635
-
636
  if not project:
637
  raise ResourceNotFoundError("project", project_id)
638
-
639
  version = next((v for v in project.versions if v.no == version_no), None)
640
  if not version:
641
  raise ResourceNotFoundError("version", version_no)
642
-
643
  # Unpublish other versions
644
  for v in project.versions:
645
  if v.published and v.no != version_no:
646
  v.published = False
647
-
648
  # Publish this version
649
  version.published = True
650
  version.publish_date = get_current_timestamp()
651
  version.published_by = username
652
-
653
  # Update project
654
  project.last_update_date = get_current_timestamp()
655
  project.last_update_user = username
656
-
657
  # Log activity
658
  cls._add_activity(
659
  config, username, "PUBLISH_VERSION",
@@ -662,14 +662,14 @@ class ConfigProvider:
662
 
663
  # Save
664
  cls.save(config, username)
665
-
666
  log_info(
667
  "Version published",
668
  project_id=project.id,
669
  version_no=version.no,
670
  user=username
671
  )
672
-
673
  return project, version
674
 
675
  @classmethod
@@ -678,22 +678,22 @@ class ConfigProvider:
678
  with cls._lock:
679
  config = cls.get()
680
  project = cls.get_project(project_id)
681
-
682
  if not project:
683
  raise ResourceNotFoundError("project", project_id)
684
-
685
  version = next((v for v in project.versions if v.no == version_no), None)
686
  if not version:
687
  raise ResourceNotFoundError("version", version_no)
688
-
689
  # Ensure published is a boolean (safety check)
690
  if version.published is None:
691
  version.published = False
692
-
693
  # Published versions cannot be edited
694
  if version.published:
695
  raise ValidationError("Published versions cannot be modified")
696
-
697
  # Check race condition
698
  if expected_last_update is not None and expected_last_update != '':
699
  if version.last_update_date and not timestamps_equal(expected_last_update, version.last_update_date):
@@ -704,8 +704,8 @@ class ConfigProvider:
704
  last_update_date=version.last_update_date,
705
  entity_type="version",
706
  entity_id=f"{project_id}:{version_no}"
707
- )
708
-
709
  # Update fields
710
  for key, value in update_data.items():
711
  if hasattr(version, key) and key not in ['no', 'created_date', 'created_by', 'published', 'last_update_date']:
@@ -723,125 +723,125 @@ class ConfigProvider:
723
  setattr(version, key, intents)
724
  else:
725
  setattr(version, key, value)
726
-
727
  version.last_update_date = get_current_timestamp()
728
  version.last_update_user = username
729
-
730
  # Update project last update
731
  project.last_update_date = get_current_timestamp()
732
  project.last_update_user = username
733
-
734
  # Log activity
735
  cls._add_activity(
736
  config, username, "UPDATE_VERSION",
737
  "version", f"{project.name} v{version.no}"
738
  )
739
-
740
  # Save
741
  cls.save(config, username)
742
-
743
  log_info(
744
  "Version updated",
745
  project_id=project.id,
746
  version_no=version.no,
747
  user=username
748
  )
749
-
750
  return version
751
-
752
  @classmethod
753
  def delete_version(cls, project_id: int, version_no: int, username: str) -> None:
754
  """Soft delete version"""
755
  with cls._lock:
756
  config = cls.get()
757
  project = cls.get_project(project_id)
758
-
759
  if not project:
760
  raise ResourceNotFoundError("project", project_id)
761
-
762
  version = next((v for v in project.versions if v.no == version_no), None)
763
  if not version:
764
  raise ResourceNotFoundError("version", version_no)
765
-
766
  if version.published:
767
  raise ValidationError("Cannot delete published version")
768
-
769
  version.deleted = True
770
  version.last_update_date = get_current_timestamp()
771
  version.last_update_user = username
772
-
773
  # Update project
774
  project.last_update_date = get_current_timestamp()
775
  project.last_update_user = username
776
-
777
  # Log activity
778
  cls._add_activity(
779
  config, username, "DELETE_VERSION",
780
  "version", f"{project.name} v{version.no}"
781
  )
782
-
783
  # Save
784
  cls.save(config, username)
785
-
786
  log_info(
787
  "Version deleted",
788
  project_id=project.id,
789
  version_no=version.no,
790
  user=username
791
  )
792
-
793
  # ===================== API Methods =====================
794
  @classmethod
795
  def create_api(cls, api_data: dict, username: str) -> APIConfig:
796
  """Create new API"""
797
  with cls._lock:
798
  config = cls.get()
799
-
800
  # Check for duplicate name
801
  existing_api = next((a for a in config.apis if a.name == api_data['name'] and not a.deleted), None)
802
  if existing_api:
803
  raise DuplicateResourceError("API", api_data['name'])
804
-
805
  # Create API
806
  api = APIConfig(
807
  created_date=get_current_timestamp(),
808
  created_by=username,
809
  **api_data
810
  )
811
-
812
  # Add to config
813
  config.apis.append(api)
814
-
815
  # Rebuild index
816
  config.build_index()
817
-
818
  # Log activity
819
  cls._add_activity(
820
  config, username, "CREATE_API",
821
  "api", api.name
822
  )
823
-
824
  # Save
825
  cls.save(config, username)
826
-
827
  log_info(
828
  "API created",
829
  api_name=api.name,
830
  user=username
831
  )
832
-
833
  return api
834
-
835
  @classmethod
836
  def update_api(cls, api_name: str, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> APIConfig:
837
  """Update API with optimistic locking"""
838
  with cls._lock:
839
  config = cls.get()
840
  api = config.get_api(api_name)
841
-
842
  if not api:
843
  raise ResourceNotFoundError("api", api_name)
844
-
845
  # Check race condition
846
  if expected_last_update is not None and expected_last_update != '':
847
  if api.last_update_date and not timestamps_equal(expected_last_update, api.last_update_date):
@@ -852,68 +852,68 @@ class ConfigProvider:
852
  last_update_date=api.last_update_date,
853
  entity_type="api",
854
  entity_id=api.name
855
- )
856
-
857
  # Update fields
858
  for key, value in update_data.items():
859
  if hasattr(api, key) and key not in ['name', 'created_date', 'created_by', 'last_update_date']:
860
  setattr(api, key, value)
861
-
862
  api.last_update_date = get_current_timestamp()
863
  api.last_update_user = username
864
-
865
  # Rebuild index
866
  config.build_index()
867
-
868
  # Log activity
869
  cls._add_activity(
870
  config, username, "UPDATE_API",
871
  "api", api.name
872
  )
873
-
874
  # Save
875
  cls.save(config, username)
876
-
877
  log_info(
878
  "API updated",
879
  api_name=api.name,
880
  user=username
881
  )
882
-
883
  return api
884
-
885
  @classmethod
886
  def delete_api(cls, api_name: str, username: str) -> None:
887
  """Soft delete API"""
888
  with cls._lock:
889
  config = cls.get()
890
  api = config.get_api(api_name)
891
-
892
  if not api:
893
  raise ResourceNotFoundError("api", api_name)
894
-
895
  api.deleted = True
896
  api.last_update_date = get_current_timestamp()
897
  api.last_update_user = username
898
-
899
  # Rebuild index
900
  config.build_index()
901
-
902
  # Log activity
903
  cls._add_activity(
904
  config, username, "DELETE_API",
905
  "api", api.name
906
  )
907
-
908
  # Save
909
  cls.save(config, username)
910
-
911
  log_info(
912
  "API deleted",
913
  api_name=api.name,
914
  user=username
915
  )
916
-
917
  # ===================== Activity Methods =====================
918
  @classmethod
919
  def _add_activity(
@@ -930,9 +930,9 @@ class ConfigProvider:
930
  max_id = 0
931
  if config.activity_log:
932
  max_id = max((entry.id for entry in config.activity_log if entry.id), default=0)
933
-
934
  activity_id = max_id + 1
935
-
936
  activity = ActivityLogEntry(
937
  id=activity_id,
938
  timestamp=get_current_timestamp(),
@@ -942,9 +942,9 @@ class ConfigProvider:
942
  entity_name=entity_name,
943
  details=details
944
  )
945
-
946
  config.activity_log.append(activity)
947
-
948
  # Keep only last 1000 entries
949
  if len(config.activity_log) > 1000:
950
  config.activity_log = config.activity_log[-1000:]
 
13
  from utils import get_current_timestamp, normalize_timestamp, timestamps_equal
14
 
15
  from config_models import (
16
+ ServiceConfig, GlobalConfig, ProjectConfig, VersionConfig,
17
  IntentConfig, APIConfig, ActivityLogEntry, ParameterConfig,
18
  LLMConfiguration, GenerationConfig
19
  )
20
+ from utils.logger import log_info, log_error, log_warning, log_debug, LogTimer
21
+ from utils.exceptions import (
22
  RaceConditionError, ConfigurationError, ResourceNotFoundError,
23
  DuplicateResourceError, ValidationError
24
  )
25
+ from utils.encryption_utils import encrypt, decrypt
26
 
27
  class ConfigProvider:
28
  """Thread-safe singleton configuration provider"""
29
+
30
  _instance: Optional[ServiceConfig] = None
31
  _lock = threading.RLock() # Reentrant lock for nested calls
32
  _file_lock = threading.Lock() # Separate lock for file operations
33
+ _CONFIG_PATH = Path(__file__).parent.parent / "service_config.jsonc"
34
 
35
  @staticmethod
36
  def _normalize_date(date_str: Optional[str]) -> str:
 
51
  cls._instance.build_index()
52
  log_info("Configuration loaded successfully")
53
  return cls._instance
54
+
55
  @classmethod
56
  def reload(cls) -> ServiceConfig:
57
  """Force reload configuration from file"""
 
59
  log_info("Reloading configuration...")
60
  cls._instance = None
61
  return cls.get()
62
+
63
  @classmethod
64
  def _load(cls) -> ServiceConfig:
65
  """Load configuration from file"""
 
69
  f"Config file not found: {cls._CONFIG_PATH}",
70
  config_key="service_config.jsonc"
71
  )
72
+
73
  with open(cls._CONFIG_PATH, 'r', encoding='utf-8') as f:
74
  config_data = commentjson.load(f)
75
+
76
  # Debug: İlk project'in tarihini kontrol et
77
  if 'projects' in config_data and len(config_data['projects']) > 0:
78
  first_project = config_data['projects'][0]
79
  log_debug(f"🔍 Raw project data - last_update_date: {first_project.get('last_update_date')}")
80
+
81
  # Ensure required fields
82
  if 'config' not in config_data:
83
  config_data['config'] = {}
 
88
  # Parse API configs (handle JSON strings)
89
  if 'apis' in config_data:
90
  cls._parse_api_configs(config_data['apis'])
91
+
92
  # Validate and create model
93
  cfg = ServiceConfig.model_validate(config_data)
94
+
95
  # Debug: Model'e dönüştükten sonra kontrol et
96
  if cfg.projects and len(cfg.projects) > 0:
97
  log_debug(f"🔍 Parsed project - last_update_date: {cfg.projects[0].last_update_date}")
 
100
  # Log versions published status after parsing
101
  for version in cfg.projects[0].versions:
102
  log_debug(f"🔍 Parsed version {version.no} - published: {version.published} (type: {type(version.published)})")
103
+
104
  log_debug(
105
  "Configuration loaded",
106
  projects=len(cfg.projects),
107
  apis=len(cfg.apis),
108
  users=len(cfg.global_config.users)
109
  )
110
+
111
  return cfg
112
+
113
  except Exception as e:
114
  log_error(f"Error loading config", error=str(e), path=str(cls._CONFIG_PATH))
115
  raise ConfigurationError(f"Failed to load configuration: {e}")
116
+
117
  @classmethod
118
  def _parse_api_configs(cls, apis: List[Dict[str, Any]]) -> None:
119
  """Parse JSON string fields in API configs"""
 
124
  api['headers'] = json.loads(api['headers'])
125
  except json.JSONDecodeError:
126
  api['headers'] = {}
127
+
128
  # Parse body_template
129
  if 'body_template' in api and isinstance(api['body_template'], str):
130
  try:
131
  api['body_template'] = json.loads(api['body_template'])
132
  except json.JSONDecodeError:
133
  api['body_template'] = {}
134
+
135
  # Parse auth configs
136
  if 'auth' in api and api['auth']:
137
  cls._parse_auth_config(api['auth'])
138
+
139
  @classmethod
140
  def _parse_auth_config(cls, auth: Dict[str, Any]) -> None:
141
  """Parse auth configuration"""
 
145
  auth['token_request_body'] = json.loads(auth['token_request_body'])
146
  except json.JSONDecodeError:
147
  auth['token_request_body'] = {}
148
+
149
  # Parse token_refresh_body
150
  if 'token_refresh_body' in auth and isinstance(auth['token_refresh_body'], str):
151
  try:
152
  auth['token_refresh_body'] = json.loads(auth['token_refresh_body'])
153
  except json.JSONDecodeError:
154
  auth['token_refresh_body'] = {}
155
+
156
  @classmethod
157
  def save(cls, config: ServiceConfig, username: str) -> None:
158
  """Thread-safe configuration save with optimistic locking"""
 
160
  try:
161
  # Convert to dict for JSON serialization
162
  config_dict = config.model_dump()
163
+
164
  # Load current config for race condition check
165
  try:
166
  current_config = cls._load()
167
+
168
  # Check for race condition
169
  if config.last_update_date and current_config.last_update_date:
170
  if not timestamps_equal(config.last_update_date, current_config.last_update_date):
 
179
  # Eğer mevcut config yüklenemiyorsa, race condition kontrolünü atla
180
  log_warning(f"Could not load current config for race condition check: {e}")
181
  current_config = None
182
+
183
  # Update metadata
184
  config.last_update_date = get_current_timestamp()
185
  config.last_update_user = username
186
+
187
  # Convert to JSON - Pydantic v2 kullanımı
188
  data = config.model_dump(mode='json')
189
  json_str = json.dumps(data, ensure_ascii=False, indent=2)
190
+
191
  # Backup current file if exists
192
  backup_path = None
193
  if cls._CONFIG_PATH.exists():
194
  backup_path = cls._CONFIG_PATH.with_suffix('.backup')
195
  shutil.copy2(str(cls._CONFIG_PATH), str(backup_path))
196
  log_debug(f"Created backup at {backup_path}")
197
+
198
  try:
199
  # Write to temporary file first
200
  temp_path = cls._CONFIG_PATH.with_suffix('.tmp')
201
  with open(temp_path, 'w', encoding='utf-8') as f:
202
  f.write(json_str)
203
+
204
  # Validate the temp file by trying to load it
205
  with open(temp_path, 'r', encoding='utf-8') as f:
206
  test_data = commentjson.load(f)
207
  ServiceConfig.model_validate(test_data)
208
+
209
  # If validation passes, replace the original
210
  shutil.move(str(temp_path), str(cls._CONFIG_PATH))
211
+
212
  # Delete backup if save successful
213
  if backup_path and backup_path.exists():
214
  backup_path.unlink()
215
+
216
  except Exception as e:
217
  # Restore from backup if something went wrong
218
  if backup_path and backup_path.exists():
219
  shutil.move(str(backup_path), str(cls._CONFIG_PATH))
220
  log_error(f"Restored configuration from backup due to error: {e}")
221
  raise
222
+
223
  # Update cached instance
224
  with cls._lock:
225
  cls._instance = config
226
+
227
  log_info(
228
  "Configuration saved successfully",
229
  user=username,
230
  last_update=config.last_update_date
231
  )
232
+
233
  except Exception as e:
234
  log_error(f"Failed to save config", error=str(e))
235
  raise ConfigurationError(
236
  f"Failed to save configuration: {str(e)}",
237
  config_key="service_config.jsonc"
238
  )
239
+
240
  # ===================== Environment Methods =====================
241
+
242
  @classmethod
243
  def update_environment(cls, update_data: dict, username: str) -> None:
244
  """Update environment configuration"""
245
  with cls._lock:
246
  config = cls.get()
247
+
248
  # Update providers
249
  if 'llm_provider' in update_data:
250
  config.global_config.llm_provider = update_data['llm_provider']
251
+
252
  if 'tts_provider' in update_data:
253
  config.global_config.tts_provider = update_data['tts_provider']
254
+
255
  if 'stt_provider' in update_data:
256
  config.global_config.stt_provider = update_data['stt_provider']
257
+
258
  # Log activity
259
  cls._add_activity(
260
  config, username, "UPDATE_ENVIRONMENT",
261
  "environment", None,
262
  f"Updated providers"
263
  )
264
+
265
  # Save
266
  cls.save(config, username)
267
 
 
270
  """Ensure config has required provider structure"""
271
  if 'config' not in config_data:
272
  config_data['config'] = {}
273
+
274
  config = config_data['config']
275
+
276
  # Ensure provider settings exist
277
  if 'llm_provider' not in config:
278
  config['llm_provider'] = {
 
281
  'endpoint': 'http://localhost:8080',
282
  'settings': {}
283
  }
284
+
285
  if 'tts_provider' not in config:
286
  config['tts_provider'] = {
287
  'name': 'no_tts',
 
289
  'endpoint': None,
290
  'settings': {}
291
  }
292
+
293
  if 'stt_provider' not in config:
294
  config['stt_provider'] = {
295
  'name': 'no_stt',
 
297
  'endpoint': None,
298
  'settings': {}
299
  }
300
+
301
  # Ensure providers list exists
302
  if 'providers' not in config:
303
  config['providers'] = [
 
329
  "description": "Speech-to-Text disabled"
330
  }
331
  ]
332
+
333
  # ===================== Project Methods =====================
334
+
335
  @classmethod
336
  def get_project(cls, project_id: int) -> Optional[ProjectConfig]:
337
  """Get project by ID"""
338
  config = cls.get()
339
  return next((p for p in config.projects if p.id == project_id), None)
340
+
341
  @classmethod
342
  def create_project(cls, project_data: dict, username: str) -> ProjectConfig:
343
  """Create new project with initial version"""
344
  with cls._lock:
345
  config = cls.get()
346
+
347
  # Check for duplicate name
348
  existing_project = next((p for p in config.projects if p.name == project_data['name'] and not p.deleted), None)
349
  if existing_project:
350
  raise DuplicateResourceError("Project", project_data['name'])
351
 
352
+
353
  # Create project
354
  project = ProjectConfig(
355
  id=config.project_id_counter,
 
359
  versions=[], # Boş başla
360
  **project_data
361
  )
362
+
363
  # Create initial version with proper models
364
  initial_version = VersionConfig(
365
  no=1,
 
387
  last_update_date=None,
388
  last_update_user=None,
389
  publish_date=None,
390
+ published_by=None
391
  )
392
+
393
  # Add initial version to project
394
  project.versions.append(initial_version)
395
  project.version_id_counter = 2 # Next version will be 2
396
+
397
  # Update config
398
  config.projects.append(project)
399
  config.project_id_counter += 1
400
+
401
  # Log activity
402
  cls._add_activity(
403
  config, username, "CREATE_PROJECT",
404
  "project", project.name,
405
  f"Created with initial version"
406
  )
407
+
408
  # Save
409
  cls.save(config, username)
410
+
411
  log_info(
412
  "Project created with initial version",
413
  project_id=project.id,
414
  name=project.name,
415
  user=username
416
  )
417
+
418
  return project
419
+
420
  @classmethod
421
  def update_project(cls, project_id: int, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> ProjectConfig:
422
  """Update project with optimistic locking"""
423
  with cls._lock:
424
  config = cls.get()
425
  project = cls.get_project(project_id)
426
+
427
  if not project:
428
  raise ResourceNotFoundError("project", project_id)
429
+
430
  # Check race condition
431
  if expected_last_update is not None and expected_last_update != '':
432
  if project.last_update_date and not timestamps_equal(expected_last_update, project.last_update_date):
 
438
  entity_type="project",
439
  entity_id=project_id
440
  )
441
+
442
  # Update fields
443
  for key, value in update_data.items():
444
  if hasattr(project, key) and key not in ['id', 'created_date', 'created_by', 'last_update_date', 'last_update_user']:
445
  setattr(project, key, value)
446
+
447
  project.last_update_date = get_current_timestamp()
448
  project.last_update_user = username
449
+
450
  cls._add_activity(
451
  config, username, "UPDATE_PROJECT",
452
  "project", project.name
453
  )
454
+
455
  # Save
456
  cls.save(config, username)
457
+
458
  log_info(
459
  "Project updated",
460
  project_id=project.id,
461
  user=username
462
  )
463
+
464
  return project
465
+
466
  @classmethod
467
  def delete_project(cls, project_id: int, username: str) -> None:
468
  """Soft delete project"""
469
  with cls._lock:
470
  config = cls.get()
471
  project = cls.get_project(project_id)
472
+
473
  if not project:
474
  raise ResourceNotFoundError("project", project_id)
475
+
476
  project.deleted = True
477
  project.last_update_date = get_current_timestamp()
478
  project.last_update_user = username
479
+
480
  cls._add_activity(
481
  config, username, "DELETE_PROJECT",
482
  "project", project.name
483
  )
484
+
485
  # Save
486
  cls.save(config, username)
487
+
488
  log_info(
489
  "Project deleted",
490
  project_id=project.id,
491
  user=username
492
  )
493
+
494
  @classmethod
495
  def toggle_project(cls, project_id: int, username: str) -> bool:
496
  """Toggle project enabled status"""
497
  with cls._lock:
498
  config = cls.get()
499
  project = cls.get_project(project_id)
500
+
501
  if not project:
502
  raise ResourceNotFoundError("project", project_id)
503
+
504
  project.enabled = not project.enabled
505
  project.last_update_date = get_current_timestamp()
506
  project.last_update_user = username
507
+
508
  # Log activity
509
  cls._add_activity(
510
  config, username, "TOGGLE_PROJECT",
511
  "project", project.name,
512
  f"{'Enabled' if project.enabled else 'Disabled'}"
513
  )
514
+
515
  # Save
516
  cls.save(config, username)
517
+
518
  log_info(
519
  "Project toggled",
520
  project_id=project.id,
521
  enabled=project.enabled,
522
  user=username
523
  )
524
+
525
  return project.enabled
526
+
527
  # ===================== Version Methods =====================
528
+
529
  @classmethod
530
  def create_version(cls, project_id: int, version_data: dict, username: str) -> VersionConfig:
531
  """Create new version"""
532
  with cls._lock:
533
  config = cls.get()
534
  project = cls.get_project(project_id)
535
+
536
  if not project:
537
  raise ResourceNotFoundError("project", project_id)
538
+
539
  # Handle source version copy
540
  if 'source_version_no' in version_data and version_data['source_version_no']:
541
  source_version = next((v for v in project.versions if v.no == version_data['source_version_no']), None)
 
543
  # Copy from source version
544
  version_dict = source_version.model_dump()
545
  # Remove fields that shouldn't be copied
546
+ for field in ['no', 'created_date', 'created_by', 'published', 'publish_date',
547
  'published_by', 'last_update_date', 'last_update_user']:
548
  version_dict.pop(field, None)
549
  # Override with provided data
 
586
  },
587
  'intents': []
588
  }
589
+
590
  # Create version
591
  version = VersionConfig(
592
  no=project.version_id_counter,
 
600
  published_by=None,
601
  **version_dict
602
  )
603
+
604
  # Update project
605
  project.versions.append(version)
606
  project.version_id_counter += 1
607
  project.last_update_date = get_current_timestamp()
608
  project.last_update_user = username
609
+
610
  # Log activity
611
  cls._add_activity(
612
  config, username, "CREATE_VERSION",
613
  "version", version.no, f"{project.name} v{version.no}",
614
  f"Project: {project.name}"
615
  )
616
+
617
  # Save
618
  cls.save(config, username)
619
+
620
  log_info(
621
  "Version created",
622
  project_id=project.id,
623
  version_no=version.no,
624
  user=username
625
  )
626
+
627
  return version
628
+
629
  @classmethod
630
  def publish_version(cls, project_id: int, version_no: int, username: str) -> tuple[ProjectConfig, VersionConfig]:
631
  """Publish a version"""
632
  with cls._lock:
633
  config = cls.get()
634
  project = cls.get_project(project_id)
635
+
636
  if not project:
637
  raise ResourceNotFoundError("project", project_id)
638
+
639
  version = next((v for v in project.versions if v.no == version_no), None)
640
  if not version:
641
  raise ResourceNotFoundError("version", version_no)
642
+
643
  # Unpublish other versions
644
  for v in project.versions:
645
  if v.published and v.no != version_no:
646
  v.published = False
647
+
648
  # Publish this version
649
  version.published = True
650
  version.publish_date = get_current_timestamp()
651
  version.published_by = username
652
+
653
  # Update project
654
  project.last_update_date = get_current_timestamp()
655
  project.last_update_user = username
656
+
657
  # Log activity
658
  cls._add_activity(
659
  config, username, "PUBLISH_VERSION",
 
662
 
663
  # Save
664
  cls.save(config, username)
665
+
666
  log_info(
667
  "Version published",
668
  project_id=project.id,
669
  version_no=version.no,
670
  user=username
671
  )
672
+
673
  return project, version
674
 
675
  @classmethod
 
678
  with cls._lock:
679
  config = cls.get()
680
  project = cls.get_project(project_id)
681
+
682
  if not project:
683
  raise ResourceNotFoundError("project", project_id)
684
+
685
  version = next((v for v in project.versions if v.no == version_no), None)
686
  if not version:
687
  raise ResourceNotFoundError("version", version_no)
688
+
689
  # Ensure published is a boolean (safety check)
690
  if version.published is None:
691
  version.published = False
692
+
693
  # Published versions cannot be edited
694
  if version.published:
695
  raise ValidationError("Published versions cannot be modified")
696
+
697
  # Check race condition
698
  if expected_last_update is not None and expected_last_update != '':
699
  if version.last_update_date and not timestamps_equal(expected_last_update, version.last_update_date):
 
704
  last_update_date=version.last_update_date,
705
  entity_type="version",
706
  entity_id=f"{project_id}:{version_no}"
707
+ )
708
+
709
  # Update fields
710
  for key, value in update_data.items():
711
  if hasattr(version, key) and key not in ['no', 'created_date', 'created_by', 'published', 'last_update_date']:
 
723
  setattr(version, key, intents)
724
  else:
725
  setattr(version, key, value)
726
+
727
  version.last_update_date = get_current_timestamp()
728
  version.last_update_user = username
729
+
730
  # Update project last update
731
  project.last_update_date = get_current_timestamp()
732
  project.last_update_user = username
733
+
734
  # Log activity
735
  cls._add_activity(
736
  config, username, "UPDATE_VERSION",
737
  "version", f"{project.name} v{version.no}"
738
  )
739
+
740
  # Save
741
  cls.save(config, username)
742
+
743
  log_info(
744
  "Version updated",
745
  project_id=project.id,
746
  version_no=version.no,
747
  user=username
748
  )
749
+
750
  return version
751
+
752
  @classmethod
753
  def delete_version(cls, project_id: int, version_no: int, username: str) -> None:
754
  """Soft delete version"""
755
  with cls._lock:
756
  config = cls.get()
757
  project = cls.get_project(project_id)
758
+
759
  if not project:
760
  raise ResourceNotFoundError("project", project_id)
761
+
762
  version = next((v for v in project.versions if v.no == version_no), None)
763
  if not version:
764
  raise ResourceNotFoundError("version", version_no)
765
+
766
  if version.published:
767
  raise ValidationError("Cannot delete published version")
768
+
769
  version.deleted = True
770
  version.last_update_date = get_current_timestamp()
771
  version.last_update_user = username
772
+
773
  # Update project
774
  project.last_update_date = get_current_timestamp()
775
  project.last_update_user = username
776
+
777
  # Log activity
778
  cls._add_activity(
779
  config, username, "DELETE_VERSION",
780
  "version", f"{project.name} v{version.no}"
781
  )
782
+
783
  # Save
784
  cls.save(config, username)
785
+
786
  log_info(
787
  "Version deleted",
788
  project_id=project.id,
789
  version_no=version.no,
790
  user=username
791
  )
792
+
793
  # ===================== API Methods =====================
794
  @classmethod
795
  def create_api(cls, api_data: dict, username: str) -> APIConfig:
796
  """Create new API"""
797
  with cls._lock:
798
  config = cls.get()
799
+
800
  # Check for duplicate name
801
  existing_api = next((a for a in config.apis if a.name == api_data['name'] and not a.deleted), None)
802
  if existing_api:
803
  raise DuplicateResourceError("API", api_data['name'])
804
+
805
  # Create API
806
  api = APIConfig(
807
  created_date=get_current_timestamp(),
808
  created_by=username,
809
  **api_data
810
  )
811
+
812
  # Add to config
813
  config.apis.append(api)
814
+
815
  # Rebuild index
816
  config.build_index()
817
+
818
  # Log activity
819
  cls._add_activity(
820
  config, username, "CREATE_API",
821
  "api", api.name
822
  )
823
+
824
  # Save
825
  cls.save(config, username)
826
+
827
  log_info(
828
  "API created",
829
  api_name=api.name,
830
  user=username
831
  )
832
+
833
  return api
834
+
835
  @classmethod
836
  def update_api(cls, api_name: str, update_data: dict, username: str, expected_last_update: Optional[str] = None) -> APIConfig:
837
  """Update API with optimistic locking"""
838
  with cls._lock:
839
  config = cls.get()
840
  api = config.get_api(api_name)
841
+
842
  if not api:
843
  raise ResourceNotFoundError("api", api_name)
844
+
845
  # Check race condition
846
  if expected_last_update is not None and expected_last_update != '':
847
  if api.last_update_date and not timestamps_equal(expected_last_update, api.last_update_date):
 
852
  last_update_date=api.last_update_date,
853
  entity_type="api",
854
  entity_id=api.name
855
+ )
856
+
857
  # Update fields
858
  for key, value in update_data.items():
859
  if hasattr(api, key) and key not in ['name', 'created_date', 'created_by', 'last_update_date']:
860
  setattr(api, key, value)
861
+
862
  api.last_update_date = get_current_timestamp()
863
  api.last_update_user = username
864
+
865
  # Rebuild index
866
  config.build_index()
867
+
868
  # Log activity
869
  cls._add_activity(
870
  config, username, "UPDATE_API",
871
  "api", api.name
872
  )
873
+
874
  # Save
875
  cls.save(config, username)
876
+
877
  log_info(
878
  "API updated",
879
  api_name=api.name,
880
  user=username
881
  )
882
+
883
  return api
884
+
885
  @classmethod
886
  def delete_api(cls, api_name: str, username: str) -> None:
887
  """Soft delete API"""
888
  with cls._lock:
889
  config = cls.get()
890
  api = config.get_api(api_name)
891
+
892
  if not api:
893
  raise ResourceNotFoundError("api", api_name)
894
+
895
  api.deleted = True
896
  api.last_update_date = get_current_timestamp()
897
  api.last_update_user = username
898
+
899
  # Rebuild index
900
  config.build_index()
901
+
902
  # Log activity
903
  cls._add_activity(
904
  config, username, "DELETE_API",
905
  "api", api.name
906
  )
907
+
908
  # Save
909
  cls.save(config, username)
910
+
911
  log_info(
912
  "API deleted",
913
  api_name=api.name,
914
  user=username
915
  )
916
+
917
  # ===================== Activity Methods =====================
918
  @classmethod
919
  def _add_activity(
 
930
  max_id = 0
931
  if config.activity_log:
932
  max_id = max((entry.id for entry in config.activity_log if entry.id), default=0)
933
+
934
  activity_id = max_id + 1
935
+
936
  activity = ActivityLogEntry(
937
  id=activity_id,
938
  timestamp=get_current_timestamp(),
 
942
  entity_name=entity_name,
943
  details=details
944
  )
945
+
946
  config.activity_log.append(activity)
947
+
948
  # Keep only last 1000 entries
949
  if len(config.activity_log) > 1000:
950
  config.activity_log = config.activity_log[-1000:]
config/locale_manager.py CHANGED
@@ -8,34 +8,34 @@ from pathlib import Path
8
  from typing import Dict, List, Optional
9
  from datetime import datetime
10
  import sys
11
- from logger import log_info, log_error, log_debug, log_warning
12
 
13
  class LocaleManager:
14
  """Manages locale files for TTS preprocessing and system-wide language support"""
15
-
16
  _cache: Dict[str, Dict] = {}
17
  _available_locales: Optional[List[Dict[str, str]]] = None
18
-
19
  @classmethod
20
  def get_locale(cls, language: str) -> Dict:
21
  """Get locale data with caching"""
22
  if language not in cls._cache:
23
  cls._cache[language] = cls._load_locale(language)
24
  return cls._cache[language]
25
-
26
  @classmethod
27
  def _load_locale(cls, language: str) -> Dict:
28
  """Load locale from file - accepts both 'tr' and 'tr-TR' formats"""
29
- base_path = Path(__file__).parent / "locales"
30
-
31
  # First try exact match
32
  locale_file = base_path / f"{language}.json"
33
-
34
  # If not found and has region code, try without region (tr-TR -> tr)
35
  if not locale_file.exists() and '-' in language:
36
  language_code = language.split('-')[0]
37
  locale_file = base_path / f"{language_code}.json"
38
-
39
  if locale_file.exists():
40
  try:
41
  with open(locale_file, 'r', encoding='utf-8') as f:
@@ -44,7 +44,7 @@ class LocaleManager:
44
  return data
45
  except Exception as e:
46
  log_error(f"Failed to load locale file {locale_file}", e)
47
-
48
  # Try English fallback
49
  fallback_file = base_path / "en.json"
50
  if fallback_file.exists():
@@ -55,7 +55,7 @@ class LocaleManager:
55
  return data
56
  except:
57
  pass
58
-
59
  # Minimal fallback if no locale files exist
60
  log_warning(f"⚠️ No locale files found, using minimal fallback")
61
  return {
@@ -64,24 +64,24 @@ class LocaleManager:
64
  "name": "Türkçe",
65
  "english_name": "Turkish"
66
  }
67
-
68
  @classmethod
69
  def list_available_locales(cls) -> List[str]:
70
  """List all available locale files"""
71
- base_path = Path(__file__).parent / "locales"
72
  if not base_path.exists():
73
  return ["en", "tr"] # Default locales
74
  return [f.stem for f in base_path.glob("*.json")]
75
-
76
  @classmethod
77
  def get_available_locales_with_names(cls) -> List[Dict[str, str]]:
78
  """Get list of all available locales with their display names"""
79
  if cls._available_locales is not None:
80
  return cls._available_locales
81
-
82
  cls._available_locales = []
83
- base_path = Path(__file__).parent / "locales"
84
-
85
  if not base_path.exists():
86
  # Return default locales if directory doesn't exist
87
  cls._available_locales = [
@@ -97,29 +97,29 @@ class LocaleManager:
97
  }
98
  ]
99
  return cls._available_locales
100
-
101
  # Load all locale files
102
  for locale_file in base_path.glob("*.json"):
103
  try:
104
  locale_code = locale_file.stem
105
  locale_data = cls.get_locale(locale_code)
106
-
107
  cls._available_locales.append({
108
  "code": locale_code,
109
  "name": locale_data.get("name", locale_code),
110
  "english_name": locale_data.get("english_name", locale_code)
111
  })
112
-
113
  log_info(f"✅ Loaded locale: {locale_code} - {locale_data.get('name', 'Unknown')}")
114
-
115
  except Exception as e:
116
  log_error(f"❌ Failed to load locale {locale_file}", e)
117
-
118
  # Sort by name for consistent ordering
119
  cls._available_locales.sort(key=lambda x: x['name'])
120
-
121
  return cls._available_locales
122
-
123
  @classmethod
124
  def get_locale_details(cls, locale_code: str) -> Optional[Dict]:
125
  """Get detailed info for a specific locale"""
@@ -130,33 +130,33 @@ class LocaleManager:
130
  return locale_data
131
  except:
132
  return None
133
-
134
  @classmethod
135
  def is_locale_supported(cls, locale_code: str) -> bool:
136
  """Check if a locale is supported system-wide"""
137
  available_codes = [locale['code'] for locale in cls.get_available_locales_with_names()]
138
  return locale_code in available_codes
139
-
140
  @classmethod
141
  def validate_project_languages(cls, languages: List[str]) -> List[str]:
142
  """Validate that all languages are system-supported, return invalid ones"""
143
  available_codes = [locale['code'] for locale in cls.get_available_locales_with_names()]
144
  invalid_languages = [
145
- lang for lang in languages
146
  if lang not in available_codes
147
  ]
148
  return invalid_languages
149
-
150
  @classmethod
151
  def get_default_locale(cls) -> str:
152
  """Get system default locale"""
153
  available_locales = cls.get_available_locales_with_names()
154
-
155
  # Priority: tr-TR, en-US, first available
156
  for preferred in ["tr-TR", "en-US"]:
157
  if any(locale['code'] == preferred for locale in available_locales):
158
  return preferred
159
-
160
  # Return first available or fallback
161
  if available_locales:
162
  return available_locales[0]['code']
 
8
  from typing import Dict, List, Optional
9
  from datetime import datetime
10
  import sys
11
+ from utils.logger import log_info, log_error, log_debug, log_warning
12
 
13
  class LocaleManager:
14
  """Manages locale files for TTS preprocessing and system-wide language support"""
15
+
16
  _cache: Dict[str, Dict] = {}
17
  _available_locales: Optional[List[Dict[str, str]]] = None
18
+
19
  @classmethod
20
  def get_locale(cls, language: str) -> Dict:
21
  """Get locale data with caching"""
22
  if language not in cls._cache:
23
  cls._cache[language] = cls._load_locale(language)
24
  return cls._cache[language]
25
+
26
  @classmethod
27
  def _load_locale(cls, language: str) -> Dict:
28
  """Load locale from file - accepts both 'tr' and 'tr-TR' formats"""
29
+ base_path = Path(__file__).parent.parent / "locales"
30
+
31
  # First try exact match
32
  locale_file = base_path / f"{language}.json"
33
+
34
  # If not found and has region code, try without region (tr-TR -> tr)
35
  if not locale_file.exists() and '-' in language:
36
  language_code = language.split('-')[0]
37
  locale_file = base_path / f"{language_code}.json"
38
+
39
  if locale_file.exists():
40
  try:
41
  with open(locale_file, 'r', encoding='utf-8') as f:
 
44
  return data
45
  except Exception as e:
46
  log_error(f"Failed to load locale file {locale_file}", e)
47
+
48
  # Try English fallback
49
  fallback_file = base_path / "en.json"
50
  if fallback_file.exists():
 
55
  return data
56
  except:
57
  pass
58
+
59
  # Minimal fallback if no locale files exist
60
  log_warning(f"⚠️ No locale files found, using minimal fallback")
61
  return {
 
64
  "name": "Türkçe",
65
  "english_name": "Turkish"
66
  }
67
+
68
  @classmethod
69
  def list_available_locales(cls) -> List[str]:
70
  """List all available locale files"""
71
+ base_path = Path(__file__).parent.parent / "locales"
72
  if not base_path.exists():
73
  return ["en", "tr"] # Default locales
74
  return [f.stem for f in base_path.glob("*.json")]
75
+
76
  @classmethod
77
  def get_available_locales_with_names(cls) -> List[Dict[str, str]]:
78
  """Get list of all available locales with their display names"""
79
  if cls._available_locales is not None:
80
  return cls._available_locales
81
+
82
  cls._available_locales = []
83
+ base_path = Path(__file__).parent.parent / "locales"
84
+
85
  if not base_path.exists():
86
  # Return default locales if directory doesn't exist
87
  cls._available_locales = [
 
97
  }
98
  ]
99
  return cls._available_locales
100
+
101
  # Load all locale files
102
  for locale_file in base_path.glob("*.json"):
103
  try:
104
  locale_code = locale_file.stem
105
  locale_data = cls.get_locale(locale_code)
106
+
107
  cls._available_locales.append({
108
  "code": locale_code,
109
  "name": locale_data.get("name", locale_code),
110
  "english_name": locale_data.get("english_name", locale_code)
111
  })
112
+
113
  log_info(f"✅ Loaded locale: {locale_code} - {locale_data.get('name', 'Unknown')}")
114
+
115
  except Exception as e:
116
  log_error(f"❌ Failed to load locale {locale_file}", e)
117
+
118
  # Sort by name for consistent ordering
119
  cls._available_locales.sort(key=lambda x: x['name'])
120
+
121
  return cls._available_locales
122
+
123
  @classmethod
124
  def get_locale_details(cls, locale_code: str) -> Optional[Dict]:
125
  """Get detailed info for a specific locale"""
 
130
  return locale_data
131
  except:
132
  return None
133
+
134
  @classmethod
135
  def is_locale_supported(cls, locale_code: str) -> bool:
136
  """Check if a locale is supported system-wide"""
137
  available_codes = [locale['code'] for locale in cls.get_available_locales_with_names()]
138
  return locale_code in available_codes
139
+
140
  @classmethod
141
  def validate_project_languages(cls, languages: List[str]) -> List[str]:
142
  """Validate that all languages are system-supported, return invalid ones"""
143
  available_codes = [locale['code'] for locale in cls.get_available_locales_with_names()]
144
  invalid_languages = [
145
+ lang for lang in languages
146
  if lang not in available_codes
147
  ]
148
  return invalid_languages
149
+
150
  @classmethod
151
  def get_default_locale(cls) -> str:
152
  """Get system default locale"""
153
  available_locales = cls.get_available_locales_with_names()
154
+
155
  # Priority: tr-TR, en-US, first available
156
  for preferred in ["tr-TR", "en-US"]:
157
  if any(locale['code'] == preferred for locale in available_locales):
158
  return preferred
159
+
160
  # Return first available or fallback
161
  if available_locales:
162
  return available_locales[0]['code']
credentials/google-service-account.json CHANGED
@@ -1,13 +1,13 @@
1
- {
2
- "type": "service_account",
3
- "project_id": "ucs-human-demo",
4
- "private_key_id": "d60575b3b0ffe2f580eb01b56414b0e1ae950268",
5
- "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",
6
- "client_email": "[email protected]",
7
- "client_id": "116817469632088219353",
8
- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
9
- "token_uri": "https://oauth2.googleapis.com/token",
10
- "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
11
- "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/745400736051-compute%40developer.gserviceaccount.com",
12
- "universe_domain": "googleapis.com"
13
- }
 
1
+ {
2
+ "type": "service_account",
3
+ "project_id": "ucs-human-demo",
4
+ "private_key_id": "d60575b3b0ffe2f580eb01b56414b0e1ae950268",
5
+ "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",
6
+ "client_email": "[email protected]",
7
+ "client_id": "116817469632088219353",
8
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
9
+ "token_uri": "https://oauth2.googleapis.com/token",
10
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
11
+ "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/745400736051-compute%40developer.gserviceaccount.com",
12
+ "universe_domain": "googleapis.com"
13
+ }
flare-ui/angular.json CHANGED
@@ -1,117 +1,117 @@
1
- {
2
- "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3
- "version": 1,
4
- "newProjectRoot": "projects",
5
- "projects": {
6
- "flare-ui": {
7
- "projectType": "application",
8
- "schematics": {
9
- "@schematics/angular:component": {
10
- "style": "scss"
11
- }
12
- },
13
- "root": "",
14
- "sourceRoot": "src",
15
- "prefix": "app",
16
- "architect": {
17
- "build": {
18
- "builder": "@angular-devkit/build-angular:browser",
19
- "options": {
20
- "outputPath": "dist/flare-ui",
21
- "index": "src/index.html",
22
- "main": "src/main.ts",
23
- "polyfills": [
24
- "zone.js"
25
- ],
26
- "tsConfig": "tsconfig.app.json",
27
- "inlineStyleLanguage": "scss",
28
- "assets": [
29
- "src/favicon.ico",
30
- "src/assets"
31
- ],
32
- "styles": [
33
- "@angular/material/prebuilt-themes/indigo-pink.css",
34
- "src/styles.scss"
35
- ],
36
- "scripts": []
37
- },
38
- "configurations": {
39
- "production": {
40
- "budgets": [
41
- {
42
- "type": "initial",
43
- "maximumWarning": "2mb",
44
- "maximumError": "4mb"
45
- },
46
- {
47
- "type": "anyComponentStyle",
48
- "maximumWarning": "8kb",
49
- "maximumError": "16kb"
50
- }
51
- ],
52
- "fileReplacements": [
53
- {
54
- "replace": "src/environments/environment.ts",
55
- "with": "src/environments/environment.prod.ts"
56
- }
57
- ],
58
- "outputHashing": "all"
59
- },
60
- "development": {
61
- "buildOptimizer": false,
62
- "optimization": false,
63
- "vendorChunk": true,
64
- "extractLicenses": false,
65
- "sourceMap": true,
66
- "namedChunks": true
67
- }
68
- },
69
- "defaultConfiguration": "production"
70
- },
71
- "serve": {
72
- "builder": "@angular-devkit/build-angular:dev-server",
73
- "configurations": {
74
- "production": {
75
- "browserTarget": "flare-ui:build:production"
76
- },
77
- "development": {
78
- "browserTarget": "flare-ui:build:development"
79
- }
80
- },
81
- "defaultConfiguration": "development",
82
- "options": {
83
- "proxyConfig": "src/proxy.conf.json"
84
- }
85
- },
86
- "extract-i18n": {
87
- "builder": "@angular-devkit/build-angular:extract-i18n",
88
- "options": {
89
- "browserTarget": "flare-ui:build"
90
- }
91
- },
92
- "test": {
93
- "builder": "@angular-devkit/build-angular:karma",
94
- "options": {
95
- "polyfills": [
96
- "zone.js",
97
- "zone.js/testing"
98
- ],
99
- "tsConfig": "tsconfig.spec.json",
100
- "inlineStyleLanguage": "scss",
101
- "assets": [
102
- "src/favicon.ico",
103
- "src/assets"
104
- ],
105
- "styles": [
106
- "src/styles.scss"
107
- ],
108
- "scripts": []
109
- }
110
- }
111
- }
112
- }
113
- },
114
- "cli": {
115
- "analytics": false
116
- }
117
  }
 
1
+ {
2
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3
+ "version": 1,
4
+ "newProjectRoot": "projects",
5
+ "projects": {
6
+ "flare-ui": {
7
+ "projectType": "application",
8
+ "schematics": {
9
+ "@schematics/angular:component": {
10
+ "style": "scss"
11
+ }
12
+ },
13
+ "root": "",
14
+ "sourceRoot": "src",
15
+ "prefix": "app",
16
+ "architect": {
17
+ "build": {
18
+ "builder": "@angular-devkit/build-angular:browser",
19
+ "options": {
20
+ "outputPath": "dist/flare-ui",
21
+ "index": "src/index.html",
22
+ "main": "src/main.ts",
23
+ "polyfills": [
24
+ "zone.js"
25
+ ],
26
+ "tsConfig": "tsconfig.app.json",
27
+ "inlineStyleLanguage": "scss",
28
+ "assets": [
29
+ "src/favicon.ico",
30
+ "src/assets"
31
+ ],
32
+ "styles": [
33
+ "@angular/material/prebuilt-themes/indigo-pink.css",
34
+ "src/styles.scss"
35
+ ],
36
+ "scripts": []
37
+ },
38
+ "configurations": {
39
+ "production": {
40
+ "budgets": [
41
+ {
42
+ "type": "initial",
43
+ "maximumWarning": "2mb",
44
+ "maximumError": "4mb"
45
+ },
46
+ {
47
+ "type": "anyComponentStyle",
48
+ "maximumWarning": "8kb",
49
+ "maximumError": "16kb"
50
+ }
51
+ ],
52
+ "fileReplacements": [
53
+ {
54
+ "replace": "src/environments/environment.ts",
55
+ "with": "src/environments/environment.prod.ts"
56
+ }
57
+ ],
58
+ "outputHashing": "all"
59
+ },
60
+ "development": {
61
+ "buildOptimizer": false,
62
+ "optimization": false,
63
+ "vendorChunk": true,
64
+ "extractLicenses": false,
65
+ "sourceMap": true,
66
+ "namedChunks": true
67
+ }
68
+ },
69
+ "defaultConfiguration": "production"
70
+ },
71
+ "serve": {
72
+ "builder": "@angular-devkit/build-angular:dev-server",
73
+ "configurations": {
74
+ "production": {
75
+ "browserTarget": "flare-ui:build:production"
76
+ },
77
+ "development": {
78
+ "browserTarget": "flare-ui:build:development"
79
+ }
80
+ },
81
+ "defaultConfiguration": "development",
82
+ "options": {
83
+ "proxyConfig": "src/proxy.conf.json"
84
+ }
85
+ },
86
+ "extract-i18n": {
87
+ "builder": "@angular-devkit/build-angular:extract-i18n",
88
+ "options": {
89
+ "browserTarget": "flare-ui:build"
90
+ }
91
+ },
92
+ "test": {
93
+ "builder": "@angular-devkit/build-angular:karma",
94
+ "options": {
95
+ "polyfills": [
96
+ "zone.js",
97
+ "zone.js/testing"
98
+ ],
99
+ "tsConfig": "tsconfig.spec.json",
100
+ "inlineStyleLanguage": "scss",
101
+ "assets": [
102
+ "src/favicon.ico",
103
+ "src/assets"
104
+ ],
105
+ "styles": [
106
+ "src/styles.scss"
107
+ ],
108
+ "scripts": []
109
+ }
110
+ }
111
+ }
112
+ }
113
+ },
114
+ "cli": {
115
+ "analytics": false
116
+ }
117
  }
flare-ui/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "flare-ui",
3
- "version": "0.1.0",
4
- "scripts": {
5
- "ng": "ng",
6
- "start": "ng serve",
7
- "build": "ng build",
8
- "build:dev": "ng build --configuration=development",
9
- "build:prod": "ng build --configuration=production",
10
- "watch": "ng build --watch --configuration development",
11
- "test": "ng test"
12
- },
13
- "private": true,
14
- "dependencies": {
15
- "@angular/animations": "^17.0.0",
16
- "@angular/cdk": "^17.0.0",
17
- "@angular/common": "^17.0.0",
18
- "@angular/compiler": "^17.0.0",
19
- "@angular/core": "^17.0.0",
20
- "@angular/forms": "^17.0.0",
21
- "@angular/material": "^17.0.0",
22
- "@angular/platform-browser": "^17.0.0",
23
- "@angular/platform-browser-dynamic": "^17.0.0",
24
- "@angular/router": "^17.0.0",
25
- "rxjs": "~7.8.0",
26
- "tslib": "^2.3.0",
27
- "zone.js": "~0.14.0"
28
- },
29
- "devDependencies": {
30
- "@angular-devkit/build-angular": "^17.0.0",
31
- "@angular/cli": "^17.0.0",
32
- "@angular/compiler-cli": "^17.0.0",
33
- "@types/jasmine": "~5.1.0",
34
- "@types/node": "^20.0.0",
35
- "jasmine-core": "~5.1.0",
36
- "karma": "~6.4.0",
37
- "karma-chrome-launcher": "~3.2.0",
38
- "karma-coverage": "~2.2.0",
39
- "karma-jasmine": "~5.1.0",
40
- "karma-jasmine-html-reporter": "~2.1.0",
41
- "typescript": "~5.2.2"
42
- }
43
  }
 
1
+ {
2
+ "name": "flare-ui",
3
+ "version": "0.1.0",
4
+ "scripts": {
5
+ "ng": "ng",
6
+ "start": "ng serve",
7
+ "build": "ng build",
8
+ "build:dev": "ng build --configuration=development",
9
+ "build:prod": "ng build --configuration=production",
10
+ "watch": "ng build --watch --configuration development",
11
+ "test": "ng test"
12
+ },
13
+ "private": true,
14
+ "dependencies": {
15
+ "@angular/animations": "^17.0.0",
16
+ "@angular/cdk": "^17.0.0",
17
+ "@angular/common": "^17.0.0",
18
+ "@angular/compiler": "^17.0.0",
19
+ "@angular/core": "^17.0.0",
20
+ "@angular/forms": "^17.0.0",
21
+ "@angular/material": "^17.0.0",
22
+ "@angular/platform-browser": "^17.0.0",
23
+ "@angular/platform-browser-dynamic": "^17.0.0",
24
+ "@angular/router": "^17.0.0",
25
+ "rxjs": "~7.8.0",
26
+ "tslib": "^2.3.0",
27
+ "zone.js": "~0.14.0"
28
+ },
29
+ "devDependencies": {
30
+ "@angular-devkit/build-angular": "^17.0.0",
31
+ "@angular/cli": "^17.0.0",
32
+ "@angular/compiler-cli": "^17.0.0",
33
+ "@types/jasmine": "~5.1.0",
34
+ "@types/node": "^20.0.0",
35
+ "jasmine-core": "~5.1.0",
36
+ "karma": "~6.4.0",
37
+ "karma-chrome-launcher": "~3.2.0",
38
+ "karma-coverage": "~2.2.0",
39
+ "karma-jasmine": "~5.1.0",
40
+ "karma-jasmine-html-reporter": "~2.1.0",
41
+ "typescript": "~5.2.2"
42
+ }
43
  }
flare-ui/src/app/app.component.ts CHANGED
@@ -1,77 +1,77 @@
1
- import { Component, OnInit } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError, RouterOutlet } from '@angular/router';
4
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
5
-
6
- @Component({
7
- selector: 'app-root',
8
- standalone: true,
9
- imports: [CommonModule, RouterOutlet, MatProgressSpinnerModule],
10
- template: `
11
- <div class="app-container">
12
- <!-- Global Loading Spinner -->
13
- <div class="global-spinner" *ngIf="loading">
14
- <mat-spinner diameter="60"></mat-spinner>
15
- <p>Loading...</p>
16
- </div>
17
-
18
- <!-- Main Content -->
19
- <router-outlet></router-outlet>
20
- </div>
21
- `,
22
- styles: [`
23
- .app-container {
24
- position: relative;
25
- min-height: 100vh;
26
- }
27
-
28
- .global-spinner {
29
- position: fixed;
30
- top: 0;
31
- left: 0;
32
- width: 100%;
33
- height: 100%;
34
- background: rgba(255, 255, 255, 0.9);
35
- display: flex;
36
- flex-direction: column;
37
- align-items: center;
38
- justify-content: center;
39
- z-index: 9999;
40
- }
41
-
42
- .global-spinner p {
43
- margin-top: 20px;
44
- color: #666;
45
- font-size: 16px;
46
- }
47
- `]
48
- })
49
- export class AppComponent implements OnInit {
50
- loading = true;
51
-
52
- constructor(private router: Router) {}
53
-
54
- ngOnInit() {
55
- // Router events - spinner'ı navigation event'lere göre yönet
56
- this.router.events.subscribe(event => {
57
- if (event instanceof NavigationStart) {
58
- this.loading = true;
59
- } else if (
60
- event instanceof NavigationEnd ||
61
- event instanceof NavigationCancel ||
62
- event instanceof NavigationError
63
- ) {
64
- // Navigation tamamlandığında spinner'ı kapat
65
- setTimeout(() => {
66
- this.loading = false;
67
-
68
- // Initial loader'ı kaldır (varsa)
69
- const initialLoader = document.querySelector('.initial-loader');
70
- if (initialLoader) {
71
- initialLoader.remove();
72
- }
73
- }, 300);
74
- }
75
- });
76
- }
77
  }
 
1
+ import { Component, OnInit } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError, RouterOutlet } from '@angular/router';
4
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
5
+
6
+ @Component({
7
+ selector: 'app-root',
8
+ standalone: true,
9
+ imports: [CommonModule, RouterOutlet, MatProgressSpinnerModule],
10
+ template: `
11
+ <div class="app-container">
12
+ <!-- Global Loading Spinner -->
13
+ <div class="global-spinner" *ngIf="loading">
14
+ <mat-spinner diameter="60"></mat-spinner>
15
+ <p>Loading...</p>
16
+ </div>
17
+
18
+ <!-- Main Content -->
19
+ <router-outlet></router-outlet>
20
+ </div>
21
+ `,
22
+ styles: [`
23
+ .app-container {
24
+ position: relative;
25
+ min-height: 100vh;
26
+ }
27
+
28
+ .global-spinner {
29
+ position: fixed;
30
+ top: 0;
31
+ left: 0;
32
+ width: 100%;
33
+ height: 100%;
34
+ background: rgba(255, 255, 255, 0.9);
35
+ display: flex;
36
+ flex-direction: column;
37
+ align-items: center;
38
+ justify-content: center;
39
+ z-index: 9999;
40
+ }
41
+
42
+ .global-spinner p {
43
+ margin-top: 20px;
44
+ color: #666;
45
+ font-size: 16px;
46
+ }
47
+ `]
48
+ })
49
+ export class AppComponent implements OnInit {
50
+ loading = true;
51
+
52
+ constructor(private router: Router) {}
53
+
54
+ ngOnInit() {
55
+ // Router events - spinner'ı navigation event'lere göre yönet
56
+ this.router.events.subscribe(event => {
57
+ if (event instanceof NavigationStart) {
58
+ this.loading = true;
59
+ } else if (
60
+ event instanceof NavigationEnd ||
61
+ event instanceof NavigationCancel ||
62
+ event instanceof NavigationError
63
+ ) {
64
+ // Navigation tamamlandığında spinner'ı kapat
65
+ setTimeout(() => {
66
+ this.loading = false;
67
+
68
+ // Initial loader'ı kaldır (varsa)
69
+ const initialLoader = document.querySelector('.initial-loader');
70
+ if (initialLoader) {
71
+ initialLoader.remove();
72
+ }
73
+ }, 300);
74
+ }
75
+ });
76
+ }
77
  }
flare-ui/src/app/app.config.ts CHANGED
@@ -1,17 +1,17 @@
1
- import { ApplicationConfig, ErrorHandler } from '@angular/core';
2
- import { provideRouter } from '@angular/router';
3
- import { routes } from './app.routes';
4
- import { provideAnimations } from '@angular/platform-browser/animations';
5
- import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
6
- import { authInterceptor } from './interceptors/auth.interceptor';
7
- import { GlobalErrorHandler, ErrorInterceptor } from './services/error-handler.service';
8
-
9
- export const appConfig: ApplicationConfig = {
10
- providers: [
11
- provideRouter(routes),
12
- provideAnimations(),
13
- provideHttpClient(withInterceptors([authInterceptor])),
14
- { provide: ErrorHandler, useClass: GlobalErrorHandler },
15
- { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }
16
- ]
17
  };
 
1
+ import { ApplicationConfig, ErrorHandler } from '@angular/core';
2
+ import { provideRouter } from '@angular/router';
3
+ import { routes } from './app.routes';
4
+ import { provideAnimations } from '@angular/platform-browser/animations';
5
+ import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
6
+ import { authInterceptor } from './interceptors/auth.interceptor';
7
+ import { GlobalErrorHandler, ErrorInterceptor } from './services/error-handler.service';
8
+
9
+ export const appConfig: ApplicationConfig = {
10
+ providers: [
11
+ provideRouter(routes),
12
+ provideAnimations(),
13
+ provideHttpClient(withInterceptors([authInterceptor])),
14
+ { provide: ErrorHandler, useClass: GlobalErrorHandler },
15
+ { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }
16
+ ]
17
  };
flare-ui/src/app/app.routes.ts CHANGED
@@ -1,60 +1,60 @@
1
- import { Routes } from '@angular/router';
2
- import { authGuard } from './guards/auth.guard';
3
- import { RealtimeChatComponent } from './components/chat/realtime-chat.component';
4
-
5
- export const routes: Routes = [
6
- {
7
- path: 'login',
8
- loadComponent: () => import('./components/login/login.component').then(m => m.LoginComponent)
9
- },
10
- {
11
- path: '',
12
- loadComponent: () => import('./components/main/main.component').then(m => m.MainComponent),
13
- canActivate: [authGuard],
14
- children: [
15
- {
16
- path: 'user-info',
17
- loadComponent: () => import('./components/user-info/user-info.component').then(m => m.UserInfoComponent)
18
- },
19
- {
20
- path: 'environment',
21
- loadComponent: () => import('./components/environment/environment.component').then(m => m.EnvironmentComponent)
22
- },
23
- {
24
- path: 'apis',
25
- loadComponent: () => import('./components/apis/apis.component').then(m => m.ApisComponent)
26
- },
27
- {
28
- path: 'projects',
29
- loadComponent: () => import('./components/projects/projects.component').then(m => m.ProjectsComponent)
30
- },
31
- {
32
- path: 'test',
33
- loadComponent: () => import('./components/test/test.component').then(m => m.TestComponent)
34
- },
35
- {
36
- path: 'chat',
37
- loadComponent: () => import('./components/chat/chat.component').then(c => c.ChatComponent)
38
- },
39
- {
40
- path: 'spark',
41
- loadComponent: () => import('./components/spark/spark.component').then(m => m.SparkComponent)
42
- },
43
- {
44
- path: 'realtime-chat/:sessionId',
45
- loadComponent: () => import('./components/chat/realtime-chat.component').then(c => c.RealtimeChatComponent),
46
- canActivate: [authGuard ],
47
- data: { title: 'Real-time Chat' }
48
- },
49
- {
50
- path: '',
51
- redirectTo: 'projects',
52
- pathMatch: 'full'
53
- }
54
- ]
55
- },
56
- {
57
- path: '**',
58
- redirectTo: ''
59
- }
60
  ];
 
1
+ import { Routes } from '@angular/router';
2
+ import { authGuard } from './guards/auth.guard';
3
+ import { RealtimeChatComponent } from './components/chat/realtime-chat.component';
4
+
5
+ export const routes: Routes = [
6
+ {
7
+ path: 'login',
8
+ loadComponent: () => import('./components/login/login.component').then(m => m.LoginComponent)
9
+ },
10
+ {
11
+ path: '',
12
+ loadComponent: () => import('./components/main/main.component').then(m => m.MainComponent),
13
+ canActivate: [authGuard],
14
+ children: [
15
+ {
16
+ path: 'user-info',
17
+ loadComponent: () => import('./components/user-info/user-info.component').then(m => m.UserInfoComponent)
18
+ },
19
+ {
20
+ path: 'environment',
21
+ loadComponent: () => import('./components/environment/environment.component').then(m => m.EnvironmentComponent)
22
+ },
23
+ {
24
+ path: 'apis',
25
+ loadComponent: () => import('./components/apis/apis.component').then(m => m.ApisComponent)
26
+ },
27
+ {
28
+ path: 'projects',
29
+ loadComponent: () => import('./components/projects/projects.component').then(m => m.ProjectsComponent)
30
+ },
31
+ {
32
+ path: 'test',
33
+ loadComponent: () => import('./components/test/test.component').then(m => m.TestComponent)
34
+ },
35
+ {
36
+ path: 'chat',
37
+ loadComponent: () => import('./components/chat/chat.component').then(c => c.ChatComponent)
38
+ },
39
+ {
40
+ path: 'spark',
41
+ loadComponent: () => import('./components/spark/spark.component').then(m => m.SparkComponent)
42
+ },
43
+ {
44
+ path: 'realtime-chat/:sessionId',
45
+ loadComponent: () => import('./components/chat/realtime-chat.component').then(c => c.RealtimeChatComponent),
46
+ canActivate: [authGuard ],
47
+ data: { title: 'Real-time Chat' }
48
+ },
49
+ {
50
+ path: '',
51
+ redirectTo: 'projects',
52
+ pathMatch: 'full'
53
+ }
54
+ ]
55
+ },
56
+ {
57
+ path: '**',
58
+ redirectTo: ''
59
+ }
60
  ];
flare-ui/src/app/components/activity-log/activity-log.component.ts CHANGED
@@ -1,430 +1,430 @@
1
- import { Component, EventEmitter, Output, inject, OnInit, OnDestroy } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { HttpClient } from '@angular/common/http';
4
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
5
- import { MatButtonModule } from '@angular/material/button';
6
- import { MatIconModule } from '@angular/material/icon';
7
- import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
8
- import { MatListModule } from '@angular/material/list';
9
- import { MatCardModule } from '@angular/material/card';
10
- import { MatDividerModule } from '@angular/material/divider';
11
- import { Subject, takeUntil } from 'rxjs';
12
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
13
-
14
- interface ActivityLog {
15
- id: number;
16
- timestamp: string;
17
- user: string;
18
- action: string;
19
- entity_type: string;
20
- entity_id: any;
21
- entity_name: string;
22
- details?: string;
23
- }
24
-
25
- interface ActivityLogResponse {
26
- items: ActivityLog[];
27
- total: number;
28
- page: number;
29
- limit: number;
30
- pages: number;
31
- }
32
-
33
- @Component({
34
- selector: 'app-activity-log',
35
- standalone: true,
36
- imports: [
37
- CommonModule,
38
- MatProgressSpinnerModule,
39
- MatButtonModule,
40
- MatIconModule,
41
- MatPaginatorModule,
42
- MatListModule,
43
- MatCardModule,
44
- MatDividerModule,
45
- MatSnackBarModule
46
- ],
47
- template: `
48
- <mat-card class="activity-log-dropdown" (click)="$event.stopPropagation()">
49
- <mat-card-header>
50
- <mat-card-title>
51
- <mat-icon>notifications</mat-icon>
52
- Recent Activities
53
- </mat-card-title>
54
- <button mat-icon-button (click)="close.emit(); $event.stopPropagation()">
55
- <mat-icon>close</mat-icon>
56
- </button>
57
- </mat-card-header>
58
-
59
- <mat-card-content>
60
- @if (loading && activities.length === 0) {
61
- <div class="loading">
62
- <mat-spinner diameter="30"></mat-spinner>
63
- </div>
64
- } @else if (error && activities.length === 0) {
65
- <div class="error-state">
66
- <mat-icon>error_outline</mat-icon>
67
- <p>{{ error }}</p>
68
- <button mat-button (click)="retry()">
69
- <mat-icon>refresh</mat-icon>
70
- Retry
71
- </button>
72
- </div>
73
- } @else if (activities.length === 0) {
74
- <div class="empty">
75
- <mat-icon>inbox</mat-icon>
76
- <p>No activities found</p>
77
- </div>
78
- } @else {
79
- <mat-list class="activity-list">
80
- @for (activity of activities; track activity.id) {
81
- <mat-list-item>
82
- <mat-icon matListItemIcon>{{ getActivityIcon(activity.action) }}</mat-icon>
83
- <div matListItemTitle>
84
- <span class="activity-time">{{ getRelativeTime(activity.timestamp) }}</span>
85
- </div>
86
- <div matListItemLine>
87
- <strong>{{ activity.user }}</strong> {{ getActionText(activity) }}
88
- <em>{{ activity.entity_name }}</em>
89
- @if (activity.details) {
90
- <span class="details">• {{ activity.details }}</span>
91
- }
92
- </div>
93
- </mat-list-item>
94
- @if (!$last) {
95
- <mat-divider></mat-divider>
96
- }
97
- }
98
- </mat-list>
99
- }
100
- </mat-card-content>
101
-
102
- <mat-card-actions *ngIf="totalItems > pageSize">
103
- <mat-paginator
104
- [length]="totalItems"
105
- [pageSize]="pageSize"
106
- [pageIndex]="currentPage - 1"
107
- [pageSizeOptions]="[10, 25, 50]"
108
- (page)="onPageChange($event)"
109
- showFirstLastButtons>
110
- </mat-paginator>
111
- </mat-card-actions>
112
-
113
- <mat-card-actions *ngIf="totalItems <= pageSize && activities.length > 0">
114
- <button mat-button (click)="openFullView()" class="full-width">
115
- <mat-icon>open_in_new</mat-icon>
116
- View All Activities
117
- </button>
118
- </mat-card-actions>
119
- </mat-card>
120
- `,
121
- styles: [`
122
- .activity-log-dropdown {
123
- width: 450px;
124
- max-height: 600px;
125
- display: flex;
126
- flex-direction: column;
127
- overflow: hidden;
128
- }
129
-
130
- mat-card-header {
131
- display: flex;
132
- justify-content: space-between;
133
- align-items: center;
134
- padding: 16px;
135
- background-color: #424242;
136
- color: white;
137
-
138
- mat-card-title {
139
- margin: 0;
140
- display: flex;
141
- align-items: center;
142
- gap: 8px;
143
- font-size: 18px;
144
- color: white;
145
-
146
- mat-icon {
147
- font-size: 24px;
148
- width: 24px;
149
- height: 24px;
150
- color: white;
151
- }
152
- }
153
-
154
- button {
155
- color: white;
156
- }
157
- }
158
-
159
- mat-card-content {
160
- flex: 1;
161
- overflow-y: auto;
162
- padding: 0;
163
- min-height: 200px;
164
- max-height: 400px;
165
- }
166
-
167
- .activity-list {
168
- padding: 0;
169
-
170
- mat-list-item {
171
- height: auto;
172
- min-height: 72px;
173
- padding: 12px 16px;
174
-
175
- &:hover {
176
- background-color: #f5f5f5;
177
- }
178
-
179
- .activity-time {
180
- font-size: 12px;
181
- color: #666;
182
- }
183
-
184
- strong {
185
- color: #1976d2;
186
- margin-right: 4px;
187
- }
188
-
189
- em {
190
- color: #673ab7;
191
- font-style: normal;
192
- font-weight: 500;
193
- margin: 0 4px;
194
- }
195
-
196
- .details {
197
- color: #666;
198
- font-size: 12px;
199
- margin-left: 4px;
200
- }
201
- }
202
- }
203
-
204
- mat-card-actions {
205
- padding: 0;
206
- margin: 0;
207
-
208
- mat-paginator {
209
- background: transparent;
210
- }
211
-
212
- .full-width {
213
- width: 100%;
214
- margin: 0;
215
- }
216
- }
217
-
218
- .loading, .empty, .error-state {
219
- padding: 60px 20px;
220
- display: flex;
221
- flex-direction: column;
222
- align-items: center;
223
- justify-content: center;
224
- color: #666;
225
-
226
- mat-icon {
227
- font-size: 48px;
228
- width: 48px;
229
- height: 48px;
230
- color: #e0e0e0;
231
- margin-bottom: 16px;
232
- }
233
-
234
- p {
235
- margin: 0 0 16px;
236
- font-size: 14px;
237
- }
238
- }
239
-
240
- .error-state {
241
- mat-icon {
242
- color: #f44336;
243
- }
244
- }
245
-
246
- ::ng-deep {
247
- .mat-mdc-list-item-unscoped-content {
248
- display: block;
249
- }
250
-
251
- .mat-mdc-paginator {
252
- .mat-mdc-paginator-container {
253
- padding: 8px;
254
- justify-content: center;
255
- }
256
- }
257
- }
258
- `]
259
- })
260
- export class ActivityLogComponent implements OnInit, OnDestroy {
261
- @Output() close = new EventEmitter<void>();
262
-
263
- private http = inject(HttpClient);
264
- private snackBar = inject(MatSnackBar);
265
- private destroyed$ = new Subject<void>();
266
-
267
- activities: ActivityLog[] = [];
268
- loading = false;
269
- error = '';
270
- currentPage = 1;
271
- pageSize = 10;
272
- totalItems = 0;
273
- totalPages = 0;
274
-
275
- ngOnInit() {
276
- this.loadActivities();
277
- }
278
-
279
- ngOnDestroy() {
280
- this.destroyed$.next();
281
- this.destroyed$.complete();
282
- }
283
-
284
- loadActivities(page: number = 1) {
285
- this.loading = true;
286
- this.error = '';
287
- this.currentPage = page;
288
-
289
- // Backend sadece limit parametresi alıyor, page almıyor
290
- const limit = this.pageSize * page; // Toplam kaç kayıt istediğimizi hesapla
291
-
292
- this.http.get<ActivityLog[]>(
293
- `/api/activity-log?limit=${limit}`
294
- ).pipe(
295
- takeUntil(this.destroyed$)
296
- ).subscribe({
297
- next: (response) => {
298
- try {
299
- // Response direkt array olarak geliyor
300
- const allActivities = response || [];
301
-
302
- // Manual pagination yap
303
- const startIndex = (page - 1) * this.pageSize;
304
- const endIndex = startIndex + this.pageSize;
305
-
306
- this.activities = allActivities.slice(startIndex, endIndex);
307
- this.totalItems = allActivities.length;
308
- this.totalPages = Math.ceil(allActivities.length / this.pageSize);
309
- this.loading = false;
310
- } catch (err) {
311
- console.error('Failed to process activities:', err);
312
- this.error = 'Failed to process activity data';
313
- this.activities = [];
314
- this.loading = false;
315
- }
316
- },
317
- error: (error) => {
318
- console.error('Failed to load activities:', error);
319
- this.error = this.getErrorMessage(error);
320
- this.activities = [];
321
- this.loading = false;
322
-
323
- // Show error in snackbar
324
- this.snackBar.open(this.error, 'Close', {
325
- duration: 5000,
326
- panelClass: 'error-snackbar'
327
- });
328
- }
329
- });
330
- }
331
-
332
- onPageChange(event: PageEvent) {
333
- this.pageSize = event.pageSize;
334
- this.loadActivities(event.pageIndex + 1);
335
- }
336
-
337
- openFullView() {
338
- // TODO: Implement full activity log view
339
- console.log('Open full activity log view');
340
- this.close.emit();
341
- }
342
-
343
- retry() {
344
- this.loadActivities(this.currentPage);
345
- }
346
-
347
- getRelativeTime(timestamp: string): string {
348
- try {
349
- const date = new Date(timestamp);
350
- const now = new Date();
351
- const diffMs = now.getTime() - date.getTime();
352
- const diffMins = Math.floor(diffMs / 60000);
353
- const diffHours = Math.floor(diffMs / 3600000);
354
- const diffDays = Math.floor(diffMs / 86400000);
355
-
356
- if (diffMs < 0) return 'just now'; // Future dates
357
- if (diffMins < 1) return 'just now';
358
- if (diffMins < 60) return `${diffMins} min ago`;
359
- if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
360
- if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
361
-
362
- return date.toLocaleDateString();
363
- } catch (err) {
364
- console.error('Invalid timestamp:', timestamp, err);
365
- return 'Unknown';
366
- }
367
- }
368
-
369
- getActionText(activity: ActivityLog): string {
370
- const actions: Record<string, string> = {
371
- 'CREATE_PROJECT': 'created project',
372
- 'UPDATE_PROJECT': 'updated project',
373
- 'DELETE_PROJECT': 'deleted project',
374
- 'ENABLE_PROJECT': 'enabled project',
375
- 'DISABLE_PROJECT': 'disabled project',
376
- 'PUBLISH_VERSION': 'published version of',
377
- 'CREATE_VERSION': 'created version for',
378
- 'UPDATE_VERSION': 'updated version of',
379
- 'DELETE_VERSION': 'deleted version from',
380
- 'CREATE_API': 'created API',
381
- 'UPDATE_API': 'updated API',
382
- 'DELETE_API': 'deleted API',
383
- 'UPDATE_ENVIRONMENT': 'updated environment',
384
- 'IMPORT_PROJECT': 'imported project',
385
- 'CHANGE_PASSWORD': 'changed password',
386
- 'LOGIN': 'logged in',
387
- 'LOGOUT': 'logged out',
388
- 'FAILED_LOGIN': 'failed login attempt'
389
- };
390
-
391
- return actions[activity.action] || activity.action.toLowerCase().replace(/_/g, ' ');
392
- }
393
-
394
- getActivityIcon(action: string): string {
395
- if (action.includes('CREATE')) return 'add_circle';
396
- if (action.includes('UPDATE')) return 'edit';
397
- if (action.includes('DELETE')) return 'delete';
398
- if (action.includes('ENABLE')) return 'check_circle';
399
- if (action.includes('DISABLE')) return 'cancel';
400
- if (action.includes('PUBLISH')) return 'publish';
401
- if (action.includes('IMPORT')) return 'cloud_upload';
402
- if (action.includes('PASSWORD')) return 'lock';
403
- if (action.includes('LOGIN')) return 'login';
404
- if (action.includes('LOGOUT')) return 'logout';
405
- return 'info';
406
- }
407
-
408
- trackByActivityId(index: number, activity: ActivityLog): number {
409
- return activity.id;
410
- }
411
-
412
- isLast(activity: ActivityLog): boolean {
413
- return this.activities.indexOf(activity) === this.activities.length - 1;
414
- }
415
-
416
- private getErrorMessage(error: any): string {
417
- if (error.status === 0) {
418
- return 'Unable to connect to server. Please check your connection.';
419
- } else if (error.status === 401) {
420
- return 'Session expired. Please login again.';
421
- } else if (error.status === 403) {
422
- return 'You do not have permission to view activity logs.';
423
- } else if (error.error?.detail) {
424
- return error.error.detail;
425
- } else if (error.message) {
426
- return error.message;
427
- }
428
- return 'Failed to load activities. Please try again.';
429
- }
430
  }
 
1
+ import { Component, EventEmitter, Output, inject, OnInit, OnDestroy } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { HttpClient } from '@angular/common/http';
4
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { MatIconModule } from '@angular/material/icon';
7
+ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
8
+ import { MatListModule } from '@angular/material/list';
9
+ import { MatCardModule } from '@angular/material/card';
10
+ import { MatDividerModule } from '@angular/material/divider';
11
+ import { Subject, takeUntil } from 'rxjs';
12
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
13
+
14
+ interface ActivityLog {
15
+ id: number;
16
+ timestamp: string;
17
+ user: string;
18
+ action: string;
19
+ entity_type: string;
20
+ entity_id: any;
21
+ entity_name: string;
22
+ details?: string;
23
+ }
24
+
25
+ interface ActivityLogResponse {
26
+ items: ActivityLog[];
27
+ total: number;
28
+ page: number;
29
+ limit: number;
30
+ pages: number;
31
+ }
32
+
33
+ @Component({
34
+ selector: 'app-activity-log',
35
+ standalone: true,
36
+ imports: [
37
+ CommonModule,
38
+ MatProgressSpinnerModule,
39
+ MatButtonModule,
40
+ MatIconModule,
41
+ MatPaginatorModule,
42
+ MatListModule,
43
+ MatCardModule,
44
+ MatDividerModule,
45
+ MatSnackBarModule
46
+ ],
47
+ template: `
48
+ <mat-card class="activity-log-dropdown" (click)="$event.stopPropagation()">
49
+ <mat-card-header>
50
+ <mat-card-title>
51
+ <mat-icon>notifications</mat-icon>
52
+ Recent Activities
53
+ </mat-card-title>
54
+ <button mat-icon-button (click)="close.emit(); $event.stopPropagation()">
55
+ <mat-icon>close</mat-icon>
56
+ </button>
57
+ </mat-card-header>
58
+
59
+ <mat-card-content>
60
+ @if (loading && activities.length === 0) {
61
+ <div class="loading">
62
+ <mat-spinner diameter="30"></mat-spinner>
63
+ </div>
64
+ } @else if (error && activities.length === 0) {
65
+ <div class="error-state">
66
+ <mat-icon>error_outline</mat-icon>
67
+ <p>{{ error }}</p>
68
+ <button mat-button (click)="retry()">
69
+ <mat-icon>refresh</mat-icon>
70
+ Retry
71
+ </button>
72
+ </div>
73
+ } @else if (activities.length === 0) {
74
+ <div class="empty">
75
+ <mat-icon>inbox</mat-icon>
76
+ <p>No activities found</p>
77
+ </div>
78
+ } @else {
79
+ <mat-list class="activity-list">
80
+ @for (activity of activities; track activity.id) {
81
+ <mat-list-item>
82
+ <mat-icon matListItemIcon>{{ getActivityIcon(activity.action) }}</mat-icon>
83
+ <div matListItemTitle>
84
+ <span class="activity-time">{{ getRelativeTime(activity.timestamp) }}</span>
85
+ </div>
86
+ <div matListItemLine>
87
+ <strong>{{ activity.user }}</strong> {{ getActionText(activity) }}
88
+ <em>{{ activity.entity_name }}</em>
89
+ @if (activity.details) {
90
+ <span class="details">• {{ activity.details }}</span>
91
+ }
92
+ </div>
93
+ </mat-list-item>
94
+ @if (!$last) {
95
+ <mat-divider></mat-divider>
96
+ }
97
+ }
98
+ </mat-list>
99
+ }
100
+ </mat-card-content>
101
+
102
+ <mat-card-actions *ngIf="totalItems > pageSize">
103
+ <mat-paginator
104
+ [length]="totalItems"
105
+ [pageSize]="pageSize"
106
+ [pageIndex]="currentPage - 1"
107
+ [pageSizeOptions]="[10, 25, 50]"
108
+ (page)="onPageChange($event)"
109
+ showFirstLastButtons>
110
+ </mat-paginator>
111
+ </mat-card-actions>
112
+
113
+ <mat-card-actions *ngIf="totalItems <= pageSize && activities.length > 0">
114
+ <button mat-button (click)="openFullView()" class="full-width">
115
+ <mat-icon>open_in_new</mat-icon>
116
+ View All Activities
117
+ </button>
118
+ </mat-card-actions>
119
+ </mat-card>
120
+ `,
121
+ styles: [`
122
+ .activity-log-dropdown {
123
+ width: 450px;
124
+ max-height: 600px;
125
+ display: flex;
126
+ flex-direction: column;
127
+ overflow: hidden;
128
+ }
129
+
130
+ mat-card-header {
131
+ display: flex;
132
+ justify-content: space-between;
133
+ align-items: center;
134
+ padding: 16px;
135
+ background-color: #424242;
136
+ color: white;
137
+
138
+ mat-card-title {
139
+ margin: 0;
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 8px;
143
+ font-size: 18px;
144
+ color: white;
145
+
146
+ mat-icon {
147
+ font-size: 24px;
148
+ width: 24px;
149
+ height: 24px;
150
+ color: white;
151
+ }
152
+ }
153
+
154
+ button {
155
+ color: white;
156
+ }
157
+ }
158
+
159
+ mat-card-content {
160
+ flex: 1;
161
+ overflow-y: auto;
162
+ padding: 0;
163
+ min-height: 200px;
164
+ max-height: 400px;
165
+ }
166
+
167
+ .activity-list {
168
+ padding: 0;
169
+
170
+ mat-list-item {
171
+ height: auto;
172
+ min-height: 72px;
173
+ padding: 12px 16px;
174
+
175
+ &:hover {
176
+ background-color: #f5f5f5;
177
+ }
178
+
179
+ .activity-time {
180
+ font-size: 12px;
181
+ color: #666;
182
+ }
183
+
184
+ strong {
185
+ color: #1976d2;
186
+ margin-right: 4px;
187
+ }
188
+
189
+ em {
190
+ color: #673ab7;
191
+ font-style: normal;
192
+ font-weight: 500;
193
+ margin: 0 4px;
194
+ }
195
+
196
+ .details {
197
+ color: #666;
198
+ font-size: 12px;
199
+ margin-left: 4px;
200
+ }
201
+ }
202
+ }
203
+
204
+ mat-card-actions {
205
+ padding: 0;
206
+ margin: 0;
207
+
208
+ mat-paginator {
209
+ background: transparent;
210
+ }
211
+
212
+ .full-width {
213
+ width: 100%;
214
+ margin: 0;
215
+ }
216
+ }
217
+
218
+ .loading, .empty, .error-state {
219
+ padding: 60px 20px;
220
+ display: flex;
221
+ flex-direction: column;
222
+ align-items: center;
223
+ justify-content: center;
224
+ color: #666;
225
+
226
+ mat-icon {
227
+ font-size: 48px;
228
+ width: 48px;
229
+ height: 48px;
230
+ color: #e0e0e0;
231
+ margin-bottom: 16px;
232
+ }
233
+
234
+ p {
235
+ margin: 0 0 16px;
236
+ font-size: 14px;
237
+ }
238
+ }
239
+
240
+ .error-state {
241
+ mat-icon {
242
+ color: #f44336;
243
+ }
244
+ }
245
+
246
+ ::ng-deep {
247
+ .mat-mdc-list-item-unscoped-content {
248
+ display: block;
249
+ }
250
+
251
+ .mat-mdc-paginator {
252
+ .mat-mdc-paginator-container {
253
+ padding: 8px;
254
+ justify-content: center;
255
+ }
256
+ }
257
+ }
258
+ `]
259
+ })
260
+ export class ActivityLogComponent implements OnInit, OnDestroy {
261
+ @Output() close = new EventEmitter<void>();
262
+
263
+ private http = inject(HttpClient);
264
+ private snackBar = inject(MatSnackBar);
265
+ private destroyed$ = new Subject<void>();
266
+
267
+ activities: ActivityLog[] = [];
268
+ loading = false;
269
+ error = '';
270
+ currentPage = 1;
271
+ pageSize = 10;
272
+ totalItems = 0;
273
+ totalPages = 0;
274
+
275
+ ngOnInit() {
276
+ this.loadActivities();
277
+ }
278
+
279
+ ngOnDestroy() {
280
+ this.destroyed$.next();
281
+ this.destroyed$.complete();
282
+ }
283
+
284
+ loadActivities(page: number = 1) {
285
+ this.loading = true;
286
+ this.error = '';
287
+ this.currentPage = page;
288
+
289
+ // Backend sadece limit parametresi alıyor, page almıyor
290
+ const limit = this.pageSize * page; // Toplam kaç kayıt istediğimizi hesapla
291
+
292
+ this.http.get<ActivityLog[]>(
293
+ `/api/activity-log?limit=${limit}`
294
+ ).pipe(
295
+ takeUntil(this.destroyed$)
296
+ ).subscribe({
297
+ next: (response) => {
298
+ try {
299
+ // Response direkt array olarak geliyor
300
+ const allActivities = response || [];
301
+
302
+ // Manual pagination yap
303
+ const startIndex = (page - 1) * this.pageSize;
304
+ const endIndex = startIndex + this.pageSize;
305
+
306
+ this.activities = allActivities.slice(startIndex, endIndex);
307
+ this.totalItems = allActivities.length;
308
+ this.totalPages = Math.ceil(allActivities.length / this.pageSize);
309
+ this.loading = false;
310
+ } catch (err) {
311
+ console.error('Failed to process activities:', err);
312
+ this.error = 'Failed to process activity data';
313
+ this.activities = [];
314
+ this.loading = false;
315
+ }
316
+ },
317
+ error: (error) => {
318
+ console.error('Failed to load activities:', error);
319
+ this.error = this.getErrorMessage(error);
320
+ this.activities = [];
321
+ this.loading = false;
322
+
323
+ // Show error in snackbar
324
+ this.snackBar.open(this.error, 'Close', {
325
+ duration: 5000,
326
+ panelClass: 'error-snackbar'
327
+ });
328
+ }
329
+ });
330
+ }
331
+
332
+ onPageChange(event: PageEvent) {
333
+ this.pageSize = event.pageSize;
334
+ this.loadActivities(event.pageIndex + 1);
335
+ }
336
+
337
+ openFullView() {
338
+ // TODO: Implement full activity log view
339
+ console.log('Open full activity log view');
340
+ this.close.emit();
341
+ }
342
+
343
+ retry() {
344
+ this.loadActivities(this.currentPage);
345
+ }
346
+
347
+ getRelativeTime(timestamp: string): string {
348
+ try {
349
+ const date = new Date(timestamp);
350
+ const now = new Date();
351
+ const diffMs = now.getTime() - date.getTime();
352
+ const diffMins = Math.floor(diffMs / 60000);
353
+ const diffHours = Math.floor(diffMs / 3600000);
354
+ const diffDays = Math.floor(diffMs / 86400000);
355
+
356
+ if (diffMs < 0) return 'just now'; // Future dates
357
+ if (diffMins < 1) return 'just now';
358
+ if (diffMins < 60) return `${diffMins} min ago`;
359
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
360
+ if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
361
+
362
+ return date.toLocaleDateString();
363
+ } catch (err) {
364
+ console.error('Invalid timestamp:', timestamp, err);
365
+ return 'Unknown';
366
+ }
367
+ }
368
+
369
+ getActionText(activity: ActivityLog): string {
370
+ const actions: Record<string, string> = {
371
+ 'CREATE_PROJECT': 'created project',
372
+ 'UPDATE_PROJECT': 'updated project',
373
+ 'DELETE_PROJECT': 'deleted project',
374
+ 'ENABLE_PROJECT': 'enabled project',
375
+ 'DISABLE_PROJECT': 'disabled project',
376
+ 'PUBLISH_VERSION': 'published version of',
377
+ 'CREATE_VERSION': 'created version for',
378
+ 'UPDATE_VERSION': 'updated version of',
379
+ 'DELETE_VERSION': 'deleted version from',
380
+ 'CREATE_API': 'created API',
381
+ 'UPDATE_API': 'updated API',
382
+ 'DELETE_API': 'deleted API',
383
+ 'UPDATE_ENVIRONMENT': 'updated environment',
384
+ 'IMPORT_PROJECT': 'imported project',
385
+ 'CHANGE_PASSWORD': 'changed password',
386
+ 'LOGIN': 'logged in',
387
+ 'LOGOUT': 'logged out',
388
+ 'FAILED_LOGIN': 'failed login attempt'
389
+ };
390
+
391
+ return actions[activity.action] || activity.action.toLowerCase().replace(/_/g, ' ');
392
+ }
393
+
394
+ getActivityIcon(action: string): string {
395
+ if (action.includes('CREATE')) return 'add_circle';
396
+ if (action.includes('UPDATE')) return 'edit';
397
+ if (action.includes('DELETE')) return 'delete';
398
+ if (action.includes('ENABLE')) return 'check_circle';
399
+ if (action.includes('DISABLE')) return 'cancel';
400
+ if (action.includes('PUBLISH')) return 'publish';
401
+ if (action.includes('IMPORT')) return 'cloud_upload';
402
+ if (action.includes('PASSWORD')) return 'lock';
403
+ if (action.includes('LOGIN')) return 'login';
404
+ if (action.includes('LOGOUT')) return 'logout';
405
+ return 'info';
406
+ }
407
+
408
+ trackByActivityId(index: number, activity: ActivityLog): number {
409
+ return activity.id;
410
+ }
411
+
412
+ isLast(activity: ActivityLog): boolean {
413
+ return this.activities.indexOf(activity) === this.activities.length - 1;
414
+ }
415
+
416
+ private getErrorMessage(error: any): string {
417
+ if (error.status === 0) {
418
+ return 'Unable to connect to server. Please check your connection.';
419
+ } else if (error.status === 401) {
420
+ return 'Session expired. Please login again.';
421
+ } else if (error.status === 403) {
422
+ return 'You do not have permission to view activity logs.';
423
+ } else if (error.error?.detail) {
424
+ return error.error.detail;
425
+ } else if (error.message) {
426
+ return error.message;
427
+ }
428
+ return 'Failed to load activities. Please try again.';
429
+ }
430
  }
flare-ui/src/app/components/apis/apis.component.ts CHANGED
@@ -1,742 +1,742 @@
1
- import { Component, inject, OnInit, OnDestroy } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { MatDialog, MatDialogModule } from '@angular/material/dialog';
5
- import { MatTableModule } from '@angular/material/table';
6
- import { MatButtonModule } from '@angular/material/button';
7
- import { MatIconModule } from '@angular/material/icon';
8
- import { MatFormFieldModule } from '@angular/material/form-field';
9
- import { MatInputModule } from '@angular/material/input';
10
- import { MatCheckboxModule } from '@angular/material/checkbox';
11
- import { MatProgressBarModule } from '@angular/material/progress-bar';
12
- import { MatChipsModule } from '@angular/material/chips';
13
- import { MatMenuModule } from '@angular/material/menu';
14
- import { MatTooltipModule } from '@angular/material/tooltip';
15
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
16
- import { MatDividerModule } from '@angular/material/divider';
17
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
18
- import { ApiService, API } from '../../services/api.service';
19
- import { Subject, takeUntil } from 'rxjs';
20
-
21
- @Component({
22
- selector: 'app-apis',
23
- standalone: true,
24
- imports: [
25
- CommonModule,
26
- FormsModule,
27
- MatDialogModule,
28
- MatTableModule,
29
- MatButtonModule,
30
- MatIconModule,
31
- MatFormFieldModule,
32
- MatInputModule,
33
- MatCheckboxModule,
34
- MatProgressBarModule,
35
- MatChipsModule,
36
- MatMenuModule,
37
- MatTooltipModule,
38
- MatSnackBarModule,
39
- MatDividerModule,
40
- MatProgressSpinnerModule
41
- ],
42
- template: `
43
- <div class="apis-container">
44
- <div class="toolbar">
45
- <h2>API Definitions</h2>
46
- <div class="toolbar-actions">
47
- <button mat-raised-button color="primary" (click)="createAPI()" [disabled]="loading">
48
- <mat-icon>add</mat-icon>
49
- New API
50
- </button>
51
- <button mat-button (click)="importAPIs()" [disabled]="loading">
52
- <mat-icon>upload</mat-icon>
53
- Import
54
- </button>
55
- <button mat-button (click)="exportAPIs()" [disabled]="loading || filteredAPIs.length === 0">
56
- <mat-icon>download</mat-icon>
57
- Export
58
- </button>
59
- <mat-form-field appearance="outline" class="search-field">
60
- <mat-label>Search APIs</mat-label>
61
- <input matInput [(ngModel)]="searchTerm" (input)="filterAPIs()">
62
- <mat-icon matSuffix>search</mat-icon>
63
- </mat-form-field>
64
- <mat-checkbox [(ngModel)]="showDeleted" (change)="loadAPIs()">
65
- Display Deleted
66
- </mat-checkbox>
67
- </div>
68
- </div>
69
-
70
- <mat-progress-bar mode="indeterminate" *ngIf="loading"></mat-progress-bar>
71
-
72
- @if (!loading && error) {
73
- <div class="error-state">
74
- <mat-icon>error_outline</mat-icon>
75
- <p>{{ error }}</p>
76
- <button mat-raised-button color="primary" (click)="loadAPIs()">
77
- <mat-icon>refresh</mat-icon>
78
- Retry
79
- </button>
80
- </div>
81
- } @else if (!loading && filteredAPIs.length === 0 && !searchTerm) {
82
- <div class="empty-state">
83
- <mat-icon>api</mat-icon>
84
- <p>No APIs found.</p>
85
- <button mat-raised-button color="primary" (click)="createAPI()">
86
- Create your first API
87
- </button>
88
- </div>
89
- } @else if (!loading && filteredAPIs.length === 0 && searchTerm) {
90
- <div class="empty-state">
91
- <mat-icon>search_off</mat-icon>
92
- <p>No APIs match your search.</p>
93
- <button mat-button (click)="searchTerm = ''; filterAPIs()">
94
- Clear search
95
- </button>
96
- </div>
97
- } @else if (!loading) {
98
- <table mat-table [dataSource]="filteredAPIs" class="apis-table">
99
- <!-- Name Column -->
100
- <ng-container matColumnDef="name">
101
- <th mat-header-cell *matHeaderCellDef>Name</th>
102
- <td mat-cell *matCellDef="let api">{{ api.name }}</td>
103
- </ng-container>
104
-
105
- <!-- URL Column -->
106
- <ng-container matColumnDef="url">
107
- <th mat-header-cell *matHeaderCellDef>URL</th>
108
- <td mat-cell *matCellDef="let api" class="url-cell">
109
- <span [matTooltip]="api.url">{{ api.url }}</span>
110
- </td>
111
- </ng-container>
112
-
113
- <!-- Method Column -->
114
- <ng-container matColumnDef="method">
115
- <th mat-header-cell *matHeaderCellDef>Method</th>
116
- <td mat-cell *matCellDef="let api">
117
- <mat-chip [class]="'method-' + api.method.toLowerCase()">
118
- {{ api.method }}
119
- </mat-chip>
120
- </td>
121
- </ng-container>
122
-
123
- <!-- Timeout Column -->
124
- <ng-container matColumnDef="timeout">
125
- <th mat-header-cell *matHeaderCellDef>Timeout</th>
126
- <td mat-cell *matCellDef="let api">{{ api.timeout_seconds }}s</td>
127
- </ng-container>
128
-
129
- <!-- Auth Column -->
130
- <ng-container matColumnDef="auth">
131
- <th mat-header-cell *matHeaderCellDef>Auth</th>
132
- <td mat-cell *matCellDef="let api">
133
- <mat-icon [color]="api.auth?.enabled ? 'primary' : ''"
134
- [matTooltip]="api.auth?.enabled ? 'Authentication enabled' : 'No authentication'">
135
- {{ api.auth?.enabled ? 'lock' : 'lock_open' }}
136
- </mat-icon>
137
- </td>
138
- </ng-container>
139
-
140
- <!-- Deleted Column -->
141
- <ng-container matColumnDef="deleted">
142
- <th mat-header-cell *matHeaderCellDef>Status</th>
143
- <td mat-cell *matCellDef="let api">
144
- @if (api.deleted) {
145
- <mat-icon color="warn" matTooltip="Deleted">delete</mat-icon>
146
- } @else {
147
- <mat-icon color="primary" matTooltip="Active">check_circle</mat-icon>
148
- }
149
- </td>
150
- </ng-container>
151
-
152
- <!-- Actions Column -->
153
- <ng-container matColumnDef="actions">
154
- <th mat-header-cell *matHeaderCellDef>Actions</th>
155
- <td mat-cell *matCellDef="let api">
156
- <button mat-icon-button [matMenuTriggerFor]="menu"
157
- (click)="$event.stopPropagation()"
158
- [disabled]="actionLoading[api.name]">
159
- @if (actionLoading[api.name]) {
160
- <mat-spinner diameter="20"></mat-spinner>
161
- } @else {
162
- <mat-icon>more_vert</mat-icon>
163
- }
164
- </button>
165
- <mat-menu #menu="matMenu">
166
- <button mat-menu-item (click)="editAPI(api)" [disabled]="api.deleted">
167
- <mat-icon>edit</mat-icon>
168
- <span>Edit</span>
169
- </button>
170
- <button mat-menu-item (click)="testAPI(api)">
171
- <mat-icon>play_arrow</mat-icon>
172
- <span>Test</span>
173
- </button>
174
- <button mat-menu-item (click)="duplicateAPI(api)">
175
- <mat-icon>content_copy</mat-icon>
176
- <span>Duplicate</span>
177
- </button>
178
- @if (!api.deleted) {
179
- <mat-divider></mat-divider>
180
- <button mat-menu-item (click)="deleteAPI(api)">
181
- <mat-icon color="warn">delete</mat-icon>
182
- <span>Delete</span>
183
- </button>
184
- } @else {
185
- <mat-divider></mat-divider>
186
- <button mat-menu-item (click)="restoreAPI(api)">
187
- <mat-icon color="primary">restore</mat-icon>
188
- <span>Restore</span>
189
- </button>
190
- }
191
- </mat-menu>
192
- </td>
193
- </ng-container>
194
-
195
- <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
196
- <tr mat-row *matRowDef="let row; columns: displayedColumns;"
197
- [class.deleted-row]="row.deleted"
198
- (click)="editAPI(row)"></tr>
199
- </table>
200
- }
201
- </div>
202
- `,
203
- styles: [`
204
- .apis-container {
205
- .toolbar {
206
- display: flex;
207
- justify-content: space-between;
208
- align-items: center;
209
- margin-bottom: 24px;
210
- flex-wrap: wrap;
211
- gap: 16px;
212
-
213
- h2 {
214
- margin: 0;
215
- font-size: 24px;
216
- }
217
-
218
- .toolbar-actions {
219
- display: flex;
220
- gap: 16px;
221
- align-items: center;
222
- flex-wrap: wrap;
223
-
224
- .search-field {
225
- width: 250px;
226
- }
227
- }
228
- }
229
-
230
- .empty-state, .error-state {
231
- text-align: center;
232
- padding: 60px 20px;
233
- background-color: white;
234
- border-radius: 8px;
235
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
236
-
237
- mat-icon {
238
- font-size: 64px;
239
- width: 64px;
240
- height: 64px;
241
- color: #e0e0e0;
242
- margin-bottom: 16px;
243
- }
244
-
245
- p {
246
- margin-bottom: 24px;
247
- color: #666;
248
- font-size: 16px;
249
- }
250
- }
251
-
252
- .error-state {
253
- mat-icon {
254
- color: #f44336;
255
- }
256
- }
257
-
258
- .apis-table {
259
- width: 100%;
260
- background: white;
261
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
262
-
263
- .url-cell {
264
- max-width: 300px;
265
-
266
- span {
267
- overflow: hidden;
268
- text-overflow: ellipsis;
269
- white-space: nowrap;
270
- display: block;
271
- }
272
- }
273
-
274
- mat-chip {
275
- font-size: 12px;
276
- min-height: 24px;
277
- padding: 4px 12px;
278
-
279
- &.method-get { background-color: #4caf50; color: white; }
280
- &.method-post { background-color: #2196f3; color: white; }
281
- &.method-put { background-color: #ff9800; color: white; }
282
- &.method-patch { background-color: #9c27b0; color: white; }
283
- &.method-delete { background-color: #f44336; color: white; }
284
- }
285
-
286
- tr.mat-mdc-row {
287
- cursor: pointer;
288
- transition: background-color 0.2s;
289
-
290
- &:hover {
291
- background-color: #f5f5f5;
292
- }
293
-
294
- &.deleted-row {
295
- opacity: 0.6;
296
- background-color: #fafafa;
297
- cursor: default;
298
- }
299
- }
300
-
301
- mat-spinner {
302
- display: inline-block;
303
- }
304
- }
305
- }
306
-
307
- ::ng-deep {
308
- .mat-mdc-form-field {
309
- font-size: 14px;
310
- }
311
-
312
- .mat-mdc-checkbox {
313
- .mdc-form-field {
314
- font-size: 14px;
315
- }
316
- }
317
- }
318
- `]
319
- })
320
- export class ApisComponent implements OnInit, OnDestroy {
321
- private apiService = inject(ApiService);
322
- private dialog = inject(MatDialog);
323
- private snackBar = inject(MatSnackBar);
324
- private destroyed$ = new Subject<void>();
325
-
326
- apis: API[] = [];
327
- filteredAPIs: API[] = [];
328
- loading = true;
329
- error = '';
330
- showDeleted = false;
331
- searchTerm = '';
332
- actionLoading: { [key: string]: boolean } = {};
333
-
334
- displayedColumns: string[] = ['name', 'url', 'method', 'timeout', 'auth', 'deleted', 'actions'];
335
-
336
- ngOnInit() {
337
- this.loadAPIs();
338
- }
339
-
340
- ngOnDestroy() {
341
- this.destroyed$.next();
342
- this.destroyed$.complete();
343
- }
344
-
345
- loadAPIs() {
346
- this.loading = true;
347
- this.error = '';
348
-
349
- this.apiService.getAPIs(this.showDeleted).pipe(
350
- takeUntil(this.destroyed$)
351
- ).subscribe({
352
- next: (apis) => {
353
- this.apis = apis;
354
- this.filterAPIs();
355
- this.loading = false;
356
- },
357
- error: (err) => {
358
- this.error = this.getErrorMessage(err);
359
- this.snackBar.open(this.error, 'Close', {
360
- duration: 5000,
361
- panelClass: 'error-snackbar'
362
- });
363
- this.loading = false;
364
- }
365
- });
366
- }
367
-
368
- filterAPIs() {
369
- const term = this.searchTerm.toLowerCase().trim();
370
- if (!term) {
371
- this.filteredAPIs = [...this.apis];
372
- } else {
373
- this.filteredAPIs = this.apis.filter(api =>
374
- api.name.toLowerCase().includes(term) ||
375
- api.url.toLowerCase().includes(term) ||
376
- api.method.toLowerCase().includes(term)
377
- );
378
- }
379
- }
380
-
381
- async createAPI() {
382
- try {
383
- const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
384
-
385
- const dialogRef = this.dialog.open(ApiEditDialogComponent, {
386
- width: '800px',
387
- data: { mode: 'create' },
388
- disableClose: true
389
- });
390
-
391
- dialogRef.afterClosed().pipe(
392
- takeUntil(this.destroyed$)
393
- ).subscribe((result: any) => {
394
- if (result) {
395
- this.loadAPIs();
396
- }
397
- });
398
- } catch (error) {
399
- console.error('Failed to load dialog:', error);
400
- this.snackBar.open('Failed to open dialog', 'Close', {
401
- duration: 3000,
402
- panelClass: 'error-snackbar'
403
- });
404
- }
405
- }
406
-
407
- async editAPI(api: API) {
408
- if (api.deleted) return;
409
-
410
- try {
411
- const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
412
-
413
- const dialogRef = this.dialog.open(ApiEditDialogComponent, {
414
- width: '800px',
415
- data: { mode: 'edit', api: { ...api } },
416
- disableClose: true
417
- });
418
-
419
- dialogRef.afterClosed().pipe(
420
- takeUntil(this.destroyed$)
421
- ).subscribe((result: any) => {
422
- if (result) {
423
- this.loadAPIs();
424
- }
425
- });
426
- } catch (error) {
427
- console.error('Failed to load dialog:', error);
428
- this.snackBar.open('Failed to open dialog', 'Close', {
429
- duration: 3000,
430
- panelClass: 'error-snackbar'
431
- });
432
- }
433
- }
434
-
435
- async testAPI(api: API) {
436
- try {
437
- const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
438
-
439
- const dialogRef = this.dialog.open(ApiEditDialogComponent, {
440
- width: '800px',
441
- data: {
442
- mode: 'test',
443
- api: { ...api },
444
- activeTab: 4
445
- },
446
- disableClose: false
447
- });
448
-
449
- dialogRef.afterClosed().pipe(
450
- takeUntil(this.destroyed$)
451
- ).subscribe((result: any) => {
452
- if (result) {
453
- this.loadAPIs();
454
- }
455
- });
456
- } catch (error) {
457
- console.error('Failed to load dialog:', error);
458
- this.snackBar.open('Failed to open dialog', 'Close', {
459
- duration: 3000,
460
- panelClass: 'error-snackbar'
461
- });
462
- }
463
- }
464
-
465
- async duplicateAPI(api: API) {
466
- try {
467
- const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
468
-
469
- const duplicatedApi = { ...api };
470
- duplicatedApi.name = `${api.name}_copy`;
471
- delete (duplicatedApi as any).last_update_date;
472
-
473
- const dialogRef = this.dialog.open(ApiEditDialogComponent, {
474
- width: '800px',
475
- data: { mode: 'duplicate', api: duplicatedApi },
476
- disableClose: true
477
- });
478
-
479
- dialogRef.afterClosed().pipe(
480
- takeUntil(this.destroyed$)
481
- ).subscribe((result: any) => {
482
- if (result) {
483
- this.loadAPIs();
484
- }
485
- });
486
- } catch (error) {
487
- console.error('Failed to load dialog:', error);
488
- this.snackBar.open('Failed to open dialog', 'Close', {
489
- duration: 3000,
490
- panelClass: 'error-snackbar'
491
- });
492
- }
493
- }
494
-
495
- async deleteAPI(api: API) {
496
- if (api.deleted) return;
497
-
498
- try {
499
- const { default: ConfirmDialogComponent } = await import('../../dialogs/confirm-dialog/confirm-dialog.component');
500
-
501
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
502
- width: '400px',
503
- data: {
504
- title: 'Delete API',
505
- message: `Are you sure you want to delete "${api.name}"?`,
506
- confirmText: 'Delete',
507
- confirmColor: 'warn'
508
- }
509
- });
510
-
511
- dialogRef.afterClosed().pipe(
512
- takeUntil(this.destroyed$)
513
- ).subscribe((confirmed) => {
514
- if (confirmed) {
515
- this.actionLoading[api.name] = true;
516
-
517
- this.apiService.deleteAPI(api.name).pipe(
518
- takeUntil(this.destroyed$)
519
- ).subscribe({
520
- next: () => {
521
- this.snackBar.open(`API "${api.name}" deleted successfully`, 'Close', {
522
- duration: 3000
523
- });
524
- this.loadAPIs();
525
- },
526
- error: (err) => {
527
- const errorMsg = this.getErrorMessage(err);
528
- this.snackBar.open(errorMsg, 'Close', {
529
- duration: 5000,
530
- panelClass: 'error-snackbar'
531
- });
532
- this.actionLoading[api.name] = false;
533
- }
534
- });
535
- }
536
- });
537
- } catch (error) {
538
- console.error('Failed to load dialog:', error);
539
- this.snackBar.open('Failed to open dialog', 'Close', {
540
- duration: 3000,
541
- panelClass: 'error-snackbar'
542
- });
543
- }
544
- }
545
-
546
- async restoreAPI(api: API) {
547
- if (!api.deleted) return;
548
-
549
- // Implement restore API functionality
550
- this.snackBar.open('Restore functionality not implemented yet', 'Close', {
551
- duration: 3000
552
- });
553
- }
554
-
555
- async importAPIs() {
556
- const input = document.createElement('input');
557
- input.type = 'file';
558
- input.accept = '.json';
559
-
560
- input.onchange = async (event: any) => {
561
- const file = event.target.files[0];
562
- if (!file) return;
563
-
564
- try {
565
- const text = await file.text();
566
- let apis: any[];
567
-
568
- try {
569
- apis = JSON.parse(text);
570
- } catch (parseError) {
571
- this.snackBar.open('Invalid JSON file format', 'Close', {
572
- duration: 5000,
573
- panelClass: 'error-snackbar'
574
- });
575
- return;
576
- }
577
-
578
- if (!Array.isArray(apis)) {
579
- this.snackBar.open('Invalid file format. Expected an array of APIs.', 'Close', {
580
- duration: 5000,
581
- panelClass: 'error-snackbar'
582
- });
583
- return;
584
- }
585
-
586
- this.loading = true;
587
- let imported = 0;
588
- let failed = 0;
589
- const errors: string[] = [];
590
-
591
- console.log('Starting API import, total APIs:', apis.length);
592
-
593
- for (const api of apis) {
594
- try {
595
- await this.apiService.createAPI(api).toPromise();
596
- imported++;
597
- } catch (err: any) {
598
- failed++;
599
- const apiName = api.name || 'unnamed';
600
-
601
- console.error(`❌ Failed to import API ${apiName}:`, err);
602
-
603
- // Parse error message - daha iyi hata mesajı parse etme
604
- let errorMsg = 'Unknown error';
605
-
606
- if (err.status === 409) {
607
- // DuplicateResourceError durumu
608
- errorMsg = `API with name '${apiName}' already exists`;
609
- } else if (err.status === 500 && err.error?.detail?.includes('already exists')) {
610
- // Backend'den gelen duplicate hatası
611
- errorMsg = `API with name '${apiName}' already exists`;
612
- } else if (err.error?.message) {
613
- errorMsg = err.error.message;
614
- } else if (err.error?.detail) {
615
- errorMsg = err.error.detail;
616
- } else if (err.message) {
617
- errorMsg = err.message;
618
- }
619
-
620
- errors.push(`${apiName}: ${errorMsg}`);
621
- }
622
- }
623
-
624
- this.loading = false;
625
-
626
- if (imported > 0) {
627
- this.loadAPIs();
628
- }
629
-
630
- // Always show dialog for import results
631
- try {
632
- await this.showImportResultsDialog(imported, failed, errors);
633
- } catch (dialogError) {
634
- console.error('Failed to show import dialog:', dialogError);
635
- // Fallback to snackbar
636
- this.showImportResultsSnackbar(imported, failed, errors);
637
- }
638
-
639
- } catch (error) {
640
- this.loading = false;
641
- this.snackBar.open('Failed to read file', 'Close', {
642
- duration: 5000,
643
- panelClass: 'error-snackbar'
644
- });
645
- }
646
- };
647
-
648
- input.click();
649
- }
650
-
651
- private async showImportResultsDialog(imported: number, failed: number, errors: string[]) {
652
- try {
653
- const { default: ImportResultsDialogComponent } = await import('../../dialogs/import-results-dialog/import-results-dialog.component');
654
-
655
- this.dialog.open(ImportResultsDialogComponent, {
656
- width: '600px',
657
- data: {
658
- title: 'API Import Results',
659
- imported,
660
- failed,
661
- errors
662
- }
663
- });
664
- } catch (error) {
665
- // Fallback to alert if dialog fails to load
666
- alert(`Imported: ${imported}\nFailed: ${failed}\n\nErrors:\n${errors.join('\n')}`);
667
- }
668
- }
669
-
670
- // Fallback method
671
- private showImportResultsSnackbar(imported: number, failed: number, errors: string[]) {
672
- let message = '';
673
- if (imported > 0) {
674
- message = `Successfully imported ${imported} API${imported > 1 ? 's' : ''}.`;
675
- }
676
-
677
- if (failed > 0) {
678
- if (message) message += '\n\n';
679
- message += `Failed to import ${failed} API${failed > 1 ? 's' : ''}:\n`;
680
- message += errors.slice(0, 5).join('\n');
681
- if (errors.length > 5) {
682
- message += `\n... and ${errors.length - 5} more errors`;
683
- }
684
- }
685
-
686
- this.snackBar.open(message, 'Close', {
687
- duration: 10000,
688
- panelClass: ['multiline-snackbar', failed > 0 ? 'error-snackbar' : 'success-snackbar'],
689
- verticalPosition: 'top',
690
- horizontalPosition: 'right'
691
- });
692
- }
693
-
694
- exportAPIs() {
695
- const selectedAPIs = this.filteredAPIs.filter(api => !api.deleted);
696
-
697
- if (selectedAPIs.length === 0) {
698
- this.snackBar.open('No APIs to export', 'Close', {
699
- duration: 3000
700
- });
701
- return;
702
- }
703
-
704
- try {
705
- const data = JSON.stringify(selectedAPIs, null, 2);
706
- const blob = new Blob([data], { type: 'application/json' });
707
- const url = window.URL.createObjectURL(blob);
708
- const link = document.createElement('a');
709
- link.href = url;
710
- link.download = `apis_export_${new Date().getTime()}.json`;
711
- link.click();
712
- window.URL.revokeObjectURL(url);
713
-
714
- this.snackBar.open(`Exported ${selectedAPIs.length} APIs`, 'Close', {
715
- duration: 3000
716
- });
717
- } catch (error) {
718
- console.error('Export failed:', error);
719
- this.snackBar.open('Failed to export APIs', 'Close', {
720
- duration: 5000,
721
- panelClass: 'error-snackbar'
722
- });
723
- }
724
- }
725
-
726
- private getErrorMessage(error: any): string {
727
- if (error.status === 0) {
728
- return 'Unable to connect to server. Please check your connection.';
729
- } else if (error.status === 401) {
730
- return 'Session expired. Please login again.';
731
- } else if (error.status === 403) {
732
- return 'You do not have permission to perform this action.';
733
- } else if (error.status === 409) {
734
- return 'This API was modified by another user. Please refresh and try again.';
735
- } else if (error.error?.detail) {
736
- return error.error.detail;
737
- } else if (error.message) {
738
- return error.message;
739
- }
740
- return 'An unexpected error occurred. Please try again.';
741
- }
742
  }
 
1
+ import { Component, inject, OnInit, OnDestroy } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
5
+ import { MatTableModule } from '@angular/material/table';
6
+ import { MatButtonModule } from '@angular/material/button';
7
+ import { MatIconModule } from '@angular/material/icon';
8
+ import { MatFormFieldModule } from '@angular/material/form-field';
9
+ import { MatInputModule } from '@angular/material/input';
10
+ import { MatCheckboxModule } from '@angular/material/checkbox';
11
+ import { MatProgressBarModule } from '@angular/material/progress-bar';
12
+ import { MatChipsModule } from '@angular/material/chips';
13
+ import { MatMenuModule } from '@angular/material/menu';
14
+ import { MatTooltipModule } from '@angular/material/tooltip';
15
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
16
+ import { MatDividerModule } from '@angular/material/divider';
17
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
18
+ import { ApiService, API } from '../../services/api.service';
19
+ import { Subject, takeUntil } from 'rxjs';
20
+
21
+ @Component({
22
+ selector: 'app-apis',
23
+ standalone: true,
24
+ imports: [
25
+ CommonModule,
26
+ FormsModule,
27
+ MatDialogModule,
28
+ MatTableModule,
29
+ MatButtonModule,
30
+ MatIconModule,
31
+ MatFormFieldModule,
32
+ MatInputModule,
33
+ MatCheckboxModule,
34
+ MatProgressBarModule,
35
+ MatChipsModule,
36
+ MatMenuModule,
37
+ MatTooltipModule,
38
+ MatSnackBarModule,
39
+ MatDividerModule,
40
+ MatProgressSpinnerModule
41
+ ],
42
+ template: `
43
+ <div class="apis-container">
44
+ <div class="toolbar">
45
+ <h2>API Definitions</h2>
46
+ <div class="toolbar-actions">
47
+ <button mat-raised-button color="primary" (click)="createAPI()" [disabled]="loading">
48
+ <mat-icon>add</mat-icon>
49
+ New API
50
+ </button>
51
+ <button mat-button (click)="importAPIs()" [disabled]="loading">
52
+ <mat-icon>upload</mat-icon>
53
+ Import
54
+ </button>
55
+ <button mat-button (click)="exportAPIs()" [disabled]="loading || filteredAPIs.length === 0">
56
+ <mat-icon>download</mat-icon>
57
+ Export
58
+ </button>
59
+ <mat-form-field appearance="outline" class="search-field">
60
+ <mat-label>Search APIs</mat-label>
61
+ <input matInput [(ngModel)]="searchTerm" (input)="filterAPIs()">
62
+ <mat-icon matSuffix>search</mat-icon>
63
+ </mat-form-field>
64
+ <mat-checkbox [(ngModel)]="showDeleted" (change)="loadAPIs()">
65
+ Display Deleted
66
+ </mat-checkbox>
67
+ </div>
68
+ </div>
69
+
70
+ <mat-progress-bar mode="indeterminate" *ngIf="loading"></mat-progress-bar>
71
+
72
+ @if (!loading && error) {
73
+ <div class="error-state">
74
+ <mat-icon>error_outline</mat-icon>
75
+ <p>{{ error }}</p>
76
+ <button mat-raised-button color="primary" (click)="loadAPIs()">
77
+ <mat-icon>refresh</mat-icon>
78
+ Retry
79
+ </button>
80
+ </div>
81
+ } @else if (!loading && filteredAPIs.length === 0 && !searchTerm) {
82
+ <div class="empty-state">
83
+ <mat-icon>api</mat-icon>
84
+ <p>No APIs found.</p>
85
+ <button mat-raised-button color="primary" (click)="createAPI()">
86
+ Create your first API
87
+ </button>
88
+ </div>
89
+ } @else if (!loading && filteredAPIs.length === 0 && searchTerm) {
90
+ <div class="empty-state">
91
+ <mat-icon>search_off</mat-icon>
92
+ <p>No APIs match your search.</p>
93
+ <button mat-button (click)="searchTerm = ''; filterAPIs()">
94
+ Clear search
95
+ </button>
96
+ </div>
97
+ } @else if (!loading) {
98
+ <table mat-table [dataSource]="filteredAPIs" class="apis-table">
99
+ <!-- Name Column -->
100
+ <ng-container matColumnDef="name">
101
+ <th mat-header-cell *matHeaderCellDef>Name</th>
102
+ <td mat-cell *matCellDef="let api">{{ api.name }}</td>
103
+ </ng-container>
104
+
105
+ <!-- URL Column -->
106
+ <ng-container matColumnDef="url">
107
+ <th mat-header-cell *matHeaderCellDef>URL</th>
108
+ <td mat-cell *matCellDef="let api" class="url-cell">
109
+ <span [matTooltip]="api.url">{{ api.url }}</span>
110
+ </td>
111
+ </ng-container>
112
+
113
+ <!-- Method Column -->
114
+ <ng-container matColumnDef="method">
115
+ <th mat-header-cell *matHeaderCellDef>Method</th>
116
+ <td mat-cell *matCellDef="let api">
117
+ <mat-chip [class]="'method-' + api.method.toLowerCase()">
118
+ {{ api.method }}
119
+ </mat-chip>
120
+ </td>
121
+ </ng-container>
122
+
123
+ <!-- Timeout Column -->
124
+ <ng-container matColumnDef="timeout">
125
+ <th mat-header-cell *matHeaderCellDef>Timeout</th>
126
+ <td mat-cell *matCellDef="let api">{{ api.timeout_seconds }}s</td>
127
+ </ng-container>
128
+
129
+ <!-- Auth Column -->
130
+ <ng-container matColumnDef="auth">
131
+ <th mat-header-cell *matHeaderCellDef>Auth</th>
132
+ <td mat-cell *matCellDef="let api">
133
+ <mat-icon [color]="api.auth?.enabled ? 'primary' : ''"
134
+ [matTooltip]="api.auth?.enabled ? 'Authentication enabled' : 'No authentication'">
135
+ {{ api.auth?.enabled ? 'lock' : 'lock_open' }}
136
+ </mat-icon>
137
+ </td>
138
+ </ng-container>
139
+
140
+ <!-- Deleted Column -->
141
+ <ng-container matColumnDef="deleted">
142
+ <th mat-header-cell *matHeaderCellDef>Status</th>
143
+ <td mat-cell *matCellDef="let api">
144
+ @if (api.deleted) {
145
+ <mat-icon color="warn" matTooltip="Deleted">delete</mat-icon>
146
+ } @else {
147
+ <mat-icon color="primary" matTooltip="Active">check_circle</mat-icon>
148
+ }
149
+ </td>
150
+ </ng-container>
151
+
152
+ <!-- Actions Column -->
153
+ <ng-container matColumnDef="actions">
154
+ <th mat-header-cell *matHeaderCellDef>Actions</th>
155
+ <td mat-cell *matCellDef="let api">
156
+ <button mat-icon-button [matMenuTriggerFor]="menu"
157
+ (click)="$event.stopPropagation()"
158
+ [disabled]="actionLoading[api.name]">
159
+ @if (actionLoading[api.name]) {
160
+ <mat-spinner diameter="20"></mat-spinner>
161
+ } @else {
162
+ <mat-icon>more_vert</mat-icon>
163
+ }
164
+ </button>
165
+ <mat-menu #menu="matMenu">
166
+ <button mat-menu-item (click)="editAPI(api)" [disabled]="api.deleted">
167
+ <mat-icon>edit</mat-icon>
168
+ <span>Edit</span>
169
+ </button>
170
+ <button mat-menu-item (click)="testAPI(api)">
171
+ <mat-icon>play_arrow</mat-icon>
172
+ <span>Test</span>
173
+ </button>
174
+ <button mat-menu-item (click)="duplicateAPI(api)">
175
+ <mat-icon>content_copy</mat-icon>
176
+ <span>Duplicate</span>
177
+ </button>
178
+ @if (!api.deleted) {
179
+ <mat-divider></mat-divider>
180
+ <button mat-menu-item (click)="deleteAPI(api)">
181
+ <mat-icon color="warn">delete</mat-icon>
182
+ <span>Delete</span>
183
+ </button>
184
+ } @else {
185
+ <mat-divider></mat-divider>
186
+ <button mat-menu-item (click)="restoreAPI(api)">
187
+ <mat-icon color="primary">restore</mat-icon>
188
+ <span>Restore</span>
189
+ </button>
190
+ }
191
+ </mat-menu>
192
+ </td>
193
+ </ng-container>
194
+
195
+ <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
196
+ <tr mat-row *matRowDef="let row; columns: displayedColumns;"
197
+ [class.deleted-row]="row.deleted"
198
+ (click)="editAPI(row)"></tr>
199
+ </table>
200
+ }
201
+ </div>
202
+ `,
203
+ styles: [`
204
+ .apis-container {
205
+ .toolbar {
206
+ display: flex;
207
+ justify-content: space-between;
208
+ align-items: center;
209
+ margin-bottom: 24px;
210
+ flex-wrap: wrap;
211
+ gap: 16px;
212
+
213
+ h2 {
214
+ margin: 0;
215
+ font-size: 24px;
216
+ }
217
+
218
+ .toolbar-actions {
219
+ display: flex;
220
+ gap: 16px;
221
+ align-items: center;
222
+ flex-wrap: wrap;
223
+
224
+ .search-field {
225
+ width: 250px;
226
+ }
227
+ }
228
+ }
229
+
230
+ .empty-state, .error-state {
231
+ text-align: center;
232
+ padding: 60px 20px;
233
+ background-color: white;
234
+ border-radius: 8px;
235
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
236
+
237
+ mat-icon {
238
+ font-size: 64px;
239
+ width: 64px;
240
+ height: 64px;
241
+ color: #e0e0e0;
242
+ margin-bottom: 16px;
243
+ }
244
+
245
+ p {
246
+ margin-bottom: 24px;
247
+ color: #666;
248
+ font-size: 16px;
249
+ }
250
+ }
251
+
252
+ .error-state {
253
+ mat-icon {
254
+ color: #f44336;
255
+ }
256
+ }
257
+
258
+ .apis-table {
259
+ width: 100%;
260
+ background: white;
261
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
262
+
263
+ .url-cell {
264
+ max-width: 300px;
265
+
266
+ span {
267
+ overflow: hidden;
268
+ text-overflow: ellipsis;
269
+ white-space: nowrap;
270
+ display: block;
271
+ }
272
+ }
273
+
274
+ mat-chip {
275
+ font-size: 12px;
276
+ min-height: 24px;
277
+ padding: 4px 12px;
278
+
279
+ &.method-get { background-color: #4caf50; color: white; }
280
+ &.method-post { background-color: #2196f3; color: white; }
281
+ &.method-put { background-color: #ff9800; color: white; }
282
+ &.method-patch { background-color: #9c27b0; color: white; }
283
+ &.method-delete { background-color: #f44336; color: white; }
284
+ }
285
+
286
+ tr.mat-mdc-row {
287
+ cursor: pointer;
288
+ transition: background-color 0.2s;
289
+
290
+ &:hover {
291
+ background-color: #f5f5f5;
292
+ }
293
+
294
+ &.deleted-row {
295
+ opacity: 0.6;
296
+ background-color: #fafafa;
297
+ cursor: default;
298
+ }
299
+ }
300
+
301
+ mat-spinner {
302
+ display: inline-block;
303
+ }
304
+ }
305
+ }
306
+
307
+ ::ng-deep {
308
+ .mat-mdc-form-field {
309
+ font-size: 14px;
310
+ }
311
+
312
+ .mat-mdc-checkbox {
313
+ .mdc-form-field {
314
+ font-size: 14px;
315
+ }
316
+ }
317
+ }
318
+ `]
319
+ })
320
+ export class ApisComponent implements OnInit, OnDestroy {
321
+ private apiService = inject(ApiService);
322
+ private dialog = inject(MatDialog);
323
+ private snackBar = inject(MatSnackBar);
324
+ private destroyed$ = new Subject<void>();
325
+
326
+ apis: API[] = [];
327
+ filteredAPIs: API[] = [];
328
+ loading = true;
329
+ error = '';
330
+ showDeleted = false;
331
+ searchTerm = '';
332
+ actionLoading: { [key: string]: boolean } = {};
333
+
334
+ displayedColumns: string[] = ['name', 'url', 'method', 'timeout', 'auth', 'deleted', 'actions'];
335
+
336
+ ngOnInit() {
337
+ this.loadAPIs();
338
+ }
339
+
340
+ ngOnDestroy() {
341
+ this.destroyed$.next();
342
+ this.destroyed$.complete();
343
+ }
344
+
345
+ loadAPIs() {
346
+ this.loading = true;
347
+ this.error = '';
348
+
349
+ this.apiService.getAPIs(this.showDeleted).pipe(
350
+ takeUntil(this.destroyed$)
351
+ ).subscribe({
352
+ next: (apis) => {
353
+ this.apis = apis;
354
+ this.filterAPIs();
355
+ this.loading = false;
356
+ },
357
+ error: (err) => {
358
+ this.error = this.getErrorMessage(err);
359
+ this.snackBar.open(this.error, 'Close', {
360
+ duration: 5000,
361
+ panelClass: 'error-snackbar'
362
+ });
363
+ this.loading = false;
364
+ }
365
+ });
366
+ }
367
+
368
+ filterAPIs() {
369
+ const term = this.searchTerm.toLowerCase().trim();
370
+ if (!term) {
371
+ this.filteredAPIs = [...this.apis];
372
+ } else {
373
+ this.filteredAPIs = this.apis.filter(api =>
374
+ api.name.toLowerCase().includes(term) ||
375
+ api.url.toLowerCase().includes(term) ||
376
+ api.method.toLowerCase().includes(term)
377
+ );
378
+ }
379
+ }
380
+
381
+ async createAPI() {
382
+ try {
383
+ const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
384
+
385
+ const dialogRef = this.dialog.open(ApiEditDialogComponent, {
386
+ width: '800px',
387
+ data: { mode: 'create' },
388
+ disableClose: true
389
+ });
390
+
391
+ dialogRef.afterClosed().pipe(
392
+ takeUntil(this.destroyed$)
393
+ ).subscribe((result: any) => {
394
+ if (result) {
395
+ this.loadAPIs();
396
+ }
397
+ });
398
+ } catch (error) {
399
+ console.error('Failed to load dialog:', error);
400
+ this.snackBar.open('Failed to open dialog', 'Close', {
401
+ duration: 3000,
402
+ panelClass: 'error-snackbar'
403
+ });
404
+ }
405
+ }
406
+
407
+ async editAPI(api: API) {
408
+ if (api.deleted) return;
409
+
410
+ try {
411
+ const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
412
+
413
+ const dialogRef = this.dialog.open(ApiEditDialogComponent, {
414
+ width: '800px',
415
+ data: { mode: 'edit', api: { ...api } },
416
+ disableClose: true
417
+ });
418
+
419
+ dialogRef.afterClosed().pipe(
420
+ takeUntil(this.destroyed$)
421
+ ).subscribe((result: any) => {
422
+ if (result) {
423
+ this.loadAPIs();
424
+ }
425
+ });
426
+ } catch (error) {
427
+ console.error('Failed to load dialog:', error);
428
+ this.snackBar.open('Failed to open dialog', 'Close', {
429
+ duration: 3000,
430
+ panelClass: 'error-snackbar'
431
+ });
432
+ }
433
+ }
434
+
435
+ async testAPI(api: API) {
436
+ try {
437
+ const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
438
+
439
+ const dialogRef = this.dialog.open(ApiEditDialogComponent, {
440
+ width: '800px',
441
+ data: {
442
+ mode: 'test',
443
+ api: { ...api },
444
+ activeTab: 4
445
+ },
446
+ disableClose: false
447
+ });
448
+
449
+ dialogRef.afterClosed().pipe(
450
+ takeUntil(this.destroyed$)
451
+ ).subscribe((result: any) => {
452
+ if (result) {
453
+ this.loadAPIs();
454
+ }
455
+ });
456
+ } catch (error) {
457
+ console.error('Failed to load dialog:', error);
458
+ this.snackBar.open('Failed to open dialog', 'Close', {
459
+ duration: 3000,
460
+ panelClass: 'error-snackbar'
461
+ });
462
+ }
463
+ }
464
+
465
+ async duplicateAPI(api: API) {
466
+ try {
467
+ const { default: ApiEditDialogComponent } = await import('../../dialogs/api-edit-dialog/api-edit-dialog.component');
468
+
469
+ const duplicatedApi = { ...api };
470
+ duplicatedApi.name = `${api.name}_copy`;
471
+ delete (duplicatedApi as any).last_update_date;
472
+
473
+ const dialogRef = this.dialog.open(ApiEditDialogComponent, {
474
+ width: '800px',
475
+ data: { mode: 'duplicate', api: duplicatedApi },
476
+ disableClose: true
477
+ });
478
+
479
+ dialogRef.afterClosed().pipe(
480
+ takeUntil(this.destroyed$)
481
+ ).subscribe((result: any) => {
482
+ if (result) {
483
+ this.loadAPIs();
484
+ }
485
+ });
486
+ } catch (error) {
487
+ console.error('Failed to load dialog:', error);
488
+ this.snackBar.open('Failed to open dialog', 'Close', {
489
+ duration: 3000,
490
+ panelClass: 'error-snackbar'
491
+ });
492
+ }
493
+ }
494
+
495
+ async deleteAPI(api: API) {
496
+ if (api.deleted) return;
497
+
498
+ try {
499
+ const { default: ConfirmDialogComponent } = await import('../../dialogs/confirm-dialog/confirm-dialog.component');
500
+
501
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
502
+ width: '400px',
503
+ data: {
504
+ title: 'Delete API',
505
+ message: `Are you sure you want to delete "${api.name}"?`,
506
+ confirmText: 'Delete',
507
+ confirmColor: 'warn'
508
+ }
509
+ });
510
+
511
+ dialogRef.afterClosed().pipe(
512
+ takeUntil(this.destroyed$)
513
+ ).subscribe((confirmed) => {
514
+ if (confirmed) {
515
+ this.actionLoading[api.name] = true;
516
+
517
+ this.apiService.deleteAPI(api.name).pipe(
518
+ takeUntil(this.destroyed$)
519
+ ).subscribe({
520
+ next: () => {
521
+ this.snackBar.open(`API "${api.name}" deleted successfully`, 'Close', {
522
+ duration: 3000
523
+ });
524
+ this.loadAPIs();
525
+ },
526
+ error: (err) => {
527
+ const errorMsg = this.getErrorMessage(err);
528
+ this.snackBar.open(errorMsg, 'Close', {
529
+ duration: 5000,
530
+ panelClass: 'error-snackbar'
531
+ });
532
+ this.actionLoading[api.name] = false;
533
+ }
534
+ });
535
+ }
536
+ });
537
+ } catch (error) {
538
+ console.error('Failed to load dialog:', error);
539
+ this.snackBar.open('Failed to open dialog', 'Close', {
540
+ duration: 3000,
541
+ panelClass: 'error-snackbar'
542
+ });
543
+ }
544
+ }
545
+
546
+ async restoreAPI(api: API) {
547
+ if (!api.deleted) return;
548
+
549
+ // Implement restore API functionality
550
+ this.snackBar.open('Restore functionality not implemented yet', 'Close', {
551
+ duration: 3000
552
+ });
553
+ }
554
+
555
+ async importAPIs() {
556
+ const input = document.createElement('input');
557
+ input.type = 'file';
558
+ input.accept = '.json';
559
+
560
+ input.onchange = async (event: any) => {
561
+ const file = event.target.files[0];
562
+ if (!file) return;
563
+
564
+ try {
565
+ const text = await file.text();
566
+ let apis: any[];
567
+
568
+ try {
569
+ apis = JSON.parse(text);
570
+ } catch (parseError) {
571
+ this.snackBar.open('Invalid JSON file format', 'Close', {
572
+ duration: 5000,
573
+ panelClass: 'error-snackbar'
574
+ });
575
+ return;
576
+ }
577
+
578
+ if (!Array.isArray(apis)) {
579
+ this.snackBar.open('Invalid file format. Expected an array of APIs.', 'Close', {
580
+ duration: 5000,
581
+ panelClass: 'error-snackbar'
582
+ });
583
+ return;
584
+ }
585
+
586
+ this.loading = true;
587
+ let imported = 0;
588
+ let failed = 0;
589
+ const errors: string[] = [];
590
+
591
+ console.log('Starting API import, total APIs:', apis.length);
592
+
593
+ for (const api of apis) {
594
+ try {
595
+ await this.apiService.createAPI(api).toPromise();
596
+ imported++;
597
+ } catch (err: any) {
598
+ failed++;
599
+ const apiName = api.name || 'unnamed';
600
+
601
+ console.error(`❌ Failed to import API ${apiName}:`, err);
602
+
603
+ // Parse error message - daha iyi hata mesajı parse etme
604
+ let errorMsg = 'Unknown error';
605
+
606
+ if (err.status === 409) {
607
+ // DuplicateResourceError durumu
608
+ errorMsg = `API with name '${apiName}' already exists`;
609
+ } else if (err.status === 500 && err.error?.detail?.includes('already exists')) {
610
+ // Backend'den gelen duplicate hatası
611
+ errorMsg = `API with name '${apiName}' already exists`;
612
+ } else if (err.error?.message) {
613
+ errorMsg = err.error.message;
614
+ } else if (err.error?.detail) {
615
+ errorMsg = err.error.detail;
616
+ } else if (err.message) {
617
+ errorMsg = err.message;
618
+ }
619
+
620
+ errors.push(`${apiName}: ${errorMsg}`);
621
+ }
622
+ }
623
+
624
+ this.loading = false;
625
+
626
+ if (imported > 0) {
627
+ this.loadAPIs();
628
+ }
629
+
630
+ // Always show dialog for import results
631
+ try {
632
+ await this.showImportResultsDialog(imported, failed, errors);
633
+ } catch (dialogError) {
634
+ console.error('Failed to show import dialog:', dialogError);
635
+ // Fallback to snackbar
636
+ this.showImportResultsSnackbar(imported, failed, errors);
637
+ }
638
+
639
+ } catch (error) {
640
+ this.loading = false;
641
+ this.snackBar.open('Failed to read file', 'Close', {
642
+ duration: 5000,
643
+ panelClass: 'error-snackbar'
644
+ });
645
+ }
646
+ };
647
+
648
+ input.click();
649
+ }
650
+
651
+ private async showImportResultsDialog(imported: number, failed: number, errors: string[]) {
652
+ try {
653
+ const { default: ImportResultsDialogComponent } = await import('../../dialogs/import-results-dialog/import-results-dialog.component');
654
+
655
+ this.dialog.open(ImportResultsDialogComponent, {
656
+ width: '600px',
657
+ data: {
658
+ title: 'API Import Results',
659
+ imported,
660
+ failed,
661
+ errors
662
+ }
663
+ });
664
+ } catch (error) {
665
+ // Fallback to alert if dialog fails to load
666
+ alert(`Imported: ${imported}\nFailed: ${failed}\n\nErrors:\n${errors.join('\n')}`);
667
+ }
668
+ }
669
+
670
+ // Fallback method
671
+ private showImportResultsSnackbar(imported: number, failed: number, errors: string[]) {
672
+ let message = '';
673
+ if (imported > 0) {
674
+ message = `Successfully imported ${imported} API${imported > 1 ? 's' : ''}.`;
675
+ }
676
+
677
+ if (failed > 0) {
678
+ if (message) message += '\n\n';
679
+ message += `Failed to import ${failed} API${failed > 1 ? 's' : ''}:\n`;
680
+ message += errors.slice(0, 5).join('\n');
681
+ if (errors.length > 5) {
682
+ message += `\n... and ${errors.length - 5} more errors`;
683
+ }
684
+ }
685
+
686
+ this.snackBar.open(message, 'Close', {
687
+ duration: 10000,
688
+ panelClass: ['multiline-snackbar', failed > 0 ? 'error-snackbar' : 'success-snackbar'],
689
+ verticalPosition: 'top',
690
+ horizontalPosition: 'right'
691
+ });
692
+ }
693
+
694
+ exportAPIs() {
695
+ const selectedAPIs = this.filteredAPIs.filter(api => !api.deleted);
696
+
697
+ if (selectedAPIs.length === 0) {
698
+ this.snackBar.open('No APIs to export', 'Close', {
699
+ duration: 3000
700
+ });
701
+ return;
702
+ }
703
+
704
+ try {
705
+ const data = JSON.stringify(selectedAPIs, null, 2);
706
+ const blob = new Blob([data], { type: 'application/json' });
707
+ const url = window.URL.createObjectURL(blob);
708
+ const link = document.createElement('a');
709
+ link.href = url;
710
+ link.download = `apis_export_${new Date().getTime()}.json`;
711
+ link.click();
712
+ window.URL.revokeObjectURL(url);
713
+
714
+ this.snackBar.open(`Exported ${selectedAPIs.length} APIs`, 'Close', {
715
+ duration: 3000
716
+ });
717
+ } catch (error) {
718
+ console.error('Export failed:', error);
719
+ this.snackBar.open('Failed to export APIs', 'Close', {
720
+ duration: 5000,
721
+ panelClass: 'error-snackbar'
722
+ });
723
+ }
724
+ }
725
+
726
+ private getErrorMessage(error: any): string {
727
+ if (error.status === 0) {
728
+ return 'Unable to connect to server. Please check your connection.';
729
+ } else if (error.status === 401) {
730
+ return 'Session expired. Please login again.';
731
+ } else if (error.status === 403) {
732
+ return 'You do not have permission to perform this action.';
733
+ } else if (error.status === 409) {
734
+ return 'This API was modified by another user. Please refresh and try again.';
735
+ } else if (error.error?.detail) {
736
+ return error.error.detail;
737
+ } else if (error.message) {
738
+ return error.message;
739
+ }
740
+ return 'An unexpected error occurred. Please try again.';
741
+ }
742
  }
flare-ui/src/app/components/chat/chat.component.html CHANGED
@@ -1,157 +1,157 @@
1
- <div class="chat-container">
2
- <!-- Project Selection and Start Chat -->
3
- <div *ngIf="!sessionId" class="start-wrapper">
4
- <mat-card>
5
- <mat-card-header>
6
- <mat-icon mat-card-avatar>chat_bubble_outline</mat-icon>
7
- <mat-card-title>Start a Chat Session</mat-card-title>
8
- <mat-card-subtitle>Select a project to begin testing</mat-card-subtitle>
9
- </mat-card-header>
10
-
11
- <mat-card-content>
12
-
13
- <mat-form-field appearance="outline" class="project-select">
14
- <mat-label>Select Project</mat-label>
15
- <mat-select [(ngModel)]="selectedProject" required>
16
- <mat-option *ngFor="let p of projects" [value]="p">{{ p }}</mat-option>
17
- </mat-select>
18
- <mat-hint>Choose an enabled project with published version</mat-hint>
19
- </mat-form-field>
20
-
21
- <mat-form-field appearance="outline" class="locale-select">
22
- <mat-label>Language</mat-label>
23
- <mat-select [(ngModel)]="selectedLocale" required>
24
- <mat-option *ngFor="let locale of availableLocales" [value]="locale.code">
25
- {{ locale.name }} ({{ locale.english_name }})
26
- </mat-option>
27
- </mat-select>
28
- <mat-hint>Select conversation language</mat-hint>
29
- </mat-form-field>
30
-
31
- <mat-checkbox
32
- [(ngModel)]="useTTS"
33
- [disabled]="!ttsAvailable"
34
- class="tts-checkbox">
35
- Use TTS (Text-to-Speech)
36
- </mat-checkbox>
37
- <div *ngIf="!ttsAvailable" class="tts-hint">
38
- TTS is not configured. Please configure a TTS engine in Environment settings.
39
- </div>
40
-
41
- <mat-checkbox
42
- [(ngModel)]="useSTT"
43
- [disabled]="!sttAvailable"
44
- class="stt-checkbox">
45
- Use STT (Speech-to-Text)
46
- </mat-checkbox>
47
- <div *ngIf="!sttAvailable" class="stt-hint">
48
- STT is not configured. Please configure an STT engine in Environment settings.
49
- </div>
50
- <div *ngIf="sttAvailable && useSTT" class="stt-hint">
51
- When STT is enabled, use the Real-time Chat button for voice conversation.
52
- </div>
53
- </mat-card-content>
54
-
55
- <mat-card-actions align="end">
56
- <button
57
- mat-raised-button
58
- color="primary"
59
- (click)="startChat()"
60
- [disabled]="!selectedProject || useSTT"
61
- >
62
- <mat-icon>chat</mat-icon>
63
- Start Chat
64
- </button>
65
-
66
- <button
67
- mat-raised-button
68
- color="accent"
69
- (click)="startRealtimeChat()"
70
- [disabled]="!selectedProject || !useSTT"
71
- class="realtime-button"
72
- matTooltip="Start real-time voice conversation with STT"
73
- >
74
- <mat-icon>mic</mat-icon>
75
- Real-time Chat
76
- </button>
77
- </mat-card-actions>
78
-
79
- </mat-card>
80
- </div>
81
-
82
- <!-- Chat Panel -->
83
- <mat-card *ngIf="sessionId" class="chat-card">
84
- <mat-card-header>
85
- <mat-icon mat-card-avatar>smart_toy</mat-icon>
86
- <mat-card-title>{{ selectedProject }}</mat-card-title>
87
- <mat-card-subtitle>Session: {{ sessionId.substring(0, 8) }}...</mat-card-subtitle>
88
- <div class="spacer"></div>
89
- <mat-icon *ngIf="useTTS" matTooltip="TTS Enabled" class="tts-indicator">record_voice_over</mat-icon>
90
- <button mat-icon-button (click)="endSession()" matTooltip="End Session">
91
- <mat-icon>close</mat-icon>
92
- </button>
93
- </mat-card-header>
94
-
95
- <mat-divider></mat-divider>
96
-
97
- <!-- Audio Waveform Visualization -->
98
- <div *ngIf="playingAudio" class="waveform-container">
99
- <canvas #waveformCanvas width="800" height="100"></canvas>
100
- </div>
101
-
102
- <div class="chat-history" #scrollMe>
103
- <div
104
- *ngFor="let msg of messages; let i = index"
105
- [ngClass]="{
106
- 'msg-row': true,
107
- 'me': msg.author === 'user',
108
- 'bot': msg.author === 'assistant'
109
- }"
110
- >
111
- <mat-icon class="msg-icon">
112
- {{ msg.author === 'user' ? 'person' : 'smart_toy' }}
113
- </mat-icon>
114
- <div class="msg-content">
115
- <span class="bubble">{{ msg.text }}</span>
116
- <button
117
- *ngIf="msg.audioUrl && msg.author === 'assistant'"
118
- mat-icon-button
119
- (click)="playAudio(msg.audioUrl)"
120
- class="play-button"
121
- matTooltip="Play audio">
122
- <mat-icon>play_arrow</mat-icon>
123
- </button>
124
- </div>
125
- </div>
126
- </div>
127
-
128
- <mat-divider></mat-divider>
129
-
130
- <form (ngSubmit)="send()" class="input-row">
131
- <mat-form-field appearance="outline" class="flex-1">
132
- <mat-label>Type your message</mat-label>
133
- <input
134
- matInput
135
- placeholder="Ask something..."
136
- [formControl]="input"
137
- autocomplete="off"
138
- cdkTextareaAutosize
139
- cdkAutosizeMinRows="1"
140
- cdkAutosizeMaxRows="3"/>
141
- <mat-hint>Press Enter to send</mat-hint>
142
- </mat-form-field>
143
-
144
- <button
145
- mat-fab
146
- color="primary"
147
- type="submit"
148
- [disabled]="input.invalid || !input.value?.trim() || loading"
149
- class="send-button">
150
- <mat-icon>send</mat-icon>
151
- </button>
152
- </form>
153
- </mat-card>
154
-
155
- <!-- Hidden audio player -->
156
- <audio #audioPlayer style="display: none;"></audio>
157
  </div>
 
1
+ <div class="chat-container">
2
+ <!-- Project Selection and Start Chat -->
3
+ <div *ngIf="!sessionId" class="start-wrapper">
4
+ <mat-card>
5
+ <mat-card-header>
6
+ <mat-icon mat-card-avatar>chat_bubble_outline</mat-icon>
7
+ <mat-card-title>Start a Chat Session</mat-card-title>
8
+ <mat-card-subtitle>Select a project to begin testing</mat-card-subtitle>
9
+ </mat-card-header>
10
+
11
+ <mat-card-content>
12
+
13
+ <mat-form-field appearance="outline" class="project-select">
14
+ <mat-label>Select Project</mat-label>
15
+ <mat-select [(ngModel)]="selectedProject" required>
16
+ <mat-option *ngFor="let p of projects" [value]="p">{{ p }}</mat-option>
17
+ </mat-select>
18
+ <mat-hint>Choose an enabled project with published version</mat-hint>
19
+ </mat-form-field>
20
+
21
+ <mat-form-field appearance="outline" class="locale-select">
22
+ <mat-label>Language</mat-label>
23
+ <mat-select [(ngModel)]="selectedLocale" required>
24
+ <mat-option *ngFor="let locale of availableLocales" [value]="locale.code">
25
+ {{ locale.name }} ({{ locale.english_name }})
26
+ </mat-option>
27
+ </mat-select>
28
+ <mat-hint>Select conversation language</mat-hint>
29
+ </mat-form-field>
30
+
31
+ <mat-checkbox
32
+ [(ngModel)]="useTTS"
33
+ [disabled]="!ttsAvailable"
34
+ class="tts-checkbox">
35
+ Use TTS (Text-to-Speech)
36
+ </mat-checkbox>
37
+ <div *ngIf="!ttsAvailable" class="tts-hint">
38
+ TTS is not configured. Please configure a TTS engine in Environment settings.
39
+ </div>
40
+
41
+ <mat-checkbox
42
+ [(ngModel)]="useSTT"
43
+ [disabled]="!sttAvailable"
44
+ class="stt-checkbox">
45
+ Use STT (Speech-to-Text)
46
+ </mat-checkbox>
47
+ <div *ngIf="!sttAvailable" class="stt-hint">
48
+ STT is not configured. Please configure an STT engine in Environment settings.
49
+ </div>
50
+ <div *ngIf="sttAvailable && useSTT" class="stt-hint">
51
+ When STT is enabled, use the Real-time Chat button for voice conversation.
52
+ </div>
53
+ </mat-card-content>
54
+
55
+ <mat-card-actions align="end">
56
+ <button
57
+ mat-raised-button
58
+ color="primary"
59
+ (click)="startChat()"
60
+ [disabled]="!selectedProject || useSTT"
61
+ >
62
+ <mat-icon>chat</mat-icon>
63
+ Start Chat
64
+ </button>
65
+
66
+ <button
67
+ mat-raised-button
68
+ color="accent"
69
+ (click)="startRealtimeChat()"
70
+ [disabled]="!selectedProject || !useSTT"
71
+ class="realtime-button"
72
+ matTooltip="Start real-time voice conversation with STT"
73
+ >
74
+ <mat-icon>mic</mat-icon>
75
+ Real-time Chat
76
+ </button>
77
+ </mat-card-actions>
78
+
79
+ </mat-card>
80
+ </div>
81
+
82
+ <!-- Chat Panel -->
83
+ <mat-card *ngIf="sessionId" class="chat-card">
84
+ <mat-card-header>
85
+ <mat-icon mat-card-avatar>smart_toy</mat-icon>
86
+ <mat-card-title>{{ selectedProject }}</mat-card-title>
87
+ <mat-card-subtitle>Session: {{ sessionId.substring(0, 8) }}...</mat-card-subtitle>
88
+ <div class="spacer"></div>
89
+ <mat-icon *ngIf="useTTS" matTooltip="TTS Enabled" class="tts-indicator">record_voice_over</mat-icon>
90
+ <button mat-icon-button (click)="endSession()" matTooltip="End Session">
91
+ <mat-icon>close</mat-icon>
92
+ </button>
93
+ </mat-card-header>
94
+
95
+ <mat-divider></mat-divider>
96
+
97
+ <!-- Audio Waveform Visualization -->
98
+ <div *ngIf="playingAudio" class="waveform-container">
99
+ <canvas #waveformCanvas width="800" height="100"></canvas>
100
+ </div>
101
+
102
+ <div class="chat-history" #scrollMe>
103
+ <div
104
+ *ngFor="let msg of messages; let i = index"
105
+ [ngClass]="{
106
+ 'msg-row': true,
107
+ 'me': msg.author === 'user',
108
+ 'bot': msg.author === 'assistant'
109
+ }"
110
+ >
111
+ <mat-icon class="msg-icon">
112
+ {{ msg.author === 'user' ? 'person' : 'smart_toy' }}
113
+ </mat-icon>
114
+ <div class="msg-content">
115
+ <span class="bubble">{{ msg.text }}</span>
116
+ <button
117
+ *ngIf="msg.audioUrl && msg.author === 'assistant'"
118
+ mat-icon-button
119
+ (click)="playAudio(msg.audioUrl)"
120
+ class="play-button"
121
+ matTooltip="Play audio">
122
+ <mat-icon>play_arrow</mat-icon>
123
+ </button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <mat-divider></mat-divider>
129
+
130
+ <form (ngSubmit)="send()" class="input-row">
131
+ <mat-form-field appearance="outline" class="flex-1">
132
+ <mat-label>Type your message</mat-label>
133
+ <input
134
+ matInput
135
+ placeholder="Ask something..."
136
+ [formControl]="input"
137
+ autocomplete="off"
138
+ cdkTextareaAutosize
139
+ cdkAutosizeMinRows="1"
140
+ cdkAutosizeMaxRows="3"/>
141
+ <mat-hint>Press Enter to send</mat-hint>
142
+ </mat-form-field>
143
+
144
+ <button
145
+ mat-fab
146
+ color="primary"
147
+ type="submit"
148
+ [disabled]="input.invalid || !input.value?.trim() || loading"
149
+ class="send-button">
150
+ <mat-icon>send</mat-icon>
151
+ </button>
152
+ </form>
153
+ </mat-card>
154
+
155
+ <!-- Hidden audio player -->
156
+ <audio #audioPlayer style="display: none;"></audio>
157
  </div>
flare-ui/src/app/components/chat/chat.component.scss CHANGED
@@ -1,290 +1,290 @@
1
- .chat-container {
2
- height: 100%;
3
- padding: 24px;
4
- max-width: 900px;
5
- margin: 0 auto;
6
- }
7
-
8
- .start-wrapper {
9
- display: flex;
10
- justify-content: center;
11
- align-items: center;
12
- min-height: 400px;
13
-
14
- mat-card {
15
- max-width: 500px;
16
- width: 100%;
17
- }
18
-
19
- .project-select {
20
- width: 100%;
21
- margin-bottom: 16px;
22
- }
23
-
24
- .tts-checkbox {
25
- margin-bottom: 8px;
26
- }
27
-
28
- .tts-hint {
29
- color: #666;
30
- font-size: 12px;
31
- margin-bottom: 16px;
32
- }
33
- }
34
-
35
- .locale-select {
36
- width: 100%;
37
- margin-bottom: 16px;
38
- }
39
-
40
- .chat-card {
41
- height: calc(100vh - 200px);
42
- display: flex;
43
- flex-direction: column;
44
-
45
- mat-card-header {
46
- background-color: #f5f5f5;
47
- padding: 16px;
48
-
49
- .spacer {
50
- flex: 1;
51
- }
52
-
53
- .tts-indicator {
54
- color: #4caf50;
55
- margin-right: 8px;
56
- }
57
- }
58
- }
59
-
60
- .waveform-container {
61
- background-color: #f0f0f0;
62
- padding: 8px;
63
- display: flex;
64
- justify-content: center;
65
- align-items: center;
66
- min-height: 116px;
67
-
68
- canvas {
69
- border-radius: 4px;
70
- background-color: #f0f0f0;
71
- }
72
- }
73
-
74
- .chat-history {
75
- flex: 1;
76
- overflow-y: auto;
77
- padding: 16px;
78
- background: #fafafa;
79
- min-height: 300px;
80
- }
81
-
82
- .msg-row {
83
- display: flex;
84
- align-items: flex-start;
85
- margin: 12px 0;
86
- gap: 8px;
87
-
88
- &.me {
89
- justify-content: flex-end;
90
- flex-direction: row-reverse;
91
-
92
- .bubble {
93
- background: #3f51b5;
94
- color: white;
95
- border-bottom-right-radius: 4px;
96
- }
97
-
98
- .msg-icon {
99
- color: #3f51b5;
100
- }
101
- }
102
-
103
- &.bot {
104
- justify-content: flex-start;
105
-
106
- .bubble {
107
- background: #e8eaf6;
108
- color: #000;
109
- border-bottom-left-radius: 4px;
110
- }
111
-
112
- .msg-icon {
113
- color: #7986cb;
114
- }
115
- }
116
-
117
- .msg-icon {
118
- margin-top: 4px;
119
- }
120
-
121
- .msg-content {
122
- display: flex;
123
- align-items: flex-start;
124
- gap: 8px;
125
- max-width: 70%;
126
-
127
- .bubble {
128
- padding: 12px 16px;
129
- border-radius: 16px;
130
- line-height: 1.5;
131
- word-wrap: break-word;
132
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
133
- animation: slideIn 0.3s ease-out;
134
- }
135
-
136
- .play-button {
137
- margin-top: 4px;
138
-
139
- mat-icon {
140
- font-size: 20px;
141
- width: 20px;
142
- height: 20px;
143
- }
144
- }
145
- }
146
- }
147
-
148
- @keyframes slideIn {
149
- from {
150
- opacity: 0;
151
- transform: translateY(10px);
152
- }
153
- to {
154
- opacity: 1;
155
- transform: translateY(0);
156
- }
157
- }
158
-
159
- .input-row {
160
- display: flex;
161
- padding: 16px;
162
- gap: 12px;
163
- align-items: flex-start;
164
- background-color: #fff;
165
-
166
- .flex-1 {
167
- flex: 1;
168
- }
169
-
170
- .send-button {
171
- margin-top: 8px;
172
- }
173
- }
174
-
175
- // Loading state
176
- .loading-spinner {
177
- display: flex;
178
- justify-content: center;
179
- padding: 20px;
180
- }
181
-
182
- // Error state
183
- .error-message {
184
- color: #f44336;
185
- padding: 16px;
186
- text-align: center;
187
- background-color: #ffebee;
188
- border-radius: 4px;
189
- margin: 16px;
190
- }
191
-
192
- // Scrollbar styling
193
- .chat-history::-webkit-scrollbar {
194
- width: 8px;
195
- }
196
-
197
- .chat-history::-webkit-scrollbar-track {
198
- background: #f1f1f1;
199
- }
200
-
201
- .chat-history::-webkit-scrollbar-thumb {
202
- background: #888;
203
- border-radius: 4px;
204
- }
205
-
206
- .chat-history::-webkit-scrollbar-thumb:hover {
207
- background: #555;
208
- }
209
-
210
- .stt-hint {
211
- font-size: 12px;
212
- color: #666;
213
- margin-top: 4px;
214
- font-style: italic;
215
- }
216
-
217
- .stt-checkbox {
218
- margin-top: 16px;
219
-
220
- &[disabled] {
221
- opacity: 0.5;
222
- }
223
- }
224
-
225
- .realtime-button {
226
- margin-left: 16px;
227
- background-color: #4caf50 !important;
228
-
229
- &:hover {
230
- background-color: #45a049 !important;
231
- }
232
-
233
- mat-icon {
234
- margin-right: 8px;
235
- }
236
- }
237
-
238
- // Real-time indicator animation
239
- @keyframes pulse {
240
- 0% {
241
- transform: scale(1);
242
- opacity: 1;
243
- }
244
- 50% {
245
- transform: scale(1.1);
246
- opacity: 0.7;
247
- }
248
- 100% {
249
- transform: scale(1);
250
- opacity: 1;
251
- }
252
- }
253
-
254
- .realtime-indicator {
255
- color: #4caf50;
256
- animation: pulse 2s infinite;
257
- }
258
-
259
- // STT/TTS selection styling
260
- .tts-checkbox, .stt-checkbox {
261
- display: block;
262
- margin-bottom: 8px;
263
-
264
- &[disabled] {
265
- opacity: 0.5;
266
- }
267
- }
268
-
269
- .tts-hint, .stt-hint {
270
- font-size: 12px;
271
- color: #666;
272
- margin-bottom: 16px;
273
- margin-left: 32px; // Align with checkbox text
274
- font-style: italic;
275
- }
276
-
277
- // Highlight when STT is enabled
278
- .stt-checkbox.mat-mdc-checkbox-checked + .stt-hint {
279
- color: #4caf50;
280
- font-weight: 500;
281
- }
282
-
283
- // Button states
284
- .mat-mdc-raised-button[disabled] {
285
- opacity: 0.6;
286
-
287
- &.realtime-button {
288
- opacity: 0.4;
289
- }
290
  }
 
1
+ .chat-container {
2
+ height: 100%;
3
+ padding: 24px;
4
+ max-width: 900px;
5
+ margin: 0 auto;
6
+ }
7
+
8
+ .start-wrapper {
9
+ display: flex;
10
+ justify-content: center;
11
+ align-items: center;
12
+ min-height: 400px;
13
+
14
+ mat-card {
15
+ max-width: 500px;
16
+ width: 100%;
17
+ }
18
+
19
+ .project-select {
20
+ width: 100%;
21
+ margin-bottom: 16px;
22
+ }
23
+
24
+ .tts-checkbox {
25
+ margin-bottom: 8px;
26
+ }
27
+
28
+ .tts-hint {
29
+ color: #666;
30
+ font-size: 12px;
31
+ margin-bottom: 16px;
32
+ }
33
+ }
34
+
35
+ .locale-select {
36
+ width: 100%;
37
+ margin-bottom: 16px;
38
+ }
39
+
40
+ .chat-card {
41
+ height: calc(100vh - 200px);
42
+ display: flex;
43
+ flex-direction: column;
44
+
45
+ mat-card-header {
46
+ background-color: #f5f5f5;
47
+ padding: 16px;
48
+
49
+ .spacer {
50
+ flex: 1;
51
+ }
52
+
53
+ .tts-indicator {
54
+ color: #4caf50;
55
+ margin-right: 8px;
56
+ }
57
+ }
58
+ }
59
+
60
+ .waveform-container {
61
+ background-color: #f0f0f0;
62
+ padding: 8px;
63
+ display: flex;
64
+ justify-content: center;
65
+ align-items: center;
66
+ min-height: 116px;
67
+
68
+ canvas {
69
+ border-radius: 4px;
70
+ background-color: #f0f0f0;
71
+ }
72
+ }
73
+
74
+ .chat-history {
75
+ flex: 1;
76
+ overflow-y: auto;
77
+ padding: 16px;
78
+ background: #fafafa;
79
+ min-height: 300px;
80
+ }
81
+
82
+ .msg-row {
83
+ display: flex;
84
+ align-items: flex-start;
85
+ margin: 12px 0;
86
+ gap: 8px;
87
+
88
+ &.me {
89
+ justify-content: flex-end;
90
+ flex-direction: row-reverse;
91
+
92
+ .bubble {
93
+ background: #3f51b5;
94
+ color: white;
95
+ border-bottom-right-radius: 4px;
96
+ }
97
+
98
+ .msg-icon {
99
+ color: #3f51b5;
100
+ }
101
+ }
102
+
103
+ &.bot {
104
+ justify-content: flex-start;
105
+
106
+ .bubble {
107
+ background: #e8eaf6;
108
+ color: #000;
109
+ border-bottom-left-radius: 4px;
110
+ }
111
+
112
+ .msg-icon {
113
+ color: #7986cb;
114
+ }
115
+ }
116
+
117
+ .msg-icon {
118
+ margin-top: 4px;
119
+ }
120
+
121
+ .msg-content {
122
+ display: flex;
123
+ align-items: flex-start;
124
+ gap: 8px;
125
+ max-width: 70%;
126
+
127
+ .bubble {
128
+ padding: 12px 16px;
129
+ border-radius: 16px;
130
+ line-height: 1.5;
131
+ word-wrap: break-word;
132
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
133
+ animation: slideIn 0.3s ease-out;
134
+ }
135
+
136
+ .play-button {
137
+ margin-top: 4px;
138
+
139
+ mat-icon {
140
+ font-size: 20px;
141
+ width: 20px;
142
+ height: 20px;
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ @keyframes slideIn {
149
+ from {
150
+ opacity: 0;
151
+ transform: translateY(10px);
152
+ }
153
+ to {
154
+ opacity: 1;
155
+ transform: translateY(0);
156
+ }
157
+ }
158
+
159
+ .input-row {
160
+ display: flex;
161
+ padding: 16px;
162
+ gap: 12px;
163
+ align-items: flex-start;
164
+ background-color: #fff;
165
+
166
+ .flex-1 {
167
+ flex: 1;
168
+ }
169
+
170
+ .send-button {
171
+ margin-top: 8px;
172
+ }
173
+ }
174
+
175
+ // Loading state
176
+ .loading-spinner {
177
+ display: flex;
178
+ justify-content: center;
179
+ padding: 20px;
180
+ }
181
+
182
+ // Error state
183
+ .error-message {
184
+ color: #f44336;
185
+ padding: 16px;
186
+ text-align: center;
187
+ background-color: #ffebee;
188
+ border-radius: 4px;
189
+ margin: 16px;
190
+ }
191
+
192
+ // Scrollbar styling
193
+ .chat-history::-webkit-scrollbar {
194
+ width: 8px;
195
+ }
196
+
197
+ .chat-history::-webkit-scrollbar-track {
198
+ background: #f1f1f1;
199
+ }
200
+
201
+ .chat-history::-webkit-scrollbar-thumb {
202
+ background: #888;
203
+ border-radius: 4px;
204
+ }
205
+
206
+ .chat-history::-webkit-scrollbar-thumb:hover {
207
+ background: #555;
208
+ }
209
+
210
+ .stt-hint {
211
+ font-size: 12px;
212
+ color: #666;
213
+ margin-top: 4px;
214
+ font-style: italic;
215
+ }
216
+
217
+ .stt-checkbox {
218
+ margin-top: 16px;
219
+
220
+ &[disabled] {
221
+ opacity: 0.5;
222
+ }
223
+ }
224
+
225
+ .realtime-button {
226
+ margin-left: 16px;
227
+ background-color: #4caf50 !important;
228
+
229
+ &:hover {
230
+ background-color: #45a049 !important;
231
+ }
232
+
233
+ mat-icon {
234
+ margin-right: 8px;
235
+ }
236
+ }
237
+
238
+ // Real-time indicator animation
239
+ @keyframes pulse {
240
+ 0% {
241
+ transform: scale(1);
242
+ opacity: 1;
243
+ }
244
+ 50% {
245
+ transform: scale(1.1);
246
+ opacity: 0.7;
247
+ }
248
+ 100% {
249
+ transform: scale(1);
250
+ opacity: 1;
251
+ }
252
+ }
253
+
254
+ .realtime-indicator {
255
+ color: #4caf50;
256
+ animation: pulse 2s infinite;
257
+ }
258
+
259
+ // STT/TTS selection styling
260
+ .tts-checkbox, .stt-checkbox {
261
+ display: block;
262
+ margin-bottom: 8px;
263
+
264
+ &[disabled] {
265
+ opacity: 0.5;
266
+ }
267
+ }
268
+
269
+ .tts-hint, .stt-hint {
270
+ font-size: 12px;
271
+ color: #666;
272
+ margin-bottom: 16px;
273
+ margin-left: 32px; // Align with checkbox text
274
+ font-style: italic;
275
+ }
276
+
277
+ // Highlight when STT is enabled
278
+ .stt-checkbox.mat-mdc-checkbox-checked + .stt-hint {
279
+ color: #4caf50;
280
+ font-weight: 500;
281
+ }
282
+
283
+ // Button states
284
+ .mat-mdc-raised-button[disabled] {
285
+ opacity: 0.6;
286
+
287
+ &.realtime-button {
288
+ opacity: 0.4;
289
+ }
290
  }
flare-ui/src/app/components/chat/chat.component.ts CHANGED
@@ -1,631 +1,631 @@
1
- import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms';
4
- import { MatButtonModule } from '@angular/material/button';
5
- import { MatIconModule } from '@angular/material/icon';
6
- import { MatFormFieldModule } from '@angular/material/form-field';
7
- import { MatInputModule } from '@angular/material/input';
8
- import { MatCardModule } from '@angular/material/card';
9
- import { MatSelectModule } from '@angular/material/select';
10
- import { MatDividerModule } from '@angular/material/divider';
11
- import { MatTooltipModule } from '@angular/material/tooltip';
12
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
13
- import { MatCheckboxModule } from '@angular/material/checkbox';
14
- import { MatDialog, MatDialogModule } from '@angular/material/dialog';
15
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
16
- import { Subject, takeUntil } from 'rxjs';
17
-
18
- import { ApiService } from '../../services/api.service';
19
- import { EnvironmentService } from '../../services/environment.service';
20
- import { Router } from '@angular/router';
21
-
22
- interface ChatMessage {
23
- author: 'user' | 'assistant';
24
- text: string;
25
- timestamp?: Date;
26
- audioUrl?: string;
27
- }
28
-
29
- @Component({
30
- selector: 'app-chat',
31
- standalone: true,
32
- imports: [
33
- CommonModule,
34
- FormsModule,
35
- ReactiveFormsModule,
36
- MatButtonModule,
37
- MatIconModule,
38
- MatFormFieldModule,
39
- MatInputModule,
40
- MatCardModule,
41
- MatSelectModule,
42
- MatDividerModule,
43
- MatTooltipModule,
44
- MatProgressSpinnerModule,
45
- MatCheckboxModule,
46
- MatDialogModule,
47
- MatSnackBarModule
48
- ],
49
- templateUrl: './chat.component.html',
50
- styleUrls: ['./chat.component.scss']
51
- })
52
- export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked {
53
- @ViewChild('scrollMe') private myScrollContainer!: ElementRef;
54
- @ViewChild('audioPlayer') private audioPlayer!: ElementRef<HTMLAudioElement>;
55
- @ViewChild('waveformCanvas') private waveformCanvas!: ElementRef<HTMLCanvasElement>;
56
-
57
- projects: string[] = [];
58
- selectedProject: string | null = null;
59
- useTTS = false;
60
- ttsAvailable = false;
61
- selectedLocale: string = 'tr';
62
- availableLocales: any[] = [];
63
-
64
- sessionId: string | null = null;
65
- messages: ChatMessage[] = [];
66
- input = this.fb.control('', Validators.required);
67
-
68
- loading = false;
69
- error = '';
70
- playingAudio = false;
71
- useSTT = false;
72
- sttAvailable = false;
73
- isListening = false;
74
-
75
- // Audio visualization
76
- audioContext?: AudioContext;
77
- analyser?: AnalyserNode;
78
- animationId?: number;
79
-
80
- private destroyed$ = new Subject<void>();
81
- private shouldScroll = false;
82
-
83
- constructor(
84
- private fb: FormBuilder,
85
- private api: ApiService,
86
- private environmentService: EnvironmentService,
87
- private dialog: MatDialog,
88
- private router: Router,
89
- private snackBar: MatSnackBar
90
- ) {}
91
-
92
- ngOnInit(): void {
93
- this.loadProjects();
94
- this.loadAvailableLocales();
95
- this.checkTTSAvailability();
96
- this.checkSTTAvailability();
97
-
98
- // Initialize Audio Context with error handling
99
- try {
100
- this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
101
- } catch (error) {
102
- console.error('Failed to create AudioContext:', error);
103
- }
104
-
105
- // Watch for STT toggle changes
106
- this.watchSTTToggle();
107
- }
108
-
109
- loadAvailableLocales(): void {
110
- this.api.getAvailableLocales().pipe(
111
- takeUntil(this.destroyed$)
112
- ).subscribe({
113
- next: (response) => {
114
- this.availableLocales = response.locales;
115
- this.selectedLocale = response.default || 'tr';
116
- },
117
- error: (err) => {
118
- console.error('Failed to load locales:', err);
119
- // Fallback locales
120
- this.availableLocales = [
121
- { code: 'tr', name: 'Türkçe' },
122
- { code: 'en', name: 'English' }
123
- ];
124
- }
125
- });
126
- }
127
-
128
- private watchSTTToggle(): void {
129
- // When STT is toggled, provide feedback
130
- // This could be implemented with form control valueChanges if needed
131
- }
132
-
133
- ngAfterViewChecked() {
134
- if (this.shouldScroll) {
135
- this.scrollToBottom();
136
- this.shouldScroll = false;
137
- }
138
- }
139
-
140
- ngOnDestroy(): void {
141
- this.destroyed$.next();
142
- this.destroyed$.complete();
143
-
144
- // Cleanup audio resources
145
- this.cleanupAudio();
146
- }
147
-
148
- private cleanupAudio(): void {
149
- if (this.animationId) {
150
- cancelAnimationFrame(this.animationId);
151
- this.animationId = undefined;
152
- }
153
-
154
- if (this.audioContext && this.audioContext.state !== 'closed') {
155
- this.audioContext.close().catch(err => console.error('Failed to close audio context:', err));
156
- }
157
-
158
- // Clean up audio URLs
159
- this.messages.forEach(msg => {
160
- if (msg.audioUrl) {
161
- URL.revokeObjectURL(msg.audioUrl);
162
- }
163
- });
164
- }
165
-
166
- private checkSTTAvailability(): void {
167
- this.api.getEnvironment().pipe(
168
- takeUntil(this.destroyed$)
169
- ).subscribe({
170
- next: (env) => {
171
- this.sttAvailable = env.stt_provider?.name !== 'no_stt';
172
- if (!this.sttAvailable) {
173
- this.useSTT = false;
174
- }
175
- },
176
- error: (err) => {
177
- console.error('Failed to check STT availability:', err);
178
- this.sttAvailable = false;
179
- }
180
- });
181
- }
182
-
183
- async startRealtimeChat(): Promise<void> {
184
- if (!this.selectedProject) {
185
- this.error = 'Please select a project first';
186
- this.snackBar.open(this.error, 'Close', { duration: 3000 });
187
- return;
188
- }
189
-
190
- if (!this.sttAvailable || !this.useSTT) {
191
- this.error = 'STT must be enabled for real-time chat';
192
- this.snackBar.open(this.error, 'Close', { duration: 5000 });
193
- return;
194
- }
195
-
196
- this.loading = true;
197
- this.error = '';
198
-
199
- this.api.startChat(this.selectedProject, true, this.selectedLocale).pipe(
200
- takeUntil(this.destroyed$)
201
- ).subscribe({
202
- next: res => {
203
- // Store session ID for realtime component
204
- localStorage.setItem('current_session_id', res.session_id);
205
- localStorage.setItem('current_project', this.selectedProject || '');
206
- localStorage.setItem('current_locale', this.selectedLocale);
207
- localStorage.setItem('use_tts', this.useTTS.toString());
208
-
209
- // Open realtime chat dialog
210
- this.openRealtimeDialog(res.session_id);
211
-
212
- this.loading = false;
213
- },
214
- error: (err) => {
215
- this.error = this.getErrorMessage(err);
216
- this.loading = false;
217
- this.snackBar.open(this.error, 'Close', {
218
- duration: 5000,
219
- panelClass: 'error-snackbar'
220
- });
221
- }
222
- });
223
- }
224
-
225
- private async openRealtimeDialog(sessionId: string): Promise<void> {
226
- try {
227
- const { RealtimeChatComponent } = await import('./realtime-chat.component');
228
-
229
- const dialogRef = this.dialog.open(RealtimeChatComponent, {
230
- width: '90%',
231
- maxWidth: '900px',
232
- height: '85vh',
233
- maxHeight: '800px',
234
- disableClose: false,
235
- panelClass: 'realtime-chat-dialog',
236
- data: {
237
- sessionId: sessionId,
238
- projectName: this.selectedProject
239
- }
240
- });
241
-
242
- dialogRef.afterClosed().pipe(
243
- takeUntil(this.destroyed$)
244
- ).subscribe(result => {
245
- // Clean up session data
246
- localStorage.removeItem('current_session_id');
247
- localStorage.removeItem('current_project');
248
- localStorage.removeItem('current_locale');
249
- localStorage.removeItem('use_tts');
250
-
251
- // If session was active, end it
252
- if (result === 'session_active' && sessionId) {
253
- this.api.endSession(sessionId).pipe(
254
- takeUntil(this.destroyed$)
255
- ).subscribe({
256
- next: () => console.log('Session ended'),
257
- error: (err: any) => console.error('Failed to end session:', err)
258
- });
259
- }
260
- });
261
- } catch (error) {
262
- console.error('Failed to load realtime chat:', error);
263
- this.snackBar.open('Failed to open realtime chat', 'Close', {
264
- duration: 3000,
265
- panelClass: 'error-snackbar'
266
- });
267
- }
268
- }
269
-
270
- loadProjects(): void {
271
- this.loading = true;
272
- this.error = '';
273
-
274
- this.api.getChatProjects().pipe(
275
- takeUntil(this.destroyed$)
276
- ).subscribe({
277
- next: projects => {
278
- this.projects = projects;
279
- this.loading = false;
280
- if (projects.length === 0) {
281
- this.error = 'No enabled projects found. Please enable a project with published version.';
282
- }
283
- },
284
- error: (err) => {
285
- this.error = 'Failed to load projects';
286
- this.loading = false;
287
- this.snackBar.open(this.error, 'Close', {
288
- duration: 5000,
289
- panelClass: 'error-snackbar'
290
- });
291
- }
292
- });
293
- }
294
-
295
- checkTTSAvailability(): void {
296
- // Subscribe to environment updates
297
- this.environmentService.environment$.pipe(
298
- takeUntil(this.destroyed$)
299
- ).subscribe(env => {
300
- if (env) {
301
- this.ttsAvailable = env.tts_provider?.name !== 'no_tts';
302
- if (!this.ttsAvailable) {
303
- this.useTTS = false;
304
- }
305
- }
306
- });
307
-
308
- // Get current environment
309
- this.api.getEnvironment().pipe(
310
- takeUntil(this.destroyed$)
311
- ).subscribe({
312
- next: (env) => {
313
- this.ttsAvailable = env.tts_provider?.name !== 'no_tts';
314
- if (!this.ttsAvailable) {
315
- this.useTTS = false;
316
- }
317
- }
318
- });
319
- }
320
-
321
- startChat(): void {
322
- if (!this.selectedProject) {
323
- this.snackBar.open('Please select a project', 'Close', { duration: 3000 });
324
- return;
325
- }
326
-
327
- if (this.useSTT) {
328
- this.snackBar.open('For voice input, please use Real-time Chat', 'Close', { duration: 3000 });
329
- return;
330
- }
331
-
332
- this.loading = true;
333
- this.error = '';
334
-
335
- this.api.startChat(this.selectedProject, false, this.selectedLocale).pipe(
336
- takeUntil(this.destroyed$)
337
- ).subscribe({
338
- next: res => {
339
- this.sessionId = res.session_id;
340
- const message: ChatMessage = {
341
- author: 'assistant',
342
- text: res.answer,
343
- timestamp: new Date()
344
- };
345
-
346
- this.messages = [message];
347
- this.loading = false;
348
- this.shouldScroll = true;
349
-
350
- // Generate TTS if enabled
351
- if (this.useTTS && this.ttsAvailable) {
352
- this.generateTTS(res.answer, this.messages.length - 1);
353
- }
354
- },
355
- error: (err) => {
356
- this.error = this.getErrorMessage(err);
357
- this.loading = false;
358
- this.snackBar.open(this.error, 'Close', {
359
- duration: 5000,
360
- panelClass: 'error-snackbar'
361
- });
362
- }
363
- });
364
- }
365
-
366
- send(): void {
367
- if (!this.sessionId || this.input.invalid || this.loading) return;
368
-
369
- const text = this.input.value!.trim();
370
- if (!text) return;
371
-
372
- // Add user message
373
- this.messages.push({
374
- author: 'user',
375
- text,
376
- timestamp: new Date()
377
- });
378
-
379
- this.input.reset();
380
- this.loading = true;
381
- this.shouldScroll = true;
382
-
383
- // Send to backend
384
- this.api.chat(this.sessionId, text).pipe(
385
- takeUntil(this.destroyed$)
386
- ).subscribe({
387
- next: res => {
388
- const message: ChatMessage = {
389
- author: 'assistant',
390
- text: res.response,
391
- timestamp: new Date()
392
- };
393
-
394
- this.messages.push(message);
395
- this.loading = false;
396
- this.shouldScroll = true;
397
-
398
- // Generate TTS if enabled
399
- if (this.useTTS && this.ttsAvailable) {
400
- this.generateTTS(res.response, this.messages.length - 1);
401
- }
402
- },
403
- error: (err) => {
404
- const errorMsg = this.getErrorMessage(err);
405
- this.messages.push({
406
- author: 'assistant',
407
- text: '⚠️ ' + errorMsg,
408
- timestamp: new Date()
409
- });
410
- this.loading = false;
411
- this.shouldScroll = true;
412
- }
413
- });
414
- }
415
-
416
- generateTTS(text: string, messageIndex: number): void {
417
- if (!this.ttsAvailable || messageIndex < 0 || messageIndex >= this.messages.length) return;
418
-
419
- this.api.generateTTS(text).pipe(
420
- takeUntil(this.destroyed$)
421
- ).subscribe({
422
- next: (audioBlob) => {
423
- const audioUrl = URL.createObjectURL(audioBlob);
424
-
425
- // Clean up old audio URL if exists
426
- if (this.messages[messageIndex].audioUrl) {
427
- URL.revokeObjectURL(this.messages[messageIndex].audioUrl!);
428
- }
429
-
430
- this.messages[messageIndex].audioUrl = audioUrl;
431
-
432
- // Auto-play the latest message
433
- if (messageIndex === this.messages.length - 1) {
434
- setTimeout(() => this.playAudio(audioUrl), 100);
435
- }
436
- },
437
- error: (err) => {
438
- console.error('TTS generation error:', err);
439
- this.snackBar.open('Failed to generate audio', 'Close', {
440
- duration: 3000,
441
- panelClass: 'error-snackbar'
442
- });
443
- }
444
- });
445
- }
446
-
447
- playAudio(audioUrl: string): void {
448
- if (!this.audioPlayer || !audioUrl) return;
449
-
450
- const audio = this.audioPlayer.nativeElement;
451
-
452
- // Stop current audio if playing
453
- if (!audio.paused) {
454
- audio.pause();
455
- audio.currentTime = 0;
456
- }
457
-
458
- audio.src = audioUrl;
459
-
460
- // Set up audio visualization
461
- if (this.audioContext && this.audioContext.state !== 'closed') {
462
- this.setupAudioVisualization(audio);
463
- }
464
-
465
- audio.play().then(() => {
466
- this.playingAudio = true;
467
- }).catch(err => {
468
- console.error('Audio play error:', err);
469
- this.snackBar.open('Failed to play audio', 'Close', {
470
- duration: 3000,
471
- panelClass: 'error-snackbar'
472
- });
473
- });
474
-
475
- audio.onended = () => {
476
- this.playingAudio = false;
477
- if (this.animationId) {
478
- cancelAnimationFrame(this.animationId);
479
- this.animationId = undefined;
480
- this.clearWaveform();
481
- }
482
- };
483
-
484
- audio.onerror = () => {
485
- this.playingAudio = false;
486
- console.error('Audio playback error');
487
- };
488
- }
489
-
490
- setupAudioVisualization(audio: HTMLAudioElement): void {
491
- if (!this.audioContext || !this.waveformCanvas || this.audioContext.state === 'closed') return;
492
-
493
- try {
494
- // Check if source already exists for this audio element
495
- if (!(audio as any).audioSource) {
496
- const source = this.audioContext.createMediaElementSource(audio);
497
- this.analyser = this.audioContext.createAnalyser();
498
- this.analyser.fftSize = 256;
499
-
500
- // Connect nodes
501
- source.connect(this.analyser);
502
- this.analyser.connect(this.audioContext.destination);
503
-
504
- // Store reference to prevent recreation
505
- (audio as any).audioSource = source;
506
- }
507
-
508
- // Start visualization
509
- this.drawWaveform();
510
- } catch (error) {
511
- console.error('Failed to setup audio visualization:', error);
512
- }
513
- }
514
-
515
- drawWaveform(): void {
516
- if (!this.analyser || !this.waveformCanvas) return;
517
-
518
- const canvas = this.waveformCanvas.nativeElement;
519
- const ctx = canvas.getContext('2d');
520
- if (!ctx) return;
521
-
522
- const bufferLength = this.analyser.frequencyBinCount;
523
- const dataArray = new Uint8Array(bufferLength);
524
-
525
- const draw = () => {
526
- if (!this.playingAudio) {
527
- this.clearWaveform();
528
- return;
529
- }
530
-
531
- this.animationId = requestAnimationFrame(draw);
532
-
533
- this.analyser!.getByteFrequencyData(dataArray);
534
-
535
- ctx.fillStyle = 'rgb(240, 240, 240)';
536
- ctx.fillRect(0, 0, canvas.width, canvas.height);
537
-
538
- const barWidth = (canvas.width / bufferLength) * 2.5;
539
- let barHeight;
540
- let x = 0;
541
-
542
- for (let i = 0; i < bufferLength; i++) {
543
- barHeight = (dataArray[i] / 255) * canvas.height * 0.8;
544
-
545
- ctx.fillStyle = `rgb(63, 81, 181)`;
546
- ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
547
-
548
- x += barWidth + 1;
549
- }
550
- };
551
-
552
- draw();
553
- }
554
-
555
- clearWaveform(): void {
556
- if (!this.waveformCanvas) return;
557
-
558
- const canvas = this.waveformCanvas.nativeElement;
559
- const ctx = canvas.getContext('2d');
560
- if (!ctx) return;
561
-
562
- ctx.fillStyle = 'rgb(240, 240, 240)';
563
- ctx.fillRect(0, 0, canvas.width, canvas.height);
564
- }
565
-
566
- endSession(): void {
567
- // Clean up current session
568
- if (this.sessionId) {
569
- this.api.endSession(this.sessionId).pipe(
570
- takeUntil(this.destroyed$)
571
- ).subscribe({
572
- error: (err) => console.error('Failed to end session:', err)
573
- });
574
- }
575
-
576
- // Clean up audio URLs
577
- this.messages.forEach(msg => {
578
- if (msg.audioUrl) {
579
- URL.revokeObjectURL(msg.audioUrl);
580
- }
581
- });
582
-
583
- // Reset state
584
- this.sessionId = null;
585
- this.messages = [];
586
- this.selectedProject = null;
587
- this.input.reset();
588
- this.error = '';
589
-
590
- // Clean up audio
591
- if (this.audioPlayer) {
592
- this.audioPlayer.nativeElement.pause();
593
- this.audioPlayer.nativeElement.src = '';
594
- }
595
-
596
- if (this.animationId) {
597
- cancelAnimationFrame(this.animationId);
598
- this.animationId = undefined;
599
- }
600
-
601
- this.clearWaveform();
602
- }
603
-
604
- private scrollToBottom(): void {
605
- try {
606
- if (this.myScrollContainer?.nativeElement) {
607
- const element = this.myScrollContainer.nativeElement;
608
- element.scrollTop = element.scrollHeight;
609
- }
610
- } catch(err) {
611
- console.error('Scroll error:', err);
612
- }
613
- }
614
-
615
- private getErrorMessage(error: any): string {
616
- if (error.status === 0) {
617
- return 'Unable to connect to server. Please check your connection.';
618
- } else if (error.status === 401) {
619
- return 'Session expired. Please login again.';
620
- } else if (error.status === 403) {
621
- return 'You do not have permission to use this feature.';
622
- } else if (error.status === 404) {
623
- return 'Project or session not found. Please try again.';
624
- } else if (error.error?.detail) {
625
- return error.error.detail;
626
- } else if (error.message) {
627
- return error.message;
628
- }
629
- return 'An unexpected error occurred. Please try again.';
630
- }
631
  }
 
1
+ import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { MatIconModule } from '@angular/material/icon';
6
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
+ import { MatInputModule } from '@angular/material/input';
8
+ import { MatCardModule } from '@angular/material/card';
9
+ import { MatSelectModule } from '@angular/material/select';
10
+ import { MatDividerModule } from '@angular/material/divider';
11
+ import { MatTooltipModule } from '@angular/material/tooltip';
12
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
13
+ import { MatCheckboxModule } from '@angular/material/checkbox';
14
+ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
15
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
16
+ import { Subject, takeUntil } from 'rxjs';
17
+
18
+ import { ApiService } from '../../services/api.service';
19
+ import { EnvironmentService } from '../../services/environment.service';
20
+ import { Router } from '@angular/router';
21
+
22
+ interface ChatMessage {
23
+ author: 'user' | 'assistant';
24
+ text: string;
25
+ timestamp?: Date;
26
+ audioUrl?: string;
27
+ }
28
+
29
+ @Component({
30
+ selector: 'app-chat',
31
+ standalone: true,
32
+ imports: [
33
+ CommonModule,
34
+ FormsModule,
35
+ ReactiveFormsModule,
36
+ MatButtonModule,
37
+ MatIconModule,
38
+ MatFormFieldModule,
39
+ MatInputModule,
40
+ MatCardModule,
41
+ MatSelectModule,
42
+ MatDividerModule,
43
+ MatTooltipModule,
44
+ MatProgressSpinnerModule,
45
+ MatCheckboxModule,
46
+ MatDialogModule,
47
+ MatSnackBarModule
48
+ ],
49
+ templateUrl: './chat.component.html',
50
+ styleUrls: ['./chat.component.scss']
51
+ })
52
+ export class ChatComponent implements OnInit, OnDestroy, AfterViewChecked {
53
+ @ViewChild('scrollMe') private myScrollContainer!: ElementRef;
54
+ @ViewChild('audioPlayer') private audioPlayer!: ElementRef<HTMLAudioElement>;
55
+ @ViewChild('waveformCanvas') private waveformCanvas!: ElementRef<HTMLCanvasElement>;
56
+
57
+ projects: string[] = [];
58
+ selectedProject: string | null = null;
59
+ useTTS = false;
60
+ ttsAvailable = false;
61
+ selectedLocale: string = 'tr';
62
+ availableLocales: any[] = [];
63
+
64
+ sessionId: string | null = null;
65
+ messages: ChatMessage[] = [];
66
+ input = this.fb.control('', Validators.required);
67
+
68
+ loading = false;
69
+ error = '';
70
+ playingAudio = false;
71
+ useSTT = false;
72
+ sttAvailable = false;
73
+ isListening = false;
74
+
75
+ // Audio visualization
76
+ audioContext?: AudioContext;
77
+ analyser?: AnalyserNode;
78
+ animationId?: number;
79
+
80
+ private destroyed$ = new Subject<void>();
81
+ private shouldScroll = false;
82
+
83
+ constructor(
84
+ private fb: FormBuilder,
85
+ private api: ApiService,
86
+ private environmentService: EnvironmentService,
87
+ private dialog: MatDialog,
88
+ private router: Router,
89
+ private snackBar: MatSnackBar
90
+ ) {}
91
+
92
+ ngOnInit(): void {
93
+ this.loadProjects();
94
+ this.loadAvailableLocales();
95
+ this.checkTTSAvailability();
96
+ this.checkSTTAvailability();
97
+
98
+ // Initialize Audio Context with error handling
99
+ try {
100
+ this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
101
+ } catch (error) {
102
+ console.error('Failed to create AudioContext:', error);
103
+ }
104
+
105
+ // Watch for STT toggle changes
106
+ this.watchSTTToggle();
107
+ }
108
+
109
+ loadAvailableLocales(): void {
110
+ this.api.getAvailableLocales().pipe(
111
+ takeUntil(this.destroyed$)
112
+ ).subscribe({
113
+ next: (response) => {
114
+ this.availableLocales = response.locales;
115
+ this.selectedLocale = response.default || 'tr';
116
+ },
117
+ error: (err) => {
118
+ console.error('Failed to load locales:', err);
119
+ // Fallback locales
120
+ this.availableLocales = [
121
+ { code: 'tr', name: 'Türkçe' },
122
+ { code: 'en', name: 'English' }
123
+ ];
124
+ }
125
+ });
126
+ }
127
+
128
+ private watchSTTToggle(): void {
129
+ // When STT is toggled, provide feedback
130
+ // This could be implemented with form control valueChanges if needed
131
+ }
132
+
133
+ ngAfterViewChecked() {
134
+ if (this.shouldScroll) {
135
+ this.scrollToBottom();
136
+ this.shouldScroll = false;
137
+ }
138
+ }
139
+
140
+ ngOnDestroy(): void {
141
+ this.destroyed$.next();
142
+ this.destroyed$.complete();
143
+
144
+ // Cleanup audio resources
145
+ this.cleanupAudio();
146
+ }
147
+
148
+ private cleanupAudio(): void {
149
+ if (this.animationId) {
150
+ cancelAnimationFrame(this.animationId);
151
+ this.animationId = undefined;
152
+ }
153
+
154
+ if (this.audioContext && this.audioContext.state !== 'closed') {
155
+ this.audioContext.close().catch(err => console.error('Failed to close audio context:', err));
156
+ }
157
+
158
+ // Clean up audio URLs
159
+ this.messages.forEach(msg => {
160
+ if (msg.audioUrl) {
161
+ URL.revokeObjectURL(msg.audioUrl);
162
+ }
163
+ });
164
+ }
165
+
166
+ private checkSTTAvailability(): void {
167
+ this.api.getEnvironment().pipe(
168
+ takeUntil(this.destroyed$)
169
+ ).subscribe({
170
+ next: (env) => {
171
+ this.sttAvailable = env.stt_provider?.name !== 'no_stt';
172
+ if (!this.sttAvailable) {
173
+ this.useSTT = false;
174
+ }
175
+ },
176
+ error: (err) => {
177
+ console.error('Failed to check STT availability:', err);
178
+ this.sttAvailable = false;
179
+ }
180
+ });
181
+ }
182
+
183
+ async startRealtimeChat(): Promise<void> {
184
+ if (!this.selectedProject) {
185
+ this.error = 'Please select a project first';
186
+ this.snackBar.open(this.error, 'Close', { duration: 3000 });
187
+ return;
188
+ }
189
+
190
+ if (!this.sttAvailable || !this.useSTT) {
191
+ this.error = 'STT must be enabled for real-time chat';
192
+ this.snackBar.open(this.error, 'Close', { duration: 5000 });
193
+ return;
194
+ }
195
+
196
+ this.loading = true;
197
+ this.error = '';
198
+
199
+ this.api.startChat(this.selectedProject, true, this.selectedLocale).pipe(
200
+ takeUntil(this.destroyed$)
201
+ ).subscribe({
202
+ next: res => {
203
+ // Store session ID for realtime component
204
+ localStorage.setItem('current_session_id', res.session_id);
205
+ localStorage.setItem('current_project', this.selectedProject || '');
206
+ localStorage.setItem('current_locale', this.selectedLocale);
207
+ localStorage.setItem('use_tts', this.useTTS.toString());
208
+
209
+ // Open realtime chat dialog
210
+ this.openRealtimeDialog(res.session_id);
211
+
212
+ this.loading = false;
213
+ },
214
+ error: (err) => {
215
+ this.error = this.getErrorMessage(err);
216
+ this.loading = false;
217
+ this.snackBar.open(this.error, 'Close', {
218
+ duration: 5000,
219
+ panelClass: 'error-snackbar'
220
+ });
221
+ }
222
+ });
223
+ }
224
+
225
+ private async openRealtimeDialog(sessionId: string): Promise<void> {
226
+ try {
227
+ const { RealtimeChatComponent } = await import('./realtime-chat.component');
228
+
229
+ const dialogRef = this.dialog.open(RealtimeChatComponent, {
230
+ width: '90%',
231
+ maxWidth: '900px',
232
+ height: '85vh',
233
+ maxHeight: '800px',
234
+ disableClose: false,
235
+ panelClass: 'realtime-chat-dialog',
236
+ data: {
237
+ sessionId: sessionId,
238
+ projectName: this.selectedProject
239
+ }
240
+ });
241
+
242
+ dialogRef.afterClosed().pipe(
243
+ takeUntil(this.destroyed$)
244
+ ).subscribe(result => {
245
+ // Clean up session data
246
+ localStorage.removeItem('current_session_id');
247
+ localStorage.removeItem('current_project');
248
+ localStorage.removeItem('current_locale');
249
+ localStorage.removeItem('use_tts');
250
+
251
+ // If session was active, end it
252
+ if (result === 'session_active' && sessionId) {
253
+ this.api.endSession(sessionId).pipe(
254
+ takeUntil(this.destroyed$)
255
+ ).subscribe({
256
+ next: () => console.log('Session ended'),
257
+ error: (err: any) => console.error('Failed to end session:', err)
258
+ });
259
+ }
260
+ });
261
+ } catch (error) {
262
+ console.error('Failed to load realtime chat:', error);
263
+ this.snackBar.open('Failed to open realtime chat', 'Close', {
264
+ duration: 3000,
265
+ panelClass: 'error-snackbar'
266
+ });
267
+ }
268
+ }
269
+
270
+ loadProjects(): void {
271
+ this.loading = true;
272
+ this.error = '';
273
+
274
+ this.api.getChatProjects().pipe(
275
+ takeUntil(this.destroyed$)
276
+ ).subscribe({
277
+ next: projects => {
278
+ this.projects = projects;
279
+ this.loading = false;
280
+ if (projects.length === 0) {
281
+ this.error = 'No enabled projects found. Please enable a project with published version.';
282
+ }
283
+ },
284
+ error: (err) => {
285
+ this.error = 'Failed to load projects';
286
+ this.loading = false;
287
+ this.snackBar.open(this.error, 'Close', {
288
+ duration: 5000,
289
+ panelClass: 'error-snackbar'
290
+ });
291
+ }
292
+ });
293
+ }
294
+
295
+ checkTTSAvailability(): void {
296
+ // Subscribe to environment updates
297
+ this.environmentService.environment$.pipe(
298
+ takeUntil(this.destroyed$)
299
+ ).subscribe(env => {
300
+ if (env) {
301
+ this.ttsAvailable = env.tts_provider?.name !== 'no_tts';
302
+ if (!this.ttsAvailable) {
303
+ this.useTTS = false;
304
+ }
305
+ }
306
+ });
307
+
308
+ // Get current environment
309
+ this.api.getEnvironment().pipe(
310
+ takeUntil(this.destroyed$)
311
+ ).subscribe({
312
+ next: (env) => {
313
+ this.ttsAvailable = env.tts_provider?.name !== 'no_tts';
314
+ if (!this.ttsAvailable) {
315
+ this.useTTS = false;
316
+ }
317
+ }
318
+ });
319
+ }
320
+
321
+ startChat(): void {
322
+ if (!this.selectedProject) {
323
+ this.snackBar.open('Please select a project', 'Close', { duration: 3000 });
324
+ return;
325
+ }
326
+
327
+ if (this.useSTT) {
328
+ this.snackBar.open('For voice input, please use Real-time Chat', 'Close', { duration: 3000 });
329
+ return;
330
+ }
331
+
332
+ this.loading = true;
333
+ this.error = '';
334
+
335
+ this.api.startChat(this.selectedProject, false, this.selectedLocale).pipe(
336
+ takeUntil(this.destroyed$)
337
+ ).subscribe({
338
+ next: res => {
339
+ this.sessionId = res.session_id;
340
+ const message: ChatMessage = {
341
+ author: 'assistant',
342
+ text: res.answer,
343
+ timestamp: new Date()
344
+ };
345
+
346
+ this.messages = [message];
347
+ this.loading = false;
348
+ this.shouldScroll = true;
349
+
350
+ // Generate TTS if enabled
351
+ if (this.useTTS && this.ttsAvailable) {
352
+ this.generateTTS(res.answer, this.messages.length - 1);
353
+ }
354
+ },
355
+ error: (err) => {
356
+ this.error = this.getErrorMessage(err);
357
+ this.loading = false;
358
+ this.snackBar.open(this.error, 'Close', {
359
+ duration: 5000,
360
+ panelClass: 'error-snackbar'
361
+ });
362
+ }
363
+ });
364
+ }
365
+
366
+ send(): void {
367
+ if (!this.sessionId || this.input.invalid || this.loading) return;
368
+
369
+ const text = this.input.value!.trim();
370
+ if (!text) return;
371
+
372
+ // Add user message
373
+ this.messages.push({
374
+ author: 'user',
375
+ text,
376
+ timestamp: new Date()
377
+ });
378
+
379
+ this.input.reset();
380
+ this.loading = true;
381
+ this.shouldScroll = true;
382
+
383
+ // Send to backend
384
+ this.api.chat(this.sessionId, text).pipe(
385
+ takeUntil(this.destroyed$)
386
+ ).subscribe({
387
+ next: res => {
388
+ const message: ChatMessage = {
389
+ author: 'assistant',
390
+ text: res.response,
391
+ timestamp: new Date()
392
+ };
393
+
394
+ this.messages.push(message);
395
+ this.loading = false;
396
+ this.shouldScroll = true;
397
+
398
+ // Generate TTS if enabled
399
+ if (this.useTTS && this.ttsAvailable) {
400
+ this.generateTTS(res.response, this.messages.length - 1);
401
+ }
402
+ },
403
+ error: (err) => {
404
+ const errorMsg = this.getErrorMessage(err);
405
+ this.messages.push({
406
+ author: 'assistant',
407
+ text: '⚠️ ' + errorMsg,
408
+ timestamp: new Date()
409
+ });
410
+ this.loading = false;
411
+ this.shouldScroll = true;
412
+ }
413
+ });
414
+ }
415
+
416
+ generateTTS(text: string, messageIndex: number): void {
417
+ if (!this.ttsAvailable || messageIndex < 0 || messageIndex >= this.messages.length) return;
418
+
419
+ this.api.generateTTS(text).pipe(
420
+ takeUntil(this.destroyed$)
421
+ ).subscribe({
422
+ next: (audioBlob) => {
423
+ const audioUrl = URL.createObjectURL(audioBlob);
424
+
425
+ // Clean up old audio URL if exists
426
+ if (this.messages[messageIndex].audioUrl) {
427
+ URL.revokeObjectURL(this.messages[messageIndex].audioUrl!);
428
+ }
429
+
430
+ this.messages[messageIndex].audioUrl = audioUrl;
431
+
432
+ // Auto-play the latest message
433
+ if (messageIndex === this.messages.length - 1) {
434
+ setTimeout(() => this.playAudio(audioUrl), 100);
435
+ }
436
+ },
437
+ error: (err) => {
438
+ console.error('TTS generation error:', err);
439
+ this.snackBar.open('Failed to generate audio', 'Close', {
440
+ duration: 3000,
441
+ panelClass: 'error-snackbar'
442
+ });
443
+ }
444
+ });
445
+ }
446
+
447
+ playAudio(audioUrl: string): void {
448
+ if (!this.audioPlayer || !audioUrl) return;
449
+
450
+ const audio = this.audioPlayer.nativeElement;
451
+
452
+ // Stop current audio if playing
453
+ if (!audio.paused) {
454
+ audio.pause();
455
+ audio.currentTime = 0;
456
+ }
457
+
458
+ audio.src = audioUrl;
459
+
460
+ // Set up audio visualization
461
+ if (this.audioContext && this.audioContext.state !== 'closed') {
462
+ this.setupAudioVisualization(audio);
463
+ }
464
+
465
+ audio.play().then(() => {
466
+ this.playingAudio = true;
467
+ }).catch(err => {
468
+ console.error('Audio play error:', err);
469
+ this.snackBar.open('Failed to play audio', 'Close', {
470
+ duration: 3000,
471
+ panelClass: 'error-snackbar'
472
+ });
473
+ });
474
+
475
+ audio.onended = () => {
476
+ this.playingAudio = false;
477
+ if (this.animationId) {
478
+ cancelAnimationFrame(this.animationId);
479
+ this.animationId = undefined;
480
+ this.clearWaveform();
481
+ }
482
+ };
483
+
484
+ audio.onerror = () => {
485
+ this.playingAudio = false;
486
+ console.error('Audio playback error');
487
+ };
488
+ }
489
+
490
+ setupAudioVisualization(audio: HTMLAudioElement): void {
491
+ if (!this.audioContext || !this.waveformCanvas || this.audioContext.state === 'closed') return;
492
+
493
+ try {
494
+ // Check if source already exists for this audio element
495
+ if (!(audio as any).audioSource) {
496
+ const source = this.audioContext.createMediaElementSource(audio);
497
+ this.analyser = this.audioContext.createAnalyser();
498
+ this.analyser.fftSize = 256;
499
+
500
+ // Connect nodes
501
+ source.connect(this.analyser);
502
+ this.analyser.connect(this.audioContext.destination);
503
+
504
+ // Store reference to prevent recreation
505
+ (audio as any).audioSource = source;
506
+ }
507
+
508
+ // Start visualization
509
+ this.drawWaveform();
510
+ } catch (error) {
511
+ console.error('Failed to setup audio visualization:', error);
512
+ }
513
+ }
514
+
515
+ drawWaveform(): void {
516
+ if (!this.analyser || !this.waveformCanvas) return;
517
+
518
+ const canvas = this.waveformCanvas.nativeElement;
519
+ const ctx = canvas.getContext('2d');
520
+ if (!ctx) return;
521
+
522
+ const bufferLength = this.analyser.frequencyBinCount;
523
+ const dataArray = new Uint8Array(bufferLength);
524
+
525
+ const draw = () => {
526
+ if (!this.playingAudio) {
527
+ this.clearWaveform();
528
+ return;
529
+ }
530
+
531
+ this.animationId = requestAnimationFrame(draw);
532
+
533
+ this.analyser!.getByteFrequencyData(dataArray);
534
+
535
+ ctx.fillStyle = 'rgb(240, 240, 240)';
536
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
537
+
538
+ const barWidth = (canvas.width / bufferLength) * 2.5;
539
+ let barHeight;
540
+ let x = 0;
541
+
542
+ for (let i = 0; i < bufferLength; i++) {
543
+ barHeight = (dataArray[i] / 255) * canvas.height * 0.8;
544
+
545
+ ctx.fillStyle = `rgb(63, 81, 181)`;
546
+ ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
547
+
548
+ x += barWidth + 1;
549
+ }
550
+ };
551
+
552
+ draw();
553
+ }
554
+
555
+ clearWaveform(): void {
556
+ if (!this.waveformCanvas) return;
557
+
558
+ const canvas = this.waveformCanvas.nativeElement;
559
+ const ctx = canvas.getContext('2d');
560
+ if (!ctx) return;
561
+
562
+ ctx.fillStyle = 'rgb(240, 240, 240)';
563
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
564
+ }
565
+
566
+ endSession(): void {
567
+ // Clean up current session
568
+ if (this.sessionId) {
569
+ this.api.endSession(this.sessionId).pipe(
570
+ takeUntil(this.destroyed$)
571
+ ).subscribe({
572
+ error: (err) => console.error('Failed to end session:', err)
573
+ });
574
+ }
575
+
576
+ // Clean up audio URLs
577
+ this.messages.forEach(msg => {
578
+ if (msg.audioUrl) {
579
+ URL.revokeObjectURL(msg.audioUrl);
580
+ }
581
+ });
582
+
583
+ // Reset state
584
+ this.sessionId = null;
585
+ this.messages = [];
586
+ this.selectedProject = null;
587
+ this.input.reset();
588
+ this.error = '';
589
+
590
+ // Clean up audio
591
+ if (this.audioPlayer) {
592
+ this.audioPlayer.nativeElement.pause();
593
+ this.audioPlayer.nativeElement.src = '';
594
+ }
595
+
596
+ if (this.animationId) {
597
+ cancelAnimationFrame(this.animationId);
598
+ this.animationId = undefined;
599
+ }
600
+
601
+ this.clearWaveform();
602
+ }
603
+
604
+ private scrollToBottom(): void {
605
+ try {
606
+ if (this.myScrollContainer?.nativeElement) {
607
+ const element = this.myScrollContainer.nativeElement;
608
+ element.scrollTop = element.scrollHeight;
609
+ }
610
+ } catch(err) {
611
+ console.error('Scroll error:', err);
612
+ }
613
+ }
614
+
615
+ private getErrorMessage(error: any): string {
616
+ if (error.status === 0) {
617
+ return 'Unable to connect to server. Please check your connection.';
618
+ } else if (error.status === 401) {
619
+ return 'Session expired. Please login again.';
620
+ } else if (error.status === 403) {
621
+ return 'You do not have permission to use this feature.';
622
+ } else if (error.status === 404) {
623
+ return 'Project or session not found. Please try again.';
624
+ } else if (error.error?.detail) {
625
+ return error.error.detail;
626
+ } else if (error.message) {
627
+ return error.message;
628
+ }
629
+ return 'An unexpected error occurred. Please try again.';
630
+ }
631
  }
flare-ui/src/app/components/chat/realtime-chat.component.html CHANGED
@@ -1,97 +1,97 @@
1
- <mat-card class="realtime-chat-container">
2
- <mat-card-header>
3
- <mat-icon mat-card-avatar>voice_chat</mat-icon>
4
- <mat-card-title>Real-time Conversation</mat-card-title>
5
- <mat-card-subtitle>
6
- <mat-chip-listbox>
7
- <mat-chip [class.active]="currentState === state"
8
- *ngFor="let state of conversationStates">
9
- {{ getStateLabel(state) }}
10
- </mat-chip>
11
- </mat-chip-listbox>
12
- </mat-card-subtitle>
13
- <button mat-icon-button class="close-button" (click)="closeDialog()">
14
- <mat-icon>close</mat-icon>
15
- </button>
16
- </mat-card-header>
17
-
18
- <mat-divider></mat-divider>
19
-
20
- <mat-card-content>
21
- <!-- Error State -->
22
- <div class="error-banner" *ngIf="error">
23
- <mat-icon>error_outline</mat-icon>
24
- <span>{{ error }}</span>
25
- <button mat-icon-button (click)="retryConnection()">
26
- <mat-icon>refresh</mat-icon>
27
- </button>
28
- </div>
29
-
30
- <!-- Chat Messages -->
31
- <div class="chat-messages" #scrollContainer>
32
- <div *ngFor="let msg of messages; trackBy: trackByIndex"
33
- [class]="'message ' + msg.role">
34
- <mat-icon class="message-icon">
35
- {{ msg.role === 'user' ? 'person' : msg.role === 'assistant' ? 'smart_toy' : 'info' }}
36
- </mat-icon>
37
- <div class="message-content">
38
- <div class="message-text">{{ msg.text }}</div>
39
- <div class="message-time">{{ msg.timestamp | date:'HH:mm:ss' }}</div>
40
- <button *ngIf="msg.audioUrl && msg.role === 'assistant'"
41
- mat-icon-button
42
- (click)="playAudio(msg.audioUrl)"
43
- class="audio-button"
44
- [disabled]="isPlayingAudio">
45
- <mat-icon>{{ isPlayingAudio ? 'stop' : 'volume_up' }}</mat-icon>
46
- </button>
47
- </div>
48
- </div>
49
-
50
- <!-- Empty State -->
51
- <div class="empty-state" *ngIf="messages.length === 0 && !isConversationActive">
52
- <mat-icon>mic_off</mat-icon>
53
- <p>Konuşmaya başlamak için aşağıdaki butona tıklayın</p>
54
- </div>
55
- </div>
56
-
57
- <!-- Audio Visualizer -->
58
- <canvas #audioVisualizer
59
- class="audio-visualizer"
60
- width="600"
61
- height="100"
62
- [class.active]="isConversationActive">
63
- </canvas>
64
-
65
- </mat-card-content>
66
-
67
- <mat-card-actions>
68
- <button mat-raised-button
69
- color="primary"
70
- (click)="toggleConversation()"
71
- [disabled]="!sessionId || loading">
72
- @if (loading) {
73
- <mat-spinner diameter="20"></mat-spinner>
74
- } @else {
75
- <mat-icon>{{ isConversationActive ? 'stop' : 'mic' }}</mat-icon>
76
- {{ isConversationActive ? 'Konuşmayı Bitir' : 'Konuşmaya Başla' }}
77
- }
78
- </button>
79
-
80
- <button mat-button
81
- (click)="clearChat()"
82
- [disabled]="messages.length === 0">
83
- <mat-icon>clear</mat-icon>
84
- Temizle
85
- </button>
86
-
87
- <!-- Barge-in butonu şimdilik gizlendi
88
- <button mat-button
89
- (click)="performBargeIn()"
90
- [disabled]="!isConversationActive || currentState === 'idle' || currentState === 'listening'">
91
- <mat-icon>pan_tool</mat-icon>
92
- Kesme (Barge-in)
93
- </button>
94
- -->
95
- </mat-card-actions>
96
-
97
  </mat-card>
 
1
+ <mat-card class="realtime-chat-container">
2
+ <mat-card-header>
3
+ <mat-icon mat-card-avatar>voice_chat</mat-icon>
4
+ <mat-card-title>Real-time Conversation</mat-card-title>
5
+ <mat-card-subtitle>
6
+ <mat-chip-listbox>
7
+ <mat-chip [class.active]="currentState === state"
8
+ *ngFor="let state of conversationStates">
9
+ {{ getStateLabel(state) }}
10
+ </mat-chip>
11
+ </mat-chip-listbox>
12
+ </mat-card-subtitle>
13
+ <button mat-icon-button class="close-button" (click)="closeDialog()">
14
+ <mat-icon>close</mat-icon>
15
+ </button>
16
+ </mat-card-header>
17
+
18
+ <mat-divider></mat-divider>
19
+
20
+ <mat-card-content>
21
+ <!-- Error State -->
22
+ <div class="error-banner" *ngIf="error">
23
+ <mat-icon>error_outline</mat-icon>
24
+ <span>{{ error }}</span>
25
+ <button mat-icon-button (click)="retryConnection()">
26
+ <mat-icon>refresh</mat-icon>
27
+ </button>
28
+ </div>
29
+
30
+ <!-- Chat Messages -->
31
+ <div class="chat-messages" #scrollContainer>
32
+ <div *ngFor="let msg of messages; trackBy: trackByIndex"
33
+ [class]="'message ' + msg.role">
34
+ <mat-icon class="message-icon">
35
+ {{ msg.role === 'user' ? 'person' : msg.role === 'assistant' ? 'smart_toy' : 'info' }}
36
+ </mat-icon>
37
+ <div class="message-content">
38
+ <div class="message-text">{{ msg.text }}</div>
39
+ <div class="message-time">{{ msg.timestamp | date:'HH:mm:ss' }}</div>
40
+ <button *ngIf="msg.audioUrl && msg.role === 'assistant'"
41
+ mat-icon-button
42
+ (click)="playAudio(msg.audioUrl)"
43
+ class="audio-button"
44
+ [disabled]="isPlayingAudio">
45
+ <mat-icon>{{ isPlayingAudio ? 'stop' : 'volume_up' }}</mat-icon>
46
+ </button>
47
+ </div>
48
+ </div>
49
+
50
+ <!-- Empty State -->
51
+ <div class="empty-state" *ngIf="messages.length === 0 && !isConversationActive">
52
+ <mat-icon>mic_off</mat-icon>
53
+ <p>Konuşmaya başlamak için aşağıdaki butona tıklayın</p>
54
+ </div>
55
+ </div>
56
+
57
+ <!-- Audio Visualizer -->
58
+ <canvas #audioVisualizer
59
+ class="audio-visualizer"
60
+ width="600"
61
+ height="100"
62
+ [class.active]="isConversationActive">
63
+ </canvas>
64
+
65
+ </mat-card-content>
66
+
67
+ <mat-card-actions>
68
+ <button mat-raised-button
69
+ color="primary"
70
+ (click)="toggleConversation()"
71
+ [disabled]="!sessionId || loading">
72
+ @if (loading) {
73
+ <mat-spinner diameter="20"></mat-spinner>
74
+ } @else {
75
+ <mat-icon>{{ isConversationActive ? 'stop' : 'mic' }}</mat-icon>
76
+ {{ isConversationActive ? 'Konuşmayı Bitir' : 'Konuşmaya Başla' }}
77
+ }
78
+ </button>
79
+
80
+ <button mat-button
81
+ (click)="clearChat()"
82
+ [disabled]="messages.length === 0">
83
+ <mat-icon>clear</mat-icon>
84
+ Temizle
85
+ </button>
86
+
87
+ <!-- Barge-in butonu şimdilik gizlendi
88
+ <button mat-button
89
+ (click)="performBargeIn()"
90
+ [disabled]="!isConversationActive || currentState === 'idle' || currentState === 'listening'">
91
+ <mat-icon>pan_tool</mat-icon>
92
+ Kesme (Barge-in)
93
+ </button>
94
+ -->
95
+ </mat-card-actions>
96
+
97
  </mat-card>
flare-ui/src/app/components/chat/realtime-chat.component.scss CHANGED
@@ -1,165 +1,165 @@
1
- .realtime-chat-container {
2
- max-width: 800px;
3
- margin: 20px auto;
4
- height: 80vh;
5
- display: flex;
6
- flex-direction: column;
7
- position: relative;
8
- }
9
-
10
- mat-card-header {
11
- position: relative;
12
-
13
- .close-button {
14
- position: absolute;
15
- top: 8px;
16
- right: 8px;
17
- }
18
- }
19
-
20
- .error-banner {
21
- background-color: #ffebee;
22
- color: #c62828;
23
- padding: 12px;
24
- border-radius: 4px;
25
- display: flex;
26
- align-items: center;
27
- gap: 8px;
28
- margin-bottom: 16px;
29
-
30
- mat-icon {
31
- font-size: 20px;
32
- width: 20px;
33
- height: 20px;
34
- }
35
-
36
- span {
37
- flex: 1;
38
- }
39
- }
40
-
41
- .chat-messages {
42
- flex: 1;
43
- overflow-y: auto;
44
- padding: 16px;
45
- background: #fafafa;
46
- border-radius: 8px;
47
- min-height: 300px;
48
- max-height: 450px;
49
- }
50
-
51
- .message {
52
- display: flex;
53
- align-items: flex-start;
54
- margin-bottom: 16px;
55
- animation: slideIn 0.3s ease-out;
56
- }
57
-
58
- @keyframes slideIn {
59
- from {
60
- opacity: 0;
61
- transform: translateY(10px);
62
- }
63
- to {
64
- opacity: 1;
65
- transform: translateY(0);
66
- }
67
- }
68
-
69
- .message.user {
70
- flex-direction: row-reverse;
71
- }
72
-
73
- .message.system {
74
- justify-content: center;
75
-
76
- .message-content {
77
- background: #e0e0e0;
78
- font-style: italic;
79
- max-width: 80%;
80
- }
81
- }
82
-
83
- .message-icon {
84
- margin: 0 8px;
85
- color: #666;
86
- }
87
-
88
- .message-content {
89
- max-width: 70%;
90
- background: white;
91
- padding: 12px 16px;
92
- border-radius: 12px;
93
- box-shadow: 0 1px 2px rgba(0,0,0,0.1);
94
- position: relative;
95
- }
96
-
97
- .message.user .message-content {
98
- background: #3f51b5;
99
- color: white;
100
- }
101
-
102
- .message-text {
103
- margin-bottom: 4px;
104
- }
105
-
106
- .message-time {
107
- font-size: 11px;
108
- opacity: 0.7;
109
- }
110
-
111
- .audio-button {
112
- margin-top: 8px;
113
- }
114
-
115
- .empty-state {
116
- text-align: center;
117
- padding: 60px 20px;
118
- color: #999;
119
-
120
- mat-icon {
121
- font-size: 48px;
122
- width: 48px;
123
- height: 48px;
124
- margin-bottom: 16px;
125
- }
126
- }
127
-
128
- .audio-visualizer {
129
- width: 100%;
130
- height: 100px;
131
- background: #212121;
132
- border-radius: 8px;
133
- margin-top: 16px;
134
- opacity: 0.3;
135
- transition: all 0.3s ease;
136
- position: relative;
137
- overflow: hidden;
138
-
139
- &.active {
140
- opacity: 1;
141
- background: #1a1a1a;
142
- box-shadow: 0 0 20px rgba(76, 175, 80, 0.3);
143
- }
144
- }
145
-
146
- mat-chip {
147
- font-size: 12px;
148
- }
149
-
150
- mat-chip.active {
151
- background-color: #3f51b5 !important;
152
- color: white !important;
153
- }
154
-
155
- mat-card-actions {
156
- padding: 16px;
157
- display: flex;
158
- gap: 16px;
159
- justify-content: flex-start;
160
-
161
- mat-spinner {
162
- display: inline-block;
163
- margin-right: 8px;
164
- }
165
  }
 
1
+ .realtime-chat-container {
2
+ max-width: 800px;
3
+ margin: 20px auto;
4
+ height: 80vh;
5
+ display: flex;
6
+ flex-direction: column;
7
+ position: relative;
8
+ }
9
+
10
+ mat-card-header {
11
+ position: relative;
12
+
13
+ .close-button {
14
+ position: absolute;
15
+ top: 8px;
16
+ right: 8px;
17
+ }
18
+ }
19
+
20
+ .error-banner {
21
+ background-color: #ffebee;
22
+ color: #c62828;
23
+ padding: 12px;
24
+ border-radius: 4px;
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 8px;
28
+ margin-bottom: 16px;
29
+
30
+ mat-icon {
31
+ font-size: 20px;
32
+ width: 20px;
33
+ height: 20px;
34
+ }
35
+
36
+ span {
37
+ flex: 1;
38
+ }
39
+ }
40
+
41
+ .chat-messages {
42
+ flex: 1;
43
+ overflow-y: auto;
44
+ padding: 16px;
45
+ background: #fafafa;
46
+ border-radius: 8px;
47
+ min-height: 300px;
48
+ max-height: 450px;
49
+ }
50
+
51
+ .message {
52
+ display: flex;
53
+ align-items: flex-start;
54
+ margin-bottom: 16px;
55
+ animation: slideIn 0.3s ease-out;
56
+ }
57
+
58
+ @keyframes slideIn {
59
+ from {
60
+ opacity: 0;
61
+ transform: translateY(10px);
62
+ }
63
+ to {
64
+ opacity: 1;
65
+ transform: translateY(0);
66
+ }
67
+ }
68
+
69
+ .message.user {
70
+ flex-direction: row-reverse;
71
+ }
72
+
73
+ .message.system {
74
+ justify-content: center;
75
+
76
+ .message-content {
77
+ background: #e0e0e0;
78
+ font-style: italic;
79
+ max-width: 80%;
80
+ }
81
+ }
82
+
83
+ .message-icon {
84
+ margin: 0 8px;
85
+ color: #666;
86
+ }
87
+
88
+ .message-content {
89
+ max-width: 70%;
90
+ background: white;
91
+ padding: 12px 16px;
92
+ border-radius: 12px;
93
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
94
+ position: relative;
95
+ }
96
+
97
+ .message.user .message-content {
98
+ background: #3f51b5;
99
+ color: white;
100
+ }
101
+
102
+ .message-text {
103
+ margin-bottom: 4px;
104
+ }
105
+
106
+ .message-time {
107
+ font-size: 11px;
108
+ opacity: 0.7;
109
+ }
110
+
111
+ .audio-button {
112
+ margin-top: 8px;
113
+ }
114
+
115
+ .empty-state {
116
+ text-align: center;
117
+ padding: 60px 20px;
118
+ color: #999;
119
+
120
+ mat-icon {
121
+ font-size: 48px;
122
+ width: 48px;
123
+ height: 48px;
124
+ margin-bottom: 16px;
125
+ }
126
+ }
127
+
128
+ .audio-visualizer {
129
+ width: 100%;
130
+ height: 100px;
131
+ background: #212121;
132
+ border-radius: 8px;
133
+ margin-top: 16px;
134
+ opacity: 0.3;
135
+ transition: all 0.3s ease;
136
+ position: relative;
137
+ overflow: hidden;
138
+
139
+ &.active {
140
+ opacity: 1;
141
+ background: #1a1a1a;
142
+ box-shadow: 0 0 20px rgba(76, 175, 80, 0.3);
143
+ }
144
+ }
145
+
146
+ mat-chip {
147
+ font-size: 12px;
148
+ }
149
+
150
+ mat-chip.active {
151
+ background-color: #3f51b5 !important;
152
+ color: white !important;
153
+ }
154
+
155
+ mat-card-actions {
156
+ padding: 16px;
157
+ display: flex;
158
+ gap: 16px;
159
+ justify-content: flex-start;
160
+
161
+ mat-spinner {
162
+ display: inline-block;
163
+ margin-right: 8px;
164
+ }
165
  }
flare-ui/src/app/components/chat/realtime-chat.component.ts CHANGED
@@ -1,422 +1,422 @@
1
- import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { MatCardModule } from '@angular/material/card';
4
- import { MatButtonModule } from '@angular/material/button';
5
- import { MatIconModule } from '@angular/material/icon';
6
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
7
- import { MatDividerModule } from '@angular/material/divider';
8
- import { MatChipsModule } from '@angular/material/chips';
9
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
10
- import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
11
- import { Inject } from '@angular/core';
12
- import { Subject, Subscription, takeUntil } from 'rxjs';
13
-
14
- import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service';
15
- import { AudioStreamService } from '../../services/audio-stream.service';
16
-
17
- @Component({
18
- selector: 'app-realtime-chat',
19
- standalone: true,
20
- imports: [
21
- CommonModule,
22
- MatCardModule,
23
- MatButtonModule,
24
- MatIconModule,
25
- MatProgressSpinnerModule,
26
- MatDividerModule,
27
- MatChipsModule,
28
- MatSnackBarModule
29
- ],
30
- templateUrl: './realtime-chat.component.html',
31
- styleUrls: ['./realtime-chat.component.scss']
32
- })
33
- export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked {
34
- @ViewChild('scrollContainer') private scrollContainer!: ElementRef;
35
- @ViewChild('audioVisualizer') private audioVisualizer!: ElementRef<HTMLCanvasElement>;
36
-
37
- sessionId: string | null = null;
38
- projectName: string | null = null;
39
- isConversationActive = false;
40
- isRecording = false;
41
- isPlayingAudio = false;
42
- currentState: ConversationState = 'idle';
43
- messages: ConversationMessage[] = [];
44
- error = '';
45
- loading = false;
46
-
47
- conversationStates: ConversationState[] = [
48
- 'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio'
49
- ];
50
-
51
- private destroyed$ = new Subject<void>();
52
- private subscriptions = new Subscription();
53
- private shouldScrollToBottom = false;
54
- private animationId: number | null = null;
55
- private currentAudio: HTMLAudioElement | null = null;
56
- private volumeUpdateSubscription?: Subscription;
57
-
58
- constructor(
59
- private conversationManager: ConversationManagerService,
60
- private audioService: AudioStreamService,
61
- private snackBar: MatSnackBar,
62
- public dialogRef: MatDialogRef<RealtimeChatComponent>,
63
- @Inject(MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string }
64
- ) {
65
- this.sessionId = data.sessionId;
66
- this.projectName = data.projectName || null;
67
- }
68
-
69
- ngOnInit(): void {
70
- console.log('🎤 RealtimeChat component initialized');
71
- console.log('Session ID:', this.sessionId);
72
- console.log('Project Name:', this.projectName);
73
-
74
- // Subscribe to messages FIRST - before any connection
75
- this.conversationManager.messages$.pipe(
76
- takeUntil(this.destroyed$)
77
- ).subscribe(messages => {
78
- console.log('💬 Messages updated:', messages.length, 'messages');
79
- this.messages = messages;
80
- this.shouldScrollToBottom = true;
81
-
82
- // Check if we have initial welcome message
83
- if (messages.length > 0) {
84
- const lastMessage = messages[messages.length - 1];
85
- console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...');
86
- }
87
- });
88
-
89
- // Check browser support
90
- if (!AudioStreamService.checkBrowserSupport()) {
91
- this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.';
92
- this.snackBar.open(this.error, 'Close', {
93
- duration: 5000,
94
- panelClass: 'error-snackbar'
95
- });
96
- return;
97
- }
98
-
99
- // Check microphone permission
100
- this.checkMicrophonePermission();
101
-
102
- // Subscribe to conversation state
103
- this.conversationManager.currentState$.pipe(
104
- takeUntil(this.destroyed$)
105
- ).subscribe(state => {
106
- console.log('📊 Conversation state:', state);
107
- this.currentState = state;
108
-
109
- // Recording state'i conversation active olduğu sürece true tut
110
- // Sadece error state'inde false yap
111
- this.isRecording = this.isConversationActive && state !== 'error';
112
- });
113
-
114
- // Subscribe to errors
115
- this.conversationManager.error$.pipe(
116
- takeUntil(this.destroyed$)
117
- ).subscribe(error => {
118
- console.error('Conversation error:', error);
119
- this.error = error.message;
120
- });
121
-
122
- // Load initial messages from session if available
123
- const initialMessages = this.conversationManager.getMessages();
124
- console.log('📋 Initial messages:', initialMessages.length);
125
- if (initialMessages.length > 0) {
126
- this.messages = initialMessages;
127
- this.shouldScrollToBottom = true;
128
- }
129
- }
130
-
131
- ngAfterViewChecked(): void {
132
- if (this.shouldScrollToBottom) {
133
- this.scrollToBottom();
134
- this.shouldScrollToBottom = false;
135
- }
136
- }
137
-
138
- ngOnDestroy(): void {
139
- this.destroyed$.next();
140
- this.destroyed$.complete();
141
- this.subscriptions.unsubscribe();
142
- this.stopVisualization();
143
- this.cleanupAudio();
144
-
145
- if (this.isConversationActive) {
146
- this.conversationManager.stopConversation();
147
- }
148
- }
149
-
150
- async toggleConversation(): Promise<void> {
151
- if (!this.sessionId) return;
152
-
153
- if (this.isConversationActive) {
154
- this.stopConversation();
155
- } else {
156
- await this.startConversation();
157
- }
158
- }
159
-
160
- async retryConnection(): Promise<void> {
161
- this.error = '';
162
- if (!this.isConversationActive && this.sessionId) {
163
- await this.startConversation();
164
- }
165
- }
166
-
167
- clearChat(): void {
168
- this.conversationManager.clearMessages();
169
- this.error = '';
170
- }
171
-
172
- performBargeIn(): void {
173
- // Barge-in özelliği devre dışı
174
- this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', {
175
- duration: 2000
176
- });
177
- }
178
-
179
- playAudio(audioUrl?: string): void {
180
- if (!audioUrl) return;
181
-
182
- // Stop current audio if playing
183
- if (this.currentAudio) {
184
- this.currentAudio.pause();
185
- this.currentAudio = null;
186
- this.isPlayingAudio = false;
187
- return;
188
- }
189
-
190
- this.currentAudio = new Audio(audioUrl);
191
- this.isPlayingAudio = true;
192
-
193
- this.currentAudio.play().catch(error => {
194
- console.error('Audio playback error:', error);
195
- this.isPlayingAudio = false;
196
- this.currentAudio = null;
197
- });
198
-
199
- this.currentAudio.onended = () => {
200
- this.isPlayingAudio = false;
201
- this.currentAudio = null;
202
- };
203
-
204
- this.currentAudio.onerror = () => {
205
- this.isPlayingAudio = false;
206
- this.currentAudio = null;
207
- this.snackBar.open('Ses çalınamadı', 'Close', {
208
- duration: 2000,
209
- panelClass: 'error-snackbar'
210
- });
211
- };
212
- }
213
-
214
- getStateLabel(state: ConversationState): string {
215
- const labels: Record<ConversationState, string> = {
216
- 'idle': 'Bekliyor',
217
- 'listening': 'Dinliyor',
218
- 'processing_stt': 'Metin Dönüştürme',
219
- 'processing_llm': 'Yanıt Hazırlanıyor',
220
- 'processing_tts': 'Ses Oluşturuluyor',
221
- 'playing_audio': 'Konuşuyor',
222
- 'error': 'Hata'
223
- };
224
- return labels[state] || state;
225
- }
226
-
227
- closeDialog(): void {
228
- const result = this.isConversationActive ? 'session_active' : 'closed';
229
- this.dialogRef.close(result);
230
- }
231
-
232
- trackByIndex(index: number): number {
233
- return index;
234
- }
235
-
236
- private async checkMicrophonePermission(): Promise<void> {
237
- try {
238
- const permission = await this.audioService.checkMicrophonePermission();
239
- if (permission === 'denied') {
240
- this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.';
241
- this.snackBar.open(this.error, 'Close', {
242
- duration: 5000,
243
- panelClass: 'error-snackbar'
244
- });
245
- }
246
- } catch (error) {
247
- console.error('Failed to check microphone permission:', error);
248
- }
249
- }
250
-
251
- private scrollToBottom(): void {
252
- try {
253
- if (this.scrollContainer?.nativeElement) {
254
- const element = this.scrollContainer.nativeElement;
255
- element.scrollTop = element.scrollHeight;
256
- }
257
- } catch(err) {
258
- console.error('Scroll error:', err);
259
- }
260
- }
261
-
262
- async startConversation(): Promise<void> {
263
- try {
264
- this.loading = true;
265
- this.error = '';
266
-
267
- // Clear existing messages - welcome will come via WebSocket
268
- this.conversationManager.clearMessages();
269
-
270
- await this.conversationManager.startConversation(this.sessionId!);
271
- this.isConversationActive = true;
272
- this.isRecording = true; // Konuşma başladığında recording'i aktif et
273
-
274
- // Visualization'ı başlat
275
- this.startVisualization();
276
-
277
- this.snackBar.open('Konuşma başlatıldı', 'Close', {
278
- duration: 2000
279
- });
280
- } catch (error: any) {
281
- console.error('Failed to start conversation:', error);
282
- this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.';
283
- this.snackBar.open(this.error, 'Close', {
284
- duration: 5000,
285
- panelClass: 'error-snackbar'
286
- });
287
- } finally {
288
- this.loading = false;
289
- }
290
- }
291
-
292
- private stopConversation(): void {
293
- this.conversationManager.stopConversation();
294
- this.isConversationActive = false;
295
- this.isRecording = false; // Konuşma bittiğinde recording'i kapat
296
- this.stopVisualization();
297
-
298
- this.snackBar.open('Konuşma sonlandırıldı', 'Close', {
299
- duration: 2000
300
- });
301
- }
302
-
303
- private startVisualization(): void {
304
- // Eğer zaten çalışıyorsa tekrar başlatma
305
- if (!this.audioVisualizer || this.animationId) {
306
- return;
307
- }
308
-
309
- const canvas = this.audioVisualizer.nativeElement;
310
- const ctx = canvas.getContext('2d');
311
- if (!ctx) {
312
- console.warn('Could not get canvas context');
313
- return;
314
- }
315
-
316
- // Set canvas size
317
- canvas.width = canvas.offsetWidth;
318
- canvas.height = canvas.offsetHeight;
319
-
320
- // Create gradient for bars
321
- const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
322
- gradient.addColorStop(0, '#4caf50');
323
- gradient.addColorStop(0.5, '#66bb6a');
324
- gradient.addColorStop(1, '#4caf50');
325
-
326
- let lastVolume = 0;
327
- let targetVolume = 0;
328
- const smoothingFactor = 0.8;
329
-
330
- // Subscribe to volume updates
331
- this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => {
332
- targetVolume = volume;
333
- });
334
-
335
- // Animation loop
336
- const animate = () => {
337
- // isConversationActive kontrolü ile devam et
338
- if (!this.isConversationActive) {
339
- this.clearVisualization();
340
- return;
341
- }
342
-
343
- // Clear canvas
344
- ctx.fillStyle = '#1a1a1a';
345
- ctx.fillRect(0, 0, canvas.width, canvas.height);
346
-
347
- // Smooth volume transition
348
- lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor);
349
-
350
- // Draw frequency bars
351
- const barCount = 32;
352
- const barWidth = canvas.width / barCount;
353
- const barSpacing = 2;
354
-
355
- for (let i = 0; i < barCount; i++) {
356
- // Create natural wave effect based on volume
357
- const frequencyFactor = Math.sin((i / barCount) * Math.PI);
358
- const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8;
359
- const randomFactor = 0.8 + Math.random() * 0.2;
360
-
361
- const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor;
362
-
363
- const x = i * barWidth;
364
- const y = (canvas.height - barHeight) / 2;
365
-
366
- // Draw bar
367
- ctx.fillStyle = gradient;
368
- ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight);
369
-
370
- // Draw reflection
371
- ctx.fillStyle = 'rgba(76, 175, 80, 0.2)';
372
- ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3);
373
- }
374
-
375
- // Draw center line
376
- ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)';
377
- ctx.lineWidth = 1;
378
- ctx.beginPath();
379
- ctx.moveTo(0, canvas.height / 2);
380
- ctx.lineTo(canvas.width, canvas.height / 2);
381
- ctx.stroke();
382
-
383
- this.animationId = requestAnimationFrame(animate);
384
- };
385
-
386
- animate();
387
- }
388
-
389
- private stopVisualization(): void {
390
- if (this.animationId) {
391
- cancelAnimationFrame(this.animationId);
392
- this.animationId = null;
393
- }
394
-
395
- if (this.volumeUpdateSubscription) {
396
- this.volumeUpdateSubscription.unsubscribe();
397
- this.volumeUpdateSubscription = undefined;
398
- }
399
-
400
- this.clearVisualization();
401
- }
402
-
403
- private clearVisualization(): void {
404
- if (!this.audioVisualizer) return;
405
-
406
- const canvas = this.audioVisualizer.nativeElement;
407
- const ctx = canvas.getContext('2d');
408
- if (ctx) {
409
- ctx.fillStyle = '#212121';
410
- ctx.fillRect(0, 0, canvas.width, canvas.height);
411
- }
412
- }
413
-
414
-
415
- private cleanupAudio(): void {
416
- if (this.currentAudio) {
417
- this.currentAudio.pause();
418
- this.currentAudio = null;
419
- this.isPlayingAudio = false;
420
- }
421
- }
422
  }
 
1
+ import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { MatCardModule } from '@angular/material/card';
4
+ import { MatButtonModule } from '@angular/material/button';
5
+ import { MatIconModule } from '@angular/material/icon';
6
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
7
+ import { MatDividerModule } from '@angular/material/divider';
8
+ import { MatChipsModule } from '@angular/material/chips';
9
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
10
+ import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
11
+ import { Inject } from '@angular/core';
12
+ import { Subject, Subscription, takeUntil } from 'rxjs';
13
+
14
+ import { ConversationManagerService, ConversationState, ConversationMessage } from '../../services/conversation-manager.service';
15
+ import { AudioStreamService } from '../../services/audio-stream.service';
16
+
17
+ @Component({
18
+ selector: 'app-realtime-chat',
19
+ standalone: true,
20
+ imports: [
21
+ CommonModule,
22
+ MatCardModule,
23
+ MatButtonModule,
24
+ MatIconModule,
25
+ MatProgressSpinnerModule,
26
+ MatDividerModule,
27
+ MatChipsModule,
28
+ MatSnackBarModule
29
+ ],
30
+ templateUrl: './realtime-chat.component.html',
31
+ styleUrls: ['./realtime-chat.component.scss']
32
+ })
33
+ export class RealtimeChatComponent implements OnInit, OnDestroy, AfterViewChecked {
34
+ @ViewChild('scrollContainer') private scrollContainer!: ElementRef;
35
+ @ViewChild('audioVisualizer') private audioVisualizer!: ElementRef<HTMLCanvasElement>;
36
+
37
+ sessionId: string | null = null;
38
+ projectName: string | null = null;
39
+ isConversationActive = false;
40
+ isRecording = false;
41
+ isPlayingAudio = false;
42
+ currentState: ConversationState = 'idle';
43
+ messages: ConversationMessage[] = [];
44
+ error = '';
45
+ loading = false;
46
+
47
+ conversationStates: ConversationState[] = [
48
+ 'idle', 'listening', 'processing_stt', 'processing_llm', 'processing_tts', 'playing_audio'
49
+ ];
50
+
51
+ private destroyed$ = new Subject<void>();
52
+ private subscriptions = new Subscription();
53
+ private shouldScrollToBottom = false;
54
+ private animationId: number | null = null;
55
+ private currentAudio: HTMLAudioElement | null = null;
56
+ private volumeUpdateSubscription?: Subscription;
57
+
58
+ constructor(
59
+ private conversationManager: ConversationManagerService,
60
+ private audioService: AudioStreamService,
61
+ private snackBar: MatSnackBar,
62
+ public dialogRef: MatDialogRef<RealtimeChatComponent>,
63
+ @Inject(MAT_DIALOG_DATA) public data: { sessionId: string; projectName?: string }
64
+ ) {
65
+ this.sessionId = data.sessionId;
66
+ this.projectName = data.projectName || null;
67
+ }
68
+
69
+ ngOnInit(): void {
70
+ console.log('🎤 RealtimeChat component initialized');
71
+ console.log('Session ID:', this.sessionId);
72
+ console.log('Project Name:', this.projectName);
73
+
74
+ // Subscribe to messages FIRST - before any connection
75
+ this.conversationManager.messages$.pipe(
76
+ takeUntil(this.destroyed$)
77
+ ).subscribe(messages => {
78
+ console.log('💬 Messages updated:', messages.length, 'messages');
79
+ this.messages = messages;
80
+ this.shouldScrollToBottom = true;
81
+
82
+ // Check if we have initial welcome message
83
+ if (messages.length > 0) {
84
+ const lastMessage = messages[messages.length - 1];
85
+ console.log('📝 Last message:', lastMessage.role, lastMessage.text?.substring(0, 50) + '...');
86
+ }
87
+ });
88
+
89
+ // Check browser support
90
+ if (!AudioStreamService.checkBrowserSupport()) {
91
+ this.error = 'Tarayıcınız ses kaydını desteklemiyor. Lütfen modern bir tarayıcı kullanın.';
92
+ this.snackBar.open(this.error, 'Close', {
93
+ duration: 5000,
94
+ panelClass: 'error-snackbar'
95
+ });
96
+ return;
97
+ }
98
+
99
+ // Check microphone permission
100
+ this.checkMicrophonePermission();
101
+
102
+ // Subscribe to conversation state
103
+ this.conversationManager.currentState$.pipe(
104
+ takeUntil(this.destroyed$)
105
+ ).subscribe(state => {
106
+ console.log('📊 Conversation state:', state);
107
+ this.currentState = state;
108
+
109
+ // Recording state'i conversation active olduğu sürece true tut
110
+ // Sadece error state'inde false yap
111
+ this.isRecording = this.isConversationActive && state !== 'error';
112
+ });
113
+
114
+ // Subscribe to errors
115
+ this.conversationManager.error$.pipe(
116
+ takeUntil(this.destroyed$)
117
+ ).subscribe(error => {
118
+ console.error('Conversation error:', error);
119
+ this.error = error.message;
120
+ });
121
+
122
+ // Load initial messages from session if available
123
+ const initialMessages = this.conversationManager.getMessages();
124
+ console.log('📋 Initial messages:', initialMessages.length);
125
+ if (initialMessages.length > 0) {
126
+ this.messages = initialMessages;
127
+ this.shouldScrollToBottom = true;
128
+ }
129
+ }
130
+
131
+ ngAfterViewChecked(): void {
132
+ if (this.shouldScrollToBottom) {
133
+ this.scrollToBottom();
134
+ this.shouldScrollToBottom = false;
135
+ }
136
+ }
137
+
138
+ ngOnDestroy(): void {
139
+ this.destroyed$.next();
140
+ this.destroyed$.complete();
141
+ this.subscriptions.unsubscribe();
142
+ this.stopVisualization();
143
+ this.cleanupAudio();
144
+
145
+ if (this.isConversationActive) {
146
+ this.conversationManager.stopConversation();
147
+ }
148
+ }
149
+
150
+ async toggleConversation(): Promise<void> {
151
+ if (!this.sessionId) return;
152
+
153
+ if (this.isConversationActive) {
154
+ this.stopConversation();
155
+ } else {
156
+ await this.startConversation();
157
+ }
158
+ }
159
+
160
+ async retryConnection(): Promise<void> {
161
+ this.error = '';
162
+ if (!this.isConversationActive && this.sessionId) {
163
+ await this.startConversation();
164
+ }
165
+ }
166
+
167
+ clearChat(): void {
168
+ this.conversationManager.clearMessages();
169
+ this.error = '';
170
+ }
171
+
172
+ performBargeIn(): void {
173
+ // Barge-in özelliği devre dışı
174
+ this.snackBar.open('Barge-in özelliği şu anda devre dışı', 'Tamam', {
175
+ duration: 2000
176
+ });
177
+ }
178
+
179
+ playAudio(audioUrl?: string): void {
180
+ if (!audioUrl) return;
181
+
182
+ // Stop current audio if playing
183
+ if (this.currentAudio) {
184
+ this.currentAudio.pause();
185
+ this.currentAudio = null;
186
+ this.isPlayingAudio = false;
187
+ return;
188
+ }
189
+
190
+ this.currentAudio = new Audio(audioUrl);
191
+ this.isPlayingAudio = true;
192
+
193
+ this.currentAudio.play().catch(error => {
194
+ console.error('Audio playback error:', error);
195
+ this.isPlayingAudio = false;
196
+ this.currentAudio = null;
197
+ });
198
+
199
+ this.currentAudio.onended = () => {
200
+ this.isPlayingAudio = false;
201
+ this.currentAudio = null;
202
+ };
203
+
204
+ this.currentAudio.onerror = () => {
205
+ this.isPlayingAudio = false;
206
+ this.currentAudio = null;
207
+ this.snackBar.open('Ses çalınamadı', 'Close', {
208
+ duration: 2000,
209
+ panelClass: 'error-snackbar'
210
+ });
211
+ };
212
+ }
213
+
214
+ getStateLabel(state: ConversationState): string {
215
+ const labels: Record<ConversationState, string> = {
216
+ 'idle': 'Bekliyor',
217
+ 'listening': 'Dinliyor',
218
+ 'processing_stt': 'Metin Dönüştürme',
219
+ 'processing_llm': 'Yanıt Hazırlanıyor',
220
+ 'processing_tts': 'Ses Oluşturuluyor',
221
+ 'playing_audio': 'Konuşuyor',
222
+ 'error': 'Hata'
223
+ };
224
+ return labels[state] || state;
225
+ }
226
+
227
+ closeDialog(): void {
228
+ const result = this.isConversationActive ? 'session_active' : 'closed';
229
+ this.dialogRef.close(result);
230
+ }
231
+
232
+ trackByIndex(index: number): number {
233
+ return index;
234
+ }
235
+
236
+ private async checkMicrophonePermission(): Promise<void> {
237
+ try {
238
+ const permission = await this.audioService.checkMicrophonePermission();
239
+ if (permission === 'denied') {
240
+ this.error = 'Mikrofon erişimi reddedildi. Lütfen tarayıcı ayarlarından izin verin.';
241
+ this.snackBar.open(this.error, 'Close', {
242
+ duration: 5000,
243
+ panelClass: 'error-snackbar'
244
+ });
245
+ }
246
+ } catch (error) {
247
+ console.error('Failed to check microphone permission:', error);
248
+ }
249
+ }
250
+
251
+ private scrollToBottom(): void {
252
+ try {
253
+ if (this.scrollContainer?.nativeElement) {
254
+ const element = this.scrollContainer.nativeElement;
255
+ element.scrollTop = element.scrollHeight;
256
+ }
257
+ } catch(err) {
258
+ console.error('Scroll error:', err);
259
+ }
260
+ }
261
+
262
+ async startConversation(): Promise<void> {
263
+ try {
264
+ this.loading = true;
265
+ this.error = '';
266
+
267
+ // Clear existing messages - welcome will come via WebSocket
268
+ this.conversationManager.clearMessages();
269
+
270
+ await this.conversationManager.startConversation(this.sessionId!);
271
+ this.isConversationActive = true;
272
+ this.isRecording = true; // Konuşma başladığında recording'i aktif et
273
+
274
+ // Visualization'ı başlat
275
+ this.startVisualization();
276
+
277
+ this.snackBar.open('Konuşma başlatıldı', 'Close', {
278
+ duration: 2000
279
+ });
280
+ } catch (error: any) {
281
+ console.error('Failed to start conversation:', error);
282
+ this.error = 'Konuşma başlatılamadı. Lütfen tekrar deneyin.';
283
+ this.snackBar.open(this.error, 'Close', {
284
+ duration: 5000,
285
+ panelClass: 'error-snackbar'
286
+ });
287
+ } finally {
288
+ this.loading = false;
289
+ }
290
+ }
291
+
292
+ private stopConversation(): void {
293
+ this.conversationManager.stopConversation();
294
+ this.isConversationActive = false;
295
+ this.isRecording = false; // Konuşma bittiğinde recording'i kapat
296
+ this.stopVisualization();
297
+
298
+ this.snackBar.open('Konuşma sonlandırıldı', 'Close', {
299
+ duration: 2000
300
+ });
301
+ }
302
+
303
+ private startVisualization(): void {
304
+ // Eğer zaten çalışıyorsa tekrar başlatma
305
+ if (!this.audioVisualizer || this.animationId) {
306
+ return;
307
+ }
308
+
309
+ const canvas = this.audioVisualizer.nativeElement;
310
+ const ctx = canvas.getContext('2d');
311
+ if (!ctx) {
312
+ console.warn('Could not get canvas context');
313
+ return;
314
+ }
315
+
316
+ // Set canvas size
317
+ canvas.width = canvas.offsetWidth;
318
+ canvas.height = canvas.offsetHeight;
319
+
320
+ // Create gradient for bars
321
+ const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
322
+ gradient.addColorStop(0, '#4caf50');
323
+ gradient.addColorStop(0.5, '#66bb6a');
324
+ gradient.addColorStop(1, '#4caf50');
325
+
326
+ let lastVolume = 0;
327
+ let targetVolume = 0;
328
+ const smoothingFactor = 0.8;
329
+
330
+ // Subscribe to volume updates
331
+ this.volumeUpdateSubscription = this.audioService.volumeLevel$.subscribe(volume => {
332
+ targetVolume = volume;
333
+ });
334
+
335
+ // Animation loop
336
+ const animate = () => {
337
+ // isConversationActive kontrolü ile devam et
338
+ if (!this.isConversationActive) {
339
+ this.clearVisualization();
340
+ return;
341
+ }
342
+
343
+ // Clear canvas
344
+ ctx.fillStyle = '#1a1a1a';
345
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
346
+
347
+ // Smooth volume transition
348
+ lastVolume = lastVolume * smoothingFactor + targetVolume * (1 - smoothingFactor);
349
+
350
+ // Draw frequency bars
351
+ const barCount = 32;
352
+ const barWidth = canvas.width / barCount;
353
+ const barSpacing = 2;
354
+
355
+ for (let i = 0; i < barCount; i++) {
356
+ // Create natural wave effect based on volume
357
+ const frequencyFactor = Math.sin((i / barCount) * Math.PI);
358
+ const timeFactor = Math.sin(Date.now() * 0.001 + i * 0.2) * 0.2 + 0.8;
359
+ const randomFactor = 0.8 + Math.random() * 0.2;
360
+
361
+ const barHeight = lastVolume * canvas.height * 0.7 * frequencyFactor * timeFactor * randomFactor;
362
+
363
+ const x = i * barWidth;
364
+ const y = (canvas.height - barHeight) / 2;
365
+
366
+ // Draw bar
367
+ ctx.fillStyle = gradient;
368
+ ctx.fillRect(x + barSpacing / 2, y, barWidth - barSpacing, barHeight);
369
+
370
+ // Draw reflection
371
+ ctx.fillStyle = 'rgba(76, 175, 80, 0.2)';
372
+ ctx.fillRect(x + barSpacing / 2, canvas.height - y, barWidth - barSpacing, -barHeight * 0.3);
373
+ }
374
+
375
+ // Draw center line
376
+ ctx.strokeStyle = 'rgba(76, 175, 80, 0.5)';
377
+ ctx.lineWidth = 1;
378
+ ctx.beginPath();
379
+ ctx.moveTo(0, canvas.height / 2);
380
+ ctx.lineTo(canvas.width, canvas.height / 2);
381
+ ctx.stroke();
382
+
383
+ this.animationId = requestAnimationFrame(animate);
384
+ };
385
+
386
+ animate();
387
+ }
388
+
389
+ private stopVisualization(): void {
390
+ if (this.animationId) {
391
+ cancelAnimationFrame(this.animationId);
392
+ this.animationId = null;
393
+ }
394
+
395
+ if (this.volumeUpdateSubscription) {
396
+ this.volumeUpdateSubscription.unsubscribe();
397
+ this.volumeUpdateSubscription = undefined;
398
+ }
399
+
400
+ this.clearVisualization();
401
+ }
402
+
403
+ private clearVisualization(): void {
404
+ if (!this.audioVisualizer) return;
405
+
406
+ const canvas = this.audioVisualizer.nativeElement;
407
+ const ctx = canvas.getContext('2d');
408
+ if (ctx) {
409
+ ctx.fillStyle = '#212121';
410
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
411
+ }
412
+ }
413
+
414
+
415
+ private cleanupAudio(): void {
416
+ if (this.currentAudio) {
417
+ this.currentAudio.pause();
418
+ this.currentAudio = null;
419
+ this.isPlayingAudio = false;
420
+ }
421
+ }
422
  }
flare-ui/src/app/components/environment/environment.component.html CHANGED
@@ -1,286 +1,286 @@
1
- <mat-card>
2
- <mat-card-header>
3
- <mat-card-title>
4
- <mat-icon>settings</mat-icon>
5
- Environment Configuration
6
- </mat-card-title>
7
- </mat-card-header>
8
-
9
- <mat-card-content>
10
- @if (loading) {
11
- <div class="loading-container">
12
- <mat-spinner></mat-spinner>
13
- <p>Loading configuration...</p>
14
- </div>
15
- } @else {
16
- <form [formGroup]="form">
17
- <!-- LLM Provider Section -->
18
- <div class="provider-section">
19
- <h3>
20
- <mat-icon>smart_toy</mat-icon>
21
- LLM Provider
22
- </h3>
23
-
24
- <mat-form-field appearance="outline" class="full-width">
25
- <mat-label>Provider</mat-label>
26
- <mat-icon matPrefix>{{ getLLMProviderIcon(currentLLMProviderSafe) }}</mat-icon>
27
- <mat-select formControlName="llm_provider_name"
28
- (selectionChange)="onLLMProviderChange($event.value)">
29
- @for (provider of llmProviders; track provider.name) {
30
- <mat-option [value]="provider.name">
31
- <mat-icon>{{ getLLMProviderIcon(provider) }}</mat-icon>
32
- {{ provider.display_name }}
33
- </mat-option>
34
- }
35
- </mat-select>
36
- @if (currentLLMProviderSafe?.description) {
37
- <mat-hint>{{ currentLLMProviderSafe?.description }}</mat-hint>
38
- }
39
- </mat-form-field>
40
-
41
- @if (currentLLMProviderSafe?.requires_api_key) {
42
- <mat-form-field appearance="outline" class="full-width">
43
- <mat-label>{{ getApiKeyLabel('llm') }}</mat-label>
44
- <mat-icon matPrefix>key</mat-icon>
45
- <input matInput
46
- type="password"
47
- formControlName="llm_provider_api_key"
48
- [placeholder]="getApiKeyPlaceholder('llm')">
49
- <mat-error *ngIf="form.get('llm_provider_api_key')?.hasError('required')">
50
- API key is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }}
51
- </mat-error>
52
- </mat-form-field>
53
- }
54
-
55
- @if (currentLLMProviderSafe?.requires_endpoint) {
56
- <mat-form-field appearance="outline" class="full-width">
57
- <mat-label>Endpoint URL</mat-label>
58
- <mat-icon matPrefix>link</mat-icon>
59
- <input matInput
60
- formControlName="llm_provider_endpoint"
61
- [placeholder]="getEndpointPlaceholder('llm')">
62
- <button mat-icon-button matSuffix
63
- (click)="testConnection()"
64
- type="button"
65
- matTooltip="Test connection">
66
- <mat-icon>wifi_tethering</mat-icon>
67
- </button>
68
- <mat-error *ngIf="form.get('llm_provider_endpoint')?.hasError('required')">
69
- Endpoint is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }}
70
- </mat-error>
71
- </mat-form-field>
72
- }
73
-
74
- <!-- LLM Settings (Internal Prompt & Parameter Collection) -->
75
- @if (currentLLMProviderSafe) {
76
- <mat-expansion-panel class="settings-panel">
77
- <mat-expansion-panel-header>
78
- <mat-panel-title>
79
- <mat-icon>psychology</mat-icon>
80
- Internal System Prompt
81
- </mat-panel-title>
82
- <mat-panel-description>
83
- Configure the internal prompt for intent detection
84
- </mat-panel-description>
85
- </mat-expansion-panel-header>
86
-
87
- <div class="panel-content">
88
- <p class="hint-text">
89
- This prompt is prepended to all intent detection requests.
90
- </p>
91
- <mat-form-field appearance="outline" class="full-width">
92
- <mat-label>Internal Prompt</mat-label>
93
- <textarea matInput
94
- [(ngModel)]="internalPrompt"
95
- [ngModelOptions]="{standalone: true}"
96
- rows="10"
97
- placeholder="Enter the system prompt that guides intent detection..."></textarea>
98
- <mat-hint>Use clear instructions to guide the LLM's behavior</mat-hint>
99
- </mat-form-field>
100
- </div>
101
- </mat-expansion-panel>
102
-
103
- <mat-expansion-panel class="settings-panel">
104
- <mat-expansion-panel-header>
105
- <mat-panel-title>
106
- <mat-icon>tune</mat-icon>
107
- Parameter Collection Configuration
108
- </mat-panel-title>
109
- <mat-panel-description>
110
- Fine-tune how parameters are collected from users
111
- </mat-panel-description>
112
- </mat-expansion-panel-header>
113
-
114
- <div class="panel-content">
115
- <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.enabled"
116
- [ngModelOptions]="{standalone: true}">
117
- Enable Smart Parameter Collection
118
- </mat-slide-toggle>
119
-
120
- <div class="config-item">
121
- <label>Max Parameters per Question</label>
122
- <mat-slider min="1" max="5" step="1" discrete>
123
- <input matSliderThumb [(ngModel)]="parameterCollectionConfig.max_params_per_question"
124
- [ngModelOptions]="{standalone: true}">
125
- </mat-slider>
126
- <span class="slider-value">{{ parameterCollectionConfig.max_params_per_question }}</span>
127
- </div>
128
-
129
- <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.show_all_required"
130
- [ngModelOptions]="{standalone: true}">
131
- Show All Required Parameters
132
- </mat-slide-toggle>
133
-
134
- <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.ask_optional_params"
135
- [ngModelOptions]="{standalone: true}">
136
- Ask for Optional Parameters
137
- </mat-slide-toggle>
138
-
139
- <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.group_related_params"
140
- [ngModelOptions]="{standalone: true}">
141
- Group Related Parameters
142
- </mat-slide-toggle>
143
-
144
- <div class="config-item">
145
- <label>Minimum Confidence Score</label>
146
- <mat-slider min="0" max="1" step="0.1" discrete>
147
- <input matSliderThumb [(ngModel)]="parameterCollectionConfig.min_confidence_score"
148
- [ngModelOptions]="{standalone: true}">
149
- </mat-slider>
150
- <span class="slider-value">{{ parameterCollectionConfig.min_confidence_score }}</span>
151
- </div>
152
-
153
- <mat-form-field appearance="outline" class="full-width">
154
- <mat-label>Collection Prompt Template</mat-label>
155
- <textarea matInput
156
- [(ngModel)]="parameterCollectionConfig.collection_prompt"
157
- [ngModelOptions]="{standalone: true}"
158
- rows="8"></textarea>
159
- <button mat-icon-button matSuffix
160
- (click)="resetCollectionPrompt()"
161
- type="button"
162
- matTooltip="Reset to default">
163
- <mat-icon>refresh</mat-icon>
164
- </button>
165
- </mat-form-field>
166
- </div>
167
- </mat-expansion-panel>
168
- }
169
- </div>
170
-
171
- <mat-divider></mat-divider>
172
-
173
- <!-- TTS Provider Section -->
174
- <div class="provider-section">
175
- <h3>
176
- <mat-icon>record_voice_over</mat-icon>
177
- TTS Provider
178
- </h3>
179
-
180
- <mat-form-field appearance="outline" class="full-width">
181
- <mat-label>Provider</mat-label>
182
- <mat-icon matPrefix>{{ getTTSProviderIcon(currentTTSProviderSafe) }}</mat-icon>
183
- <mat-select formControlName="tts_provider_name"
184
- (selectionChange)="onTTSProviderChange($event.value)">
185
- @for (provider of ttsProviders; track provider.name) {
186
- <mat-option [value]="provider.name">
187
- <mat-icon>{{ getTTSProviderIcon(provider) }}</mat-icon>
188
- {{ provider.display_name }}
189
- </mat-option>
190
- }
191
- </mat-select>
192
- @if (currentTTSProviderSafe?.description) {
193
- <mat-hint>{{ currentTTSProviderSafe?.description }}</mat-hint>
194
- }
195
- </mat-form-field>
196
-
197
- @if (currentTTSProviderSafe?.requires_api_key) {
198
- <mat-form-field appearance="outline" class="full-width">
199
- <mat-label>API Key</mat-label>
200
- <mat-icon matPrefix>key</mat-icon>
201
- <input matInput
202
- type="password"
203
- formControlName="tts_provider_api_key"
204
- [placeholder]="getApiKeyPlaceholder('tts')">
205
- <mat-error *ngIf="form.get('tts_provider_api_key')?.hasError('required')">
206
- API key is required for {{ currentTTSProviderSafe?.display_name || 'this provider' }}
207
- </mat-error>
208
- </mat-form-field>
209
- }
210
-
211
- @if (currentTTSProviderSafe?.requires_endpoint) {
212
- <mat-form-field appearance="outline" class="full-width">
213
- <mat-label>Endpoint URL</mat-label>
214
- <mat-icon matPrefix>link</mat-icon>
215
- <input matInput
216
- formControlName="tts_provider_endpoint"
217
- [placeholder]="getEndpointPlaceholder('tts')">
218
- </mat-form-field>
219
- }
220
- </div>
221
-
222
- <mat-divider></mat-divider>
223
-
224
- <!-- STT Provider Section -->
225
- <div class="provider-section">
226
- <h3>
227
- <mat-icon>mic</mat-icon>
228
- STT Provider
229
- </h3>
230
-
231
- <mat-form-field appearance="outline" class="full-width">
232
- <mat-label>Provider</mat-label>
233
- <mat-icon matPrefix>{{ getSTTProviderIcon(currentSTTProviderSafe) }}</mat-icon>
234
- <mat-select formControlName="stt_provider_name"
235
- (selectionChange)="onSTTProviderChange($event.value)">
236
- @for (provider of sttProviders; track provider.name) {
237
- <mat-option [value]="provider.name">
238
- <mat-icon>{{ getSTTProviderIcon(provider) }}</mat-icon>
239
- {{ provider.display_name }}
240
- </mat-option>
241
- }
242
- </mat-select>
243
- @if (currentSTTProviderSafe?.description) {
244
- <mat-hint>{{ currentSTTProviderSafe?.description }}</mat-hint>
245
- }
246
- </mat-form-field>
247
-
248
- @if (currentSTTProviderSafe?.requires_api_key) {
249
- <mat-form-field appearance="outline" class="full-width">
250
- <mat-label>{{ getApiKeyLabel('stt') }}</mat-label>
251
- <mat-icon matPrefix>key</mat-icon>
252
- <input matInput
253
- [type]="currentSTTProviderSafe?.name === 'google' ? 'text' : 'password'"
254
- formControlName="stt_provider_api_key"
255
- [placeholder]="getApiKeyPlaceholder('stt')">
256
- <mat-error *ngIf="form.get('stt_provider_api_key')?.hasError('required')">
257
- {{ currentSTTProviderSafe?.name === 'google' ? 'Credentials path' : 'API key' }} is required for {{ currentSTTProviderSafe?.display_name || 'this provider' }}
258
- </mat-error>
259
- </mat-form-field>
260
- }
261
-
262
- @if (currentSTTProviderSafe?.requires_endpoint) {
263
- <mat-form-field appearance="outline" class="full-width">
264
- <mat-label>Endpoint URL</mat-label>
265
- <mat-icon matPrefix>link</mat-icon>
266
- <input matInput
267
- formControlName="stt_provider_endpoint"
268
- [placeholder]="getEndpointPlaceholder('stt')">
269
- </mat-form-field>
270
- }
271
- </div>
272
-
273
- <mat-card-actions align="end">
274
- <button mat-raised-button
275
- color="primary"
276
- (click)="saveEnvironment()"
277
- [disabled]="form.invalid || saving">
278
- <mat-icon>save</mat-icon>
279
- {{ saving ? 'Saving...' : 'Save Configuration' }}
280
- </button>
281
- </mat-card-actions>
282
-
283
- </form>
284
- }
285
- </mat-card-content>
286
  </mat-card>
 
1
+ <mat-card>
2
+ <mat-card-header>
3
+ <mat-card-title>
4
+ <mat-icon>settings</mat-icon>
5
+ Environment Configuration
6
+ </mat-card-title>
7
+ </mat-card-header>
8
+
9
+ <mat-card-content>
10
+ @if (loading) {
11
+ <div class="loading-container">
12
+ <mat-spinner></mat-spinner>
13
+ <p>Loading configuration...</p>
14
+ </div>
15
+ } @else {
16
+ <form [formGroup]="form">
17
+ <!-- LLM Provider Section -->
18
+ <div class="provider-section">
19
+ <h3>
20
+ <mat-icon>smart_toy</mat-icon>
21
+ LLM Provider
22
+ </h3>
23
+
24
+ <mat-form-field appearance="outline" class="full-width">
25
+ <mat-label>Provider</mat-label>
26
+ <mat-icon matPrefix>{{ getLLMProviderIcon(currentLLMProviderSafe) }}</mat-icon>
27
+ <mat-select formControlName="llm_provider_name"
28
+ (selectionChange)="onLLMProviderChange($event.value)">
29
+ @for (provider of llmProviders; track provider.name) {
30
+ <mat-option [value]="provider.name">
31
+ <mat-icon>{{ getLLMProviderIcon(provider) }}</mat-icon>
32
+ {{ provider.display_name }}
33
+ </mat-option>
34
+ }
35
+ </mat-select>
36
+ @if (currentLLMProviderSafe?.description) {
37
+ <mat-hint>{{ currentLLMProviderSafe?.description }}</mat-hint>
38
+ }
39
+ </mat-form-field>
40
+
41
+ @if (currentLLMProviderSafe?.requires_api_key) {
42
+ <mat-form-field appearance="outline" class="full-width">
43
+ <mat-label>{{ getApiKeyLabel('llm') }}</mat-label>
44
+ <mat-icon matPrefix>key</mat-icon>
45
+ <input matInput
46
+ type="password"
47
+ formControlName="llm_provider_api_key"
48
+ [placeholder]="getApiKeyPlaceholder('llm')">
49
+ <mat-error *ngIf="form.get('llm_provider_api_key')?.hasError('required')">
50
+ API key is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }}
51
+ </mat-error>
52
+ </mat-form-field>
53
+ }
54
+
55
+ @if (currentLLMProviderSafe?.requires_endpoint) {
56
+ <mat-form-field appearance="outline" class="full-width">
57
+ <mat-label>Endpoint URL</mat-label>
58
+ <mat-icon matPrefix>link</mat-icon>
59
+ <input matInput
60
+ formControlName="llm_provider_endpoint"
61
+ [placeholder]="getEndpointPlaceholder('llm')">
62
+ <button mat-icon-button matSuffix
63
+ (click)="testConnection()"
64
+ type="button"
65
+ matTooltip="Test connection">
66
+ <mat-icon>wifi_tethering</mat-icon>
67
+ </button>
68
+ <mat-error *ngIf="form.get('llm_provider_endpoint')?.hasError('required')">
69
+ Endpoint is required for {{ currentLLMProviderSafe?.display_name || 'this provider' }}
70
+ </mat-error>
71
+ </mat-form-field>
72
+ }
73
+
74
+ <!-- LLM Settings (Internal Prompt & Parameter Collection) -->
75
+ @if (currentLLMProviderSafe) {
76
+ <mat-expansion-panel class="settings-panel">
77
+ <mat-expansion-panel-header>
78
+ <mat-panel-title>
79
+ <mat-icon>psychology</mat-icon>
80
+ Internal System Prompt
81
+ </mat-panel-title>
82
+ <mat-panel-description>
83
+ Configure the internal prompt for intent detection
84
+ </mat-panel-description>
85
+ </mat-expansion-panel-header>
86
+
87
+ <div class="panel-content">
88
+ <p class="hint-text">
89
+ This prompt is prepended to all intent detection requests.
90
+ </p>
91
+ <mat-form-field appearance="outline" class="full-width">
92
+ <mat-label>Internal Prompt</mat-label>
93
+ <textarea matInput
94
+ [(ngModel)]="internalPrompt"
95
+ [ngModelOptions]="{standalone: true}"
96
+ rows="10"
97
+ placeholder="Enter the system prompt that guides intent detection..."></textarea>
98
+ <mat-hint>Use clear instructions to guide the LLM's behavior</mat-hint>
99
+ </mat-form-field>
100
+ </div>
101
+ </mat-expansion-panel>
102
+
103
+ <mat-expansion-panel class="settings-panel">
104
+ <mat-expansion-panel-header>
105
+ <mat-panel-title>
106
+ <mat-icon>tune</mat-icon>
107
+ Parameter Collection Configuration
108
+ </mat-panel-title>
109
+ <mat-panel-description>
110
+ Fine-tune how parameters are collected from users
111
+ </mat-panel-description>
112
+ </mat-expansion-panel-header>
113
+
114
+ <div class="panel-content">
115
+ <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.enabled"
116
+ [ngModelOptions]="{standalone: true}">
117
+ Enable Smart Parameter Collection
118
+ </mat-slide-toggle>
119
+
120
+ <div class="config-item">
121
+ <label>Max Parameters per Question</label>
122
+ <mat-slider min="1" max="5" step="1" discrete>
123
+ <input matSliderThumb [(ngModel)]="parameterCollectionConfig.max_params_per_question"
124
+ [ngModelOptions]="{standalone: true}">
125
+ </mat-slider>
126
+ <span class="slider-value">{{ parameterCollectionConfig.max_params_per_question }}</span>
127
+ </div>
128
+
129
+ <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.show_all_required"
130
+ [ngModelOptions]="{standalone: true}">
131
+ Show All Required Parameters
132
+ </mat-slide-toggle>
133
+
134
+ <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.ask_optional_params"
135
+ [ngModelOptions]="{standalone: true}">
136
+ Ask for Optional Parameters
137
+ </mat-slide-toggle>
138
+
139
+ <mat-slide-toggle [(ngModel)]="parameterCollectionConfig.group_related_params"
140
+ [ngModelOptions]="{standalone: true}">
141
+ Group Related Parameters
142
+ </mat-slide-toggle>
143
+
144
+ <div class="config-item">
145
+ <label>Minimum Confidence Score</label>
146
+ <mat-slider min="0" max="1" step="0.1" discrete>
147
+ <input matSliderThumb [(ngModel)]="parameterCollectionConfig.min_confidence_score"
148
+ [ngModelOptions]="{standalone: true}">
149
+ </mat-slider>
150
+ <span class="slider-value">{{ parameterCollectionConfig.min_confidence_score }}</span>
151
+ </div>
152
+
153
+ <mat-form-field appearance="outline" class="full-width">
154
+ <mat-label>Collection Prompt Template</mat-label>
155
+ <textarea matInput
156
+ [(ngModel)]="parameterCollectionConfig.collection_prompt"
157
+ [ngModelOptions]="{standalone: true}"
158
+ rows="8"></textarea>
159
+ <button mat-icon-button matSuffix
160
+ (click)="resetCollectionPrompt()"
161
+ type="button"
162
+ matTooltip="Reset to default">
163
+ <mat-icon>refresh</mat-icon>
164
+ </button>
165
+ </mat-form-field>
166
+ </div>
167
+ </mat-expansion-panel>
168
+ }
169
+ </div>
170
+
171
+ <mat-divider></mat-divider>
172
+
173
+ <!-- TTS Provider Section -->
174
+ <div class="provider-section">
175
+ <h3>
176
+ <mat-icon>record_voice_over</mat-icon>
177
+ TTS Provider
178
+ </h3>
179
+
180
+ <mat-form-field appearance="outline" class="full-width">
181
+ <mat-label>Provider</mat-label>
182
+ <mat-icon matPrefix>{{ getTTSProviderIcon(currentTTSProviderSafe) }}</mat-icon>
183
+ <mat-select formControlName="tts_provider_name"
184
+ (selectionChange)="onTTSProviderChange($event.value)">
185
+ @for (provider of ttsProviders; track provider.name) {
186
+ <mat-option [value]="provider.name">
187
+ <mat-icon>{{ getTTSProviderIcon(provider) }}</mat-icon>
188
+ {{ provider.display_name }}
189
+ </mat-option>
190
+ }
191
+ </mat-select>
192
+ @if (currentTTSProviderSafe?.description) {
193
+ <mat-hint>{{ currentTTSProviderSafe?.description }}</mat-hint>
194
+ }
195
+ </mat-form-field>
196
+
197
+ @if (currentTTSProviderSafe?.requires_api_key) {
198
+ <mat-form-field appearance="outline" class="full-width">
199
+ <mat-label>API Key</mat-label>
200
+ <mat-icon matPrefix>key</mat-icon>
201
+ <input matInput
202
+ type="password"
203
+ formControlName="tts_provider_api_key"
204
+ [placeholder]="getApiKeyPlaceholder('tts')">
205
+ <mat-error *ngIf="form.get('tts_provider_api_key')?.hasError('required')">
206
+ API key is required for {{ currentTTSProviderSafe?.display_name || 'this provider' }}
207
+ </mat-error>
208
+ </mat-form-field>
209
+ }
210
+
211
+ @if (currentTTSProviderSafe?.requires_endpoint) {
212
+ <mat-form-field appearance="outline" class="full-width">
213
+ <mat-label>Endpoint URL</mat-label>
214
+ <mat-icon matPrefix>link</mat-icon>
215
+ <input matInput
216
+ formControlName="tts_provider_endpoint"
217
+ [placeholder]="getEndpointPlaceholder('tts')">
218
+ </mat-form-field>
219
+ }
220
+ </div>
221
+
222
+ <mat-divider></mat-divider>
223
+
224
+ <!-- STT Provider Section -->
225
+ <div class="provider-section">
226
+ <h3>
227
+ <mat-icon>mic</mat-icon>
228
+ STT Provider
229
+ </h3>
230
+
231
+ <mat-form-field appearance="outline" class="full-width">
232
+ <mat-label>Provider</mat-label>
233
+ <mat-icon matPrefix>{{ getSTTProviderIcon(currentSTTProviderSafe) }}</mat-icon>
234
+ <mat-select formControlName="stt_provider_name"
235
+ (selectionChange)="onSTTProviderChange($event.value)">
236
+ @for (provider of sttProviders; track provider.name) {
237
+ <mat-option [value]="provider.name">
238
+ <mat-icon>{{ getSTTProviderIcon(provider) }}</mat-icon>
239
+ {{ provider.display_name }}
240
+ </mat-option>
241
+ }
242
+ </mat-select>
243
+ @if (currentSTTProviderSafe?.description) {
244
+ <mat-hint>{{ currentSTTProviderSafe?.description }}</mat-hint>
245
+ }
246
+ </mat-form-field>
247
+
248
+ @if (currentSTTProviderSafe?.requires_api_key) {
249
+ <mat-form-field appearance="outline" class="full-width">
250
+ <mat-label>{{ getApiKeyLabel('stt') }}</mat-label>
251
+ <mat-icon matPrefix>key</mat-icon>
252
+ <input matInput
253
+ [type]="currentSTTProviderSafe?.name === 'google' ? 'text' : 'password'"
254
+ formControlName="stt_provider_api_key"
255
+ [placeholder]="getApiKeyPlaceholder('stt')">
256
+ <mat-error *ngIf="form.get('stt_provider_api_key')?.hasError('required')">
257
+ {{ currentSTTProviderSafe?.name === 'google' ? 'Credentials path' : 'API key' }} is required for {{ currentSTTProviderSafe?.display_name || 'this provider' }}
258
+ </mat-error>
259
+ </mat-form-field>
260
+ }
261
+
262
+ @if (currentSTTProviderSafe?.requires_endpoint) {
263
+ <mat-form-field appearance="outline" class="full-width">
264
+ <mat-label>Endpoint URL</mat-label>
265
+ <mat-icon matPrefix>link</mat-icon>
266
+ <input matInput
267
+ formControlName="stt_provider_endpoint"
268
+ [placeholder]="getEndpointPlaceholder('stt')">
269
+ </mat-form-field>
270
+ }
271
+ </div>
272
+
273
+ <mat-card-actions align="end">
274
+ <button mat-raised-button
275
+ color="primary"
276
+ (click)="saveEnvironment()"
277
+ [disabled]="form.invalid || saving">
278
+ <mat-icon>save</mat-icon>
279
+ {{ saving ? 'Saving...' : 'Save Configuration' }}
280
+ </button>
281
+ </mat-card-actions>
282
+
283
+ </form>
284
+ }
285
+ </mat-card-content>
286
  </mat-card>
flare-ui/src/app/components/environment/environment.component.scss CHANGED
@@ -1,168 +1,168 @@
1
- :host {
2
- display: block;
3
- padding: 24px;
4
- max-width: 1200px;
5
- margin: 0 auto;
6
- }
7
-
8
- mat-card {
9
- mat-card-header {
10
- margin-bottom: 24px;
11
-
12
- mat-card-title {
13
- display: flex;
14
- align-items: center;
15
- gap: 8px;
16
- font-size: 24px;
17
-
18
- mat-icon {
19
- font-size: 28px;
20
- width: 28px;
21
- height: 28px;
22
- }
23
- }
24
- }
25
- }
26
-
27
- .loading-container {
28
- display: flex;
29
- flex-direction: column;
30
- align-items: center;
31
- justify-content: center;
32
- padding: 48px;
33
- gap: 16px;
34
-
35
- p {
36
- color: rgba(0, 0, 0, 0.6);
37
- margin: 0;
38
- }
39
- }
40
-
41
- .provider-section {
42
- margin-bottom: 32px;
43
-
44
- h3 {
45
- display: flex;
46
- align-items: center;
47
- gap: 8px;
48
- color: rgba(0, 0, 0, 0.87);
49
- margin-bottom: 16px;
50
- font-size: 18px;
51
- font-weight: 500;
52
-
53
- mat-icon {
54
- font-size: 24px;
55
- width: 24px;
56
- height: 24px;
57
- }
58
- }
59
- }
60
-
61
- .full-width {
62
- width: 100%;
63
- }
64
-
65
- mat-form-field {
66
- margin-bottom: 16px;
67
-
68
- &.full-width {
69
- width: 100%;
70
- }
71
- }
72
-
73
- mat-divider {
74
- margin: 32px 0;
75
- }
76
-
77
- .settings-panel {
78
- margin-top: 16px;
79
- background: #f5f5f5;
80
-
81
- mat-expansion-panel-header {
82
- mat-panel-title {
83
- display: flex;
84
- align-items: center;
85
- gap: 8px;
86
-
87
- mat-icon {
88
- font-size: 20px;
89
- width: 20px;
90
- height: 20px;
91
- }
92
- }
93
- }
94
-
95
- .panel-content {
96
- padding: 16px;
97
-
98
- .hint-text {
99
- color: rgba(0, 0, 0, 0.6);
100
- font-size: 14px;
101
- margin-bottom: 16px;
102
- }
103
- }
104
- }
105
-
106
- mat-slide-toggle {
107
- display: block;
108
- margin-bottom: 16px;
109
- }
110
-
111
- .config-item {
112
- margin: 24px 0;
113
-
114
- label {
115
- display: block;
116
- color: rgba(0, 0, 0, 0.87);
117
- font-weight: 500;
118
- margin-bottom: 8px;
119
- }
120
-
121
- mat-slider {
122
- width: calc(100% - 60px);
123
- display: inline-block;
124
- }
125
-
126
- .slider-value {
127
- display: inline-block;
128
- width: 50px;
129
- text-align: right;
130
- color: rgba(0, 0, 0, 0.6);
131
- font-weight: 500;
132
- }
133
- }
134
-
135
- mat-card-actions {
136
- padding: 16px 24px;
137
- margin: 0 -24px -24px;
138
- border-top: 1px solid rgba(0, 0, 0, 0.12);
139
-
140
- button {
141
- mat-icon {
142
- margin-right: 4px;
143
- }
144
- }
145
- }
146
-
147
- // Icon styling in select options
148
- mat-option {
149
- mat-icon {
150
- margin-right: 8px;
151
- vertical-align: middle;
152
- }
153
- }
154
-
155
- // Responsive adjustments
156
- @media (max-width: 768px) {
157
- :host {
158
- padding: 16px;
159
- }
160
-
161
- .provider-section {
162
- margin-bottom: 24px;
163
- }
164
-
165
- mat-divider {
166
- margin: 24px 0;
167
- }
168
  }
 
1
+ :host {
2
+ display: block;
3
+ padding: 24px;
4
+ max-width: 1200px;
5
+ margin: 0 auto;
6
+ }
7
+
8
+ mat-card {
9
+ mat-card-header {
10
+ margin-bottom: 24px;
11
+
12
+ mat-card-title {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 8px;
16
+ font-size: 24px;
17
+
18
+ mat-icon {
19
+ font-size: 28px;
20
+ width: 28px;
21
+ height: 28px;
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ .loading-container {
28
+ display: flex;
29
+ flex-direction: column;
30
+ align-items: center;
31
+ justify-content: center;
32
+ padding: 48px;
33
+ gap: 16px;
34
+
35
+ p {
36
+ color: rgba(0, 0, 0, 0.6);
37
+ margin: 0;
38
+ }
39
+ }
40
+
41
+ .provider-section {
42
+ margin-bottom: 32px;
43
+
44
+ h3 {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 8px;
48
+ color: rgba(0, 0, 0, 0.87);
49
+ margin-bottom: 16px;
50
+ font-size: 18px;
51
+ font-weight: 500;
52
+
53
+ mat-icon {
54
+ font-size: 24px;
55
+ width: 24px;
56
+ height: 24px;
57
+ }
58
+ }
59
+ }
60
+
61
+ .full-width {
62
+ width: 100%;
63
+ }
64
+
65
+ mat-form-field {
66
+ margin-bottom: 16px;
67
+
68
+ &.full-width {
69
+ width: 100%;
70
+ }
71
+ }
72
+
73
+ mat-divider {
74
+ margin: 32px 0;
75
+ }
76
+
77
+ .settings-panel {
78
+ margin-top: 16px;
79
+ background: #f5f5f5;
80
+
81
+ mat-expansion-panel-header {
82
+ mat-panel-title {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 8px;
86
+
87
+ mat-icon {
88
+ font-size: 20px;
89
+ width: 20px;
90
+ height: 20px;
91
+ }
92
+ }
93
+ }
94
+
95
+ .panel-content {
96
+ padding: 16px;
97
+
98
+ .hint-text {
99
+ color: rgba(0, 0, 0, 0.6);
100
+ font-size: 14px;
101
+ margin-bottom: 16px;
102
+ }
103
+ }
104
+ }
105
+
106
+ mat-slide-toggle {
107
+ display: block;
108
+ margin-bottom: 16px;
109
+ }
110
+
111
+ .config-item {
112
+ margin: 24px 0;
113
+
114
+ label {
115
+ display: block;
116
+ color: rgba(0, 0, 0, 0.87);
117
+ font-weight: 500;
118
+ margin-bottom: 8px;
119
+ }
120
+
121
+ mat-slider {
122
+ width: calc(100% - 60px);
123
+ display: inline-block;
124
+ }
125
+
126
+ .slider-value {
127
+ display: inline-block;
128
+ width: 50px;
129
+ text-align: right;
130
+ color: rgba(0, 0, 0, 0.6);
131
+ font-weight: 500;
132
+ }
133
+ }
134
+
135
+ mat-card-actions {
136
+ padding: 16px 24px;
137
+ margin: 0 -24px -24px;
138
+ border-top: 1px solid rgba(0, 0, 0, 0.12);
139
+
140
+ button {
141
+ mat-icon {
142
+ margin-right: 4px;
143
+ }
144
+ }
145
+ }
146
+
147
+ // Icon styling in select options
148
+ mat-option {
149
+ mat-icon {
150
+ margin-right: 8px;
151
+ vertical-align: middle;
152
+ }
153
+ }
154
+
155
+ // Responsive adjustments
156
+ @media (max-width: 768px) {
157
+ :host {
158
+ padding: 16px;
159
+ }
160
+
161
+ .provider-section {
162
+ margin-bottom: 24px;
163
+ }
164
+
165
+ mat-divider {
166
+ margin: 24px 0;
167
+ }
168
  }
flare-ui/src/app/components/environment/environment.component.ts CHANGED
@@ -1,715 +1,715 @@
1
- import { Component, OnInit, OnDestroy } from '@angular/core';
2
- import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
3
- import { FormsModule } from '@angular/forms';
4
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
5
- import { ApiService } from '../../services/api.service';
6
- import { EnvironmentService } from '../../services/environment.service';
7
- import { CommonModule } from '@angular/common';
8
- import { MatCardModule } from '@angular/material/card';
9
- import { MatFormFieldModule } from '@angular/material/form-field';
10
- import { MatInputModule } from '@angular/material/input';
11
- import { MatSelectModule } from '@angular/material/select';
12
- import { MatButtonModule } from '@angular/material/button';
13
- import { MatIconModule } from '@angular/material/icon';
14
- import { MatSliderModule } from '@angular/material/slider';
15
- import { MatSlideToggleModule } from '@angular/material/slide-toggle';
16
- import { MatExpansionModule } from '@angular/material/expansion';
17
- import { MatDividerModule } from '@angular/material/divider';
18
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
19
- import { MatTooltipModule } from '@angular/material/tooltip';
20
- import { MatDialogModule } from '@angular/material/dialog';
21
- import { Subject, takeUntil } from 'rxjs';
22
-
23
- // Provider interfaces
24
- interface ProviderConfig {
25
- type: string;
26
- name: string;
27
- display_name: string;
28
- requires_endpoint: boolean;
29
- requires_api_key: boolean;
30
- requires_repo_info: boolean;
31
- description?: string;
32
- }
33
-
34
- interface ProviderSettings {
35
- name: string;
36
- api_key?: string;
37
- endpoint?: string;
38
- settings: any;
39
- }
40
-
41
- interface EnvironmentConfig {
42
- llm_provider: ProviderSettings;
43
- tts_provider: ProviderSettings;
44
- stt_provider: ProviderSettings;
45
- providers: ProviderConfig[];
46
- }
47
-
48
- @Component({
49
- selector: 'app-environment',
50
- standalone: true,
51
- imports: [
52
- CommonModule,
53
- ReactiveFormsModule,
54
- FormsModule,
55
- MatCardModule,
56
- MatFormFieldModule,
57
- MatInputModule,
58
- MatSelectModule,
59
- MatButtonModule,
60
- MatIconModule,
61
- MatSliderModule,
62
- MatSlideToggleModule,
63
- MatExpansionModule,
64
- MatDividerModule,
65
- MatProgressSpinnerModule,
66
- MatSnackBarModule,
67
- MatTooltipModule,
68
- MatDialogModule
69
- ],
70
- templateUrl: './environment.component.html',
71
- styleUrls: ['./environment.component.scss']
72
- })
73
- export class EnvironmentComponent implements OnInit, OnDestroy {
74
- form: FormGroup;
75
- loading = false;
76
- saving = false;
77
- isLoading = false;
78
-
79
- // Provider lists
80
- llmProviders: ProviderConfig[] = [];
81
- ttsProviders: ProviderConfig[] = [];
82
- sttProviders: ProviderConfig[] = [];
83
-
84
- // Current provider configurations
85
- currentLLMProvider?: ProviderConfig;
86
- currentTTSProvider?: ProviderConfig;
87
- currentSTTProvider?: ProviderConfig;
88
-
89
- // Settings for LLM
90
- internalPrompt: string = '';
91
- parameterCollectionConfig: any = {
92
- enabled: false,
93
- max_params_per_question: 1,
94
- show_all_required: false,
95
- ask_optional_params: false,
96
- group_related_params: false,
97
- min_confidence_score: 0.7,
98
- collection_prompt: 'Please provide the following information:'
99
- };
100
-
101
- hideSTTKey = true;
102
- sttLanguages = [
103
- { code: 'tr-TR', name: 'Türkçe' },
104
- { code: 'en-US', name: 'English (US)' },
105
- { code: 'en-GB', name: 'English (UK)' },
106
- { code: 'de-DE', name: 'Deutsch' },
107
- { code: 'fr-FR', name: 'Français' },
108
- { code: 'es-ES', name: 'Español' },
109
- { code: 'it-IT', name: 'Italiano' },
110
- { code: 'pt-BR', name: 'Português (BR)' },
111
- { code: 'ja-JP', name: '日本語' },
112
- { code: 'ko-KR', name: '한국어' },
113
- { code: 'zh-CN', name: '中文' }
114
- ];
115
-
116
- sttModels = [
117
- { value: 'default', name: 'Default' },
118
- { value: 'latest_short', name: 'Latest Short (Optimized for short audio)' },
119
- { value: 'latest_long', name: 'Latest Long (Best accuracy)' },
120
- { value: 'command_and_search', name: 'Command and Search' },
121
- { value: 'phone_call', name: 'Phone Call (Optimized for telephony)' }
122
- ];
123
-
124
- // API key visibility tracking
125
- showApiKeys: { [key: string]: boolean } = {};
126
-
127
- // Memory leak prevention
128
- private destroyed$ = new Subject<void>();
129
-
130
- constructor(
131
- private fb: FormBuilder,
132
- private apiService: ApiService,
133
- private environmentService: EnvironmentService,
134
- private snackBar: MatSnackBar
135
- ) {
136
- this.form = this.fb.group({
137
- // LLM Provider
138
- llm_provider_name: ['', Validators.required],
139
- llm_provider_api_key: [''],
140
- llm_provider_endpoint: [''],
141
-
142
- // TTS Provider
143
- tts_provider_name: ['no_tts', Validators.required],
144
- tts_provider_api_key: [''],
145
- tts_provider_endpoint: [''],
146
-
147
- // STT Provider
148
- stt_provider_name: ['no_stt', Validators.required],
149
- stt_provider_api_key: [''],
150
- stt_provider_endpoint: [''],
151
-
152
- // STT Settings
153
- stt_settings: this.fb.group({
154
- language: ['tr-TR'],
155
- speech_timeout_ms: [2000],
156
- enable_punctuation: [true],
157
- interim_results: [true],
158
- use_enhanced: [true],
159
- model: ['latest_long'],
160
- noise_reduction_level: [2],
161
- vad_sensitivity: [0.5]
162
- })
163
- });
164
- }
165
-
166
- ngOnInit() {
167
- this.loadEnvironment();
168
- }
169
-
170
- ngOnDestroy() {
171
- this.destroyed$.next();
172
- this.destroyed$.complete();
173
- }
174
-
175
- // Safe getters for template
176
- get currentLLMProviderSafe(): ProviderConfig | null {
177
- return this.currentLLMProvider || null;
178
- }
179
-
180
- get currentTTSProviderSafe(): ProviderConfig | null {
181
- return this.currentTTSProvider || null;
182
- }
183
-
184
- get currentSTTProviderSafe(): ProviderConfig | null {
185
- return this.currentSTTProvider || null;
186
- }
187
-
188
- // API key masking methods
189
- maskApiKey(key?: string): string {
190
- if (!key) return '';
191
- if (key.length <= 8) return '••••••••';
192
- return key.substring(0, 4) + '••••' + key.substring(key.length - 4);
193
- }
194
-
195
- toggleApiKeyVisibility(fieldName: string): void {
196
- this.showApiKeys[fieldName] = !this.showApiKeys[fieldName];
197
- }
198
-
199
- getApiKeyInputType(fieldName: string): string {
200
- return this.showApiKeys[fieldName] ? 'text' : 'password';
201
- }
202
-
203
- formatApiKeyForDisplay(fieldName: string, value?: string): string {
204
- if (this.showApiKeys[fieldName]) {
205
- return value || '';
206
- }
207
- return this.maskApiKey(value);
208
- }
209
-
210
- loadEnvironment(): void {
211
- this.loading = true;
212
- this.isLoading = true;
213
-
214
- this.apiService.getEnvironment()
215
- .pipe(takeUntil(this.destroyed$))
216
- .subscribe({
217
- next: (data: any) => {
218
- // Check if it's new format or legacy
219
- if (data.llm_provider) {
220
- this.handleNewFormat(data);
221
- } else {
222
- this.handleLegacyFormat(data);
223
- }
224
- this.loading = false;
225
- this.isLoading = false;
226
- },
227
- error: (err) => {
228
- console.error('Failed to load environment:', err);
229
- this.snackBar.open('Failed to load environment configuration', 'Close', {
230
- duration: 3000,
231
- panelClass: ['error-snackbar']
232
- });
233
- this.loading = false;
234
- this.isLoading = false;
235
- }
236
- });
237
- }
238
-
239
- handleNewFormat(data: EnvironmentConfig): void {
240
- // Update provider lists
241
- if (data.providers) {
242
- this.llmProviders = data.providers.filter(p => p.type === 'llm');
243
- this.ttsProviders = data.providers.filter(p => p.type === 'tts');
244
- this.sttProviders = data.providers.filter(p => p.type === 'stt');
245
- }
246
-
247
- // Set form values
248
- this.form.patchValue({
249
- llm_provider_name: data.llm_provider?.name || '',
250
- llm_provider_api_key: data.llm_provider?.api_key || '',
251
- llm_provider_endpoint: data.llm_provider?.endpoint || '',
252
- tts_provider_name: data.tts_provider?.name || 'no_tts',
253
- tts_provider_api_key: data.tts_provider?.api_key || '',
254
- tts_provider_endpoint: data.tts_provider?.endpoint || '',
255
- stt_provider_name: data.stt_provider?.name || 'no_stt',
256
- stt_provider_api_key: data.stt_provider?.api_key || '',
257
- stt_provider_endpoint: data.stt_provider?.endpoint || ''
258
- });
259
-
260
- // Set internal prompt and parameter collection config
261
- this.internalPrompt = data.llm_provider?.settings?.internal_prompt || '';
262
- this.parameterCollectionConfig = data.llm_provider?.settings?.parameter_collection_config || this.parameterCollectionConfig;
263
-
264
- // Update current providers
265
- this.updateCurrentProviders();
266
-
267
- // Notify environment service
268
- if (data.tts_provider?.name !== 'no_tts') {
269
- this.environmentService.setTTSEnabled(true);
270
- }
271
- if (data.stt_provider?.name !== 'no_stt') {
272
- this.environmentService.setSTTEnabled(true);
273
- }
274
-
275
- if (data.stt_provider?.settings) {
276
- this.form.get('stt_settings')?.patchValue(data.stt_provider.settings);
277
- }
278
- }
279
-
280
- handleLegacyFormat(data: any): void {
281
- console.warn('Legacy environment format detected, using defaults');
282
-
283
- // Set default providers if not present
284
- this.llmProviders = this.getDefaultProviders('llm');
285
- this.ttsProviders = this.getDefaultProviders('tts');
286
- this.sttProviders = this.getDefaultProviders('stt');
287
-
288
- // Map legacy fields
289
- this.form.patchValue({
290
- llm_provider_name: data.work_mode || 'spark',
291
- llm_provider_api_key: data.cloud_token || '',
292
- llm_provider_endpoint: data.spark_endpoint || '',
293
- tts_provider_name: data.tts_engine || 'no_tts',
294
- tts_provider_api_key: data.tts_engine_api_key || '',
295
- stt_provider_name: data.stt_engine || 'no_stt',
296
- stt_provider_api_key: data.stt_engine_api_key || ''
297
- });
298
-
299
- this.internalPrompt = data.internal_prompt || '';
300
- this.parameterCollectionConfig = data.parameter_collection_config || this.parameterCollectionConfig;
301
-
302
- this.updateCurrentProviders();
303
-
304
- if (data.stt_settings) {
305
- this.form.get('stt_settings')?.patchValue(data.stt_settings);
306
- }
307
- }
308
-
309
- getDefaultProviders(type: string): ProviderConfig[] {
310
- const defaults: { [key: string]: ProviderConfig[] } = {
311
- llm: [
312
- {
313
- type: 'llm',
314
- name: 'spark',
315
- display_name: 'Spark (YTU Cosmos)',
316
- requires_endpoint: true,
317
- requires_api_key: true,
318
- requires_repo_info: true,
319
- description: 'YTU Cosmos Spark LLM Service'
320
- },
321
- {
322
- type: 'llm',
323
- name: 'gpt-4o',
324
- display_name: 'GPT-4o',
325
- requires_endpoint: false,
326
- requires_api_key: true,
327
- requires_repo_info: false,
328
- description: 'OpenAI GPT-4o model'
329
- },
330
- {
331
- type: 'llm',
332
- name: 'gpt-4o-mini',
333
- display_name: 'GPT-4o Mini',
334
- requires_endpoint: false,
335
- requires_api_key: true,
336
- requires_repo_info: false,
337
- description: 'OpenAI GPT-4o Mini model'
338
- }
339
- ],
340
- tts: [
341
- {
342
- type: 'tts',
343
- name: 'no_tts',
344
- display_name: 'No TTS',
345
- requires_endpoint: false,
346
- requires_api_key: false,
347
- requires_repo_info: false,
348
- description: 'Disable text-to-speech'
349
- },
350
- {
351
- type: 'tts',
352
- name: 'elevenlabs',
353
- display_name: 'ElevenLabs',
354
- requires_endpoint: false,
355
- requires_api_key: true,
356
- requires_repo_info: false,
357
- description: 'ElevenLabs TTS service'
358
- }
359
- ],
360
- stt: [
361
- {
362
- type: 'stt',
363
- name: 'no_stt',
364
- display_name: 'No STT',
365
- requires_endpoint: false,
366
- requires_api_key: false,
367
- requires_repo_info: false,
368
- description: 'Disable speech-to-text'
369
- },
370
- {
371
- type: 'stt',
372
- name: 'google',
373
- display_name: 'Google Cloud Speech',
374
- requires_endpoint: false,
375
- requires_api_key: true,
376
- requires_repo_info: false,
377
- description: 'Google Cloud Speech-to-Text API'
378
- },
379
- {
380
- type: 'stt',
381
- name: 'azure',
382
- display_name: 'Azure Speech Services',
383
- requires_endpoint: false,
384
- requires_api_key: true,
385
- requires_repo_info: false,
386
- description: 'Azure Cognitive Services Speech'
387
- },
388
- {
389
- type: 'stt',
390
- name: 'flicker',
391
- display_name: 'Flicker STT',
392
- requires_endpoint: true,
393
- requires_api_key: true,
394
- requires_repo_info: false,
395
- description: 'Flicker Speech Recognition Service'
396
- }
397
- ]
398
- };
399
-
400
- return defaults[type] || [];
401
- }
402
-
403
- updateCurrentProviders(): void {
404
- const llmName = this.form.get('llm_provider_name')?.value;
405
- const ttsName = this.form.get('tts_provider_name')?.value;
406
- const sttName = this.form.get('stt_provider_name')?.value;
407
-
408
- this.currentLLMProvider = this.llmProviders.find(p => p.name === llmName);
409
- this.currentTTSProvider = this.ttsProviders.find(p => p.name === ttsName);
410
- this.currentSTTProvider = this.sttProviders.find(p => p.name === sttName);
411
-
412
- // Update form validators based on requirements
413
- this.updateFormValidators();
414
- }
415
-
416
- updateFormValidators(): void {
417
- // LLM validators
418
- if (this.currentLLMProvider?.requires_api_key) {
419
- this.form.get('llm_provider_api_key')?.setValidators(Validators.required);
420
- } else {
421
- this.form.get('llm_provider_api_key')?.clearValidators();
422
- }
423
-
424
- if (this.currentLLMProvider?.requires_endpoint) {
425
- this.form.get('llm_provider_endpoint')?.setValidators(Validators.required);
426
- } else {
427
- this.form.get('llm_provider_endpoint')?.clearValidators();
428
- }
429
-
430
- // TTS validators
431
- if (this.currentTTSProvider?.requires_api_key) {
432
- this.form.get('tts_provider_api_key')?.setValidators(Validators.required);
433
- } else {
434
- this.form.get('tts_provider_api_key')?.clearValidators();
435
- }
436
-
437
- // STT validators
438
- if (this.currentSTTProvider?.requires_api_key) {
439
- this.form.get('stt_provider_api_key')?.setValidators(Validators.required);
440
- } else {
441
- this.form.get('stt_provider_api_key')?.clearValidators();
442
- }
443
-
444
- // STT endpoint validator
445
- if (this.currentSTTProvider?.requires_endpoint) {
446
- this.form.get('stt_provider_endpoint')?.setValidators(Validators.required);
447
- } else {
448
- this.form.get('stt_provider_endpoint')?.clearValidators();
449
- }
450
-
451
- // Update validity
452
- this.form.get('llm_provider_api_key')?.updateValueAndValidity();
453
- this.form.get('llm_provider_endpoint')?.updateValueAndValidity();
454
- this.form.get('tts_provider_api_key')?.updateValueAndValidity();
455
- this.form.get('stt_provider_api_key')?.updateValueAndValidity();
456
- this.form.get('stt_provider_endpoint')?.updateValueAndValidity();
457
- }
458
-
459
- onLLMProviderChange(value: string): void {
460
- this.currentLLMProvider = this.llmProviders.find(p => p.name === value);
461
- this.updateFormValidators();
462
-
463
- // Reset fields if provider doesn't require them
464
- if (!this.currentLLMProvider?.requires_api_key) {
465
- this.form.get('llm_provider_api_key')?.setValue('');
466
- }
467
- if (!this.currentLLMProvider?.requires_endpoint) {
468
- this.form.get('llm_provider_endpoint')?.setValue('');
469
- }
470
- }
471
-
472
- onTTSProviderChange(value: string): void {
473
- this.currentTTSProvider = this.ttsProviders.find(p => p.name === value);
474
- this.updateFormValidators();
475
-
476
- if (!this.currentTTSProvider?.requires_api_key) {
477
- this.form.get('tts_provider_api_key')?.setValue('');
478
- }
479
-
480
- if (value !== this.form.get('stt_provider_name')?.value) {
481
- this.form.get('stt_provider_api_key')?.setValue('');
482
- }
483
-
484
- // Provider-specific defaults
485
- if (value === 'google') {
486
- this.form.get('stt_settings')?.patchValue({
487
- model: 'latest_long',
488
- use_enhanced: true
489
- });
490
- } else if (value === 'azure') {
491
- this.form.get('stt_settings')?.patchValue({
492
- model: 'default',
493
- use_enhanced: false
494
- });
495
- }
496
-
497
- // STT endpoint validator
498
- if (this.currentSTTProvider?.requires_endpoint) {
499
- this.form.get('stt_provider_endpoint')?.setValidators(Validators.required);
500
- } else {
501
- this.form.get('stt_provider_endpoint')?.clearValidators();
502
- }
503
-
504
- this.form.get('stt_provider_endpoint')?.updateValueAndValidity();
505
-
506
- // Notify environment service
507
- this.environmentService.setTTSEnabled(value !== 'no_tts');
508
- }
509
-
510
- onSTTProviderChange(value: string): void {
511
- this.currentSTTProvider = this.sttProviders.find(p => p.name === value);
512
- this.updateFormValidators();
513
-
514
- if (!this.currentSTTProvider?.requires_api_key) {
515
- this.form.get('stt_provider_api_key')?.setValue('');
516
- }
517
-
518
- // Notify environment service
519
- this.environmentService.setSTTEnabled(value !== 'no_stt');
520
- }
521
-
522
- saveEnvironment(): void {
523
- if (this.form.invalid || this.saving) {
524
- this.snackBar.open('Please fix validation errors', 'Close', {
525
- duration: 3000,
526
- panelClass: ['error-snackbar']
527
- });
528
- return;
529
- }
530
-
531
- this.saving = true;
532
- const formValue = this.form.value;
533
-
534
- const saveData = {
535
- llm_provider: {
536
- name: formValue.llm_provider_name,
537
- api_key: formValue.llm_provider_api_key,
538
- endpoint: formValue.llm_provider_endpoint,
539
- settings: {
540
- internal_prompt: this.internalPrompt,
541
- parameter_collection_config: this.parameterCollectionConfig
542
- }
543
- },
544
- tts_provider: {
545
- name: formValue.tts_provider_name,
546
- api_key: formValue.tts_provider_api_key,
547
- endpoint: formValue.tts_provider_endpoint,
548
- settings: {}
549
- },
550
- stt_provider: {
551
- name: formValue.stt_provider_name,
552
- api_key: formValue.stt_provider_api_key,
553
- endpoint: formValue.stt_provider_endpoint,
554
- settings: formValue.stt_settings || {}
555
- }
556
- };
557
-
558
- this.apiService.updateEnvironment(saveData as any)
559
- .pipe(takeUntil(this.destroyed$))
560
- .subscribe({
561
- next: () => {
562
- this.saving = false;
563
- this.snackBar.open('Environment configuration saved successfully', 'Close', {
564
- duration: 3000,
565
- panelClass: ['success-snackbar']
566
- });
567
-
568
- // Update environment service
569
- this.environmentService.updateEnvironment(saveData as any);
570
-
571
- // Clear form dirty state
572
- this.form.markAsPristine();
573
- },
574
- error: (error) => {
575
- this.saving = false;
576
-
577
- // Race condition handling
578
- if (error.status === 409) {
579
- const details = error.error?.details || {};
580
- this.snackBar.open(
581
- `Settings were modified by ${details.last_update_user || 'another user'}. Please reload.`,
582
- 'Reload',
583
- { duration: 0 }
584
- ).onAction().subscribe(() => {
585
- this.loadEnvironment();
586
- });
587
- } else {
588
- this.snackBar.open(
589
- error.error?.detail || 'Failed to save environment configuration',
590
- 'Close',
591
- {
592
- duration: 5000,
593
- panelClass: ['error-snackbar']
594
- }
595
- );
596
- }
597
- }
598
- });
599
- }
600
-
601
- // Icon helpers
602
- getLLMProviderIcon(provider: ProviderConfig | null): string {
603
- if (!provider || !provider.name) return 'smart_toy';
604
-
605
- switch(provider.name) {
606
- case 'gpt-4o':
607
- case 'gpt-4o-mini':
608
- return 'psychology';
609
- case 'spark':
610
- return 'auto_awesome';
611
- default:
612
- return 'smart_toy';
613
- }
614
- }
615
-
616
- getTTSProviderIcon(provider: ProviderConfig | null): string {
617
- if (!provider || !provider.name) return 'record_voice_over';
618
-
619
- switch(provider.name) {
620
- case 'elevenlabs':
621
- return 'graphic_eq';
622
- case 'blaze':
623
- return 'volume_up';
624
- default:
625
- return 'record_voice_over';
626
- }
627
- }
628
-
629
- getSTTProviderIcon(provider: ProviderConfig | null): string {
630
- if (!provider || !provider.name) return 'mic';
631
-
632
- switch(provider.name) {
633
- case 'google':
634
- return 'g_translate';
635
- case 'azure':
636
- return 'cloud';
637
- case 'flicker':
638
- return 'mic_none';
639
- default:
640
- return 'mic';
641
- }
642
- }
643
-
644
- getProviderIcon(provider: ProviderConfig): string {
645
- switch(provider.type) {
646
- case 'llm':
647
- return this.getLLMProviderIcon(provider);
648
- case 'tts':
649
- return this.getTTSProviderIcon(provider);
650
- case 'stt':
651
- return this.getSTTProviderIcon(provider);
652
- default:
653
- return 'settings';
654
- }
655
- }
656
-
657
- // Helper methods
658
- getApiKeyLabel(type: string): string {
659
- switch(type) {
660
- case 'llm':
661
- return this.currentLLMProvider?.name === 'spark' ? 'API Token' : 'API Key';
662
- case 'tts':
663
- return 'API Key';
664
- case 'stt':
665
- return this.currentSTTProvider?.name === 'google' ? 'Credentials JSON Path' : 'API Key';
666
- default:
667
- return 'API Key';
668
- }
669
- }
670
-
671
- getApiKeyPlaceholder(type: string): string {
672
- switch(type) {
673
- case 'llm':
674
- if (this.currentLLMProvider?.name === 'spark') return 'Enter Spark token';
675
- if (this.currentLLMProvider?.name?.includes('gpt')) return 'sk-...';
676
- return 'Enter API key';
677
- case 'tts':
678
- return 'Enter TTS API key';
679
- case 'stt':
680
- if (this.currentSTTProvider?.name === 'google') return '/path/to/credentials.json';
681
- if (this.currentSTTProvider?.name === 'azure') return 'subscription_key|region';
682
- return 'Enter STT API key';
683
- default:
684
- return 'Enter API key';
685
- }
686
- }
687
-
688
- getEndpointPlaceholder(type: string): string {
689
- switch(type) {
690
- case 'llm':
691
- return 'https://spark-api.example.com';
692
- case 'tts':
693
- return 'https://tts-api.example.com';
694
- case 'stt':
695
- return 'https://stt-api.example.com';
696
- default:
697
- return 'https://api.example.com';
698
- }
699
- }
700
-
701
- resetCollectionPrompt(): void {
702
- this.parameterCollectionConfig.collection_prompt = 'Please provide the following information:';
703
- }
704
-
705
- testConnection(): void {
706
- const endpoint = this.form.get('llm_provider_endpoint')?.value;
707
- if (!endpoint) {
708
- this.snackBar.open('Please enter an endpoint URL', 'Close', { duration: 2000 });
709
- return;
710
- }
711
-
712
- this.snackBar.open('Testing connection...', 'Close', { duration: 2000 });
713
- // TODO: Implement actual connection test
714
- }
715
  }
 
1
+ import { Component, OnInit, OnDestroy } from '@angular/core';
2
+ import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
5
+ import { ApiService } from '../../services/api.service';
6
+ import { EnvironmentService } from '../../services/environment.service';
7
+ import { CommonModule } from '@angular/common';
8
+ import { MatCardModule } from '@angular/material/card';
9
+ import { MatFormFieldModule } from '@angular/material/form-field';
10
+ import { MatInputModule } from '@angular/material/input';
11
+ import { MatSelectModule } from '@angular/material/select';
12
+ import { MatButtonModule } from '@angular/material/button';
13
+ import { MatIconModule } from '@angular/material/icon';
14
+ import { MatSliderModule } from '@angular/material/slider';
15
+ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
16
+ import { MatExpansionModule } from '@angular/material/expansion';
17
+ import { MatDividerModule } from '@angular/material/divider';
18
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
19
+ import { MatTooltipModule } from '@angular/material/tooltip';
20
+ import { MatDialogModule } from '@angular/material/dialog';
21
+ import { Subject, takeUntil } from 'rxjs';
22
+
23
+ // Provider interfaces
24
+ interface ProviderConfig {
25
+ type: string;
26
+ name: string;
27
+ display_name: string;
28
+ requires_endpoint: boolean;
29
+ requires_api_key: boolean;
30
+ requires_repo_info: boolean;
31
+ description?: string;
32
+ }
33
+
34
+ interface ProviderSettings {
35
+ name: string;
36
+ api_key?: string;
37
+ endpoint?: string;
38
+ settings: any;
39
+ }
40
+
41
+ interface EnvironmentConfig {
42
+ llm_provider: ProviderSettings;
43
+ tts_provider: ProviderSettings;
44
+ stt_provider: ProviderSettings;
45
+ providers: ProviderConfig[];
46
+ }
47
+
48
+ @Component({
49
+ selector: 'app-environment',
50
+ standalone: true,
51
+ imports: [
52
+ CommonModule,
53
+ ReactiveFormsModule,
54
+ FormsModule,
55
+ MatCardModule,
56
+ MatFormFieldModule,
57
+ MatInputModule,
58
+ MatSelectModule,
59
+ MatButtonModule,
60
+ MatIconModule,
61
+ MatSliderModule,
62
+ MatSlideToggleModule,
63
+ MatExpansionModule,
64
+ MatDividerModule,
65
+ MatProgressSpinnerModule,
66
+ MatSnackBarModule,
67
+ MatTooltipModule,
68
+ MatDialogModule
69
+ ],
70
+ templateUrl: './environment.component.html',
71
+ styleUrls: ['./environment.component.scss']
72
+ })
73
+ export class EnvironmentComponent implements OnInit, OnDestroy {
74
+ form: FormGroup;
75
+ loading = false;
76
+ saving = false;
77
+ isLoading = false;
78
+
79
+ // Provider lists
80
+ llmProviders: ProviderConfig[] = [];
81
+ ttsProviders: ProviderConfig[] = [];
82
+ sttProviders: ProviderConfig[] = [];
83
+
84
+ // Current provider configurations
85
+ currentLLMProvider?: ProviderConfig;
86
+ currentTTSProvider?: ProviderConfig;
87
+ currentSTTProvider?: ProviderConfig;
88
+
89
+ // Settings for LLM
90
+ internalPrompt: string = '';
91
+ parameterCollectionConfig: any = {
92
+ enabled: false,
93
+ max_params_per_question: 1,
94
+ show_all_required: false,
95
+ ask_optional_params: false,
96
+ group_related_params: false,
97
+ min_confidence_score: 0.7,
98
+ collection_prompt: 'Please provide the following information:'
99
+ };
100
+
101
+ hideSTTKey = true;
102
+ sttLanguages = [
103
+ { code: 'tr-TR', name: 'Türkçe' },
104
+ { code: 'en-US', name: 'English (US)' },
105
+ { code: 'en-GB', name: 'English (UK)' },
106
+ { code: 'de-DE', name: 'Deutsch' },
107
+ { code: 'fr-FR', name: 'Français' },
108
+ { code: 'es-ES', name: 'Español' },
109
+ { code: 'it-IT', name: 'Italiano' },
110
+ { code: 'pt-BR', name: 'Português (BR)' },
111
+ { code: 'ja-JP', name: '日本語' },
112
+ { code: 'ko-KR', name: '한국어' },
113
+ { code: 'zh-CN', name: '中文' }
114
+ ];
115
+
116
+ sttModels = [
117
+ { value: 'default', name: 'Default' },
118
+ { value: 'latest_short', name: 'Latest Short (Optimized for short audio)' },
119
+ { value: 'latest_long', name: 'Latest Long (Best accuracy)' },
120
+ { value: 'command_and_search', name: 'Command and Search' },
121
+ { value: 'phone_call', name: 'Phone Call (Optimized for telephony)' }
122
+ ];
123
+
124
+ // API key visibility tracking
125
+ showApiKeys: { [key: string]: boolean } = {};
126
+
127
+ // Memory leak prevention
128
+ private destroyed$ = new Subject<void>();
129
+
130
+ constructor(
131
+ private fb: FormBuilder,
132
+ private apiService: ApiService,
133
+ private environmentService: EnvironmentService,
134
+ private snackBar: MatSnackBar
135
+ ) {
136
+ this.form = this.fb.group({
137
+ // LLM Provider
138
+ llm_provider_name: ['', Validators.required],
139
+ llm_provider_api_key: [''],
140
+ llm_provider_endpoint: [''],
141
+
142
+ // TTS Provider
143
+ tts_provider_name: ['no_tts', Validators.required],
144
+ tts_provider_api_key: [''],
145
+ tts_provider_endpoint: [''],
146
+
147
+ // STT Provider
148
+ stt_provider_name: ['no_stt', Validators.required],
149
+ stt_provider_api_key: [''],
150
+ stt_provider_endpoint: [''],
151
+
152
+ // STT Settings
153
+ stt_settings: this.fb.group({
154
+ language: ['tr-TR'],
155
+ speech_timeout_ms: [2000],
156
+ enable_punctuation: [true],
157
+ interim_results: [true],
158
+ use_enhanced: [true],
159
+ model: ['latest_long'],
160
+ noise_reduction_level: [2],
161
+ vad_sensitivity: [0.5]
162
+ })
163
+ });
164
+ }
165
+
166
+ ngOnInit() {
167
+ this.loadEnvironment();
168
+ }
169
+
170
+ ngOnDestroy() {
171
+ this.destroyed$.next();
172
+ this.destroyed$.complete();
173
+ }
174
+
175
+ // Safe getters for template
176
+ get currentLLMProviderSafe(): ProviderConfig | null {
177
+ return this.currentLLMProvider || null;
178
+ }
179
+
180
+ get currentTTSProviderSafe(): ProviderConfig | null {
181
+ return this.currentTTSProvider || null;
182
+ }
183
+
184
+ get currentSTTProviderSafe(): ProviderConfig | null {
185
+ return this.currentSTTProvider || null;
186
+ }
187
+
188
+ // API key masking methods
189
+ maskApiKey(key?: string): string {
190
+ if (!key) return '';
191
+ if (key.length <= 8) return '••••••••';
192
+ return key.substring(0, 4) + '••••' + key.substring(key.length - 4);
193
+ }
194
+
195
+ toggleApiKeyVisibility(fieldName: string): void {
196
+ this.showApiKeys[fieldName] = !this.showApiKeys[fieldName];
197
+ }
198
+
199
+ getApiKeyInputType(fieldName: string): string {
200
+ return this.showApiKeys[fieldName] ? 'text' : 'password';
201
+ }
202
+
203
+ formatApiKeyForDisplay(fieldName: string, value?: string): string {
204
+ if (this.showApiKeys[fieldName]) {
205
+ return value || '';
206
+ }
207
+ return this.maskApiKey(value);
208
+ }
209
+
210
+ loadEnvironment(): void {
211
+ this.loading = true;
212
+ this.isLoading = true;
213
+
214
+ this.apiService.getEnvironment()
215
+ .pipe(takeUntil(this.destroyed$))
216
+ .subscribe({
217
+ next: (data: any) => {
218
+ // Check if it's new format or legacy
219
+ if (data.llm_provider) {
220
+ this.handleNewFormat(data);
221
+ } else {
222
+ this.handleLegacyFormat(data);
223
+ }
224
+ this.loading = false;
225
+ this.isLoading = false;
226
+ },
227
+ error: (err) => {
228
+ console.error('Failed to load environment:', err);
229
+ this.snackBar.open('Failed to load environment configuration', 'Close', {
230
+ duration: 3000,
231
+ panelClass: ['error-snackbar']
232
+ });
233
+ this.loading = false;
234
+ this.isLoading = false;
235
+ }
236
+ });
237
+ }
238
+
239
+ handleNewFormat(data: EnvironmentConfig): void {
240
+ // Update provider lists
241
+ if (data.providers) {
242
+ this.llmProviders = data.providers.filter(p => p.type === 'llm');
243
+ this.ttsProviders = data.providers.filter(p => p.type === 'tts');
244
+ this.sttProviders = data.providers.filter(p => p.type === 'stt');
245
+ }
246
+
247
+ // Set form values
248
+ this.form.patchValue({
249
+ llm_provider_name: data.llm_provider?.name || '',
250
+ llm_provider_api_key: data.llm_provider?.api_key || '',
251
+ llm_provider_endpoint: data.llm_provider?.endpoint || '',
252
+ tts_provider_name: data.tts_provider?.name || 'no_tts',
253
+ tts_provider_api_key: data.tts_provider?.api_key || '',
254
+ tts_provider_endpoint: data.tts_provider?.endpoint || '',
255
+ stt_provider_name: data.stt_provider?.name || 'no_stt',
256
+ stt_provider_api_key: data.stt_provider?.api_key || '',
257
+ stt_provider_endpoint: data.stt_provider?.endpoint || ''
258
+ });
259
+
260
+ // Set internal prompt and parameter collection config
261
+ this.internalPrompt = data.llm_provider?.settings?.internal_prompt || '';
262
+ this.parameterCollectionConfig = data.llm_provider?.settings?.parameter_collection_config || this.parameterCollectionConfig;
263
+
264
+ // Update current providers
265
+ this.updateCurrentProviders();
266
+
267
+ // Notify environment service
268
+ if (data.tts_provider?.name !== 'no_tts') {
269
+ this.environmentService.setTTSEnabled(true);
270
+ }
271
+ if (data.stt_provider?.name !== 'no_stt') {
272
+ this.environmentService.setSTTEnabled(true);
273
+ }
274
+
275
+ if (data.stt_provider?.settings) {
276
+ this.form.get('stt_settings')?.patchValue(data.stt_provider.settings);
277
+ }
278
+ }
279
+
280
+ handleLegacyFormat(data: any): void {
281
+ console.warn('Legacy environment format detected, using defaults');
282
+
283
+ // Set default providers if not present
284
+ this.llmProviders = this.getDefaultProviders('llm');
285
+ this.ttsProviders = this.getDefaultProviders('tts');
286
+ this.sttProviders = this.getDefaultProviders('stt');
287
+
288
+ // Map legacy fields
289
+ this.form.patchValue({
290
+ llm_provider_name: data.work_mode || 'spark',
291
+ llm_provider_api_key: data.cloud_token || '',
292
+ llm_provider_endpoint: data.spark_endpoint || '',
293
+ tts_provider_name: data.tts_engine || 'no_tts',
294
+ tts_provider_api_key: data.tts_engine_api_key || '',
295
+ stt_provider_name: data.stt_engine || 'no_stt',
296
+ stt_provider_api_key: data.stt_engine_api_key || ''
297
+ });
298
+
299
+ this.internalPrompt = data.internal_prompt || '';
300
+ this.parameterCollectionConfig = data.parameter_collection_config || this.parameterCollectionConfig;
301
+
302
+ this.updateCurrentProviders();
303
+
304
+ if (data.stt_settings) {
305
+ this.form.get('stt_settings')?.patchValue(data.stt_settings);
306
+ }
307
+ }
308
+
309
+ getDefaultProviders(type: string): ProviderConfig[] {
310
+ const defaults: { [key: string]: ProviderConfig[] } = {
311
+ llm: [
312
+ {
313
+ type: 'llm',
314
+ name: 'spark',
315
+ display_name: 'Spark (YTU Cosmos)',
316
+ requires_endpoint: true,
317
+ requires_api_key: true,
318
+ requires_repo_info: true,
319
+ description: 'YTU Cosmos Spark LLM Service'
320
+ },
321
+ {
322
+ type: 'llm',
323
+ name: 'gpt-4o',
324
+ display_name: 'GPT-4o',
325
+ requires_endpoint: false,
326
+ requires_api_key: true,
327
+ requires_repo_info: false,
328
+ description: 'OpenAI GPT-4o model'
329
+ },
330
+ {
331
+ type: 'llm',
332
+ name: 'gpt-4o-mini',
333
+ display_name: 'GPT-4o Mini',
334
+ requires_endpoint: false,
335
+ requires_api_key: true,
336
+ requires_repo_info: false,
337
+ description: 'OpenAI GPT-4o Mini model'
338
+ }
339
+ ],
340
+ tts: [
341
+ {
342
+ type: 'tts',
343
+ name: 'no_tts',
344
+ display_name: 'No TTS',
345
+ requires_endpoint: false,
346
+ requires_api_key: false,
347
+ requires_repo_info: false,
348
+ description: 'Disable text-to-speech'
349
+ },
350
+ {
351
+ type: 'tts',
352
+ name: 'elevenlabs',
353
+ display_name: 'ElevenLabs',
354
+ requires_endpoint: false,
355
+ requires_api_key: true,
356
+ requires_repo_info: false,
357
+ description: 'ElevenLabs TTS service'
358
+ }
359
+ ],
360
+ stt: [
361
+ {
362
+ type: 'stt',
363
+ name: 'no_stt',
364
+ display_name: 'No STT',
365
+ requires_endpoint: false,
366
+ requires_api_key: false,
367
+ requires_repo_info: false,
368
+ description: 'Disable speech-to-text'
369
+ },
370
+ {
371
+ type: 'stt',
372
+ name: 'google',
373
+ display_name: 'Google Cloud Speech',
374
+ requires_endpoint: false,
375
+ requires_api_key: true,
376
+ requires_repo_info: false,
377
+ description: 'Google Cloud Speech-to-Text API'
378
+ },
379
+ {
380
+ type: 'stt',
381
+ name: 'azure',
382
+ display_name: 'Azure Speech Services',
383
+ requires_endpoint: false,
384
+ requires_api_key: true,
385
+ requires_repo_info: false,
386
+ description: 'Azure Cognitive Services Speech'
387
+ },
388
+ {
389
+ type: 'stt',
390
+ name: 'flicker',
391
+ display_name: 'Flicker STT',
392
+ requires_endpoint: true,
393
+ requires_api_key: true,
394
+ requires_repo_info: false,
395
+ description: 'Flicker Speech Recognition Service'
396
+ }
397
+ ]
398
+ };
399
+
400
+ return defaults[type] || [];
401
+ }
402
+
403
+ updateCurrentProviders(): void {
404
+ const llmName = this.form.get('llm_provider_name')?.value;
405
+ const ttsName = this.form.get('tts_provider_name')?.value;
406
+ const sttName = this.form.get('stt_provider_name')?.value;
407
+
408
+ this.currentLLMProvider = this.llmProviders.find(p => p.name === llmName);
409
+ this.currentTTSProvider = this.ttsProviders.find(p => p.name === ttsName);
410
+ this.currentSTTProvider = this.sttProviders.find(p => p.name === sttName);
411
+
412
+ // Update form validators based on requirements
413
+ this.updateFormValidators();
414
+ }
415
+
416
+ updateFormValidators(): void {
417
+ // LLM validators
418
+ if (this.currentLLMProvider?.requires_api_key) {
419
+ this.form.get('llm_provider_api_key')?.setValidators(Validators.required);
420
+ } else {
421
+ this.form.get('llm_provider_api_key')?.clearValidators();
422
+ }
423
+
424
+ if (this.currentLLMProvider?.requires_endpoint) {
425
+ this.form.get('llm_provider_endpoint')?.setValidators(Validators.required);
426
+ } else {
427
+ this.form.get('llm_provider_endpoint')?.clearValidators();
428
+ }
429
+
430
+ // TTS validators
431
+ if (this.currentTTSProvider?.requires_api_key) {
432
+ this.form.get('tts_provider_api_key')?.setValidators(Validators.required);
433
+ } else {
434
+ this.form.get('tts_provider_api_key')?.clearValidators();
435
+ }
436
+
437
+ // STT validators
438
+ if (this.currentSTTProvider?.requires_api_key) {
439
+ this.form.get('stt_provider_api_key')?.setValidators(Validators.required);
440
+ } else {
441
+ this.form.get('stt_provider_api_key')?.clearValidators();
442
+ }
443
+
444
+ // STT endpoint validator
445
+ if (this.currentSTTProvider?.requires_endpoint) {
446
+ this.form.get('stt_provider_endpoint')?.setValidators(Validators.required);
447
+ } else {
448
+ this.form.get('stt_provider_endpoint')?.clearValidators();
449
+ }
450
+
451
+ // Update validity
452
+ this.form.get('llm_provider_api_key')?.updateValueAndValidity();
453
+ this.form.get('llm_provider_endpoint')?.updateValueAndValidity();
454
+ this.form.get('tts_provider_api_key')?.updateValueAndValidity();
455
+ this.form.get('stt_provider_api_key')?.updateValueAndValidity();
456
+ this.form.get('stt_provider_endpoint')?.updateValueAndValidity();
457
+ }
458
+
459
+ onLLMProviderChange(value: string): void {
460
+ this.currentLLMProvider = this.llmProviders.find(p => p.name === value);
461
+ this.updateFormValidators();
462
+
463
+ // Reset fields if provider doesn't require them
464
+ if (!this.currentLLMProvider?.requires_api_key) {
465
+ this.form.get('llm_provider_api_key')?.setValue('');
466
+ }
467
+ if (!this.currentLLMProvider?.requires_endpoint) {
468
+ this.form.get('llm_provider_endpoint')?.setValue('');
469
+ }
470
+ }
471
+
472
+ onTTSProviderChange(value: string): void {
473
+ this.currentTTSProvider = this.ttsProviders.find(p => p.name === value);
474
+ this.updateFormValidators();
475
+
476
+ if (!this.currentTTSProvider?.requires_api_key) {
477
+ this.form.get('tts_provider_api_key')?.setValue('');
478
+ }
479
+
480
+ if (value !== this.form.get('stt_provider_name')?.value) {
481
+ this.form.get('stt_provider_api_key')?.setValue('');
482
+ }
483
+
484
+ // Provider-specific defaults
485
+ if (value === 'google') {
486
+ this.form.get('stt_settings')?.patchValue({
487
+ model: 'latest_long',
488
+ use_enhanced: true
489
+ });
490
+ } else if (value === 'azure') {
491
+ this.form.get('stt_settings')?.patchValue({
492
+ model: 'default',
493
+ use_enhanced: false
494
+ });
495
+ }
496
+
497
+ // STT endpoint validator
498
+ if (this.currentSTTProvider?.requires_endpoint) {
499
+ this.form.get('stt_provider_endpoint')?.setValidators(Validators.required);
500
+ } else {
501
+ this.form.get('stt_provider_endpoint')?.clearValidators();
502
+ }
503
+
504
+ this.form.get('stt_provider_endpoint')?.updateValueAndValidity();
505
+
506
+ // Notify environment service
507
+ this.environmentService.setTTSEnabled(value !== 'no_tts');
508
+ }
509
+
510
+ onSTTProviderChange(value: string): void {
511
+ this.currentSTTProvider = this.sttProviders.find(p => p.name === value);
512
+ this.updateFormValidators();
513
+
514
+ if (!this.currentSTTProvider?.requires_api_key) {
515
+ this.form.get('stt_provider_api_key')?.setValue('');
516
+ }
517
+
518
+ // Notify environment service
519
+ this.environmentService.setSTTEnabled(value !== 'no_stt');
520
+ }
521
+
522
+ saveEnvironment(): void {
523
+ if (this.form.invalid || this.saving) {
524
+ this.snackBar.open('Please fix validation errors', 'Close', {
525
+ duration: 3000,
526
+ panelClass: ['error-snackbar']
527
+ });
528
+ return;
529
+ }
530
+
531
+ this.saving = true;
532
+ const formValue = this.form.value;
533
+
534
+ const saveData = {
535
+ llm_provider: {
536
+ name: formValue.llm_provider_name,
537
+ api_key: formValue.llm_provider_api_key,
538
+ endpoint: formValue.llm_provider_endpoint,
539
+ settings: {
540
+ internal_prompt: this.internalPrompt,
541
+ parameter_collection_config: this.parameterCollectionConfig
542
+ }
543
+ },
544
+ tts_provider: {
545
+ name: formValue.tts_provider_name,
546
+ api_key: formValue.tts_provider_api_key,
547
+ endpoint: formValue.tts_provider_endpoint,
548
+ settings: {}
549
+ },
550
+ stt_provider: {
551
+ name: formValue.stt_provider_name,
552
+ api_key: formValue.stt_provider_api_key,
553
+ endpoint: formValue.stt_provider_endpoint,
554
+ settings: formValue.stt_settings || {}
555
+ }
556
+ };
557
+
558
+ this.apiService.updateEnvironment(saveData as any)
559
+ .pipe(takeUntil(this.destroyed$))
560
+ .subscribe({
561
+ next: () => {
562
+ this.saving = false;
563
+ this.snackBar.open('Environment configuration saved successfully', 'Close', {
564
+ duration: 3000,
565
+ panelClass: ['success-snackbar']
566
+ });
567
+
568
+ // Update environment service
569
+ this.environmentService.updateEnvironment(saveData as any);
570
+
571
+ // Clear form dirty state
572
+ this.form.markAsPristine();
573
+ },
574
+ error: (error) => {
575
+ this.saving = false;
576
+
577
+ // Race condition handling
578
+ if (error.status === 409) {
579
+ const details = error.error?.details || {};
580
+ this.snackBar.open(
581
+ `Settings were modified by ${details.last_update_user || 'another user'}. Please reload.`,
582
+ 'Reload',
583
+ { duration: 0 }
584
+ ).onAction().subscribe(() => {
585
+ this.loadEnvironment();
586
+ });
587
+ } else {
588
+ this.snackBar.open(
589
+ error.error?.detail || 'Failed to save environment configuration',
590
+ 'Close',
591
+ {
592
+ duration: 5000,
593
+ panelClass: ['error-snackbar']
594
+ }
595
+ );
596
+ }
597
+ }
598
+ });
599
+ }
600
+
601
+ // Icon helpers
602
+ getLLMProviderIcon(provider: ProviderConfig | null): string {
603
+ if (!provider || !provider.name) return 'smart_toy';
604
+
605
+ switch(provider.name) {
606
+ case 'gpt-4o':
607
+ case 'gpt-4o-mini':
608
+ return 'psychology';
609
+ case 'spark':
610
+ return 'auto_awesome';
611
+ default:
612
+ return 'smart_toy';
613
+ }
614
+ }
615
+
616
+ getTTSProviderIcon(provider: ProviderConfig | null): string {
617
+ if (!provider || !provider.name) return 'record_voice_over';
618
+
619
+ switch(provider.name) {
620
+ case 'elevenlabs':
621
+ return 'graphic_eq';
622
+ case 'blaze':
623
+ return 'volume_up';
624
+ default:
625
+ return 'record_voice_over';
626
+ }
627
+ }
628
+
629
+ getSTTProviderIcon(provider: ProviderConfig | null): string {
630
+ if (!provider || !provider.name) return 'mic';
631
+
632
+ switch(provider.name) {
633
+ case 'google':
634
+ return 'g_translate';
635
+ case 'azure':
636
+ return 'cloud';
637
+ case 'flicker':
638
+ return 'mic_none';
639
+ default:
640
+ return 'mic';
641
+ }
642
+ }
643
+
644
+ getProviderIcon(provider: ProviderConfig): string {
645
+ switch(provider.type) {
646
+ case 'llm':
647
+ return this.getLLMProviderIcon(provider);
648
+ case 'tts':
649
+ return this.getTTSProviderIcon(provider);
650
+ case 'stt':
651
+ return this.getSTTProviderIcon(provider);
652
+ default:
653
+ return 'settings';
654
+ }
655
+ }
656
+
657
+ // Helper methods
658
+ getApiKeyLabel(type: string): string {
659
+ switch(type) {
660
+ case 'llm':
661
+ return this.currentLLMProvider?.name === 'spark' ? 'API Token' : 'API Key';
662
+ case 'tts':
663
+ return 'API Key';
664
+ case 'stt':
665
+ return this.currentSTTProvider?.name === 'google' ? 'Credentials JSON Path' : 'API Key';
666
+ default:
667
+ return 'API Key';
668
+ }
669
+ }
670
+
671
+ getApiKeyPlaceholder(type: string): string {
672
+ switch(type) {
673
+ case 'llm':
674
+ if (this.currentLLMProvider?.name === 'spark') return 'Enter Spark token';
675
+ if (this.currentLLMProvider?.name?.includes('gpt')) return 'sk-...';
676
+ return 'Enter API key';
677
+ case 'tts':
678
+ return 'Enter TTS API key';
679
+ case 'stt':
680
+ if (this.currentSTTProvider?.name === 'google') return '/path/to/credentials.json';
681
+ if (this.currentSTTProvider?.name === 'azure') return 'subscription_key|region';
682
+ return 'Enter STT API key';
683
+ default:
684
+ return 'Enter API key';
685
+ }
686
+ }
687
+
688
+ getEndpointPlaceholder(type: string): string {
689
+ switch(type) {
690
+ case 'llm':
691
+ return 'https://spark-api.example.com';
692
+ case 'tts':
693
+ return 'https://tts-api.example.com';
694
+ case 'stt':
695
+ return 'https://stt-api.example.com';
696
+ default:
697
+ return 'https://api.example.com';
698
+ }
699
+ }
700
+
701
+ resetCollectionPrompt(): void {
702
+ this.parameterCollectionConfig.collection_prompt = 'Please provide the following information:';
703
+ }
704
+
705
+ testConnection(): void {
706
+ const endpoint = this.form.get('llm_provider_endpoint')?.value;
707
+ if (!endpoint) {
708
+ this.snackBar.open('Please enter an endpoint URL', 'Close', { duration: 2000 });
709
+ return;
710
+ }
711
+
712
+ this.snackBar.open('Testing connection...', 'Close', { duration: 2000 });
713
+ // TODO: Implement actual connection test
714
+ }
715
  }
flare-ui/src/app/components/login/login.component.ts CHANGED
@@ -1,209 +1,209 @@
1
- import { Component, inject } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { Router } from '@angular/router';
5
- import { MatCardModule } from '@angular/material/card';
6
- import { MatFormFieldModule } from '@angular/material/form-field';
7
- import { MatInputModule } from '@angular/material/input';
8
- import { MatButtonModule } from '@angular/material/button';
9
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
10
- import { MatIconModule } from '@angular/material/icon';
11
- import { AuthService } from '../../services/auth.service';
12
-
13
- @Component({
14
- selector: 'app-login',
15
- standalone: true,
16
- imports: [
17
- CommonModule,
18
- FormsModule,
19
- MatCardModule,
20
- MatFormFieldModule,
21
- MatInputModule,
22
- MatButtonModule,
23
- MatProgressSpinnerModule,
24
- MatIconModule
25
- ],
26
- template: `
27
- <div class="login-container">
28
- <mat-card class="login-card">
29
- <mat-card-header>
30
- <mat-card-title>Flare Administration</mat-card-title>
31
- </mat-card-header>
32
- <mat-card-content>
33
- <form (ngSubmit)="login()" #loginForm="ngForm">
34
- <mat-form-field appearance="outline" class="full-width">
35
- <mat-label>Username</mat-label>
36
- <input
37
- matInput
38
- type="text"
39
- name="username"
40
- [(ngModel)]="username"
41
- required
42
- [disabled]="loading"
43
- autocomplete="username"
44
- >
45
- <mat-icon matPrefix>person</mat-icon>
46
- <mat-error>Username is required</mat-error>
47
- </mat-form-field>
48
-
49
- <mat-form-field appearance="outline" class="full-width">
50
- <mat-label>Password</mat-label>
51
- <input
52
- matInput
53
- [type]="hidePassword ? 'password' : 'text'"
54
- name="password"
55
- [(ngModel)]="password"
56
- required
57
- [disabled]="loading"
58
- autocomplete="current-password"
59
- >
60
- <mat-icon matPrefix>lock</mat-icon>
61
- <button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" type="button">
62
- <mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
63
- </button>
64
- <mat-error>Password is required</mat-error>
65
- </mat-form-field>
66
-
67
- @if (error) {
68
- <div class="error-message">
69
- <mat-icon>error</mat-icon>
70
- {{ error }}
71
- </div>
72
- }
73
-
74
- <button
75
- mat-raised-button
76
- color="primary"
77
- type="submit"
78
- class="full-width submit-button"
79
- [disabled]="loading || !loginForm.valid"
80
- >
81
- @if (loading) {
82
- <mat-spinner diameter="20" class="button-spinner"></mat-spinner>
83
- Logging in...
84
- } @else {
85
- <mat-icon>login</mat-icon>
86
- Login
87
- }
88
- </button>
89
- </form>
90
- </mat-card-content>
91
- </mat-card>
92
- </div>
93
- `,
94
- styles: [`
95
- .login-container {
96
- min-height: 100vh;
97
- display: flex;
98
- align-items: center;
99
- justify-content: center;
100
- background-color: #f5f5f5;
101
- }
102
-
103
- .login-card {
104
- width: 100%;
105
- max-width: 400px;
106
- padding: 20px;
107
-
108
- mat-card-header {
109
- display: flex;
110
- justify-content: center;
111
- margin-bottom: 30px;
112
-
113
- mat-card-title {
114
- font-size: 24px;
115
- font-weight: 500;
116
- color: #333;
117
- }
118
- }
119
- }
120
-
121
- .full-width {
122
- width: 100%;
123
- }
124
-
125
- mat-form-field {
126
- margin-bottom: 20px;
127
- }
128
-
129
- .error-message {
130
- display: flex;
131
- align-items: center;
132
- gap: 8px;
133
- color: #f44336;
134
- font-size: 14px;
135
- margin-bottom: 20px;
136
- padding: 12px;
137
- background-color: #ffebee;
138
- border-radius: 4px;
139
-
140
- mat-icon {
141
- font-size: 20px;
142
- width: 20px;
143
- height: 20px;
144
- }
145
- }
146
-
147
- .submit-button {
148
- height: 48px;
149
- font-size: 16px;
150
- margin-top: 10px;
151
- display: flex;
152
- align-items: center;
153
- justify-content: center;
154
- gap: 8px;
155
-
156
- mat-icon {
157
- margin-right: 4px;
158
- }
159
- }
160
-
161
- .button-spinner {
162
- display: inline-block;
163
- margin-right: 8px;
164
- }
165
-
166
- ::ng-deep {
167
- .mat-mdc-card {
168
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.16) !important;
169
- }
170
-
171
- .mat-mdc-form-field-icon-prefix,
172
- .mat-mdc-form-field-icon-suffix {
173
- padding: 0 4px;
174
- }
175
-
176
- .mat-mdc-progress-spinner {
177
- --mdc-circular-progress-active-indicator-color: white;
178
- }
179
-
180
- .mat-mdc-form-field-error {
181
- font-size: 12px;
182
- }
183
- }
184
- `]
185
- })
186
- export class LoginComponent {
187
- private authService = inject(AuthService);
188
- private router = inject(Router);
189
-
190
- username = '';
191
- password = '';
192
- loading = false;
193
- error = '';
194
- hidePassword = true;
195
-
196
- async login() {
197
- this.loading = true;
198
- this.error = '';
199
-
200
- try {
201
- await this.authService.login(this.username, this.password).toPromise();
202
- this.router.navigate(['/']);
203
- } catch (err: any) {
204
- this.error = err.error?.detail || 'Invalid credentials';
205
- } finally {
206
- this.loading = false;
207
- }
208
- }
209
  }
 
1
+ import { Component, inject } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { Router } from '@angular/router';
5
+ import { MatCardModule } from '@angular/material/card';
6
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
+ import { MatInputModule } from '@angular/material/input';
8
+ import { MatButtonModule } from '@angular/material/button';
9
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
10
+ import { MatIconModule } from '@angular/material/icon';
11
+ import { AuthService } from '../../services/auth.service';
12
+
13
+ @Component({
14
+ selector: 'app-login',
15
+ standalone: true,
16
+ imports: [
17
+ CommonModule,
18
+ FormsModule,
19
+ MatCardModule,
20
+ MatFormFieldModule,
21
+ MatInputModule,
22
+ MatButtonModule,
23
+ MatProgressSpinnerModule,
24
+ MatIconModule
25
+ ],
26
+ template: `
27
+ <div class="login-container">
28
+ <mat-card class="login-card">
29
+ <mat-card-header>
30
+ <mat-card-title>Flare Administration</mat-card-title>
31
+ </mat-card-header>
32
+ <mat-card-content>
33
+ <form (ngSubmit)="login()" #loginForm="ngForm">
34
+ <mat-form-field appearance="outline" class="full-width">
35
+ <mat-label>Username</mat-label>
36
+ <input
37
+ matInput
38
+ type="text"
39
+ name="username"
40
+ [(ngModel)]="username"
41
+ required
42
+ [disabled]="loading"
43
+ autocomplete="username"
44
+ >
45
+ <mat-icon matPrefix>person</mat-icon>
46
+ <mat-error>Username is required</mat-error>
47
+ </mat-form-field>
48
+
49
+ <mat-form-field appearance="outline" class="full-width">
50
+ <mat-label>Password</mat-label>
51
+ <input
52
+ matInput
53
+ [type]="hidePassword ? 'password' : 'text'"
54
+ name="password"
55
+ [(ngModel)]="password"
56
+ required
57
+ [disabled]="loading"
58
+ autocomplete="current-password"
59
+ >
60
+ <mat-icon matPrefix>lock</mat-icon>
61
+ <button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" type="button">
62
+ <mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
63
+ </button>
64
+ <mat-error>Password is required</mat-error>
65
+ </mat-form-field>
66
+
67
+ @if (error) {
68
+ <div class="error-message">
69
+ <mat-icon>error</mat-icon>
70
+ {{ error }}
71
+ </div>
72
+ }
73
+
74
+ <button
75
+ mat-raised-button
76
+ color="primary"
77
+ type="submit"
78
+ class="full-width submit-button"
79
+ [disabled]="loading || !loginForm.valid"
80
+ >
81
+ @if (loading) {
82
+ <mat-spinner diameter="20" class="button-spinner"></mat-spinner>
83
+ Logging in...
84
+ } @else {
85
+ <mat-icon>login</mat-icon>
86
+ Login
87
+ }
88
+ </button>
89
+ </form>
90
+ </mat-card-content>
91
+ </mat-card>
92
+ </div>
93
+ `,
94
+ styles: [`
95
+ .login-container {
96
+ min-height: 100vh;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ background-color: #f5f5f5;
101
+ }
102
+
103
+ .login-card {
104
+ width: 100%;
105
+ max-width: 400px;
106
+ padding: 20px;
107
+
108
+ mat-card-header {
109
+ display: flex;
110
+ justify-content: center;
111
+ margin-bottom: 30px;
112
+
113
+ mat-card-title {
114
+ font-size: 24px;
115
+ font-weight: 500;
116
+ color: #333;
117
+ }
118
+ }
119
+ }
120
+
121
+ .full-width {
122
+ width: 100%;
123
+ }
124
+
125
+ mat-form-field {
126
+ margin-bottom: 20px;
127
+ }
128
+
129
+ .error-message {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 8px;
133
+ color: #f44336;
134
+ font-size: 14px;
135
+ margin-bottom: 20px;
136
+ padding: 12px;
137
+ background-color: #ffebee;
138
+ border-radius: 4px;
139
+
140
+ mat-icon {
141
+ font-size: 20px;
142
+ width: 20px;
143
+ height: 20px;
144
+ }
145
+ }
146
+
147
+ .submit-button {
148
+ height: 48px;
149
+ font-size: 16px;
150
+ margin-top: 10px;
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ gap: 8px;
155
+
156
+ mat-icon {
157
+ margin-right: 4px;
158
+ }
159
+ }
160
+
161
+ .button-spinner {
162
+ display: inline-block;
163
+ margin-right: 8px;
164
+ }
165
+
166
+ ::ng-deep {
167
+ .mat-mdc-card {
168
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.16) !important;
169
+ }
170
+
171
+ .mat-mdc-form-field-icon-prefix,
172
+ .mat-mdc-form-field-icon-suffix {
173
+ padding: 0 4px;
174
+ }
175
+
176
+ .mat-mdc-progress-spinner {
177
+ --mdc-circular-progress-active-indicator-color: white;
178
+ }
179
+
180
+ .mat-mdc-form-field-error {
181
+ font-size: 12px;
182
+ }
183
+ }
184
+ `]
185
+ })
186
+ export class LoginComponent {
187
+ private authService = inject(AuthService);
188
+ private router = inject(Router);
189
+
190
+ username = '';
191
+ password = '';
192
+ loading = false;
193
+ error = '';
194
+ hidePassword = true;
195
+
196
+ async login() {
197
+ this.loading = true;
198
+ this.error = '';
199
+
200
+ try {
201
+ await this.authService.login(this.username, this.password).toPromise();
202
+ this.router.navigate(['/']);
203
+ } catch (err: any) {
204
+ this.error = err.error?.detail || 'Invalid credentials';
205
+ } finally {
206
+ this.loading = false;
207
+ }
208
+ }
209
  }
flare-ui/src/app/components/main/main.component.scss CHANGED
@@ -1,145 +1,145 @@
1
- .main-layout {
2
- display: flex;
3
- flex-direction: column;
4
- height: 100vh;
5
- background-color: #fafafa;
6
-
7
- .header-toolbar {
8
- position: sticky;
9
- top: 0;
10
- z-index: 100;
11
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
12
-
13
- mat-toolbar-row {
14
- padding: 0 16px;
15
- }
16
-
17
- .logo {
18
- display: flex;
19
- align-items: center;
20
- gap: 8px;
21
- font-size: 20px;
22
- font-weight: 500;
23
-
24
- mat-icon {
25
- vertical-align: middle;
26
- }
27
- }
28
-
29
- .spacer {
30
- flex: 1;
31
- }
32
-
33
- .header-actions {
34
- display: flex;
35
- align-items: center;
36
- gap: 16px;
37
-
38
- .username {
39
- display: flex;
40
- align-items: center;
41
- gap: 8px;
42
- font-size: 14px;
43
-
44
- mat-icon {
45
- font-size: 20px;
46
- width: 20px;
47
- height: 20px;
48
- vertical-align: middle;
49
- }
50
- }
51
-
52
- .activity-button {
53
- position: relative;
54
-
55
- mat-icon {
56
- vertical-align: middle;
57
- }
58
- }
59
- }
60
- }
61
-
62
- nav {
63
- background-color: white;
64
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
65
- position: sticky;
66
- top: 64px;
67
- z-index: 99;
68
-
69
- .mat-mdc-tab-nav-bar {
70
- padding: 0 16px;
71
- }
72
-
73
- .mat-mdc-tab-link {
74
- height: 48px;
75
- opacity: 0.7;
76
- font-weight: 500;
77
-
78
- &.mdc-tab--active {
79
- opacity: 1;
80
- }
81
-
82
- .tab-content {
83
- display: flex;
84
- align-items: center;
85
- gap: 8px;
86
-
87
- mat-icon {
88
- font-size: 20px;
89
- width: 20px;
90
- height: 20px;
91
- vertical-align: middle;
92
- }
93
-
94
- span {
95
- vertical-align: middle;
96
- }
97
- }
98
- }
99
- }
100
-
101
- .main-content {
102
- flex: 1;
103
- overflow: auto;
104
- background-color: #fafafa;
105
- }
106
- }
107
-
108
- // Material overrides
109
- ::ng-deep {
110
- .mat-toolbar-single-row {
111
- height: 64px;
112
- }
113
-
114
- .mat-mdc-menu-panel {
115
- margin-top: 8px;
116
- }
117
-
118
- .mat-mdc-tab-header {
119
- border-bottom: none;
120
- }
121
-
122
- .mat-mdc-tab-labels {
123
- gap: 8px;
124
- }
125
- }
126
-
127
- // Responsive
128
- @media (max-width: 768px) {
129
- .main-layout {
130
- nav {
131
- .mat-mdc-tab-nav-bar {
132
- padding: 0 8px;
133
- }
134
-
135
- .mat-mdc-tab-link {
136
- min-width: auto;
137
- padding: 0 12px;
138
-
139
- .tab-content span {
140
- display: none;
141
- }
142
- }
143
- }
144
- }
145
  }
 
1
+ .main-layout {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100vh;
5
+ background-color: #fafafa;
6
+
7
+ .header-toolbar {
8
+ position: sticky;
9
+ top: 0;
10
+ z-index: 100;
11
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
12
+
13
+ mat-toolbar-row {
14
+ padding: 0 16px;
15
+ }
16
+
17
+ .logo {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: 8px;
21
+ font-size: 20px;
22
+ font-weight: 500;
23
+
24
+ mat-icon {
25
+ vertical-align: middle;
26
+ }
27
+ }
28
+
29
+ .spacer {
30
+ flex: 1;
31
+ }
32
+
33
+ .header-actions {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 16px;
37
+
38
+ .username {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 8px;
42
+ font-size: 14px;
43
+
44
+ mat-icon {
45
+ font-size: 20px;
46
+ width: 20px;
47
+ height: 20px;
48
+ vertical-align: middle;
49
+ }
50
+ }
51
+
52
+ .activity-button {
53
+ position: relative;
54
+
55
+ mat-icon {
56
+ vertical-align: middle;
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ nav {
63
+ background-color: white;
64
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
65
+ position: sticky;
66
+ top: 64px;
67
+ z-index: 99;
68
+
69
+ .mat-mdc-tab-nav-bar {
70
+ padding: 0 16px;
71
+ }
72
+
73
+ .mat-mdc-tab-link {
74
+ height: 48px;
75
+ opacity: 0.7;
76
+ font-weight: 500;
77
+
78
+ &.mdc-tab--active {
79
+ opacity: 1;
80
+ }
81
+
82
+ .tab-content {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 8px;
86
+
87
+ mat-icon {
88
+ font-size: 20px;
89
+ width: 20px;
90
+ height: 20px;
91
+ vertical-align: middle;
92
+ }
93
+
94
+ span {
95
+ vertical-align: middle;
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ .main-content {
102
+ flex: 1;
103
+ overflow: auto;
104
+ background-color: #fafafa;
105
+ }
106
+ }
107
+
108
+ // Material overrides
109
+ ::ng-deep {
110
+ .mat-toolbar-single-row {
111
+ height: 64px;
112
+ }
113
+
114
+ .mat-mdc-menu-panel {
115
+ margin-top: 8px;
116
+ }
117
+
118
+ .mat-mdc-tab-header {
119
+ border-bottom: none;
120
+ }
121
+
122
+ .mat-mdc-tab-labels {
123
+ gap: 8px;
124
+ }
125
+ }
126
+
127
+ // Responsive
128
+ @media (max-width: 768px) {
129
+ .main-layout {
130
+ nav {
131
+ .mat-mdc-tab-nav-bar {
132
+ padding: 0 8px;
133
+ }
134
+
135
+ .mat-mdc-tab-link {
136
+ min-width: auto;
137
+ padding: 0 12px;
138
+
139
+ .tab-content span {
140
+ display: none;
141
+ }
142
+ }
143
+ }
144
+ }
145
  }
flare-ui/src/app/components/main/main.component.ts CHANGED
@@ -1,302 +1,302 @@
1
- import { Component, inject, OnInit, OnDestroy } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
4
- import { MatToolbarModule } from '@angular/material/toolbar';
5
- import { MatTabsModule } from '@angular/material/tabs';
6
- import { MatButtonModule } from '@angular/material/button';
7
- import { MatIconModule } from '@angular/material/icon';
8
- import { MatMenuModule } from '@angular/material/menu';
9
- import { MatBadgeModule } from '@angular/material/badge';
10
- import { MatDividerModule } from '@angular/material/divider';
11
- import { Subject, takeUntil } from 'rxjs';
12
- import { AuthService } from '../../services/auth.service';
13
- import { ActivityLogComponent } from '../activity-log/activity-log.component';
14
- import { ApiService } from '../../services/api.service';
15
- import { EnvironmentService } from '../../services/environment.service';
16
-
17
- @Component({
18
- selector: 'app-main',
19
- standalone: true,
20
- imports: [
21
- CommonModule,
22
- RouterLink,
23
- RouterLinkActive,
24
- RouterOutlet,
25
- MatToolbarModule,
26
- MatTabsModule,
27
- MatButtonModule,
28
- MatIconModule,
29
- MatMenuModule,
30
- MatBadgeModule,
31
- MatDividerModule,
32
- ActivityLogComponent
33
- ],
34
- template: `
35
- <div class="main-layout">
36
- <mat-toolbar color="primary" class="header-toolbar">
37
- <mat-toolbar-row>
38
- <span class="logo">
39
- <mat-icon>dashboard</mat-icon>
40
- Flare Administration
41
- </span>
42
-
43
- <span class="spacer"></span>
44
-
45
- <div class="header-actions">
46
- <span class="username">
47
- <mat-icon>person</mat-icon>
48
- {{ username }}
49
- </span>
50
-
51
- <button mat-icon-button
52
- (click)="toggleActivityLog()"
53
- matTooltip="Activity Log">
54
- <mat-icon>notifications</mat-icon>
55
- </button>
56
-
57
- @if (showActivityLog) {
58
- <div class="activity-log-wrapper" (click)="$event.stopPropagation()">
59
- <app-activity-log (close)="toggleActivityLog()"></app-activity-log>
60
- </div>
61
- }
62
-
63
- <button mat-icon-button [matMenuTriggerFor]="userMenu" matTooltip="User Menu">
64
- <mat-icon>account_circle</mat-icon>
65
- </button>
66
-
67
- <mat-menu #userMenu="matMenu">
68
- <button mat-menu-item routerLink="/user-info">
69
- <mat-icon>settings</mat-icon>
70
- <span>User Settings</span>
71
- </button>
72
- <mat-divider></mat-divider>
73
- <button mat-menu-item (click)="logout()">
74
- <mat-icon>exit_to_app</mat-icon>
75
- <span>Logout</span>
76
- </button>
77
- </mat-menu>
78
- </div>
79
- </mat-toolbar-row>
80
- </mat-toolbar>
81
-
82
- <nav mat-tab-nav-bar class="nav-tabs" #navBar="matTabNavBar" [tabPanel]="tabPanel">
83
- <a mat-tab-link
84
- routerLink="/user-info"
85
- routerLinkActive #rla1="routerLinkActive"
86
- [active]="rla1.isActive">
87
- <mat-icon>person</mat-icon>
88
- User Info
89
- </a>
90
-
91
- <a mat-tab-link
92
- routerLink="/environment"
93
- routerLinkActive #rla2="routerLinkActive"
94
- [active]="rla2.isActive">
95
- <mat-icon>settings</mat-icon>
96
- Environment
97
- </a>
98
-
99
- <a mat-tab-link
100
- routerLink="/apis"
101
- routerLinkActive #rla3="routerLinkActive"
102
- [active]="rla3.isActive">
103
- <mat-icon>api</mat-icon>
104
- APIs
105
- </a>
106
-
107
- <a mat-tab-link
108
- routerLink="/projects"
109
- routerLinkActive #rla4="routerLinkActive"
110
- [active]="rla4.isActive">
111
- <mat-icon>folder_special</mat-icon>
112
- Projects
113
- </a>
114
-
115
- <a mat-tab-link
116
- routerLink="/chat"
117
- routerLinkActive #rla5="routerLinkActive"
118
- [active]="rla5.isActive">
119
- <mat-icon>chat_bubble_outline</mat-icon>
120
- Chat
121
- </a>
122
-
123
- @if (!isGPTMode) {
124
- <a mat-tab-link
125
- routerLink="/spark"
126
- routerLinkActive #rla6="routerLinkActive"
127
- [active]="rla6.isActive">
128
- <mat-icon>flash_on</mat-icon>
129
- Spark Integration
130
- </a>
131
- }
132
-
133
- <a mat-tab-link
134
- routerLink="/test"
135
- routerLinkActive #rla7="routerLinkActive"
136
- [active]="rla7.isActive">
137
- <mat-icon>bug_report</mat-icon>
138
- Test
139
- </a>
140
- </nav>
141
-
142
- <mat-tab-nav-panel #tabPanel>
143
- <main class="content">
144
- <router-outlet></router-outlet>
145
- </main>
146
- </mat-tab-nav-panel>
147
-
148
- </div>
149
- `,
150
- styles: [`
151
- .main-layout {
152
- height: 100vh;
153
- display: flex;
154
- flex-direction: column;
155
- background-color: #fafafa;
156
- }
157
-
158
- .header-toolbar {
159
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
160
- z-index: 100;
161
- position: relative;
162
-
163
- .logo {
164
- display: flex;
165
- align-items: center;
166
- gap: 8px;
167
- font-size: 20px;
168
- font-weight: 500;
169
-
170
- mat-icon {
171
- font-size: 28px;
172
- width: 28px;
173
- height: 28px;
174
- }
175
- }
176
-
177
- .spacer {
178
- flex: 1 1 auto;
179
- }
180
-
181
- .header-actions {
182
- display: flex;
183
- align-items: center;
184
- gap: 8px;
185
- position: relative;
186
-
187
- .username {
188
- display: flex;
189
- align-items: center;
190
- gap: 4px;
191
- margin-right: 16px;
192
-
193
- mat-icon {
194
- font-size: 20px;
195
- width: 20px;
196
- height: 20px;
197
- }
198
- }
199
-
200
- .activity-log-wrapper {
201
- position: absolute;
202
- top: 56px;
203
- right: 0;
204
- z-index: 1000;
205
- }
206
- }
207
- }
208
-
209
- .nav-tabs {
210
- background-color: white;
211
- box-shadow: 0 2px 4px rgba(0,0,0,0.08);
212
-
213
- ::ng-deep {
214
- .mat-mdc-tab-link {
215
- min-width: 120px;
216
- opacity: 0.8;
217
-
218
- mat-icon {
219
- margin-right: 8px;
220
- }
221
-
222
- &.mdc-tab--active {
223
- opacity: 1;
224
- }
225
- }
226
- }
227
- }
228
-
229
- .content {
230
- flex: 1;
231
- overflow-y: auto;
232
- padding: 24px;
233
- }
234
- `]
235
- })
236
- export class MainComponent implements OnInit, OnDestroy {
237
- private authService = inject(AuthService);
238
- private apiService = inject(ApiService);
239
- private environmentService = inject(EnvironmentService);
240
-
241
- username = this.authService.getUsername() || '';
242
- showActivityLog = false;
243
- isGPTMode = false;
244
-
245
- // Memory leak prevention
246
- private destroyed$ = new Subject<void>();
247
-
248
- ngOnInit() {
249
- // Environment değişikliklerini dinle
250
- this.environmentService.environment$
251
- .pipe(takeUntil(this.destroyed$))
252
- .subscribe(env => {
253
- if (env) {
254
- // work_mode yerine llm_provider.name kullan
255
- this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false;
256
- this.updateProviderInfo(env);
257
- }
258
- });
259
-
260
- // Environment bilgisini al
261
- this.loadEnvironment();
262
- }
263
-
264
- ngOnDestroy() {
265
- this.destroyed$.next();
266
- this.destroyed$.complete();
267
- }
268
-
269
- loadEnvironment() {
270
- this.apiService.getEnvironment()
271
- .pipe(takeUntil(this.destroyed$))
272
- .subscribe({
273
- next: (env) => {
274
- this.environmentService.updateEnvironment(env);
275
- this.updateProviderInfo(env);
276
- },
277
- error: (error) => {
278
- console.error('Failed to load environment:', error);
279
- // Show snackbar if needed
280
- }
281
- });
282
- }
283
-
284
- updateProviderInfo(env: any) {
285
- // Update TTS/STT availability - zaten doğru
286
- this.environmentService.setTTSEnabled(!!env.tts_provider?.name && env.tts_provider.name !== 'no_tts');
287
- this.environmentService.setSTTEnabled(!!env.stt_provider?.name && env.stt_provider.name !== 'no_stt');
288
-
289
- // GPT mode'u da burada güncelleyebiliriz
290
- this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false;
291
- }
292
-
293
- logout() {
294
- // Cleanup before logout
295
- this.destroyed$.next();
296
- this.authService.logout();
297
- }
298
-
299
- toggleActivityLog() {
300
- this.showActivityLog = !this.showActivityLog;
301
- }
302
  }
 
1
+ import { Component, inject, OnInit, OnDestroy } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
4
+ import { MatToolbarModule } from '@angular/material/toolbar';
5
+ import { MatTabsModule } from '@angular/material/tabs';
6
+ import { MatButtonModule } from '@angular/material/button';
7
+ import { MatIconModule } from '@angular/material/icon';
8
+ import { MatMenuModule } from '@angular/material/menu';
9
+ import { MatBadgeModule } from '@angular/material/badge';
10
+ import { MatDividerModule } from '@angular/material/divider';
11
+ import { Subject, takeUntil } from 'rxjs';
12
+ import { AuthService } from '../../services/auth.service';
13
+ import { ActivityLogComponent } from '../activity-log/activity-log.component';
14
+ import { ApiService } from '../../services/api.service';
15
+ import { EnvironmentService } from '../../services/environment.service';
16
+
17
+ @Component({
18
+ selector: 'app-main',
19
+ standalone: true,
20
+ imports: [
21
+ CommonModule,
22
+ RouterLink,
23
+ RouterLinkActive,
24
+ RouterOutlet,
25
+ MatToolbarModule,
26
+ MatTabsModule,
27
+ MatButtonModule,
28
+ MatIconModule,
29
+ MatMenuModule,
30
+ MatBadgeModule,
31
+ MatDividerModule,
32
+ ActivityLogComponent
33
+ ],
34
+ template: `
35
+ <div class="main-layout">
36
+ <mat-toolbar color="primary" class="header-toolbar">
37
+ <mat-toolbar-row>
38
+ <span class="logo">
39
+ <mat-icon>dashboard</mat-icon>
40
+ Flare Administration
41
+ </span>
42
+
43
+ <span class="spacer"></span>
44
+
45
+ <div class="header-actions">
46
+ <span class="username">
47
+ <mat-icon>person</mat-icon>
48
+ {{ username }}
49
+ </span>
50
+
51
+ <button mat-icon-button
52
+ (click)="toggleActivityLog()"
53
+ matTooltip="Activity Log">
54
+ <mat-icon>notifications</mat-icon>
55
+ </button>
56
+
57
+ @if (showActivityLog) {
58
+ <div class="activity-log-wrapper" (click)="$event.stopPropagation()">
59
+ <app-activity-log (close)="toggleActivityLog()"></app-activity-log>
60
+ </div>
61
+ }
62
+
63
+ <button mat-icon-button [matMenuTriggerFor]="userMenu" matTooltip="User Menu">
64
+ <mat-icon>account_circle</mat-icon>
65
+ </button>
66
+
67
+ <mat-menu #userMenu="matMenu">
68
+ <button mat-menu-item routerLink="/user-info">
69
+ <mat-icon>settings</mat-icon>
70
+ <span>User Settings</span>
71
+ </button>
72
+ <mat-divider></mat-divider>
73
+ <button mat-menu-item (click)="logout()">
74
+ <mat-icon>exit_to_app</mat-icon>
75
+ <span>Logout</span>
76
+ </button>
77
+ </mat-menu>
78
+ </div>
79
+ </mat-toolbar-row>
80
+ </mat-toolbar>
81
+
82
+ <nav mat-tab-nav-bar class="nav-tabs" #navBar="matTabNavBar" [tabPanel]="tabPanel">
83
+ <a mat-tab-link
84
+ routerLink="/user-info"
85
+ routerLinkActive #rla1="routerLinkActive"
86
+ [active]="rla1.isActive">
87
+ <mat-icon>person</mat-icon>
88
+ User Info
89
+ </a>
90
+
91
+ <a mat-tab-link
92
+ routerLink="/environment"
93
+ routerLinkActive #rla2="routerLinkActive"
94
+ [active]="rla2.isActive">
95
+ <mat-icon>settings</mat-icon>
96
+ Environment
97
+ </a>
98
+
99
+ <a mat-tab-link
100
+ routerLink="/apis"
101
+ routerLinkActive #rla3="routerLinkActive"
102
+ [active]="rla3.isActive">
103
+ <mat-icon>api</mat-icon>
104
+ APIs
105
+ </a>
106
+
107
+ <a mat-tab-link
108
+ routerLink="/projects"
109
+ routerLinkActive #rla4="routerLinkActive"
110
+ [active]="rla4.isActive">
111
+ <mat-icon>folder_special</mat-icon>
112
+ Projects
113
+ </a>
114
+
115
+ <a mat-tab-link
116
+ routerLink="/chat"
117
+ routerLinkActive #rla5="routerLinkActive"
118
+ [active]="rla5.isActive">
119
+ <mat-icon>chat_bubble_outline</mat-icon>
120
+ Chat
121
+ </a>
122
+
123
+ @if (!isGPTMode) {
124
+ <a mat-tab-link
125
+ routerLink="/spark"
126
+ routerLinkActive #rla6="routerLinkActive"
127
+ [active]="rla6.isActive">
128
+ <mat-icon>flash_on</mat-icon>
129
+ Spark Integration
130
+ </a>
131
+ }
132
+
133
+ <a mat-tab-link
134
+ routerLink="/test"
135
+ routerLinkActive #rla7="routerLinkActive"
136
+ [active]="rla7.isActive">
137
+ <mat-icon>bug_report</mat-icon>
138
+ Test
139
+ </a>
140
+ </nav>
141
+
142
+ <mat-tab-nav-panel #tabPanel>
143
+ <main class="content">
144
+ <router-outlet></router-outlet>
145
+ </main>
146
+ </mat-tab-nav-panel>
147
+
148
+ </div>
149
+ `,
150
+ styles: [`
151
+ .main-layout {
152
+ height: 100vh;
153
+ display: flex;
154
+ flex-direction: column;
155
+ background-color: #fafafa;
156
+ }
157
+
158
+ .header-toolbar {
159
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
160
+ z-index: 100;
161
+ position: relative;
162
+
163
+ .logo {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ font-size: 20px;
168
+ font-weight: 500;
169
+
170
+ mat-icon {
171
+ font-size: 28px;
172
+ width: 28px;
173
+ height: 28px;
174
+ }
175
+ }
176
+
177
+ .spacer {
178
+ flex: 1 1 auto;
179
+ }
180
+
181
+ .header-actions {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 8px;
185
+ position: relative;
186
+
187
+ .username {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 4px;
191
+ margin-right: 16px;
192
+
193
+ mat-icon {
194
+ font-size: 20px;
195
+ width: 20px;
196
+ height: 20px;
197
+ }
198
+ }
199
+
200
+ .activity-log-wrapper {
201
+ position: absolute;
202
+ top: 56px;
203
+ right: 0;
204
+ z-index: 1000;
205
+ }
206
+ }
207
+ }
208
+
209
+ .nav-tabs {
210
+ background-color: white;
211
+ box-shadow: 0 2px 4px rgba(0,0,0,0.08);
212
+
213
+ ::ng-deep {
214
+ .mat-mdc-tab-link {
215
+ min-width: 120px;
216
+ opacity: 0.8;
217
+
218
+ mat-icon {
219
+ margin-right: 8px;
220
+ }
221
+
222
+ &.mdc-tab--active {
223
+ opacity: 1;
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ .content {
230
+ flex: 1;
231
+ overflow-y: auto;
232
+ padding: 24px;
233
+ }
234
+ `]
235
+ })
236
+ export class MainComponent implements OnInit, OnDestroy {
237
+ private authService = inject(AuthService);
238
+ private apiService = inject(ApiService);
239
+ private environmentService = inject(EnvironmentService);
240
+
241
+ username = this.authService.getUsername() || '';
242
+ showActivityLog = false;
243
+ isGPTMode = false;
244
+
245
+ // Memory leak prevention
246
+ private destroyed$ = new Subject<void>();
247
+
248
+ ngOnInit() {
249
+ // Environment değişikliklerini dinle
250
+ this.environmentService.environment$
251
+ .pipe(takeUntil(this.destroyed$))
252
+ .subscribe(env => {
253
+ if (env) {
254
+ // work_mode yerine llm_provider.name kullan
255
+ this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false;
256
+ this.updateProviderInfo(env);
257
+ }
258
+ });
259
+
260
+ // Environment bilgisini al
261
+ this.loadEnvironment();
262
+ }
263
+
264
+ ngOnDestroy() {
265
+ this.destroyed$.next();
266
+ this.destroyed$.complete();
267
+ }
268
+
269
+ loadEnvironment() {
270
+ this.apiService.getEnvironment()
271
+ .pipe(takeUntil(this.destroyed$))
272
+ .subscribe({
273
+ next: (env) => {
274
+ this.environmentService.updateEnvironment(env);
275
+ this.updateProviderInfo(env);
276
+ },
277
+ error: (error) => {
278
+ console.error('Failed to load environment:', error);
279
+ // Show snackbar if needed
280
+ }
281
+ });
282
+ }
283
+
284
+ updateProviderInfo(env: any) {
285
+ // Update TTS/STT availability - zaten doğru
286
+ this.environmentService.setTTSEnabled(!!env.tts_provider?.name && env.tts_provider.name !== 'no_tts');
287
+ this.environmentService.setSTTEnabled(!!env.stt_provider?.name && env.stt_provider.name !== 'no_stt');
288
+
289
+ // GPT mode'u da burada güncelleyebiliriz
290
+ this.isGPTMode = env.llm_provider?.name?.startsWith('gpt4o') || false;
291
+ }
292
+
293
+ logout() {
294
+ // Cleanup before logout
295
+ this.destroyed$.next();
296
+ this.authService.logout();
297
+ }
298
+
299
+ toggleActivityLog() {
300
+ this.showActivityLog = !this.showActivityLog;
301
+ }
302
  }
flare-ui/src/app/components/projects/projects.component.html CHANGED
@@ -1,185 +1,185 @@
1
- <div class="projects-container">
2
- <div class="toolbar">
3
- <div class="toolbar-left">
4
- <h2>Projects</h2>
5
- </div>
6
- <div class="toolbar-right">
7
- <button mat-raised-button color="primary" (click)="createProject()">
8
- <mat-icon>add</mat-icon>
9
- New Project
10
- </button>
11
- <button mat-button (click)="importProject()">
12
- <mat-icon>upload</mat-icon>
13
- Import Project
14
- </button>
15
- <mat-form-field appearance="outline" class="search-field">
16
- <mat-label>Search projects</mat-label>
17
- <input matInput [(ngModel)]="searchTerm" (input)="filterProjects()">
18
- <mat-icon matSuffix>search</mat-icon>
19
- </mat-form-field>
20
- <mat-checkbox [(ngModel)]="showDeleted" (change)="loadProjects()">
21
- Display Deleted
22
- </mat-checkbox>
23
- <mat-button-toggle-group [(ngModel)]="viewMode" class="view-toggle">
24
- <mat-button-toggle value="card">
25
- <mat-icon>view_module</mat-icon>
26
- </mat-button-toggle>
27
- <mat-button-toggle value="list">
28
- <mat-icon>view_list</mat-icon>
29
- </mat-button-toggle>
30
- </mat-button-toggle-group>
31
- </div>
32
- </div>
33
-
34
- <mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
35
-
36
- <div class="content" *ngIf="!loading">
37
- <!-- Empty State -->
38
- <div class="no-data" *ngIf="filteredProjects.length === 0">
39
- <mat-icon>folder_open</mat-icon>
40
- <p>No projects found.</p>
41
- <button mat-raised-button color="primary" (click)="createProject()">
42
- Create your first project
43
- </button>
44
- </div>
45
-
46
- <!-- Card View -->
47
- <div class="projects-grid" *ngIf="viewMode === 'card' && filteredProjects.length > 0">
48
- <mat-card *ngFor="let project of filteredProjects; trackBy: trackByProjectId"
49
- class="project-card"
50
- [class.disabled]="!project.enabled"
51
- [class.deleted]="project.deleted">
52
- <mat-card-header>
53
- <div mat-card-avatar class="project-icon">
54
- <mat-icon>{{ project.icon || 'flight_takeoff' }}</mat-icon>
55
- </div>
56
- <mat-card-title>{{ project.name }}</mat-card-title>
57
- <mat-card-subtitle>{{ project.caption || 'No description' }}</mat-card-subtitle>
58
- </mat-card-header>
59
-
60
- <mat-card-content>
61
- <div class="project-info">
62
- <div class="info-item">
63
- <mat-icon>layers</mat-icon>
64
- <span>{{ project.versions.length || 0 }} versions ({{ getPublishedCount(project) }} published)</span>
65
- </div>
66
- <div class="info-item">
67
- <mat-icon>{{ project.enabled ? 'check_circle' : 'cancel' }}</mat-icon>
68
- <span>{{ project.enabled ? 'Enabled' : 'Disabled' }}</span>
69
- </div>
70
- <div class="info-item">
71
- <mat-icon>update</mat-icon>
72
- <span>{{ getRelativeTime(project.last_update_date) }}</span>
73
- </div>
74
- </div>
75
- </mat-card-content>
76
-
77
- <mat-card-actions>
78
- <button mat-button (click)="editProject(project)">EDIT</button>
79
- <button mat-button (click)="manageVersions(project)">VERSIONS</button>
80
- <button mat-button (click)="exportProject(project)">EXPORT</button>
81
- <button mat-button (click)="toggleProject(project)" color="warn">
82
- {{ project.enabled ? 'DISABLE' : 'ENABLE' }}
83
- </button>
84
- </mat-card-actions>
85
- </mat-card>
86
- </div>
87
-
88
- <!-- Table View -->
89
- <div class="table-container" *ngIf="viewMode === 'list' && filteredProjects.length > 0">
90
- <table mat-table [dataSource]="filteredProjects" class="projects-table">
91
-
92
- <!-- Name Column -->
93
- <ng-container matColumnDef="name">
94
- <th mat-header-cell *matHeaderCellDef>Name</th>
95
- <td mat-cell *matCellDef="let project">
96
- <div class="name-with-icon">
97
- <mat-icon class="project-table-icon">{{ project.icon || 'flight_takeoff' }}</mat-icon>
98
- {{ project.name }}
99
- <mat-icon class="deleted-icon" *ngIf="project.deleted">delete</mat-icon>
100
- </div>
101
- </td>
102
- </ng-container>
103
-
104
- <!-- Caption Column -->
105
- <ng-container matColumnDef="caption">
106
- <th mat-header-cell *matHeaderCellDef>Caption</th>
107
- <td mat-cell *matCellDef="let project">{{ project.caption || '-' }}</td>
108
- </ng-container>
109
-
110
- <!-- Versions Column -->
111
- <ng-container matColumnDef="versions">
112
- <th mat-header-cell *matHeaderCellDef>Versions</th>
113
- <td mat-cell *matCellDef="let project">
114
- <mat-chip-listbox>
115
- <mat-chip-option>{{ project.versions?.length || 0 }} total</mat-chip-option>
116
- <mat-chip-option selected>{{ getPublishedCount(project) }} published</mat-chip-option>
117
- </mat-chip-listbox>
118
- </td>
119
- </ng-container>
120
-
121
- <!-- Status Column -->
122
- <ng-container matColumnDef="status">
123
- <th mat-header-cell *matHeaderCellDef>Status</th>
124
- <td mat-cell *matCellDef="let project">
125
- <mat-icon *ngIf="project.enabled" color="primary">check_circle</mat-icon>
126
- <mat-icon *ngIf="!project.enabled" color="warn">cancel</mat-icon>
127
- </td>
128
- </ng-container>
129
-
130
- <!-- Last Update Column -->
131
- <ng-container matColumnDef="lastUpdate">
132
- <th mat-header-cell *matHeaderCellDef>Last Update</th>
133
- <td mat-cell *matCellDef="let project">{{ getRelativeTime(project.last_update_date) }}</td>
134
- </ng-container>
135
-
136
- <!-- Actions Column -->
137
- <ng-container matColumnDef="actions">
138
- <th mat-header-cell *matHeaderCellDef>Actions</th>
139
- <td mat-cell *matCellDef="let project">
140
- <button mat-icon-button [matMenuTriggerFor]="menu" aria-label="Project actions">
141
- <mat-icon>more_vert</mat-icon>
142
- </button>
143
- <mat-menu #menu="matMenu">
144
- <button mat-menu-item (click)="editProject(project)">
145
- <mat-icon>edit</mat-icon>
146
- <span>Edit</span>
147
- </button>
148
- <button mat-menu-item (click)="manageVersions(project)">
149
- <mat-icon>layers</mat-icon>
150
- <span>Manage Versions</span>
151
- </button>
152
- <button mat-menu-item (click)="exportProject(project)">
153
- <mat-icon>download</mat-icon>
154
- <span>Export</span>
155
- </button>
156
- <button mat-menu-item (click)="toggleProject(project)">
157
- <mat-icon>{{ project.enabled ? 'block' : 'check_circle' }}</mat-icon>
158
- <span>{{ project.enabled ? 'Disable' : 'Enable' }}</span>
159
- </button>
160
- <mat-divider *ngIf="!project.deleted"></mat-divider>
161
- <button mat-menu-item (click)="deleteProject(project)" *ngIf="!project.deleted">
162
- <mat-icon color="warn">delete</mat-icon>
163
- <span>Delete</span>
164
- </button>
165
- </mat-menu>
166
- </td>
167
- </ng-container>
168
-
169
- <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
170
- <tr mat-row *matRowDef="let row; columns: displayedColumns;"
171
- [class.deleted-row]="row.deleted"></tr>
172
- </table>
173
- </div>
174
- </div>
175
-
176
- <!-- Message Snackbar -->
177
- <div class="message-container" *ngIf="message">
178
- <mat-card [class.success]="!isError" [class.error]="isError">
179
- <mat-card-content>
180
- <mat-icon>{{ isError ? 'error' : 'check_circle' }}</mat-icon>
181
- {{ message }}
182
- </mat-card-content>
183
- </mat-card>
184
- </div>
185
  </div>
 
1
+ <div class="projects-container">
2
+ <div class="toolbar">
3
+ <div class="toolbar-left">
4
+ <h2>Projects</h2>
5
+ </div>
6
+ <div class="toolbar-right">
7
+ <button mat-raised-button color="primary" (click)="createProject()">
8
+ <mat-icon>add</mat-icon>
9
+ New Project
10
+ </button>
11
+ <button mat-button (click)="importProject()">
12
+ <mat-icon>upload</mat-icon>
13
+ Import Project
14
+ </button>
15
+ <mat-form-field appearance="outline" class="search-field">
16
+ <mat-label>Search projects</mat-label>
17
+ <input matInput [(ngModel)]="searchTerm" (input)="filterProjects()">
18
+ <mat-icon matSuffix>search</mat-icon>
19
+ </mat-form-field>
20
+ <mat-checkbox [(ngModel)]="showDeleted" (change)="loadProjects()">
21
+ Display Deleted
22
+ </mat-checkbox>
23
+ <mat-button-toggle-group [(ngModel)]="viewMode" class="view-toggle">
24
+ <mat-button-toggle value="card">
25
+ <mat-icon>view_module</mat-icon>
26
+ </mat-button-toggle>
27
+ <mat-button-toggle value="list">
28
+ <mat-icon>view_list</mat-icon>
29
+ </mat-button-toggle>
30
+ </mat-button-toggle-group>
31
+ </div>
32
+ </div>
33
+
34
+ <mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
35
+
36
+ <div class="content" *ngIf="!loading">
37
+ <!-- Empty State -->
38
+ <div class="no-data" *ngIf="filteredProjects.length === 0">
39
+ <mat-icon>folder_open</mat-icon>
40
+ <p>No projects found.</p>
41
+ <button mat-raised-button color="primary" (click)="createProject()">
42
+ Create your first project
43
+ </button>
44
+ </div>
45
+
46
+ <!-- Card View -->
47
+ <div class="projects-grid" *ngIf="viewMode === 'card' && filteredProjects.length > 0">
48
+ <mat-card *ngFor="let project of filteredProjects; trackBy: trackByProjectId"
49
+ class="project-card"
50
+ [class.disabled]="!project.enabled"
51
+ [class.deleted]="project.deleted">
52
+ <mat-card-header>
53
+ <div mat-card-avatar class="project-icon">
54
+ <mat-icon>{{ project.icon || 'flight_takeoff' }}</mat-icon>
55
+ </div>
56
+ <mat-card-title>{{ project.name }}</mat-card-title>
57
+ <mat-card-subtitle>{{ project.caption || 'No description' }}</mat-card-subtitle>
58
+ </mat-card-header>
59
+
60
+ <mat-card-content>
61
+ <div class="project-info">
62
+ <div class="info-item">
63
+ <mat-icon>layers</mat-icon>
64
+ <span>{{ project.versions.length || 0 }} versions ({{ getPublishedCount(project) }} published)</span>
65
+ </div>
66
+ <div class="info-item">
67
+ <mat-icon>{{ project.enabled ? 'check_circle' : 'cancel' }}</mat-icon>
68
+ <span>{{ project.enabled ? 'Enabled' : 'Disabled' }}</span>
69
+ </div>
70
+ <div class="info-item">
71
+ <mat-icon>update</mat-icon>
72
+ <span>{{ getRelativeTime(project.last_update_date) }}</span>
73
+ </div>
74
+ </div>
75
+ </mat-card-content>
76
+
77
+ <mat-card-actions>
78
+ <button mat-button (click)="editProject(project)">EDIT</button>
79
+ <button mat-button (click)="manageVersions(project)">VERSIONS</button>
80
+ <button mat-button (click)="exportProject(project)">EXPORT</button>
81
+ <button mat-button (click)="toggleProject(project)" color="warn">
82
+ {{ project.enabled ? 'DISABLE' : 'ENABLE' }}
83
+ </button>
84
+ </mat-card-actions>
85
+ </mat-card>
86
+ </div>
87
+
88
+ <!-- Table View -->
89
+ <div class="table-container" *ngIf="viewMode === 'list' && filteredProjects.length > 0">
90
+ <table mat-table [dataSource]="filteredProjects" class="projects-table">
91
+
92
+ <!-- Name Column -->
93
+ <ng-container matColumnDef="name">
94
+ <th mat-header-cell *matHeaderCellDef>Name</th>
95
+ <td mat-cell *matCellDef="let project">
96
+ <div class="name-with-icon">
97
+ <mat-icon class="project-table-icon">{{ project.icon || 'flight_takeoff' }}</mat-icon>
98
+ {{ project.name }}
99
+ <mat-icon class="deleted-icon" *ngIf="project.deleted">delete</mat-icon>
100
+ </div>
101
+ </td>
102
+ </ng-container>
103
+
104
+ <!-- Caption Column -->
105
+ <ng-container matColumnDef="caption">
106
+ <th mat-header-cell *matHeaderCellDef>Caption</th>
107
+ <td mat-cell *matCellDef="let project">{{ project.caption || '-' }}</td>
108
+ </ng-container>
109
+
110
+ <!-- Versions Column -->
111
+ <ng-container matColumnDef="versions">
112
+ <th mat-header-cell *matHeaderCellDef>Versions</th>
113
+ <td mat-cell *matCellDef="let project">
114
+ <mat-chip-listbox>
115
+ <mat-chip-option>{{ project.versions?.length || 0 }} total</mat-chip-option>
116
+ <mat-chip-option selected>{{ getPublishedCount(project) }} published</mat-chip-option>
117
+ </mat-chip-listbox>
118
+ </td>
119
+ </ng-container>
120
+
121
+ <!-- Status Column -->
122
+ <ng-container matColumnDef="status">
123
+ <th mat-header-cell *matHeaderCellDef>Status</th>
124
+ <td mat-cell *matCellDef="let project">
125
+ <mat-icon *ngIf="project.enabled" color="primary">check_circle</mat-icon>
126
+ <mat-icon *ngIf="!project.enabled" color="warn">cancel</mat-icon>
127
+ </td>
128
+ </ng-container>
129
+
130
+ <!-- Last Update Column -->
131
+ <ng-container matColumnDef="lastUpdate">
132
+ <th mat-header-cell *matHeaderCellDef>Last Update</th>
133
+ <td mat-cell *matCellDef="let project">{{ getRelativeTime(project.last_update_date) }}</td>
134
+ </ng-container>
135
+
136
+ <!-- Actions Column -->
137
+ <ng-container matColumnDef="actions">
138
+ <th mat-header-cell *matHeaderCellDef>Actions</th>
139
+ <td mat-cell *matCellDef="let project">
140
+ <button mat-icon-button [matMenuTriggerFor]="menu" aria-label="Project actions">
141
+ <mat-icon>more_vert</mat-icon>
142
+ </button>
143
+ <mat-menu #menu="matMenu">
144
+ <button mat-menu-item (click)="editProject(project)">
145
+ <mat-icon>edit</mat-icon>
146
+ <span>Edit</span>
147
+ </button>
148
+ <button mat-menu-item (click)="manageVersions(project)">
149
+ <mat-icon>layers</mat-icon>
150
+ <span>Manage Versions</span>
151
+ </button>
152
+ <button mat-menu-item (click)="exportProject(project)">
153
+ <mat-icon>download</mat-icon>
154
+ <span>Export</span>
155
+ </button>
156
+ <button mat-menu-item (click)="toggleProject(project)">
157
+ <mat-icon>{{ project.enabled ? 'block' : 'check_circle' }}</mat-icon>
158
+ <span>{{ project.enabled ? 'Disable' : 'Enable' }}</span>
159
+ </button>
160
+ <mat-divider *ngIf="!project.deleted"></mat-divider>
161
+ <button mat-menu-item (click)="deleteProject(project)" *ngIf="!project.deleted">
162
+ <mat-icon color="warn">delete</mat-icon>
163
+ <span>Delete</span>
164
+ </button>
165
+ </mat-menu>
166
+ </td>
167
+ </ng-container>
168
+
169
+ <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
170
+ <tr mat-row *matRowDef="let row; columns: displayedColumns;"
171
+ [class.deleted-row]="row.deleted"></tr>
172
+ </table>
173
+ </div>
174
+ </div>
175
+
176
+ <!-- Message Snackbar -->
177
+ <div class="message-container" *ngIf="message">
178
+ <mat-card [class.success]="!isError" [class.error]="isError">
179
+ <mat-card-content>
180
+ <mat-icon>{{ isError ? 'error' : 'check_circle' }}</mat-icon>
181
+ {{ message }}
182
+ </mat-card-content>
183
+ </mat-card>
184
+ </div>
185
  </div>
flare-ui/src/app/components/projects/projects.component.scss CHANGED
@@ -1,275 +1,275 @@
1
- .projects-container {
2
- display: flex;
3
- flex-direction: column;
4
- height: 100%;
5
- padding: 20px;
6
-
7
- .toolbar {
8
- display: flex;
9
- justify-content: space-between;
10
- align-items: center;
11
- margin-bottom: 20px;
12
- gap: 20px;
13
- flex-wrap: wrap;
14
-
15
- .toolbar-left {
16
- display: flex;
17
- align-items: center;
18
- gap: 16px;
19
- }
20
-
21
- .toolbar-right {
22
- display: flex;
23
- align-items: center;
24
- gap: 16px;
25
- }
26
-
27
- .search-field {
28
- width: 300px;
29
- }
30
-
31
- .view-toggle {
32
- border: 1px solid rgba(0, 0, 0, 0.12);
33
- border-radius: 4px;
34
- }
35
- }
36
-
37
- mat-progress-bar {
38
- margin-bottom: 20px;
39
- }
40
-
41
- .content {
42
- flex: 1;
43
- overflow: auto;
44
- }
45
-
46
- .projects-grid {
47
- display: grid;
48
- grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
49
- gap: 20px;
50
- padding-bottom: 20px;
51
-
52
- .project-card {
53
- transition: all 0.3s ease;
54
- cursor: pointer;
55
-
56
- &:hover {
57
- transform: translateY(-2px);
58
- box-shadow: 0 4px 8px rgba(0,0,0,0.15);
59
- }
60
-
61
- &.disabled {
62
- opacity: 0.7;
63
-
64
- .project-icon {
65
- background-color: #999 !important;
66
- }
67
- }
68
-
69
- &.deleted {
70
- opacity: 0.5;
71
- background-color: #fafafa;
72
- }
73
-
74
- .project-icon {
75
- background-color: #3f51b5;
76
- color: white;
77
- display: flex;
78
- align-items: center;
79
- justify-content: center;
80
- width: 40px;
81
- height: 40px;
82
- border-radius: 50%;
83
-
84
- mat-icon {
85
- font-size: 24px;
86
- width: 24px;
87
- height: 24px;
88
- }
89
- }
90
-
91
- mat-card-title {
92
- font-size: 18px;
93
- font-weight: 500;
94
- }
95
-
96
- mat-card-subtitle {
97
- margin-top: 4px;
98
- }
99
-
100
- .project-info {
101
- margin-top: 16px;
102
-
103
- .info-item {
104
- display: flex;
105
- align-items: center;
106
- gap: 8px;
107
- margin-bottom: 12px;
108
- color: #666;
109
-
110
- &:last-child {
111
- margin-bottom: 0;
112
- }
113
-
114
- mat-icon {
115
- font-size: 18px;
116
- width: 18px;
117
- height: 18px;
118
- color: #999;
119
- vertical-align: middle;
120
- }
121
-
122
- .info-label {
123
- font-size: 13px;
124
- vertical-align: middle;
125
- line-height: 18px;
126
- }
127
-
128
- mat-checkbox {
129
- margin-left: 4px;
130
- vertical-align: middle;
131
-
132
- ::ng-deep .mat-mdc-checkbox-touch-target {
133
- height: 18px;
134
- }
135
- }
136
-
137
- .time-text {
138
- font-size: 12px;
139
- color: #999;
140
- }
141
- }
142
- }
143
- }
144
- }
145
-
146
- .projects-table {
147
- overflow: auto;
148
-
149
- mat-checkbox {
150
- vertical-align: middle;
151
- }
152
-
153
- .name-with-icon {
154
- display: flex;
155
- align-items: center;
156
- gap: 8px;
157
-
158
- .project-table-icon {
159
- color: #3f51b5;
160
- font-size: 20px;
161
- width: 20px;
162
- height: 20px;
163
- }
164
-
165
- .deleted-icon {
166
- margin-left: auto;
167
- color: #f44336;
168
- }
169
- }
170
-
171
- .action-buttons {
172
- display: flex;
173
- gap: 8px;
174
-
175
- button {
176
- min-width: auto;
177
- }
178
- }
179
- }
180
-
181
- .empty-state {
182
- text-align: center;
183
- padding: 60px 20px;
184
-
185
- mat-icon {
186
- font-size: 64px;
187
- width: 64px;
188
- height: 64px;
189
- color: #ccc;
190
- margin-bottom: 16px;
191
- }
192
-
193
- h3 {
194
- color: #666;
195
- margin: 0 0 24px 0;
196
- }
197
- }
198
-
199
- .message-container {
200
- position: fixed;
201
- bottom: 20px;
202
- left: 50%;
203
- transform: translateX(-50%);
204
- z-index: 1000;
205
-
206
- mat-card {
207
- min-width: 300px;
208
-
209
- &.success mat-card-content {
210
- color: #4caf50;
211
- }
212
-
213
- &.error mat-card-content {
214
- color: #f44336;
215
- }
216
-
217
- mat-card-content {
218
- display: flex;
219
- align-items: center;
220
- gap: 12px;
221
- padding: 12px 16px;
222
- margin: 0;
223
-
224
- mat-icon {
225
- font-size: 20px;
226
- width: 20px;
227
- height: 20px;
228
- }
229
- }
230
- }
231
- }
232
- }
233
-
234
- // Material overrides for this component
235
- ::ng-deep {
236
- .mat-mdc-button-toggle-appearance-standard .mat-button-toggle-label-content {
237
- line-height: 36px;
238
- padding: 0 12px;
239
- }
240
-
241
- .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-infix {
242
- padding: 12px 0;
243
- }
244
-
245
- .mat-mdc-text-field-wrapper.mdc-text-field--outlined {
246
- .mat-mdc-form-field-infix {
247
- min-height: auto;
248
- }
249
- }
250
-
251
- .mat-mdc-card {
252
- --mdc-elevated-card-container-color: white;
253
- --mdc-elevated-card-container-elevation: 0 2px 4px rgba(0,0,0,0.1);
254
- }
255
- }
256
-
257
- // Responsive adjustments
258
- @media (max-width: 768px) {
259
- .projects-container {
260
- .toolbar {
261
- .toolbar-left, .toolbar-right {
262
- width: 100%;
263
- justify-content: center;
264
- }
265
-
266
- .search-field {
267
- width: 100%;
268
- }
269
- }
270
-
271
- .projects-grid {
272
- grid-template-columns: 1fr;
273
- }
274
- }
275
  }
 
1
+ .projects-container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ padding: 20px;
6
+
7
+ .toolbar {
8
+ display: flex;
9
+ justify-content: space-between;
10
+ align-items: center;
11
+ margin-bottom: 20px;
12
+ gap: 20px;
13
+ flex-wrap: wrap;
14
+
15
+ .toolbar-left {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 16px;
19
+ }
20
+
21
+ .toolbar-right {
22
+ display: flex;
23
+ align-items: center;
24
+ gap: 16px;
25
+ }
26
+
27
+ .search-field {
28
+ width: 300px;
29
+ }
30
+
31
+ .view-toggle {
32
+ border: 1px solid rgba(0, 0, 0, 0.12);
33
+ border-radius: 4px;
34
+ }
35
+ }
36
+
37
+ mat-progress-bar {
38
+ margin-bottom: 20px;
39
+ }
40
+
41
+ .content {
42
+ flex: 1;
43
+ overflow: auto;
44
+ }
45
+
46
+ .projects-grid {
47
+ display: grid;
48
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
49
+ gap: 20px;
50
+ padding-bottom: 20px;
51
+
52
+ .project-card {
53
+ transition: all 0.3s ease;
54
+ cursor: pointer;
55
+
56
+ &:hover {
57
+ transform: translateY(-2px);
58
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
59
+ }
60
+
61
+ &.disabled {
62
+ opacity: 0.7;
63
+
64
+ .project-icon {
65
+ background-color: #999 !important;
66
+ }
67
+ }
68
+
69
+ &.deleted {
70
+ opacity: 0.5;
71
+ background-color: #fafafa;
72
+ }
73
+
74
+ .project-icon {
75
+ background-color: #3f51b5;
76
+ color: white;
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ width: 40px;
81
+ height: 40px;
82
+ border-radius: 50%;
83
+
84
+ mat-icon {
85
+ font-size: 24px;
86
+ width: 24px;
87
+ height: 24px;
88
+ }
89
+ }
90
+
91
+ mat-card-title {
92
+ font-size: 18px;
93
+ font-weight: 500;
94
+ }
95
+
96
+ mat-card-subtitle {
97
+ margin-top: 4px;
98
+ }
99
+
100
+ .project-info {
101
+ margin-top: 16px;
102
+
103
+ .info-item {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 8px;
107
+ margin-bottom: 12px;
108
+ color: #666;
109
+
110
+ &:last-child {
111
+ margin-bottom: 0;
112
+ }
113
+
114
+ mat-icon {
115
+ font-size: 18px;
116
+ width: 18px;
117
+ height: 18px;
118
+ color: #999;
119
+ vertical-align: middle;
120
+ }
121
+
122
+ .info-label {
123
+ font-size: 13px;
124
+ vertical-align: middle;
125
+ line-height: 18px;
126
+ }
127
+
128
+ mat-checkbox {
129
+ margin-left: 4px;
130
+ vertical-align: middle;
131
+
132
+ ::ng-deep .mat-mdc-checkbox-touch-target {
133
+ height: 18px;
134
+ }
135
+ }
136
+
137
+ .time-text {
138
+ font-size: 12px;
139
+ color: #999;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+
146
+ .projects-table {
147
+ overflow: auto;
148
+
149
+ mat-checkbox {
150
+ vertical-align: middle;
151
+ }
152
+
153
+ .name-with-icon {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 8px;
157
+
158
+ .project-table-icon {
159
+ color: #3f51b5;
160
+ font-size: 20px;
161
+ width: 20px;
162
+ height: 20px;
163
+ }
164
+
165
+ .deleted-icon {
166
+ margin-left: auto;
167
+ color: #f44336;
168
+ }
169
+ }
170
+
171
+ .action-buttons {
172
+ display: flex;
173
+ gap: 8px;
174
+
175
+ button {
176
+ min-width: auto;
177
+ }
178
+ }
179
+ }
180
+
181
+ .empty-state {
182
+ text-align: center;
183
+ padding: 60px 20px;
184
+
185
+ mat-icon {
186
+ font-size: 64px;
187
+ width: 64px;
188
+ height: 64px;
189
+ color: #ccc;
190
+ margin-bottom: 16px;
191
+ }
192
+
193
+ h3 {
194
+ color: #666;
195
+ margin: 0 0 24px 0;
196
+ }
197
+ }
198
+
199
+ .message-container {
200
+ position: fixed;
201
+ bottom: 20px;
202
+ left: 50%;
203
+ transform: translateX(-50%);
204
+ z-index: 1000;
205
+
206
+ mat-card {
207
+ min-width: 300px;
208
+
209
+ &.success mat-card-content {
210
+ color: #4caf50;
211
+ }
212
+
213
+ &.error mat-card-content {
214
+ color: #f44336;
215
+ }
216
+
217
+ mat-card-content {
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 12px;
221
+ padding: 12px 16px;
222
+ margin: 0;
223
+
224
+ mat-icon {
225
+ font-size: 20px;
226
+ width: 20px;
227
+ height: 20px;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // Material overrides for this component
235
+ ::ng-deep {
236
+ .mat-mdc-button-toggle-appearance-standard .mat-button-toggle-label-content {
237
+ line-height: 36px;
238
+ padding: 0 12px;
239
+ }
240
+
241
+ .mat-mdc-form-field-appearance-outline .mat-mdc-form-field-infix {
242
+ padding: 12px 0;
243
+ }
244
+
245
+ .mat-mdc-text-field-wrapper.mdc-text-field--outlined {
246
+ .mat-mdc-form-field-infix {
247
+ min-height: auto;
248
+ }
249
+ }
250
+
251
+ .mat-mdc-card {
252
+ --mdc-elevated-card-container-color: white;
253
+ --mdc-elevated-card-container-elevation: 0 2px 4px rgba(0,0,0,0.1);
254
+ }
255
+ }
256
+
257
+ // Responsive adjustments
258
+ @media (max-width: 768px) {
259
+ .projects-container {
260
+ .toolbar {
261
+ .toolbar-left, .toolbar-right {
262
+ width: 100%;
263
+ justify-content: center;
264
+ }
265
+
266
+ .search-field {
267
+ width: 100%;
268
+ }
269
+ }
270
+
271
+ .projects-grid {
272
+ grid-template-columns: 1fr;
273
+ }
274
+ }
275
  }
flare-ui/src/app/components/projects/projects.component.ts CHANGED
@@ -1,449 +1,449 @@
1
- import { Component, OnInit, OnDestroy } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { MatDialog, MatDialogModule } from '@angular/material/dialog';
5
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
6
- import { MatTableModule } from '@angular/material/table';
7
- import { MatProgressBarModule } from '@angular/material/progress-bar';
8
- import { MatButtonModule } from '@angular/material/button';
9
- import { MatCheckboxModule } from '@angular/material/checkbox';
10
- import { MatFormFieldModule } from '@angular/material/form-field';
11
- import { MatInputModule } from '@angular/material/input';
12
- import { MatButtonToggleModule } from '@angular/material/button-toggle';
13
- import { MatCardModule } from '@angular/material/card';
14
- import { MatChipsModule } from '@angular/material/chips';
15
- import { MatIconModule } from '@angular/material/icon';
16
- import { MatMenuModule } from '@angular/material/menu';
17
- import { MatDividerModule } from '@angular/material/divider';
18
- import { ApiService, Project } from '../../services/api.service';
19
- import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
20
- import { authInterceptor } from '../../interceptors/auth.interceptor';
21
- import { Subject, takeUntil } from 'rxjs';
22
-
23
- // Dynamic imports for dialogs
24
- const loadProjectEditDialog = () => import('../../dialogs/project-edit-dialog/project-edit-dialog.component');
25
- const loadVersionEditDialog = () => import('../../dialogs/version-edit-dialog/version-edit-dialog.component');
26
- const loadConfirmDialog = () => import('../../dialogs/confirm-dialog/confirm-dialog.component');
27
-
28
- @Component({
29
- selector: 'app-projects',
30
- standalone: true,
31
- imports: [
32
- CommonModule,
33
- FormsModule,
34
- HttpClientModule,
35
- MatTableModule,
36
- MatProgressBarModule,
37
- MatButtonModule,
38
- MatCheckboxModule,
39
- MatFormFieldModule,
40
- MatInputModule,
41
- MatButtonToggleModule,
42
- MatCardModule,
43
- MatChipsModule,
44
- MatIconModule,
45
- MatMenuModule,
46
- MatDividerModule,
47
- MatDialogModule,
48
- MatSnackBarModule
49
- ],
50
- providers: [
51
- ApiService
52
- ],
53
- templateUrl: './projects.component.html',
54
- styleUrls: ['./projects.component.scss']
55
- })
56
- export class ProjectsComponent implements OnInit, OnDestroy {
57
- projects: Project[] = [];
58
- filteredProjects: Project[] = [];
59
- searchTerm = '';
60
- showDeleted = false;
61
- viewMode: 'list' | 'card' = 'card';
62
- loading = false;
63
- message = '';
64
- isError = false;
65
-
66
- // For table view
67
- displayedColumns: string[] = ['name', 'caption', 'versions', 'status', 'lastUpdate', 'actions'];
68
-
69
- // Memory leak prevention
70
- private destroyed$ = new Subject<void>();
71
-
72
- constructor(
73
- private apiService: ApiService,
74
- private dialog: MatDialog,
75
- private snackBar: MatSnackBar
76
- ) {}
77
-
78
- ngOnInit() {
79
- this.loadProjects();
80
- this.loadEnvironment();
81
- }
82
-
83
- ngOnDestroy() {
84
- this.destroyed$.next();
85
- this.destroyed$.complete();
86
- }
87
-
88
- isSparkTabVisible(): boolean {
89
- // Environment bilgisini cache'ten al (eğer varsa)
90
- const env = localStorage.getItem('flare_environment');
91
- if (env) {
92
- const config = JSON.parse(env);
93
- return !config.work_mode?.startsWith('gpt4o');
94
- }
95
- return true; // Default olarak göster
96
- }
97
-
98
- loadProjects() {
99
- this.loading = true;
100
- this.apiService.getProjects(this.showDeleted)
101
- .pipe(takeUntil(this.destroyed$))
102
- .subscribe({
103
- next: (projects) => {
104
- this.projects = projects || [];
105
- this.applyFilter();
106
- this.loading = false;
107
- },
108
- error: (error) => {
109
- this.loading = false;
110
- this.showMessage('Failed to load projects', true);
111
- console.error('Load projects error:', error);
112
- }
113
- });
114
- }
115
-
116
- private loadEnvironment() {
117
- this.apiService.getEnvironment()
118
- .pipe(takeUntil(this.destroyed$))
119
- .subscribe({
120
- next: (env) => {
121
- localStorage.setItem('flare_environment', JSON.stringify(env));
122
- },
123
- error: (err) => {
124
- console.error('Failed to load environment:', err);
125
- }
126
- });
127
- }
128
-
129
- applyFilter() {
130
- this.filteredProjects = this.projects.filter(project => {
131
- const matchesSearch = !this.searchTerm ||
132
- project.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
133
- (project.caption || '').toLowerCase().includes(this.searchTerm.toLowerCase());
134
-
135
- const matchesDeleted = this.showDeleted || !project.deleted;
136
-
137
- return matchesSearch && matchesDeleted;
138
- });
139
- }
140
-
141
- filterProjects() {
142
- this.applyFilter();
143
- }
144
-
145
- onSearchChange() {
146
- this.applyFilter();
147
- }
148
-
149
- onShowDeletedChange() {
150
- this.loadProjects();
151
- }
152
-
153
- async createProject() {
154
- try {
155
- const { default: ProjectEditDialogComponent } = await loadProjectEditDialog();
156
-
157
- const dialogRef = this.dialog.open(ProjectEditDialogComponent, {
158
- width: '500px',
159
- data: { mode: 'create' }
160
- });
161
-
162
- dialogRef.afterClosed()
163
- .pipe(takeUntil(this.destroyed$))
164
- .subscribe(result => {
165
- if (result) {
166
- this.loadProjects();
167
- this.showMessage('Project created successfully', false);
168
- }
169
- });
170
- } catch (error) {
171
- console.error('Failed to load dialog:', error);
172
- this.showMessage('Failed to open dialog', true);
173
- }
174
- }
175
-
176
- async editProject(project: Project, event?: Event) {
177
- if (event) {
178
- event.stopPropagation();
179
- }
180
-
181
- try {
182
- const { default: ProjectEditDialogComponent } = await loadProjectEditDialog();
183
-
184
- const dialogRef = this.dialog.open(ProjectEditDialogComponent, {
185
- width: '500px',
186
- data: { mode: 'edit', project: { ...project } }
187
- });
188
-
189
- dialogRef.afterClosed()
190
- .pipe(takeUntil(this.destroyed$))
191
- .subscribe(result => {
192
- if (result) {
193
- // Listeyi güncelle
194
- const index = this.projects.findIndex(p => p.id === result.id);
195
- if (index !== -1) {
196
- this.projects[index] = result;
197
- this.applyFilter(); // Filtreyi yeniden uygula
198
- } else {
199
- this.loadProjects(); // Bulunamazsa tüm listeyi yenile
200
- }
201
- this.showMessage('Project updated successfully', false);
202
- }
203
- });
204
- } catch (error) {
205
- console.error('Failed to load dialog:', error);
206
- this.showMessage('Failed to open dialog', true);
207
- }
208
- }
209
-
210
- toggleProject(project: Project, event?: Event) {
211
- if (event) {
212
- event.stopPropagation();
213
- }
214
-
215
- const action = project.enabled ? 'disable' : 'enable';
216
- const confirmMessage = `Are you sure you want to ${action} "${project.caption}"?`;
217
-
218
- this.confirmAction(
219
- `${action.charAt(0).toUpperCase() + action.slice(1)} Project`,
220
- confirmMessage,
221
- action.charAt(0).toUpperCase() + action.slice(1),
222
- !project.enabled
223
- ).then(confirmed => {
224
- if (confirmed) {
225
- this.apiService.toggleProject(project.id)
226
- .pipe(takeUntil(this.destroyed$))
227
- .subscribe({
228
- next: (result) => {
229
- project.enabled = result.enabled;
230
- this.showMessage(
231
- `Project ${project.enabled ? 'enabled' : 'disabled'} successfully`,
232
- false
233
- );
234
- },
235
- error: (error) => this.handleUpdateError(error, project.caption)
236
- });
237
- }
238
- });
239
- }
240
-
241
- async manageVersions(project: Project, event?: Event) {
242
- if (event) {
243
- event.stopPropagation();
244
- }
245
-
246
- try {
247
- const { default: VersionEditDialogComponent } = await loadVersionEditDialog();
248
-
249
- const dialogRef = this.dialog.open(VersionEditDialogComponent, {
250
- width: '90vw',
251
- maxWidth: '1200px',
252
- height: '90vh',
253
- data: { project }
254
- });
255
-
256
- dialogRef.afterClosed()
257
- .pipe(takeUntil(this.destroyed$))
258
- .subscribe(result => {
259
- if (result) {
260
- this.loadProjects();
261
- }
262
- });
263
- } catch (error) {
264
- console.error('Failed to load dialog:', error);
265
- this.showMessage('Failed to open dialog', true);
266
- }
267
- }
268
-
269
- deleteProject(project: Project, event?: Event) {
270
- if (event) {
271
- event.stopPropagation();
272
- }
273
-
274
- const hasVersions = project.versions && project.versions.length > 0;
275
- const message = hasVersions ?
276
- `Project "${project.name}" has ${project.versions.length} version(s). Are you sure you want to delete it?` :
277
- `Are you sure you want to delete project "${project.name}"?`;
278
-
279
- this.confirmAction('Delete Project', message, 'Delete', true).then(confirmed => {
280
- if (confirmed) {
281
- this.apiService.deleteProject(project.id)
282
- .pipe(takeUntil(this.destroyed$))
283
- .subscribe({
284
- next: () => {
285
- this.showMessage('Project deleted successfully', false);
286
- this.loadProjects();
287
- },
288
- error: (error) => {
289
- const message = error.error?.detail || 'Failed to delete project';
290
- this.showMessage(message, true);
291
- }
292
- });
293
- }
294
- });
295
- }
296
-
297
- exportProject(project: Project, event?: Event) {
298
- if (event) {
299
- event.stopPropagation();
300
- }
301
-
302
- this.apiService.exportProject(project.id)
303
- .pipe(takeUntil(this.destroyed$))
304
- .subscribe({
305
- next: (data) => {
306
- // Create and download file
307
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
308
- const url = window.URL.createObjectURL(blob);
309
- const link = document.createElement('a');
310
- link.href = url;
311
- link.download = `${project.name}_export_${new Date().getTime()}.json`;
312
- link.click();
313
- window.URL.revokeObjectURL(url);
314
-
315
- this.showMessage('Project exported successfully', false);
316
- },
317
- error: (error) => {
318
- this.showMessage('Failed to export project', true);
319
- console.error('Export error:', error);
320
- }
321
- });
322
- }
323
-
324
- importProject() {
325
- const input = document.createElement('input');
326
- input.type = 'file';
327
- input.accept = '.json';
328
-
329
- input.onchange = async (event: any) => {
330
- const file = event.target.files[0];
331
- if (!file) return;
332
-
333
- try {
334
- const text = await file.text();
335
- const data = JSON.parse(text);
336
-
337
- this.apiService.importProject(data)
338
- .pipe(takeUntil(this.destroyed$))
339
- .subscribe({
340
- next: () => {
341
- this.showMessage('Project imported successfully', false);
342
- this.loadProjects();
343
- },
344
- error: (error) => {
345
- const message = error.error?.detail || 'Failed to import project';
346
- this.showMessage(message, true);
347
- }
348
- });
349
- } catch (error) {
350
- this.showMessage('Invalid file format', true);
351
- }
352
- };
353
-
354
- input.click();
355
- }
356
-
357
- getPublishedCount(project: Project): number {
358
- return project.versions?.filter(v => v.published).length || 0;
359
- }
360
-
361
- getRelativeTime(timestamp: string | undefined): string {
362
- if (!timestamp) return 'Never';
363
-
364
- const date = new Date(timestamp);
365
- const now = new Date();
366
- const diffMs = now.getTime() - date.getTime();
367
- const diffMins = Math.floor(diffMs / 60000);
368
- const diffHours = Math.floor(diffMs / 3600000);
369
- const diffDays = Math.floor(diffMs / 86400000);
370
-
371
- if (diffMins < 60) return `${diffMins} minutes ago`;
372
- if (diffHours < 24) return `${diffHours} hours ago`;
373
- if (diffDays < 7) return `${diffDays} days ago`;
374
-
375
- return date.toLocaleDateString();
376
- }
377
-
378
- trackByProjectId(index: number, project: Project): number {
379
- return project.id;
380
- }
381
-
382
- handleUpdateError(error: any, projectName?: string): void {
383
- if (error.status === 409 || error.raceCondition) {
384
- const details = error.error?.details || error;
385
- const lastUpdateUser = details.last_update_user || error.lastUpdateUser || 'another user';
386
- const lastUpdateDate = details.last_update_date || error.lastUpdateDate;
387
-
388
- const message = projectName
389
- ? `Project "${projectName}" was modified by ${lastUpdateUser}. Please reload.`
390
- : `Project was modified by ${lastUpdateUser}. Please reload.`;
391
-
392
- this.snackBar.open(
393
- message,
394
- 'Reload',
395
- {
396
- duration: 0,
397
- panelClass: ['error-snackbar', 'race-condition-snackbar']
398
- }
399
- ).onAction().subscribe(() => {
400
- this.loadProjects();
401
- });
402
-
403
- // Log additional info if available
404
- if (lastUpdateDate) {
405
- console.info(`Last updated at: ${lastUpdateDate}`);
406
- }
407
- } else {
408
- // Generic error handling
409
- this.snackBar.open(
410
- error.error?.detail || error.message || 'Operation failed',
411
- 'Close',
412
- {
413
- duration: 5000,
414
- panelClass: ['error-snackbar']
415
- }
416
- );
417
- }
418
- }
419
-
420
- private async confirmAction(title: string, message: string, confirmText: string, dangerous: boolean): Promise<boolean> {
421
- try {
422
- const { default: ConfirmDialogComponent } = await loadConfirmDialog();
423
-
424
- const dialogRef = this.dialog.open(ConfirmDialogComponent, {
425
- width: '400px',
426
- data: {
427
- title,
428
- message,
429
- confirmText,
430
- confirmColor: dangerous ? 'warn' : 'primary'
431
- }
432
- });
433
-
434
- return await dialogRef.afterClosed().toPromise() || false;
435
- } catch (error) {
436
- console.error('Failed to load confirm dialog:', error);
437
- return false;
438
- }
439
- }
440
-
441
- private showMessage(message: string, isError: boolean) {
442
- this.message = message;
443
- this.isError = isError;
444
-
445
- setTimeout(() => {
446
- this.message = '';
447
- }, 5000);
448
- }
449
  }
 
1
+ import { Component, OnInit, OnDestroy } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
5
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
6
+ import { MatTableModule } from '@angular/material/table';
7
+ import { MatProgressBarModule } from '@angular/material/progress-bar';
8
+ import { MatButtonModule } from '@angular/material/button';
9
+ import { MatCheckboxModule } from '@angular/material/checkbox';
10
+ import { MatFormFieldModule } from '@angular/material/form-field';
11
+ import { MatInputModule } from '@angular/material/input';
12
+ import { MatButtonToggleModule } from '@angular/material/button-toggle';
13
+ import { MatCardModule } from '@angular/material/card';
14
+ import { MatChipsModule } from '@angular/material/chips';
15
+ import { MatIconModule } from '@angular/material/icon';
16
+ import { MatMenuModule } from '@angular/material/menu';
17
+ import { MatDividerModule } from '@angular/material/divider';
18
+ import { ApiService, Project } from '../../services/api.service';
19
+ import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
20
+ import { authInterceptor } from '../../interceptors/auth.interceptor';
21
+ import { Subject, takeUntil } from 'rxjs';
22
+
23
+ // Dynamic imports for dialogs
24
+ const loadProjectEditDialog = () => import('../../dialogs/project-edit-dialog/project-edit-dialog.component');
25
+ const loadVersionEditDialog = () => import('../../dialogs/version-edit-dialog/version-edit-dialog.component');
26
+ const loadConfirmDialog = () => import('../../dialogs/confirm-dialog/confirm-dialog.component');
27
+
28
+ @Component({
29
+ selector: 'app-projects',
30
+ standalone: true,
31
+ imports: [
32
+ CommonModule,
33
+ FormsModule,
34
+ HttpClientModule,
35
+ MatTableModule,
36
+ MatProgressBarModule,
37
+ MatButtonModule,
38
+ MatCheckboxModule,
39
+ MatFormFieldModule,
40
+ MatInputModule,
41
+ MatButtonToggleModule,
42
+ MatCardModule,
43
+ MatChipsModule,
44
+ MatIconModule,
45
+ MatMenuModule,
46
+ MatDividerModule,
47
+ MatDialogModule,
48
+ MatSnackBarModule
49
+ ],
50
+ providers: [
51
+ ApiService
52
+ ],
53
+ templateUrl: './projects.component.html',
54
+ styleUrls: ['./projects.component.scss']
55
+ })
56
+ export class ProjectsComponent implements OnInit, OnDestroy {
57
+ projects: Project[] = [];
58
+ filteredProjects: Project[] = [];
59
+ searchTerm = '';
60
+ showDeleted = false;
61
+ viewMode: 'list' | 'card' = 'card';
62
+ loading = false;
63
+ message = '';
64
+ isError = false;
65
+
66
+ // For table view
67
+ displayedColumns: string[] = ['name', 'caption', 'versions', 'status', 'lastUpdate', 'actions'];
68
+
69
+ // Memory leak prevention
70
+ private destroyed$ = new Subject<void>();
71
+
72
+ constructor(
73
+ private apiService: ApiService,
74
+ private dialog: MatDialog,
75
+ private snackBar: MatSnackBar
76
+ ) {}
77
+
78
+ ngOnInit() {
79
+ this.loadProjects();
80
+ this.loadEnvironment();
81
+ }
82
+
83
+ ngOnDestroy() {
84
+ this.destroyed$.next();
85
+ this.destroyed$.complete();
86
+ }
87
+
88
+ isSparkTabVisible(): boolean {
89
+ // Environment bilgisini cache'ten al (eğer varsa)
90
+ const env = localStorage.getItem('flare_environment');
91
+ if (env) {
92
+ const config = JSON.parse(env);
93
+ return !config.work_mode?.startsWith('gpt4o');
94
+ }
95
+ return true; // Default olarak göster
96
+ }
97
+
98
+ loadProjects() {
99
+ this.loading = true;
100
+ this.apiService.getProjects(this.showDeleted)
101
+ .pipe(takeUntil(this.destroyed$))
102
+ .subscribe({
103
+ next: (projects) => {
104
+ this.projects = projects || [];
105
+ this.applyFilter();
106
+ this.loading = false;
107
+ },
108
+ error: (error) => {
109
+ this.loading = false;
110
+ this.showMessage('Failed to load projects', true);
111
+ console.error('Load projects error:', error);
112
+ }
113
+ });
114
+ }
115
+
116
+ private loadEnvironment() {
117
+ this.apiService.getEnvironment()
118
+ .pipe(takeUntil(this.destroyed$))
119
+ .subscribe({
120
+ next: (env) => {
121
+ localStorage.setItem('flare_environment', JSON.stringify(env));
122
+ },
123
+ error: (err) => {
124
+ console.error('Failed to load environment:', err);
125
+ }
126
+ });
127
+ }
128
+
129
+ applyFilter() {
130
+ this.filteredProjects = this.projects.filter(project => {
131
+ const matchesSearch = !this.searchTerm ||
132
+ project.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
133
+ (project.caption || '').toLowerCase().includes(this.searchTerm.toLowerCase());
134
+
135
+ const matchesDeleted = this.showDeleted || !project.deleted;
136
+
137
+ return matchesSearch && matchesDeleted;
138
+ });
139
+ }
140
+
141
+ filterProjects() {
142
+ this.applyFilter();
143
+ }
144
+
145
+ onSearchChange() {
146
+ this.applyFilter();
147
+ }
148
+
149
+ onShowDeletedChange() {
150
+ this.loadProjects();
151
+ }
152
+
153
+ async createProject() {
154
+ try {
155
+ const { default: ProjectEditDialogComponent } = await loadProjectEditDialog();
156
+
157
+ const dialogRef = this.dialog.open(ProjectEditDialogComponent, {
158
+ width: '500px',
159
+ data: { mode: 'create' }
160
+ });
161
+
162
+ dialogRef.afterClosed()
163
+ .pipe(takeUntil(this.destroyed$))
164
+ .subscribe(result => {
165
+ if (result) {
166
+ this.loadProjects();
167
+ this.showMessage('Project created successfully', false);
168
+ }
169
+ });
170
+ } catch (error) {
171
+ console.error('Failed to load dialog:', error);
172
+ this.showMessage('Failed to open dialog', true);
173
+ }
174
+ }
175
+
176
+ async editProject(project: Project, event?: Event) {
177
+ if (event) {
178
+ event.stopPropagation();
179
+ }
180
+
181
+ try {
182
+ const { default: ProjectEditDialogComponent } = await loadProjectEditDialog();
183
+
184
+ const dialogRef = this.dialog.open(ProjectEditDialogComponent, {
185
+ width: '500px',
186
+ data: { mode: 'edit', project: { ...project } }
187
+ });
188
+
189
+ dialogRef.afterClosed()
190
+ .pipe(takeUntil(this.destroyed$))
191
+ .subscribe(result => {
192
+ if (result) {
193
+ // Listeyi güncelle
194
+ const index = this.projects.findIndex(p => p.id === result.id);
195
+ if (index !== -1) {
196
+ this.projects[index] = result;
197
+ this.applyFilter(); // Filtreyi yeniden uygula
198
+ } else {
199
+ this.loadProjects(); // Bulunamazsa tüm listeyi yenile
200
+ }
201
+ this.showMessage('Project updated successfully', false);
202
+ }
203
+ });
204
+ } catch (error) {
205
+ console.error('Failed to load dialog:', error);
206
+ this.showMessage('Failed to open dialog', true);
207
+ }
208
+ }
209
+
210
+ toggleProject(project: Project, event?: Event) {
211
+ if (event) {
212
+ event.stopPropagation();
213
+ }
214
+
215
+ const action = project.enabled ? 'disable' : 'enable';
216
+ const confirmMessage = `Are you sure you want to ${action} "${project.caption}"?`;
217
+
218
+ this.confirmAction(
219
+ `${action.charAt(0).toUpperCase() + action.slice(1)} Project`,
220
+ confirmMessage,
221
+ action.charAt(0).toUpperCase() + action.slice(1),
222
+ !project.enabled
223
+ ).then(confirmed => {
224
+ if (confirmed) {
225
+ this.apiService.toggleProject(project.id)
226
+ .pipe(takeUntil(this.destroyed$))
227
+ .subscribe({
228
+ next: (result) => {
229
+ project.enabled = result.enabled;
230
+ this.showMessage(
231
+ `Project ${project.enabled ? 'enabled' : 'disabled'} successfully`,
232
+ false
233
+ );
234
+ },
235
+ error: (error) => this.handleUpdateError(error, project.caption)
236
+ });
237
+ }
238
+ });
239
+ }
240
+
241
+ async manageVersions(project: Project, event?: Event) {
242
+ if (event) {
243
+ event.stopPropagation();
244
+ }
245
+
246
+ try {
247
+ const { default: VersionEditDialogComponent } = await loadVersionEditDialog();
248
+
249
+ const dialogRef = this.dialog.open(VersionEditDialogComponent, {
250
+ width: '90vw',
251
+ maxWidth: '1200px',
252
+ height: '90vh',
253
+ data: { project }
254
+ });
255
+
256
+ dialogRef.afterClosed()
257
+ .pipe(takeUntil(this.destroyed$))
258
+ .subscribe(result => {
259
+ if (result) {
260
+ this.loadProjects();
261
+ }
262
+ });
263
+ } catch (error) {
264
+ console.error('Failed to load dialog:', error);
265
+ this.showMessage('Failed to open dialog', true);
266
+ }
267
+ }
268
+
269
+ deleteProject(project: Project, event?: Event) {
270
+ if (event) {
271
+ event.stopPropagation();
272
+ }
273
+
274
+ const hasVersions = project.versions && project.versions.length > 0;
275
+ const message = hasVersions ?
276
+ `Project "${project.name}" has ${project.versions.length} version(s). Are you sure you want to delete it?` :
277
+ `Are you sure you want to delete project "${project.name}"?`;
278
+
279
+ this.confirmAction('Delete Project', message, 'Delete', true).then(confirmed => {
280
+ if (confirmed) {
281
+ this.apiService.deleteProject(project.id)
282
+ .pipe(takeUntil(this.destroyed$))
283
+ .subscribe({
284
+ next: () => {
285
+ this.showMessage('Project deleted successfully', false);
286
+ this.loadProjects();
287
+ },
288
+ error: (error) => {
289
+ const message = error.error?.detail || 'Failed to delete project';
290
+ this.showMessage(message, true);
291
+ }
292
+ });
293
+ }
294
+ });
295
+ }
296
+
297
+ exportProject(project: Project, event?: Event) {
298
+ if (event) {
299
+ event.stopPropagation();
300
+ }
301
+
302
+ this.apiService.exportProject(project.id)
303
+ .pipe(takeUntil(this.destroyed$))
304
+ .subscribe({
305
+ next: (data) => {
306
+ // Create and download file
307
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
308
+ const url = window.URL.createObjectURL(blob);
309
+ const link = document.createElement('a');
310
+ link.href = url;
311
+ link.download = `${project.name}_export_${new Date().getTime()}.json`;
312
+ link.click();
313
+ window.URL.revokeObjectURL(url);
314
+
315
+ this.showMessage('Project exported successfully', false);
316
+ },
317
+ error: (error) => {
318
+ this.showMessage('Failed to export project', true);
319
+ console.error('Export error:', error);
320
+ }
321
+ });
322
+ }
323
+
324
+ importProject() {
325
+ const input = document.createElement('input');
326
+ input.type = 'file';
327
+ input.accept = '.json';
328
+
329
+ input.onchange = async (event: any) => {
330
+ const file = event.target.files[0];
331
+ if (!file) return;
332
+
333
+ try {
334
+ const text = await file.text();
335
+ const data = JSON.parse(text);
336
+
337
+ this.apiService.importProject(data)
338
+ .pipe(takeUntil(this.destroyed$))
339
+ .subscribe({
340
+ next: () => {
341
+ this.showMessage('Project imported successfully', false);
342
+ this.loadProjects();
343
+ },
344
+ error: (error) => {
345
+ const message = error.error?.detail || 'Failed to import project';
346
+ this.showMessage(message, true);
347
+ }
348
+ });
349
+ } catch (error) {
350
+ this.showMessage('Invalid file format', true);
351
+ }
352
+ };
353
+
354
+ input.click();
355
+ }
356
+
357
+ getPublishedCount(project: Project): number {
358
+ return project.versions?.filter(v => v.published).length || 0;
359
+ }
360
+
361
+ getRelativeTime(timestamp: string | undefined): string {
362
+ if (!timestamp) return 'Never';
363
+
364
+ const date = new Date(timestamp);
365
+ const now = new Date();
366
+ const diffMs = now.getTime() - date.getTime();
367
+ const diffMins = Math.floor(diffMs / 60000);
368
+ const diffHours = Math.floor(diffMs / 3600000);
369
+ const diffDays = Math.floor(diffMs / 86400000);
370
+
371
+ if (diffMins < 60) return `${diffMins} minutes ago`;
372
+ if (diffHours < 24) return `${diffHours} hours ago`;
373
+ if (diffDays < 7) return `${diffDays} days ago`;
374
+
375
+ return date.toLocaleDateString();
376
+ }
377
+
378
+ trackByProjectId(index: number, project: Project): number {
379
+ return project.id;
380
+ }
381
+
382
+ handleUpdateError(error: any, projectName?: string): void {
383
+ if (error.status === 409 || error.raceCondition) {
384
+ const details = error.error?.details || error;
385
+ const lastUpdateUser = details.last_update_user || error.lastUpdateUser || 'another user';
386
+ const lastUpdateDate = details.last_update_date || error.lastUpdateDate;
387
+
388
+ const message = projectName
389
+ ? `Project "${projectName}" was modified by ${lastUpdateUser}. Please reload.`
390
+ : `Project was modified by ${lastUpdateUser}. Please reload.`;
391
+
392
+ this.snackBar.open(
393
+ message,
394
+ 'Reload',
395
+ {
396
+ duration: 0,
397
+ panelClass: ['error-snackbar', 'race-condition-snackbar']
398
+ }
399
+ ).onAction().subscribe(() => {
400
+ this.loadProjects();
401
+ });
402
+
403
+ // Log additional info if available
404
+ if (lastUpdateDate) {
405
+ console.info(`Last updated at: ${lastUpdateDate}`);
406
+ }
407
+ } else {
408
+ // Generic error handling
409
+ this.snackBar.open(
410
+ error.error?.detail || error.message || 'Operation failed',
411
+ 'Close',
412
+ {
413
+ duration: 5000,
414
+ panelClass: ['error-snackbar']
415
+ }
416
+ );
417
+ }
418
+ }
419
+
420
+ private async confirmAction(title: string, message: string, confirmText: string, dangerous: boolean): Promise<boolean> {
421
+ try {
422
+ const { default: ConfirmDialogComponent } = await loadConfirmDialog();
423
+
424
+ const dialogRef = this.dialog.open(ConfirmDialogComponent, {
425
+ width: '400px',
426
+ data: {
427
+ title,
428
+ message,
429
+ confirmText,
430
+ confirmColor: dangerous ? 'warn' : 'primary'
431
+ }
432
+ });
433
+
434
+ return await dialogRef.afterClosed().toPromise() || false;
435
+ } catch (error) {
436
+ console.error('Failed to load confirm dialog:', error);
437
+ return false;
438
+ }
439
+ }
440
+
441
+ private showMessage(message: string, isError: boolean) {
442
+ this.message = message;
443
+ this.isError = isError;
444
+
445
+ setTimeout(() => {
446
+ this.message = '';
447
+ }, 5000);
448
+ }
449
  }
flare-ui/src/app/components/spark/spark.component.ts CHANGED
@@ -1,550 +1,550 @@
1
- import { Component, OnInit } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { MatCardModule } from '@angular/material/card';
5
- import { MatFormFieldModule } from '@angular/material/form-field';
6
- import { MatSelectModule } from '@angular/material/select';
7
- import { MatButtonModule } from '@angular/material/button';
8
- import { MatIconModule } from '@angular/material/icon';
9
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
10
- import { MatExpansionModule } from '@angular/material/expansion';
11
- import { MatTableModule } from '@angular/material/table';
12
- import { MatChipsModule } from '@angular/material/chips';
13
- import { MatDividerModule } from '@angular/material/divider';
14
- import { ApiService } from '../../services/api.service';
15
- import { MatSnackBar } from '@angular/material/snack-bar';
16
-
17
- interface SparkResponse {
18
- type: string;
19
- timestamp: Date;
20
- request?: any;
21
- response?: any;
22
- error?: string;
23
- }
24
-
25
- interface SparkProject {
26
- project_name: string;
27
- version: number;
28
- enabled: boolean;
29
- status: string;
30
- last_accessed: string;
31
- base_model: string;
32
- has_adapter: boolean;
33
- }
34
-
35
- @Component({
36
- selector: 'app-spark',
37
- standalone: true,
38
- imports: [
39
- CommonModule,
40
- FormsModule,
41
- MatCardModule,
42
- MatFormFieldModule,
43
- MatSelectModule,
44
- MatButtonModule,
45
- MatIconModule,
46
- MatProgressSpinnerModule,
47
- MatExpansionModule,
48
- MatTableModule,
49
- MatChipsModule,
50
- MatDividerModule
51
- ],
52
- template: `
53
- <div class="spark-container">
54
- <mat-card>
55
- <mat-card-header>
56
- <mat-card-title>
57
- <mat-icon>flash_on</mat-icon>
58
- Spark Integration
59
- </mat-card-title>
60
- <mat-card-subtitle>
61
- Manage Spark LLM service integration
62
- </mat-card-subtitle>
63
- </mat-card-header>
64
-
65
- <mat-card-content>
66
- <mat-form-field appearance="outline" class="project-select">
67
- <mat-label>Select Project</mat-label>
68
- <mat-select [(ngModel)]="selectedProject" (selectionChange)="onProjectChange()">
69
- <mat-option *ngFor="let project of projects" [value]="project.name">
70
- {{ project.name }} {{ project.caption ? '- ' + project.caption : '' }}
71
- </mat-option>
72
- </mat-select>
73
- <mat-icon matPrefix>folder</mat-icon>
74
- </mat-form-field>
75
-
76
- <div class="action-buttons">
77
- <button mat-raised-button color="primary"
78
- (click)="projectStartup()"
79
- [disabled]="!selectedProject || loading">
80
- <mat-icon>rocket_launch</mat-icon>
81
- Project Startup
82
- </button>
83
-
84
- <button mat-raised-button
85
- (click)="getProjectStatus()"
86
- [disabled]="!selectedProject || loading">
87
- <mat-icon>info</mat-icon>
88
- Get Project Status
89
- </button>
90
-
91
- <button mat-raised-button color="accent"
92
- (click)="enableProject()"
93
- [disabled]="!selectedProject || loading">
94
- <mat-icon>power</mat-icon>
95
- Enable Project
96
- </button>
97
-
98
- <button mat-raised-button
99
- (click)="disableProject()"
100
- [disabled]="!selectedProject || loading">
101
- <mat-icon>power_off</mat-icon>
102
- Disable Project
103
- </button>
104
-
105
- <button mat-raised-button color="warn"
106
- (click)="deleteProject()"
107
- [disabled]="!selectedProject || loading">
108
- <mat-icon>delete</mat-icon>
109
- Delete Project
110
- </button>
111
- </div>
112
-
113
- @if (loading) {
114
- <div class="loading-indicator">
115
- <mat-spinner diameter="40"></mat-spinner>
116
- <p>Processing request...</p>
117
- </div>
118
- }
119
-
120
- @if (responses.length > 0) {
121
- <mat-divider class="section-divider"></mat-divider>
122
-
123
- <h3>Response History</h3>
124
-
125
- <div class="response-list">
126
- @for (response of responses; track response.timestamp) {
127
- <mat-expansion-panel [expanded]="$index === 0">
128
- <mat-expansion-panel-header>
129
- <mat-panel-title>
130
- <mat-chip [class]="response.error ? 'error-chip' : 'success-chip'">
131
- {{ response.type }}
132
- </mat-chip>
133
- <span class="timestamp">{{ response.timestamp | date:'HH:mm:ss' }}</span>
134
- </mat-panel-title>
135
- </mat-expansion-panel-header>
136
-
137
- @if (response.request) {
138
- <div class="response-section">
139
- <h4>Request:</h4>
140
- <pre class="json-display">{{ response.request | json }}</pre>
141
- </div>
142
- }
143
-
144
- @if (response.response) {
145
- <div class="response-section">
146
- <h4>Response:</h4>
147
- @if (response.type === 'Get Project Status' && response.response.projects) {
148
- <table mat-table [dataSource]="response.response.projects" class="projects-table">
149
- <ng-container matColumnDef="project_name">
150
- <th mat-header-cell *matHeaderCellDef>Project</th>
151
- <td mat-cell *matCellDef="let project">{{ project.project_name }}</td>
152
- </ng-container>
153
-
154
- <ng-container matColumnDef="version">
155
- <th mat-header-cell *matHeaderCellDef>Version</th>
156
- <td mat-cell *matCellDef="let project">v{{ project.version }}</td>
157
- </ng-container>
158
-
159
- <ng-container matColumnDef="status">
160
- <th mat-header-cell *matHeaderCellDef>Status</th>
161
- <td mat-cell *matCellDef="let project">
162
- <mat-chip [class]="getStatusClass(project.status)">
163
- {{ project.status }}
164
- </mat-chip>
165
- </td>
166
- </ng-container>
167
-
168
- <ng-container matColumnDef="enabled">
169
- <th mat-header-cell *matHeaderCellDef>Enabled</th>
170
- <td mat-cell *matCellDef="let project">
171
- <mat-icon [color]="project.enabled ? 'primary' : ''">
172
- {{ project.enabled ? 'check_circle' : 'cancel' }}
173
- </mat-icon>
174
- </td>
175
- </ng-container>
176
-
177
- <ng-container matColumnDef="base_model">
178
- <th mat-header-cell *matHeaderCellDef>Base Model</th>
179
- <td mat-cell *matCellDef="let project" class="model-cell">
180
- {{ project.base_model }}
181
- </td>
182
- </ng-container>
183
-
184
- <ng-container matColumnDef="last_accessed">
185
- <th mat-header-cell *matHeaderCellDef>Last Accessed</th>
186
- <td mat-cell *matCellDef="let project">{{ project.last_accessed }}</td>
187
- </ng-container>
188
-
189
- <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
190
- <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
191
- </table>
192
- } @else {
193
- <pre class="json-display">{{ response.response | json }}</pre>
194
- }
195
- </div>
196
- }
197
-
198
- @if (response.error) {
199
- <div class="response-section error">
200
- <h4>Error:</h4>
201
- <pre class="json-display error-text">{{ response.error }}</pre>
202
- </div>
203
- }
204
- </mat-expansion-panel>
205
- }
206
- </div>
207
- }
208
- </mat-card-content>
209
- </mat-card>
210
- </div>
211
- `,
212
- styles: [`
213
- .spark-container {
214
- max-width: 1200px;
215
- margin: 0 auto;
216
- }
217
-
218
- mat-card-header {
219
- margin-bottom: 24px;
220
-
221
- mat-card-title {
222
- display: flex;
223
- align-items: center;
224
- gap: 8px;
225
- font-size: 24px;
226
-
227
- mat-icon {
228
- font-size: 28px;
229
- width: 28px;
230
- height: 28px;
231
- }
232
- }
233
- }
234
-
235
- .project-select {
236
- width: 100%;
237
- max-width: 400px;
238
- margin-bottom: 24px;
239
- }
240
-
241
- .action-buttons {
242
- display: flex;
243
- gap: 16px;
244
- flex-wrap: wrap;
245
- margin-bottom: 24px;
246
-
247
- button {
248
- display: flex;
249
- align-items: center;
250
- gap: 8px;
251
- }
252
- }
253
-
254
- .loading-indicator {
255
- display: flex;
256
- flex-direction: column;
257
- align-items: center;
258
- gap: 16px;
259
- padding: 32px;
260
-
261
- p {
262
- color: #666;
263
- font-size: 14px;
264
- }
265
- }
266
-
267
- .section-divider {
268
- margin: 32px 0;
269
- }
270
-
271
- .response-list {
272
- margin-top: 16px;
273
-
274
- mat-expansion-panel {
275
- margin-bottom: 16px;
276
- }
277
-
278
- mat-panel-title {
279
- display: flex;
280
- align-items: center;
281
- gap: 12px;
282
-
283
- .timestamp {
284
- margin-left: auto;
285
- color: #666;
286
- font-size: 14px;
287
- }
288
- }
289
- }
290
-
291
- .response-section {
292
- margin: 16px 0;
293
-
294
- h4 {
295
- margin-bottom: 8px;
296
- color: #666;
297
- }
298
-
299
- &.error {
300
- h4 {
301
- color: #f44336;
302
- }
303
- }
304
- }
305
-
306
- .json-display {
307
- background-color: #f5f5f5;
308
- padding: 16px;
309
- border-radius: 4px;
310
- font-family: 'Consolas', 'Monaco', monospace;
311
- font-size: 13px;
312
- overflow-x: auto;
313
- white-space: pre-wrap;
314
- word-break: break-word;
315
-
316
- &.error-text {
317
- background-color: #ffebee;
318
- color: #c62828;
319
- }
320
- }
321
-
322
- .projects-table {
323
- width: 100%;
324
- background: #fafafa;
325
-
326
- .model-cell {
327
- font-size: 12px;
328
- max-width: 200px;
329
- overflow: hidden;
330
- text-overflow: ellipsis;
331
- white-space: nowrap;
332
- }
333
- }
334
-
335
- mat-chip {
336
- font-size: 12px;
337
- min-height: 24px;
338
- padding: 4px 12px;
339
-
340
- &.success-chip {
341
- background-color: #4caf50;
342
- color: white;
343
- }
344
-
345
- &.error-chip {
346
- background-color: #f44336;
347
- color: white;
348
- }
349
- }
350
-
351
- ::ng-deep {
352
- .mat-mdc-progress-spinner {
353
- --mdc-circular-progress-active-indicator-color: #3f51b5;
354
- }
355
- }
356
- `]
357
- })
358
- export class SparkComponent implements OnInit {
359
- projects: any[] = [];
360
- selectedProject: string = '';
361
- loading = false;
362
- responses: SparkResponse[] = [];
363
- displayedColumns: string[] = ['project_name', 'version', 'status', 'enabled', 'base_model', 'last_accessed'];
364
-
365
- constructor(
366
- private apiService: ApiService,
367
- private snackBar: MatSnackBar
368
- ) {}
369
-
370
- ngOnInit() {
371
- this.loadProjects();
372
- }
373
-
374
- loadProjects() {
375
- this.apiService.getProjects().subscribe({
376
- next: (projects) => {
377
- this.projects = projects.filter((p: any) => p.enabled && !p.deleted);
378
- },
379
- error: (err) => {
380
- this.snackBar.open('Failed to load projects', 'Close', {
381
- duration: 5000,
382
- panelClass: 'error-snackbar'
383
- });
384
- }
385
- });
386
- }
387
-
388
- onProjectChange() {
389
- // Clear previous responses when project changes
390
- this.responses = [];
391
- }
392
-
393
- private addResponse(type: string, request?: any, response?: any, error?: string) {
394
- this.responses.unshift({
395
- type,
396
- timestamp: new Date(),
397
- request,
398
- response,
399
- error
400
- });
401
-
402
- // Keep only last 10 responses
403
- if (this.responses.length > 10) {
404
- this.responses.pop();
405
- }
406
- }
407
-
408
- projectStartup() {
409
- if (!this.selectedProject) return;
410
-
411
- this.loading = true;
412
- const request = { project_name: this.selectedProject };
413
-
414
- this.apiService.sparkStartup(this.selectedProject).subscribe({
415
- next: (response) => {
416
- this.addResponse('Project Startup', request, response);
417
- this.snackBar.open(response.message || 'Startup initiated', 'Close', {
418
- duration: 3000
419
- });
420
- this.loading = false;
421
- },
422
- error: (err) => {
423
- this.addResponse('Project Startup', request, null, err.error?.detail || err.message);
424
- this.snackBar.open(err.error?.detail || 'Startup failed', 'Close', {
425
- duration: 5000,
426
- panelClass: 'error-snackbar'
427
- });
428
- this.loading = false;
429
- }
430
- });
431
- }
432
-
433
- getProjectStatus() {
434
- this.loading = true;
435
-
436
- this.apiService.sparkGetProjects().subscribe({
437
- next: (response) => {
438
- this.addResponse('Get Project Status', null, response);
439
- this.loading = false;
440
- },
441
- error: (err) => {
442
- this.addResponse('Get Project Status', null, null, err.error?.detail || err.message);
443
- this.snackBar.open(err.error?.detail || 'Failed to get status', 'Close', {
444
- duration: 5000,
445
- panelClass: 'error-snackbar'
446
- });
447
- this.loading = false;
448
- }
449
- });
450
- }
451
-
452
- enableProject() {
453
- if (!this.selectedProject) return;
454
-
455
- this.loading = true;
456
- const request = { project_name: this.selectedProject };
457
-
458
- this.apiService.sparkEnableProject(this.selectedProject).subscribe({
459
- next: (response) => {
460
- this.addResponse('Enable Project', request, response);
461
- this.snackBar.open(response.message || 'Project enabled', 'Close', {
462
- duration: 3000
463
- });
464
- this.loading = false;
465
- },
466
- error: (err) => {
467
- this.addResponse('Enable Project', request, null, err.error?.detail || err.message);
468
- this.snackBar.open(err.error?.detail || 'Enable failed', 'Close', {
469
- duration: 5000,
470
- panelClass: 'error-snackbar'
471
- });
472
- this.loading = false;
473
- }
474
- });
475
- }
476
-
477
- disableProject() {
478
- if (!this.selectedProject) return;
479
-
480
- this.loading = true;
481
- const request = { project_name: this.selectedProject };
482
-
483
- this.apiService.sparkDisableProject(this.selectedProject).subscribe({
484
- next: (response) => {
485
- this.addResponse('Disable Project', request, response);
486
- this.snackBar.open(response.message || 'Project disabled', 'Close', {
487
- duration: 3000
488
- });
489
- this.loading = false;
490
- },
491
- error: (err) => {
492
- this.addResponse('Disable Project', request, null, err.error?.detail || err.message);
493
- this.snackBar.open(err.error?.detail || 'Disable failed', 'Close', {
494
- duration: 5000,
495
- panelClass: 'error-snackbar'
496
- });
497
- this.loading = false;
498
- }
499
- });
500
- }
501
-
502
- deleteProject() {
503
- if (!this.selectedProject) return;
504
-
505
- if (!confirm(`Are you sure you want to delete "${this.selectedProject}" from Spark?`)) {
506
- return;
507
- }
508
-
509
- this.loading = true;
510
- const request = { project_name: this.selectedProject };
511
-
512
- this.apiService.sparkDeleteProject(this.selectedProject).subscribe({
513
- next: (response) => {
514
- this.addResponse('Delete Project', request, response);
515
- this.snackBar.open(response.message || 'Project deleted', 'Close', {
516
- duration: 3000
517
- });
518
- this.loading = false;
519
- this.selectedProject = '';
520
- },
521
- error: (err) => {
522
- this.addResponse('Delete Project', request, null, err.error?.detail || err.message);
523
- this.snackBar.open(err.error?.detail || 'Delete failed', 'Close', {
524
- duration: 5000,
525
- panelClass: 'error-snackbar'
526
- });
527
- this.loading = false;
528
- }
529
- });
530
- }
531
-
532
- getStatusClass(status: string): string {
533
- switch (status) {
534
- case 'ready':
535
- return 'status-ready';
536
- case 'loading':
537
- return 'status-loading';
538
- case 'error':
539
- return 'status-error';
540
- case 'unloaded':
541
- return 'status-unloaded';
542
- default:
543
- return '';
544
- }
545
- }
546
-
547
- trackByTimestamp(index: number, response: SparkResponse): Date {
548
- return response.timestamp;
549
- }
550
  }
 
1
+ import { Component, OnInit } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { MatCardModule } from '@angular/material/card';
5
+ import { MatFormFieldModule } from '@angular/material/form-field';
6
+ import { MatSelectModule } from '@angular/material/select';
7
+ import { MatButtonModule } from '@angular/material/button';
8
+ import { MatIconModule } from '@angular/material/icon';
9
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
10
+ import { MatExpansionModule } from '@angular/material/expansion';
11
+ import { MatTableModule } from '@angular/material/table';
12
+ import { MatChipsModule } from '@angular/material/chips';
13
+ import { MatDividerModule } from '@angular/material/divider';
14
+ import { ApiService } from '../../services/api.service';
15
+ import { MatSnackBar } from '@angular/material/snack-bar';
16
+
17
+ interface SparkResponse {
18
+ type: string;
19
+ timestamp: Date;
20
+ request?: any;
21
+ response?: any;
22
+ error?: string;
23
+ }
24
+
25
+ interface SparkProject {
26
+ project_name: string;
27
+ version: number;
28
+ enabled: boolean;
29
+ status: string;
30
+ last_accessed: string;
31
+ base_model: string;
32
+ has_adapter: boolean;
33
+ }
34
+
35
+ @Component({
36
+ selector: 'app-spark',
37
+ standalone: true,
38
+ imports: [
39
+ CommonModule,
40
+ FormsModule,
41
+ MatCardModule,
42
+ MatFormFieldModule,
43
+ MatSelectModule,
44
+ MatButtonModule,
45
+ MatIconModule,
46
+ MatProgressSpinnerModule,
47
+ MatExpansionModule,
48
+ MatTableModule,
49
+ MatChipsModule,
50
+ MatDividerModule
51
+ ],
52
+ template: `
53
+ <div class="spark-container">
54
+ <mat-card>
55
+ <mat-card-header>
56
+ <mat-card-title>
57
+ <mat-icon>flash_on</mat-icon>
58
+ Spark Integration
59
+ </mat-card-title>
60
+ <mat-card-subtitle>
61
+ Manage Spark LLM service integration
62
+ </mat-card-subtitle>
63
+ </mat-card-header>
64
+
65
+ <mat-card-content>
66
+ <mat-form-field appearance="outline" class="project-select">
67
+ <mat-label>Select Project</mat-label>
68
+ <mat-select [(ngModel)]="selectedProject" (selectionChange)="onProjectChange()">
69
+ <mat-option *ngFor="let project of projects" [value]="project.name">
70
+ {{ project.name }} {{ project.caption ? '- ' + project.caption : '' }}
71
+ </mat-option>
72
+ </mat-select>
73
+ <mat-icon matPrefix>folder</mat-icon>
74
+ </mat-form-field>
75
+
76
+ <div class="action-buttons">
77
+ <button mat-raised-button color="primary"
78
+ (click)="projectStartup()"
79
+ [disabled]="!selectedProject || loading">
80
+ <mat-icon>rocket_launch</mat-icon>
81
+ Project Startup
82
+ </button>
83
+
84
+ <button mat-raised-button
85
+ (click)="getProjectStatus()"
86
+ [disabled]="!selectedProject || loading">
87
+ <mat-icon>info</mat-icon>
88
+ Get Project Status
89
+ </button>
90
+
91
+ <button mat-raised-button color="accent"
92
+ (click)="enableProject()"
93
+ [disabled]="!selectedProject || loading">
94
+ <mat-icon>power</mat-icon>
95
+ Enable Project
96
+ </button>
97
+
98
+ <button mat-raised-button
99
+ (click)="disableProject()"
100
+ [disabled]="!selectedProject || loading">
101
+ <mat-icon>power_off</mat-icon>
102
+ Disable Project
103
+ </button>
104
+
105
+ <button mat-raised-button color="warn"
106
+ (click)="deleteProject()"
107
+ [disabled]="!selectedProject || loading">
108
+ <mat-icon>delete</mat-icon>
109
+ Delete Project
110
+ </button>
111
+ </div>
112
+
113
+ @if (loading) {
114
+ <div class="loading-indicator">
115
+ <mat-spinner diameter="40"></mat-spinner>
116
+ <p>Processing request...</p>
117
+ </div>
118
+ }
119
+
120
+ @if (responses.length > 0) {
121
+ <mat-divider class="section-divider"></mat-divider>
122
+
123
+ <h3>Response History</h3>
124
+
125
+ <div class="response-list">
126
+ @for (response of responses; track response.timestamp) {
127
+ <mat-expansion-panel [expanded]="$index === 0">
128
+ <mat-expansion-panel-header>
129
+ <mat-panel-title>
130
+ <mat-chip [class]="response.error ? 'error-chip' : 'success-chip'">
131
+ {{ response.type }}
132
+ </mat-chip>
133
+ <span class="timestamp">{{ response.timestamp | date:'HH:mm:ss' }}</span>
134
+ </mat-panel-title>
135
+ </mat-expansion-panel-header>
136
+
137
+ @if (response.request) {
138
+ <div class="response-section">
139
+ <h4>Request:</h4>
140
+ <pre class="json-display">{{ response.request | json }}</pre>
141
+ </div>
142
+ }
143
+
144
+ @if (response.response) {
145
+ <div class="response-section">
146
+ <h4>Response:</h4>
147
+ @if (response.type === 'Get Project Status' && response.response.projects) {
148
+ <table mat-table [dataSource]="response.response.projects" class="projects-table">
149
+ <ng-container matColumnDef="project_name">
150
+ <th mat-header-cell *matHeaderCellDef>Project</th>
151
+ <td mat-cell *matCellDef="let project">{{ project.project_name }}</td>
152
+ </ng-container>
153
+
154
+ <ng-container matColumnDef="version">
155
+ <th mat-header-cell *matHeaderCellDef>Version</th>
156
+ <td mat-cell *matCellDef="let project">v{{ project.version }}</td>
157
+ </ng-container>
158
+
159
+ <ng-container matColumnDef="status">
160
+ <th mat-header-cell *matHeaderCellDef>Status</th>
161
+ <td mat-cell *matCellDef="let project">
162
+ <mat-chip [class]="getStatusClass(project.status)">
163
+ {{ project.status }}
164
+ </mat-chip>
165
+ </td>
166
+ </ng-container>
167
+
168
+ <ng-container matColumnDef="enabled">
169
+ <th mat-header-cell *matHeaderCellDef>Enabled</th>
170
+ <td mat-cell *matCellDef="let project">
171
+ <mat-icon [color]="project.enabled ? 'primary' : ''">
172
+ {{ project.enabled ? 'check_circle' : 'cancel' }}
173
+ </mat-icon>
174
+ </td>
175
+ </ng-container>
176
+
177
+ <ng-container matColumnDef="base_model">
178
+ <th mat-header-cell *matHeaderCellDef>Base Model</th>
179
+ <td mat-cell *matCellDef="let project" class="model-cell">
180
+ {{ project.base_model }}
181
+ </td>
182
+ </ng-container>
183
+
184
+ <ng-container matColumnDef="last_accessed">
185
+ <th mat-header-cell *matHeaderCellDef>Last Accessed</th>
186
+ <td mat-cell *matCellDef="let project">{{ project.last_accessed }}</td>
187
+ </ng-container>
188
+
189
+ <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
190
+ <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
191
+ </table>
192
+ } @else {
193
+ <pre class="json-display">{{ response.response | json }}</pre>
194
+ }
195
+ </div>
196
+ }
197
+
198
+ @if (response.error) {
199
+ <div class="response-section error">
200
+ <h4>Error:</h4>
201
+ <pre class="json-display error-text">{{ response.error }}</pre>
202
+ </div>
203
+ }
204
+ </mat-expansion-panel>
205
+ }
206
+ </div>
207
+ }
208
+ </mat-card-content>
209
+ </mat-card>
210
+ </div>
211
+ `,
212
+ styles: [`
213
+ .spark-container {
214
+ max-width: 1200px;
215
+ margin: 0 auto;
216
+ }
217
+
218
+ mat-card-header {
219
+ margin-bottom: 24px;
220
+
221
+ mat-card-title {
222
+ display: flex;
223
+ align-items: center;
224
+ gap: 8px;
225
+ font-size: 24px;
226
+
227
+ mat-icon {
228
+ font-size: 28px;
229
+ width: 28px;
230
+ height: 28px;
231
+ }
232
+ }
233
+ }
234
+
235
+ .project-select {
236
+ width: 100%;
237
+ max-width: 400px;
238
+ margin-bottom: 24px;
239
+ }
240
+
241
+ .action-buttons {
242
+ display: flex;
243
+ gap: 16px;
244
+ flex-wrap: wrap;
245
+ margin-bottom: 24px;
246
+
247
+ button {
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 8px;
251
+ }
252
+ }
253
+
254
+ .loading-indicator {
255
+ display: flex;
256
+ flex-direction: column;
257
+ align-items: center;
258
+ gap: 16px;
259
+ padding: 32px;
260
+
261
+ p {
262
+ color: #666;
263
+ font-size: 14px;
264
+ }
265
+ }
266
+
267
+ .section-divider {
268
+ margin: 32px 0;
269
+ }
270
+
271
+ .response-list {
272
+ margin-top: 16px;
273
+
274
+ mat-expansion-panel {
275
+ margin-bottom: 16px;
276
+ }
277
+
278
+ mat-panel-title {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 12px;
282
+
283
+ .timestamp {
284
+ margin-left: auto;
285
+ color: #666;
286
+ font-size: 14px;
287
+ }
288
+ }
289
+ }
290
+
291
+ .response-section {
292
+ margin: 16px 0;
293
+
294
+ h4 {
295
+ margin-bottom: 8px;
296
+ color: #666;
297
+ }
298
+
299
+ &.error {
300
+ h4 {
301
+ color: #f44336;
302
+ }
303
+ }
304
+ }
305
+
306
+ .json-display {
307
+ background-color: #f5f5f5;
308
+ padding: 16px;
309
+ border-radius: 4px;
310
+ font-family: 'Consolas', 'Monaco', monospace;
311
+ font-size: 13px;
312
+ overflow-x: auto;
313
+ white-space: pre-wrap;
314
+ word-break: break-word;
315
+
316
+ &.error-text {
317
+ background-color: #ffebee;
318
+ color: #c62828;
319
+ }
320
+ }
321
+
322
+ .projects-table {
323
+ width: 100%;
324
+ background: #fafafa;
325
+
326
+ .model-cell {
327
+ font-size: 12px;
328
+ max-width: 200px;
329
+ overflow: hidden;
330
+ text-overflow: ellipsis;
331
+ white-space: nowrap;
332
+ }
333
+ }
334
+
335
+ mat-chip {
336
+ font-size: 12px;
337
+ min-height: 24px;
338
+ padding: 4px 12px;
339
+
340
+ &.success-chip {
341
+ background-color: #4caf50;
342
+ color: white;
343
+ }
344
+
345
+ &.error-chip {
346
+ background-color: #f44336;
347
+ color: white;
348
+ }
349
+ }
350
+
351
+ ::ng-deep {
352
+ .mat-mdc-progress-spinner {
353
+ --mdc-circular-progress-active-indicator-color: #3f51b5;
354
+ }
355
+ }
356
+ `]
357
+ })
358
+ export class SparkComponent implements OnInit {
359
+ projects: any[] = [];
360
+ selectedProject: string = '';
361
+ loading = false;
362
+ responses: SparkResponse[] = [];
363
+ displayedColumns: string[] = ['project_name', 'version', 'status', 'enabled', 'base_model', 'last_accessed'];
364
+
365
+ constructor(
366
+ private apiService: ApiService,
367
+ private snackBar: MatSnackBar
368
+ ) {}
369
+
370
+ ngOnInit() {
371
+ this.loadProjects();
372
+ }
373
+
374
+ loadProjects() {
375
+ this.apiService.getProjects().subscribe({
376
+ next: (projects) => {
377
+ this.projects = projects.filter((p: any) => p.enabled && !p.deleted);
378
+ },
379
+ error: (err) => {
380
+ this.snackBar.open('Failed to load projects', 'Close', {
381
+ duration: 5000,
382
+ panelClass: 'error-snackbar'
383
+ });
384
+ }
385
+ });
386
+ }
387
+
388
+ onProjectChange() {
389
+ // Clear previous responses when project changes
390
+ this.responses = [];
391
+ }
392
+
393
+ private addResponse(type: string, request?: any, response?: any, error?: string) {
394
+ this.responses.unshift({
395
+ type,
396
+ timestamp: new Date(),
397
+ request,
398
+ response,
399
+ error
400
+ });
401
+
402
+ // Keep only last 10 responses
403
+ if (this.responses.length > 10) {
404
+ this.responses.pop();
405
+ }
406
+ }
407
+
408
+ projectStartup() {
409
+ if (!this.selectedProject) return;
410
+
411
+ this.loading = true;
412
+ const request = { project_name: this.selectedProject };
413
+
414
+ this.apiService.sparkStartup(this.selectedProject).subscribe({
415
+ next: (response) => {
416
+ this.addResponse('Project Startup', request, response);
417
+ this.snackBar.open(response.message || 'Startup initiated', 'Close', {
418
+ duration: 3000
419
+ });
420
+ this.loading = false;
421
+ },
422
+ error: (err) => {
423
+ this.addResponse('Project Startup', request, null, err.error?.detail || err.message);
424
+ this.snackBar.open(err.error?.detail || 'Startup failed', 'Close', {
425
+ duration: 5000,
426
+ panelClass: 'error-snackbar'
427
+ });
428
+ this.loading = false;
429
+ }
430
+ });
431
+ }
432
+
433
+ getProjectStatus() {
434
+ this.loading = true;
435
+
436
+ this.apiService.sparkGetProjects().subscribe({
437
+ next: (response) => {
438
+ this.addResponse('Get Project Status', null, response);
439
+ this.loading = false;
440
+ },
441
+ error: (err) => {
442
+ this.addResponse('Get Project Status', null, null, err.error?.detail || err.message);
443
+ this.snackBar.open(err.error?.detail || 'Failed to get status', 'Close', {
444
+ duration: 5000,
445
+ panelClass: 'error-snackbar'
446
+ });
447
+ this.loading = false;
448
+ }
449
+ });
450
+ }
451
+
452
+ enableProject() {
453
+ if (!this.selectedProject) return;
454
+
455
+ this.loading = true;
456
+ const request = { project_name: this.selectedProject };
457
+
458
+ this.apiService.sparkEnableProject(this.selectedProject).subscribe({
459
+ next: (response) => {
460
+ this.addResponse('Enable Project', request, response);
461
+ this.snackBar.open(response.message || 'Project enabled', 'Close', {
462
+ duration: 3000
463
+ });
464
+ this.loading = false;
465
+ },
466
+ error: (err) => {
467
+ this.addResponse('Enable Project', request, null, err.error?.detail || err.message);
468
+ this.snackBar.open(err.error?.detail || 'Enable failed', 'Close', {
469
+ duration: 5000,
470
+ panelClass: 'error-snackbar'
471
+ });
472
+ this.loading = false;
473
+ }
474
+ });
475
+ }
476
+
477
+ disableProject() {
478
+ if (!this.selectedProject) return;
479
+
480
+ this.loading = true;
481
+ const request = { project_name: this.selectedProject };
482
+
483
+ this.apiService.sparkDisableProject(this.selectedProject).subscribe({
484
+ next: (response) => {
485
+ this.addResponse('Disable Project', request, response);
486
+ this.snackBar.open(response.message || 'Project disabled', 'Close', {
487
+ duration: 3000
488
+ });
489
+ this.loading = false;
490
+ },
491
+ error: (err) => {
492
+ this.addResponse('Disable Project', request, null, err.error?.detail || err.message);
493
+ this.snackBar.open(err.error?.detail || 'Disable failed', 'Close', {
494
+ duration: 5000,
495
+ panelClass: 'error-snackbar'
496
+ });
497
+ this.loading = false;
498
+ }
499
+ });
500
+ }
501
+
502
+ deleteProject() {
503
+ if (!this.selectedProject) return;
504
+
505
+ if (!confirm(`Are you sure you want to delete "${this.selectedProject}" from Spark?`)) {
506
+ return;
507
+ }
508
+
509
+ this.loading = true;
510
+ const request = { project_name: this.selectedProject };
511
+
512
+ this.apiService.sparkDeleteProject(this.selectedProject).subscribe({
513
+ next: (response) => {
514
+ this.addResponse('Delete Project', request, response);
515
+ this.snackBar.open(response.message || 'Project deleted', 'Close', {
516
+ duration: 3000
517
+ });
518
+ this.loading = false;
519
+ this.selectedProject = '';
520
+ },
521
+ error: (err) => {
522
+ this.addResponse('Delete Project', request, null, err.error?.detail || err.message);
523
+ this.snackBar.open(err.error?.detail || 'Delete failed', 'Close', {
524
+ duration: 5000,
525
+ panelClass: 'error-snackbar'
526
+ });
527
+ this.loading = false;
528
+ }
529
+ });
530
+ }
531
+
532
+ getStatusClass(status: string): string {
533
+ switch (status) {
534
+ case 'ready':
535
+ return 'status-ready';
536
+ case 'loading':
537
+ return 'status-loading';
538
+ case 'error':
539
+ return 'status-error';
540
+ case 'unloaded':
541
+ return 'status-unloaded';
542
+ default:
543
+ return '';
544
+ }
545
+ }
546
+
547
+ trackByTimestamp(index: number, response: SparkResponse): Date {
548
+ return response.timestamp;
549
+ }
550
  }
flare-ui/src/app/components/test/test.component.html CHANGED
@@ -1,116 +1,116 @@
1
- <div class="test-container">
2
- <h2>System Tests</h2>
3
-
4
- <div class="test-controls">
5
- <button mat-raised-button color="primary" (click)="runAllTests()" [disabled]="running">
6
- <mat-icon>play_arrow</mat-icon>
7
- Run All Tests
8
- </button>
9
- <button mat-raised-button (click)="runSelectedTests()"
10
- [disabled]="running || selectedTests.length === 0">
11
- <mat-icon>play_circle_outline</mat-icon>
12
- Run Selected ({{ selectedTests.length }})
13
- </button>
14
- <button mat-raised-button color="warn" (click)="stopTests()" [disabled]="!running">
15
- <mat-icon>stop</mat-icon>
16
- Stop
17
- </button>
18
- </div>
19
-
20
- <mat-card class="test-categories">
21
- <mat-checkbox [(ngModel)]="allSelected" (change)="toggleAll()">
22
- <strong>All Tests ({{ totalTests }} tests)</strong>
23
- </mat-checkbox>
24
-
25
- <mat-accordion>
26
- <mat-expansion-panel *ngFor="let category of categories"
27
- [(expanded)]="category.expanded">
28
- <mat-expansion-panel-header>
29
- <mat-panel-title>
30
- <mat-checkbox [checked]="allSelected" (change)="toggleAll()">
31
- <strong>All Tests ({{ totalTests }} tests)</strong>
32
- </mat-checkbox>
33
- </mat-panel-title>
34
- <mat-panel-description>
35
- <div class="category-status" *ngIf="getCategoryResults(category).total > 0">
36
- <mat-chip-listbox>
37
- <mat-chip-option *ngIf="getCategoryResults(category).passed > 0"
38
- class="success-chip">
39
- {{ getCategoryResults(category).passed }} passed
40
- </mat-chip-option>
41
- <mat-chip-option *ngIf="getCategoryResults(category).failed > 0"
42
- class="error-chip">
43
- {{ getCategoryResults(category).failed }} failed
44
- </mat-chip-option>
45
- </mat-chip-listbox>
46
- </div>
47
- </mat-panel-description>
48
- </mat-expansion-panel-header>
49
-
50
- <mat-list>
51
- <mat-list-item *ngFor="let test of category.tests">
52
- <mat-icon matListItemIcon [class]="'status-' + (getTestResult(test.name)?.status || 'pending')">
53
- {{ getTestResult(test.name)?.status === 'PASS' ? 'check_circle' :
54
- getTestResult(test.name)?.status === 'FAIL' ? 'cancel' :
55
- getTestResult(test.name)?.status === 'RUNNING' ? 'hourglass_empty' :
56
- 'radio_button_unchecked' }}
57
- </mat-icon>
58
- <div matListItemTitle>{{ test.name }}</div>
59
- <div matListItemLine *ngIf="getTestResult(test.name)">
60
- <span class="test-duration" *ngIf="getTestResult(test.name)?.duration_ms">
61
- {{ getTestResult(test.name)?.duration_ms }}ms
62
- </span>
63
- <span class="test-details" *ngIf="getTestResult(test.name)?.details">
64
- • {{ getTestResult(test.name)?.details }}
65
- </span>
66
- <span class="test-error" *ngIf="getTestResult(test.name)?.error">
67
- • {{ getTestResult(test.name)?.error }}
68
- </span>
69
- </div>
70
- <mat-icon matListItemMeta *ngIf="currentTest === test.name" class="running-icon">
71
- sync
72
- </mat-icon>
73
- </mat-list-item>
74
- </mat-list>
75
- </mat-expansion-panel>
76
- </mat-accordion>
77
- </mat-card>
78
-
79
- <mat-card class="test-results" *ngIf="testResults.length > 0 || running">
80
- <mat-card-header>
81
- <mat-card-title>Test Progress</mat-card-title>
82
- </mat-card-header>
83
-
84
- <mat-card-content>
85
- <mat-progress-bar [value]="progress"
86
- [mode]="running ? 'determinate' : 'determinate'"
87
- [color]="failedTests > 0 ? 'warn' : 'primary'">
88
- </mat-progress-bar>
89
-
90
- <div class="test-summary">
91
- <div class="summary-item">
92
- <mat-icon class="success">check_circle</mat-icon>
93
- <span>Passed: {{ passedTests }}</span>
94
- </div>
95
- <div class="summary-item">
96
- <mat-icon class="error">cancel</mat-icon>
97
- <span>Failed: {{ failedTests }}</span>
98
- </div>
99
- <div class="summary-item">
100
- <mat-icon>timer</mat-icon>
101
- <span>Total: {{ testResults.length }}/{{ selectedTests.length }}</span>
102
- </div>
103
- </div>
104
-
105
- <div class="current-test" *ngIf="currentTest">
106
- <mat-icon class="spin">sync</mat-icon>
107
- Running: {{ currentTest }}
108
- </div>
109
- </mat-card-content>
110
- </mat-card>
111
-
112
- <div class="empty-state" *ngIf="!running && testResults.length === 0">
113
- <mat-icon>assignment_turned_in</mat-icon>
114
- <p>No test results yet. Select tests and click "Run Selected" to start.</p>
115
- </div>
116
  </div>
 
1
+ <div class="test-container">
2
+ <h2>System Tests</h2>
3
+
4
+ <div class="test-controls">
5
+ <button mat-raised-button color="primary" (click)="runAllTests()" [disabled]="running">
6
+ <mat-icon>play_arrow</mat-icon>
7
+ Run All Tests
8
+ </button>
9
+ <button mat-raised-button (click)="runSelectedTests()"
10
+ [disabled]="running || selectedTests.length === 0">
11
+ <mat-icon>play_circle_outline</mat-icon>
12
+ Run Selected ({{ selectedTests.length }})
13
+ </button>
14
+ <button mat-raised-button color="warn" (click)="stopTests()" [disabled]="!running">
15
+ <mat-icon>stop</mat-icon>
16
+ Stop
17
+ </button>
18
+ </div>
19
+
20
+ <mat-card class="test-categories">
21
+ <mat-checkbox [(ngModel)]="allSelected" (change)="toggleAll()">
22
+ <strong>All Tests ({{ totalTests }} tests)</strong>
23
+ </mat-checkbox>
24
+
25
+ <mat-accordion>
26
+ <mat-expansion-panel *ngFor="let category of categories"
27
+ [(expanded)]="category.expanded">
28
+ <mat-expansion-panel-header>
29
+ <mat-panel-title>
30
+ <mat-checkbox [checked]="allSelected" (change)="toggleAll()">
31
+ <strong>All Tests ({{ totalTests }} tests)</strong>
32
+ </mat-checkbox>
33
+ </mat-panel-title>
34
+ <mat-panel-description>
35
+ <div class="category-status" *ngIf="getCategoryResults(category).total > 0">
36
+ <mat-chip-listbox>
37
+ <mat-chip-option *ngIf="getCategoryResults(category).passed > 0"
38
+ class="success-chip">
39
+ {{ getCategoryResults(category).passed }} passed
40
+ </mat-chip-option>
41
+ <mat-chip-option *ngIf="getCategoryResults(category).failed > 0"
42
+ class="error-chip">
43
+ {{ getCategoryResults(category).failed }} failed
44
+ </mat-chip-option>
45
+ </mat-chip-listbox>
46
+ </div>
47
+ </mat-panel-description>
48
+ </mat-expansion-panel-header>
49
+
50
+ <mat-list>
51
+ <mat-list-item *ngFor="let test of category.tests">
52
+ <mat-icon matListItemIcon [class]="'status-' + (getTestResult(test.name)?.status || 'pending')">
53
+ {{ getTestResult(test.name)?.status === 'PASS' ? 'check_circle' :
54
+ getTestResult(test.name)?.status === 'FAIL' ? 'cancel' :
55
+ getTestResult(test.name)?.status === 'RUNNING' ? 'hourglass_empty' :
56
+ 'radio_button_unchecked' }}
57
+ </mat-icon>
58
+ <div matListItemTitle>{{ test.name }}</div>
59
+ <div matListItemLine *ngIf="getTestResult(test.name)">
60
+ <span class="test-duration" *ngIf="getTestResult(test.name)?.duration_ms">
61
+ {{ getTestResult(test.name)?.duration_ms }}ms
62
+ </span>
63
+ <span class="test-details" *ngIf="getTestResult(test.name)?.details">
64
+ • {{ getTestResult(test.name)?.details }}
65
+ </span>
66
+ <span class="test-error" *ngIf="getTestResult(test.name)?.error">
67
+ • {{ getTestResult(test.name)?.error }}
68
+ </span>
69
+ </div>
70
+ <mat-icon matListItemMeta *ngIf="currentTest === test.name" class="running-icon">
71
+ sync
72
+ </mat-icon>
73
+ </mat-list-item>
74
+ </mat-list>
75
+ </mat-expansion-panel>
76
+ </mat-accordion>
77
+ </mat-card>
78
+
79
+ <mat-card class="test-results" *ngIf="testResults.length > 0 || running">
80
+ <mat-card-header>
81
+ <mat-card-title>Test Progress</mat-card-title>
82
+ </mat-card-header>
83
+
84
+ <mat-card-content>
85
+ <mat-progress-bar [value]="progress"
86
+ [mode]="running ? 'determinate' : 'determinate'"
87
+ [color]="failedTests > 0 ? 'warn' : 'primary'">
88
+ </mat-progress-bar>
89
+
90
+ <div class="test-summary">
91
+ <div class="summary-item">
92
+ <mat-icon class="success">check_circle</mat-icon>
93
+ <span>Passed: {{ passedTests }}</span>
94
+ </div>
95
+ <div class="summary-item">
96
+ <mat-icon class="error">cancel</mat-icon>
97
+ <span>Failed: {{ failedTests }}</span>
98
+ </div>
99
+ <div class="summary-item">
100
+ <mat-icon>timer</mat-icon>
101
+ <span>Total: {{ testResults.length }}/{{ selectedTests.length }}</span>
102
+ </div>
103
+ </div>
104
+
105
+ <div class="current-test" *ngIf="currentTest">
106
+ <mat-icon class="spin">sync</mat-icon>
107
+ Running: {{ currentTest }}
108
+ </div>
109
+ </mat-card-content>
110
+ </mat-card>
111
+
112
+ <div class="empty-state" *ngIf="!running && testResults.length === 0">
113
+ <mat-icon>assignment_turned_in</mat-icon>
114
+ <p>No test results yet. Select tests and click "Run Selected" to start.</p>
115
+ </div>
116
  </div>
flare-ui/src/app/components/test/test.component.scss CHANGED
@@ -1,258 +1,258 @@
1
- .test-container {
2
- padding: 24px;
3
- max-width: 1200px;
4
- margin: 0 auto;
5
-
6
- .header {
7
- margin-bottom: 32px;
8
-
9
- h2 {
10
- margin: 0 0 8px 0;
11
- display: flex;
12
- align-items: center;
13
- gap: 12px;
14
-
15
- mat-icon {
16
- color: #666;
17
- vertical-align: middle;
18
- }
19
- }
20
-
21
- p {
22
- color: #666;
23
- margin: 0;
24
- }
25
- }
26
-
27
- .actions {
28
- display: flex;
29
- gap: 16px;
30
- align-items: center;
31
- margin-bottom: 24px;
32
-
33
- .run-buttons {
34
- display: flex;
35
- gap: 12px;
36
- align-items: center;
37
-
38
- .selected-count {
39
- color: #666;
40
- font-size: 14px;
41
- margin-left: 8px;
42
- }
43
- }
44
-
45
- .select-all {
46
- margin-left: auto;
47
- display: flex;
48
- align-items: center;
49
-
50
- mat-checkbox {
51
- vertical-align: middle;
52
- }
53
- }
54
- }
55
-
56
- .test-progress {
57
- margin-bottom: 32px;
58
-
59
- mat-progress-bar {
60
- margin-bottom: 8px;
61
- }
62
-
63
- .progress-info {
64
- display: flex;
65
- justify-content: space-between;
66
- align-items: center;
67
-
68
- .current-test {
69
- color: #666;
70
- font-size: 14px;
71
- }
72
-
73
- .test-stats {
74
- display: flex;
75
- gap: 16px;
76
- font-size: 14px;
77
-
78
- .stat {
79
- display: flex;
80
- align-items: center;
81
- gap: 4px;
82
-
83
- mat-icon {
84
- font-size: 18px;
85
- width: 18px;
86
- height: 18px;
87
- vertical-align: middle;
88
- }
89
-
90
- &.passed {
91
- color: #4caf50;
92
- }
93
-
94
- &.failed {
95
- color: #f44336;
96
- }
97
- }
98
- }
99
- }
100
- }
101
-
102
- .test-categories {
103
- mat-expansion-panel {
104
- margin-bottom: 8px;
105
-
106
- mat-expansion-panel-header {
107
- padding: 0 24px;
108
-
109
- .category-header {
110
- display: flex;
111
- align-items: center;
112
- width: 100%;
113
-
114
- mat-checkbox {
115
- margin-right: 16px;
116
- vertical-align: middle;
117
- }
118
-
119
- .category-info {
120
- flex: 1;
121
-
122
- .category-name {
123
- font-weight: 500;
124
- }
125
- }
126
-
127
- .category-stats {
128
- display: flex;
129
- gap: 12px;
130
- align-items: center;
131
-
132
- mat-chip {
133
- min-height: 24px;
134
- font-size: 12px;
135
- }
136
- }
137
- }
138
- }
139
-
140
- .test-list {
141
- padding: 0 24px 16px 24px;
142
-
143
- mat-list-item {
144
- height: auto;
145
- padding: 8px 0;
146
-
147
- .test-item {
148
- display: flex;
149
- align-items: center;
150
- width: 100%;
151
- gap: 16px;
152
-
153
- mat-checkbox {
154
- flex-shrink: 0;
155
- vertical-align: middle;
156
- }
157
-
158
- .test-name {
159
- flex: 1;
160
- font-size: 14px;
161
- }
162
-
163
- .test-result {
164
- display: flex;
165
- align-items: center;
166
- gap: 8px;
167
-
168
- mat-icon {
169
- font-size: 20px;
170
- width: 20px;
171
- height: 20px;
172
- vertical-align: middle;
173
- }
174
-
175
- .duration {
176
- font-size: 12px;
177
- color: #666;
178
- }
179
-
180
- &.pass mat-icon {
181
- color: #4caf50;
182
- }
183
-
184
- &.fail {
185
- mat-icon {
186
- color: #f44336;
187
- }
188
-
189
- .error-details {
190
- margin-left: 8px;
191
- font-size: 12px;
192
- color: #f44336;
193
- max-width: 300px;
194
- white-space: nowrap;
195
- overflow: hidden;
196
- text-overflow: ellipsis;
197
- }
198
- }
199
-
200
- &.running mat-icon {
201
- color: #2196f3;
202
- animation: spin 1s linear infinite;
203
- }
204
- }
205
- }
206
- }
207
- }
208
- }
209
- }
210
-
211
- .empty-state {
212
- text-align: center;
213
- padding: 60px 20px;
214
- color: #666;
215
-
216
- mat-icon {
217
- font-size: 64px;
218
- width: 64px;
219
- height: 64px;
220
- margin-bottom: 16px;
221
- opacity: 0.3;
222
- }
223
-
224
- h3 {
225
- margin: 0 0 8px 0;
226
- font-weight: normal;
227
- }
228
-
229
- p {
230
- margin: 0;
231
- font-size: 14px;
232
- }
233
- }
234
- }
235
-
236
- @keyframes spin {
237
- from {
238
- transform: rotate(0deg);
239
- }
240
- to {
241
- transform: rotate(360deg);
242
- }
243
- }
244
-
245
- // Material overrides
246
- ::ng-deep {
247
- .mat-mdc-list-item {
248
- height: auto !important;
249
- }
250
-
251
- .mat-expansion-panel-header {
252
- height: 64px;
253
- }
254
-
255
- .mat-expansion-panel-header-title {
256
- align-items: center;
257
- }
258
  }
 
1
+ .test-container {
2
+ padding: 24px;
3
+ max-width: 1200px;
4
+ margin: 0 auto;
5
+
6
+ .header {
7
+ margin-bottom: 32px;
8
+
9
+ h2 {
10
+ margin: 0 0 8px 0;
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 12px;
14
+
15
+ mat-icon {
16
+ color: #666;
17
+ vertical-align: middle;
18
+ }
19
+ }
20
+
21
+ p {
22
+ color: #666;
23
+ margin: 0;
24
+ }
25
+ }
26
+
27
+ .actions {
28
+ display: flex;
29
+ gap: 16px;
30
+ align-items: center;
31
+ margin-bottom: 24px;
32
+
33
+ .run-buttons {
34
+ display: flex;
35
+ gap: 12px;
36
+ align-items: center;
37
+
38
+ .selected-count {
39
+ color: #666;
40
+ font-size: 14px;
41
+ margin-left: 8px;
42
+ }
43
+ }
44
+
45
+ .select-all {
46
+ margin-left: auto;
47
+ display: flex;
48
+ align-items: center;
49
+
50
+ mat-checkbox {
51
+ vertical-align: middle;
52
+ }
53
+ }
54
+ }
55
+
56
+ .test-progress {
57
+ margin-bottom: 32px;
58
+
59
+ mat-progress-bar {
60
+ margin-bottom: 8px;
61
+ }
62
+
63
+ .progress-info {
64
+ display: flex;
65
+ justify-content: space-between;
66
+ align-items: center;
67
+
68
+ .current-test {
69
+ color: #666;
70
+ font-size: 14px;
71
+ }
72
+
73
+ .test-stats {
74
+ display: flex;
75
+ gap: 16px;
76
+ font-size: 14px;
77
+
78
+ .stat {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 4px;
82
+
83
+ mat-icon {
84
+ font-size: 18px;
85
+ width: 18px;
86
+ height: 18px;
87
+ vertical-align: middle;
88
+ }
89
+
90
+ &.passed {
91
+ color: #4caf50;
92
+ }
93
+
94
+ &.failed {
95
+ color: #f44336;
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ .test-categories {
103
+ mat-expansion-panel {
104
+ margin-bottom: 8px;
105
+
106
+ mat-expansion-panel-header {
107
+ padding: 0 24px;
108
+
109
+ .category-header {
110
+ display: flex;
111
+ align-items: center;
112
+ width: 100%;
113
+
114
+ mat-checkbox {
115
+ margin-right: 16px;
116
+ vertical-align: middle;
117
+ }
118
+
119
+ .category-info {
120
+ flex: 1;
121
+
122
+ .category-name {
123
+ font-weight: 500;
124
+ }
125
+ }
126
+
127
+ .category-stats {
128
+ display: flex;
129
+ gap: 12px;
130
+ align-items: center;
131
+
132
+ mat-chip {
133
+ min-height: 24px;
134
+ font-size: 12px;
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ .test-list {
141
+ padding: 0 24px 16px 24px;
142
+
143
+ mat-list-item {
144
+ height: auto;
145
+ padding: 8px 0;
146
+
147
+ .test-item {
148
+ display: flex;
149
+ align-items: center;
150
+ width: 100%;
151
+ gap: 16px;
152
+
153
+ mat-checkbox {
154
+ flex-shrink: 0;
155
+ vertical-align: middle;
156
+ }
157
+
158
+ .test-name {
159
+ flex: 1;
160
+ font-size: 14px;
161
+ }
162
+
163
+ .test-result {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+
168
+ mat-icon {
169
+ font-size: 20px;
170
+ width: 20px;
171
+ height: 20px;
172
+ vertical-align: middle;
173
+ }
174
+
175
+ .duration {
176
+ font-size: 12px;
177
+ color: #666;
178
+ }
179
+
180
+ &.pass mat-icon {
181
+ color: #4caf50;
182
+ }
183
+
184
+ &.fail {
185
+ mat-icon {
186
+ color: #f44336;
187
+ }
188
+
189
+ .error-details {
190
+ margin-left: 8px;
191
+ font-size: 12px;
192
+ color: #f44336;
193
+ max-width: 300px;
194
+ white-space: nowrap;
195
+ overflow: hidden;
196
+ text-overflow: ellipsis;
197
+ }
198
+ }
199
+
200
+ &.running mat-icon {
201
+ color: #2196f3;
202
+ animation: spin 1s linear infinite;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ .empty-state {
212
+ text-align: center;
213
+ padding: 60px 20px;
214
+ color: #666;
215
+
216
+ mat-icon {
217
+ font-size: 64px;
218
+ width: 64px;
219
+ height: 64px;
220
+ margin-bottom: 16px;
221
+ opacity: 0.3;
222
+ }
223
+
224
+ h3 {
225
+ margin: 0 0 8px 0;
226
+ font-weight: normal;
227
+ }
228
+
229
+ p {
230
+ margin: 0;
231
+ font-size: 14px;
232
+ }
233
+ }
234
+ }
235
+
236
+ @keyframes spin {
237
+ from {
238
+ transform: rotate(0deg);
239
+ }
240
+ to {
241
+ transform: rotate(360deg);
242
+ }
243
+ }
244
+
245
+ // Material overrides
246
+ ::ng-deep {
247
+ .mat-mdc-list-item {
248
+ height: auto !important;
249
+ }
250
+
251
+ .mat-expansion-panel-header {
252
+ height: 64px;
253
+ }
254
+
255
+ .mat-expansion-panel-header-title {
256
+ align-items: center;
257
+ }
258
  }
flare-ui/src/app/components/test/test.component.ts CHANGED
@@ -1,710 +1,710 @@
1
- import { Component, inject, OnInit, OnDestroy } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { MatProgressBarModule } from '@angular/material/progress-bar';
5
- import { MatCheckboxModule } from '@angular/material/checkbox';
6
- import { MatButtonModule } from '@angular/material/button';
7
- import { MatIconModule } from '@angular/material/icon';
8
- import { MatExpansionModule } from '@angular/material/expansion';
9
- import { MatListModule } from '@angular/material/list';
10
- import { MatChipsModule } from '@angular/material/chips';
11
- import { MatCardModule } from '@angular/material/card';
12
- import { ApiService } from '../../services/api.service';
13
- import { AuthService } from '../../services/auth.service';
14
- import { HttpClient } from '@angular/common/http';
15
- import { Subject, takeUntil } from 'rxjs';
16
-
17
- interface TestResult {
18
- name: string;
19
- status: 'PASS' | 'FAIL' | 'RUNNING' | 'SKIP';
20
- duration_ms?: number;
21
- error?: string;
22
- details?: string;
23
- }
24
-
25
- interface TestCategory {
26
- name: string;
27
- displayName: string;
28
- tests: TestCase[];
29
- selected: boolean;
30
- expanded: boolean;
31
- }
32
-
33
- interface TestCase {
34
- name: string;
35
- category: string;
36
- selected: boolean;
37
- testFn: () => Promise<TestResult>;
38
- }
39
-
40
- @Component({
41
- selector: 'app-test',
42
- standalone: true,
43
- imports: [
44
- CommonModule,
45
- FormsModule,
46
- MatProgressBarModule,
47
- MatCheckboxModule,
48
- MatButtonModule,
49
- MatIconModule,
50
- MatExpansionModule,
51
- MatListModule,
52
- MatChipsModule,
53
- MatCardModule
54
- ],
55
- templateUrl: './test.component.html',
56
- styleUrls: ['./test.component.scss']
57
- })
58
- export class TestComponent implements OnInit, OnDestroy {
59
- private apiService = inject(ApiService);
60
- private authService = inject(AuthService);
61
- private http = inject(HttpClient);
62
- private destroyed$ = new Subject<void>();
63
-
64
- running = false;
65
- currentTest: string = '';
66
- testResults: TestResult[] = [];
67
-
68
- categories: TestCategory[] = [
69
- {
70
- name: 'auth',
71
- displayName: 'Authentication Tests',
72
- tests: [],
73
- selected: true,
74
- expanded: false
75
- },
76
- {
77
- name: 'api',
78
- displayName: 'API Endpoint Tests',
79
- tests: [],
80
- selected: true,
81
- expanded: false
82
- },
83
- {
84
- name: 'validation',
85
- displayName: 'Validation Tests',
86
- tests: [],
87
- selected: true,
88
- expanded: false
89
- },
90
- {
91
- name: 'integration',
92
- displayName: 'Integration Tests',
93
- tests: [],
94
- selected: true,
95
- expanded: false
96
- }
97
- ];
98
-
99
- allSelected = false;
100
-
101
- get selectedTests(): TestCase[] {
102
- return this.categories
103
- .filter(c => c.selected)
104
- .flatMap(c => c.tests);
105
- }
106
-
107
- get totalTests(): number {
108
- return this.categories.reduce((sum, c) => sum + c.tests.length, 0);
109
- }
110
-
111
- get passedTests(): number {
112
- return this.testResults.filter(r => r.status === 'PASS').length;
113
- }
114
-
115
- get failedTests(): number {
116
- return this.testResults.filter(r => r.status === 'FAIL').length;
117
- }
118
-
119
- get progress(): number {
120
- if (this.testResults.length === 0) return 0;
121
- return (this.testResults.length / this.selectedTests.length) * 100;
122
- }
123
-
124
- ngOnInit() {
125
- this.initializeTests();
126
- this.updateAllSelected();
127
- }
128
-
129
- ngOnDestroy() {
130
- this.destroyed$.next();
131
- this.destroyed$.complete();
132
- }
133
-
134
- updateAllSelected() {
135
- this.allSelected = this.categories.length > 0 && this.categories.every(c => c.selected);
136
- }
137
-
138
- onCategorySelectionChange() {
139
- this.updateAllSelected();
140
- }
141
-
142
- // Helper method to ensure authentication
143
- private ensureAuth(): Promise<boolean> {
144
- return new Promise((resolve) => {
145
- try {
146
- // Check if we already have a valid token
147
- const token = this.authService.getToken();
148
- if (token) {
149
- // Try to make a simple authenticated request to verify token is still valid
150
- this.apiService.getEnvironment()
151
- .pipe(takeUntil(this.destroyed$))
152
- .subscribe({
153
- next: () => resolve(true),
154
- error: (error: any) => {
155
- if (error.status === 401) {
156
- // Token expired, need to re-login
157
- this.authService.logout();
158
- resolve(false);
159
- } else {
160
- // Other error, assume auth is ok
161
- resolve(true);
162
- }
163
- }
164
- });
165
- } else {
166
- // Login with test credentials
167
- this.http.post('/api/admin/login', {
168
- username: 'admin',
169
- password: 'admin'
170
- }).pipe(takeUntil(this.destroyed$))
171
- .subscribe({
172
- next: (response: any) => {
173
- if (response?.token) {
174
- this.authService.setToken(response.token);
175
- this.authService.setUsername(response.username);
176
- resolve(true);
177
- } else {
178
- resolve(false);
179
- }
180
- },
181
- error: () => resolve(false)
182
- });
183
- }
184
- } catch {
185
- resolve(false);
186
- }
187
- });
188
- }
189
-
190
- initializeTests() {
191
- // Authentication Tests
192
- this.addTest('auth', 'Login with valid credentials', async () => {
193
- const start = Date.now();
194
- try {
195
- const response = await this.http.post('/api/login', {
196
- username: 'admin',
197
- password: 'admin'
198
- }).toPromise() as any;
199
-
200
- return {
201
- name: 'Login with valid credentials',
202
- status: response?.token ? 'PASS' : 'FAIL',
203
- duration_ms: Date.now() - start,
204
- details: response?.token ? 'Successfully authenticated' : 'No token received'
205
- };
206
- } catch (error) {
207
- return {
208
- name: 'Login with valid credentials',
209
- status: 'FAIL',
210
- error: 'Login failed',
211
- duration_ms: Date.now() - start
212
- };
213
- }
214
- });
215
-
216
- this.addTest('auth', 'Login with invalid credentials', async () => {
217
- const start = Date.now();
218
- try {
219
- await this.http.post('/api/login', {
220
- username: 'admin',
221
- password: 'wrong_password_12345'
222
- }).toPromise();
223
-
224
- return {
225
- name: 'Login with invalid credentials',
226
- status: 'FAIL',
227
- error: 'Expected 401 but got success',
228
- duration_ms: Date.now() - start
229
- };
230
- } catch (error: any) {
231
- return {
232
- name: 'Login with invalid credentials',
233
- status: error.status === 401 ? 'PASS' : 'FAIL',
234
- duration_ms: Date.now() - start,
235
- details: error.status === 401 ? 'Correctly rejected invalid credentials' : `Unexpected status: ${error.status}`
236
- };
237
- }
238
- });
239
-
240
- // API Endpoint Tests
241
- this.addTest('api', 'GET /api/environment', async () => {
242
- const start = Date.now();
243
- try {
244
- if (!await this.ensureAuth()) {
245
- return {
246
- name: 'GET /api/environment',
247
- status: 'SKIP',
248
- error: 'Authentication failed',
249
- duration_ms: Date.now() - start
250
- };
251
- }
252
-
253
- const response = await this.apiService.getEnvironment().toPromise();
254
- return {
255
- name: 'GET /api/environment',
256
- status: response?.work_mode ? 'PASS' : 'FAIL',
257
- duration_ms: Date.now() - start,
258
- details: response?.work_mode ? `Work mode: ${response.work_mode}` : 'No work mode returned'
259
- };
260
- } catch (error) {
261
- return {
262
- name: 'GET /api/environment',
263
- status: 'FAIL',
264
- error: 'Failed to get environment',
265
- duration_ms: Date.now() - start
266
- };
267
- }
268
- });
269
-
270
- this.addTest('api', 'GET /api/projects', async () => {
271
- const start = Date.now();
272
- try {
273
- if (!await this.ensureAuth()) {
274
- return {
275
- name: 'GET /api/projects',
276
- status: 'SKIP',
277
- error: 'Authentication failed',
278
- duration_ms: Date.now() - start
279
- };
280
- }
281
-
282
- const response = await this.apiService.getProjects().toPromise();
283
- return {
284
- name: 'GET /api/projects',
285
- status: Array.isArray(response) ? 'PASS' : 'FAIL',
286
- duration_ms: Date.now() - start,
287
- details: Array.isArray(response) ? `Retrieved ${response.length} projects` : 'Invalid response format'
288
- };
289
- } catch (error) {
290
- return {
291
- name: 'GET /api/projects',
292
- status: 'FAIL',
293
- error: 'Failed to get projects',
294
- duration_ms: Date.now() - start
295
- };
296
- }
297
- });
298
-
299
- this.addTest('api', 'GET /api/apis', async () => {
300
- const start = Date.now();
301
- try {
302
- if (!await this.ensureAuth()) {
303
- return {
304
- name: 'GET /api/apis',
305
- status: 'SKIP',
306
- error: 'Authentication failed',
307
- duration_ms: Date.now() - start
308
- };
309
- }
310
-
311
- const response = await this.apiService.getAPIs().toPromise();
312
- return {
313
- name: 'GET /api/apis',
314
- status: Array.isArray(response) ? 'PASS' : 'FAIL',
315
- duration_ms: Date.now() - start,
316
- details: Array.isArray(response) ? `Retrieved ${response.length} APIs` : 'Invalid response format'
317
- };
318
- } catch (error) {
319
- return {
320
- name: 'GET /api/apis',
321
- status: 'FAIL',
322
- error: 'Failed to get APIs',
323
- duration_ms: Date.now() - start
324
- };
325
- }
326
- });
327
-
328
- // Integration Tests
329
- this.addTest('integration', 'Create and delete project', async () => {
330
- const start = Date.now();
331
- let projectId: number | undefined = undefined;
332
-
333
- try {
334
- // Ensure we're authenticated
335
- if (!await this.ensureAuth()) {
336
- return {
337
- name: 'Create and delete project',
338
- status: 'SKIP',
339
- error: 'Authentication failed',
340
- duration_ms: Date.now() - start
341
- };
342
- }
343
-
344
- // Create test project
345
- const testProjectName = `test_project_${Date.now()}`;
346
- const createResponse = await this.apiService.createProject({
347
- name: testProjectName,
348
- caption: 'Test Project for Integration Test',
349
- icon: 'folder',
350
- description: 'This is a test project',
351
- default_language: 'Turkish',
352
- supported_languages: ['tr'],
353
- timezone: 'Europe/Istanbul',
354
- region: 'tr-TR'
355
- }).toPromise() as any;
356
-
357
- if (!createResponse?.id) {
358
- throw new Error('Project creation failed - no ID returned');
359
- }
360
-
361
- projectId = createResponse.id;
362
-
363
- // Verify project was created
364
- const projects = await this.apiService.getProjects().toPromise() as any[];
365
- const createdProject = projects.find(p => p.id === projectId);
366
-
367
- if (!createdProject) {
368
- throw new Error('Created project not found in project list');
369
- }
370
-
371
- // Delete project
372
- await this.apiService.deleteProject(projectId!).toPromise();
373
-
374
- // Verify project was soft deleted
375
- const projectsAfterDelete = await this.apiService.getProjects().toPromise() as any[];
376
- const deletedProject = projectsAfterDelete.find(p => p.id === projectId);
377
-
378
- if (deletedProject) {
379
- throw new Error('Project still visible after deletion');
380
- }
381
-
382
- return {
383
- name: 'Create and delete project',
384
- status: 'PASS',
385
- duration_ms: Date.now() - start,
386
- details: `Successfully created and deleted project: ${testProjectName}`
387
- };
388
- } catch (error: any) {
389
- // Try to clean up if project was created
390
- if (projectId !== undefined) {
391
- try {
392
- await this.apiService.deleteProject(projectId).toPromise();
393
- } catch {}
394
- }
395
-
396
- return {
397
- name: 'Create and delete project',
398
- status: 'FAIL',
399
- error: error.message || 'Test failed',
400
- duration_ms: Date.now() - start
401
- };
402
- }
403
- });
404
-
405
- this.addTest('integration', 'API used in intent cannot be deleted', async () => {
406
- const start = Date.now();
407
- let testApiName: string | undefined;
408
- let testProjectId: number | undefined;
409
-
410
- try {
411
- // Ensure we're authenticated
412
- if (!await this.ensureAuth()) {
413
- return {
414
- name: 'API used in intent cannot be deleted',
415
- status: 'SKIP',
416
- error: 'Authentication failed',
417
- duration_ms: Date.now() - start
418
- };
419
- }
420
-
421
- // 1. Create test API
422
- testApiName = `test_api_${Date.now()}`;
423
- await this.apiService.createAPI({
424
- name: testApiName,
425
- url: 'https://test.example.com/api',
426
- method: 'POST',
427
- timeout_seconds: 10,
428
- headers: { 'Content-Type': 'application/json' },
429
- body_template: {},
430
- retry: {
431
- retry_count: 3,
432
- backoff_seconds: 2,
433
- strategy: 'static'
434
- }
435
- }).toPromise();
436
-
437
- // 2. Create test project
438
- const testProjectName = `test_project_${Date.now()}`;
439
- const createProjectResponse = await this.apiService.createProject({
440
- name: testProjectName,
441
- caption: 'Test Project',
442
- icon: 'folder',
443
- description: 'Test project for API deletion test',
444
- default_language: 'Turkish',
445
- supported_languages: ['tr'],
446
- timezone: 'Europe/Istanbul',
447
- region: 'tr-TR'
448
- }).toPromise() as any;
449
-
450
- if (!createProjectResponse?.id) {
451
- throw new Error('Project creation failed');
452
- }
453
-
454
- testProjectId = createProjectResponse.id;
455
-
456
- // 3. Get the first version
457
- const version = createProjectResponse.versions[0];
458
- if (!version) {
459
- throw new Error('No version found in created project');
460
- }
461
-
462
- // 4. Update the version to add an intent that uses our API
463
- // testProjectId is guaranteed to be a number here
464
- await this.apiService.updateVersion(testProjectId!, version.id, {
465
- caption: version.caption,
466
- general_prompt: 'Test prompt',
467
- llm: version.llm,
468
- intents: [{
469
- name: 'test-intent',
470
- caption: 'Test Intent',
471
- locale: 'tr-TR',
472
- detection_prompt: 'Test detection',
473
- examples: ['test example'],
474
- parameters: [],
475
- action: testApiName,
476
- fallback_timeout_prompt: 'Timeout',
477
- fallback_error_prompt: 'Error'
478
- }],
479
- last_update_date: version.last_update_date
480
- }).toPromise();
481
-
482
- // 5. Try to delete the API - this should fail with 400
483
- try {
484
- await this.apiService.deleteAPI(testApiName).toPromise();
485
-
486
- // If deletion succeeded, test failed
487
- return {
488
- name: 'API used in intent cannot be deleted',
489
- status: 'FAIL',
490
- error: 'API was deleted even though it was in use',
491
- duration_ms: Date.now() - start
492
- };
493
- } catch (deleteError: any) {
494
- // Check if we got the expected 400 error
495
- const errorMessage = deleteError.error?.detail || deleteError.message || '';
496
- const isExpectedError = deleteError.status === 400 &&
497
- errorMessage.includes('API is used');
498
-
499
- if (!isExpectedError) {
500
- console.error('Delete API Error Details:', {
501
- status: deleteError.status,
502
- error: deleteError.error,
503
- message: errorMessage
504
- });
505
- }
506
-
507
- return {
508
- name: 'API used in intent cannot be deleted',
509
- status: isExpectedError ? 'PASS' : 'FAIL',
510
- duration_ms: Date.now() - start,
511
- details: isExpectedError
512
- ? 'Correctly prevented deletion of API in use'
513
- : `Unexpected error: Status ${deleteError.status}, Message: ${errorMessage}`
514
- };
515
- }
516
- } catch (setupError: any) {
517
- return {
518
- name: 'API used in intent cannot be deleted',
519
- status: 'FAIL',
520
- error: `Test setup failed: ${setupError.message || setupError}`,
521
- duration_ms: Date.now() - start
522
- };
523
- } finally {
524
- // Cleanup: first delete project, then API
525
- try {
526
- if (testProjectId !== undefined) {
527
- await this.apiService.deleteProject(testProjectId).toPromise();
528
- }
529
- } catch {}
530
-
531
- try {
532
- if (testApiName) {
533
- await this.apiService.deleteAPI(testApiName).toPromise();
534
- }
535
- } catch {}
536
- }
537
- });
538
-
539
- // Validation Tests
540
- this.addTest('validation', 'Regex validation - valid pattern', async () => {
541
- const start = Date.now();
542
- try {
543
- if (!await this.ensureAuth()) {
544
- return {
545
- name: 'Regex validation - valid pattern',
546
- status: 'SKIP',
547
- error: 'Authentication failed',
548
- duration_ms: Date.now() - start
549
- };
550
- }
551
-
552
- const response = await this.apiService.validateRegex('^[A-Z]{3}$', 'ABC').toPromise() as any;
553
- return {
554
- name: 'Regex validation - valid pattern',
555
- status: response?.valid && response?.matches ? 'PASS' : 'FAIL',
556
- duration_ms: Date.now() - start,
557
- details: response?.valid && response?.matches
558
- ? 'Pattern matched successfully'
559
- : 'Pattern did not match or validation failed'
560
- };
561
- } catch (error) {
562
- return {
563
- name: 'Regex validation - valid pattern',
564
- status: 'FAIL',
565
- error: 'Validation endpoint failed',
566
- duration_ms: Date.now() - start
567
- };
568
- }
569
- });
570
-
571
- this.addTest('validation', 'Regex validation - invalid pattern', async () => {
572
- const start = Date.now();
573
- try {
574
- if (!await this.ensureAuth()) {
575
- return {
576
- name: 'Regex validation - invalid pattern',
577
- status: 'SKIP',
578
- error: 'Authentication failed',
579
- duration_ms: Date.now() - start
580
- };
581
- }
582
-
583
- const response = await this.apiService.validateRegex('[invalid', 'test').toPromise() as any;
584
- return {
585
- name: 'Regex validation - invalid pattern',
586
- status: !response?.valid ? 'PASS' : 'FAIL',
587
- duration_ms: Date.now() - start,
588
- details: !response?.valid
589
- ? 'Correctly identified invalid regex'
590
- : 'Failed to identify invalid regex'
591
- };
592
- } catch (error: any) {
593
- // Some errors are expected for invalid regex
594
- return {
595
- name: 'Regex validation - invalid pattern',
596
- status: 'PASS',
597
- duration_ms: Date.now() - start,
598
- details: 'Correctly rejected invalid regex'
599
- };
600
- }
601
- });
602
-
603
- // Update test counts
604
- this.categories.forEach(cat => {
605
- const originalName = cat.displayName.split(' (')[0];
606
- cat.displayName = `${originalName} (${cat.tests.length} tests)`;
607
- });
608
- }
609
-
610
- private addTest(category: string, name: string, testFn: () => Promise<TestResult>) {
611
- const cat = this.categories.find(c => c.name === category);
612
- if (cat) {
613
- cat.tests.push({
614
- name,
615
- category,
616
- selected: true,
617
- testFn
618
- });
619
- }
620
- }
621
-
622
- toggleAll() {
623
- this.allSelected = !this.allSelected;
624
- this.categories.forEach(c => c.selected = this.allSelected);
625
- }
626
-
627
- async runAllTests() {
628
- this.categories.forEach(c => c.selected = true);
629
- await this.runTests();
630
- }
631
-
632
- async runSelectedTests() {
633
- await this.runTests();
634
- }
635
-
636
- async runTests() {
637
- if (this.running || this.selectedTests.length === 0) return;
638
-
639
- this.running = true;
640
- this.testResults = [];
641
- this.currentTest = '';
642
-
643
- try {
644
- // Ensure we're authenticated before running tests
645
- const authOk = await this.ensureAuth();
646
- if (!authOk) {
647
- this.testResults.push({
648
- name: 'Authentication',
649
- status: 'FAIL',
650
- error: 'Failed to authenticate for tests',
651
- duration_ms: 0
652
- });
653
- this.running = false;
654
- return;
655
- }
656
-
657
- // Run selected tests
658
- for (const test of this.selectedTests) {
659
- if (!this.running) break; // Allow cancellation
660
-
661
- this.currentTest = test.name;
662
-
663
- try {
664
- const result = await test.testFn();
665
- this.testResults.push(result);
666
- } catch (error: any) {
667
- // Catch any uncaught errors from test
668
- this.testResults.push({
669
- name: test.name,
670
- status: 'FAIL',
671
- error: error.message || 'Test threw an exception',
672
- duration_ms: 0
673
- });
674
- }
675
- }
676
- } catch (error: any) {
677
- console.error('Test runner error:', error);
678
- this.testResults.push({
679
- name: 'Test Runner',
680
- status: 'FAIL',
681
- error: 'Test runner encountered an error',
682
- duration_ms: 0
683
- });
684
- } finally {
685
- this.running = false;
686
- this.currentTest = '';
687
- }
688
- }
689
-
690
- stopTests() {
691
- this.running = false;
692
- this.currentTest = '';
693
- }
694
-
695
- getTestResult(testName: string): TestResult | undefined {
696
- return this.testResults.find(r => r.name === testName);
697
- }
698
-
699
- getCategoryResults(category: TestCategory): { passed: number; failed: number; total: number } {
700
- const categoryResults = this.testResults.filter(r =>
701
- category.tests.some(t => t.name === r.name)
702
- );
703
-
704
- return {
705
- passed: categoryResults.filter(r => r.status === 'PASS').length,
706
- failed: categoryResults.filter(r => r.status === 'FAIL').length,
707
- total: category.tests.length
708
- };
709
- }
710
  }
 
1
+ import { Component, inject, OnInit, OnDestroy } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { MatProgressBarModule } from '@angular/material/progress-bar';
5
+ import { MatCheckboxModule } from '@angular/material/checkbox';
6
+ import { MatButtonModule } from '@angular/material/button';
7
+ import { MatIconModule } from '@angular/material/icon';
8
+ import { MatExpansionModule } from '@angular/material/expansion';
9
+ import { MatListModule } from '@angular/material/list';
10
+ import { MatChipsModule } from '@angular/material/chips';
11
+ import { MatCardModule } from '@angular/material/card';
12
+ import { ApiService } from '../../services/api.service';
13
+ import { AuthService } from '../../services/auth.service';
14
+ import { HttpClient } from '@angular/common/http';
15
+ import { Subject, takeUntil } from 'rxjs';
16
+
17
+ interface TestResult {
18
+ name: string;
19
+ status: 'PASS' | 'FAIL' | 'RUNNING' | 'SKIP';
20
+ duration_ms?: number;
21
+ error?: string;
22
+ details?: string;
23
+ }
24
+
25
+ interface TestCategory {
26
+ name: string;
27
+ displayName: string;
28
+ tests: TestCase[];
29
+ selected: boolean;
30
+ expanded: boolean;
31
+ }
32
+
33
+ interface TestCase {
34
+ name: string;
35
+ category: string;
36
+ selected: boolean;
37
+ testFn: () => Promise<TestResult>;
38
+ }
39
+
40
+ @Component({
41
+ selector: 'app-test',
42
+ standalone: true,
43
+ imports: [
44
+ CommonModule,
45
+ FormsModule,
46
+ MatProgressBarModule,
47
+ MatCheckboxModule,
48
+ MatButtonModule,
49
+ MatIconModule,
50
+ MatExpansionModule,
51
+ MatListModule,
52
+ MatChipsModule,
53
+ MatCardModule
54
+ ],
55
+ templateUrl: './test.component.html',
56
+ styleUrls: ['./test.component.scss']
57
+ })
58
+ export class TestComponent implements OnInit, OnDestroy {
59
+ private apiService = inject(ApiService);
60
+ private authService = inject(AuthService);
61
+ private http = inject(HttpClient);
62
+ private destroyed$ = new Subject<void>();
63
+
64
+ running = false;
65
+ currentTest: string = '';
66
+ testResults: TestResult[] = [];
67
+
68
+ categories: TestCategory[] = [
69
+ {
70
+ name: 'auth',
71
+ displayName: 'Authentication Tests',
72
+ tests: [],
73
+ selected: true,
74
+ expanded: false
75
+ },
76
+ {
77
+ name: 'api',
78
+ displayName: 'API Endpoint Tests',
79
+ tests: [],
80
+ selected: true,
81
+ expanded: false
82
+ },
83
+ {
84
+ name: 'validation',
85
+ displayName: 'Validation Tests',
86
+ tests: [],
87
+ selected: true,
88
+ expanded: false
89
+ },
90
+ {
91
+ name: 'integration',
92
+ displayName: 'Integration Tests',
93
+ tests: [],
94
+ selected: true,
95
+ expanded: false
96
+ }
97
+ ];
98
+
99
+ allSelected = false;
100
+
101
+ get selectedTests(): TestCase[] {
102
+ return this.categories
103
+ .filter(c => c.selected)
104
+ .flatMap(c => c.tests);
105
+ }
106
+
107
+ get totalTests(): number {
108
+ return this.categories.reduce((sum, c) => sum + c.tests.length, 0);
109
+ }
110
+
111
+ get passedTests(): number {
112
+ return this.testResults.filter(r => r.status === 'PASS').length;
113
+ }
114
+
115
+ get failedTests(): number {
116
+ return this.testResults.filter(r => r.status === 'FAIL').length;
117
+ }
118
+
119
+ get progress(): number {
120
+ if (this.testResults.length === 0) return 0;
121
+ return (this.testResults.length / this.selectedTests.length) * 100;
122
+ }
123
+
124
+ ngOnInit() {
125
+ this.initializeTests();
126
+ this.updateAllSelected();
127
+ }
128
+
129
+ ngOnDestroy() {
130
+ this.destroyed$.next();
131
+ this.destroyed$.complete();
132
+ }
133
+
134
+ updateAllSelected() {
135
+ this.allSelected = this.categories.length > 0 && this.categories.every(c => c.selected);
136
+ }
137
+
138
+ onCategorySelectionChange() {
139
+ this.updateAllSelected();
140
+ }
141
+
142
+ // Helper method to ensure authentication
143
+ private ensureAuth(): Promise<boolean> {
144
+ return new Promise((resolve) => {
145
+ try {
146
+ // Check if we already have a valid token
147
+ const token = this.authService.getToken();
148
+ if (token) {
149
+ // Try to make a simple authenticated request to verify token is still valid
150
+ this.apiService.getEnvironment()
151
+ .pipe(takeUntil(this.destroyed$))
152
+ .subscribe({
153
+ next: () => resolve(true),
154
+ error: (error: any) => {
155
+ if (error.status === 401) {
156
+ // Token expired, need to re-login
157
+ this.authService.logout();
158
+ resolve(false);
159
+ } else {
160
+ // Other error, assume auth is ok
161
+ resolve(true);
162
+ }
163
+ }
164
+ });
165
+ } else {
166
+ // Login with test credentials
167
+ this.http.post('/api/admin/login', {
168
+ username: 'admin',
169
+ password: 'admin'
170
+ }).pipe(takeUntil(this.destroyed$))
171
+ .subscribe({
172
+ next: (response: any) => {
173
+ if (response?.token) {
174
+ this.authService.setToken(response.token);
175
+ this.authService.setUsername(response.username);
176
+ resolve(true);
177
+ } else {
178
+ resolve(false);
179
+ }
180
+ },
181
+ error: () => resolve(false)
182
+ });
183
+ }
184
+ } catch {
185
+ resolve(false);
186
+ }
187
+ });
188
+ }
189
+
190
+ initializeTests() {
191
+ // Authentication Tests
192
+ this.addTest('auth', 'Login with valid credentials', async () => {
193
+ const start = Date.now();
194
+ try {
195
+ const response = await this.http.post('/api/login', {
196
+ username: 'admin',
197
+ password: 'admin'
198
+ }).toPromise() as any;
199
+
200
+ return {
201
+ name: 'Login with valid credentials',
202
+ status: response?.token ? 'PASS' : 'FAIL',
203
+ duration_ms: Date.now() - start,
204
+ details: response?.token ? 'Successfully authenticated' : 'No token received'
205
+ };
206
+ } catch (error) {
207
+ return {
208
+ name: 'Login with valid credentials',
209
+ status: 'FAIL',
210
+ error: 'Login failed',
211
+ duration_ms: Date.now() - start
212
+ };
213
+ }
214
+ });
215
+
216
+ this.addTest('auth', 'Login with invalid credentials', async () => {
217
+ const start = Date.now();
218
+ try {
219
+ await this.http.post('/api/login', {
220
+ username: 'admin',
221
+ password: 'wrong_password_12345'
222
+ }).toPromise();
223
+
224
+ return {
225
+ name: 'Login with invalid credentials',
226
+ status: 'FAIL',
227
+ error: 'Expected 401 but got success',
228
+ duration_ms: Date.now() - start
229
+ };
230
+ } catch (error: any) {
231
+ return {
232
+ name: 'Login with invalid credentials',
233
+ status: error.status === 401 ? 'PASS' : 'FAIL',
234
+ duration_ms: Date.now() - start,
235
+ details: error.status === 401 ? 'Correctly rejected invalid credentials' : `Unexpected status: ${error.status}`
236
+ };
237
+ }
238
+ });
239
+
240
+ // API Endpoint Tests
241
+ this.addTest('api', 'GET /api/environment', async () => {
242
+ const start = Date.now();
243
+ try {
244
+ if (!await this.ensureAuth()) {
245
+ return {
246
+ name: 'GET /api/environment',
247
+ status: 'SKIP',
248
+ error: 'Authentication failed',
249
+ duration_ms: Date.now() - start
250
+ };
251
+ }
252
+
253
+ const response = await this.apiService.getEnvironment().toPromise();
254
+ return {
255
+ name: 'GET /api/environment',
256
+ status: response?.work_mode ? 'PASS' : 'FAIL',
257
+ duration_ms: Date.now() - start,
258
+ details: response?.work_mode ? `Work mode: ${response.work_mode}` : 'No work mode returned'
259
+ };
260
+ } catch (error) {
261
+ return {
262
+ name: 'GET /api/environment',
263
+ status: 'FAIL',
264
+ error: 'Failed to get environment',
265
+ duration_ms: Date.now() - start
266
+ };
267
+ }
268
+ });
269
+
270
+ this.addTest('api', 'GET /api/projects', async () => {
271
+ const start = Date.now();
272
+ try {
273
+ if (!await this.ensureAuth()) {
274
+ return {
275
+ name: 'GET /api/projects',
276
+ status: 'SKIP',
277
+ error: 'Authentication failed',
278
+ duration_ms: Date.now() - start
279
+ };
280
+ }
281
+
282
+ const response = await this.apiService.getProjects().toPromise();
283
+ return {
284
+ name: 'GET /api/projects',
285
+ status: Array.isArray(response) ? 'PASS' : 'FAIL',
286
+ duration_ms: Date.now() - start,
287
+ details: Array.isArray(response) ? `Retrieved ${response.length} projects` : 'Invalid response format'
288
+ };
289
+ } catch (error) {
290
+ return {
291
+ name: 'GET /api/projects',
292
+ status: 'FAIL',
293
+ error: 'Failed to get projects',
294
+ duration_ms: Date.now() - start
295
+ };
296
+ }
297
+ });
298
+
299
+ this.addTest('api', 'GET /api/apis', async () => {
300
+ const start = Date.now();
301
+ try {
302
+ if (!await this.ensureAuth()) {
303
+ return {
304
+ name: 'GET /api/apis',
305
+ status: 'SKIP',
306
+ error: 'Authentication failed',
307
+ duration_ms: Date.now() - start
308
+ };
309
+ }
310
+
311
+ const response = await this.apiService.getAPIs().toPromise();
312
+ return {
313
+ name: 'GET /api/apis',
314
+ status: Array.isArray(response) ? 'PASS' : 'FAIL',
315
+ duration_ms: Date.now() - start,
316
+ details: Array.isArray(response) ? `Retrieved ${response.length} APIs` : 'Invalid response format'
317
+ };
318
+ } catch (error) {
319
+ return {
320
+ name: 'GET /api/apis',
321
+ status: 'FAIL',
322
+ error: 'Failed to get APIs',
323
+ duration_ms: Date.now() - start
324
+ };
325
+ }
326
+ });
327
+
328
+ // Integration Tests
329
+ this.addTest('integration', 'Create and delete project', async () => {
330
+ const start = Date.now();
331
+ let projectId: number | undefined = undefined;
332
+
333
+ try {
334
+ // Ensure we're authenticated
335
+ if (!await this.ensureAuth()) {
336
+ return {
337
+ name: 'Create and delete project',
338
+ status: 'SKIP',
339
+ error: 'Authentication failed',
340
+ duration_ms: Date.now() - start
341
+ };
342
+ }
343
+
344
+ // Create test project
345
+ const testProjectName = `test_project_${Date.now()}`;
346
+ const createResponse = await this.apiService.createProject({
347
+ name: testProjectName,
348
+ caption: 'Test Project for Integration Test',
349
+ icon: 'folder',
350
+ description: 'This is a test project',
351
+ default_language: 'Turkish',
352
+ supported_languages: ['tr'],
353
+ timezone: 'Europe/Istanbul',
354
+ region: 'tr-TR'
355
+ }).toPromise() as any;
356
+
357
+ if (!createResponse?.id) {
358
+ throw new Error('Project creation failed - no ID returned');
359
+ }
360
+
361
+ projectId = createResponse.id;
362
+
363
+ // Verify project was created
364
+ const projects = await this.apiService.getProjects().toPromise() as any[];
365
+ const createdProject = projects.find(p => p.id === projectId);
366
+
367
+ if (!createdProject) {
368
+ throw new Error('Created project not found in project list');
369
+ }
370
+
371
+ // Delete project
372
+ await this.apiService.deleteProject(projectId!).toPromise();
373
+
374
+ // Verify project was soft deleted
375
+ const projectsAfterDelete = await this.apiService.getProjects().toPromise() as any[];
376
+ const deletedProject = projectsAfterDelete.find(p => p.id === projectId);
377
+
378
+ if (deletedProject) {
379
+ throw new Error('Project still visible after deletion');
380
+ }
381
+
382
+ return {
383
+ name: 'Create and delete project',
384
+ status: 'PASS',
385
+ duration_ms: Date.now() - start,
386
+ details: `Successfully created and deleted project: ${testProjectName}`
387
+ };
388
+ } catch (error: any) {
389
+ // Try to clean up if project was created
390
+ if (projectId !== undefined) {
391
+ try {
392
+ await this.apiService.deleteProject(projectId).toPromise();
393
+ } catch {}
394
+ }
395
+
396
+ return {
397
+ name: 'Create and delete project',
398
+ status: 'FAIL',
399
+ error: error.message || 'Test failed',
400
+ duration_ms: Date.now() - start
401
+ };
402
+ }
403
+ });
404
+
405
+ this.addTest('integration', 'API used in intent cannot be deleted', async () => {
406
+ const start = Date.now();
407
+ let testApiName: string | undefined;
408
+ let testProjectId: number | undefined;
409
+
410
+ try {
411
+ // Ensure we're authenticated
412
+ if (!await this.ensureAuth()) {
413
+ return {
414
+ name: 'API used in intent cannot be deleted',
415
+ status: 'SKIP',
416
+ error: 'Authentication failed',
417
+ duration_ms: Date.now() - start
418
+ };
419
+ }
420
+
421
+ // 1. Create test API
422
+ testApiName = `test_api_${Date.now()}`;
423
+ await this.apiService.createAPI({
424
+ name: testApiName,
425
+ url: 'https://test.example.com/api',
426
+ method: 'POST',
427
+ timeout_seconds: 10,
428
+ headers: { 'Content-Type': 'application/json' },
429
+ body_template: {},
430
+ retry: {
431
+ retry_count: 3,
432
+ backoff_seconds: 2,
433
+ strategy: 'static'
434
+ }
435
+ }).toPromise();
436
+
437
+ // 2. Create test project
438
+ const testProjectName = `test_project_${Date.now()}`;
439
+ const createProjectResponse = await this.apiService.createProject({
440
+ name: testProjectName,
441
+ caption: 'Test Project',
442
+ icon: 'folder',
443
+ description: 'Test project for API deletion test',
444
+ default_language: 'Turkish',
445
+ supported_languages: ['tr'],
446
+ timezone: 'Europe/Istanbul',
447
+ region: 'tr-TR'
448
+ }).toPromise() as any;
449
+
450
+ if (!createProjectResponse?.id) {
451
+ throw new Error('Project creation failed');
452
+ }
453
+
454
+ testProjectId = createProjectResponse.id;
455
+
456
+ // 3. Get the first version
457
+ const version = createProjectResponse.versions[0];
458
+ if (!version) {
459
+ throw new Error('No version found in created project');
460
+ }
461
+
462
+ // 4. Update the version to add an intent that uses our API
463
+ // testProjectId is guaranteed to be a number here
464
+ await this.apiService.updateVersion(testProjectId!, version.id, {
465
+ caption: version.caption,
466
+ general_prompt: 'Test prompt',
467
+ llm: version.llm,
468
+ intents: [{
469
+ name: 'test-intent',
470
+ caption: 'Test Intent',
471
+ locale: 'tr-TR',
472
+ detection_prompt: 'Test detection',
473
+ examples: ['test example'],
474
+ parameters: [],
475
+ action: testApiName,
476
+ fallback_timeout_prompt: 'Timeout',
477
+ fallback_error_prompt: 'Error'
478
+ }],
479
+ last_update_date: version.last_update_date
480
+ }).toPromise();
481
+
482
+ // 5. Try to delete the API - this should fail with 400
483
+ try {
484
+ await this.apiService.deleteAPI(testApiName).toPromise();
485
+
486
+ // If deletion succeeded, test failed
487
+ return {
488
+ name: 'API used in intent cannot be deleted',
489
+ status: 'FAIL',
490
+ error: 'API was deleted even though it was in use',
491
+ duration_ms: Date.now() - start
492
+ };
493
+ } catch (deleteError: any) {
494
+ // Check if we got the expected 400 error
495
+ const errorMessage = deleteError.error?.detail || deleteError.message || '';
496
+ const isExpectedError = deleteError.status === 400 &&
497
+ errorMessage.includes('API is used');
498
+
499
+ if (!isExpectedError) {
500
+ console.error('Delete API Error Details:', {
501
+ status: deleteError.status,
502
+ error: deleteError.error,
503
+ message: errorMessage
504
+ });
505
+ }
506
+
507
+ return {
508
+ name: 'API used in intent cannot be deleted',
509
+ status: isExpectedError ? 'PASS' : 'FAIL',
510
+ duration_ms: Date.now() - start,
511
+ details: isExpectedError
512
+ ? 'Correctly prevented deletion of API in use'
513
+ : `Unexpected error: Status ${deleteError.status}, Message: ${errorMessage}`
514
+ };
515
+ }
516
+ } catch (setupError: any) {
517
+ return {
518
+ name: 'API used in intent cannot be deleted',
519
+ status: 'FAIL',
520
+ error: `Test setup failed: ${setupError.message || setupError}`,
521
+ duration_ms: Date.now() - start
522
+ };
523
+ } finally {
524
+ // Cleanup: first delete project, then API
525
+ try {
526
+ if (testProjectId !== undefined) {
527
+ await this.apiService.deleteProject(testProjectId).toPromise();
528
+ }
529
+ } catch {}
530
+
531
+ try {
532
+ if (testApiName) {
533
+ await this.apiService.deleteAPI(testApiName).toPromise();
534
+ }
535
+ } catch {}
536
+ }
537
+ });
538
+
539
+ // Validation Tests
540
+ this.addTest('validation', 'Regex validation - valid pattern', async () => {
541
+ const start = Date.now();
542
+ try {
543
+ if (!await this.ensureAuth()) {
544
+ return {
545
+ name: 'Regex validation - valid pattern',
546
+ status: 'SKIP',
547
+ error: 'Authentication failed',
548
+ duration_ms: Date.now() - start
549
+ };
550
+ }
551
+
552
+ const response = await this.apiService.validateRegex('^[A-Z]{3}$', 'ABC').toPromise() as any;
553
+ return {
554
+ name: 'Regex validation - valid pattern',
555
+ status: response?.valid && response?.matches ? 'PASS' : 'FAIL',
556
+ duration_ms: Date.now() - start,
557
+ details: response?.valid && response?.matches
558
+ ? 'Pattern matched successfully'
559
+ : 'Pattern did not match or validation failed'
560
+ };
561
+ } catch (error) {
562
+ return {
563
+ name: 'Regex validation - valid pattern',
564
+ status: 'FAIL',
565
+ error: 'Validation endpoint failed',
566
+ duration_ms: Date.now() - start
567
+ };
568
+ }
569
+ });
570
+
571
+ this.addTest('validation', 'Regex validation - invalid pattern', async () => {
572
+ const start = Date.now();
573
+ try {
574
+ if (!await this.ensureAuth()) {
575
+ return {
576
+ name: 'Regex validation - invalid pattern',
577
+ status: 'SKIP',
578
+ error: 'Authentication failed',
579
+ duration_ms: Date.now() - start
580
+ };
581
+ }
582
+
583
+ const response = await this.apiService.validateRegex('[invalid', 'test').toPromise() as any;
584
+ return {
585
+ name: 'Regex validation - invalid pattern',
586
+ status: !response?.valid ? 'PASS' : 'FAIL',
587
+ duration_ms: Date.now() - start,
588
+ details: !response?.valid
589
+ ? 'Correctly identified invalid regex'
590
+ : 'Failed to identify invalid regex'
591
+ };
592
+ } catch (error: any) {
593
+ // Some errors are expected for invalid regex
594
+ return {
595
+ name: 'Regex validation - invalid pattern',
596
+ status: 'PASS',
597
+ duration_ms: Date.now() - start,
598
+ details: 'Correctly rejected invalid regex'
599
+ };
600
+ }
601
+ });
602
+
603
+ // Update test counts
604
+ this.categories.forEach(cat => {
605
+ const originalName = cat.displayName.split(' (')[0];
606
+ cat.displayName = `${originalName} (${cat.tests.length} tests)`;
607
+ });
608
+ }
609
+
610
+ private addTest(category: string, name: string, testFn: () => Promise<TestResult>) {
611
+ const cat = this.categories.find(c => c.name === category);
612
+ if (cat) {
613
+ cat.tests.push({
614
+ name,
615
+ category,
616
+ selected: true,
617
+ testFn
618
+ });
619
+ }
620
+ }
621
+
622
+ toggleAll() {
623
+ this.allSelected = !this.allSelected;
624
+ this.categories.forEach(c => c.selected = this.allSelected);
625
+ }
626
+
627
+ async runAllTests() {
628
+ this.categories.forEach(c => c.selected = true);
629
+ await this.runTests();
630
+ }
631
+
632
+ async runSelectedTests() {
633
+ await this.runTests();
634
+ }
635
+
636
+ async runTests() {
637
+ if (this.running || this.selectedTests.length === 0) return;
638
+
639
+ this.running = true;
640
+ this.testResults = [];
641
+ this.currentTest = '';
642
+
643
+ try {
644
+ // Ensure we're authenticated before running tests
645
+ const authOk = await this.ensureAuth();
646
+ if (!authOk) {
647
+ this.testResults.push({
648
+ name: 'Authentication',
649
+ status: 'FAIL',
650
+ error: 'Failed to authenticate for tests',
651
+ duration_ms: 0
652
+ });
653
+ this.running = false;
654
+ return;
655
+ }
656
+
657
+ // Run selected tests
658
+ for (const test of this.selectedTests) {
659
+ if (!this.running) break; // Allow cancellation
660
+
661
+ this.currentTest = test.name;
662
+
663
+ try {
664
+ const result = await test.testFn();
665
+ this.testResults.push(result);
666
+ } catch (error: any) {
667
+ // Catch any uncaught errors from test
668
+ this.testResults.push({
669
+ name: test.name,
670
+ status: 'FAIL',
671
+ error: error.message || 'Test threw an exception',
672
+ duration_ms: 0
673
+ });
674
+ }
675
+ }
676
+ } catch (error: any) {
677
+ console.error('Test runner error:', error);
678
+ this.testResults.push({
679
+ name: 'Test Runner',
680
+ status: 'FAIL',
681
+ error: 'Test runner encountered an error',
682
+ duration_ms: 0
683
+ });
684
+ } finally {
685
+ this.running = false;
686
+ this.currentTest = '';
687
+ }
688
+ }
689
+
690
+ stopTests() {
691
+ this.running = false;
692
+ this.currentTest = '';
693
+ }
694
+
695
+ getTestResult(testName: string): TestResult | undefined {
696
+ return this.testResults.find(r => r.name === testName);
697
+ }
698
+
699
+ getCategoryResults(category: TestCategory): { passed: number; failed: number; total: number } {
700
+ const categoryResults = this.testResults.filter(r =>
701
+ category.tests.some(t => t.name === r.name)
702
+ );
703
+
704
+ return {
705
+ passed: categoryResults.filter(r => r.status === 'PASS').length,
706
+ failed: categoryResults.filter(r => r.status === 'FAIL').length,
707
+ total: category.tests.length
708
+ };
709
+ }
710
  }
flare-ui/src/app/components/user-info/user-info.component.html CHANGED
@@ -1,83 +1,83 @@
1
- <div class="user-info-container">
2
- <h2>User Information</h2>
3
-
4
- <mat-card>
5
- <mat-card-header>
6
- <mat-card-title>Change Password</mat-card-title>
7
- <mat-card-subtitle>User: {{ username }}</mat-card-subtitle>
8
- </mat-card-header>
9
-
10
- <mat-card-content>
11
- <form (ngSubmit)="changePassword()" #passwordForm="ngForm">
12
- <mat-form-field appearance="outline" class="full-width">
13
- <mat-label>Current Password</mat-label>
14
- <input matInput
15
- [type]="showCurrentPassword ? 'text' : 'password'"
16
- name="currentPassword"
17
- [(ngModel)]="currentPassword"
18
- required
19
- [disabled]="saving">
20
- <button mat-icon-button matSuffix type="button"
21
- (click)="showCurrentPassword = !showCurrentPassword">
22
- <mat-icon>{{ showCurrentPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
23
- </button>
24
- </mat-form-field>
25
-
26
- <mat-form-field appearance="outline" class="full-width">
27
- <mat-label>New Password</mat-label>
28
- <input matInput
29
- [type]="showNewPassword ? 'text' : 'password'"
30
- name="newPassword"
31
- [(ngModel)]="newPassword"
32
- required
33
- [disabled]="saving">
34
- <button mat-icon-button matSuffix type="button"
35
- (click)="showNewPassword = !showNewPassword">
36
- <mat-icon>{{ showNewPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
37
- </button>
38
- <mat-hint>At least 8 characters with uppercase, lowercase and numbers</mat-hint>
39
- </mat-form-field>
40
-
41
- <div class="password-strength" *ngIf="newPassword">
42
- <div class="strength-label">
43
- Password Strength:
44
- <span [class]="'strength-' + passwordStrength.color">
45
- {{ passwordStrength.text }}
46
- </span>
47
- </div>
48
- <mat-progress-bar
49
- [value]="passwordStrength.level"
50
- [color]="passwordStrength.color">
51
- </mat-progress-bar>
52
- </div>
53
-
54
- <mat-form-field appearance="outline" class="full-width">
55
- <mat-label>Confirm New Password</mat-label>
56
- <input matInput
57
- [type]="showConfirmPassword ? 'text' : 'password'"
58
- name="confirmPassword"
59
- [(ngModel)]="confirmPassword"
60
- required
61
- [disabled]="saving">
62
- <button mat-icon-button matSuffix type="button"
63
- (click)="showConfirmPassword = !showConfirmPassword">
64
- <mat-icon>{{ showConfirmPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
65
- </button>
66
- <mat-error *ngIf="confirmPassword && confirmPassword !== newPassword">
67
- Passwords do not match
68
- </mat-error>
69
- </mat-form-field>
70
-
71
- <div class="form-actions">
72
- <button mat-raised-button color="primary"
73
- type="submit"
74
- [disabled]="!isFormValid || saving">
75
- <mat-icon *ngIf="!saving">save</mat-icon>
76
- <mat-spinner *ngIf="saving" diameter="20"></mat-spinner>
77
- {{ saving ? 'Saving...' : 'Change Password' }}
78
- </button>
79
- </div>
80
- </form>
81
- </mat-card-content>
82
- </mat-card>
83
  </div>
 
1
+ <div class="user-info-container">
2
+ <h2>User Information</h2>
3
+
4
+ <mat-card>
5
+ <mat-card-header>
6
+ <mat-card-title>Change Password</mat-card-title>
7
+ <mat-card-subtitle>User: {{ username }}</mat-card-subtitle>
8
+ </mat-card-header>
9
+
10
+ <mat-card-content>
11
+ <form (ngSubmit)="changePassword()" #passwordForm="ngForm">
12
+ <mat-form-field appearance="outline" class="full-width">
13
+ <mat-label>Current Password</mat-label>
14
+ <input matInput
15
+ [type]="showCurrentPassword ? 'text' : 'password'"
16
+ name="currentPassword"
17
+ [(ngModel)]="currentPassword"
18
+ required
19
+ [disabled]="saving">
20
+ <button mat-icon-button matSuffix type="button"
21
+ (click)="showCurrentPassword = !showCurrentPassword">
22
+ <mat-icon>{{ showCurrentPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
23
+ </button>
24
+ </mat-form-field>
25
+
26
+ <mat-form-field appearance="outline" class="full-width">
27
+ <mat-label>New Password</mat-label>
28
+ <input matInput
29
+ [type]="showNewPassword ? 'text' : 'password'"
30
+ name="newPassword"
31
+ [(ngModel)]="newPassword"
32
+ required
33
+ [disabled]="saving">
34
+ <button mat-icon-button matSuffix type="button"
35
+ (click)="showNewPassword = !showNewPassword">
36
+ <mat-icon>{{ showNewPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
37
+ </button>
38
+ <mat-hint>At least 8 characters with uppercase, lowercase and numbers</mat-hint>
39
+ </mat-form-field>
40
+
41
+ <div class="password-strength" *ngIf="newPassword">
42
+ <div class="strength-label">
43
+ Password Strength:
44
+ <span [class]="'strength-' + passwordStrength.color">
45
+ {{ passwordStrength.text }}
46
+ </span>
47
+ </div>
48
+ <mat-progress-bar
49
+ [value]="passwordStrength.level"
50
+ [color]="passwordStrength.color">
51
+ </mat-progress-bar>
52
+ </div>
53
+
54
+ <mat-form-field appearance="outline" class="full-width">
55
+ <mat-label>Confirm New Password</mat-label>
56
+ <input matInput
57
+ [type]="showConfirmPassword ? 'text' : 'password'"
58
+ name="confirmPassword"
59
+ [(ngModel)]="confirmPassword"
60
+ required
61
+ [disabled]="saving">
62
+ <button mat-icon-button matSuffix type="button"
63
+ (click)="showConfirmPassword = !showConfirmPassword">
64
+ <mat-icon>{{ showConfirmPassword ? 'visibility_off' : 'visibility' }}</mat-icon>
65
+ </button>
66
+ <mat-error *ngIf="confirmPassword && confirmPassword !== newPassword">
67
+ Passwords do not match
68
+ </mat-error>
69
+ </mat-form-field>
70
+
71
+ <div class="form-actions">
72
+ <button mat-raised-button color="primary"
73
+ type="submit"
74
+ [disabled]="!isFormValid || saving">
75
+ <mat-icon *ngIf="!saving">save</mat-icon>
76
+ <mat-spinner *ngIf="saving" diameter="20"></mat-spinner>
77
+ {{ saving ? 'Saving...' : 'Change Password' }}
78
+ </button>
79
+ </div>
80
+ </form>
81
+ </mat-card-content>
82
+ </mat-card>
83
  </div>
flare-ui/src/app/components/user-info/user-info.component.ts CHANGED
@@ -1,175 +1,175 @@
1
- import { Component, inject, OnDestroy } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { Router } from '@angular/router';
5
- import { MatFormFieldModule } from '@angular/material/form-field';
6
- import { MatInputModule } from '@angular/material/input';
7
- import { MatButtonModule } from '@angular/material/button';
8
- import { MatIconModule } from '@angular/material/icon';
9
- import { MatProgressBarModule } from '@angular/material/progress-bar';
10
- import { MatCardModule } from '@angular/material/card';
11
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
12
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
13
- import { ApiService } from '../../services/api.service';
14
- import { AuthService } from '../../services/auth.service';
15
- import { Subject, takeUntil } from 'rxjs';
16
-
17
- @Component({
18
- selector: 'app-user-info',
19
- standalone: true,
20
- imports: [
21
- CommonModule,
22
- FormsModule,
23
- MatFormFieldModule,
24
- MatInputModule,
25
- MatButtonModule,
26
- MatIconModule,
27
- MatProgressBarModule,
28
- MatCardModule,
29
- MatSnackBarModule,
30
- MatProgressSpinnerModule
31
- ],
32
- templateUrl: './user-info.component.html',
33
- styleUrls: ['./user-info.component.scss']
34
- })
35
- export class UserInfoComponent implements OnDestroy {
36
- private apiService = inject(ApiService);
37
- private authService = inject(AuthService);
38
- private snackBar = inject(MatSnackBar);
39
- private router = inject(Router);
40
-
41
- username = this.authService.getUsername() || '';
42
- currentPassword = '';
43
- newPassword = '';
44
- confirmPassword = '';
45
- saving = false;
46
- showCurrentPassword = false;
47
- showNewPassword = false;
48
- showConfirmPassword = false;
49
-
50
- // Memory leak prevention
51
- private destroyed$ = new Subject<void>();
52
-
53
- ngOnDestroy() {
54
- this.destroyed$.next();
55
- this.destroyed$.complete();
56
- }
57
-
58
- get passwordStrength(): { level: number; text: string; color: string } {
59
- if (!this.newPassword) {
60
- return { level: 0, text: '', color: '' };
61
- }
62
-
63
- let strength = 0;
64
-
65
- // Length check
66
- if (this.newPassword.length >= 8) strength++;
67
- if (this.newPassword.length >= 12) strength++;
68
-
69
- // Character variety
70
- if (/[a-z]/.test(this.newPassword)) strength++;
71
- if (/[A-Z]/.test(this.newPassword)) strength++;
72
- if (/[0-9]/.test(this.newPassword)) strength++;
73
- if (/[^a-zA-Z0-9]/.test(this.newPassword)) strength++;
74
-
75
- if (strength <= 2) {
76
- return { level: 33, text: 'Weak', color: 'warn' };
77
- } else if (strength <= 4) {
78
- return { level: 66, text: 'Medium', color: 'accent' };
79
- } else {
80
- return { level: 100, text: 'Strong', color: 'primary' };
81
- }
82
- }
83
-
84
- get isFormValid(): boolean {
85
- return !!this.currentPassword &&
86
- !!this.newPassword &&
87
- this.newPassword === this.confirmPassword &&
88
- this.newPassword.length >= 8;
89
- }
90
-
91
- changePassword() {
92
- if (!this.isFormValid || this.saving) return;
93
-
94
- this.saving = true;
95
-
96
- this.apiService.changePassword(this.currentPassword, this.newPassword)
97
- .pipe(takeUntil(this.destroyed$))
98
- .subscribe({
99
- next: () => {
100
- this.saving = false;
101
-
102
- // Clear form
103
- this.currentPassword = '';
104
- this.newPassword = '';
105
- this.confirmPassword = '';
106
-
107
- // Show success message
108
- this.snackBar.open(
109
- 'Password changed successfully. Please login again.',
110
- 'OK',
111
- {
112
- duration: 5000,
113
- panelClass: ['success-snackbar']
114
- }
115
- ).afterDismissed().subscribe(() => {
116
- // Logout after password change
117
- this.authService.logout();
118
- });
119
- },
120
- error: (error) => {
121
- this.saving = false;
122
-
123
- // Handle validation errors
124
- if (error.status === 422 && error.error?.details) {
125
- // Field-level errors
126
- const fieldErrors = error.error.details;
127
- if (fieldErrors.some((e: any) => e.field === 'current_password')) {
128
- this.snackBar.open(
129
- 'Current password is incorrect',
130
- 'Close',
131
- {
132
- duration: 5000,
133
- panelClass: ['error-snackbar']
134
- }
135
- );
136
- } else if (fieldErrors.some((e: any) => e.field === 'new_password')) {
137
- const pwError = fieldErrors.find((e: any) => e.field === 'new_password');
138
- this.snackBar.open(
139
- pwError.message || 'New password does not meet requirements',
140
- 'Close',
141
- {
142
- duration: 5000,
143
- panelClass: ['error-snackbar']
144
- }
145
- );
146
- }
147
- } else {
148
- // Generic error
149
- this.snackBar.open(
150
- error.error?.detail || 'Failed to change password',
151
- 'Close',
152
- {
153
- duration: 5000,
154
- panelClass: ['error-snackbar']
155
- }
156
- );
157
- }
158
- }
159
- });
160
- }
161
-
162
- togglePasswordVisibility(field: 'current' | 'new' | 'confirm') {
163
- switch(field) {
164
- case 'current':
165
- this.showCurrentPassword = !this.showCurrentPassword;
166
- break;
167
- case 'new':
168
- this.showNewPassword = !this.showNewPassword;
169
- break;
170
- case 'confirm':
171
- this.showConfirmPassword = !this.showConfirmPassword;
172
- break;
173
- }
174
- }
175
  }
 
1
+ import { Component, inject, OnDestroy } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { Router } from '@angular/router';
5
+ import { MatFormFieldModule } from '@angular/material/form-field';
6
+ import { MatInputModule } from '@angular/material/input';
7
+ import { MatButtonModule } from '@angular/material/button';
8
+ import { MatIconModule } from '@angular/material/icon';
9
+ import { MatProgressBarModule } from '@angular/material/progress-bar';
10
+ import { MatCardModule } from '@angular/material/card';
11
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
12
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
13
+ import { ApiService } from '../../services/api.service';
14
+ import { AuthService } from '../../services/auth.service';
15
+ import { Subject, takeUntil } from 'rxjs';
16
+
17
+ @Component({
18
+ selector: 'app-user-info',
19
+ standalone: true,
20
+ imports: [
21
+ CommonModule,
22
+ FormsModule,
23
+ MatFormFieldModule,
24
+ MatInputModule,
25
+ MatButtonModule,
26
+ MatIconModule,
27
+ MatProgressBarModule,
28
+ MatCardModule,
29
+ MatSnackBarModule,
30
+ MatProgressSpinnerModule
31
+ ],
32
+ templateUrl: './user-info.component.html',
33
+ styleUrls: ['./user-info.component.scss']
34
+ })
35
+ export class UserInfoComponent implements OnDestroy {
36
+ private apiService = inject(ApiService);
37
+ private authService = inject(AuthService);
38
+ private snackBar = inject(MatSnackBar);
39
+ private router = inject(Router);
40
+
41
+ username = this.authService.getUsername() || '';
42
+ currentPassword = '';
43
+ newPassword = '';
44
+ confirmPassword = '';
45
+ saving = false;
46
+ showCurrentPassword = false;
47
+ showNewPassword = false;
48
+ showConfirmPassword = false;
49
+
50
+ // Memory leak prevention
51
+ private destroyed$ = new Subject<void>();
52
+
53
+ ngOnDestroy() {
54
+ this.destroyed$.next();
55
+ this.destroyed$.complete();
56
+ }
57
+
58
+ get passwordStrength(): { level: number; text: string; color: string } {
59
+ if (!this.newPassword) {
60
+ return { level: 0, text: '', color: '' };
61
+ }
62
+
63
+ let strength = 0;
64
+
65
+ // Length check
66
+ if (this.newPassword.length >= 8) strength++;
67
+ if (this.newPassword.length >= 12) strength++;
68
+
69
+ // Character variety
70
+ if (/[a-z]/.test(this.newPassword)) strength++;
71
+ if (/[A-Z]/.test(this.newPassword)) strength++;
72
+ if (/[0-9]/.test(this.newPassword)) strength++;
73
+ if (/[^a-zA-Z0-9]/.test(this.newPassword)) strength++;
74
+
75
+ if (strength <= 2) {
76
+ return { level: 33, text: 'Weak', color: 'warn' };
77
+ } else if (strength <= 4) {
78
+ return { level: 66, text: 'Medium', color: 'accent' };
79
+ } else {
80
+ return { level: 100, text: 'Strong', color: 'primary' };
81
+ }
82
+ }
83
+
84
+ get isFormValid(): boolean {
85
+ return !!this.currentPassword &&
86
+ !!this.newPassword &&
87
+ this.newPassword === this.confirmPassword &&
88
+ this.newPassword.length >= 8;
89
+ }
90
+
91
+ changePassword() {
92
+ if (!this.isFormValid || this.saving) return;
93
+
94
+ this.saving = true;
95
+
96
+ this.apiService.changePassword(this.currentPassword, this.newPassword)
97
+ .pipe(takeUntil(this.destroyed$))
98
+ .subscribe({
99
+ next: () => {
100
+ this.saving = false;
101
+
102
+ // Clear form
103
+ this.currentPassword = '';
104
+ this.newPassword = '';
105
+ this.confirmPassword = '';
106
+
107
+ // Show success message
108
+ this.snackBar.open(
109
+ 'Password changed successfully. Please login again.',
110
+ 'OK',
111
+ {
112
+ duration: 5000,
113
+ panelClass: ['success-snackbar']
114
+ }
115
+ ).afterDismissed().subscribe(() => {
116
+ // Logout after password change
117
+ this.authService.logout();
118
+ });
119
+ },
120
+ error: (error) => {
121
+ this.saving = false;
122
+
123
+ // Handle validation errors
124
+ if (error.status === 422 && error.error?.details) {
125
+ // Field-level errors
126
+ const fieldErrors = error.error.details;
127
+ if (fieldErrors.some((e: any) => e.field === 'current_password')) {
128
+ this.snackBar.open(
129
+ 'Current password is incorrect',
130
+ 'Close',
131
+ {
132
+ duration: 5000,
133
+ panelClass: ['error-snackbar']
134
+ }
135
+ );
136
+ } else if (fieldErrors.some((e: any) => e.field === 'new_password')) {
137
+ const pwError = fieldErrors.find((e: any) => e.field === 'new_password');
138
+ this.snackBar.open(
139
+ pwError.message || 'New password does not meet requirements',
140
+ 'Close',
141
+ {
142
+ duration: 5000,
143
+ panelClass: ['error-snackbar']
144
+ }
145
+ );
146
+ }
147
+ } else {
148
+ // Generic error
149
+ this.snackBar.open(
150
+ error.error?.detail || 'Failed to change password',
151
+ 'Close',
152
+ {
153
+ duration: 5000,
154
+ panelClass: ['error-snackbar']
155
+ }
156
+ );
157
+ }
158
+ }
159
+ });
160
+ }
161
+
162
+ togglePasswordVisibility(field: 'current' | 'new' | 'confirm') {
163
+ switch(field) {
164
+ case 'current':
165
+ this.showCurrentPassword = !this.showCurrentPassword;
166
+ break;
167
+ case 'new':
168
+ this.showNewPassword = !this.showNewPassword;
169
+ break;
170
+ case 'confirm':
171
+ this.showConfirmPassword = !this.showConfirmPassword;
172
+ break;
173
+ }
174
+ }
175
  }
flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html CHANGED
@@ -1,482 +1,482 @@
1
- <h2 mat-dialog-title>
2
- @if (data.mode === 'create') {
3
- Create New API
4
- } @else if (data.mode === 'duplicate') {
5
- Duplicate API
6
- } @else if (data.mode === 'test') {
7
- Test API: {{ data.api.name }}
8
- } @else {
9
- Edit API: {{ data.api.name }}
10
- }
11
- </h2>
12
-
13
- <mat-dialog-content>
14
- <mat-tab-group [(selectedIndex)]="activeTabIndex">
15
- <!-- General Tab -->
16
- <mat-tab label="General">
17
- <div class="tab-content">
18
- <mat-form-field appearance="outline">
19
- <mat-label>Name</mat-label>
20
- <input matInput [formControl]="$any(form.get('name'))" placeholder="e.g., get_flights">
21
- <mat-hint>Unique identifier for this API</mat-hint>
22
- @if (form.get('name')?.hasError('required') && form.get('name')?.touched) {
23
- <mat-error>Name is required</mat-error>
24
- }
25
- @if (form.get('name')?.hasError('pattern') && form.get('name')?.touched) {
26
- <mat-error>Only alphanumeric and underscore allowed</mat-error>
27
- }
28
- </mat-form-field>
29
-
30
- <mat-form-field appearance="outline">
31
- <mat-label>URL</mat-label>
32
- <input matInput [formControl]="$any(form.get('url'))" placeholder="https://api.example.com/endpoint">
33
- <mat-hint>Full URL including protocol</mat-hint>
34
- @if (form.get('url')?.hasError('required') && form.get('url')?.touched) {
35
- <mat-error>URL is required</mat-error>
36
- }
37
- @if (form.get('url')?.hasError('pattern') && form.get('url')?.touched) {
38
- <mat-error>Invalid URL format</mat-error>
39
- }
40
- </mat-form-field>
41
-
42
- <div class="row">
43
- <mat-form-field appearance="outline" class="method-field">
44
- <mat-label>Method</mat-label>
45
- <mat-select [formControl]="$any(form.get('method'))">
46
- @for (method of httpMethods; track method) {
47
- <mat-option [value]="method">{{ method }}</mat-option>
48
- }
49
- </mat-select>
50
- </mat-form-field>
51
-
52
- <mat-form-field appearance="outline" class="timeout-field">
53
- <mat-label>Timeout (seconds)</mat-label>
54
- <input matInput type="number" [formControl]="$any(form.get('timeout_seconds'))">
55
- <mat-hint>Request timeout in seconds</mat-hint>
56
- @if (form.get('timeout_seconds')?.hasError('min')) {
57
- <mat-error>Minimum 1 second</mat-error>
58
- }
59
- @if (form.get('timeout_seconds')?.hasError('max')) {
60
- <mat-error>Maximum 300 seconds</mat-error>
61
- }
62
- </mat-form-field>
63
- </div>
64
-
65
- <app-json-editor
66
- [formControl]="$any(form.get('body_template'))"
67
- label="Body Template"
68
- placeholder='{"key": "value"}'
69
- hint="JSON template with template variable support"
70
- [rows]="8"
71
- [availableVariables]="getTemplateVariables(false)"
72
- [variableReplacer]="replaceVariablesForValidation">
73
- </app-json-editor>
74
-
75
- <mat-form-field appearance="outline">
76
- <mat-label>Proxy URL (Optional)</mat-label>
77
- <input matInput [formControl]="$any(form.get('proxy'))" placeholder="http://proxy.example.com:8080">
78
- <mat-hint>HTTP proxy for this API call</mat-hint>
79
- </mat-form-field>
80
- </div>
81
- </mat-tab>
82
-
83
- <!-- Headers Tab -->
84
- <mat-tab label="Headers">
85
- <div class="tab-content">
86
- <div class="array-section">
87
- <div class="section-header">
88
- <h3>Request Headers</h3>
89
- <button mat-button color="primary" (click)="addHeader()">
90
- <mat-icon>add</mat-icon>
91
- Add Header
92
- </button>
93
- </div>
94
-
95
- @if (headers.length === 0) {
96
- <p class="empty-message">No headers configured. Click "Add Header" to add one.</p>
97
- }
98
-
99
- @for (header of headers.controls; track header; let i = $index) {
100
- <div class="array-item" [formGroup]="$any(header)">
101
- <mat-form-field appearance="outline" class="key-field">
102
- <mat-label>Header Name</mat-label>
103
- <input matInput formControlName="key" placeholder="Content-Type">
104
- </mat-form-field>
105
-
106
- <mat-form-field appearance="outline" class="value-field">
107
- <mat-label>Header Value</mat-label>
108
- <input matInput formControlName="value" placeholder="application/json">
109
- <button mat-icon-button matSuffix [matMenuTriggerFor]="headerMenu">
110
- <mat-icon>code</mat-icon>
111
- </button>
112
- <mat-menu #headerMenu="matMenu">
113
- @for (variable of getTemplateVariables(); track variable) {
114
- <button mat-menu-item (click)="insertHeaderValue(i, variable)">
115
- {{ variable }}
116
- </button>
117
- }
118
- </mat-menu>
119
- </mat-form-field>
120
-
121
- <button mat-icon-button color="warn" (click)="removeHeader(i)">
122
- <mat-icon>delete</mat-icon>
123
- </button>
124
- </div>
125
- }
126
- </div>
127
- </div>
128
- </mat-tab>
129
-
130
- <!-- Auth Tab -->
131
- <mat-tab label="Authentication">
132
- <div class="tab-content" [formGroup]="$any(form.get('auth'))">
133
- <mat-checkbox formControlName="enabled">
134
- Enable Authentication
135
- </mat-checkbox>
136
-
137
- @if (form.get('auth.enabled')?.value) {
138
- <mat-divider></mat-divider>
139
-
140
- <div class="auth-section">
141
- <h3>Token Configuration</h3>
142
-
143
- <mat-form-field appearance="outline">
144
- <mat-label>Token Endpoint</mat-label>
145
- <input matInput formControlName="token_endpoint" placeholder="https://api.example.com/auth/token">
146
- <mat-hint>URL to obtain authentication token</mat-hint>
147
- @if (form.get('auth.token_endpoint')?.hasError('required') && form.get('auth.token_endpoint')?.touched) {
148
- <mat-error>Token endpoint is required when auth is enabled</mat-error>
149
- }
150
- </mat-form-field>
151
-
152
- <mat-form-field appearance="outline">
153
- <mat-label>Token Response Path</mat-label>
154
- <input matInput formControlName="response_token_path" placeholder="token">
155
- <mat-hint>JSON path to extract token from response</mat-hint>
156
- @if (form.get('auth.response_token_path')?.hasError('required') && form.get('auth.response_token_path')?.touched) {
157
- <mat-error>Token path is required when auth is enabled</mat-error>
158
- }
159
- </mat-form-field>
160
-
161
- <app-json-editor
162
- formControlName="token_request_body"
163
- label="Token Request Body"
164
- placeholder='{"username": "api_user", "password": "api_pass"}'
165
- hint="JSON body for token request"
166
- [rows]="6"
167
- [availableVariables]="getTemplateVariables()"
168
- [variableReplacer]="replaceVariablesForValidation">
169
- </app-json-editor>
170
-
171
- <mat-divider></mat-divider>
172
-
173
- <h3>Token Refresh (Optional)</h3>
174
-
175
- <mat-form-field appearance="outline">
176
- <mat-label>Refresh Endpoint</mat-label>
177
- <input matInput formControlName="token_refresh_endpoint" placeholder="https://api.example.com/auth/refresh">
178
- <mat-hint>URL to refresh expired token</mat-hint>
179
- </mat-form-field>
180
-
181
- <app-json-editor
182
- formControlName="token_refresh_body"
183
- label="Refresh Request Body"
184
- placeholder='{"refresh_token": "your_refresh_token"}'
185
- hint="JSON body for refresh request"
186
- [rows]="4"
187
- [availableVariables]="getTemplateVariables()"
188
- [variableReplacer]="replaceVariablesForValidation">
189
- </app-json-editor>
190
- </div>
191
- }
192
- </div>
193
- </mat-tab>
194
-
195
- <!-- Response Tab -->
196
- <mat-tab label="Response">
197
- <div class="tab-content">
198
- <mat-form-field appearance="outline" class="full-width">
199
- <mat-label>Response Prompt</mat-label>
200
- <textarea matInput
201
- [formControl]="$any(form.get('response_prompt'))"
202
- rows="4"
203
- placeholder="Optional instructions for processing the response"></textarea>
204
- <mat-hint>Instructions for AI to process the response (optional)</mat-hint>
205
- </mat-form-field>
206
-
207
- <mat-divider></mat-divider>
208
-
209
- <div class="array-section">
210
- <div class="section-header">
211
- <h3>Response Mappings</h3>
212
- <button mat-button color="primary" (click)="addResponseMapping()">
213
- <mat-icon>add</mat-icon>
214
- Add Mapping
215
- </button>
216
- </div>
217
-
218
- @if (responseMappings.length === 0) {
219
- <p class="empty-message">No response mappings configured.</p>
220
- }
221
-
222
- @for (mapping of responseMappings.controls; track mapping; let i = $index) {
223
- <mat-expansion-panel [formGroup]="$any(mapping)">
224
- <mat-expansion-panel-header>
225
- <mat-panel-title>
226
- {{ mapping.get('variable_name')?.value || 'New Mapping' }}
227
- </mat-panel-title>
228
- <mat-panel-description>
229
- {{ mapping.get('json_path')?.value || 'Configure mapping' }}
230
- </mat-panel-description>
231
- </mat-expansion-panel-header>
232
-
233
- <div class="mapping-content">
234
- <mat-form-field appearance="outline">
235
- <mat-label>Variable Name</mat-label>
236
- <input matInput formControlName="variable_name" placeholder="booking_ref">
237
- <mat-hint>Name to store the extracted value</mat-hint>
238
- @if (mapping.get('variable_name')?.hasError('pattern')) {
239
- <mat-error>Lowercase letters, numbers and underscore only</mat-error>
240
- }
241
- </mat-form-field>
242
-
243
- <mat-form-field appearance="outline">
244
- <mat-label>Caption</mat-label>
245
- <input matInput formControlName="caption" placeholder="Booking Reference">
246
- <mat-hint>Human-readable description</mat-hint>
247
- </mat-form-field>
248
-
249
- <div class="row">
250
- <mat-form-field appearance="outline" class="type-field">
251
- <mat-label>Type</mat-label>
252
- <mat-select formControlName="type">
253
- @for (type of variableTypes; track type) {
254
- <mat-option [value]="type">{{ type }}</mat-option>
255
- }
256
- </mat-select>
257
- </mat-form-field>
258
-
259
- <mat-form-field appearance="outline" class="path-field">
260
- <mat-label>JSON Path</mat-label>
261
- <input matInput formControlName="json_path" placeholder="$.data.bookingReference">
262
- <mat-hint>JSONPath expression to extract value</mat-hint>
263
- </mat-form-field>
264
- </div>
265
-
266
- <button mat-button color="warn" (click)="removeResponseMapping(i)">
267
- <mat-icon>delete</mat-icon>
268
- Remove Mapping
269
- </button>
270
- </div>
271
- </mat-expansion-panel>
272
- }
273
- </div>
274
-
275
- <!-- Retry Settings -->
276
- <mat-divider></mat-divider>
277
-
278
- <div class="retry-section" [formGroup]="$any(form.get('retry'))">
279
- <h3>Retry Settings</h3>
280
-
281
- <div class="row">
282
- <mat-form-field appearance="outline">
283
- <mat-label>Retry Count</mat-label>
284
- <input matInput type="number" formControlName="retry_count">
285
- <mat-hint>Number of retry attempts</mat-hint>
286
- </mat-form-field>
287
-
288
- <mat-form-field appearance="outline">
289
- <mat-label>Backoff (seconds)</mat-label>
290
- <input matInput type="number" formControlName="backoff_seconds">
291
- <mat-hint>Delay between retries</mat-hint>
292
- </mat-form-field>
293
-
294
- <mat-form-field appearance="outline">
295
- <mat-label>Strategy</mat-label>
296
- <mat-select formControlName="strategy">
297
- @for (strategy of retryStrategies; track strategy) {
298
- <mat-option [value]="strategy">{{ strategy }}</mat-option>
299
- }
300
- </mat-select>
301
- </mat-form-field>
302
- </div>
303
- </div>
304
- </div>
305
- </mat-tab>
306
-
307
- <!-- Test Tab -->
308
- <mat-tab label="Test">
309
- <div class="tab-content">
310
- <div class="test-section">
311
- <h3>Test API Call</h3>
312
-
313
- <div class="test-controls">
314
- <button mat-raised-button color="primary"
315
- (click)="testAPI()"
316
- [disabled]="testing || !form.get('url')?.valid || !form.get('method')?.valid">
317
- @if (testing) {
318
- <ng-container>
319
- <mat-icon class="spin">sync</mat-icon>
320
- Testing...
321
- </ng-container>
322
- } @else {
323
- <ng-container>
324
- <mat-icon>play_arrow</mat-icon>
325
- Test API
326
- </ng-container>
327
- }
328
- </button>
329
-
330
- <button mat-button (click)="updateTestRequestJson()">
331
- <mat-icon>refresh</mat-icon>
332
- Generate Test Data
333
- </button>
334
- </div>
335
-
336
- <app-json-editor
337
- [(ngModel)]="testRequestJson"
338
- label="Test Request Body"
339
- placeholder="Enter test request JSON here"
340
- hint="Variables will be replaced with test values"
341
- [rows]="10">
342
- </app-json-editor>
343
-
344
- @if (testResult) {
345
- <mat-divider></mat-divider>
346
-
347
- <div class="test-result" [class.success]="testResult.success" [class.error]="!testResult.success">
348
- <h4>Test Result</h4>
349
-
350
- @if (testResult.success) {
351
- <div class="result-status">
352
- <mat-icon>check_circle</mat-icon>
353
- <span>Success ({{ testResult.status_code }})</span>
354
- </div>
355
- } @else {
356
- <div class="result-status">
357
- <mat-icon>error</mat-icon>
358
- <span>Failed: {{ testResult.error }}</span>
359
- </div>
360
- }
361
-
362
- @if (testResult.response_time) {
363
- <p><strong>Response Time:</strong> {{ testResult.response_time }}ms</p>
364
- }
365
-
366
- @if (testResult.response_headers) {
367
- <mat-expansion-panel>
368
- <mat-expansion-panel-header>
369
- <mat-panel-title>Response Headers</mat-panel-title>
370
- </mat-expansion-panel-header>
371
- <mat-form-field appearance="outline" class="full-width">
372
- <mat-label>Headers</mat-label>
373
- <textarea matInput
374
- [value]="testResult.response_headers | json"
375
- rows="6"
376
- readonly></textarea>
377
- </mat-form-field>
378
- </mat-expansion-panel>
379
- }
380
-
381
- @if (testResult.response_body) {
382
- <mat-expansion-panel [expanded]="true">
383
- <mat-expansion-panel-header>
384
- <mat-panel-title>Response Body</mat-panel-title>
385
- </mat-expansion-panel-header>
386
- <mat-form-field appearance="outline" class="full-width">
387
- <mat-label>Response</mat-label>
388
- <textarea matInput
389
- [value]="testResult.response_body | json"
390
- rows="12"
391
- readonly></textarea>
392
- </mat-form-field>
393
- </mat-expansion-panel>
394
- }
395
-
396
- @if (testResult.request_body) {
397
- <mat-expansion-panel>
398
- <mat-expansion-panel-header>
399
- <mat-panel-title>Request Details</mat-panel-title>
400
- </mat-expansion-panel-header>
401
- <mat-form-field appearance="outline" class="full-width">
402
- <mat-label>Actual Request Sent</mat-label>
403
- <textarea matInput
404
- [value]="testResult.request_body | json"
405
- rows="8"
406
- readonly></textarea>
407
- </mat-form-field>
408
-
409
- @if (testResult.request_headers) {
410
- <mat-form-field appearance="outline" class="full-width">
411
- <mat-label>Request Headers</mat-label>
412
- <textarea matInput
413
- [value]="testResult.request_headers | json"
414
- rows="6"
415
- readonly></textarea>
416
- </mat-form-field>
417
- }
418
- </mat-expansion-panel>
419
- }
420
-
421
- @if (testResult.extracted_values && testResult.extracted_values.length > 0) {
422
- <mat-expansion-panel>
423
- <mat-expansion-panel-header>
424
- <mat-panel-title>Extracted Values</mat-panel-title>
425
- </mat-expansion-panel-header>
426
- <table mat-table [dataSource]="testResult.extracted_values" class="full-width">
427
- <ng-container matColumnDef="variable">
428
- <th mat-header-cell *matHeaderCellDef>Variable</th>
429
- <td mat-cell *matCellDef="let element">{{ element.variable_name }}</td>
430
- </ng-container>
431
-
432
- <ng-container matColumnDef="value">
433
- <th mat-header-cell *matHeaderCellDef>Value</th>
434
- <td mat-cell *matCellDef="let element">{{ element.value }}</td>
435
- </ng-container>
436
-
437
- <ng-container matColumnDef="type">
438
- <th mat-header-cell *matHeaderCellDef>Type</th>
439
- <td mat-cell *matCellDef="let element">{{ element.type }}</td>
440
- </ng-container>
441
-
442
- <tr mat-header-row *matHeaderRowDef="['variable', 'value', 'type']"></tr>
443
- <tr mat-row *matRowDef="let row; columns: ['variable', 'value', 'type'];"></tr>
444
- </table>
445
- </mat-expansion-panel>
446
- }
447
- </div>
448
- }
449
- </div>
450
- </div>
451
- </mat-tab>
452
-
453
- </mat-tab-group>
454
- </mat-dialog-content>
455
-
456
- <mat-dialog-actions align="end">
457
- <button mat-button (click)="cancel()">
458
- @if (data.mode === 'test') {
459
- Close
460
- } @else {
461
- Cancel
462
- }
463
- </button>
464
- @if (data.mode !== 'test') {
465
- <button mat-raised-button color="primary"
466
- (click)="save()"
467
- [disabled]="saving || form.invalid">
468
- @if (saving) {
469
- <ng-container>
470
- <mat-icon class="spin">sync</mat-icon>
471
- Saving...
472
- </ng-container>
473
- } @else {
474
- @if (data.mode === 'create' || data.mode === 'duplicate') {
475
- Create
476
- } @else {
477
- Update
478
- }
479
- }
480
- </button>
481
- }
482
  </mat-dialog-actions>
 
1
+ <h2 mat-dialog-title>
2
+ @if (data.mode === 'create') {
3
+ Create New API
4
+ } @else if (data.mode === 'duplicate') {
5
+ Duplicate API
6
+ } @else if (data.mode === 'test') {
7
+ Test API: {{ data.api.name }}
8
+ } @else {
9
+ Edit API: {{ data.api.name }}
10
+ }
11
+ </h2>
12
+
13
+ <mat-dialog-content>
14
+ <mat-tab-group [(selectedIndex)]="activeTabIndex">
15
+ <!-- General Tab -->
16
+ <mat-tab label="General">
17
+ <div class="tab-content">
18
+ <mat-form-field appearance="outline">
19
+ <mat-label>Name</mat-label>
20
+ <input matInput [formControl]="$any(form.get('name'))" placeholder="e.g., get_flights">
21
+ <mat-hint>Unique identifier for this API</mat-hint>
22
+ @if (form.get('name')?.hasError('required') && form.get('name')?.touched) {
23
+ <mat-error>Name is required</mat-error>
24
+ }
25
+ @if (form.get('name')?.hasError('pattern') && form.get('name')?.touched) {
26
+ <mat-error>Only alphanumeric and underscore allowed</mat-error>
27
+ }
28
+ </mat-form-field>
29
+
30
+ <mat-form-field appearance="outline">
31
+ <mat-label>URL</mat-label>
32
+ <input matInput [formControl]="$any(form.get('url'))" placeholder="https://api.example.com/endpoint">
33
+ <mat-hint>Full URL including protocol</mat-hint>
34
+ @if (form.get('url')?.hasError('required') && form.get('url')?.touched) {
35
+ <mat-error>URL is required</mat-error>
36
+ }
37
+ @if (form.get('url')?.hasError('pattern') && form.get('url')?.touched) {
38
+ <mat-error>Invalid URL format</mat-error>
39
+ }
40
+ </mat-form-field>
41
+
42
+ <div class="row">
43
+ <mat-form-field appearance="outline" class="method-field">
44
+ <mat-label>Method</mat-label>
45
+ <mat-select [formControl]="$any(form.get('method'))">
46
+ @for (method of httpMethods; track method) {
47
+ <mat-option [value]="method">{{ method }}</mat-option>
48
+ }
49
+ </mat-select>
50
+ </mat-form-field>
51
+
52
+ <mat-form-field appearance="outline" class="timeout-field">
53
+ <mat-label>Timeout (seconds)</mat-label>
54
+ <input matInput type="number" [formControl]="$any(form.get('timeout_seconds'))">
55
+ <mat-hint>Request timeout in seconds</mat-hint>
56
+ @if (form.get('timeout_seconds')?.hasError('min')) {
57
+ <mat-error>Minimum 1 second</mat-error>
58
+ }
59
+ @if (form.get('timeout_seconds')?.hasError('max')) {
60
+ <mat-error>Maximum 300 seconds</mat-error>
61
+ }
62
+ </mat-form-field>
63
+ </div>
64
+
65
+ <app-json-editor
66
+ [formControl]="$any(form.get('body_template'))"
67
+ label="Body Template"
68
+ placeholder='{"key": "value"}'
69
+ hint="JSON template with template variable support"
70
+ [rows]="8"
71
+ [availableVariables]="getTemplateVariables(false)"
72
+ [variableReplacer]="replaceVariablesForValidation">
73
+ </app-json-editor>
74
+
75
+ <mat-form-field appearance="outline">
76
+ <mat-label>Proxy URL (Optional)</mat-label>
77
+ <input matInput [formControl]="$any(form.get('proxy'))" placeholder="http://proxy.example.com:8080">
78
+ <mat-hint>HTTP proxy for this API call</mat-hint>
79
+ </mat-form-field>
80
+ </div>
81
+ </mat-tab>
82
+
83
+ <!-- Headers Tab -->
84
+ <mat-tab label="Headers">
85
+ <div class="tab-content">
86
+ <div class="array-section">
87
+ <div class="section-header">
88
+ <h3>Request Headers</h3>
89
+ <button mat-button color="primary" (click)="addHeader()">
90
+ <mat-icon>add</mat-icon>
91
+ Add Header
92
+ </button>
93
+ </div>
94
+
95
+ @if (headers.length === 0) {
96
+ <p class="empty-message">No headers configured. Click "Add Header" to add one.</p>
97
+ }
98
+
99
+ @for (header of headers.controls; track header; let i = $index) {
100
+ <div class="array-item" [formGroup]="$any(header)">
101
+ <mat-form-field appearance="outline" class="key-field">
102
+ <mat-label>Header Name</mat-label>
103
+ <input matInput formControlName="key" placeholder="Content-Type">
104
+ </mat-form-field>
105
+
106
+ <mat-form-field appearance="outline" class="value-field">
107
+ <mat-label>Header Value</mat-label>
108
+ <input matInput formControlName="value" placeholder="application/json">
109
+ <button mat-icon-button matSuffix [matMenuTriggerFor]="headerMenu">
110
+ <mat-icon>code</mat-icon>
111
+ </button>
112
+ <mat-menu #headerMenu="matMenu">
113
+ @for (variable of getTemplateVariables(); track variable) {
114
+ <button mat-menu-item (click)="insertHeaderValue(i, variable)">
115
+ {{ variable }}
116
+ </button>
117
+ }
118
+ </mat-menu>
119
+ </mat-form-field>
120
+
121
+ <button mat-icon-button color="warn" (click)="removeHeader(i)">
122
+ <mat-icon>delete</mat-icon>
123
+ </button>
124
+ </div>
125
+ }
126
+ </div>
127
+ </div>
128
+ </mat-tab>
129
+
130
+ <!-- Auth Tab -->
131
+ <mat-tab label="Authentication">
132
+ <div class="tab-content" [formGroup]="$any(form.get('auth'))">
133
+ <mat-checkbox formControlName="enabled">
134
+ Enable Authentication
135
+ </mat-checkbox>
136
+
137
+ @if (form.get('auth.enabled')?.value) {
138
+ <mat-divider></mat-divider>
139
+
140
+ <div class="auth-section">
141
+ <h3>Token Configuration</h3>
142
+
143
+ <mat-form-field appearance="outline">
144
+ <mat-label>Token Endpoint</mat-label>
145
+ <input matInput formControlName="token_endpoint" placeholder="https://api.example.com/auth/token">
146
+ <mat-hint>URL to obtain authentication token</mat-hint>
147
+ @if (form.get('auth.token_endpoint')?.hasError('required') && form.get('auth.token_endpoint')?.touched) {
148
+ <mat-error>Token endpoint is required when auth is enabled</mat-error>
149
+ }
150
+ </mat-form-field>
151
+
152
+ <mat-form-field appearance="outline">
153
+ <mat-label>Token Response Path</mat-label>
154
+ <input matInput formControlName="response_token_path" placeholder="token">
155
+ <mat-hint>JSON path to extract token from response</mat-hint>
156
+ @if (form.get('auth.response_token_path')?.hasError('required') && form.get('auth.response_token_path')?.touched) {
157
+ <mat-error>Token path is required when auth is enabled</mat-error>
158
+ }
159
+ </mat-form-field>
160
+
161
+ <app-json-editor
162
+ formControlName="token_request_body"
163
+ label="Token Request Body"
164
+ placeholder='{"username": "api_user", "password": "api_pass"}'
165
+ hint="JSON body for token request"
166
+ [rows]="6"
167
+ [availableVariables]="getTemplateVariables()"
168
+ [variableReplacer]="replaceVariablesForValidation">
169
+ </app-json-editor>
170
+
171
+ <mat-divider></mat-divider>
172
+
173
+ <h3>Token Refresh (Optional)</h3>
174
+
175
+ <mat-form-field appearance="outline">
176
+ <mat-label>Refresh Endpoint</mat-label>
177
+ <input matInput formControlName="token_refresh_endpoint" placeholder="https://api.example.com/auth/refresh">
178
+ <mat-hint>URL to refresh expired token</mat-hint>
179
+ </mat-form-field>
180
+
181
+ <app-json-editor
182
+ formControlName="token_refresh_body"
183
+ label="Refresh Request Body"
184
+ placeholder='{"refresh_token": "your_refresh_token"}'
185
+ hint="JSON body for refresh request"
186
+ [rows]="4"
187
+ [availableVariables]="getTemplateVariables()"
188
+ [variableReplacer]="replaceVariablesForValidation">
189
+ </app-json-editor>
190
+ </div>
191
+ }
192
+ </div>
193
+ </mat-tab>
194
+
195
+ <!-- Response Tab -->
196
+ <mat-tab label="Response">
197
+ <div class="tab-content">
198
+ <mat-form-field appearance="outline" class="full-width">
199
+ <mat-label>Response Prompt</mat-label>
200
+ <textarea matInput
201
+ [formControl]="$any(form.get('response_prompt'))"
202
+ rows="4"
203
+ placeholder="Optional instructions for processing the response"></textarea>
204
+ <mat-hint>Instructions for AI to process the response (optional)</mat-hint>
205
+ </mat-form-field>
206
+
207
+ <mat-divider></mat-divider>
208
+
209
+ <div class="array-section">
210
+ <div class="section-header">
211
+ <h3>Response Mappings</h3>
212
+ <button mat-button color="primary" (click)="addResponseMapping()">
213
+ <mat-icon>add</mat-icon>
214
+ Add Mapping
215
+ </button>
216
+ </div>
217
+
218
+ @if (responseMappings.length === 0) {
219
+ <p class="empty-message">No response mappings configured.</p>
220
+ }
221
+
222
+ @for (mapping of responseMappings.controls; track mapping; let i = $index) {
223
+ <mat-expansion-panel [formGroup]="$any(mapping)">
224
+ <mat-expansion-panel-header>
225
+ <mat-panel-title>
226
+ {{ mapping.get('variable_name')?.value || 'New Mapping' }}
227
+ </mat-panel-title>
228
+ <mat-panel-description>
229
+ {{ mapping.get('json_path')?.value || 'Configure mapping' }}
230
+ </mat-panel-description>
231
+ </mat-expansion-panel-header>
232
+
233
+ <div class="mapping-content">
234
+ <mat-form-field appearance="outline">
235
+ <mat-label>Variable Name</mat-label>
236
+ <input matInput formControlName="variable_name" placeholder="booking_ref">
237
+ <mat-hint>Name to store the extracted value</mat-hint>
238
+ @if (mapping.get('variable_name')?.hasError('pattern')) {
239
+ <mat-error>Lowercase letters, numbers and underscore only</mat-error>
240
+ }
241
+ </mat-form-field>
242
+
243
+ <mat-form-field appearance="outline">
244
+ <mat-label>Caption</mat-label>
245
+ <input matInput formControlName="caption" placeholder="Booking Reference">
246
+ <mat-hint>Human-readable description</mat-hint>
247
+ </mat-form-field>
248
+
249
+ <div class="row">
250
+ <mat-form-field appearance="outline" class="type-field">
251
+ <mat-label>Type</mat-label>
252
+ <mat-select formControlName="type">
253
+ @for (type of variableTypes; track type) {
254
+ <mat-option [value]="type">{{ type }}</mat-option>
255
+ }
256
+ </mat-select>
257
+ </mat-form-field>
258
+
259
+ <mat-form-field appearance="outline" class="path-field">
260
+ <mat-label>JSON Path</mat-label>
261
+ <input matInput formControlName="json_path" placeholder="$.data.bookingReference">
262
+ <mat-hint>JSONPath expression to extract value</mat-hint>
263
+ </mat-form-field>
264
+ </div>
265
+
266
+ <button mat-button color="warn" (click)="removeResponseMapping(i)">
267
+ <mat-icon>delete</mat-icon>
268
+ Remove Mapping
269
+ </button>
270
+ </div>
271
+ </mat-expansion-panel>
272
+ }
273
+ </div>
274
+
275
+ <!-- Retry Settings -->
276
+ <mat-divider></mat-divider>
277
+
278
+ <div class="retry-section" [formGroup]="$any(form.get('retry'))">
279
+ <h3>Retry Settings</h3>
280
+
281
+ <div class="row">
282
+ <mat-form-field appearance="outline">
283
+ <mat-label>Retry Count</mat-label>
284
+ <input matInput type="number" formControlName="retry_count">
285
+ <mat-hint>Number of retry attempts</mat-hint>
286
+ </mat-form-field>
287
+
288
+ <mat-form-field appearance="outline">
289
+ <mat-label>Backoff (seconds)</mat-label>
290
+ <input matInput type="number" formControlName="backoff_seconds">
291
+ <mat-hint>Delay between retries</mat-hint>
292
+ </mat-form-field>
293
+
294
+ <mat-form-field appearance="outline">
295
+ <mat-label>Strategy</mat-label>
296
+ <mat-select formControlName="strategy">
297
+ @for (strategy of retryStrategies; track strategy) {
298
+ <mat-option [value]="strategy">{{ strategy }}</mat-option>
299
+ }
300
+ </mat-select>
301
+ </mat-form-field>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </mat-tab>
306
+
307
+ <!-- Test Tab -->
308
+ <mat-tab label="Test">
309
+ <div class="tab-content">
310
+ <div class="test-section">
311
+ <h3>Test API Call</h3>
312
+
313
+ <div class="test-controls">
314
+ <button mat-raised-button color="primary"
315
+ (click)="testAPI()"
316
+ [disabled]="testing || !form.get('url')?.valid || !form.get('method')?.valid">
317
+ @if (testing) {
318
+ <ng-container>
319
+ <mat-icon class="spin">sync</mat-icon>
320
+ Testing...
321
+ </ng-container>
322
+ } @else {
323
+ <ng-container>
324
+ <mat-icon>play_arrow</mat-icon>
325
+ Test API
326
+ </ng-container>
327
+ }
328
+ </button>
329
+
330
+ <button mat-button (click)="updateTestRequestJson()">
331
+ <mat-icon>refresh</mat-icon>
332
+ Generate Test Data
333
+ </button>
334
+ </div>
335
+
336
+ <app-json-editor
337
+ [(ngModel)]="testRequestJson"
338
+ label="Test Request Body"
339
+ placeholder="Enter test request JSON here"
340
+ hint="Variables will be replaced with test values"
341
+ [rows]="10">
342
+ </app-json-editor>
343
+
344
+ @if (testResult) {
345
+ <mat-divider></mat-divider>
346
+
347
+ <div class="test-result" [class.success]="testResult.success" [class.error]="!testResult.success">
348
+ <h4>Test Result</h4>
349
+
350
+ @if (testResult.success) {
351
+ <div class="result-status">
352
+ <mat-icon>check_circle</mat-icon>
353
+ <span>Success ({{ testResult.status_code }})</span>
354
+ </div>
355
+ } @else {
356
+ <div class="result-status">
357
+ <mat-icon>error</mat-icon>
358
+ <span>Failed: {{ testResult.error }}</span>
359
+ </div>
360
+ }
361
+
362
+ @if (testResult.response_time) {
363
+ <p><strong>Response Time:</strong> {{ testResult.response_time }}ms</p>
364
+ }
365
+
366
+ @if (testResult.response_headers) {
367
+ <mat-expansion-panel>
368
+ <mat-expansion-panel-header>
369
+ <mat-panel-title>Response Headers</mat-panel-title>
370
+ </mat-expansion-panel-header>
371
+ <mat-form-field appearance="outline" class="full-width">
372
+ <mat-label>Headers</mat-label>
373
+ <textarea matInput
374
+ [value]="testResult.response_headers | json"
375
+ rows="6"
376
+ readonly></textarea>
377
+ </mat-form-field>
378
+ </mat-expansion-panel>
379
+ }
380
+
381
+ @if (testResult.response_body) {
382
+ <mat-expansion-panel [expanded]="true">
383
+ <mat-expansion-panel-header>
384
+ <mat-panel-title>Response Body</mat-panel-title>
385
+ </mat-expansion-panel-header>
386
+ <mat-form-field appearance="outline" class="full-width">
387
+ <mat-label>Response</mat-label>
388
+ <textarea matInput
389
+ [value]="testResult.response_body | json"
390
+ rows="12"
391
+ readonly></textarea>
392
+ </mat-form-field>
393
+ </mat-expansion-panel>
394
+ }
395
+
396
+ @if (testResult.request_body) {
397
+ <mat-expansion-panel>
398
+ <mat-expansion-panel-header>
399
+ <mat-panel-title>Request Details</mat-panel-title>
400
+ </mat-expansion-panel-header>
401
+ <mat-form-field appearance="outline" class="full-width">
402
+ <mat-label>Actual Request Sent</mat-label>
403
+ <textarea matInput
404
+ [value]="testResult.request_body | json"
405
+ rows="8"
406
+ readonly></textarea>
407
+ </mat-form-field>
408
+
409
+ @if (testResult.request_headers) {
410
+ <mat-form-field appearance="outline" class="full-width">
411
+ <mat-label>Request Headers</mat-label>
412
+ <textarea matInput
413
+ [value]="testResult.request_headers | json"
414
+ rows="6"
415
+ readonly></textarea>
416
+ </mat-form-field>
417
+ }
418
+ </mat-expansion-panel>
419
+ }
420
+
421
+ @if (testResult.extracted_values && testResult.extracted_values.length > 0) {
422
+ <mat-expansion-panel>
423
+ <mat-expansion-panel-header>
424
+ <mat-panel-title>Extracted Values</mat-panel-title>
425
+ </mat-expansion-panel-header>
426
+ <table mat-table [dataSource]="testResult.extracted_values" class="full-width">
427
+ <ng-container matColumnDef="variable">
428
+ <th mat-header-cell *matHeaderCellDef>Variable</th>
429
+ <td mat-cell *matCellDef="let element">{{ element.variable_name }}</td>
430
+ </ng-container>
431
+
432
+ <ng-container matColumnDef="value">
433
+ <th mat-header-cell *matHeaderCellDef>Value</th>
434
+ <td mat-cell *matCellDef="let element">{{ element.value }}</td>
435
+ </ng-container>
436
+
437
+ <ng-container matColumnDef="type">
438
+ <th mat-header-cell *matHeaderCellDef>Type</th>
439
+ <td mat-cell *matCellDef="let element">{{ element.type }}</td>
440
+ </ng-container>
441
+
442
+ <tr mat-header-row *matHeaderRowDef="['variable', 'value', 'type']"></tr>
443
+ <tr mat-row *matRowDef="let row; columns: ['variable', 'value', 'type'];"></tr>
444
+ </table>
445
+ </mat-expansion-panel>
446
+ }
447
+ </div>
448
+ }
449
+ </div>
450
+ </div>
451
+ </mat-tab>
452
+
453
+ </mat-tab-group>
454
+ </mat-dialog-content>
455
+
456
+ <mat-dialog-actions align="end">
457
+ <button mat-button (click)="cancel()">
458
+ @if (data.mode === 'test') {
459
+ Close
460
+ } @else {
461
+ Cancel
462
+ }
463
+ </button>
464
+ @if (data.mode !== 'test') {
465
+ <button mat-raised-button color="primary"
466
+ (click)="save()"
467
+ [disabled]="saving || form.invalid">
468
+ @if (saving) {
469
+ <ng-container>
470
+ <mat-icon class="spin">sync</mat-icon>
471
+ Saving...
472
+ </ng-container>
473
+ } @else {
474
+ @if (data.mode === 'create' || data.mode === 'duplicate') {
475
+ Create
476
+ } @else {
477
+ Update
478
+ }
479
+ }
480
+ </button>
481
+ }
482
  </mat-dialog-actions>
flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss CHANGED
@@ -1,232 +1,232 @@
1
- .tab-content {
2
- padding: 24px 0;
3
-
4
- mat-form-field {
5
- display: block;
6
- margin-bottom: 16px;
7
- }
8
-
9
- h3 {
10
- margin-top: 10px;
11
- margin-bottom: 10px;
12
- }
13
- }
14
-
15
- .full-width {
16
- width: 100%;
17
- }
18
-
19
- // Row layout
20
- .row {
21
- display: flex;
22
- gap: 12px;
23
- align-items: flex-start;
24
-
25
- mat-form-field {
26
- flex: 1;
27
- }
28
-
29
- .method-field {
30
- flex: 0 0 150px;
31
- }
32
-
33
- .timeout-field {
34
- flex: 1;
35
- }
36
-
37
- .type-field {
38
- flex: 0 0 150px;
39
- }
40
-
41
- .path-field {
42
- flex: 1;
43
- }
44
- }
45
-
46
- // Headers array section
47
- .array-section {
48
- .section-header {
49
- display: flex;
50
- justify-content: space-between;
51
- align-items: center;
52
- margin-bottom: 16px;
53
-
54
- h3 {
55
- margin: 0;
56
- font-size: 16px;
57
- }
58
- }
59
-
60
- .array-item {
61
- display: flex;
62
- gap: 12px;
63
- align-items: flex-start;
64
- margin-bottom: 16px;
65
-
66
- .key-field {
67
- flex: 1;
68
- min-width: 150px;
69
- }
70
-
71
- .value-field {
72
- flex: 2;
73
- min-width: 200px;
74
- }
75
-
76
- > button[mat-icon-button] {
77
- margin-top: 8px;
78
- }
79
- }
80
-
81
- .empty-message {
82
- text-align: center;
83
- color: #666;
84
- padding: 20px;
85
- background-color: #f5f5f5;
86
- border-radius: 4px;
87
- margin: 16px 0;
88
- }
89
- }
90
-
91
- // Response mappings
92
- .mapping-content {
93
- padding: 16px 0;
94
-
95
- mat-form-field {
96
- display: block;
97
- width: 100%;
98
- margin-bottom: 16px;
99
- }
100
- }
101
-
102
- // Retry section
103
- .retry-section {
104
- margin-top: 24px;
105
-
106
- h3 {
107
- margin-bottom: 16px;
108
- font-size: 16px;
109
- }
110
- }
111
-
112
- .test-section {
113
- h3 {
114
- margin-bottom: 16px;
115
- }
116
-
117
- .test-controls {
118
- display: flex;
119
- gap: 12px;
120
- margin-bottom: 16px;
121
- }
122
-
123
- .test-result {
124
- margin-top: 24px;
125
- padding: 16px;
126
- border-radius: 4px;
127
-
128
- &.success {
129
- background-color: #e8f5e9;
130
- border: 1px solid #4caf50;
131
- }
132
-
133
- &.error {
134
- background-color: #ffebee;
135
- border: 1px solid #f44336;
136
- }
137
-
138
- h4 {
139
- margin-top: 0;
140
- }
141
-
142
- .result-status {
143
- display: flex;
144
- align-items: center;
145
- gap: 8px;
146
- margin-bottom: 16px;
147
-
148
- mat-icon {
149
- &.mat-icon {
150
- color: inherit;
151
- }
152
- }
153
- }
154
-
155
- mat-expansion-panel {
156
- margin-top: 16px;
157
-
158
- &:first-of-type {
159
- margin-top: 24px;
160
- }
161
- }
162
-
163
- table {
164
- margin-top: 8px;
165
- }
166
- }
167
- }
168
-
169
- // Auth section
170
- .auth-section {
171
- margin-top: 16px;
172
-
173
- h3 {
174
- margin: 24px 0 16px;
175
- font-size: 16px;
176
- }
177
- }
178
-
179
- // Info text
180
- .info-text {
181
- color: #666;
182
- font-size: 14px;
183
- margin-bottom: 16px;
184
- }
185
-
186
- // Spinning icon animation
187
- @keyframes spin {
188
- from {
189
- transform: rotate(0deg);
190
- }
191
- to {
192
- transform: rotate(360deg);
193
- }
194
- }
195
-
196
- .spin {
197
- animation: spin 1s linear infinite;
198
- }
199
-
200
- // Dialog actions
201
- mat-dialog-actions {
202
- padding: 16px 24px !important;
203
- margin: 0 !important;
204
- }
205
-
206
- // Responsive
207
- @media (max-width: 768px) {
208
- .row {
209
- flex-wrap: wrap;
210
-
211
- mat-form-field {
212
- flex: 1 1 100%;
213
- }
214
-
215
- .method-field,
216
- .type-field {
217
- flex: 1 1 100%;
218
- }
219
- }
220
-
221
- .array-section {
222
- .array-item {
223
- flex-wrap: wrap;
224
-
225
- .key-field,
226
- .value-field {
227
- flex: 1 1 100%;
228
- min-width: unset;
229
- }
230
- }
231
- }
232
  }
 
1
+ .tab-content {
2
+ padding: 24px 0;
3
+
4
+ mat-form-field {
5
+ display: block;
6
+ margin-bottom: 16px;
7
+ }
8
+
9
+ h3 {
10
+ margin-top: 10px;
11
+ margin-bottom: 10px;
12
+ }
13
+ }
14
+
15
+ .full-width {
16
+ width: 100%;
17
+ }
18
+
19
+ // Row layout
20
+ .row {
21
+ display: flex;
22
+ gap: 12px;
23
+ align-items: flex-start;
24
+
25
+ mat-form-field {
26
+ flex: 1;
27
+ }
28
+
29
+ .method-field {
30
+ flex: 0 0 150px;
31
+ }
32
+
33
+ .timeout-field {
34
+ flex: 1;
35
+ }
36
+
37
+ .type-field {
38
+ flex: 0 0 150px;
39
+ }
40
+
41
+ .path-field {
42
+ flex: 1;
43
+ }
44
+ }
45
+
46
+ // Headers array section
47
+ .array-section {
48
+ .section-header {
49
+ display: flex;
50
+ justify-content: space-between;
51
+ align-items: center;
52
+ margin-bottom: 16px;
53
+
54
+ h3 {
55
+ margin: 0;
56
+ font-size: 16px;
57
+ }
58
+ }
59
+
60
+ .array-item {
61
+ display: flex;
62
+ gap: 12px;
63
+ align-items: flex-start;
64
+ margin-bottom: 16px;
65
+
66
+ .key-field {
67
+ flex: 1;
68
+ min-width: 150px;
69
+ }
70
+
71
+ .value-field {
72
+ flex: 2;
73
+ min-width: 200px;
74
+ }
75
+
76
+ > button[mat-icon-button] {
77
+ margin-top: 8px;
78
+ }
79
+ }
80
+
81
+ .empty-message {
82
+ text-align: center;
83
+ color: #666;
84
+ padding: 20px;
85
+ background-color: #f5f5f5;
86
+ border-radius: 4px;
87
+ margin: 16px 0;
88
+ }
89
+ }
90
+
91
+ // Response mappings
92
+ .mapping-content {
93
+ padding: 16px 0;
94
+
95
+ mat-form-field {
96
+ display: block;
97
+ width: 100%;
98
+ margin-bottom: 16px;
99
+ }
100
+ }
101
+
102
+ // Retry section
103
+ .retry-section {
104
+ margin-top: 24px;
105
+
106
+ h3 {
107
+ margin-bottom: 16px;
108
+ font-size: 16px;
109
+ }
110
+ }
111
+
112
+ .test-section {
113
+ h3 {
114
+ margin-bottom: 16px;
115
+ }
116
+
117
+ .test-controls {
118
+ display: flex;
119
+ gap: 12px;
120
+ margin-bottom: 16px;
121
+ }
122
+
123
+ .test-result {
124
+ margin-top: 24px;
125
+ padding: 16px;
126
+ border-radius: 4px;
127
+
128
+ &.success {
129
+ background-color: #e8f5e9;
130
+ border: 1px solid #4caf50;
131
+ }
132
+
133
+ &.error {
134
+ background-color: #ffebee;
135
+ border: 1px solid #f44336;
136
+ }
137
+
138
+ h4 {
139
+ margin-top: 0;
140
+ }
141
+
142
+ .result-status {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ margin-bottom: 16px;
147
+
148
+ mat-icon {
149
+ &.mat-icon {
150
+ color: inherit;
151
+ }
152
+ }
153
+ }
154
+
155
+ mat-expansion-panel {
156
+ margin-top: 16px;
157
+
158
+ &:first-of-type {
159
+ margin-top: 24px;
160
+ }
161
+ }
162
+
163
+ table {
164
+ margin-top: 8px;
165
+ }
166
+ }
167
+ }
168
+
169
+ // Auth section
170
+ .auth-section {
171
+ margin-top: 16px;
172
+
173
+ h3 {
174
+ margin: 24px 0 16px;
175
+ font-size: 16px;
176
+ }
177
+ }
178
+
179
+ // Info text
180
+ .info-text {
181
+ color: #666;
182
+ font-size: 14px;
183
+ margin-bottom: 16px;
184
+ }
185
+
186
+ // Spinning icon animation
187
+ @keyframes spin {
188
+ from {
189
+ transform: rotate(0deg);
190
+ }
191
+ to {
192
+ transform: rotate(360deg);
193
+ }
194
+ }
195
+
196
+ .spin {
197
+ animation: spin 1s linear infinite;
198
+ }
199
+
200
+ // Dialog actions
201
+ mat-dialog-actions {
202
+ padding: 16px 24px !important;
203
+ margin: 0 !important;
204
+ }
205
+
206
+ // Responsive
207
+ @media (max-width: 768px) {
208
+ .row {
209
+ flex-wrap: wrap;
210
+
211
+ mat-form-field {
212
+ flex: 1 1 100%;
213
+ }
214
+
215
+ .method-field,
216
+ .type-field {
217
+ flex: 1 1 100%;
218
+ }
219
+ }
220
+
221
+ .array-section {
222
+ .array-item {
223
+ flex-wrap: wrap;
224
+
225
+ .key-field,
226
+ .value-field {
227
+ flex: 1 1 100%;
228
+ min-width: unset;
229
+ }
230
+ }
231
+ }
232
  }
flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts CHANGED
@@ -1,578 +1,578 @@
1
- import { Component, Inject, OnInit } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
4
- import { FormsModule } from '@angular/forms';
5
- import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
6
- import { MatTabsModule } from '@angular/material/tabs';
7
- import { MatFormFieldModule } from '@angular/material/form-field';
8
- import { MatInputModule } from '@angular/material/input';
9
- import { MatSelectModule } from '@angular/material/select';
10
- import { MatCheckboxModule } from '@angular/material/checkbox';
11
- import { MatButtonModule } from '@angular/material/button';
12
- import { MatIconModule } from '@angular/material/icon';
13
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
14
- import { MatDividerModule } from '@angular/material/divider';
15
- import { MatExpansionModule } from '@angular/material/expansion';
16
- import { MatChipsModule } from '@angular/material/chips';
17
- import { MatMenuModule } from '@angular/material/menu';
18
- import { MatTableModule } from '@angular/material/table';
19
- import { ApiService } from '../../services/api.service';
20
- import { JsonEditorComponent } from '../../shared/json-editor/json-editor.component';
21
-
22
- @Component({
23
- selector: 'app-api-edit-dialog',
24
- standalone: true,
25
- imports: [
26
- CommonModule,
27
- ReactiveFormsModule,
28
- FormsModule,
29
- MatDialogModule,
30
- MatTabsModule,
31
- MatFormFieldModule,
32
- MatInputModule,
33
- MatSelectModule,
34
- MatCheckboxModule,
35
- MatButtonModule,
36
- MatIconModule,
37
- MatSnackBarModule,
38
- MatDividerModule,
39
- MatExpansionModule,
40
- MatChipsModule,
41
- MatMenuModule,
42
- MatTableModule,
43
- JsonEditorComponent
44
- ],
45
- templateUrl: './api-edit-dialog.component.html',
46
- styleUrls: ['./api-edit-dialog.component.scss']
47
- })
48
- export default class ApiEditDialogComponent implements OnInit {
49
- form!: FormGroup;
50
- saving = false;
51
- testing = false;
52
- testResult: any = null;
53
- testRequestJson = '{}';
54
- allIntentParameters: string[] = [];
55
- responseMappingVariables: string[] = [];
56
- activeTabIndex = 0;
57
-
58
- httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
59
- retryStrategies = ['static', 'exponential'];
60
- variableTypes = ['str', 'int', 'float', 'bool', 'date'];
61
-
62
- constructor(
63
- private fb: FormBuilder,
64
- private apiService: ApiService,
65
- private snackBar: MatSnackBar,
66
- public dialogRef: MatDialogRef<ApiEditDialogComponent>,
67
- @Inject(MAT_DIALOG_DATA) public data: any
68
- ) {}
69
-
70
- ngOnInit() {
71
- this.initializeForm();
72
- this.loadIntentParameters();
73
-
74
- // Aktif tab'ı ayarla
75
- if (this.data.activeTab !== undefined) {
76
- this.activeTabIndex = this.data.activeTab;
77
- }
78
-
79
- if ((this.data.mode === 'edit' || this.data.mode === 'test') && this.data.api) {
80
- this.populateForm(this.data.api);
81
- } else if (this.data.mode === 'duplicate' && this.data.api) {
82
- const duplicateData = { ...this.data.api };
83
- duplicateData.name = duplicateData.name + '_copy';
84
- delete duplicateData.last_update_date;
85
- this.populateForm(duplicateData);
86
- }
87
-
88
- // Test modunda açıldıysa test JSON'ını hazırla
89
- if (this.data.mode === 'test') {
90
- setTimeout(() => {
91
- this.updateTestRequestJson();
92
- }, 100);
93
- }
94
-
95
- // Watch response mappings changes
96
- this.form.get('response_mappings')?.valueChanges.subscribe(() => {
97
- this.updateResponseMappingVariables();
98
- });
99
- }
100
-
101
- initializeForm() {
102
- this.form = this.fb.group({
103
- // General Tab
104
- name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_]+$/)]],
105
- url: ['', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]],
106
- method: ['POST', Validators.required],
107
- body_template: ['{}'],
108
- timeout_seconds: [10, [Validators.required, Validators.min(1), Validators.max(300)]],
109
- response_prompt: [''],
110
- response_mappings: this.fb.array([]),
111
-
112
- // Headers Tab
113
- headers: this.fb.array([]),
114
-
115
- // Retry Settings
116
- retry: this.fb.group({
117
- retry_count: [3, [Validators.required, Validators.min(0), Validators.max(10)]],
118
- backoff_seconds: [2, [Validators.required, Validators.min(1), Validators.max(60)]],
119
- strategy: ['static', Validators.required]
120
- }),
121
-
122
- // Auth Tab
123
- auth: this.fb.group({
124
- enabled: [false],
125
- token_endpoint: [''],
126
- response_token_path: ['token'],
127
- token_request_body: ['{}'],
128
- token_refresh_endpoint: [''],
129
- token_refresh_body: ['{}']
130
- }),
131
-
132
- // Proxy (optional)
133
- proxy: [''],
134
-
135
- // For race condition handling
136
- last_update_date: ['']
137
- });
138
-
139
- // Watch for auth enabled changes
140
- this.form.get('auth.enabled')?.valueChanges.subscribe(enabled => {
141
- const authGroup = this.form.get('auth');
142
- if (enabled) {
143
- authGroup?.get('token_endpoint')?.setValidators([Validators.required]);
144
- authGroup?.get('response_token_path')?.setValidators([Validators.required]);
145
- } else {
146
- authGroup?.get('token_endpoint')?.clearValidators();
147
- authGroup?.get('response_token_path')?.clearValidators();
148
- }
149
- authGroup?.get('token_endpoint')?.updateValueAndValidity();
150
- authGroup?.get('response_token_path')?.updateValueAndValidity();
151
- });
152
- }
153
-
154
- populateForm(api: any) {
155
- console.log('Populating form with API:', api);
156
-
157
- // Convert headers object to FormArray
158
- const headersArray = this.form.get('headers') as FormArray;
159
- headersArray.clear();
160
-
161
- if (api.headers) {
162
- if (Array.isArray(api.headers)) {
163
- api.headers.forEach((header: any) => {
164
- headersArray.push(this.createHeaderFormGroup(header.key || '', header.value || ''));
165
- });
166
- } else if (typeof api.headers === 'object') {
167
- Object.entries(api.headers).forEach(([key, value]) => {
168
- headersArray.push(this.createHeaderFormGroup(key, value as string));
169
- });
170
- }
171
- }
172
-
173
- // Convert response_mappings to FormArray
174
- const responseMappingsArray = this.form.get('response_mappings') as FormArray;
175
- responseMappingsArray.clear();
176
-
177
- if (api.response_mappings && Array.isArray(api.response_mappings)) {
178
- api.response_mappings.forEach((mapping: any) => {
179
- responseMappingsArray.push(this.createResponseMappingFormGroup(mapping));
180
- });
181
- }
182
-
183
- // Convert body_template to JSON string if it's an object
184
- if (api.body_template && typeof api.body_template === 'object') {
185
- api.body_template = JSON.stringify(api.body_template, null, 2);
186
- }
187
-
188
- // Convert auth bodies to JSON strings
189
- if (api.auth) {
190
- if (api.auth.token_request_body && typeof api.auth.token_request_body === 'object') {
191
- api.auth.token_request_body = JSON.stringify(api.auth.token_request_body, null, 2);
192
- }
193
- if (api.auth.token_refresh_body && typeof api.auth.token_refresh_body === 'object') {
194
- api.auth.token_refresh_body = JSON.stringify(api.auth.token_refresh_body, null, 2);
195
- }
196
- }
197
-
198
- const formData = { ...api };
199
-
200
- // headers array'ini kaldır çünkü zaten FormArray'e ekledik
201
- delete formData.headers;
202
- delete formData.response_mappings;
203
-
204
- // Patch form values
205
- this.form.patchValue(formData);
206
-
207
- // Disable name field if editing or testing
208
- if (this.data.mode === 'edit' || this.data.mode === 'test') {
209
- this.form.get('name')?.disable();
210
- }
211
- }
212
-
213
- get headers() {
214
- return this.form.get('headers') as FormArray;
215
- }
216
-
217
- get responseMappings() {
218
- return this.form.get('response_mappings') as FormArray;
219
- }
220
-
221
- createHeaderFormGroup(key = '', value = ''): FormGroup {
222
- return this.fb.group({
223
- key: [key, Validators.required],
224
- value: [value, Validators.required]
225
- });
226
- }
227
-
228
- createResponseMappingFormGroup(data: any = {}): FormGroup {
229
- return this.fb.group({
230
- variable_name: [data.variable_name || '', [Validators.required, Validators.pattern(/^[a-z_][a-z0-9_]*$/)]],
231
- type: [data.type || 'str', Validators.required],
232
- json_path: [data.json_path || '', Validators.required],
233
- caption: [data.caption || '', Validators.required]
234
- });
235
- }
236
-
237
- addHeader() {
238
- this.headers.push(this.createHeaderFormGroup());
239
- }
240
-
241
- removeHeader(index: number) {
242
- this.headers.removeAt(index);
243
- }
244
-
245
- addResponseMapping() {
246
- this.responseMappings.push(this.createResponseMappingFormGroup());
247
- }
248
-
249
- removeResponseMapping(index: number) {
250
- this.responseMappings.removeAt(index);
251
- }
252
-
253
- insertHeaderValue(index: number, variable: string) {
254
- const headerGroup = this.headers.at(index);
255
- if (headerGroup) {
256
- const valueControl = headerGroup.get('value');
257
- if (valueControl) {
258
- const currentValue = valueControl.value || '';
259
- const newValue = currentValue + `{{${variable}}}`;
260
- valueControl.setValue(newValue);
261
- }
262
- }
263
- }
264
-
265
- getTemplateVariables(includeResponseMappings = true): string[] {
266
- const variables = new Set<string>();
267
-
268
- // Intent parameters
269
- this.allIntentParameters.forEach(param => {
270
- variables.add(`variables.${param}`);
271
- });
272
-
273
- // Auth tokens
274
- const apiName = this.form.get('name')?.value || 'api_name';
275
- variables.add(`auth_tokens.${apiName}.token`);
276
-
277
- // Response mappings
278
- if (includeResponseMappings) {
279
- this.responseMappingVariables.forEach(varName => {
280
- variables.add(`variables.${varName}`);
281
- });
282
- }
283
-
284
- // Config variables
285
- variables.add('config.work_mode');
286
- variables.add('config.cloud_token');
287
-
288
- return Array.from(variables).sort();
289
- }
290
-
291
- updateResponseMappingVariables() {
292
- this.responseMappingVariables = [];
293
- const mappings = this.responseMappings.value;
294
- mappings.forEach((mapping: any) => {
295
- if (mapping.variable_name) {
296
- this.responseMappingVariables.push(mapping.variable_name);
297
- }
298
- });
299
- }
300
-
301
- async loadIntentParameters() {
302
- try {
303
- const projects = await this.apiService.getProjects(false).toPromise();
304
- const params = new Set<string>();
305
-
306
- projects?.forEach(project => {
307
- project.versions?.forEach(version => {
308
- version.intents?.forEach(intent => {
309
- intent.parameters?.forEach((param: any) => {
310
- if (param.variable_name) {
311
- params.add(param.variable_name);
312
- }
313
- });
314
- });
315
- });
316
- });
317
-
318
- this.allIntentParameters = Array.from(params).sort();
319
- } catch (error) {
320
- console.error('Failed to load intent parameters:', error);
321
- }
322
- }
323
-
324
- // JSON validation için replacer fonksiyonu
325
- replaceVariablesForValidation = (jsonStr: string): string => {
326
- let processed = jsonStr;
327
-
328
- processed = processed.replace(/\{\{([^}]+)\}\}/g, (match, variablePath) => {
329
- if (variablePath.includes('variables.')) {
330
- const varName = variablePath.split('.').pop()?.toLowerCase() || '';
331
-
332
- const numericVars = ['count', 'passenger_count', 'timeout_seconds', 'retry_count', 'amount', 'price', 'quantity', 'age', 'id'];
333
- const booleanVars = ['enabled', 'published', 'is_active', 'confirmed', 'canceled', 'deleted', 'required'];
334
-
335
- if (numericVars.some(v => varName.includes(v))) {
336
- return '1';
337
- } else if (booleanVars.some(v => varName.includes(v))) {
338
- return 'true';
339
- } else {
340
- return '"placeholder"';
341
- }
342
- }
343
-
344
- return '"placeholder"';
345
- });
346
-
347
- return processed;
348
- }
349
-
350
- async testAPI() {
351
- const generalValid = this.form.get('url')?.valid && this.form.get('method')?.valid;
352
- if (!generalValid) {
353
- this.snackBar.open('Please fill in required fields first', 'Close', { duration: 3000 });
354
- return;
355
- }
356
-
357
- this.testing = true;
358
- this.testResult = null;
359
-
360
- try {
361
- const testData = this.prepareAPIData();
362
-
363
- let testRequestData = {};
364
- try {
365
- testRequestData = JSON.parse(this.testRequestJson);
366
- } catch (e) {
367
- this.snackBar.open('Invalid test request JSON', 'Close', {
368
- duration: 3000,
369
- panelClass: 'error-snackbar'
370
- });
371
- this.testing = false;
372
- return;
373
- }
374
-
375
- testData.test_request = testRequestData;
376
-
377
- const result = await this.apiService.testAPI(testData).toPromise();
378
-
379
- // Response headers'ı obje olarak sakla
380
- if (result.response_headers && typeof result.response_headers === 'string') {
381
- try {
382
- result.response_headers = JSON.parse(result.response_headers);
383
- } catch {
384
- // Headers parse edilemezse string olarak bırak
385
- }
386
- }
387
-
388
- this.testResult = result;
389
-
390
- if (result.success) {
391
- this.snackBar.open(`API test successful! (${result.status_code})`, 'Close', {
392
- duration: 3000
393
- });
394
- } else {
395
- const errorMsg = result.error || `API returned status ${result.status_code}`;
396
- this.snackBar.open(`API test failed: ${errorMsg}`, 'Close', {
397
- duration: 5000,
398
- panelClass: 'error-snackbar'
399
- });
400
- }
401
- } catch (error: any) {
402
- this.testResult = {
403
- success: false,
404
- error: error.message || 'Test failed'
405
- };
406
- this.snackBar.open('API test failed', 'Close', {
407
- duration: 3000,
408
- panelClass: 'error-snackbar'
409
- });
410
- } finally {
411
- this.testing = false;
412
- }
413
- }
414
-
415
- updateTestRequestJson() {
416
- const formValue = this.form.getRawValue();
417
- let bodyTemplate = {};
418
-
419
- try {
420
- bodyTemplate = JSON.parse(formValue.body_template);
421
- } catch {
422
- bodyTemplate = {};
423
- }
424
-
425
- const testData = this.replacePlaceholdersForTest(bodyTemplate);
426
- this.testRequestJson = JSON.stringify(testData, null, 2);
427
- }
428
-
429
- replacePlaceholdersForTest(obj: any): any {
430
- if (typeof obj === 'string') {
431
- let result = obj;
432
-
433
- result = result.replace(/\{\{variables\.origin\}\}/g, 'Istanbul');
434
- result = result.replace(/\{\{variables\.destination\}\}/g, 'Ankara');
435
- result = result.replace(/\{\{variables\.flight_date\}\}/g, '2025-06-15');
436
- result = result.replace(/\{\{variables\.passenger_count\}\}/g, '2');
437
- result = result.replace(/\{\{variables\.flight_number\}\}/g, 'TK123');
438
- result = result.replace(/\{\{variables\.pnr\}\}/g, 'ABC12');
439
- result = result.replace(/\{\{variables\.surname\}\}/g, 'Test');
440
-
441
- result = result.replace(/\{\{auth_tokens\.[^}]+\.token\}\}/g, 'test_token_123');
442
-
443
- result = result.replace(/\{\{config\.work_mode\}\}/g, 'hfcloud');
444
-
445
- result = result.replace(/\{\{[^}]+\}\}/g, 'test_value');
446
-
447
- return result;
448
- } else if (typeof obj === 'object' && obj !== null) {
449
- const result: any = Array.isArray(obj) ? [] : {};
450
- for (const key in obj) {
451
- result[key] = this.replacePlaceholdersForTest(obj[key]);
452
- }
453
- return result;
454
- }
455
- return obj;
456
- }
457
-
458
- prepareAPIData(): any {
459
- const formValue = this.form.getRawValue();
460
-
461
- const headers: any = {};
462
- formValue.headers.forEach((h: any) => {
463
- if (h.key && h.value) {
464
- headers[h.key] = h.value;
465
- }
466
- });
467
-
468
- let body_template = {};
469
- let auth_token_request_body = {};
470
- let auth_token_refresh_body = {};
471
-
472
- try {
473
- body_template = formValue.body_template ? JSON.parse(formValue.body_template) : {};
474
- } catch (e) {
475
- console.error('Invalid body_template JSON:', e);
476
- }
477
-
478
- try {
479
- auth_token_request_body = formValue.auth.token_request_body ? JSON.parse(formValue.auth.token_request_body) : {};
480
- } catch (e) {
481
- console.error('Invalid auth token_request_body JSON:', e);
482
- }
483
-
484
- try {
485
- auth_token_refresh_body = formValue.auth.token_refresh_body ? JSON.parse(formValue.auth.token_refresh_body) : {};
486
- } catch (e) {
487
- console.error('Invalid auth token_refresh_body JSON:', e);
488
- }
489
-
490
- const apiData: any = {
491
- name: formValue.name,
492
- url: formValue.url,
493
- method: formValue.method,
494
- headers,
495
- body_template,
496
- timeout_seconds: formValue.timeout_seconds,
497
- retry: formValue.retry,
498
- response_prompt: formValue.response_prompt,
499
- response_mappings: formValue.response_mappings || []
500
- };
501
-
502
- // Proxy - null olarak gönder boşsa
503
- apiData.proxy = formValue.proxy || null;
504
-
505
- if (formValue.proxy) {
506
- apiData.proxy = formValue.proxy;
507
- }
508
-
509
- if (formValue.auth.enabled) {
510
- apiData.auth = {
511
- enabled: true,
512
- token_endpoint: formValue.auth.token_endpoint,
513
- response_token_path: formValue.auth.response_token_path,
514
- token_request_body: auth_token_request_body
515
- };
516
-
517
- if (formValue.auth.token_refresh_endpoint) {
518
- apiData.auth.token_refresh_endpoint = formValue.auth.token_refresh_endpoint;
519
- apiData.auth.token_refresh_body = auth_token_refresh_body;
520
- }
521
- }else {
522
- // Auth disabled olsa bile null olarak gönder
523
- apiData.auth = null;
524
- }
525
-
526
- // Edit modunda last_update_date'i ekle
527
- if (this.data.mode === 'edit' && formValue.last_update_date) {
528
- apiData.last_update_date = formValue.last_update_date;
529
- }
530
-
531
- console.log('Prepared API data:', apiData);
532
- return apiData;
533
- }
534
-
535
- async save() {
536
- if (this.data.mode === 'test') {
537
- this.cancel();
538
- return;
539
- }
540
-
541
- if (this.form.invalid) {
542
- Object.keys(this.form.controls).forEach(key => {
543
- this.form.get(key)?.markAsTouched();
544
- });
545
-
546
- this.snackBar.open('Please fix validation errors', 'Close', { duration: 3000 });
547
- return;
548
- }
549
-
550
- this.saving = true;
551
- try {
552
- const apiData = this.prepareAPIData();
553
-
554
- if (this.data.mode === 'create' || this.data.mode === 'duplicate') {
555
- await this.apiService.createAPI(apiData).toPromise();
556
- this.snackBar.open('API created successfully', 'Close', { duration: 3000 });
557
- } else {
558
- await this.apiService.updateAPI(this.data.api.name, apiData).toPromise();
559
- this.snackBar.open('API updated successfully', 'Close', { duration: 3000 });
560
- }
561
-
562
- this.dialogRef.close(true);
563
- } catch (error: any) {
564
- const message = error.error?.detail ||
565
- (this.data.mode === 'create' ? 'Failed to create API' : 'Failed to update API');
566
- this.snackBar.open(message, 'Close', {
567
- duration: 5000,
568
- panelClass: 'error-snackbar'
569
- });
570
- } finally {
571
- this.saving = false;
572
- }
573
- }
574
-
575
- cancel() {
576
- this.dialogRef.close(false);
577
- }
578
  }
 
1
+ import { Component, Inject, OnInit } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
4
+ import { FormsModule } from '@angular/forms';
5
+ import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
6
+ import { MatTabsModule } from '@angular/material/tabs';
7
+ import { MatFormFieldModule } from '@angular/material/form-field';
8
+ import { MatInputModule } from '@angular/material/input';
9
+ import { MatSelectModule } from '@angular/material/select';
10
+ import { MatCheckboxModule } from '@angular/material/checkbox';
11
+ import { MatButtonModule } from '@angular/material/button';
12
+ import { MatIconModule } from '@angular/material/icon';
13
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
14
+ import { MatDividerModule } from '@angular/material/divider';
15
+ import { MatExpansionModule } from '@angular/material/expansion';
16
+ import { MatChipsModule } from '@angular/material/chips';
17
+ import { MatMenuModule } from '@angular/material/menu';
18
+ import { MatTableModule } from '@angular/material/table';
19
+ import { ApiService } from '../../services/api.service';
20
+ import { JsonEditorComponent } from '../../shared/json-editor/json-editor.component';
21
+
22
+ @Component({
23
+ selector: 'app-api-edit-dialog',
24
+ standalone: true,
25
+ imports: [
26
+ CommonModule,
27
+ ReactiveFormsModule,
28
+ FormsModule,
29
+ MatDialogModule,
30
+ MatTabsModule,
31
+ MatFormFieldModule,
32
+ MatInputModule,
33
+ MatSelectModule,
34
+ MatCheckboxModule,
35
+ MatButtonModule,
36
+ MatIconModule,
37
+ MatSnackBarModule,
38
+ MatDividerModule,
39
+ MatExpansionModule,
40
+ MatChipsModule,
41
+ MatMenuModule,
42
+ MatTableModule,
43
+ JsonEditorComponent
44
+ ],
45
+ templateUrl: './api-edit-dialog.component.html',
46
+ styleUrls: ['./api-edit-dialog.component.scss']
47
+ })
48
+ export default class ApiEditDialogComponent implements OnInit {
49
+ form!: FormGroup;
50
+ saving = false;
51
+ testing = false;
52
+ testResult: any = null;
53
+ testRequestJson = '{}';
54
+ allIntentParameters: string[] = [];
55
+ responseMappingVariables: string[] = [];
56
+ activeTabIndex = 0;
57
+
58
+ httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
59
+ retryStrategies = ['static', 'exponential'];
60
+ variableTypes = ['str', 'int', 'float', 'bool', 'date'];
61
+
62
+ constructor(
63
+ private fb: FormBuilder,
64
+ private apiService: ApiService,
65
+ private snackBar: MatSnackBar,
66
+ public dialogRef: MatDialogRef<ApiEditDialogComponent>,
67
+ @Inject(MAT_DIALOG_DATA) public data: any
68
+ ) {}
69
+
70
+ ngOnInit() {
71
+ this.initializeForm();
72
+ this.loadIntentParameters();
73
+
74
+ // Aktif tab'ı ayarla
75
+ if (this.data.activeTab !== undefined) {
76
+ this.activeTabIndex = this.data.activeTab;
77
+ }
78
+
79
+ if ((this.data.mode === 'edit' || this.data.mode === 'test') && this.data.api) {
80
+ this.populateForm(this.data.api);
81
+ } else if (this.data.mode === 'duplicate' && this.data.api) {
82
+ const duplicateData = { ...this.data.api };
83
+ duplicateData.name = duplicateData.name + '_copy';
84
+ delete duplicateData.last_update_date;
85
+ this.populateForm(duplicateData);
86
+ }
87
+
88
+ // Test modunda açıldıysa test JSON'ını hazırla
89
+ if (this.data.mode === 'test') {
90
+ setTimeout(() => {
91
+ this.updateTestRequestJson();
92
+ }, 100);
93
+ }
94
+
95
+ // Watch response mappings changes
96
+ this.form.get('response_mappings')?.valueChanges.subscribe(() => {
97
+ this.updateResponseMappingVariables();
98
+ });
99
+ }
100
+
101
+ initializeForm() {
102
+ this.form = this.fb.group({
103
+ // General Tab
104
+ name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9_]+$/)]],
105
+ url: ['', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]],
106
+ method: ['POST', Validators.required],
107
+ body_template: ['{}'],
108
+ timeout_seconds: [10, [Validators.required, Validators.min(1), Validators.max(300)]],
109
+ response_prompt: [''],
110
+ response_mappings: this.fb.array([]),
111
+
112
+ // Headers Tab
113
+ headers: this.fb.array([]),
114
+
115
+ // Retry Settings
116
+ retry: this.fb.group({
117
+ retry_count: [3, [Validators.required, Validators.min(0), Validators.max(10)]],
118
+ backoff_seconds: [2, [Validators.required, Validators.min(1), Validators.max(60)]],
119
+ strategy: ['static', Validators.required]
120
+ }),
121
+
122
+ // Auth Tab
123
+ auth: this.fb.group({
124
+ enabled: [false],
125
+ token_endpoint: [''],
126
+ response_token_path: ['token'],
127
+ token_request_body: ['{}'],
128
+ token_refresh_endpoint: [''],
129
+ token_refresh_body: ['{}']
130
+ }),
131
+
132
+ // Proxy (optional)
133
+ proxy: [''],
134
+
135
+ // For race condition handling
136
+ last_update_date: ['']
137
+ });
138
+
139
+ // Watch for auth enabled changes
140
+ this.form.get('auth.enabled')?.valueChanges.subscribe(enabled => {
141
+ const authGroup = this.form.get('auth');
142
+ if (enabled) {
143
+ authGroup?.get('token_endpoint')?.setValidators([Validators.required]);
144
+ authGroup?.get('response_token_path')?.setValidators([Validators.required]);
145
+ } else {
146
+ authGroup?.get('token_endpoint')?.clearValidators();
147
+ authGroup?.get('response_token_path')?.clearValidators();
148
+ }
149
+ authGroup?.get('token_endpoint')?.updateValueAndValidity();
150
+ authGroup?.get('response_token_path')?.updateValueAndValidity();
151
+ });
152
+ }
153
+
154
+ populateForm(api: any) {
155
+ console.log('Populating form with API:', api);
156
+
157
+ // Convert headers object to FormArray
158
+ const headersArray = this.form.get('headers') as FormArray;
159
+ headersArray.clear();
160
+
161
+ if (api.headers) {
162
+ if (Array.isArray(api.headers)) {
163
+ api.headers.forEach((header: any) => {
164
+ headersArray.push(this.createHeaderFormGroup(header.key || '', header.value || ''));
165
+ });
166
+ } else if (typeof api.headers === 'object') {
167
+ Object.entries(api.headers).forEach(([key, value]) => {
168
+ headersArray.push(this.createHeaderFormGroup(key, value as string));
169
+ });
170
+ }
171
+ }
172
+
173
+ // Convert response_mappings to FormArray
174
+ const responseMappingsArray = this.form.get('response_mappings') as FormArray;
175
+ responseMappingsArray.clear();
176
+
177
+ if (api.response_mappings && Array.isArray(api.response_mappings)) {
178
+ api.response_mappings.forEach((mapping: any) => {
179
+ responseMappingsArray.push(this.createResponseMappingFormGroup(mapping));
180
+ });
181
+ }
182
+
183
+ // Convert body_template to JSON string if it's an object
184
+ if (api.body_template && typeof api.body_template === 'object') {
185
+ api.body_template = JSON.stringify(api.body_template, null, 2);
186
+ }
187
+
188
+ // Convert auth bodies to JSON strings
189
+ if (api.auth) {
190
+ if (api.auth.token_request_body && typeof api.auth.token_request_body === 'object') {
191
+ api.auth.token_request_body = JSON.stringify(api.auth.token_request_body, null, 2);
192
+ }
193
+ if (api.auth.token_refresh_body && typeof api.auth.token_refresh_body === 'object') {
194
+ api.auth.token_refresh_body = JSON.stringify(api.auth.token_refresh_body, null, 2);
195
+ }
196
+ }
197
+
198
+ const formData = { ...api };
199
+
200
+ // headers array'ini kaldır çünkü zaten FormArray'e ekledik
201
+ delete formData.headers;
202
+ delete formData.response_mappings;
203
+
204
+ // Patch form values
205
+ this.form.patchValue(formData);
206
+
207
+ // Disable name field if editing or testing
208
+ if (this.data.mode === 'edit' || this.data.mode === 'test') {
209
+ this.form.get('name')?.disable();
210
+ }
211
+ }
212
+
213
+ get headers() {
214
+ return this.form.get('headers') as FormArray;
215
+ }
216
+
217
+ get responseMappings() {
218
+ return this.form.get('response_mappings') as FormArray;
219
+ }
220
+
221
+ createHeaderFormGroup(key = '', value = ''): FormGroup {
222
+ return this.fb.group({
223
+ key: [key, Validators.required],
224
+ value: [value, Validators.required]
225
+ });
226
+ }
227
+
228
+ createResponseMappingFormGroup(data: any = {}): FormGroup {
229
+ return this.fb.group({
230
+ variable_name: [data.variable_name || '', [Validators.required, Validators.pattern(/^[a-z_][a-z0-9_]*$/)]],
231
+ type: [data.type || 'str', Validators.required],
232
+ json_path: [data.json_path || '', Validators.required],
233
+ caption: [data.caption || '', Validators.required]
234
+ });
235
+ }
236
+
237
+ addHeader() {
238
+ this.headers.push(this.createHeaderFormGroup());
239
+ }
240
+
241
+ removeHeader(index: number) {
242
+ this.headers.removeAt(index);
243
+ }
244
+
245
+ addResponseMapping() {
246
+ this.responseMappings.push(this.createResponseMappingFormGroup());
247
+ }
248
+
249
+ removeResponseMapping(index: number) {
250
+ this.responseMappings.removeAt(index);
251
+ }
252
+
253
+ insertHeaderValue(index: number, variable: string) {
254
+ const headerGroup = this.headers.at(index);
255
+ if (headerGroup) {
256
+ const valueControl = headerGroup.get('value');
257
+ if (valueControl) {
258
+ const currentValue = valueControl.value || '';
259
+ const newValue = currentValue + `{{${variable}}}`;
260
+ valueControl.setValue(newValue);
261
+ }
262
+ }
263
+ }
264
+
265
+ getTemplateVariables(includeResponseMappings = true): string[] {
266
+ const variables = new Set<string>();
267
+
268
+ // Intent parameters
269
+ this.allIntentParameters.forEach(param => {
270
+ variables.add(`variables.${param}`);
271
+ });
272
+
273
+ // Auth tokens
274
+ const apiName = this.form.get('name')?.value || 'api_name';
275
+ variables.add(`auth_tokens.${apiName}.token`);
276
+
277
+ // Response mappings
278
+ if (includeResponseMappings) {
279
+ this.responseMappingVariables.forEach(varName => {
280
+ variables.add(`variables.${varName}`);
281
+ });
282
+ }
283
+
284
+ // Config variables
285
+ variables.add('config.work_mode');
286
+ variables.add('config.cloud_token');
287
+
288
+ return Array.from(variables).sort();
289
+ }
290
+
291
+ updateResponseMappingVariables() {
292
+ this.responseMappingVariables = [];
293
+ const mappings = this.responseMappings.value;
294
+ mappings.forEach((mapping: any) => {
295
+ if (mapping.variable_name) {
296
+ this.responseMappingVariables.push(mapping.variable_name);
297
+ }
298
+ });
299
+ }
300
+
301
+ async loadIntentParameters() {
302
+ try {
303
+ const projects = await this.apiService.getProjects(false).toPromise();
304
+ const params = new Set<string>();
305
+
306
+ projects?.forEach(project => {
307
+ project.versions?.forEach(version => {
308
+ version.intents?.forEach(intent => {
309
+ intent.parameters?.forEach((param: any) => {
310
+ if (param.variable_name) {
311
+ params.add(param.variable_name);
312
+ }
313
+ });
314
+ });
315
+ });
316
+ });
317
+
318
+ this.allIntentParameters = Array.from(params).sort();
319
+ } catch (error) {
320
+ console.error('Failed to load intent parameters:', error);
321
+ }
322
+ }
323
+
324
+ // JSON validation için replacer fonksiyonu
325
+ replaceVariablesForValidation = (jsonStr: string): string => {
326
+ let processed = jsonStr;
327
+
328
+ processed = processed.replace(/\{\{([^}]+)\}\}/g, (match, variablePath) => {
329
+ if (variablePath.includes('variables.')) {
330
+ const varName = variablePath.split('.').pop()?.toLowerCase() || '';
331
+
332
+ const numericVars = ['count', 'passenger_count', 'timeout_seconds', 'retry_count', 'amount', 'price', 'quantity', 'age', 'id'];
333
+ const booleanVars = ['enabled', 'published', 'is_active', 'confirmed', 'canceled', 'deleted', 'required'];
334
+
335
+ if (numericVars.some(v => varName.includes(v))) {
336
+ return '1';
337
+ } else if (booleanVars.some(v => varName.includes(v))) {
338
+ return 'true';
339
+ } else {
340
+ return '"placeholder"';
341
+ }
342
+ }
343
+
344
+ return '"placeholder"';
345
+ });
346
+
347
+ return processed;
348
+ }
349
+
350
+ async testAPI() {
351
+ const generalValid = this.form.get('url')?.valid && this.form.get('method')?.valid;
352
+ if (!generalValid) {
353
+ this.snackBar.open('Please fill in required fields first', 'Close', { duration: 3000 });
354
+ return;
355
+ }
356
+
357
+ this.testing = true;
358
+ this.testResult = null;
359
+
360
+ try {
361
+ const testData = this.prepareAPIData();
362
+
363
+ let testRequestData = {};
364
+ try {
365
+ testRequestData = JSON.parse(this.testRequestJson);
366
+ } catch (e) {
367
+ this.snackBar.open('Invalid test request JSON', 'Close', {
368
+ duration: 3000,
369
+ panelClass: 'error-snackbar'
370
+ });
371
+ this.testing = false;
372
+ return;
373
+ }
374
+
375
+ testData.test_request = testRequestData;
376
+
377
+ const result = await this.apiService.testAPI(testData).toPromise();
378
+
379
+ // Response headers'ı obje olarak sakla
380
+ if (result.response_headers && typeof result.response_headers === 'string') {
381
+ try {
382
+ result.response_headers = JSON.parse(result.response_headers);
383
+ } catch {
384
+ // Headers parse edilemezse string olarak bırak
385
+ }
386
+ }
387
+
388
+ this.testResult = result;
389
+
390
+ if (result.success) {
391
+ this.snackBar.open(`API test successful! (${result.status_code})`, 'Close', {
392
+ duration: 3000
393
+ });
394
+ } else {
395
+ const errorMsg = result.error || `API returned status ${result.status_code}`;
396
+ this.snackBar.open(`API test failed: ${errorMsg}`, 'Close', {
397
+ duration: 5000,
398
+ panelClass: 'error-snackbar'
399
+ });
400
+ }
401
+ } catch (error: any) {
402
+ this.testResult = {
403
+ success: false,
404
+ error: error.message || 'Test failed'
405
+ };
406
+ this.snackBar.open('API test failed', 'Close', {
407
+ duration: 3000,
408
+ panelClass: 'error-snackbar'
409
+ });
410
+ } finally {
411
+ this.testing = false;
412
+ }
413
+ }
414
+
415
+ updateTestRequestJson() {
416
+ const formValue = this.form.getRawValue();
417
+ let bodyTemplate = {};
418
+
419
+ try {
420
+ bodyTemplate = JSON.parse(formValue.body_template);
421
+ } catch {
422
+ bodyTemplate = {};
423
+ }
424
+
425
+ const testData = this.replacePlaceholdersForTest(bodyTemplate);
426
+ this.testRequestJson = JSON.stringify(testData, null, 2);
427
+ }
428
+
429
+ replacePlaceholdersForTest(obj: any): any {
430
+ if (typeof obj === 'string') {
431
+ let result = obj;
432
+
433
+ result = result.replace(/\{\{variables\.origin\}\}/g, 'Istanbul');
434
+ result = result.replace(/\{\{variables\.destination\}\}/g, 'Ankara');
435
+ result = result.replace(/\{\{variables\.flight_date\}\}/g, '2025-06-15');
436
+ result = result.replace(/\{\{variables\.passenger_count\}\}/g, '2');
437
+ result = result.replace(/\{\{variables\.flight_number\}\}/g, 'TK123');
438
+ result = result.replace(/\{\{variables\.pnr\}\}/g, 'ABC12');
439
+ result = result.replace(/\{\{variables\.surname\}\}/g, 'Test');
440
+
441
+ result = result.replace(/\{\{auth_tokens\.[^}]+\.token\}\}/g, 'test_token_123');
442
+
443
+ result = result.replace(/\{\{config\.work_mode\}\}/g, 'hfcloud');
444
+
445
+ result = result.replace(/\{\{[^}]+\}\}/g, 'test_value');
446
+
447
+ return result;
448
+ } else if (typeof obj === 'object' && obj !== null) {
449
+ const result: any = Array.isArray(obj) ? [] : {};
450
+ for (const key in obj) {
451
+ result[key] = this.replacePlaceholdersForTest(obj[key]);
452
+ }
453
+ return result;
454
+ }
455
+ return obj;
456
+ }
457
+
458
+ prepareAPIData(): any {
459
+ const formValue = this.form.getRawValue();
460
+
461
+ const headers: any = {};
462
+ formValue.headers.forEach((h: any) => {
463
+ if (h.key && h.value) {
464
+ headers[h.key] = h.value;
465
+ }
466
+ });
467
+
468
+ let body_template = {};
469
+ let auth_token_request_body = {};
470
+ let auth_token_refresh_body = {};
471
+
472
+ try {
473
+ body_template = formValue.body_template ? JSON.parse(formValue.body_template) : {};
474
+ } catch (e) {
475
+ console.error('Invalid body_template JSON:', e);
476
+ }
477
+
478
+ try {
479
+ auth_token_request_body = formValue.auth.token_request_body ? JSON.parse(formValue.auth.token_request_body) : {};
480
+ } catch (e) {
481
+ console.error('Invalid auth token_request_body JSON:', e);
482
+ }
483
+
484
+ try {
485
+ auth_token_refresh_body = formValue.auth.token_refresh_body ? JSON.parse(formValue.auth.token_refresh_body) : {};
486
+ } catch (e) {
487
+ console.error('Invalid auth token_refresh_body JSON:', e);
488
+ }
489
+
490
+ const apiData: any = {
491
+ name: formValue.name,
492
+ url: formValue.url,
493
+ method: formValue.method,
494
+ headers,
495
+ body_template,
496
+ timeout_seconds: formValue.timeout_seconds,
497
+ retry: formValue.retry,
498
+ response_prompt: formValue.response_prompt,
499
+ response_mappings: formValue.response_mappings || []
500
+ };
501
+
502
+ // Proxy - null olarak gönder boşsa
503
+ apiData.proxy = formValue.proxy || null;
504
+
505
+ if (formValue.proxy) {
506
+ apiData.proxy = formValue.proxy;
507
+ }
508
+
509
+ if (formValue.auth.enabled) {
510
+ apiData.auth = {
511
+ enabled: true,
512
+ token_endpoint: formValue.auth.token_endpoint,
513
+ response_token_path: formValue.auth.response_token_path,
514
+ token_request_body: auth_token_request_body
515
+ };
516
+
517
+ if (formValue.auth.token_refresh_endpoint) {
518
+ apiData.auth.token_refresh_endpoint = formValue.auth.token_refresh_endpoint;
519
+ apiData.auth.token_refresh_body = auth_token_refresh_body;
520
+ }
521
+ }else {
522
+ // Auth disabled olsa bile null olarak gönder
523
+ apiData.auth = null;
524
+ }
525
+
526
+ // Edit modunda last_update_date'i ekle
527
+ if (this.data.mode === 'edit' && formValue.last_update_date) {
528
+ apiData.last_update_date = formValue.last_update_date;
529
+ }
530
+
531
+ console.log('Prepared API data:', apiData);
532
+ return apiData;
533
+ }
534
+
535
+ async save() {
536
+ if (this.data.mode === 'test') {
537
+ this.cancel();
538
+ return;
539
+ }
540
+
541
+ if (this.form.invalid) {
542
+ Object.keys(this.form.controls).forEach(key => {
543
+ this.form.get(key)?.markAsTouched();
544
+ });
545
+
546
+ this.snackBar.open('Please fix validation errors', 'Close', { duration: 3000 });
547
+ return;
548
+ }
549
+
550
+ this.saving = true;
551
+ try {
552
+ const apiData = this.prepareAPIData();
553
+
554
+ if (this.data.mode === 'create' || this.data.mode === 'duplicate') {
555
+ await this.apiService.createAPI(apiData).toPromise();
556
+ this.snackBar.open('API created successfully', 'Close', { duration: 3000 });
557
+ } else {
558
+ await this.apiService.updateAPI(this.data.api.name, apiData).toPromise();
559
+ this.snackBar.open('API updated successfully', 'Close', { duration: 3000 });
560
+ }
561
+
562
+ this.dialogRef.close(true);
563
+ } catch (error: any) {
564
+ const message = error.error?.detail ||
565
+ (this.data.mode === 'create' ? 'Failed to create API' : 'Failed to update API');
566
+ this.snackBar.open(message, 'Close', {
567
+ duration: 5000,
568
+ panelClass: 'error-snackbar'
569
+ });
570
+ } finally {
571
+ this.saving = false;
572
+ }
573
+ }
574
+
575
+ cancel() {
576
+ this.dialogRef.close(false);
577
+ }
578
  }
flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts CHANGED
@@ -1,137 +1,137 @@
1
- import { Component, Inject } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormsModule } from '@angular/forms';
4
- import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
5
- import { MatButtonModule } from '@angular/material/button';
6
- import { MatSelectModule } from '@angular/material/select';
7
- import { MatFormFieldModule } from '@angular/material/form-field';
8
-
9
- export interface ConfirmDialogData {
10
- title: string;
11
- message: string;
12
- confirmText?: string;
13
- cancelText?: string;
14
- confirmColor?: 'primary' | 'accent' | 'warn';
15
- showVersionSelect?: boolean;
16
- versions?: any[];
17
- showDropdown?: boolean;
18
- dropdownOptions?: Array<{value: any, label: string}>;
19
- dropdownPlaceholder?: string;
20
- }
21
-
22
- @Component({
23
- selector: 'app-confirm-dialog',
24
- standalone: true,
25
- imports: [
26
- CommonModule,
27
- FormsModule,
28
- MatDialogModule,
29
- MatButtonModule,
30
- MatSelectModule,
31
- MatFormFieldModule
32
- ],
33
- template: `
34
- <h2 mat-dialog-title>{{ data.title }}</h2>
35
- <mat-dialog-content>
36
- <p>{{ data.message }}</p>
37
-
38
- @if (data.showVersionSelect && data.versions) {
39
- <mat-form-field appearance="outline" class="full-width">
40
- <mat-label>Select Source Version</mat-label>
41
- <mat-select [(ngModel)]="selectedVersionId" required>
42
- @for (version of data.versions; track version.id) {
43
- <mat-option [value]="version.id">
44
- Version {{ version.id }} - {{ version.caption }}
45
- @if (version.published) {
46
- <span class="published-badge">(Published)</span>
47
- }
48
- </mat-option>
49
- }
50
- </mat-select>
51
- </mat-form-field>
52
- }
53
-
54
- @if (data.showDropdown && data.dropdownOptions) {
55
- <mat-form-field appearance="outline" class="full-width">
56
- <mat-label>{{ data.dropdownPlaceholder || 'Select an option' }}</mat-label>
57
- <mat-select [(ngModel)]="selectedValue">
58
- @for (option of data.dropdownOptions; track option.value) {
59
- <mat-option [value]="option.value">
60
- {{ option.label }}
61
- </mat-option>
62
- }
63
- </mat-select>
64
- </mat-form-field>
65
- }
66
-
67
- </mat-dialog-content>
68
- <mat-dialog-actions align="end">
69
- <button mat-button (click)="onCancel()">{{ data.cancelText || 'Cancel' }}</button>
70
- <button mat-raised-button
71
- [color]="data.confirmColor || 'primary'"
72
- (click)="onConfirm()"
73
- [disabled]="(data.showVersionSelect && !selectedVersionId) || (data.showDropdown === true && selectedValue === undefined)">
74
- {{ data.confirmText || 'Confirm' }}
75
- </button>
76
- </mat-dialog-actions>
77
- `,
78
- styles: [`
79
- mat-dialog-content {
80
- padding: 20px 24px;
81
- min-width: 400px;
82
- }
83
-
84
- p {
85
- margin: 0 0 16px 0;
86
- color: rgba(0,0,0,0.87);
87
- line-height: 1.5;
88
- }
89
-
90
- .full-width {
91
- width: 100%;
92
- }
93
-
94
- .published-badge {
95
- color: #4caf50;
96
- font-weight: 500;
97
- margin-left: 8px;
98
- }
99
-
100
- mat-dialog-actions {
101
- padding: 16px 24px;
102
- }
103
- `]
104
- })
105
- export default class ConfirmDialogComponent {
106
- selectedVersionId: number | null = null;
107
- selectedValue: any = undefined;
108
-
109
- constructor(
110
- public dialogRef: MatDialogRef<ConfirmDialogComponent>,
111
- @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
112
- ) {
113
- // Pre-select first version if available
114
- if (data.showVersionSelect && data.versions && data.versions.length > 0) {
115
- this.selectedVersionId = data.versions[0].id;
116
- }
117
-
118
- // Dropdown için başlangıç değeri undefined olsun (seçim yapılmamış)
119
- if (data.showDropdown) {
120
- this.selectedValue = undefined;
121
- }
122
- }
123
-
124
- onConfirm(): void {
125
- if (this.data.showVersionSelect) {
126
- this.dialogRef.close(this.selectedVersionId);
127
- } else if (this.data.showDropdown) {
128
- this.dialogRef.close({ confirmed: true, selectedValue: this.selectedValue });
129
- } else {
130
- this.dialogRef.close(true);
131
- }
132
- }
133
-
134
- onCancel(): void {
135
- this.dialogRef.close(false);
136
- }
137
  }
 
1
+ import { Component, Inject } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormsModule } from '@angular/forms';
4
+ import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { MatSelectModule } from '@angular/material/select';
7
+ import { MatFormFieldModule } from '@angular/material/form-field';
8
+
9
+ export interface ConfirmDialogData {
10
+ title: string;
11
+ message: string;
12
+ confirmText?: string;
13
+ cancelText?: string;
14
+ confirmColor?: 'primary' | 'accent' | 'warn';
15
+ showVersionSelect?: boolean;
16
+ versions?: any[];
17
+ showDropdown?: boolean;
18
+ dropdownOptions?: Array<{value: any, label: string}>;
19
+ dropdownPlaceholder?: string;
20
+ }
21
+
22
+ @Component({
23
+ selector: 'app-confirm-dialog',
24
+ standalone: true,
25
+ imports: [
26
+ CommonModule,
27
+ FormsModule,
28
+ MatDialogModule,
29
+ MatButtonModule,
30
+ MatSelectModule,
31
+ MatFormFieldModule
32
+ ],
33
+ template: `
34
+ <h2 mat-dialog-title>{{ data.title }}</h2>
35
+ <mat-dialog-content>
36
+ <p>{{ data.message }}</p>
37
+
38
+ @if (data.showVersionSelect && data.versions) {
39
+ <mat-form-field appearance="outline" class="full-width">
40
+ <mat-label>Select Source Version</mat-label>
41
+ <mat-select [(ngModel)]="selectedVersionId" required>
42
+ @for (version of data.versions; track version.id) {
43
+ <mat-option [value]="version.id">
44
+ Version {{ version.id }} - {{ version.caption }}
45
+ @if (version.published) {
46
+ <span class="published-badge">(Published)</span>
47
+ }
48
+ </mat-option>
49
+ }
50
+ </mat-select>
51
+ </mat-form-field>
52
+ }
53
+
54
+ @if (data.showDropdown && data.dropdownOptions) {
55
+ <mat-form-field appearance="outline" class="full-width">
56
+ <mat-label>{{ data.dropdownPlaceholder || 'Select an option' }}</mat-label>
57
+ <mat-select [(ngModel)]="selectedValue">
58
+ @for (option of data.dropdownOptions; track option.value) {
59
+ <mat-option [value]="option.value">
60
+ {{ option.label }}
61
+ </mat-option>
62
+ }
63
+ </mat-select>
64
+ </mat-form-field>
65
+ }
66
+
67
+ </mat-dialog-content>
68
+ <mat-dialog-actions align="end">
69
+ <button mat-button (click)="onCancel()">{{ data.cancelText || 'Cancel' }}</button>
70
+ <button mat-raised-button
71
+ [color]="data.confirmColor || 'primary'"
72
+ (click)="onConfirm()"
73
+ [disabled]="(data.showVersionSelect && !selectedVersionId) || (data.showDropdown === true && selectedValue === undefined)">
74
+ {{ data.confirmText || 'Confirm' }}
75
+ </button>
76
+ </mat-dialog-actions>
77
+ `,
78
+ styles: [`
79
+ mat-dialog-content {
80
+ padding: 20px 24px;
81
+ min-width: 400px;
82
+ }
83
+
84
+ p {
85
+ margin: 0 0 16px 0;
86
+ color: rgba(0,0,0,0.87);
87
+ line-height: 1.5;
88
+ }
89
+
90
+ .full-width {
91
+ width: 100%;
92
+ }
93
+
94
+ .published-badge {
95
+ color: #4caf50;
96
+ font-weight: 500;
97
+ margin-left: 8px;
98
+ }
99
+
100
+ mat-dialog-actions {
101
+ padding: 16px 24px;
102
+ }
103
+ `]
104
+ })
105
+ export default class ConfirmDialogComponent {
106
+ selectedVersionId: number | null = null;
107
+ selectedValue: any = undefined;
108
+
109
+ constructor(
110
+ public dialogRef: MatDialogRef<ConfirmDialogComponent>,
111
+ @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
112
+ ) {
113
+ // Pre-select first version if available
114
+ if (data.showVersionSelect && data.versions && data.versions.length > 0) {
115
+ this.selectedVersionId = data.versions[0].id;
116
+ }
117
+
118
+ // Dropdown için başlangıç değeri undefined olsun (seçim yapılmamış)
119
+ if (data.showDropdown) {
120
+ this.selectedValue = undefined;
121
+ }
122
+ }
123
+
124
+ onConfirm(): void {
125
+ if (this.data.showVersionSelect) {
126
+ this.dialogRef.close(this.selectedVersionId);
127
+ } else if (this.data.showDropdown) {
128
+ this.dialogRef.close({ confirmed: true, selectedValue: this.selectedValue });
129
+ } else {
130
+ this.dialogRef.close(true);
131
+ }
132
+ }
133
+
134
+ onCancel(): void {
135
+ this.dialogRef.close(false);
136
+ }
137
  }
flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html CHANGED
@@ -1,243 +1,243 @@
1
- <h2 mat-dialog-title>
2
- {{ data.intent ? 'Edit Intent' : 'Create Intent' }}
3
- </h2>
4
-
5
- <mat-dialog-content>
6
- <form [formGroup]="form">
7
- <mat-tab-group>
8
- <!-- General Tab -->
9
- <mat-tab label="General">
10
- <div class="tab-content">
11
-
12
- <mat-form-field appearance="outline" class="full-width">
13
- <mat-label>Intent Name*</mat-label>
14
- <input matInput formControlName="name"
15
- placeholder="e.g., flight-booking">
16
- <mat-hint>Use lowercase with hyphens, no spaces</mat-hint>
17
- <mat-error *ngIf="form.get('name')?.hasError('required')">Name is required</mat-error>
18
- <mat-error *ngIf="form.get('name')?.hasError('pattern')">Invalid format</mat-error>
19
- </mat-form-field>
20
-
21
- <mat-form-field appearance="outline" class="full-width">
22
- <mat-label>Caption*</mat-label>
23
- <input matInput formControlName="caption"
24
- placeholder="e.g., Flight Booking Intent">
25
- <mat-error *ngIf="form.get('caption')?.hasError('required')">Caption is required</mat-error>
26
- </mat-form-field>
27
-
28
- <mat-form-field appearance="outline" class="full-width">
29
- <mat-label>Detection Prompt*</mat-label>
30
- <textarea matInput formControlName="detection_prompt" class="code-textarea"
31
- rows="4"
32
- placeholder="Describe when this intent should be detected..."></textarea>
33
- <mat-hint>Explain to the LLM when to detect this intent</mat-hint>
34
- </mat-form-field>
35
-
36
- <mat-form-field appearance="outline" class="full-width">
37
- <mat-label>API Action*</mat-label>
38
- <mat-select formControlName="action">
39
- <mat-option *ngFor="let api of availableAPIs" [value]="api.name">
40
- {{ api.name }} - {{ api.method }} {{ api.url }}
41
- </mat-option>
42
- </mat-select>
43
- <mat-hint>Select the API to call when this intent is triggered</mat-hint>
44
- </mat-form-field>
45
-
46
- <mat-divider></mat-divider>
47
-
48
- <h4>Fallback Messages</h4>
49
-
50
- <mat-form-field appearance="outline" class="full-width">
51
- <mat-label>Timeout Message</mat-label>
52
- <textarea matInput formControlName="fallback_timeout_prompt" class="code-textarea"
53
- rows="2"
54
- placeholder="Message when API times out..."></textarea>
55
- </mat-form-field>
56
-
57
- <mat-form-field appearance="outline" class="full-width">
58
- <mat-label>Error Message</mat-label>
59
- <textarea matInput formControlName="fallback_error_prompt" class="code-textarea"
60
- rows="2"
61
- placeholder="Message when API returns error..."></textarea>
62
- </mat-form-field>
63
- </div>
64
- </mat-tab>
65
-
66
- <!-- Examples Tab -->
67
- <mat-tab label="Examples">
68
- <div class="tab-content">
69
- <div class="examples-section">
70
- <div class="examples-header">
71
- <h4>Examples for</h4>
72
- <mat-form-field appearance="outline" class="locale-selector">
73
- <mat-select [(value)]="selectedExampleLocale">
74
- <mat-option *ngFor="let locale of supportedLocales" [value]="locale">
75
- {{ getLocaleName(locale) }}
76
- </mat-option>
77
- </mat-select>
78
- </mat-form-field>
79
- </div>
80
-
81
- <div class="add-example">
82
- <mat-form-field appearance="outline" class="example-input">
83
- <mat-label>New Example</mat-label>
84
- <input matInput [(ngModel)]="newExample" [ngModelOptions]="{standalone: true}"
85
- placeholder="e.g., I want to book a flight from Istanbul to Ankara"
86
- (keyup.enter)="addExample()">
87
- </mat-form-field>
88
- <button mat-raised-button color="accent" (click)="addExample()" [disabled]="!newExample.trim()">
89
- <mat-icon>add</mat-icon>
90
- Add Example
91
- </button>
92
- </div>
93
-
94
- <mat-list class="examples-list" *ngIf="getExamplesForCurrentLocale().length > 0">
95
- <mat-list-item *ngFor="let example of getExamplesForCurrentLocale()">
96
- {{ example.example }}
97
- <button mat-icon-button matListItemMeta (click)="removeExample(example)">
98
- <mat-icon>delete</mat-icon>
99
- </button>
100
- </mat-list-item>
101
- </mat-list>
102
-
103
- <div class="empty-state" *ngIf="getExamplesForCurrentLocale().length === 0">
104
- <mat-icon>format_list_bulleted</mat-icon>
105
- <p>No examples for {{ getLocaleName(selectedExampleLocale) }} yet.</p>
106
- </div>
107
- </div>
108
- </div>
109
- </mat-tab>
110
-
111
- <!-- Parameters Tab -->
112
- <mat-tab label="Parameters">
113
- <div class="tab-content">
114
- <div class="parameters-header">
115
- <h4>Intent Parameters</h4>
116
- <button mat-raised-button color="primary" (click)="addParameter()">
117
- <mat-icon>add</mat-icon>
118
- Add Parameter
119
- </button>
120
- </div>
121
-
122
- <div formArrayName="parameters" class="parameters-list">
123
- <mat-expansion-panel *ngFor="let param of parameters.controls; let i = index"
124
- [formGroupName]="i">
125
- <mat-expansion-panel-header>
126
- <mat-panel-title>
127
- {{ param.get('name')?.value || 'New Parameter' }}
128
- </mat-panel-title>
129
- <mat-panel-description>
130
- <mat-chip-listbox>
131
- <mat-chip-option>{{ param.get('type')?.value }}</mat-chip-option>
132
- <mat-chip-option *ngIf="param.get('required')?.value" selected>Required</mat-chip-option>
133
- <mat-chip-option *ngIf="!param.get('required')?.value">Optional</mat-chip-option>
134
- </mat-chip-listbox>
135
- </mat-panel-description>
136
- </mat-expansion-panel-header>
137
-
138
- <div class="parameter-content">
139
- <div class="parameter-grid">
140
- <mat-form-field appearance="outline">
141
- <mat-label>Parameter Name*</mat-label>
142
- <input matInput formControlName="name"
143
- placeholder="e.g., origin_city">
144
- <mat-hint>Use snake_case</mat-hint>
145
- </mat-form-field>
146
-
147
- <mat-form-field appearance="outline">
148
- <mat-label>Display Name</mat-label>
149
- <input matInput [value]="getCaptionDisplay(param.get('caption')?.value)"
150
- readonly
151
- (click)="openCaptionDialog(i)"
152
- placeholder="Click to edit captions">
153
- <button mat-icon-button matSuffix (click)="openCaptionDialog(i)" type="button">
154
- <mat-icon>edit</mat-icon>
155
- </button>
156
- <mat-hint>Multi-language captions</mat-hint>
157
- </mat-form-field>
158
-
159
- <mat-form-field appearance="outline">
160
- <mat-label>Type*</mat-label>
161
- <mat-select formControlName="type">
162
- <mat-option *ngFor="let type of parameterTypes" [value]="type">
163
- {{ type }}
164
- </mat-option>
165
- </mat-select>
166
- </mat-form-field>
167
-
168
- <mat-form-field appearance="outline">
169
- <mat-label>Variable Name*</mat-label>
170
- <input matInput formControlName="variable_name"
171
- placeholder="e.g., origin">
172
- <mat-hint>Session variable name</mat-hint>
173
- </mat-form-field>
174
- </div>
175
-
176
- <mat-checkbox formControlName="required">Required Parameter</mat-checkbox>
177
-
178
- <mat-form-field appearance="outline" class="full-width">
179
- <mat-label>Extraction Prompt</mat-label>
180
- <textarea matInput formControlName="extraction_prompt" class="code-textarea"
181
- rows="3"
182
- placeholder="Instructions for extracting this parameter..."></textarea>
183
- </mat-form-field>
184
-
185
- <mat-form-field appearance="outline" class="full-width">
186
- <mat-label>Validation Regex</mat-label>
187
- <input matInput formControlName="validation_regex"
188
- placeholder="e.g., ^[A-Z]{3}$">
189
- <button mat-icon-button matSuffix (click)="testRegex(i)" type="button">
190
- <mat-icon>bug_report</mat-icon>
191
- </button>
192
- <mat-hint>Optional regex pattern for validation</mat-hint>
193
- </mat-form-field>
194
-
195
- <mat-form-field appearance="outline" class="full-width">
196
- <mat-label>Invalid Value Message</mat-label>
197
- <input matInput formControlName="invalid_prompt"
198
- placeholder="Message when value doesn't match regex...">
199
- </mat-form-field>
200
-
201
- <mat-form-field appearance="outline" class="full-width">
202
- <mat-label>Type Error Message</mat-label>
203
- <input matInput formControlName="type_error_prompt"
204
- placeholder="Message when value has wrong type...">
205
- </mat-form-field>
206
-
207
- <div class="parameter-actions">
208
- <button mat-button color="warn" (click)="removeParameter(i)">
209
- <mat-icon>delete</mat-icon>
210
- Remove
211
- </button>
212
- <div class="spacer"></div>
213
- <button mat-icon-button (click)="moveParameter(i, 'up')"
214
- [disabled]="i === 0">
215
- <mat-icon>arrow_upward</mat-icon>
216
- </button>
217
- <button mat-icon-button (click)="moveParameter(i, 'down')"
218
- [disabled]="i === parameters.length - 1">
219
- <mat-icon>arrow_downward</mat-icon>
220
- </button>
221
- </div>
222
- </div>
223
- </mat-expansion-panel>
224
- </div>
225
-
226
- <div class="empty-state" *ngIf="parameters.length === 0">
227
- <mat-icon>input</mat-icon>
228
- <p>No parameters defined. Add parameters that need to be extracted from user input.</p>
229
- </div>
230
- </div>
231
- </mat-tab>
232
- </mat-tab-group>
233
- </form>
234
- </mat-dialog-content>
235
-
236
- <mat-dialog-actions align="end">
237
- <button mat-button (click)="cancel()">Cancel</button>
238
- <button mat-raised-button color="primary"
239
- (click)="save()"
240
- [disabled]="form.invalid">
241
- Save
242
- </button>
243
  </mat-dialog-actions>
 
1
+ <h2 mat-dialog-title>
2
+ {{ data.intent ? 'Edit Intent' : 'Create Intent' }}
3
+ </h2>
4
+
5
+ <mat-dialog-content>
6
+ <form [formGroup]="form">
7
+ <mat-tab-group>
8
+ <!-- General Tab -->
9
+ <mat-tab label="General">
10
+ <div class="tab-content">
11
+
12
+ <mat-form-field appearance="outline" class="full-width">
13
+ <mat-label>Intent Name*</mat-label>
14
+ <input matInput formControlName="name"
15
+ placeholder="e.g., flight-booking">
16
+ <mat-hint>Use lowercase with hyphens, no spaces</mat-hint>
17
+ <mat-error *ngIf="form.get('name')?.hasError('required')">Name is required</mat-error>
18
+ <mat-error *ngIf="form.get('name')?.hasError('pattern')">Invalid format</mat-error>
19
+ </mat-form-field>
20
+
21
+ <mat-form-field appearance="outline" class="full-width">
22
+ <mat-label>Caption*</mat-label>
23
+ <input matInput formControlName="caption"
24
+ placeholder="e.g., Flight Booking Intent">
25
+ <mat-error *ngIf="form.get('caption')?.hasError('required')">Caption is required</mat-error>
26
+ </mat-form-field>
27
+
28
+ <mat-form-field appearance="outline" class="full-width">
29
+ <mat-label>Detection Prompt*</mat-label>
30
+ <textarea matInput formControlName="detection_prompt" class="code-textarea"
31
+ rows="4"
32
+ placeholder="Describe when this intent should be detected..."></textarea>
33
+ <mat-hint>Explain to the LLM when to detect this intent</mat-hint>
34
+ </mat-form-field>
35
+
36
+ <mat-form-field appearance="outline" class="full-width">
37
+ <mat-label>API Action*</mat-label>
38
+ <mat-select formControlName="action">
39
+ <mat-option *ngFor="let api of availableAPIs" [value]="api.name">
40
+ {{ api.name }} - {{ api.method }} {{ api.url }}
41
+ </mat-option>
42
+ </mat-select>
43
+ <mat-hint>Select the API to call when this intent is triggered</mat-hint>
44
+ </mat-form-field>
45
+
46
+ <mat-divider></mat-divider>
47
+
48
+ <h4>Fallback Messages</h4>
49
+
50
+ <mat-form-field appearance="outline" class="full-width">
51
+ <mat-label>Timeout Message</mat-label>
52
+ <textarea matInput formControlName="fallback_timeout_prompt" class="code-textarea"
53
+ rows="2"
54
+ placeholder="Message when API times out..."></textarea>
55
+ </mat-form-field>
56
+
57
+ <mat-form-field appearance="outline" class="full-width">
58
+ <mat-label>Error Message</mat-label>
59
+ <textarea matInput formControlName="fallback_error_prompt" class="code-textarea"
60
+ rows="2"
61
+ placeholder="Message when API returns error..."></textarea>
62
+ </mat-form-field>
63
+ </div>
64
+ </mat-tab>
65
+
66
+ <!-- Examples Tab -->
67
+ <mat-tab label="Examples">
68
+ <div class="tab-content">
69
+ <div class="examples-section">
70
+ <div class="examples-header">
71
+ <h4>Examples for</h4>
72
+ <mat-form-field appearance="outline" class="locale-selector">
73
+ <mat-select [(value)]="selectedExampleLocale">
74
+ <mat-option *ngFor="let locale of supportedLocales" [value]="locale">
75
+ {{ getLocaleName(locale) }}
76
+ </mat-option>
77
+ </mat-select>
78
+ </mat-form-field>
79
+ </div>
80
+
81
+ <div class="add-example">
82
+ <mat-form-field appearance="outline" class="example-input">
83
+ <mat-label>New Example</mat-label>
84
+ <input matInput [(ngModel)]="newExample" [ngModelOptions]="{standalone: true}"
85
+ placeholder="e.g., I want to book a flight from Istanbul to Ankara"
86
+ (keyup.enter)="addExample()">
87
+ </mat-form-field>
88
+ <button mat-raised-button color="accent" (click)="addExample()" [disabled]="!newExample.trim()">
89
+ <mat-icon>add</mat-icon>
90
+ Add Example
91
+ </button>
92
+ </div>
93
+
94
+ <mat-list class="examples-list" *ngIf="getExamplesForCurrentLocale().length > 0">
95
+ <mat-list-item *ngFor="let example of getExamplesForCurrentLocale()">
96
+ {{ example.example }}
97
+ <button mat-icon-button matListItemMeta (click)="removeExample(example)">
98
+ <mat-icon>delete</mat-icon>
99
+ </button>
100
+ </mat-list-item>
101
+ </mat-list>
102
+
103
+ <div class="empty-state" *ngIf="getExamplesForCurrentLocale().length === 0">
104
+ <mat-icon>format_list_bulleted</mat-icon>
105
+ <p>No examples for {{ getLocaleName(selectedExampleLocale) }} yet.</p>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </mat-tab>
110
+
111
+ <!-- Parameters Tab -->
112
+ <mat-tab label="Parameters">
113
+ <div class="tab-content">
114
+ <div class="parameters-header">
115
+ <h4>Intent Parameters</h4>
116
+ <button mat-raised-button color="primary" (click)="addParameter()">
117
+ <mat-icon>add</mat-icon>
118
+ Add Parameter
119
+ </button>
120
+ </div>
121
+
122
+ <div formArrayName="parameters" class="parameters-list">
123
+ <mat-expansion-panel *ngFor="let param of parameters.controls; let i = index"
124
+ [formGroupName]="i">
125
+ <mat-expansion-panel-header>
126
+ <mat-panel-title>
127
+ {{ param.get('name')?.value || 'New Parameter' }}
128
+ </mat-panel-title>
129
+ <mat-panel-description>
130
+ <mat-chip-listbox>
131
+ <mat-chip-option>{{ param.get('type')?.value }}</mat-chip-option>
132
+ <mat-chip-option *ngIf="param.get('required')?.value" selected>Required</mat-chip-option>
133
+ <mat-chip-option *ngIf="!param.get('required')?.value">Optional</mat-chip-option>
134
+ </mat-chip-listbox>
135
+ </mat-panel-description>
136
+ </mat-expansion-panel-header>
137
+
138
+ <div class="parameter-content">
139
+ <div class="parameter-grid">
140
+ <mat-form-field appearance="outline">
141
+ <mat-label>Parameter Name*</mat-label>
142
+ <input matInput formControlName="name"
143
+ placeholder="e.g., origin_city">
144
+ <mat-hint>Use snake_case</mat-hint>
145
+ </mat-form-field>
146
+
147
+ <mat-form-field appearance="outline">
148
+ <mat-label>Display Name</mat-label>
149
+ <input matInput [value]="getCaptionDisplay(param.get('caption')?.value)"
150
+ readonly
151
+ (click)="openCaptionDialog(i)"
152
+ placeholder="Click to edit captions">
153
+ <button mat-icon-button matSuffix (click)="openCaptionDialog(i)" type="button">
154
+ <mat-icon>edit</mat-icon>
155
+ </button>
156
+ <mat-hint>Multi-language captions</mat-hint>
157
+ </mat-form-field>
158
+
159
+ <mat-form-field appearance="outline">
160
+ <mat-label>Type*</mat-label>
161
+ <mat-select formControlName="type">
162
+ <mat-option *ngFor="let type of parameterTypes" [value]="type">
163
+ {{ type }}
164
+ </mat-option>
165
+ </mat-select>
166
+ </mat-form-field>
167
+
168
+ <mat-form-field appearance="outline">
169
+ <mat-label>Variable Name*</mat-label>
170
+ <input matInput formControlName="variable_name"
171
+ placeholder="e.g., origin">
172
+ <mat-hint>Session variable name</mat-hint>
173
+ </mat-form-field>
174
+ </div>
175
+
176
+ <mat-checkbox formControlName="required">Required Parameter</mat-checkbox>
177
+
178
+ <mat-form-field appearance="outline" class="full-width">
179
+ <mat-label>Extraction Prompt</mat-label>
180
+ <textarea matInput formControlName="extraction_prompt" class="code-textarea"
181
+ rows="3"
182
+ placeholder="Instructions for extracting this parameter..."></textarea>
183
+ </mat-form-field>
184
+
185
+ <mat-form-field appearance="outline" class="full-width">
186
+ <mat-label>Validation Regex</mat-label>
187
+ <input matInput formControlName="validation_regex"
188
+ placeholder="e.g., ^[A-Z]{3}$">
189
+ <button mat-icon-button matSuffix (click)="testRegex(i)" type="button">
190
+ <mat-icon>bug_report</mat-icon>
191
+ </button>
192
+ <mat-hint>Optional regex pattern for validation</mat-hint>
193
+ </mat-form-field>
194
+
195
+ <mat-form-field appearance="outline" class="full-width">
196
+ <mat-label>Invalid Value Message</mat-label>
197
+ <input matInput formControlName="invalid_prompt"
198
+ placeholder="Message when value doesn't match regex...">
199
+ </mat-form-field>
200
+
201
+ <mat-form-field appearance="outline" class="full-width">
202
+ <mat-label>Type Error Message</mat-label>
203
+ <input matInput formControlName="type_error_prompt"
204
+ placeholder="Message when value has wrong type...">
205
+ </mat-form-field>
206
+
207
+ <div class="parameter-actions">
208
+ <button mat-button color="warn" (click)="removeParameter(i)">
209
+ <mat-icon>delete</mat-icon>
210
+ Remove
211
+ </button>
212
+ <div class="spacer"></div>
213
+ <button mat-icon-button (click)="moveParameter(i, 'up')"
214
+ [disabled]="i === 0">
215
+ <mat-icon>arrow_upward</mat-icon>
216
+ </button>
217
+ <button mat-icon-button (click)="moveParameter(i, 'down')"
218
+ [disabled]="i === parameters.length - 1">
219
+ <mat-icon>arrow_downward</mat-icon>
220
+ </button>
221
+ </div>
222
+ </div>
223
+ </mat-expansion-panel>
224
+ </div>
225
+
226
+ <div class="empty-state" *ngIf="parameters.length === 0">
227
+ <mat-icon>input</mat-icon>
228
+ <p>No parameters defined. Add parameters that need to be extracted from user input.</p>
229
+ </div>
230
+ </div>
231
+ </mat-tab>
232
+ </mat-tab-group>
233
+ </form>
234
+ </mat-dialog-content>
235
+
236
+ <mat-dialog-actions align="end">
237
+ <button mat-button (click)="cancel()">Cancel</button>
238
+ <button mat-raised-button color="primary"
239
+ (click)="save()"
240
+ [disabled]="form.invalid">
241
+ Save
242
+ </button>
243
  </mat-dialog-actions>
flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss CHANGED
@@ -1,149 +1,149 @@
1
- mat-dialog-content {
2
- min-width: 700px;
3
- max-width: 900px;
4
- max-height: 70vh;
5
- padding: 0;
6
- }
7
-
8
- .tab-content {
9
- padding: 24px;
10
- }
11
-
12
- .full-width {
13
- width: 100%;
14
- margin-bottom: 16px;
15
- }
16
-
17
- h4 {
18
- margin: 24px 0 16px 0;
19
- color: rgba(0, 0, 0, 0.87);
20
- }
21
-
22
- mat-divider {
23
- margin: 24px 0;
24
- }
25
-
26
- // Examples Tab
27
- .examples-section {
28
- .examples-header {
29
- display: flex;
30
- align-items: center;
31
- gap: 16px;
32
- margin-bottom: 16px;
33
-
34
- h4 {
35
- margin: 0;
36
- }
37
-
38
- .locale-selector {
39
- width: 150px;
40
- }
41
- }
42
-
43
- .add-example {
44
- display: flex;
45
- gap: 16px;
46
- align-items: flex-start;
47
- margin-bottom: 24px;
48
-
49
- .example-input {
50
- flex: 1;
51
- }
52
- }
53
-
54
- .examples-list {
55
- border: 1px solid #e0e0e0;
56
- border-radius: 4px;
57
- padding: 0;
58
-
59
- mat-list-item {
60
- border-bottom: 1px solid #f5f5f5;
61
-
62
- &:last-child {
63
- border-bottom: none;
64
- }
65
-
66
- &:hover {
67
- background-color: #f5f5f5;
68
- }
69
- }
70
- }
71
- }
72
-
73
- // Parameters Tab
74
- .parameters-header {
75
- display: flex;
76
- justify-content: space-between;
77
- align-items: center;
78
- margin-bottom: 16px;
79
-
80
- h4 {
81
- margin: 0;
82
- }
83
- }
84
-
85
- .parameters-list {
86
- mat-expansion-panel {
87
- margin-bottom: 8px;
88
-
89
- mat-chip-listbox {
90
- margin-left: 16px;
91
-
92
- mat-chip {
93
- font-size: 11px;
94
- min-height: 20px;
95
- padding: 2px 8px;
96
- }
97
- }
98
- }
99
-
100
- .parameter-content {
101
- padding: 16px;
102
-
103
- .parameter-grid {
104
- display: grid;
105
- grid-template-columns: 1fr 1fr;
106
- gap: 16px;
107
- margin-bottom: 16px;
108
- }
109
-
110
- mat-checkbox {
111
- margin-bottom: 16px;
112
- }
113
-
114
- .parameter-actions {
115
- display: flex;
116
- align-items: center;
117
- margin-top: 16px;
118
- padding-top: 16px;
119
- border-top: 1px solid #e0e0e0;
120
-
121
- .spacer {
122
- flex: 1;
123
- }
124
- }
125
- }
126
- }
127
-
128
- .empty-state {
129
- text-align: center;
130
- padding: 40px 20px;
131
-
132
- mat-icon {
133
- font-size: 48px;
134
- width: 48px;
135
- height: 48px;
136
- color: #e0e0e0;
137
- margin-bottom: 16px;
138
- }
139
-
140
- p {
141
- color: #666;
142
- margin: 0;
143
- }
144
- }
145
-
146
- mat-dialog-actions {
147
- padding: 16px 24px;
148
- margin: 0;
149
  }
 
1
+ mat-dialog-content {
2
+ min-width: 700px;
3
+ max-width: 900px;
4
+ max-height: 70vh;
5
+ padding: 0;
6
+ }
7
+
8
+ .tab-content {
9
+ padding: 24px;
10
+ }
11
+
12
+ .full-width {
13
+ width: 100%;
14
+ margin-bottom: 16px;
15
+ }
16
+
17
+ h4 {
18
+ margin: 24px 0 16px 0;
19
+ color: rgba(0, 0, 0, 0.87);
20
+ }
21
+
22
+ mat-divider {
23
+ margin: 24px 0;
24
+ }
25
+
26
+ // Examples Tab
27
+ .examples-section {
28
+ .examples-header {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 16px;
32
+ margin-bottom: 16px;
33
+
34
+ h4 {
35
+ margin: 0;
36
+ }
37
+
38
+ .locale-selector {
39
+ width: 150px;
40
+ }
41
+ }
42
+
43
+ .add-example {
44
+ display: flex;
45
+ gap: 16px;
46
+ align-items: flex-start;
47
+ margin-bottom: 24px;
48
+
49
+ .example-input {
50
+ flex: 1;
51
+ }
52
+ }
53
+
54
+ .examples-list {
55
+ border: 1px solid #e0e0e0;
56
+ border-radius: 4px;
57
+ padding: 0;
58
+
59
+ mat-list-item {
60
+ border-bottom: 1px solid #f5f5f5;
61
+
62
+ &:last-child {
63
+ border-bottom: none;
64
+ }
65
+
66
+ &:hover {
67
+ background-color: #f5f5f5;
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ // Parameters Tab
74
+ .parameters-header {
75
+ display: flex;
76
+ justify-content: space-between;
77
+ align-items: center;
78
+ margin-bottom: 16px;
79
+
80
+ h4 {
81
+ margin: 0;
82
+ }
83
+ }
84
+
85
+ .parameters-list {
86
+ mat-expansion-panel {
87
+ margin-bottom: 8px;
88
+
89
+ mat-chip-listbox {
90
+ margin-left: 16px;
91
+
92
+ mat-chip {
93
+ font-size: 11px;
94
+ min-height: 20px;
95
+ padding: 2px 8px;
96
+ }
97
+ }
98
+ }
99
+
100
+ .parameter-content {
101
+ padding: 16px;
102
+
103
+ .parameter-grid {
104
+ display: grid;
105
+ grid-template-columns: 1fr 1fr;
106
+ gap: 16px;
107
+ margin-bottom: 16px;
108
+ }
109
+
110
+ mat-checkbox {
111
+ margin-bottom: 16px;
112
+ }
113
+
114
+ .parameter-actions {
115
+ display: flex;
116
+ align-items: center;
117
+ margin-top: 16px;
118
+ padding-top: 16px;
119
+ border-top: 1px solid #e0e0e0;
120
+
121
+ .spacer {
122
+ flex: 1;
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ .empty-state {
129
+ text-align: center;
130
+ padding: 40px 20px;
131
+
132
+ mat-icon {
133
+ font-size: 48px;
134
+ width: 48px;
135
+ height: 48px;
136
+ color: #e0e0e0;
137
+ margin-bottom: 16px;
138
+ }
139
+
140
+ p {
141
+ color: #666;
142
+ margin: 0;
143
+ }
144
+ }
145
+
146
+ mat-dialog-actions {
147
+ padding: 16px 24px;
148
+ margin: 0;
149
  }
flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts CHANGED
@@ -1,341 +1,341 @@
1
- import { Component, Inject, OnInit } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
4
- import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
5
- import { MatFormFieldModule } from '@angular/material/form-field';
6
- import { MatInputModule } from '@angular/material/input';
7
- import { MatSelectModule } from '@angular/material/select';
8
- import { MatCheckboxModule } from '@angular/material/checkbox';
9
- import { MatButtonModule } from '@angular/material/button';
10
- import { MatIconModule } from '@angular/material/icon';
11
- import { MatChipsModule } from '@angular/material/chips';
12
- import { MatTableModule } from '@angular/material/table';
13
- import { MatTabsModule } from '@angular/material/tabs';
14
- import { MatExpansionModule } from '@angular/material/expansion';
15
- import { MatListModule } from '@angular/material/list';
16
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
17
- import { MatDialog } from '@angular/material/dialog';
18
-
19
- // Interfaces for multi-language support
20
- interface LocalizedExample {
21
- locale_code: string;
22
- example: string;
23
- }
24
-
25
- interface LocalizedCaption {
26
- locale_code: string;
27
- caption: string;
28
- }
29
-
30
- interface ParameterWithLocalizedCaption {
31
- name: string;
32
- caption: LocalizedCaption[];
33
- type: string;
34
- required: boolean;
35
- variable_name: string;
36
- extraction_prompt?: string;
37
- validation_regex?: string;
38
- invalid_prompt?: string;
39
- type_error_prompt?: string;
40
- }
41
-
42
- @Component({
43
- selector: 'app-intent-edit-dialog',
44
- standalone: true,
45
- imports: [
46
- CommonModule,
47
- ReactiveFormsModule,
48
- FormsModule,
49
- MatDialogModule,
50
- MatFormFieldModule,
51
- MatInputModule,
52
- MatSelectModule,
53
- MatCheckboxModule,
54
- MatButtonModule,
55
- MatIconModule,
56
- MatChipsModule,
57
- MatTableModule,
58
- MatTabsModule,
59
- MatExpansionModule,
60
- MatListModule,
61
- MatSnackBarModule
62
- ],
63
- templateUrl: './intent-edit-dialog.component.html',
64
- styleUrls: ['./intent-edit-dialog.component.scss']
65
- })
66
- export default class IntentEditDialogComponent implements OnInit {
67
- form!: FormGroup;
68
- availableAPIs: any[] = [];
69
- parameterTypes = ['str', 'int', 'float', 'bool', 'date'];
70
-
71
- // Multi-language support
72
- supportedLocales: string[] = [];
73
- selectedExampleLocale: string = '';
74
- examples: LocalizedExample[] = [];
75
-
76
- newExample = '';
77
-
78
- constructor(
79
- private fb: FormBuilder,
80
- private snackBar: MatSnackBar,
81
- private dialog: MatDialog,
82
- public dialogRef: MatDialogRef<IntentEditDialogComponent>,
83
- @Inject(MAT_DIALOG_DATA) public data: any
84
- ) {
85
- this.availableAPIs = data.apis || [];
86
- this.supportedLocales = data.project?.supported_locales || data.supportedLocales || ['tr'];
87
- this.selectedExampleLocale = data.project?.default_locale || data.defaultLocale || this.supportedLocales[0] || 'tr';
88
- }
89
-
90
- ngOnInit() {
91
- this.initializeForm();
92
- if (this.data.intent) {
93
- this.populateForm(this.data.intent);
94
- }
95
- }
96
-
97
- initializeForm() {
98
- this.form = this.fb.group({
99
- name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9-]+$/)]],
100
- caption: ['', Validators.required],
101
- detection_prompt: ['', Validators.required],
102
- parameters: this.fb.array([]),
103
- action: ['', Validators.required],
104
- fallback_timeout_prompt: [''],
105
- fallback_error_prompt: ['']
106
- });
107
- }
108
-
109
- populateForm(intent: any) {
110
- // Populate basic fields
111
- this.form.patchValue({
112
- name: intent.name || '',
113
- caption: intent.caption || '',
114
- detection_prompt: intent.detection_prompt || '',
115
- action: intent.action || '',
116
- fallback_timeout_prompt: intent.fallback_timeout_prompt || '',
117
- fallback_error_prompt: intent.fallback_error_prompt || ''
118
- });
119
-
120
- // Populate localized examples
121
- if (intent.examples && Array.isArray(intent.examples)) {
122
- if (intent.examples.length > 0 && typeof intent.examples[0] === 'object' && 'locale_code' in intent.examples[0]) {
123
- // New format with LocalizedExample
124
- this.examples = [...intent.examples];
125
- } else if (typeof intent.examples[0] === 'string') {
126
- // Old format - convert to new format using default locale
127
- this.examples = intent.examples.map((ex: string) => ({
128
- locale_code: this.selectedExampleLocale,
129
- example: ex
130
- }));
131
- }
132
- }
133
-
134
- // Populate parameters with localized captions
135
- if (intent.parameters && Array.isArray(intent.parameters)) {
136
- const paramsArray = this.form.get('parameters') as FormArray;
137
- paramsArray.clear();
138
-
139
- intent.parameters.forEach((param: any) => {
140
- paramsArray.push(this.createParameterFormGroup(param));
141
- });
142
- }
143
- }
144
-
145
- createParameterFormGroup(param?: any): FormGroup {
146
- // Convert old caption format to new if needed
147
- let captionArray: LocalizedCaption[] = [];
148
- if (param?.caption) {
149
- if (Array.isArray(param.caption)) {
150
- captionArray = param.caption;
151
- } else if (typeof param.caption === 'string') {
152
- // Old format - convert to new
153
- captionArray = [{
154
- locale_code: this.selectedExampleLocale,
155
- caption: param.caption
156
- }];
157
- }
158
- }
159
-
160
- return this.fb.group({
161
- name: [param?.name || '', Validators.required],
162
- caption: [captionArray],
163
- type: [param?.type || 'str', Validators.required],
164
- required: [param?.required !== false],
165
- variable_name: [param?.variable_name || '', Validators.required],
166
- extraction_prompt: [param?.extraction_prompt || ''],
167
- validation_regex: [param?.validation_regex || ''],
168
- invalid_prompt: [param?.invalid_prompt || ''],
169
- type_error_prompt: [param?.type_error_prompt || '']
170
- });
171
- }
172
-
173
- get parameters() {
174
- return this.form.get('parameters') as FormArray;
175
- }
176
-
177
- addParameter() {
178
- this.parameters.push(this.createParameterFormGroup());
179
- }
180
-
181
- removeParameter(index: number) {
182
- this.parameters.removeAt(index);
183
- }
184
-
185
- // Multi-language example management
186
- getExamplesForCurrentLocale(): LocalizedExample[] {
187
- return this.examples.filter(ex => ex.locale_code === this.selectedExampleLocale);
188
- }
189
-
190
- addExample() {
191
- if (this.newExample.trim()) {
192
- const existingIndex = this.examples.findIndex(
193
- ex => ex.locale_code === this.selectedExampleLocale && ex.example === this.newExample.trim()
194
- );
195
-
196
- if (existingIndex === -1) {
197
- this.examples.push({
198
- locale_code: this.selectedExampleLocale,
199
- example: this.newExample.trim()
200
- });
201
- this.newExample = '';
202
- } else {
203
- this.snackBar.open('This example already exists for this locale', 'Close', { duration: 3000 });
204
- }
205
- }
206
- }
207
-
208
- removeExample(example: LocalizedExample) {
209
- const index = this.examples.findIndex(
210
- ex => ex.locale_code === example.locale_code && ex.example === example.example
211
- );
212
- if (index !== -1) {
213
- this.examples.splice(index, 1);
214
- }
215
- }
216
-
217
- // Test regex functionality
218
- testRegex(paramIndex: number) {
219
- const param = this.parameters.at(paramIndex);
220
- const regex = param.get('validation_regex')?.value;
221
-
222
- if (!regex) {
223
- this.snackBar.open('No regex pattern to test', 'Close', { duration: 2000 });
224
- return;
225
- }
226
-
227
- // Simple test implementation
228
- const testValue = prompt('Enter a test value:');
229
- if (testValue !== null) {
230
- try {
231
- const pattern = new RegExp(regex);
232
- const matches = pattern.test(testValue);
233
- this.snackBar.open(
234
- matches ? '✓ Pattern matches!' : '✗ Pattern does not match',
235
- 'Close',
236
- { duration: 3000 }
237
- );
238
- } catch (e) {
239
- this.snackBar.open('Invalid regex pattern', 'Close', { duration: 3000 });
240
- }
241
- }
242
- }
243
-
244
- // Move parameter up or down
245
- moveParameter(index: number, direction: 'up' | 'down') {
246
- const newIndex = direction === 'up' ? index - 1 : index + 1;
247
-
248
- if (newIndex < 0 || newIndex >= this.parameters.length) {
249
- return;
250
- }
251
-
252
- const currentItem = this.parameters.at(index);
253
- this.parameters.removeAt(index);
254
- this.parameters.insert(newIndex, currentItem);
255
- }
256
-
257
- // Parameter caption management
258
- getCaptionDisplay(captions: LocalizedCaption[]): string {
259
- if (!captions || captions.length === 0) return '(No caption)';
260
-
261
- // Try to find caption for default locale
262
- const defaultCaption = captions.find(c => c.locale_code === (this.data.project?.default_locale || this.data.defaultLocale || 'tr'));
263
- if (defaultCaption) return defaultCaption.caption;
264
-
265
- // Return first available caption
266
- return captions[0].caption;
267
- }
268
-
269
- async openCaptionDialog(paramIndex: number) {
270
- const param = this.parameters.at(paramIndex);
271
- const currentCaptions = param.get('caption')?.value || [];
272
-
273
- // Import and open caption dialog
274
- const { default: CaptionDialogComponent } = await import('../caption-dialog/caption-dialog.component');
275
-
276
- const dialogRef = this.dialog.open(CaptionDialogComponent, {
277
- width: '600px',
278
- data: {
279
- captions: [...currentCaptions],
280
- supportedLocales: this.supportedLocales,
281
- defaultLocale: this.data.project?.default_locale || this.data.defaultLocale
282
- }
283
- });
284
-
285
- dialogRef.afterClosed().subscribe(result => {
286
- if (result) {
287
- param.patchValue({ caption: result });
288
- }
289
- });
290
- }
291
-
292
- // Locale helpers
293
- getLocaleName(localeCode: string): string {
294
- const localeNames: { [key: string]: string } = {
295
- 'tr': 'Türkçe',
296
- 'en': 'English',
297
- 'de': 'Deutsch',
298
- 'fr': 'Français',
299
- 'es': 'Español',
300
- 'ar': 'العربية',
301
- 'ru': 'Русский',
302
- 'zh': '中文',
303
- 'ja': '日本語',
304
- 'ko': '한국어'
305
- };
306
- return localeNames[localeCode] || localeCode;
307
- }
308
-
309
- onSubmit() {
310
- if (this.form.valid) {
311
- const formValue = this.form.value;
312
-
313
- // Add examples to the result
314
- formValue.examples = this.examples;
315
-
316
- // Ensure all parameters have captions
317
- formValue.parameters = formValue.parameters.map((param: any) => {
318
- if (!param.caption || param.caption.length === 0) {
319
- // Create default caption if missing
320
- param.caption = [{
321
- locale_code: this.data.project?.default_locale || this.data.defaultLocale || 'tr',
322
- caption: param.name
323
- }];
324
- }
325
- return param;
326
- });
327
-
328
- this.dialogRef.close(formValue);
329
- } else {
330
- this.snackBar.open('Please fill all required fields', 'Close', { duration: 3000 });
331
- }
332
- }
333
-
334
- save() {
335
- this.onSubmit();
336
- }
337
-
338
- cancel() {
339
- this.dialogRef.close();
340
- }
341
  }
 
1
+ import { Component, Inject, OnInit } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, FormsModule } from '@angular/forms';
4
+ import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
5
+ import { MatFormFieldModule } from '@angular/material/form-field';
6
+ import { MatInputModule } from '@angular/material/input';
7
+ import { MatSelectModule } from '@angular/material/select';
8
+ import { MatCheckboxModule } from '@angular/material/checkbox';
9
+ import { MatButtonModule } from '@angular/material/button';
10
+ import { MatIconModule } from '@angular/material/icon';
11
+ import { MatChipsModule } from '@angular/material/chips';
12
+ import { MatTableModule } from '@angular/material/table';
13
+ import { MatTabsModule } from '@angular/material/tabs';
14
+ import { MatExpansionModule } from '@angular/material/expansion';
15
+ import { MatListModule } from '@angular/material/list';
16
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
17
+ import { MatDialog } from '@angular/material/dialog';
18
+
19
+ // Interfaces for multi-language support
20
+ interface LocalizedExample {
21
+ locale_code: string;
22
+ example: string;
23
+ }
24
+
25
+ interface LocalizedCaption {
26
+ locale_code: string;
27
+ caption: string;
28
+ }
29
+
30
+ interface ParameterWithLocalizedCaption {
31
+ name: string;
32
+ caption: LocalizedCaption[];
33
+ type: string;
34
+ required: boolean;
35
+ variable_name: string;
36
+ extraction_prompt?: string;
37
+ validation_regex?: string;
38
+ invalid_prompt?: string;
39
+ type_error_prompt?: string;
40
+ }
41
+
42
+ @Component({
43
+ selector: 'app-intent-edit-dialog',
44
+ standalone: true,
45
+ imports: [
46
+ CommonModule,
47
+ ReactiveFormsModule,
48
+ FormsModule,
49
+ MatDialogModule,
50
+ MatFormFieldModule,
51
+ MatInputModule,
52
+ MatSelectModule,
53
+ MatCheckboxModule,
54
+ MatButtonModule,
55
+ MatIconModule,
56
+ MatChipsModule,
57
+ MatTableModule,
58
+ MatTabsModule,
59
+ MatExpansionModule,
60
+ MatListModule,
61
+ MatSnackBarModule
62
+ ],
63
+ templateUrl: './intent-edit-dialog.component.html',
64
+ styleUrls: ['./intent-edit-dialog.component.scss']
65
+ })
66
+ export default class IntentEditDialogComponent implements OnInit {
67
+ form!: FormGroup;
68
+ availableAPIs: any[] = [];
69
+ parameterTypes = ['str', 'int', 'float', 'bool', 'date'];
70
+
71
+ // Multi-language support
72
+ supportedLocales: string[] = [];
73
+ selectedExampleLocale: string = '';
74
+ examples: LocalizedExample[] = [];
75
+
76
+ newExample = '';
77
+
78
+ constructor(
79
+ private fb: FormBuilder,
80
+ private snackBar: MatSnackBar,
81
+ private dialog: MatDialog,
82
+ public dialogRef: MatDialogRef<IntentEditDialogComponent>,
83
+ @Inject(MAT_DIALOG_DATA) public data: any
84
+ ) {
85
+ this.availableAPIs = data.apis || [];
86
+ this.supportedLocales = data.project?.supported_locales || data.supportedLocales || ['tr'];
87
+ this.selectedExampleLocale = data.project?.default_locale || data.defaultLocale || this.supportedLocales[0] || 'tr';
88
+ }
89
+
90
+ ngOnInit() {
91
+ this.initializeForm();
92
+ if (this.data.intent) {
93
+ this.populateForm(this.data.intent);
94
+ }
95
+ }
96
+
97
+ initializeForm() {
98
+ this.form = this.fb.group({
99
+ name: ['', [Validators.required, Validators.pattern(/^[a-zA-Z0-9-]+$/)]],
100
+ caption: ['', Validators.required],
101
+ detection_prompt: ['', Validators.required],
102
+ parameters: this.fb.array([]),
103
+ action: ['', Validators.required],
104
+ fallback_timeout_prompt: [''],
105
+ fallback_error_prompt: ['']
106
+ });
107
+ }
108
+
109
+ populateForm(intent: any) {
110
+ // Populate basic fields
111
+ this.form.patchValue({
112
+ name: intent.name || '',
113
+ caption: intent.caption || '',
114
+ detection_prompt: intent.detection_prompt || '',
115
+ action: intent.action || '',
116
+ fallback_timeout_prompt: intent.fallback_timeout_prompt || '',
117
+ fallback_error_prompt: intent.fallback_error_prompt || ''
118
+ });
119
+
120
+ // Populate localized examples
121
+ if (intent.examples && Array.isArray(intent.examples)) {
122
+ if (intent.examples.length > 0 && typeof intent.examples[0] === 'object' && 'locale_code' in intent.examples[0]) {
123
+ // New format with LocalizedExample
124
+ this.examples = [...intent.examples];
125
+ } else if (typeof intent.examples[0] === 'string') {
126
+ // Old format - convert to new format using default locale
127
+ this.examples = intent.examples.map((ex: string) => ({
128
+ locale_code: this.selectedExampleLocale,
129
+ example: ex
130
+ }));
131
+ }
132
+ }
133
+
134
+ // Populate parameters with localized captions
135
+ if (intent.parameters && Array.isArray(intent.parameters)) {
136
+ const paramsArray = this.form.get('parameters') as FormArray;
137
+ paramsArray.clear();
138
+
139
+ intent.parameters.forEach((param: any) => {
140
+ paramsArray.push(this.createParameterFormGroup(param));
141
+ });
142
+ }
143
+ }
144
+
145
+ createParameterFormGroup(param?: any): FormGroup {
146
+ // Convert old caption format to new if needed
147
+ let captionArray: LocalizedCaption[] = [];
148
+ if (param?.caption) {
149
+ if (Array.isArray(param.caption)) {
150
+ captionArray = param.caption;
151
+ } else if (typeof param.caption === 'string') {
152
+ // Old format - convert to new
153
+ captionArray = [{
154
+ locale_code: this.selectedExampleLocale,
155
+ caption: param.caption
156
+ }];
157
+ }
158
+ }
159
+
160
+ return this.fb.group({
161
+ name: [param?.name || '', Validators.required],
162
+ caption: [captionArray],
163
+ type: [param?.type || 'str', Validators.required],
164
+ required: [param?.required !== false],
165
+ variable_name: [param?.variable_name || '', Validators.required],
166
+ extraction_prompt: [param?.extraction_prompt || ''],
167
+ validation_regex: [param?.validation_regex || ''],
168
+ invalid_prompt: [param?.invalid_prompt || ''],
169
+ type_error_prompt: [param?.type_error_prompt || '']
170
+ });
171
+ }
172
+
173
+ get parameters() {
174
+ return this.form.get('parameters') as FormArray;
175
+ }
176
+
177
+ addParameter() {
178
+ this.parameters.push(this.createParameterFormGroup());
179
+ }
180
+
181
+ removeParameter(index: number) {
182
+ this.parameters.removeAt(index);
183
+ }
184
+
185
+ // Multi-language example management
186
+ getExamplesForCurrentLocale(): LocalizedExample[] {
187
+ return this.examples.filter(ex => ex.locale_code === this.selectedExampleLocale);
188
+ }
189
+
190
+ addExample() {
191
+ if (this.newExample.trim()) {
192
+ const existingIndex = this.examples.findIndex(
193
+ ex => ex.locale_code === this.selectedExampleLocale && ex.example === this.newExample.trim()
194
+ );
195
+
196
+ if (existingIndex === -1) {
197
+ this.examples.push({
198
+ locale_code: this.selectedExampleLocale,
199
+ example: this.newExample.trim()
200
+ });
201
+ this.newExample = '';
202
+ } else {
203
+ this.snackBar.open('This example already exists for this locale', 'Close', { duration: 3000 });
204
+ }
205
+ }
206
+ }
207
+
208
+ removeExample(example: LocalizedExample) {
209
+ const index = this.examples.findIndex(
210
+ ex => ex.locale_code === example.locale_code && ex.example === example.example
211
+ );
212
+ if (index !== -1) {
213
+ this.examples.splice(index, 1);
214
+ }
215
+ }
216
+
217
+ // Test regex functionality
218
+ testRegex(paramIndex: number) {
219
+ const param = this.parameters.at(paramIndex);
220
+ const regex = param.get('validation_regex')?.value;
221
+
222
+ if (!regex) {
223
+ this.snackBar.open('No regex pattern to test', 'Close', { duration: 2000 });
224
+ return;
225
+ }
226
+
227
+ // Simple test implementation
228
+ const testValue = prompt('Enter a test value:');
229
+ if (testValue !== null) {
230
+ try {
231
+ const pattern = new RegExp(regex);
232
+ const matches = pattern.test(testValue);
233
+ this.snackBar.open(
234
+ matches ? '✓ Pattern matches!' : '✗ Pattern does not match',
235
+ 'Close',
236
+ { duration: 3000 }
237
+ );
238
+ } catch (e) {
239
+ this.snackBar.open('Invalid regex pattern', 'Close', { duration: 3000 });
240
+ }
241
+ }
242
+ }
243
+
244
+ // Move parameter up or down
245
+ moveParameter(index: number, direction: 'up' | 'down') {
246
+ const newIndex = direction === 'up' ? index - 1 : index + 1;
247
+
248
+ if (newIndex < 0 || newIndex >= this.parameters.length) {
249
+ return;
250
+ }
251
+
252
+ const currentItem = this.parameters.at(index);
253
+ this.parameters.removeAt(index);
254
+ this.parameters.insert(newIndex, currentItem);
255
+ }
256
+
257
+ // Parameter caption management
258
+ getCaptionDisplay(captions: LocalizedCaption[]): string {
259
+ if (!captions || captions.length === 0) return '(No caption)';
260
+
261
+ // Try to find caption for default locale
262
+ const defaultCaption = captions.find(c => c.locale_code === (this.data.project?.default_locale || this.data.defaultLocale || 'tr'));
263
+ if (defaultCaption) return defaultCaption.caption;
264
+
265
+ // Return first available caption
266
+ return captions[0].caption;
267
+ }
268
+
269
+ async openCaptionDialog(paramIndex: number) {
270
+ const param = this.parameters.at(paramIndex);
271
+ const currentCaptions = param.get('caption')?.value || [];
272
+
273
+ // Import and open caption dialog
274
+ const { default: CaptionDialogComponent } = await import('../caption-dialog/caption-dialog.component');
275
+
276
+ const dialogRef = this.dialog.open(CaptionDialogComponent, {
277
+ width: '600px',
278
+ data: {
279
+ captions: [...currentCaptions],
280
+ supportedLocales: this.supportedLocales,
281
+ defaultLocale: this.data.project?.default_locale || this.data.defaultLocale
282
+ }
283
+ });
284
+
285
+ dialogRef.afterClosed().subscribe(result => {
286
+ if (result) {
287
+ param.patchValue({ caption: result });
288
+ }
289
+ });
290
+ }
291
+
292
+ // Locale helpers
293
+ getLocaleName(localeCode: string): string {
294
+ const localeNames: { [key: string]: string } = {
295
+ 'tr': 'Türkçe',
296
+ 'en': 'English',
297
+ 'de': 'Deutsch',
298
+ 'fr': 'Français',
299
+ 'es': 'Español',
300
+ 'ar': 'العربية',
301
+ 'ru': 'Русский',
302
+ 'zh': '中文',
303
+ 'ja': '日本語',
304
+ 'ko': '한국어'
305
+ };
306
+ return localeNames[localeCode] || localeCode;
307
+ }
308
+
309
+ onSubmit() {
310
+ if (this.form.valid) {
311
+ const formValue = this.form.value;
312
+
313
+ // Add examples to the result
314
+ formValue.examples = this.examples;
315
+
316
+ // Ensure all parameters have captions
317
+ formValue.parameters = formValue.parameters.map((param: any) => {
318
+ if (!param.caption || param.caption.length === 0) {
319
+ // Create default caption if missing
320
+ param.caption = [{
321
+ locale_code: this.data.project?.default_locale || this.data.defaultLocale || 'tr',
322
+ caption: param.name
323
+ }];
324
+ }
325
+ return param;
326
+ });
327
+
328
+ this.dialogRef.close(formValue);
329
+ } else {
330
+ this.snackBar.open('Please fill all required fields', 'Close', { duration: 3000 });
331
+ }
332
+ }
333
+
334
+ save() {
335
+ this.onSubmit();
336
+ }
337
+
338
+ cancel() {
339
+ this.dialogRef.close();
340
+ }
341
  }
flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss CHANGED
@@ -1,92 +1,92 @@
1
- mat-dialog-content {
2
- padding: 20px 24px;
3
- min-width: 500px;
4
- max-width: 600px;
5
- }
6
-
7
- .full-width {
8
- width: 100%;
9
- margin-bottom: 16px;
10
- }
11
-
12
- .test-users-section {
13
- margin-top: 24px;
14
-
15
- h4 {
16
- margin-bottom: 16px;
17
- color: rgba(0, 0, 0, 0.87);
18
- }
19
-
20
- .test-user-row {
21
- display: flex;
22
- gap: 8px;
23
- align-items: flex-start;
24
- margin-bottom: 8px;
25
-
26
- .flex-1 {
27
- flex: 1;
28
- }
29
-
30
- button {
31
- margin-top: 8px;
32
- }
33
- }
34
- }
35
-
36
- mat-dialog-actions {
37
- padding: 16px 24px;
38
- margin: 0;
39
- border-top: 1px solid #e0e0e0;
40
- }
41
-
42
- // Locale specific styles
43
- .locale-code {
44
- color: #666;
45
- font-size: 0.85em;
46
- margin-left: 8px;
47
- font-family: 'Courier New', monospace;
48
- }
49
-
50
- .selected-languages {
51
- display: flex;
52
- flex-wrap: wrap;
53
- gap: 4px;
54
- align-items: center;
55
-
56
- span {
57
- white-space: nowrap;
58
- }
59
- }
60
-
61
- mat-option {
62
- &:hover .locale-code {
63
- color: #333;
64
- }
65
- }
66
-
67
- // Multi-select için özel stil
68
- .mat-mdc-select-trigger {
69
- min-height: 56px;
70
- display: flex;
71
- align-items: center;
72
- padding: 0 16px;
73
- }
74
-
75
- // Loading spinner in select
76
- mat-spinner {
77
- margin: 0 auto;
78
- }
79
-
80
- // Material form field density
81
- ::ng-deep {
82
- .mat-mdc-form-field {
83
- margin-bottom: 4px;
84
- }
85
-
86
- .mat-mdc-option {
87
- .mat-icon {
88
- margin-right: 8px;
89
- vertical-align: middle;
90
- }
91
- }
92
  }
 
1
+ mat-dialog-content {
2
+ padding: 20px 24px;
3
+ min-width: 500px;
4
+ max-width: 600px;
5
+ }
6
+
7
+ .full-width {
8
+ width: 100%;
9
+ margin-bottom: 16px;
10
+ }
11
+
12
+ .test-users-section {
13
+ margin-top: 24px;
14
+
15
+ h4 {
16
+ margin-bottom: 16px;
17
+ color: rgba(0, 0, 0, 0.87);
18
+ }
19
+
20
+ .test-user-row {
21
+ display: flex;
22
+ gap: 8px;
23
+ align-items: flex-start;
24
+ margin-bottom: 8px;
25
+
26
+ .flex-1 {
27
+ flex: 1;
28
+ }
29
+
30
+ button {
31
+ margin-top: 8px;
32
+ }
33
+ }
34
+ }
35
+
36
+ mat-dialog-actions {
37
+ padding: 16px 24px;
38
+ margin: 0;
39
+ border-top: 1px solid #e0e0e0;
40
+ }
41
+
42
+ // Locale specific styles
43
+ .locale-code {
44
+ color: #666;
45
+ font-size: 0.85em;
46
+ margin-left: 8px;
47
+ font-family: 'Courier New', monospace;
48
+ }
49
+
50
+ .selected-languages {
51
+ display: flex;
52
+ flex-wrap: wrap;
53
+ gap: 4px;
54
+ align-items: center;
55
+
56
+ span {
57
+ white-space: nowrap;
58
+ }
59
+ }
60
+
61
+ mat-option {
62
+ &:hover .locale-code {
63
+ color: #333;
64
+ }
65
+ }
66
+
67
+ // Multi-select için özel stil
68
+ .mat-mdc-select-trigger {
69
+ min-height: 56px;
70
+ display: flex;
71
+ align-items: center;
72
+ padding: 0 16px;
73
+ }
74
+
75
+ // Loading spinner in select
76
+ mat-spinner {
77
+ margin: 0 auto;
78
+ }
79
+
80
+ // Material form field density
81
+ ::ng-deep {
82
+ .mat-mdc-form-field {
83
+ margin-bottom: 4px;
84
+ }
85
+
86
+ .mat-mdc-option {
87
+ .mat-icon {
88
+ margin-right: 8px;
89
+ vertical-align: middle;
90
+ }
91
+ }
92
  }
flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts CHANGED
@@ -1,486 +1,486 @@
1
- // project-edit-dialog.component.ts
2
- import { Component, Inject, OnInit, OnDestroy } from '@angular/core';
3
- import { CommonModule } from '@angular/common';
4
- import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
5
- import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
6
- import { MatFormFieldModule } from '@angular/material/form-field';
7
- import { MatInputModule } from '@angular/material/input';
8
- import { MatSelectModule } from '@angular/material/select';
9
- import { MatCheckboxModule } from '@angular/material/checkbox';
10
- import { MatButtonModule } from '@angular/material/button';
11
- import { MatIconModule } from '@angular/material/icon';
12
- import { MatChipsModule } from '@angular/material/chips';
13
- import { MatDividerModule } from '@angular/material/divider';
14
- import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
15
- import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
16
- import { ApiService } from '../../services/api.service';
17
- import { LocaleManagerService, Locale } from '../../services/locale-manager.service';
18
- import { Subject, takeUntil } from 'rxjs';
19
- import { HttpErrorResponse } from '@angular/common/http';
20
-
21
- export interface ProjectDialogData {
22
- mode: 'create' | 'edit';
23
- project?: any;
24
- }
25
-
26
- @Component({
27
- selector: 'app-project-edit-dialog',
28
- standalone: true,
29
- imports: [
30
- CommonModule,
31
- ReactiveFormsModule,
32
- MatDialogModule,
33
- MatFormFieldModule,
34
- MatInputModule,
35
- MatSelectModule,
36
- MatCheckboxModule,
37
- MatButtonModule,
38
- MatIconModule,
39
- MatChipsModule,
40
- MatDividerModule,
41
- MatSnackBarModule,
42
- MatProgressSpinnerModule
43
- ],
44
- template: `
45
- <h2 mat-dialog-title>{{ data.mode === 'create' ? 'Create New Project' : 'Edit Project' }}</h2>
46
-
47
- <mat-dialog-content>
48
- <form [formGroup]="form">
49
- <mat-form-field appearance="outline" class="full-width">
50
- <mat-label>Name*</mat-label>
51
- <input matInput formControlName="name"
52
- [readonly]="data.mode === 'edit'"
53
- placeholder="e.g., airline_agent">
54
- <mat-hint>Use only letters, numbers, and underscores</mat-hint>
55
- <mat-error>{{ getErrorMessage('name') }}</mat-error>
56
- </mat-form-field>
57
-
58
- <mat-form-field appearance="outline" class="full-width">
59
- <mat-label>Caption*</mat-label>
60
- <input matInput formControlName="caption"
61
- placeholder="e.g., Airline Customer Service Agent">
62
- <mat-error>{{ getErrorMessage('caption') }}</mat-error>
63
- </mat-form-field>
64
-
65
- <mat-form-field appearance="outline" class="full-width">
66
- <mat-label>Icon</mat-label>
67
- <mat-select formControlName="icon">
68
- @for (icon of projectIcons; track icon) {
69
- <mat-option [value]="icon">
70
- <mat-icon>{{ icon }}</mat-icon>
71
- {{ icon }}
72
- </mat-option>
73
- }
74
- </mat-select>
75
- </mat-form-field>
76
-
77
- <mat-form-field appearance="outline" class="full-width">
78
- <mat-label>Description</mat-label>
79
- <textarea matInput formControlName="description" rows="3"></textarea>
80
- </mat-form-field>
81
-
82
- <!-- Default Locale -->
83
- <mat-form-field appearance="outline" class="full-width">
84
- <mat-label>Default Locale</mat-label>
85
- <mat-select
86
- formControlName="defaultLocale"
87
- (selectionChange)="onDefaultLocaleChange()">
88
- @if (loadingLocales) {
89
- <mat-option disabled>
90
- <mat-spinner diameter="20"></mat-spinner>
91
- Loading Locales...
92
- </mat-option>
93
- }
94
- @for (locale of availableLocales; track locale.code) {
95
- <mat-option [value]="locale.code"> <!-- locale.name yerine locale.code -->
96
- {{ locale.name }}
97
- <span class="locale-code">{{ locale.code }}</span>
98
- </mat-option>
99
- }
100
- </mat-select>
101
- <mat-icon matPrefix>translate</mat-icon>
102
- <mat-hint>Primary Locale for this project</mat-hint>
103
- </mat-form-field>
104
-
105
- <!-- Supported Locales -->
106
- <mat-form-field appearance="outline" class="full-width">
107
- <mat-label>Supported Locales</mat-label>
108
- <mat-select
109
- formControlName="supportedLocales"
110
- (selectionChange)="onSupportedLocalesChange()"
111
- multiple>
112
- <mat-select-trigger>
113
- <div class="selected-locales">
114
- @for (lang of form.get('supportedLocales')?.value || []; track lang; let last = $last) {
115
- <span>{{ getLocaleName(lang) }}@if (!last) {, }</span>
116
- }
117
- </div>
118
- </mat-select-trigger>
119
- @for (locale of availableLocales; track locale.code) {
120
- <mat-option [value]="locale.code">
121
- {{ locale.name }}
122
- <span class="locale-code">{{ locale.code }}</span>
123
- </mat-option>
124
- }
125
- </mat-select>
126
- <mat-icon matPrefix>locale</mat-icon>
127
- <mat-hint>Locales available in this project</mat-hint>
128
- </mat-form-field>
129
-
130
- <mat-form-field appearance="outline" class="full-width">
131
- <mat-label>Timezone</mat-label>
132
- <mat-select formControlName="timezone">
133
- @for (tz of timezones; track tz) {
134
- <mat-option [value]="tz">{{ tz }}</mat-option>
135
- }
136
- </mat-select>
137
- </mat-form-field>
138
-
139
- <mat-form-field appearance="outline" class="full-width">
140
- <mat-label>Region</mat-label>
141
- <input matInput formControlName="region" placeholder="e.g., tr-TR">
142
- </mat-form-field>
143
- </form>
144
- </mat-dialog-content>
145
-
146
- <mat-dialog-actions align="end">
147
- <button mat-button (click)="close()">Cancel</button>
148
- <button mat-raised-button color="primary"
149
- (click)="save()"
150
- [disabled]="form.invalid || saving">
151
- {{ saving ? 'Saving...' : (data.mode === 'create' ? 'Create' : 'Save') }}
152
- </button>
153
- </mat-dialog-actions>
154
- `,
155
- styleUrls: ['./project-edit-dialog.component.scss']
156
- })
157
- export default class ProjectEditDialogComponent implements OnInit, OnDestroy {
158
- form!: FormGroup;
159
- saving = false;
160
- loadingLocales = true;
161
- availableLocales: Locale[] = [];
162
-
163
- // Memory leak prevention
164
- private destroyed$ = new Subject<void>();
165
-
166
- projectIcons = ['folder', 'work', 'shopping_cart', 'school', 'local_hospital', 'restaurant', 'home', 'business'];
167
-
168
- timezones = [
169
- 'Europe/Istanbul',
170
- 'Europe/London',
171
- 'Europe/Berlin',
172
- 'America/New_York',
173
- 'America/Los_Angeles',
174
- 'Asia/Tokyo'
175
- ];
176
-
177
- constructor(
178
- private fb: FormBuilder,
179
- private apiService: ApiService,
180
- private localeManager: LocaleManagerService,
181
- private snackBar: MatSnackBar,
182
- public dialogRef: MatDialogRef<ProjectEditDialogComponent>,
183
- @Inject(MAT_DIALOG_DATA) public data: ProjectDialogData
184
- ) {}
185
-
186
- ngOnInit() {
187
- this.initializeForm();
188
- this.loadAvailableLocales();
189
- }
190
-
191
- ngOnDestroy() {
192
- this.destroyed$.next();
193
- this.destroyed$.complete();
194
- }
195
-
196
- initializeForm() {
197
- const defaultValues = this.data.mode === 'edit' && this.data.project ? {
198
- name: this.data.project.name,
199
- caption: this.data.project.caption || '',
200
- icon: this.data.project.icon || 'folder',
201
- description: this.data.project.description || '',
202
- defaultLocale: this.data.project.default_locale || 'tr',
203
- supportedLocales: this.data.project.supported_locales || ['tr'], // Düzeltildi: supportedLolcales -> supportedLocales
204
- timezone: this.data.project.timezone || 'Europe/Istanbul',
205
- region: this.data.project.region || 'tr-TR'
206
- } : {
207
- name: '',
208
- caption: '',
209
- icon: 'folder',
210
- description: '',
211
- defaultLocale: 'tr',
212
- supportedLocales: ['tr'],
213
- timezone: 'Europe/Istanbul',
214
- region: 'tr-TR'
215
- };
216
-
217
- this.form = this.fb.group({
218
- name: [defaultValues.name, [Validators.required, Validators.pattern(/^[a-z0-9_]+$/)]],
219
- caption: [defaultValues.caption, Validators.required],
220
- icon: [defaultValues.icon],
221
- description: [defaultValues.description],
222
- defaultLocale: [defaultValues.defaultLocale],
223
- supportedLocales: [defaultValues.supportedLocales],
224
- timezone: [defaultValues.timezone],
225
- region: [defaultValues.region]
226
- });
227
-
228
- // Disable name field in edit mode
229
- if (this.data.mode === 'edit') {
230
- this.form.get('name')?.disable();
231
- }
232
- }
233
-
234
- loadAvailableLocales() {
235
- this.loadingLocales = true;
236
- this.localeManager.getAvailableLocales()
237
- .pipe(takeUntil(this.destroyed$))
238
- .subscribe({
239
- next: (locales) => {
240
- this.availableLocales = locales;
241
- this.loadingLocales = false;
242
- this.validateSelectedLocales();
243
- },
244
- error: (err) => {
245
- this.showMessage('Failed to load available locales', 'error');
246
- this.loadingLocales = false;
247
- // Use fallback locales
248
- this.availableLocales = [
249
- { code: 'tr', name: 'Türkçe', english_name: 'Turkish' },
250
- { code: 'en', name: 'English', english_name: 'English' }
251
- ];
252
- }
253
- });
254
- }
255
-
256
- validateSelectedLocales() {
257
- const availableCodes = this.availableLocales.map(l => l.code);
258
- const currentSupported = this.form.get('supportedLocales')?.value || [];
259
- const currentDefault = this.form.get('defaultLocale')?.value;
260
-
261
- // Filter out any unsupported Locales
262
- const validSupported = currentSupported.filter((lang: string) =>
263
- availableCodes.includes(lang)
264
- );
265
-
266
- // Update form if any Locales were removed
267
- if (validSupported.length !== currentSupported.length) {
268
- this.form.patchValue({ supportedLocales: validSupported });
269
- }
270
-
271
- // Ensure default Locale is valid
272
- if (!availableCodes.includes(currentDefault)) {
273
- const newDefault = availableCodes[0] || 'tr-TR';
274
- this.form.patchValue({
275
- defaultLocale: newDefault,
276
- supportedLocales: [...validSupported, newDefault]
277
- });
278
- }
279
- }
280
-
281
- onDefaultLocaleChange() {
282
- // Default Locale değiştiğinde bir şey yapmaya gerek yok
283
- // Çünkü default_locale (Türkçe) ve supported_locales (tr-TR) farklı tipte
284
- }
285
-
286
- onSupportedLocalesChange() {
287
- // Supported locales değiştiğinde de bir şey yapmaya gerek yok
288
- // En az bir dil seçili olduğu sürece sorun yok
289
- const supportedLocales = this.form.get('supportedLocales')?.value || [];
290
- if (supportedLocales.length === 0) {
291
- // En az bir dil seçilmeli
292
- this.form.patchValue({
293
- supportedLocales: ['tr-TR']
294
- });
295
- }
296
- }
297
-
298
- getLocaleName(code: string): string {
299
- // Önce availableLocales'da ara
300
- const locale = this.availableLocales.find(l => l.code === code);
301
- if (locale) {
302
- return locale.name;
303
- }
304
-
305
- // Bulamazsan fallback locale isimleri kullan
306
- const localeNames: { [key: string]: string } = {
307
- 'tr': 'Türkçe',
308
- 'tr-TR': 'Türkçe',
309
- 'en': 'English',
310
- 'en-US': 'English',
311
- 'en-GB': 'English (UK)',
312
- 'de': 'Deutsch',
313
- 'de-DE': 'Deutsch',
314
- 'fr': 'Français',
315
- 'fr-FR': 'Français',
316
- 'es': 'Español',
317
- 'es-ES': 'Español',
318
- 'ar': 'العربية',
319
- 'ar-SA': 'العربية',
320
- 'ru': 'Русский',
321
- 'ru-RU': 'Русский',
322
- 'zh': '中文',
323
- 'zh-CN': '中文',
324
- 'ja': '日本語',
325
- 'ja-JP': '日本語',
326
- 'ko': '한국어',
327
- 'ko-KR': '한국어'
328
- };
329
-
330
- return localeNames[code] || code;
331
- }
332
-
333
- getErrorMessage(fieldName: string): string {
334
- const control = this.form.get(fieldName);
335
- if (!control) return '';
336
-
337
- if (control.hasError('required')) {
338
- return `${this.getFieldLabel(fieldName)} is required`;
339
- }
340
- if (control.hasError('pattern')) {
341
- return `${this.getFieldLabel(fieldName)} contains invalid characters`;
342
- }
343
- if (control.hasError('server')) {
344
- return control.errors?.['server'];
345
- }
346
- return '';
347
- }
348
-
349
- private getFieldLabel(fieldName: string): string {
350
- const labels: { [key: string]: string } = {
351
- 'name': 'Project Name',
352
- 'caption': 'Caption',
353
- 'description': 'Description',
354
- 'defaultLocale': 'Default Locale',
355
- 'supportedLocales': 'Supported Locales',
356
- 'timezone': 'Timezone',
357
- 'region': 'Region',
358
- 'icon': 'Icon'
359
- };
360
- return labels[fieldName] || fieldName;
361
- }
362
-
363
- handleValidationError(error: HttpErrorResponse): void {
364
- if (error.status === 422 && error.error?.details) {
365
- // Show specific field errors
366
- error.error.details.forEach((detail: any) => {
367
- const control = this.form.get(detail.field);
368
- if (control) {
369
- control.setErrors({ server: detail.message });
370
- control.markAsTouched();
371
- }
372
- });
373
-
374
- this.snackBar.open(
375
- 'Please fix the validation errors',
376
- 'Close',
377
- {
378
- duration: 5000,
379
- panelClass: ['error-snackbar']
380
- }
381
- );
382
- } else {
383
- // Generic error handling
384
- this.showMessage(
385
- error.error?.detail || error.message || 'Operation failed',
386
- 'error'
387
- );
388
- }
389
- }
390
-
391
- save() {
392
- console.log('Save clicked - Form valid:', this.form.valid, 'Saving:', this.saving);
393
- console.log('Form errors:', this.form.errors);
394
- console.log('Form value:', this.form.value);
395
-
396
- if (this.form.invalid || this.saving) {
397
- // Mark all fields as touched to show validation errors
398
- Object.keys(this.form.controls).forEach(key => {
399
- const control = this.form.get(key);
400
- if (control) {
401
- control.markAsTouched();
402
- if (control.errors) {
403
- console.log(`Field ${key} errors:`, control.errors);
404
- }
405
- }
406
- });
407
-
408
- if (this.form.invalid) {
409
- this.showMessage('Please fill all required fields correctly', 'error');
410
- }
411
- return;
412
- }
413
-
414
- this.saving = true;
415
-
416
- const formValue = this.form.getRawValue(); // getRawValue to include disabled fields
417
-
418
- // Project data format matching backend expectations
419
- const projectData = {
420
- name: formValue.name,
421
- caption: formValue.caption,
422
- icon: formValue.icon,
423
- description: formValue.description,
424
- default_locale: formValue.defaultLocale,
425
- supported_locales: formValue.supportedLocales,
426
- timezone: formValue.timezone,
427
- region: formValue.region
428
- };
429
-
430
- const saveOperation = this.data.mode === 'create'
431
- ? this.apiService.createProject(projectData)
432
- : this.apiService.updateProject(this.data.project.id, {
433
- ...projectData,
434
- last_update_date: this.data.project.last_update_date || ''
435
- });
436
-
437
- saveOperation
438
- .pipe(takeUntil(this.destroyed$))
439
- .subscribe({
440
- next: (result) => {
441
- this.saving = false;
442
- this.showMessage(
443
- this.data.mode === 'create'
444
- ? 'Project created successfully!'
445
- : 'Project updated successfully!'
446
- );
447
- this.dialogRef.close(result);
448
- },
449
- error: (error: HttpErrorResponse) => {
450
- this.saving = false;
451
-
452
- // Race condition handling
453
- if (error.status === 409) {
454
- const details = error.error?.details || {};
455
- this.snackBar.open(
456
- `Project was modified by ${details.last_update_user || 'another user'}. Please reload.`,
457
- 'Reload',
458
- { duration: 0 }
459
- ).onAction().subscribe(() => {
460
- this.dialogRef.close('reload');
461
- });
462
- } else if (error.status === 422) {
463
- this.handleValidationError(error);
464
- } else {
465
- this.showMessage(
466
- error.error?.detail || 'Operation failed',
467
- 'error'
468
- );
469
- }
470
- }
471
- });
472
- }
473
-
474
- close() {
475
- this.dialogRef.close();
476
- }
477
-
478
- private showMessage(message: string, type: 'success' | 'error' = 'success') {
479
- this.snackBar.open(message, 'Close', {
480
- duration: 5000,
481
- panelClass: type === 'error' ? ['error-snackbar'] : ['success-snackbar'],
482
- horizontalPosition: 'right',
483
- verticalPosition: 'top'
484
- });
485
- }
486
  }
 
1
+ // project-edit-dialog.component.ts
2
+ import { Component, Inject, OnInit, OnDestroy } from '@angular/core';
3
+ import { CommonModule } from '@angular/common';
4
+ import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, FormArray } from '@angular/forms';
5
+ import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
6
+ import { MatFormFieldModule } from '@angular/material/form-field';
7
+ import { MatInputModule } from '@angular/material/input';
8
+ import { MatSelectModule } from '@angular/material/select';
9
+ import { MatCheckboxModule } from '@angular/material/checkbox';
10
+ import { MatButtonModule } from '@angular/material/button';
11
+ import { MatIconModule } from '@angular/material/icon';
12
+ import { MatChipsModule } from '@angular/material/chips';
13
+ import { MatDividerModule } from '@angular/material/divider';
14
+ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
15
+ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
16
+ import { ApiService } from '../../services/api.service';
17
+ import { LocaleManagerService, Locale } from '../../services/locale-manager.service';
18
+ import { Subject, takeUntil } from 'rxjs';
19
+ import { HttpErrorResponse } from '@angular/common/http';
20
+
21
+ export interface ProjectDialogData {
22
+ mode: 'create' | 'edit';
23
+ project?: any;
24
+ }
25
+
26
+ @Component({
27
+ selector: 'app-project-edit-dialog',
28
+ standalone: true,
29
+ imports: [
30
+ CommonModule,
31
+ ReactiveFormsModule,
32
+ MatDialogModule,
33
+ MatFormFieldModule,
34
+ MatInputModule,
35
+ MatSelectModule,
36
+ MatCheckboxModule,
37
+ MatButtonModule,
38
+ MatIconModule,
39
+ MatChipsModule,
40
+ MatDividerModule,
41
+ MatSnackBarModule,
42
+ MatProgressSpinnerModule
43
+ ],
44
+ template: `
45
+ <h2 mat-dialog-title>{{ data.mode === 'create' ? 'Create New Project' : 'Edit Project' }}</h2>
46
+
47
+ <mat-dialog-content>
48
+ <form [formGroup]="form">
49
+ <mat-form-field appearance="outline" class="full-width">
50
+ <mat-label>Name*</mat-label>
51
+ <input matInput formControlName="name"
52
+ [readonly]="data.mode === 'edit'"
53
+ placeholder="e.g., airline_agent">
54
+ <mat-hint>Use only letters, numbers, and underscores</mat-hint>
55
+ <mat-error>{{ getErrorMessage('name') }}</mat-error>
56
+ </mat-form-field>
57
+
58
+ <mat-form-field appearance="outline" class="full-width">
59
+ <mat-label>Caption*</mat-label>
60
+ <input matInput formControlName="caption"
61
+ placeholder="e.g., Airline Customer Service Agent">
62
+ <mat-error>{{ getErrorMessage('caption') }}</mat-error>
63
+ </mat-form-field>
64
+
65
+ <mat-form-field appearance="outline" class="full-width">
66
+ <mat-label>Icon</mat-label>
67
+ <mat-select formControlName="icon">
68
+ @for (icon of projectIcons; track icon) {
69
+ <mat-option [value]="icon">
70
+ <mat-icon>{{ icon }}</mat-icon>
71
+ {{ icon }}
72
+ </mat-option>
73
+ }
74
+ </mat-select>
75
+ </mat-form-field>
76
+
77
+ <mat-form-field appearance="outline" class="full-width">
78
+ <mat-label>Description</mat-label>
79
+ <textarea matInput formControlName="description" rows="3"></textarea>
80
+ </mat-form-field>
81
+
82
+ <!-- Default Locale -->
83
+ <mat-form-field appearance="outline" class="full-width">
84
+ <mat-label>Default Locale</mat-label>
85
+ <mat-select
86
+ formControlName="defaultLocale"
87
+ (selectionChange)="onDefaultLocaleChange()">
88
+ @if (loadingLocales) {
89
+ <mat-option disabled>
90
+ <mat-spinner diameter="20"></mat-spinner>
91
+ Loading Locales...
92
+ </mat-option>
93
+ }
94
+ @for (locale of availableLocales; track locale.code) {
95
+ <mat-option [value]="locale.code"> <!-- locale.name yerine locale.code -->
96
+ {{ locale.name }}
97
+ <span class="locale-code">{{ locale.code }}</span>
98
+ </mat-option>
99
+ }
100
+ </mat-select>
101
+ <mat-icon matPrefix>translate</mat-icon>
102
+ <mat-hint>Primary Locale for this project</mat-hint>
103
+ </mat-form-field>
104
+
105
+ <!-- Supported Locales -->
106
+ <mat-form-field appearance="outline" class="full-width">
107
+ <mat-label>Supported Locales</mat-label>
108
+ <mat-select
109
+ formControlName="supportedLocales"
110
+ (selectionChange)="onSupportedLocalesChange()"
111
+ multiple>
112
+ <mat-select-trigger>
113
+ <div class="selected-locales">
114
+ @for (lang of form.get('supportedLocales')?.value || []; track lang; let last = $last) {
115
+ <span>{{ getLocaleName(lang) }}@if (!last) {, }</span>
116
+ }
117
+ </div>
118
+ </mat-select-trigger>
119
+ @for (locale of availableLocales; track locale.code) {
120
+ <mat-option [value]="locale.code">
121
+ {{ locale.name }}
122
+ <span class="locale-code">{{ locale.code }}</span>
123
+ </mat-option>
124
+ }
125
+ </mat-select>
126
+ <mat-icon matPrefix>locale</mat-icon>
127
+ <mat-hint>Locales available in this project</mat-hint>
128
+ </mat-form-field>
129
+
130
+ <mat-form-field appearance="outline" class="full-width">
131
+ <mat-label>Timezone</mat-label>
132
+ <mat-select formControlName="timezone">
133
+ @for (tz of timezones; track tz) {
134
+ <mat-option [value]="tz">{{ tz }}</mat-option>
135
+ }
136
+ </mat-select>
137
+ </mat-form-field>
138
+
139
+ <mat-form-field appearance="outline" class="full-width">
140
+ <mat-label>Region</mat-label>
141
+ <input matInput formControlName="region" placeholder="e.g., tr-TR">
142
+ </mat-form-field>
143
+ </form>
144
+ </mat-dialog-content>
145
+
146
+ <mat-dialog-actions align="end">
147
+ <button mat-button (click)="close()">Cancel</button>
148
+ <button mat-raised-button color="primary"
149
+ (click)="save()"
150
+ [disabled]="form.invalid || saving">
151
+ {{ saving ? 'Saving...' : (data.mode === 'create' ? 'Create' : 'Save') }}
152
+ </button>
153
+ </mat-dialog-actions>
154
+ `,
155
+ styleUrls: ['./project-edit-dialog.component.scss']
156
+ })
157
+ export default class ProjectEditDialogComponent implements OnInit, OnDestroy {
158
+ form!: FormGroup;
159
+ saving = false;
160
+ loadingLocales = true;
161
+ availableLocales: Locale[] = [];
162
+
163
+ // Memory leak prevention
164
+ private destroyed$ = new Subject<void>();
165
+
166
+ projectIcons = ['folder', 'work', 'shopping_cart', 'school', 'local_hospital', 'restaurant', 'home', 'business'];
167
+
168
+ timezones = [
169
+ 'Europe/Istanbul',
170
+ 'Europe/London',
171
+ 'Europe/Berlin',
172
+ 'America/New_York',
173
+ 'America/Los_Angeles',
174
+ 'Asia/Tokyo'
175
+ ];
176
+
177
+ constructor(
178
+ private fb: FormBuilder,
179
+ private apiService: ApiService,
180
+ private localeManager: LocaleManagerService,
181
+ private snackBar: MatSnackBar,
182
+ public dialogRef: MatDialogRef<ProjectEditDialogComponent>,
183
+ @Inject(MAT_DIALOG_DATA) public data: ProjectDialogData
184
+ ) {}
185
+
186
+ ngOnInit() {
187
+ this.initializeForm();
188
+ this.loadAvailableLocales();
189
+ }
190
+
191
+ ngOnDestroy() {
192
+ this.destroyed$.next();
193
+ this.destroyed$.complete();
194
+ }
195
+
196
+ initializeForm() {
197
+ const defaultValues = this.data.mode === 'edit' && this.data.project ? {
198
+ name: this.data.project.name,
199
+ caption: this.data.project.caption || '',
200
+ icon: this.data.project.icon || 'folder',
201
+ description: this.data.project.description || '',
202
+ defaultLocale: this.data.project.default_locale || 'tr',
203
+ supportedLocales: this.data.project.supported_locales || ['tr'], // Düzeltildi: supportedLolcales -> supportedLocales
204
+ timezone: this.data.project.timezone || 'Europe/Istanbul',
205
+ region: this.data.project.region || 'tr-TR'
206
+ } : {
207
+ name: '',
208
+ caption: '',
209
+ icon: 'folder',
210
+ description: '',
211
+ defaultLocale: 'tr',
212
+ supportedLocales: ['tr'],
213
+ timezone: 'Europe/Istanbul',
214
+ region: 'tr-TR'
215
+ };
216
+
217
+ this.form = this.fb.group({
218
+ name: [defaultValues.name, [Validators.required, Validators.pattern(/^[a-z0-9_]+$/)]],
219
+ caption: [defaultValues.caption, Validators.required],
220
+ icon: [defaultValues.icon],
221
+ description: [defaultValues.description],
222
+ defaultLocale: [defaultValues.defaultLocale],
223
+ supportedLocales: [defaultValues.supportedLocales],
224
+ timezone: [defaultValues.timezone],
225
+ region: [defaultValues.region]
226
+ });
227
+
228
+ // Disable name field in edit mode
229
+ if (this.data.mode === 'edit') {
230
+ this.form.get('name')?.disable();
231
+ }
232
+ }
233
+
234
+ loadAvailableLocales() {
235
+ this.loadingLocales = true;
236
+ this.localeManager.getAvailableLocales()
237
+ .pipe(takeUntil(this.destroyed$))
238
+ .subscribe({
239
+ next: (locales) => {
240
+ this.availableLocales = locales;
241
+ this.loadingLocales = false;
242
+ this.validateSelectedLocales();
243
+ },
244
+ error: (err) => {
245
+ this.showMessage('Failed to load available locales', 'error');
246
+ this.loadingLocales = false;
247
+ // Use fallback locales
248
+ this.availableLocales = [
249
+ { code: 'tr', name: 'Türkçe', english_name: 'Turkish' },
250
+ { code: 'en', name: 'English', english_name: 'English' }
251
+ ];
252
+ }
253
+ });
254
+ }
255
+
256
+ validateSelectedLocales() {
257
+ const availableCodes = this.availableLocales.map(l => l.code);
258
+ const currentSupported = this.form.get('supportedLocales')?.value || [];
259
+ const currentDefault = this.form.get('defaultLocale')?.value;
260
+
261
+ // Filter out any unsupported Locales
262
+ const validSupported = currentSupported.filter((lang: string) =>
263
+ availableCodes.includes(lang)
264
+ );
265
+
266
+ // Update form if any Locales were removed
267
+ if (validSupported.length !== currentSupported.length) {
268
+ this.form.patchValue({ supportedLocales: validSupported });
269
+ }
270
+
271
+ // Ensure default Locale is valid
272
+ if (!availableCodes.includes(currentDefault)) {
273
+ const newDefault = availableCodes[0] || 'tr-TR';
274
+ this.form.patchValue({
275
+ defaultLocale: newDefault,
276
+ supportedLocales: [...validSupported, newDefault]
277
+ });
278
+ }
279
+ }
280
+
281
+ onDefaultLocaleChange() {
282
+ // Default Locale değiştiğinde bir şey yapmaya gerek yok
283
+ // Çünkü default_locale (Türkçe) ve supported_locales (tr-TR) farklı tipte
284
+ }
285
+
286
+ onSupportedLocalesChange() {
287
+ // Supported locales değiştiğinde de bir şey yapmaya gerek yok
288
+ // En az bir dil seçili olduğu sürece sorun yok
289
+ const supportedLocales = this.form.get('supportedLocales')?.value || [];
290
+ if (supportedLocales.length === 0) {
291
+ // En az bir dil seçilmeli
292
+ this.form.patchValue({
293
+ supportedLocales: ['tr-TR']
294
+ });
295
+ }
296
+ }
297
+
298
+ getLocaleName(code: string): string {
299
+ // Önce availableLocales'da ara
300
+ const locale = this.availableLocales.find(l => l.code === code);
301
+ if (locale) {
302
+ return locale.name;
303
+ }
304
+
305
+ // Bulamazsan fallback locale isimleri kullan
306
+ const localeNames: { [key: string]: string } = {
307
+ 'tr': 'Türkçe',
308
+ 'tr-TR': 'Türkçe',
309
+ 'en': 'English',
310
+ 'en-US': 'English',
311
+ 'en-GB': 'English (UK)',
312
+ 'de': 'Deutsch',
313
+ 'de-DE': 'Deutsch',
314
+ 'fr': 'Français',
315
+ 'fr-FR': 'Français',
316
+ 'es': 'Español',
317
+ 'es-ES': 'Español',
318
+ 'ar': 'العربية',
319
+ 'ar-SA': 'العربية',
320
+ 'ru': 'Русский',
321
+ 'ru-RU': 'Русский',
322
+ 'zh': '中文',
323
+ 'zh-CN': '中文',
324
+ 'ja': '日本語',
325
+ 'ja-JP': '日本語',
326
+ 'ko': '한국어',
327
+ 'ko-KR': '한국어'
328
+ };
329
+
330
+ return localeNames[code] || code;
331
+ }
332
+
333
+ getErrorMessage(fieldName: string): string {
334
+ const control = this.form.get(fieldName);
335
+ if (!control) return '';
336
+
337
+ if (control.hasError('required')) {
338
+ return `${this.getFieldLabel(fieldName)} is required`;
339
+ }
340
+ if (control.hasError('pattern')) {
341
+ return `${this.getFieldLabel(fieldName)} contains invalid characters`;
342
+ }
343
+ if (control.hasError('server')) {
344
+ return control.errors?.['server'];
345
+ }
346
+ return '';
347
+ }
348
+
349
+ private getFieldLabel(fieldName: string): string {
350
+ const labels: { [key: string]: string } = {
351
+ 'name': 'Project Name',
352
+ 'caption': 'Caption',
353
+ 'description': 'Description',
354
+ 'defaultLocale': 'Default Locale',
355
+ 'supportedLocales': 'Supported Locales',
356
+ 'timezone': 'Timezone',
357
+ 'region': 'Region',
358
+ 'icon': 'Icon'
359
+ };
360
+ return labels[fieldName] || fieldName;
361
+ }
362
+
363
+ handleValidationError(error: HttpErrorResponse): void {
364
+ if (error.status === 422 && error.error?.details) {
365
+ // Show specific field errors
366
+ error.error.details.forEach((detail: any) => {
367
+ const control = this.form.get(detail.field);
368
+ if (control) {
369
+ control.setErrors({ server: detail.message });
370
+ control.markAsTouched();
371
+ }
372
+ });
373
+
374
+ this.snackBar.open(
375
+ 'Please fix the validation errors',
376
+ 'Close',
377
+ {
378
+ duration: 5000,
379
+ panelClass: ['error-snackbar']
380
+ }
381
+ );
382
+ } else {
383
+ // Generic error handling
384
+ this.showMessage(
385
+ error.error?.detail || error.message || 'Operation failed',
386
+ 'error'
387
+ );
388
+ }
389
+ }
390
+
391
+ save() {
392
+ console.log('Save clicked - Form valid:', this.form.valid, 'Saving:', this.saving);
393
+ console.log('Form errors:', this.form.errors);
394
+ console.log('Form value:', this.form.value);
395
+
396
+ if (this.form.invalid || this.saving) {
397
+ // Mark all fields as touched to show validation errors
398
+ Object.keys(this.form.controls).forEach(key => {
399
+ const control = this.form.get(key);
400
+ if (control) {
401
+ control.markAsTouched();
402
+ if (control.errors) {
403
+ console.log(`Field ${key} errors:`, control.errors);
404
+ }
405
+ }
406
+ });
407
+
408
+ if (this.form.invalid) {
409
+ this.showMessage('Please fill all required fields correctly', 'error');
410
+ }
411
+ return;
412
+ }
413
+
414
+ this.saving = true;
415
+
416
+ const formValue = this.form.getRawValue(); // getRawValue to include disabled fields
417
+
418
+ // Project data format matching backend expectations
419
+ const projectData = {
420
+ name: formValue.name,
421
+ caption: formValue.caption,
422
+ icon: formValue.icon,
423
+ description: formValue.description,
424
+ default_locale: formValue.defaultLocale,
425
+ supported_locales: formValue.supportedLocales,
426
+ timezone: formValue.timezone,
427
+ region: formValue.region
428
+ };
429
+
430
+ const saveOperation = this.data.mode === 'create'
431
+ ? this.apiService.createProject(projectData)
432
+ : this.apiService.updateProject(this.data.project.id, {
433
+ ...projectData,
434
+ last_update_date: this.data.project.last_update_date || ''
435
+ });
436
+
437
+ saveOperation
438
+ .pipe(takeUntil(this.destroyed$))
439
+ .subscribe({
440
+ next: (result) => {
441
+ this.saving = false;
442
+ this.showMessage(
443
+ this.data.mode === 'create'
444
+ ? 'Project created successfully!'
445
+ : 'Project updated successfully!'
446
+ );
447
+ this.dialogRef.close(result);
448
+ },
449
+ error: (error: HttpErrorResponse) => {
450
+ this.saving = false;
451
+
452
+ // Race condition handling
453
+ if (error.status === 409) {
454
+ const details = error.error?.details || {};
455
+ this.snackBar.open(
456
+ `Project was modified by ${details.last_update_user || 'another user'}. Please reload.`,
457
+ 'Reload',
458
+ { duration: 0 }
459
+ ).onAction().subscribe(() => {
460
+ this.dialogRef.close('reload');
461
+ });
462
+ } else if (error.status === 422) {
463
+ this.handleValidationError(error);
464
+ } else {
465
+ this.showMessage(
466
+ error.error?.detail || 'Operation failed',
467
+ 'error'
468
+ );
469
+ }
470
+ }
471
+ });
472
+ }
473
+
474
+ close() {
475
+ this.dialogRef.close();
476
+ }
477
+
478
+ private showMessage(message: string, type: 'success' | 'error' = 'success') {
479
+ this.snackBar.open(message, 'Close', {
480
+ duration: 5000,
481
+ panelClass: type === 'error' ? ['error-snackbar'] : ['success-snackbar'],
482
+ horizontalPosition: 'right',
483
+ verticalPosition: 'top'
484
+ });
485
+ }
486
  }
flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts CHANGED
@@ -1,611 +1,611 @@
1
- import { Component, Inject } from '@angular/core';
2
- import { CommonModule } from '@angular/common';
3
- import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
4
- import { MatSelectModule } from '@angular/material/select';
5
- import { MatButtonModule } from '@angular/material/button';
6
- import { MatIconModule } from '@angular/material/icon';
7
- import { MatChipsModule } from '@angular/material/chips';
8
- import { MatExpansionModule } from '@angular/material/expansion';
9
- import { MatDividerModule } from '@angular/material/divider';
10
- import { MatListModule } from '@angular/material/list';
11
- import { FormsModule } from '@angular/forms';
12
- import { Version } from '../../services/api.service';
13
-
14
- interface Difference {
15
- field: string;
16
- label: string;
17
- v1Value: any;
18
- v2Value: any;
19
- type: 'added' | 'removed' | 'modified' | 'unchanged';
20
- }
21
-
22
- @Component({
23
- selector: 'app-version-compare-dialog',
24
- standalone: true,
25
- imports: [
26
- CommonModule,
27
- FormsModule,
28
- MatDialogModule,
29
- MatSelectModule,
30
- MatButtonModule,
31
- MatIconModule,
32
- MatChipsModule,
33
- MatExpansionModule,
34
- MatDividerModule,
35
- MatListModule
36
- ],
37
- template: `
38
- <h2 mat-dialog-title>Compare Versions</h2>
39
-
40
- <mat-dialog-content>
41
- <div class="compare-container">
42
- <!-- Version Selectors -->
43
- <div class="version-selectors">
44
- <mat-form-field appearance="outline">
45
- <mat-label>Version 1</mat-label>
46
- <mat-select [(value)]="version1" (selectionChange)="compareVersions()">
47
- <mat-option *ngFor="let v of versions" [value]="v">
48
- Version {{ v.no }} - {{ v.caption }}
49
- <span class="published-marker" *ngIf="v.published">(Published)</span>
50
- </mat-option>
51
- </mat-select>
52
- </mat-form-field>
53
-
54
- <mat-icon class="compare-icon">compare_arrows</mat-icon>
55
-
56
- <mat-form-field appearance="outline">
57
- <mat-label>Version 2</mat-label>
58
- <mat-select [(value)]="version2" (selectionChange)="compareVersions()">
59
- <mat-option *ngFor="let v of versions" [value]="v">
60
- Version {{ v.no }} - {{ v.caption }}
61
- <span class="published-marker" *ngIf="v.published">(Published)</span>
62
- </mat-option>
63
- </mat-select>
64
- </mat-form-field>
65
- </div>
66
-
67
- <!-- Comparison Results -->
68
- <div class="comparison-results" *ngIf="differences.length > 0">
69
-
70
- <!-- Summary -->
71
- <div class="summary-chips">
72
- <mat-chip-listbox>
73
- <mat-chip-option selected>
74
- <mat-icon>add_circle</mat-icon>
75
- {{ addedCount }} Added
76
- </mat-chip-option>
77
- <mat-chip-option selected color="warn">
78
- <mat-icon>remove_circle</mat-icon>
79
- {{ removedCount }} Removed
80
- </mat-chip-option>
81
- <mat-chip-option selected color="accent">
82
- <mat-icon>edit</mat-icon>
83
- {{ modifiedCount }} Modified
84
- </mat-chip-option>
85
- </mat-chip-listbox>
86
- </div>
87
-
88
- <!-- General Differences -->
89
- <mat-expansion-panel [expanded]="hasGeneralDifferences">
90
- <mat-expansion-panel-header>
91
- <mat-panel-title>
92
- General Configuration
93
- </mat-panel-title>
94
- <mat-panel-description>
95
- {{ generalDifferences.length }} differences
96
- </mat-panel-description>
97
- </mat-expansion-panel-header>
98
-
99
- <mat-list>
100
- <mat-list-item *ngFor="let diff of generalDifferences">
101
- <mat-icon matListItemIcon [class]="'diff-' + diff.type">
102
- {{ getDiffIcon(diff.type) }}
103
- </mat-icon>
104
- <div matListItemTitle>{{ diff.label }}</div>
105
- <div matListItemLine class="diff-values">
106
- <span class="old-value" *ngIf="diff.type !== 'added'">{{ formatValue(diff.v1Value) }}</span>
107
- <mat-icon *ngIf="diff.type === 'modified'">arrow_forward</mat-icon>
108
- <span class="new-value" *ngIf="diff.type !== 'removed'">{{ formatValue(diff.v2Value) }}</span>
109
- </div>
110
- </mat-list-item>
111
- </mat-list>
112
- </mat-expansion-panel>
113
-
114
- <!-- LLM Differences -->
115
- <mat-expansion-panel [expanded]="hasLLMDifferences">
116
- <mat-expansion-panel-header>
117
- <mat-panel-title>
118
- LLM Configuration
119
- </mat-panel-title>
120
- <mat-panel-description>
121
- {{ llmDifferences.length }} differences
122
- </mat-panel-description>
123
- </mat-expansion-panel-header>
124
-
125
- <mat-list>
126
- <mat-list-item *ngFor="let diff of llmDifferences">
127
- <mat-icon matListItemIcon [class]="'diff-' + diff.type">
128
- {{ getDiffIcon(diff.type) }}
129
- </mat-icon>
130
- <div matListItemTitle>{{ diff.label }}</div>
131
- <div matListItemLine class="diff-values">
132
- <span class="old-value" *ngIf="diff.type !== 'added'">{{ formatValue(diff.v1Value) }}</span>
133
- <mat-icon *ngIf="diff.type === 'modified'">arrow_forward</mat-icon>
134
- <span class="new-value" *ngIf="diff.type !== 'removed'">{{ formatValue(diff.v2Value) }}</span>
135
- </div>
136
- </mat-list-item>
137
- </mat-list>
138
- </mat-expansion-panel>
139
-
140
- <!-- Intent Differences -->
141
- <mat-expansion-panel [expanded]="hasIntentDifferences">
142
- <mat-expansion-panel-header>
143
- <mat-panel-title>
144
- Intents
145
- </mat-panel-title>
146
- <mat-panel-description>
147
- {{ intentDifferences.added.length + intentDifferences.removed.length + intentDifferences.modified.length }} differences
148
- </mat-panel-description>
149
- </mat-expansion-panel-header>
150
-
151
- <div class="intents-comparison">
152
- <!-- Added Intents -->
153
- <div class="intent-group" *ngIf="intentDifferences.added.length > 0">
154
- <h4><mat-icon>add_circle</mat-icon> Added Intents</h4>
155
- <mat-list>
156
- <mat-list-item *ngFor="let intent of intentDifferences.added">
157
- <mat-icon matListItemIcon class="diff-added">add</mat-icon>
158
- <div matListItemTitle>{{ intent.name }}</div>
159
- <div matListItemLine>{{ intent.caption || 'No description' }}</div>
160
- </mat-list-item>
161
- </mat-list>
162
- </div>
163
-
164
- <!-- Removed Intents -->
165
- <div class="intent-group" *ngIf="intentDifferences.removed.length > 0">
166
- <h4><mat-icon>remove_circle</mat-icon> Removed Intents</h4>
167
- <mat-list>
168
- <mat-list-item *ngFor="let intent of intentDifferences.removed">
169
- <mat-icon matListItemIcon class="diff-removed">remove</mat-icon>
170
- <div matListItemTitle>{{ intent.name }}</div>
171
- <div matListItemLine>{{ intent.caption || 'No description' }}</div>
172
- </mat-list-item>
173
- </mat-list>
174
- </div>
175
-
176
- <!-- Modified Intents -->
177
- <div class="intent-group" *ngIf="intentDifferences.modified.length > 0">
178
- <h4><mat-icon>edit</mat-icon> Modified Intents</h4>
179
- <mat-expansion-panel *ngFor="let intent of intentDifferences.modified">
180
- <mat-expansion-panel-header>
181
- <mat-panel-title>{{ intent.name }}</mat-panel-title>
182
- <mat-panel-description>{{ intent.changes.length }} changes</mat-panel-description>
183
- </mat-expansion-panel-header>
184
-
185
- <mat-list>
186
- <mat-list-item *ngFor="let change of intent.changes">
187
- <mat-icon matListItemIcon [class]="'diff-' + change.type">
188
- {{ getDiffIcon(change.type) }}
189
- </mat-icon>
190
- <div matListItemTitle>{{ change.label }}</div>
191
- <div matListItemLine class="diff-values">
192
- <span class="old-value" *ngIf="change.type !== 'added'">{{ formatValue(change.v1Value) }}</span>
193
- <mat-icon *ngIf="change.type === 'modified'">arrow_forward</mat-icon>
194
- <span class="new-value" *ngIf="change.type !== 'removed'">{{ formatValue(change.v2Value) }}</span>
195
- </div>
196
- </mat-list-item>
197
- </mat-list>
198
- </mat-expansion-panel>
199
- </div>
200
- </div>
201
- </mat-expansion-panel>
202
-
203
- </div>
204
-
205
- <!-- No Selection State -->
206
- <div class="empty-state" *ngIf="!version1 || !version2">
207
- <mat-icon>compare</mat-icon>
208
- <p>Select two versions to compare</p>
209
- </div>
210
-
211
- <!-- Same Version State -->
212
- <div class="empty-state" *ngIf="version1 && version2 && version1.no === version2.no">
213
- <mat-icon>info</mat-icon>
214
- <p>Please select different versions to compare</p>
215
- </div>
216
-
217
- <!-- No Differences State -->
218
- <div class="empty-state" *ngIf="version1 && version2 && version1.no !== version2.no && differences.length === 0">
219
- <mat-icon>check_circle</mat-icon>
220
- <p>These versions are identical</p>
221
- </div>
222
- </div>
223
- </mat-dialog-content>
224
-
225
- <mat-dialog-actions align="end">
226
- <button mat-button (click)="close()">Close</button>
227
- </mat-dialog-actions>
228
- `,
229
- styles: [`
230
- .compare-container {
231
- min-width: 800px;
232
- max-width: 1000px;
233
- }
234
-
235
- .version-selectors {
236
- display: flex;
237
- gap: 24px;
238
- align-items: center;
239
- justify-content: center;
240
- margin-bottom: 32px;
241
-
242
- mat-form-field {
243
- flex: 1;
244
- max-width: 350px;
245
- }
246
-
247
- .compare-icon {
248
- font-size: 32px;
249
- width: 32px;
250
- height: 32px;
251
- color: #666;
252
- }
253
-
254
- .published-marker {
255
- color: #4caf50;
256
- font-weight: 500;
257
- margin-left: 8px;
258
- }
259
- }
260
-
261
- .summary-chips {
262
- margin-bottom: 24px;
263
- display: flex;
264
- justify-content: center;
265
-
266
- mat-chip {
267
- margin: 0 4px;
268
-
269
- mat-icon {
270
- margin-right: 4px;
271
- }
272
- }
273
- }
274
-
275
- .comparison-results {
276
- mat-expansion-panel {
277
- margin-bottom: 16px;
278
- }
279
- }
280
-
281
- .diff-values {
282
- display: flex;
283
- align-items: center;
284
- gap: 8px;
285
- margin-top: 4px;
286
-
287
- .old-value {
288
- color: #d32f2f;
289
- text-decoration: line-through;
290
- }
291
-
292
- .new-value {
293
- color: #388e3c;
294
- font-weight: 500;
295
- }
296
-
297
- mat-icon {
298
- font-size: 16px;
299
- width: 16px;
300
- height: 16px;
301
- color: #666;
302
- }
303
- }
304
-
305
- .diff-added {
306
- color: #388e3c;
307
- }
308
-
309
- .diff-removed {
310
- color: #d32f2f;
311
- }
312
-
313
- .diff-modified {
314
- color: #1976d2;
315
- }
316
-
317
- .intents-comparison {
318
- .intent-group {
319
- margin-bottom: 24px;
320
-
321
- h4 {
322
- display: flex;
323
- align-items: center;
324
- gap: 8px;
325
- margin-bottom: 12px;
326
- color: #666;
327
-
328
- mat-icon {
329
- font-size: 20px;
330
- width: 20px;
331
- height: 20px;
332
- }
333
- }
334
-
335
- mat-expansion-panel {
336
- margin-bottom: 8px;
337
- }
338
- }
339
- }
340
-
341
- .empty-state {
342
- text-align: center;
343
- padding: 60px 20px;
344
-
345
- mat-icon {
346
- font-size: 64px;
347
- width: 64px;
348
- height: 64px;
349
- color: #e0e0e0;
350
- margin-bottom: 16px;
351
- }
352
-
353
- p {
354
- color: #666;
355
- font-size: 16px;
356
- }
357
- }
358
- `]
359
- })
360
- export default class VersionCompareDialogComponent {
361
- versions: Version[];
362
- version1: Version | null = null;
363
- version2: Version | null = null;
364
-
365
- differences: Difference[] = [];
366
- generalDifferences: Difference[] = [];
367
- llmDifferences: Difference[] = [];
368
- intentDifferences = {
369
- added: [] as any[],
370
- removed: [] as any[],
371
- modified: [] as any[]
372
- };
373
-
374
- constructor(
375
- public dialogRef: MatDialogRef<VersionCompareDialogComponent>,
376
- @Inject(MAT_DIALOG_DATA) public data: { versions: Version[], selectedVersion?: Version }
377
- ) {
378
- this.versions = data.versions;
379
-
380
- // Pre-select versions
381
- if (data.selectedVersion) {
382
- this.version1 = data.selectedVersion;
383
- // Select the next most recent version as version2
384
- const otherVersions = this.versions.filter(v => v.no !== data.selectedVersion!.no);
385
- if (otherVersions.length > 0) {
386
- this.version2 = otherVersions[0];
387
- this.compareVersions();
388
- }
389
- }
390
- }
391
-
392
- get addedCount(): number {
393
- return this.differences.filter(d => d.type === 'added').length + this.intentDifferences.added.length;
394
- }
395
-
396
- get removedCount(): number {
397
- return this.differences.filter(d => d.type === 'removed').length + this.intentDifferences.removed.length;
398
- }
399
-
400
- get modifiedCount(): number {
401
- return this.differences.filter(d => d.type === 'modified').length + this.intentDifferences.modified.length;
402
- }
403
-
404
- get hasGeneralDifferences(): boolean {
405
- return this.generalDifferences.length > 0;
406
- }
407
-
408
- get hasLLMDifferences(): boolean {
409
- return this.llmDifferences.length > 0;
410
- }
411
-
412
- get hasIntentDifferences(): boolean {
413
- return this.intentDifferences.added.length > 0 ||
414
- this.intentDifferences.removed.length > 0 ||
415
- this.intentDifferences.modified.length > 0;
416
- }
417
-
418
- compareVersions() {
419
- if (!this.version1 || !this.version2 || this.version1.no === this.version2.no) {
420
- this.differences = [];
421
- this.generalDifferences = [];
422
- this.llmDifferences = [];
423
- this.intentDifferences = { added: [], removed: [], modified: [] };
424
- return;
425
- }
426
-
427
- this.differences = [];
428
-
429
- // Compare general fields
430
- this.compareField('caption', 'Caption', this.version1.caption, this.version2.caption);
431
- this.compareField('general_prompt', 'General Prompt',
432
- (this.version1 as any).general_prompt,
433
- (this.version2 as any).general_prompt);
434
- this.compareField('published', 'Published Status', this.version1.published, this.version2.published);
435
-
436
- // Compare LLM configuration
437
- if (this.version1.llm && this.version2.llm) {
438
- this.compareField('llm.repo_id', 'Model Repository',
439
- this.version1.llm.repo_id,
440
- this.version2.llm.repo_id);
441
- this.compareField('llm.use_fine_tune', 'Use Fine-Tune',
442
- this.version1.llm.use_fine_tune,
443
- this.version2.llm.use_fine_tune);
444
-
445
- if (this.version1.llm.use_fine_tune || this.version2.llm.use_fine_tune) {
446
- this.compareField('llm.fine_tune_zip', 'Fine-Tune ZIP',
447
- this.version1.llm.fine_tune_zip,
448
- this.version2.llm.fine_tune_zip);
449
- }
450
-
451
- // Compare generation config
452
- const gc1 = this.version1.llm.generation_config;
453
- const gc2 = this.version2.llm.generation_config;
454
- if (gc1 && gc2) {
455
- this.compareField('llm.generation_config.max_new_tokens', 'Max Tokens',
456
- gc1.max_new_tokens, gc2.max_new_tokens);
457
- this.compareField('llm.generation_config.temperature', 'Temperature',
458
- gc1.temperature, gc2.temperature);
459
- this.compareField('llm.generation_config.top_p', 'Top P',
460
- gc1.top_p, gc2.top_p);
461
- this.compareField('llm.generation_config.repetition_penalty', 'Repetition Penalty',
462
- gc1.repetition_penalty, gc2.repetition_penalty);
463
- }
464
- }
465
-
466
- // Compare intents
467
- this.compareIntents();
468
-
469
- // Categorize differences
470
- this.generalDifferences = this.differences.filter(d =>
471
- !d.field.startsWith('llm.') && !d.field.startsWith('intent.'));
472
- this.llmDifferences = this.differences.filter(d => d.field.startsWith('llm.'));
473
- }
474
-
475
- private compareField(field: string, label: string, v1Value: any, v2Value: any) {
476
- if (v1Value === v2Value) {
477
- return;
478
- }
479
-
480
- let type: 'added' | 'removed' | 'modified';
481
- if (v1Value === undefined || v1Value === null || v1Value === '') {
482
- type = 'added';
483
- } else if (v2Value === undefined || v2Value === null || v2Value === '') {
484
- type = 'removed';
485
- } else {
486
- type = 'modified';
487
- }
488
-
489
- this.differences.push({
490
- field,
491
- label,
492
- v1Value,
493
- v2Value,
494
- type
495
- });
496
- }
497
-
498
- private compareIntents() {
499
- const intents1 = this.version1?.intents || [];
500
- const intents2 = this.version2?.intents || [];
501
-
502
- const intents1Map = new Map(intents1.map(i => [i.name, i]));
503
- const intents2Map = new Map(intents2.map(i => [i.name, i]));
504
-
505
- // Find added intents
506
- this.intentDifferences.added = intents2.filter(i => !intents1Map.has(i.name));
507
-
508
- // Find removed intents
509
- this.intentDifferences.removed = intents1.filter(i => !intents2Map.has(i.name));
510
-
511
- // Find modified intents
512
- this.intentDifferences.modified = [];
513
- for (const [name, intent1] of intents1Map) {
514
- const intent2 = intents2Map.get(name);
515
- if (intent2) {
516
- const changes = this.compareIntentDetails(intent1, intent2);
517
- if (changes.length > 0) {
518
- this.intentDifferences.modified.push({
519
- name,
520
- changes
521
- });
522
- }
523
- }
524
- }
525
- }
526
-
527
- private compareIntentDetails(intent1: any, intent2: any): Difference[] {
528
- const changes: Difference[] = [];
529
-
530
- // Compare basic fields
531
- if (intent1.caption !== intent2.caption) {
532
- changes.push({
533
- field: `intent.${intent1.name}.caption`,
534
- label: 'Caption',
535
- v1Value: intent1.caption,
536
- v2Value: intent2.caption,
537
- type: 'modified'
538
- });
539
- }
540
-
541
- if (intent1.detection_prompt !== intent2.detection_prompt) {
542
- changes.push({
543
- field: `intent.${intent1.name}.detection_prompt`,
544
- label: 'Detection Prompt',
545
- v1Value: intent1.detection_prompt,
546
- v2Value: intent2.detection_prompt,
547
- type: 'modified'
548
- });
549
- }
550
-
551
- if (intent1.action !== intent2.action) {
552
- changes.push({
553
- field: `intent.${intent1.name}.action`,
554
- label: 'API Action',
555
- v1Value: intent1.action,
556
- v2Value: intent2.action,
557
- type: 'modified'
558
- });
559
- }
560
-
561
- // Compare examples
562
- const examples1 = intent1.examples || [];
563
- const examples2 = intent2.examples || [];
564
- if (JSON.stringify(examples1) !== JSON.stringify(examples2)) {
565
- changes.push({
566
- field: `intent.${intent1.name}.examples`,
567
- label: 'Examples',
568
- v1Value: `${examples1.length} examples`,
569
- v2Value: `${examples2.length} examples`,
570
- type: 'modified'
571
- });
572
- }
573
-
574
- // Compare parameters
575
- const params1 = intent1.parameters || [];
576
- const params2 = intent2.parameters || [];
577
- if (JSON.stringify(params1) !== JSON.stringify(params2)) {
578
- changes.push({
579
- field: `intent.${intent1.name}.parameters`,
580
- label: 'Parameters',
581
- v1Value: `${params1.length} parameters`,
582
- v2Value: `${params2.length} parameters`,
583
- type: 'modified'
584
- });
585
- }
586
-
587
- return changes;
588
- }
589
-
590
- getDiffIcon(type: string): string {
591
- switch (type) {
592
- case 'added': return 'add_circle';
593
- case 'removed': return 'remove_circle';
594
- case 'modified': return 'edit';
595
- default: return 'circle';
596
- }
597
- }
598
-
599
- formatValue(value: any): string {
600
- if (value === null || value === undefined) return 'Not set';
601
- if (typeof value === 'boolean') return value ? 'Yes' : 'No';
602
- if (typeof value === 'string' && value.length > 100) {
603
- return value.substring(0, 100) + '...';
604
- }
605
- return String(value);
606
- }
607
-
608
- close() {
609
- this.dialogRef.close();
610
- }
611
  }
 
1
+ import { Component, Inject } from '@angular/core';
2
+ import { CommonModule } from '@angular/common';
3
+ import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
4
+ import { MatSelectModule } from '@angular/material/select';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { MatIconModule } from '@angular/material/icon';
7
+ import { MatChipsModule } from '@angular/material/chips';
8
+ import { MatExpansionModule } from '@angular/material/expansion';
9
+ import { MatDividerModule } from '@angular/material/divider';
10
+ import { MatListModule } from '@angular/material/list';
11
+ import { FormsModule } from '@angular/forms';
12
+ import { Version } from '../../services/api.service';
13
+
14
+ interface Difference {
15
+ field: string;
16
+ label: string;
17
+ v1Value: any;
18
+ v2Value: any;
19
+ type: 'added' | 'removed' | 'modified' | 'unchanged';
20
+ }
21
+
22
+ @Component({
23
+ selector: 'app-version-compare-dialog',
24
+ standalone: true,
25
+ imports: [
26
+ CommonModule,
27
+ FormsModule,
28
+ MatDialogModule,
29
+ MatSelectModule,
30
+ MatButtonModule,
31
+ MatIconModule,
32
+ MatChipsModule,
33
+ MatExpansionModule,
34
+ MatDividerModule,
35
+ MatListModule
36
+ ],
37
+ template: `
38
+ <h2 mat-dialog-title>Compare Versions</h2>
39
+
40
+ <mat-dialog-content>
41
+ <div class="compare-container">
42
+ <!-- Version Selectors -->
43
+ <div class="version-selectors">
44
+ <mat-form-field appearance="outline">
45
+ <mat-label>Version 1</mat-label>
46
+ <mat-select [(value)]="version1" (selectionChange)="compareVersions()">
47
+ <mat-option *ngFor="let v of versions" [value]="v">
48
+ Version {{ v.no }} - {{ v.caption }}
49
+ <span class="published-marker" *ngIf="v.published">(Published)</span>
50
+ </mat-option>
51
+ </mat-select>
52
+ </mat-form-field>
53
+
54
+ <mat-icon class="compare-icon">compare_arrows</mat-icon>
55
+
56
+ <mat-form-field appearance="outline">
57
+ <mat-label>Version 2</mat-label>
58
+ <mat-select [(value)]="version2" (selectionChange)="compareVersions()">
59
+ <mat-option *ngFor="let v of versions" [value]="v">
60
+ Version {{ v.no }} - {{ v.caption }}
61
+ <span class="published-marker" *ngIf="v.published">(Published)</span>
62
+ </mat-option>
63
+ </mat-select>
64
+ </mat-form-field>
65
+ </div>
66
+
67
+ <!-- Comparison Results -->
68
+ <div class="comparison-results" *ngIf="differences.length > 0">
69
+
70
+ <!-- Summary -->
71
+ <div class="summary-chips">
72
+ <mat-chip-listbox>
73
+ <mat-chip-option selected>
74
+ <mat-icon>add_circle</mat-icon>
75
+ {{ addedCount }} Added
76
+ </mat-chip-option>
77
+ <mat-chip-option selected color="warn">
78
+ <mat-icon>remove_circle</mat-icon>
79
+ {{ removedCount }} Removed
80
+ </mat-chip-option>
81
+ <mat-chip-option selected color="accent">
82
+ <mat-icon>edit</mat-icon>
83
+ {{ modifiedCount }} Modified
84
+ </mat-chip-option>
85
+ </mat-chip-listbox>
86
+ </div>
87
+
88
+ <!-- General Differences -->
89
+ <mat-expansion-panel [expanded]="hasGeneralDifferences">
90
+ <mat-expansion-panel-header>
91
+ <mat-panel-title>
92
+ General Configuration
93
+ </mat-panel-title>
94
+ <mat-panel-description>
95
+ {{ generalDifferences.length }} differences
96
+ </mat-panel-description>
97
+ </mat-expansion-panel-header>
98
+
99
+ <mat-list>
100
+ <mat-list-item *ngFor="let diff of generalDifferences">
101
+ <mat-icon matListItemIcon [class]="'diff-' + diff.type">
102
+ {{ getDiffIcon(diff.type) }}
103
+ </mat-icon>
104
+ <div matListItemTitle>{{ diff.label }}</div>
105
+ <div matListItemLine class="diff-values">
106
+ <span class="old-value" *ngIf="diff.type !== 'added'">{{ formatValue(diff.v1Value) }}</span>
107
+ <mat-icon *ngIf="diff.type === 'modified'">arrow_forward</mat-icon>
108
+ <span class="new-value" *ngIf="diff.type !== 'removed'">{{ formatValue(diff.v2Value) }}</span>
109
+ </div>
110
+ </mat-list-item>
111
+ </mat-list>
112
+ </mat-expansion-panel>
113
+
114
+ <!-- LLM Differences -->
115
+ <mat-expansion-panel [expanded]="hasLLMDifferences">
116
+ <mat-expansion-panel-header>
117
+ <mat-panel-title>
118
+ LLM Configuration
119
+ </mat-panel-title>
120
+ <mat-panel-description>
121
+ {{ llmDifferences.length }} differences
122
+ </mat-panel-description>
123
+ </mat-expansion-panel-header>
124
+
125
+ <mat-list>
126
+ <mat-list-item *ngFor="let diff of llmDifferences">
127
+ <mat-icon matListItemIcon [class]="'diff-' + diff.type">
128
+ {{ getDiffIcon(diff.type) }}
129
+ </mat-icon>
130
+ <div matListItemTitle>{{ diff.label }}</div>
131
+ <div matListItemLine class="diff-values">
132
+ <span class="old-value" *ngIf="diff.type !== 'added'">{{ formatValue(diff.v1Value) }}</span>
133
+ <mat-icon *ngIf="diff.type === 'modified'">arrow_forward</mat-icon>
134
+ <span class="new-value" *ngIf="diff.type !== 'removed'">{{ formatValue(diff.v2Value) }}</span>
135
+ </div>
136
+ </mat-list-item>
137
+ </mat-list>
138
+ </mat-expansion-panel>
139
+
140
+ <!-- Intent Differences -->
141
+ <mat-expansion-panel [expanded]="hasIntentDifferences">
142
+ <mat-expansion-panel-header>
143
+ <mat-panel-title>
144
+ Intents
145
+ </mat-panel-title>
146
+ <mat-panel-description>
147
+ {{ intentDifferences.added.length + intentDifferences.removed.length + intentDifferences.modified.length }} differences
148
+ </mat-panel-description>
149
+ </mat-expansion-panel-header>
150
+
151
+ <div class="intents-comparison">
152
+ <!-- Added Intents -->
153
+ <div class="intent-group" *ngIf="intentDifferences.added.length > 0">
154
+ <h4><mat-icon>add_circle</mat-icon> Added Intents</h4>
155
+ <mat-list>
156
+ <mat-list-item *ngFor="let intent of intentDifferences.added">
157
+ <mat-icon matListItemIcon class="diff-added">add</mat-icon>
158
+ <div matListItemTitle>{{ intent.name }}</div>
159
+ <div matListItemLine>{{ intent.caption || 'No description' }}</div>
160
+ </mat-list-item>
161
+ </mat-list>
162
+ </div>
163
+
164
+ <!-- Removed Intents -->
165
+ <div class="intent-group" *ngIf="intentDifferences.removed.length > 0">
166
+ <h4><mat-icon>remove_circle</mat-icon> Removed Intents</h4>
167
+ <mat-list>
168
+ <mat-list-item *ngFor="let intent of intentDifferences.removed">
169
+ <mat-icon matListItemIcon class="diff-removed">remove</mat-icon>
170
+ <div matListItemTitle>{{ intent.name }}</div>
171
+ <div matListItemLine>{{ intent.caption || 'No description' }}</div>
172
+ </mat-list-item>
173
+ </mat-list>
174
+ </div>
175
+
176
+ <!-- Modified Intents -->
177
+ <div class="intent-group" *ngIf="intentDifferences.modified.length > 0">
178
+ <h4><mat-icon>edit</mat-icon> Modified Intents</h4>
179
+ <mat-expansion-panel *ngFor="let intent of intentDifferences.modified">
180
+ <mat-expansion-panel-header>
181
+ <mat-panel-title>{{ intent.name }}</mat-panel-title>
182
+ <mat-panel-description>{{ intent.changes.length }} changes</mat-panel-description>
183
+ </mat-expansion-panel-header>
184
+
185
+ <mat-list>
186
+ <mat-list-item *ngFor="let change of intent.changes">
187
+ <mat-icon matListItemIcon [class]="'diff-' + change.type">
188
+ {{ getDiffIcon(change.type) }}
189
+ </mat-icon>
190
+ <div matListItemTitle>{{ change.label }}</div>
191
+ <div matListItemLine class="diff-values">
192
+ <span class="old-value" *ngIf="change.type !== 'added'">{{ formatValue(change.v1Value) }}</span>
193
+ <mat-icon *ngIf="change.type === 'modified'">arrow_forward</mat-icon>
194
+ <span class="new-value" *ngIf="change.type !== 'removed'">{{ formatValue(change.v2Value) }}</span>
195
+ </div>
196
+ </mat-list-item>
197
+ </mat-list>
198
+ </mat-expansion-panel>
199
+ </div>
200
+ </div>
201
+ </mat-expansion-panel>
202
+
203
+ </div>
204
+
205
+ <!-- No Selection State -->
206
+ <div class="empty-state" *ngIf="!version1 || !version2">
207
+ <mat-icon>compare</mat-icon>
208
+ <p>Select two versions to compare</p>
209
+ </div>
210
+
211
+ <!-- Same Version State -->
212
+ <div class="empty-state" *ngIf="version1 && version2 && version1.no === version2.no">
213
+ <mat-icon>info</mat-icon>
214
+ <p>Please select different versions to compare</p>
215
+ </div>
216
+
217
+ <!-- No Differences State -->
218
+ <div class="empty-state" *ngIf="version1 && version2 && version1.no !== version2.no && differences.length === 0">
219
+ <mat-icon>check_circle</mat-icon>
220
+ <p>These versions are identical</p>
221
+ </div>
222
+ </div>
223
+ </mat-dialog-content>
224
+
225
+ <mat-dialog-actions align="end">
226
+ <button mat-button (click)="close()">Close</button>
227
+ </mat-dialog-actions>
228
+ `,
229
+ styles: [`
230
+ .compare-container {
231
+ min-width: 800px;
232
+ max-width: 1000px;
233
+ }
234
+
235
+ .version-selectors {
236
+ display: flex;
237
+ gap: 24px;
238
+ align-items: center;
239
+ justify-content: center;
240
+ margin-bottom: 32px;
241
+
242
+ mat-form-field {
243
+ flex: 1;
244
+ max-width: 350px;
245
+ }
246
+
247
+ .compare-icon {
248
+ font-size: 32px;
249
+ width: 32px;
250
+ height: 32px;
251
+ color: #666;
252
+ }
253
+
254
+ .published-marker {
255
+ color: #4caf50;
256
+ font-weight: 500;
257
+ margin-left: 8px;
258
+ }
259
+ }
260
+
261
+ .summary-chips {
262
+ margin-bottom: 24px;
263
+ display: flex;
264
+ justify-content: center;
265
+
266
+ mat-chip {
267
+ margin: 0 4px;
268
+
269
+ mat-icon {
270
+ margin-right: 4px;
271
+ }
272
+ }
273
+ }
274
+
275
+ .comparison-results {
276
+ mat-expansion-panel {
277
+ margin-bottom: 16px;
278
+ }
279
+ }
280
+
281
+ .diff-values {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 8px;
285
+ margin-top: 4px;
286
+
287
+ .old-value {
288
+ color: #d32f2f;
289
+ text-decoration: line-through;
290
+ }
291
+
292
+ .new-value {
293
+ color: #388e3c;
294
+ font-weight: 500;
295
+ }
296
+
297
+ mat-icon {
298
+ font-size: 16px;
299
+ width: 16px;
300
+ height: 16px;
301
+ color: #666;
302
+ }
303
+ }
304
+
305
+ .diff-added {
306
+ color: #388e3c;
307
+ }
308
+
309
+ .diff-removed {
310
+ color: #d32f2f;
311
+ }
312
+
313
+ .diff-modified {
314
+ color: #1976d2;
315
+ }
316
+
317
+ .intents-comparison {
318
+ .intent-group {
319
+ margin-bottom: 24px;
320
+
321
+ h4 {
322
+ display: flex;
323
+ align-items: center;
324
+ gap: 8px;
325
+ margin-bottom: 12px;
326
+ color: #666;
327
+
328
+ mat-icon {
329
+ font-size: 20px;
330
+ width: 20px;
331
+ height: 20px;
332
+ }
333
+ }
334
+
335
+ mat-expansion-panel {
336
+ margin-bottom: 8px;
337
+ }
338
+ }
339
+ }
340
+
341
+ .empty-state {
342
+ text-align: center;
343
+ padding: 60px 20px;
344
+
345
+ mat-icon {
346
+ font-size: 64px;
347
+ width: 64px;
348
+ height: 64px;
349
+ color: #e0e0e0;
350
+ margin-bottom: 16px;
351
+ }
352
+
353
+ p {
354
+ color: #666;
355
+ font-size: 16px;
356
+ }
357
+ }
358
+ `]
359
+ })
360
+ export default class VersionCompareDialogComponent {
361
+ versions: Version[];
362
+ version1: Version | null = null;
363
+ version2: Version | null = null;
364
+
365
+ differences: Difference[] = [];
366
+ generalDifferences: Difference[] = [];
367
+ llmDifferences: Difference[] = [];
368
+ intentDifferences = {
369
+ added: [] as any[],
370
+ removed: [] as any[],
371
+ modified: [] as any[]
372
+ };
373
+
374
+ constructor(
375
+ public dialogRef: MatDialogRef<VersionCompareDialogComponent>,
376
+ @Inject(MAT_DIALOG_DATA) public data: { versions: Version[], selectedVersion?: Version }
377
+ ) {
378
+ this.versions = data.versions;
379
+
380
+ // Pre-select versions
381
+ if (data.selectedVersion) {
382
+ this.version1 = data.selectedVersion;
383
+ // Select the next most recent version as version2
384
+ const otherVersions = this.versions.filter(v => v.no !== data.selectedVersion!.no);
385
+ if (otherVersions.length > 0) {
386
+ this.version2 = otherVersions[0];
387
+ this.compareVersions();
388
+ }
389
+ }
390
+ }
391
+
392
+ get addedCount(): number {
393
+ return this.differences.filter(d => d.type === 'added').length + this.intentDifferences.added.length;
394
+ }
395
+
396
+ get removedCount(): number {
397
+ return this.differences.filter(d => d.type === 'removed').length + this.intentDifferences.removed.length;
398
+ }
399
+
400
+ get modifiedCount(): number {
401
+ return this.differences.filter(d => d.type === 'modified').length + this.intentDifferences.modified.length;
402
+ }
403
+
404
+ get hasGeneralDifferences(): boolean {
405
+ return this.generalDifferences.length > 0;
406
+ }
407
+
408
+ get hasLLMDifferences(): boolean {
409
+ return this.llmDifferences.length > 0;
410
+ }
411
+
412
+ get hasIntentDifferences(): boolean {
413
+ return this.intentDifferences.added.length > 0 ||
414
+ this.intentDifferences.removed.length > 0 ||
415
+ this.intentDifferences.modified.length > 0;
416
+ }
417
+
418
+ compareVersions() {
419
+ if (!this.version1 || !this.version2 || this.version1.no === this.version2.no) {
420
+ this.differences = [];
421
+ this.generalDifferences = [];
422
+ this.llmDifferences = [];
423
+ this.intentDifferences = { added: [], removed: [], modified: [] };
424
+ return;
425
+ }
426
+
427
+ this.differences = [];
428
+
429
+ // Compare general fields
430
+ this.compareField('caption', 'Caption', this.version1.caption, this.version2.caption);
431
+ this.compareField('general_prompt', 'General Prompt',
432
+ (this.version1 as any).general_prompt,
433
+ (this.version2 as any).general_prompt);
434
+ this.compareField('published', 'Published Status', this.version1.published, this.version2.published);
435
+
436
+ // Compare LLM configuration
437
+ if (this.version1.llm && this.version2.llm) {
438
+ this.compareField('llm.repo_id', 'Model Repository',
439
+ this.version1.llm.repo_id,
440
+ this.version2.llm.repo_id);
441
+ this.compareField('llm.use_fine_tune', 'Use Fine-Tune',
442
+ this.version1.llm.use_fine_tune,
443
+ this.version2.llm.use_fine_tune);
444
+
445
+ if (this.version1.llm.use_fine_tune || this.version2.llm.use_fine_tune) {
446
+ this.compareField('llm.fine_tune_zip', 'Fine-Tune ZIP',
447
+ this.version1.llm.fine_tune_zip,
448
+ this.version2.llm.fine_tune_zip);
449
+ }
450
+
451
+ // Compare generation config
452
+ const gc1 = this.version1.llm.generation_config;
453
+ const gc2 = this.version2.llm.generation_config;
454
+ if (gc1 && gc2) {
455
+ this.compareField('llm.generation_config.max_new_tokens', 'Max Tokens',
456
+ gc1.max_new_tokens, gc2.max_new_tokens);
457
+ this.compareField('llm.generation_config.temperature', 'Temperature',
458
+ gc1.temperature, gc2.temperature);
459
+ this.compareField('llm.generation_config.top_p', 'Top P',
460
+ gc1.top_p, gc2.top_p);
461
+ this.compareField('llm.generation_config.repetition_penalty', 'Repetition Penalty',
462
+ gc1.repetition_penalty, gc2.repetition_penalty);
463
+ }
464
+ }
465
+
466
+ // Compare intents
467
+ this.compareIntents();
468
+
469
+ // Categorize differences
470
+ this.generalDifferences = this.differences.filter(d =>
471
+ !d.field.startsWith('llm.') && !d.field.startsWith('intent.'));
472
+ this.llmDifferences = this.differences.filter(d => d.field.startsWith('llm.'));
473
+ }
474
+
475
+ private compareField(field: string, label: string, v1Value: any, v2Value: any) {
476
+ if (v1Value === v2Value) {
477
+ return;
478
+ }
479
+
480
+ let type: 'added' | 'removed' | 'modified';
481
+ if (v1Value === undefined || v1Value === null || v1Value === '') {
482
+ type = 'added';
483
+ } else if (v2Value === undefined || v2Value === null || v2Value === '') {
484
+ type = 'removed';
485
+ } else {
486
+ type = 'modified';
487
+ }
488
+
489
+ this.differences.push({
490
+ field,
491
+ label,
492
+ v1Value,
493
+ v2Value,
494
+ type
495
+ });
496
+ }
497
+
498
+ private compareIntents() {
499
+ const intents1 = this.version1?.intents || [];
500
+ const intents2 = this.version2?.intents || [];
501
+
502
+ const intents1Map = new Map(intents1.map(i => [i.name, i]));
503
+ const intents2Map = new Map(intents2.map(i => [i.name, i]));
504
+
505
+ // Find added intents
506
+ this.intentDifferences.added = intents2.filter(i => !intents1Map.has(i.name));
507
+
508
+ // Find removed intents
509
+ this.intentDifferences.removed = intents1.filter(i => !intents2Map.has(i.name));
510
+
511
+ // Find modified intents
512
+ this.intentDifferences.modified = [];
513
+ for (const [name, intent1] of intents1Map) {
514
+ const intent2 = intents2Map.get(name);
515
+ if (intent2) {
516
+ const changes = this.compareIntentDetails(intent1, intent2);
517
+ if (changes.length > 0) {
518
+ this.intentDifferences.modified.push({
519
+ name,
520
+ changes
521
+ });
522
+ }
523
+ }
524
+ }
525
+ }
526
+
527
+ private compareIntentDetails(intent1: any, intent2: any): Difference[] {
528
+ const changes: Difference[] = [];
529
+
530
+ // Compare basic fields
531
+ if (intent1.caption !== intent2.caption) {
532
+ changes.push({
533
+ field: `intent.${intent1.name}.caption`,
534
+ label: 'Caption',
535
+ v1Value: intent1.caption,
536
+ v2Value: intent2.caption,
537
+ type: 'modified'
538
+ });
539
+ }
540
+
541
+ if (intent1.detection_prompt !== intent2.detection_prompt) {
542
+ changes.push({
543
+ field: `intent.${intent1.name}.detection_prompt`,
544
+ label: 'Detection Prompt',
545
+ v1Value: intent1.detection_prompt,
546
+ v2Value: intent2.detection_prompt,
547
+ type: 'modified'
548
+ });
549
+ }
550
+
551
+ if (intent1.action !== intent2.action) {
552
+ changes.push({
553
+ field: `intent.${intent1.name}.action`,
554
+ label: 'API Action',
555
+ v1Value: intent1.action,
556
+ v2Value: intent2.action,
557
+ type: 'modified'
558
+ });
559
+ }
560
+
561
+ // Compare examples
562
+ const examples1 = intent1.examples || [];
563
+ const examples2 = intent2.examples || [];
564
+ if (JSON.stringify(examples1) !== JSON.stringify(examples2)) {
565
+ changes.push({
566
+ field: `intent.${intent1.name}.examples`,
567
+ label: 'Examples',
568
+ v1Value: `${examples1.length} examples`,
569
+ v2Value: `${examples2.length} examples`,
570
+ type: 'modified'
571
+ });
572
+ }
573
+
574
+ // Compare parameters
575
+ const params1 = intent1.parameters || [];
576
+ const params2 = intent2.parameters || [];
577
+ if (JSON.stringify(params1) !== JSON.stringify(params2)) {
578
+ changes.push({
579
+ field: `intent.${intent1.name}.parameters`,
580
+ label: 'Parameters',
581
+ v1Value: `${params1.length} parameters`,
582
+ v2Value: `${params2.length} parameters`,
583
+ type: 'modified'
584
+ });
585
+ }
586
+
587
+ return changes;
588
+ }
589
+
590
+ getDiffIcon(type: string): string {
591
+ switch (type) {
592
+ case 'added': return 'add_circle';
593
+ case 'removed': return 'remove_circle';
594
+ case 'modified': return 'edit';
595
+ default: return 'circle';
596
+ }
597
+ }
598
+
599
+ formatValue(value: any): string {
600
+ if (value === null || value === undefined) return 'Not set';
601
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
602
+ if (typeof value === 'string' && value.length > 100) {
603
+ return value.substring(0, 100) + '...';
604
+ }
605
+ return String(value);
606
+ }
607
+
608
+ close() {
609
+ this.dialogRef.close();
610
+ }
611
  }
flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html CHANGED
@@ -1,336 +1,336 @@
1
- <mat-dialog-content class="version-management-container">
2
- <h2 mat-dialog-title>
3
- Manage Versions - {{ project.name }}
4
- <mat-chip-listbox class="title-chips">
5
- <mat-chip-option [disabled]="true">
6
- <mat-icon>layers</mat-icon>
7
- {{ versions.length }} versions
8
- </mat-chip-option>
9
- </mat-chip-listbox>
10
- </h2>
11
-
12
- <div class="dialog-content">
13
- <!-- Version Selector -->
14
- <div class="version-selector">
15
- <mat-form-field appearance="outline" class="version-select">
16
- <mat-label>Select Version</mat-label>
17
- <mat-select [(value)]="selectedVersion" (selectionChange)="loadVersion($event.value)">
18
- <mat-option *ngFor="let version of versions" [value]="version">
19
- Version {{ version.no }} - {{ version.caption || 'No description' }}
20
- <span class="version-status" *ngIf="version.published">[Published]</span>
21
- </mat-option>
22
- </mat-select>
23
- </mat-form-field>
24
-
25
- <div class="version-actions">
26
- <button mat-raised-button color="primary" (click)="createVersion()" [disabled]="creating">
27
- <mat-icon>add</mat-icon>
28
- New Version
29
- </button>
30
- <button mat-button (click)="compareVersions()" [disabled]="versions.length < 2">
31
- <mat-icon>compare_arrows</mat-icon>
32
- Compare
33
- </button>
34
- </div>
35
- </div>
36
-
37
- <mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
38
-
39
- <!-- Warning for published version -->
40
- <div class="alert alert-warning" *ngIf="selectedVersion?.published">
41
- <mat-icon>info</mat-icon>
42
- This version is published and cannot be edited. Create a new version or unpublish to make changes.
43
- </div>
44
-
45
- <!-- Version Editor -->
46
- <div class="version-editor" *ngIf="selectedVersion && !loading">
47
- <form [formGroup]="versionForm">
48
- <mat-tab-group [(selectedIndex)]="selectedTabIndex" dynamicHeight>
49
- <!-- General Tab -->
50
- <mat-tab label="General">
51
- <div class="tab-content">
52
- <div class="metadata-info">
53
- <mat-chip-listbox>
54
- <mat-chip-option>Version {{ selectedVersion.no }}</mat-chip-option>
55
- <mat-chip-option *ngIf="selectedVersion.published" selected>Published</mat-chip-option>
56
- <mat-chip-option *ngIf="!selectedVersion.published">Draft</mat-chip-option>
57
- <mat-chip-option *ngIf="selectedVersion.last_update_date">
58
- Last updated: {{ selectedVersion.last_update_date | date:'short' }}
59
- </mat-chip-option>
60
-
61
- </mat-chip-listbox>
62
- </div>
63
-
64
- <mat-form-field appearance="outline" class="full-width">
65
- <mat-label>Caption</mat-label>
66
- <input matInput formControlName="caption" placeholder="Version description" [readonly]="!canEdit">
67
- </mat-form-field>
68
-
69
- <mat-form-field appearance="outline" class="full-width">
70
- <mat-label>General System Prompt</mat-label>
71
- <textarea matInput
72
- class="code-textarea"
73
- formControlName="general_prompt"
74
- rows="10"
75
- placeholder="Define the assistant's behavior and capabilities..."
76
- [readonly]="!canEdit"></textarea>
77
- <mat-hint>This prompt defines the overall behavior of your assistant</mat-hint>
78
- </mat-form-field>
79
-
80
- <mat-form-field appearance="outline" class="full-width">
81
- <mat-label>Welcome Prompt</mat-label>
82
- <textarea matInput formControlName="welcome_prompt" rows="4" [readonly]="!canEdit"></textarea>
83
- <mat-hint>Initial greeting message (use {{ '{{user_name}}' }} for personalization)</mat-hint>
84
- </mat-form-field>
85
-
86
- <div class="action-buttons">
87
- <button mat-raised-button color="warn"
88
- (click)="deleteVersion()"
89
- [disabled]="selectedVersion.published"
90
- *ngIf="!selectedVersion.published">
91
- <mat-icon>delete</mat-icon>
92
- Delete Version
93
- </button>
94
- </div>
95
- </div>
96
- </mat-tab>
97
-
98
- <!-- LLM Configuration Tab -->
99
- <mat-tab label="LLM">
100
- <div class="tab-content" formGroupName="llm">
101
- <mat-form-field appearance="outline" class="full-width">
102
- <mat-label>Model Repository ID</mat-label>
103
- <input matInput formControlName="repo_id"
104
- placeholder="e.g., ytu-ce-cosmos/Turkish-Llama-8b-Instruct-v0.1"
105
- [readonly]="!canEdit">
106
- <mat-hint>HuggingFace model repository ID</mat-hint>
107
- </mat-form-field>
108
-
109
- <h4>Generation Configuration</h4>
110
- <div formGroupName="generation_config" class="generation-config">
111
- <mat-form-field appearance="outline">
112
- <mat-label>Max New Tokens</mat-label>
113
- <input matInput type="number" formControlName="max_new_tokens" [readonly]="!canEdit">
114
- <mat-hint>Maximum tokens to generate (1-2048)</mat-hint>
115
- </mat-form-field>
116
-
117
- <mat-form-field appearance="outline">
118
- <mat-label>Temperature</mat-label>
119
- <input matInput type="number" step="0.1" formControlName="temperature" [readonly]="!canEdit">
120
- <mat-hint>Controls randomness (0-2)</mat-hint>
121
- </mat-form-field>
122
-
123
- <mat-form-field appearance="outline">
124
- <mat-label>Top P</mat-label>
125
- <input matInput type="number" step="0.1" formControlName="top_p" [readonly]="!canEdit">
126
- <mat-hint>Nucleus sampling (0-1)</mat-hint>
127
- </mat-form-field>
128
-
129
- <mat-form-field appearance="outline">
130
- <mat-label>Repetition Penalty</mat-label>
131
- <input matInput type="number" step="0.1" formControlName="repetition_penalty" [readonly]="!canEdit">
132
- <mat-hint>Penalty for repetition (1-2)</mat-hint>
133
- </mat-form-field>
134
- </div>
135
-
136
- <mat-divider></mat-divider>
137
-
138
- <div class="fine-tune-section">
139
- <mat-checkbox formControlName="use_fine_tune" [disabled]="!canEdit">
140
- Use Fine-Tuned Model
141
- </mat-checkbox>
142
-
143
- <mat-form-field appearance="outline" class="full-width"
144
- *ngIf="versionForm.get('llm.use_fine_tune')?.value">
145
- <mat-label>Fine-Tune ZIP URL</mat-label>
146
- <input matInput formControlName="fine_tune_zip"
147
- placeholder="https://example.com/lora-adapter.zip"
148
- [readonly]="!canEdit">
149
- <mat-hint>URL to LoRA adapter ZIP file</mat-hint>
150
- </mat-form-field>
151
- </div>
152
- </div>
153
- </mat-tab>
154
-
155
- <!-- Intents Tab -->
156
- <mat-tab label="Intents" [matBadge]="intents.length" matBadgeColor="primary">
157
- <div class="tab-content">
158
- <div class="intents-header">
159
- <h3>Intent Definitions</h3>
160
- <button mat-raised-button color="primary" (click)="addIntent()" [disabled]="!canEdit">
161
- <mat-icon>add</mat-icon>
162
- Add Intent
163
- </button>
164
- </div>
165
-
166
- <!-- Example Language Selector -->
167
- <mat-form-field appearance="outline" class="locale-selector">
168
- <mat-label>Example Language</mat-label>
169
- <mat-select [(value)]="selectedExampleLocale">
170
- <mat-option *ngFor="let locale of getAvailableLocales()" [value]="locale.code">
171
- {{ locale.name }}
172
- </mat-option>
173
- </mat-select>
174
- </mat-form-field>
175
-
176
- <div formArrayName="intents" class="intents-list">
177
- <mat-expansion-panel *ngFor="let intent of intents.controls; let i = index"
178
- [formGroupName]="i">
179
- <mat-expansion-panel-header>
180
- <mat-panel-title>
181
- {{ intent.get('name')?.value || 'New Intent' }}
182
- </mat-panel-title>
183
- <mat-panel-description>
184
- {{ intent.get('caption')?.value || 'No description' }}
185
- <mat-chip-listbox class="intent-chips">
186
- <mat-chip-option>{{ getIntentParameters(i).length }} params</mat-chip-option>
187
- <mat-chip-option>{{ intent.get('action')?.value || 'No API' }}</mat-chip-option>
188
- </mat-chip-listbox>
189
- </mat-panel-description>
190
- </mat-expansion-panel-header>
191
-
192
- <div class="intent-content">
193
- <div class="intent-actions">
194
- <button mat-button color="primary" (click)="editIntent(i)" [disabled]="!canEdit">
195
- <mat-icon>edit</mat-icon>
196
- Edit Details
197
- </button>
198
- <button mat-button color="warn" (click)="removeIntent(i)" [disabled]="!canEdit">
199
- <mat-icon>delete</mat-icon>
200
- Delete
201
- </button>
202
- </div>
203
-
204
- <!-- Quick view of intent details -->
205
- <div class="intent-summary">
206
- <div class="summary-item">
207
- <strong>Detection Prompt:</strong>
208
- <p>{{ intent.get('detection_prompt')?.value || 'Not set' }}</p>
209
- </div>
210
-
211
- <div class="summary-item" *ngIf="getLocalizedExamples(intent.get('examples')?.value, selectedExampleLocale).length > 0">
212
- <strong>Examples ({{ getLocaleName(selectedExampleLocale) }}):</strong>
213
- <div class="examples-display">
214
- <mat-chip-row *ngFor="let ex of getLocalizedExamples(intent.get('examples')?.value, selectedExampleLocale)">
215
- {{ ex.example }}
216
- </mat-chip-row>
217
- </div>
218
- </div>
219
-
220
- <div class="summary-item" *ngIf="getIntentParameters(i).length > 0">
221
- <strong>Parameters:</strong>
222
- <mat-list>
223
- <mat-list-item *ngFor="let param of getIntentParameters(i).controls">
224
- <mat-icon matListItemIcon>
225
- {{ param.get('required')?.value ? 'check_box' : 'check_box_outline_blank' }}
226
- </mat-icon>
227
- <div matListItemTitle>{{ param.get('name')?.value }}</div>
228
- <div matListItemLine>
229
- {{ getParameterCaptionDisplay(param.get('caption')?.value) }}
230
- ({{ param.get('type')?.value }})
231
- </div>
232
- </mat-list-item>
233
- </mat-list>
234
- </div>
235
- </div>
236
- </div>
237
- </mat-expansion-panel>
238
- </div>
239
-
240
- <div class="empty-state" *ngIf="intents.length === 0">
241
- <mat-icon>psychology</mat-icon>
242
- <p>No intents defined yet.</p>
243
- <button mat-raised-button color="primary" (click)="addIntent()" [disabled]="!canEdit">
244
- Add First Intent
245
- </button>
246
- </div>
247
- </div>
248
- </mat-tab>
249
-
250
- <!-- Test Tab -->
251
- <mat-tab label="Test">
252
- <div class="tab-content">
253
- <h3>Test Intent Detection</h3>
254
- <p>Enter a user message to test which intent would be detected.</p>
255
-
256
- <mat-form-field appearance="outline" class="full-width">
257
- <mat-label>User Message</mat-label>
258
- <textarea matInput
259
- [(ngModel)]="testUserMessage"
260
- [ngModelOptions]="{standalone: true}"
261
- rows="3"
262
- placeholder="e.g., I want to book a flight from Istanbul to Ankara"></textarea>
263
- </mat-form-field>
264
-
265
- <button mat-raised-button color="accent"
266
- (click)="testIntentDetection()"
267
- [disabled]="testing || !testUserMessage">
268
- <mat-icon>play_arrow</mat-icon>
269
- {{ testing ? 'Testing...' : 'Test Intent Detection' }}
270
- </button>
271
-
272
- <div class="test-result" *ngIf="testResult">
273
- <h4>Test Result:</h4>
274
-
275
- <div class="result-card" [class.success]="testResult.intent" [class.no-match]="!testResult.intent">
276
- <div class="result-header">
277
- <mat-icon>{{ testResult.intent ? 'check_circle' : 'info' }}</mat-icon>
278
- <span *ngIf="testResult.intent">Intent Detected: <strong>{{ testResult.intent }}</strong></span>
279
- <span *ngIf="!testResult.intent">No intent matched</span>
280
- </div>
281
-
282
- <div class="result-details" *ngIf="testResult.intent">
283
- <div class="confidence">
284
- Confidence: {{ (testResult.confidence * 100).toFixed(0) }}%
285
- <mat-progress-bar [value]="testResult.confidence * 100"></mat-progress-bar>
286
- </div>
287
-
288
- <div class="parameters" *ngIf="testResult.parameters.length > 0">
289
- <h5>Parameters that would be extracted:</h5>
290
- <mat-list>
291
- <mat-list-item *ngFor="let param of testResult.parameters">
292
- <mat-icon matListItemIcon [color]="param.extracted ? 'primary' : ''">
293
- {{ param.extracted ? 'check_circle' : 'radio_button_unchecked' }}
294
- </mat-icon>
295
- <div matListItemTitle>{{ param.name }}</div>
296
- <div matListItemLine>
297
- {{ param.extracted ? 'Value: ' + param.value : 'Missing - would ask user' }}
298
- </div>
299
- </mat-list-item>
300
- </mat-list>
301
- </div>
302
- </div>
303
- </div>
304
- </div>
305
- </div>
306
- </mat-tab>
307
- </mat-tab-group>
308
- </form>
309
- </div>
310
-
311
- <!-- No Version Selected -->
312
- <div class="empty-state" *ngIf="!selectedVersion && !loading">
313
- <mat-icon>layers</mat-icon>
314
- <p>No version selected. Create a new version to get started.</p>
315
- <button mat-raised-button color="primary" (click)="createVersion()">
316
- Create First Version
317
- </button>
318
- </div>
319
- </div>
320
- </mat-dialog-content>
321
-
322
- <mat-dialog-actions align="end">
323
- <button mat-button (click)="close()">Close</button>
324
- <button mat-raised-button
325
- color="primary"
326
- (click)="saveVersion()"
327
- [disabled]="!selectedVersion || !canEdit || versionForm.invalid || saving">
328
- {{ saving ? 'Saving...' : 'Save Changes' }}
329
- </button>
330
- <button mat-raised-button
331
- color="accent"
332
- (click)="publishVersion()"
333
- [disabled]="!selectedVersion || selectedVersion.published || publishing || isDirty || versionForm.invalid">
334
- {{ publishing ? 'Publishing...' : 'Publish Version' }}
335
- </button>
336
  </mat-dialog-actions>
 
1
+ <mat-dialog-content class="version-management-container">
2
+ <h2 mat-dialog-title>
3
+ Manage Versions - {{ project.name }}
4
+ <mat-chip-listbox class="title-chips">
5
+ <mat-chip-option [disabled]="true">
6
+ <mat-icon>layers</mat-icon>
7
+ {{ versions.length }} versions
8
+ </mat-chip-option>
9
+ </mat-chip-listbox>
10
+ </h2>
11
+
12
+ <div class="dialog-content">
13
+ <!-- Version Selector -->
14
+ <div class="version-selector">
15
+ <mat-form-field appearance="outline" class="version-select">
16
+ <mat-label>Select Version</mat-label>
17
+ <mat-select [(value)]="selectedVersion" (selectionChange)="loadVersion($event.value)">
18
+ <mat-option *ngFor="let version of versions" [value]="version">
19
+ Version {{ version.no }} - {{ version.caption || 'No description' }}
20
+ <span class="version-status" *ngIf="version.published">[Published]</span>
21
+ </mat-option>
22
+ </mat-select>
23
+ </mat-form-field>
24
+
25
+ <div class="version-actions">
26
+ <button mat-raised-button color="primary" (click)="createVersion()" [disabled]="creating">
27
+ <mat-icon>add</mat-icon>
28
+ New Version
29
+ </button>
30
+ <button mat-button (click)="compareVersions()" [disabled]="versions.length < 2">
31
+ <mat-icon>compare_arrows</mat-icon>
32
+ Compare
33
+ </button>
34
+ </div>
35
+ </div>
36
+
37
+ <mat-progress-bar *ngIf="loading" mode="indeterminate"></mat-progress-bar>
38
+
39
+ <!-- Warning for published version -->
40
+ <div class="alert alert-warning" *ngIf="selectedVersion?.published">
41
+ <mat-icon>info</mat-icon>
42
+ This version is published and cannot be edited. Create a new version or unpublish to make changes.
43
+ </div>
44
+
45
+ <!-- Version Editor -->
46
+ <div class="version-editor" *ngIf="selectedVersion && !loading">
47
+ <form [formGroup]="versionForm">
48
+ <mat-tab-group [(selectedIndex)]="selectedTabIndex" dynamicHeight>
49
+ <!-- General Tab -->
50
+ <mat-tab label="General">
51
+ <div class="tab-content">
52
+ <div class="metadata-info">
53
+ <mat-chip-listbox>
54
+ <mat-chip-option>Version {{ selectedVersion.no }}</mat-chip-option>
55
+ <mat-chip-option *ngIf="selectedVersion.published" selected>Published</mat-chip-option>
56
+ <mat-chip-option *ngIf="!selectedVersion.published">Draft</mat-chip-option>
57
+ <mat-chip-option *ngIf="selectedVersion.last_update_date">
58
+ Last updated: {{ selectedVersion.last_update_date | date:'short' }}
59
+ </mat-chip-option>
60
+
61
+ </mat-chip-listbox>
62
+ </div>
63
+
64
+ <mat-form-field appearance="outline" class="full-width">
65
+ <mat-label>Caption</mat-label>
66
+ <input matInput formControlName="caption" placeholder="Version description" [readonly]="!canEdit">
67
+ </mat-form-field>
68
+
69
+ <mat-form-field appearance="outline" class="full-width">
70
+ <mat-label>General System Prompt</mat-label>
71
+ <textarea matInput
72
+ class="code-textarea"
73
+ formControlName="general_prompt"
74
+ rows="10"
75
+ placeholder="Define the assistant's behavior and capabilities..."
76
+ [readonly]="!canEdit"></textarea>
77
+ <mat-hint>This prompt defines the overall behavior of your assistant</mat-hint>
78
+ </mat-form-field>
79
+
80
+ <mat-form-field appearance="outline" class="full-width">
81
+ <mat-label>Welcome Prompt</mat-label>
82
+ <textarea matInput formControlName="welcome_prompt" rows="4" [readonly]="!canEdit"></textarea>
83
+ <mat-hint>Initial greeting message (use {{ '{{user_name}}' }} for personalization)</mat-hint>
84
+ </mat-form-field>
85
+
86
+ <div class="action-buttons">
87
+ <button mat-raised-button color="warn"
88
+ (click)="deleteVersion()"
89
+ [disabled]="selectedVersion.published"
90
+ *ngIf="!selectedVersion.published">
91
+ <mat-icon>delete</mat-icon>
92
+ Delete Version
93
+ </button>
94
+ </div>
95
+ </div>
96
+ </mat-tab>
97
+
98
+ <!-- LLM Configuration Tab -->
99
+ <mat-tab label="LLM">
100
+ <div class="tab-content" formGroupName="llm">
101
+ <mat-form-field appearance="outline" class="full-width">
102
+ <mat-label>Model Repository ID</mat-label>
103
+ <input matInput formControlName="repo_id"
104
+ placeholder="e.g., ytu-ce-cosmos/Turkish-Llama-8b-Instruct-v0.1"
105
+ [readonly]="!canEdit">
106
+ <mat-hint>HuggingFace model repository ID</mat-hint>
107
+ </mat-form-field>
108
+
109
+ <h4>Generation Configuration</h4>
110
+ <div formGroupName="generation_config" class="generation-config">
111
+ <mat-form-field appearance="outline">
112
+ <mat-label>Max New Tokens</mat-label>
113
+ <input matInput type="number" formControlName="max_new_tokens" [readonly]="!canEdit">
114
+ <mat-hint>Maximum tokens to generate (1-2048)</mat-hint>
115
+ </mat-form-field>
116
+
117
+ <mat-form-field appearance="outline">
118
+ <mat-label>Temperature</mat-label>
119
+ <input matInput type="number" step="0.1" formControlName="temperature" [readonly]="!canEdit">
120
+ <mat-hint>Controls randomness (0-2)</mat-hint>
121
+ </mat-form-field>
122
+
123
+ <mat-form-field appearance="outline">
124
+ <mat-label>Top P</mat-label>
125
+ <input matInput type="number" step="0.1" formControlName="top_p" [readonly]="!canEdit">
126
+ <mat-hint>Nucleus sampling (0-1)</mat-hint>
127
+ </mat-form-field>
128
+
129
+ <mat-form-field appearance="outline">
130
+ <mat-label>Repetition Penalty</mat-label>
131
+ <input matInput type="number" step="0.1" formControlName="repetition_penalty" [readonly]="!canEdit">
132
+ <mat-hint>Penalty for repetition (1-2)</mat-hint>
133
+ </mat-form-field>
134
+ </div>
135
+
136
+ <mat-divider></mat-divider>
137
+
138
+ <div class="fine-tune-section">
139
+ <mat-checkbox formControlName="use_fine_tune" [disabled]="!canEdit">
140
+ Use Fine-Tuned Model
141
+ </mat-checkbox>
142
+
143
+ <mat-form-field appearance="outline" class="full-width"
144
+ *ngIf="versionForm.get('llm.use_fine_tune')?.value">
145
+ <mat-label>Fine-Tune ZIP URL</mat-label>
146
+ <input matInput formControlName="fine_tune_zip"
147
+ placeholder="https://example.com/lora-adapter.zip"
148
+ [readonly]="!canEdit">
149
+ <mat-hint>URL to LoRA adapter ZIP file</mat-hint>
150
+ </mat-form-field>
151
+ </div>
152
+ </div>
153
+ </mat-tab>
154
+
155
+ <!-- Intents Tab -->
156
+ <mat-tab label="Intents" [matBadge]="intents.length" matBadgeColor="primary">
157
+ <div class="tab-content">
158
+ <div class="intents-header">
159
+ <h3>Intent Definitions</h3>
160
+ <button mat-raised-button color="primary" (click)="addIntent()" [disabled]="!canEdit">
161
+ <mat-icon>add</mat-icon>
162
+ Add Intent
163
+ </button>
164
+ </div>
165
+
166
+ <!-- Example Language Selector -->
167
+ <mat-form-field appearance="outline" class="locale-selector">
168
+ <mat-label>Example Language</mat-label>
169
+ <mat-select [(value)]="selectedExampleLocale">
170
+ <mat-option *ngFor="let locale of getAvailableLocales()" [value]="locale.code">
171
+ {{ locale.name }}
172
+ </mat-option>
173
+ </mat-select>
174
+ </mat-form-field>
175
+
176
+ <div formArrayName="intents" class="intents-list">
177
+ <mat-expansion-panel *ngFor="let intent of intents.controls; let i = index"
178
+ [formGroupName]="i">
179
+ <mat-expansion-panel-header>
180
+ <mat-panel-title>
181
+ {{ intent.get('name')?.value || 'New Intent' }}
182
+ </mat-panel-title>
183
+ <mat-panel-description>
184
+ {{ intent.get('caption')?.value || 'No description' }}
185
+ <mat-chip-listbox class="intent-chips">
186
+ <mat-chip-option>{{ getIntentParameters(i).length }} params</mat-chip-option>
187
+ <mat-chip-option>{{ intent.get('action')?.value || 'No API' }}</mat-chip-option>
188
+ </mat-chip-listbox>
189
+ </mat-panel-description>
190
+ </mat-expansion-panel-header>
191
+
192
+ <div class="intent-content">
193
+ <div class="intent-actions">
194
+ <button mat-button color="primary" (click)="editIntent(i)" [disabled]="!canEdit">
195
+ <mat-icon>edit</mat-icon>
196
+ Edit Details
197
+ </button>
198
+ <button mat-button color="warn" (click)="removeIntent(i)" [disabled]="!canEdit">
199
+ <mat-icon>delete</mat-icon>
200
+ Delete
201
+ </button>
202
+ </div>
203
+
204
+ <!-- Quick view of intent details -->
205
+ <div class="intent-summary">
206
+ <div class="summary-item">
207
+ <strong>Detection Prompt:</strong>
208
+ <p>{{ intent.get('detection_prompt')?.value || 'Not set' }}</p>
209
+ </div>
210
+
211
+ <div class="summary-item" *ngIf="getLocalizedExamples(intent.get('examples')?.value, selectedExampleLocale).length > 0">
212
+ <strong>Examples ({{ getLocaleName(selectedExampleLocale) }}):</strong>
213
+ <div class="examples-display">
214
+ <mat-chip-row *ngFor="let ex of getLocalizedExamples(intent.get('examples')?.value, selectedExampleLocale)">
215
+ {{ ex.example }}
216
+ </mat-chip-row>
217
+ </div>
218
+ </div>
219
+
220
+ <div class="summary-item" *ngIf="getIntentParameters(i).length > 0">
221
+ <strong>Parameters:</strong>
222
+ <mat-list>
223
+ <mat-list-item *ngFor="let param of getIntentParameters(i).controls">
224
+ <mat-icon matListItemIcon>
225
+ {{ param.get('required')?.value ? 'check_box' : 'check_box_outline_blank' }}
226
+ </mat-icon>
227
+ <div matListItemTitle>{{ param.get('name')?.value }}</div>
228
+ <div matListItemLine>
229
+ {{ getParameterCaptionDisplay(param.get('caption')?.value) }}
230
+ ({{ param.get('type')?.value }})
231
+ </div>
232
+ </mat-list-item>
233
+ </mat-list>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ </mat-expansion-panel>
238
+ </div>
239
+
240
+ <div class="empty-state" *ngIf="intents.length === 0">
241
+ <mat-icon>psychology</mat-icon>
242
+ <p>No intents defined yet.</p>
243
+ <button mat-raised-button color="primary" (click)="addIntent()" [disabled]="!canEdit">
244
+ Add First Intent
245
+ </button>
246
+ </div>
247
+ </div>
248
+ </mat-tab>
249
+
250
+ <!-- Test Tab -->
251
+ <mat-tab label="Test">
252
+ <div class="tab-content">
253
+ <h3>Test Intent Detection</h3>
254
+ <p>Enter a user message to test which intent would be detected.</p>
255
+
256
+ <mat-form-field appearance="outline" class="full-width">
257
+ <mat-label>User Message</mat-label>
258
+ <textarea matInput
259
+ [(ngModel)]="testUserMessage"
260
+ [ngModelOptions]="{standalone: true}"
261
+ rows="3"
262
+ placeholder="e.g., I want to book a flight from Istanbul to Ankara"></textarea>
263
+ </mat-form-field>
264
+
265
+ <button mat-raised-button color="accent"
266
+ (click)="testIntentDetection()"
267
+ [disabled]="testing || !testUserMessage">
268
+ <mat-icon>play_arrow</mat-icon>
269
+ {{ testing ? 'Testing...' : 'Test Intent Detection' }}
270
+ </button>
271
+
272
+ <div class="test-result" *ngIf="testResult">
273
+ <h4>Test Result:</h4>
274
+
275
+ <div class="result-card" [class.success]="testResult.intent" [class.no-match]="!testResult.intent">
276
+ <div class="result-header">
277
+ <mat-icon>{{ testResult.intent ? 'check_circle' : 'info' }}</mat-icon>
278
+ <span *ngIf="testResult.intent">Intent Detected: <strong>{{ testResult.intent }}</strong></span>
279
+ <span *ngIf="!testResult.intent">No intent matched</span>
280
+ </div>
281
+
282
+ <div class="result-details" *ngIf="testResult.intent">
283
+ <div class="confidence">
284
+ Confidence: {{ (testResult.confidence * 100).toFixed(0) }}%
285
+ <mat-progress-bar [value]="testResult.confidence * 100"></mat-progress-bar>
286
+ </div>
287
+
288
+ <div class="parameters" *ngIf="testResult.parameters.length > 0">
289
+ <h5>Parameters that would be extracted:</h5>
290
+ <mat-list>
291
+ <mat-list-item *ngFor="let param of testResult.parameters">
292
+ <mat-icon matListItemIcon [color]="param.extracted ? 'primary' : ''">
293
+ {{ param.extracted ? 'check_circle' : 'radio_button_unchecked' }}
294
+ </mat-icon>
295
+ <div matListItemTitle>{{ param.name }}</div>
296
+ <div matListItemLine>
297
+ {{ param.extracted ? 'Value: ' + param.value : 'Missing - would ask user' }}
298
+ </div>
299
+ </mat-list-item>
300
+ </mat-list>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </mat-tab>
307
+ </mat-tab-group>
308
+ </form>
309
+ </div>
310
+
311
+ <!-- No Version Selected -->
312
+ <div class="empty-state" *ngIf="!selectedVersion && !loading">
313
+ <mat-icon>layers</mat-icon>
314
+ <p>No version selected. Create a new version to get started.</p>
315
+ <button mat-raised-button color="primary" (click)="createVersion()">
316
+ Create First Version
317
+ </button>
318
+ </div>
319
+ </div>
320
+ </mat-dialog-content>
321
+
322
+ <mat-dialog-actions align="end">
323
+ <button mat-button (click)="close()">Close</button>
324
+ <button mat-raised-button
325
+ color="primary"
326
+ (click)="saveVersion()"
327
+ [disabled]="!selectedVersion || !canEdit || versionForm.invalid || saving">
328
+ {{ saving ? 'Saving...' : 'Save Changes' }}
329
+ </button>
330
+ <button mat-raised-button
331
+ color="accent"
332
+ (click)="publishVersion()"
333
+ [disabled]="!selectedVersion || selectedVersion.published || publishing || isDirty || versionForm.invalid">
334
+ {{ publishing ? 'Publishing...' : 'Publish Version' }}
335
+ </button>
336
  </mat-dialog-actions>
flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.scss CHANGED
@@ -1,288 +1,288 @@
1
- .version-management-container {
2
- min-height: 500px;
3
-
4
- .title-chips {
5
- float: right;
6
- margin-top: -8px;
7
-
8
- mat-chip {
9
- font-size: 12px;
10
- margin: 0 2px;
11
- }
12
- }
13
-
14
- .version-selector {
15
- display: flex;
16
- gap: 16px;
17
- align-items: center;
18
- margin-bottom: 24px;
19
- flex-wrap: wrap;
20
-
21
- .version-select {
22
- flex: 1;
23
- max-width: 400px;
24
- min-width: 250px;
25
- }
26
-
27
- .version-actions {
28
- display: flex;
29
- gap: 8px;
30
- flex-wrap: wrap;
31
- }
32
-
33
- .version-status {
34
- color: #4caf50;
35
- font-weight: 500;
36
- margin-left: 8px;
37
- }
38
- }
39
-
40
- .alert.alert-warning {
41
- background-color: #fff3cd;
42
- border: 1px solid #ffeaa7;
43
- color: #856404;
44
- padding: 12px 20px;
45
- border-radius: 4px;
46
- margin-bottom: 16px;
47
- display: flex;
48
- align-items: center;
49
- gap: 8px;
50
-
51
- mat-icon {
52
- color: #856404;
53
- }
54
- }
55
-
56
- .locale-selector {
57
- max-width: 200px;
58
- margin-bottom: 16px;
59
- }
60
-
61
- .version-editor {
62
- mat-tab-group {
63
- min-height: 400px;
64
- }
65
- }
66
-
67
- .tab-content {
68
- padding: 24px;
69
- }
70
-
71
- .full-width {
72
- width: 100%;
73
- margin-bottom: 16px;
74
- }
75
-
76
- .metadata-info {
77
- margin-bottom: 24px;
78
-
79
- mat-chip {
80
- font-size: 12px;
81
- margin: 2px;
82
- }
83
- }
84
-
85
- .generation-config {
86
- display: grid;
87
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
88
- gap: 16px;
89
- margin-bottom: 24px;
90
- padding: 16px;
91
- background-color: #f5f5f5;
92
- border-radius: 4px;
93
- }
94
-
95
- .fine-tune-section {
96
- margin-top: 24px;
97
-
98
- mat-checkbox {
99
- margin-bottom: 16px;
100
- }
101
- }
102
-
103
- .intents-header {
104
- display: flex;
105
- justify-content: space-between;
106
- align-items: center;
107
- margin-bottom: 16px;
108
-
109
- h3 {
110
- margin: 0;
111
- }
112
- }
113
-
114
- .intents-list {
115
- mat-expansion-panel {
116
- margin-bottom: 8px;
117
-
118
- .intent-chips {
119
- margin-left: 16px;
120
-
121
- mat-chip {
122
- font-size: 11px;
123
- min-height: 20px;
124
- padding: 2px 8px;
125
- }
126
- }
127
- }
128
-
129
- .intent-content {
130
- padding: 16px;
131
- }
132
-
133
- .intent-actions {
134
- display: flex;
135
- gap: 8px;
136
- margin-bottom: 16px;
137
- padding-bottom: 16px;
138
- border-bottom: 1px solid #e0e0e0;
139
- }
140
-
141
- .intent-summary {
142
- .summary-item {
143
- margin-bottom: 16px;
144
-
145
- strong {
146
- display: block;
147
- margin-bottom: 8px;
148
- color: rgba(0, 0, 0, 0.87);
149
- }
150
-
151
- p {
152
- margin: 0;
153
- color: rgba(0, 0, 0, 0.6);
154
- }
155
-
156
- mat-chip {
157
- margin: 2px;
158
- font-size: 12px;
159
- }
160
-
161
- mat-list {
162
- padding-top: 0;
163
- }
164
-
165
- .examples-display {
166
- mat-chip-row {
167
- margin: 4px;
168
- }
169
- }
170
- }
171
- }
172
- }
173
-
174
- .test-result {
175
- margin-top: 24px;
176
-
177
- h4 {
178
- margin-bottom: 16px;
179
- }
180
-
181
- .result-card {
182
- border: 1px solid #e0e0e0;
183
- border-radius: 4px;
184
- padding: 16px;
185
-
186
- &.success {
187
- background-color: #e8f5e9;
188
- border-color: #4caf50;
189
-
190
- .result-header {
191
- color: #2e7d32;
192
-
193
- mat-icon {
194
- color: #4caf50;
195
- }
196
- }
197
- }
198
-
199
- &.no-match {
200
- background-color: #fff3e0;
201
- border-color: #ff9800;
202
-
203
- .result-header {
204
- color: #e65100;
205
-
206
- mat-icon {
207
- color: #ff9800;
208
- }
209
- }
210
- }
211
-
212
- .result-header {
213
- display: flex;
214
- align-items: center;
215
- gap: 8px;
216
- margin-bottom: 16px;
217
- font-size: 16px;
218
-
219
- mat-icon {
220
- font-size: 24px;
221
- width: 24px;
222
- height: 24px;
223
- }
224
- }
225
-
226
- .confidence {
227
- margin-bottom: 16px;
228
-
229
- mat-progress-bar {
230
- margin-top: 8px;
231
- }
232
- }
233
-
234
- .parameters {
235
- h5 {
236
- margin-bottom: 8px;
237
- }
238
-
239
- mat-list {
240
- background: white;
241
- border-radius: 4px;
242
- }
243
- }
244
- }
245
- }
246
-
247
- .empty-state {
248
- text-align: center;
249
- padding: 60px 20px;
250
-
251
- mat-icon {
252
- font-size: 64px;
253
- width: 64px;
254
- height: 64px;
255
- color: #e0e0e0;
256
- margin-bottom: 16px;
257
- }
258
-
259
- p {
260
- color: #666;
261
- margin-bottom: 24px;
262
- }
263
- }
264
-
265
- .action-buttons {
266
- margin-top: 24px;
267
- padding-top: 24px;
268
- border-top: 1px solid #e0e0e0;
269
- }
270
- }
271
-
272
- mat-dialog-content {
273
- max-width: 1000px;
274
- min-width: 800px;
275
- max-height: 80vh;
276
- padding: 0;
277
- }
278
-
279
- mat-dialog-actions {
280
- padding: 16px 24px;
281
- margin: 0;
282
- border-top: 1px solid #e0e0e0;
283
- gap: 8px;
284
-
285
- button {
286
- margin: 0 !important;
287
- }
288
  }
 
1
+ .version-management-container {
2
+ min-height: 500px;
3
+
4
+ .title-chips {
5
+ float: right;
6
+ margin-top: -8px;
7
+
8
+ mat-chip {
9
+ font-size: 12px;
10
+ margin: 0 2px;
11
+ }
12
+ }
13
+
14
+ .version-selector {
15
+ display: flex;
16
+ gap: 16px;
17
+ align-items: center;
18
+ margin-bottom: 24px;
19
+ flex-wrap: wrap;
20
+
21
+ .version-select {
22
+ flex: 1;
23
+ max-width: 400px;
24
+ min-width: 250px;
25
+ }
26
+
27
+ .version-actions {
28
+ display: flex;
29
+ gap: 8px;
30
+ flex-wrap: wrap;
31
+ }
32
+
33
+ .version-status {
34
+ color: #4caf50;
35
+ font-weight: 500;
36
+ margin-left: 8px;
37
+ }
38
+ }
39
+
40
+ .alert.alert-warning {
41
+ background-color: #fff3cd;
42
+ border: 1px solid #ffeaa7;
43
+ color: #856404;
44
+ padding: 12px 20px;
45
+ border-radius: 4px;
46
+ margin-bottom: 16px;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 8px;
50
+
51
+ mat-icon {
52
+ color: #856404;
53
+ }
54
+ }
55
+
56
+ .locale-selector {
57
+ max-width: 200px;
58
+ margin-bottom: 16px;
59
+ }
60
+
61
+ .version-editor {
62
+ mat-tab-group {
63
+ min-height: 400px;
64
+ }
65
+ }
66
+
67
+ .tab-content {
68
+ padding: 24px;
69
+ }
70
+
71
+ .full-width {
72
+ width: 100%;
73
+ margin-bottom: 16px;
74
+ }
75
+
76
+ .metadata-info {
77
+ margin-bottom: 24px;
78
+
79
+ mat-chip {
80
+ font-size: 12px;
81
+ margin: 2px;
82
+ }
83
+ }
84
+
85
+ .generation-config {
86
+ display: grid;
87
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
88
+ gap: 16px;
89
+ margin-bottom: 24px;
90
+ padding: 16px;
91
+ background-color: #f5f5f5;
92
+ border-radius: 4px;
93
+ }
94
+
95
+ .fine-tune-section {
96
+ margin-top: 24px;
97
+
98
+ mat-checkbox {
99
+ margin-bottom: 16px;
100
+ }
101
+ }
102
+
103
+ .intents-header {
104
+ display: flex;
105
+ justify-content: space-between;
106
+ align-items: center;
107
+ margin-bottom: 16px;
108
+
109
+ h3 {
110
+ margin: 0;
111
+ }
112
+ }
113
+
114
+ .intents-list {
115
+ mat-expansion-panel {
116
+ margin-bottom: 8px;
117
+
118
+ .intent-chips {
119
+ margin-left: 16px;
120
+
121
+ mat-chip {
122
+ font-size: 11px;
123
+ min-height: 20px;
124
+ padding: 2px 8px;
125
+ }
126
+ }
127
+ }
128
+
129
+ .intent-content {
130
+ padding: 16px;
131
+ }
132
+
133
+ .intent-actions {
134
+ display: flex;
135
+ gap: 8px;
136
+ margin-bottom: 16px;
137
+ padding-bottom: 16px;
138
+ border-bottom: 1px solid #e0e0e0;
139
+ }
140
+
141
+ .intent-summary {
142
+ .summary-item {
143
+ margin-bottom: 16px;
144
+
145
+ strong {
146
+ display: block;
147
+ margin-bottom: 8px;
148
+ color: rgba(0, 0, 0, 0.87);
149
+ }
150
+
151
+ p {
152
+ margin: 0;
153
+ color: rgba(0, 0, 0, 0.6);
154
+ }
155
+
156
+ mat-chip {
157
+ margin: 2px;
158
+ font-size: 12px;
159
+ }
160
+
161
+ mat-list {
162
+ padding-top: 0;
163
+ }
164
+
165
+ .examples-display {
166
+ mat-chip-row {
167
+ margin: 4px;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+
174
+ .test-result {
175
+ margin-top: 24px;
176
+
177
+ h4 {
178
+ margin-bottom: 16px;
179
+ }
180
+
181
+ .result-card {
182
+ border: 1px solid #e0e0e0;
183
+ border-radius: 4px;
184
+ padding: 16px;
185
+
186
+ &.success {
187
+ background-color: #e8f5e9;
188
+ border-color: #4caf50;
189
+
190
+ .result-header {
191
+ color: #2e7d32;
192
+
193
+ mat-icon {
194
+ color: #4caf50;
195
+ }
196
+ }
197
+ }
198
+
199
+ &.no-match {
200
+ background-color: #fff3e0;
201
+ border-color: #ff9800;
202
+
203
+ .result-header {
204
+ color: #e65100;
205
+
206
+ mat-icon {
207
+ color: #ff9800;
208
+ }
209
+ }
210
+ }
211
+
212
+ .result-header {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: 8px;
216
+ margin-bottom: 16px;
217
+ font-size: 16px;
218
+
219
+ mat-icon {
220
+ font-size: 24px;
221
+ width: 24px;
222
+ height: 24px;
223
+ }
224
+ }
225
+
226
+ .confidence {
227
+ margin-bottom: 16px;
228
+
229
+ mat-progress-bar {
230
+ margin-top: 8px;
231
+ }
232
+ }
233
+
234
+ .parameters {
235
+ h5 {
236
+ margin-bottom: 8px;
237
+ }
238
+
239
+ mat-list {
240
+ background: white;
241
+ border-radius: 4px;
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ .empty-state {
248
+ text-align: center;
249
+ padding: 60px 20px;
250
+
251
+ mat-icon {
252
+ font-size: 64px;
253
+ width: 64px;
254
+ height: 64px;
255
+ color: #e0e0e0;
256
+ margin-bottom: 16px;
257
+ }
258
+
259
+ p {
260
+ color: #666;
261
+ margin-bottom: 24px;
262
+ }
263
+ }
264
+
265
+ .action-buttons {
266
+ margin-top: 24px;
267
+ padding-top: 24px;
268
+ border-top: 1px solid #e0e0e0;
269
+ }
270
+ }
271
+
272
+ mat-dialog-content {
273
+ max-width: 1000px;
274
+ min-width: 800px;
275
+ max-height: 80vh;
276
+ padding: 0;
277
+ }
278
+
279
+ mat-dialog-actions {
280
+ padding: 16px 24px;
281
+ margin: 0;
282
+ border-top: 1px solid #e0e0e0;
283
+ gap: 8px;
284
+
285
+ button {
286
+ margin: 0 !important;
287
+ }
288
  }