Spaces:
Paused
Paused
Upload 118 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env +33 -33
- .env.example +33 -33
- .gitignore +31 -31
- Dockerfile +87 -87
- api_connector.py +99 -99
- api_executor.py +425 -425
- app.py +387 -387
- config/config_provider.py +156 -156
- config/locale_manager.py +29 -29
- credentials/google-service-account.json +13 -13
- flare-ui/angular.json +116 -116
- flare-ui/package.json +42 -42
- flare-ui/src/app/app.component.ts +76 -76
- flare-ui/src/app/app.config.ts +16 -16
- flare-ui/src/app/app.routes.ts +59 -59
- flare-ui/src/app/components/activity-log/activity-log.component.ts +429 -429
- flare-ui/src/app/components/apis/apis.component.ts +741 -741
- flare-ui/src/app/components/chat/chat.component.html +156 -156
- flare-ui/src/app/components/chat/chat.component.scss +289 -289
- flare-ui/src/app/components/chat/chat.component.ts +630 -630
- flare-ui/src/app/components/chat/realtime-chat.component.html +96 -96
- flare-ui/src/app/components/chat/realtime-chat.component.scss +164 -164
- flare-ui/src/app/components/chat/realtime-chat.component.ts +421 -421
- flare-ui/src/app/components/environment/environment.component.html +285 -285
- flare-ui/src/app/components/environment/environment.component.scss +167 -167
- flare-ui/src/app/components/environment/environment.component.ts +714 -714
- flare-ui/src/app/components/login/login.component.ts +208 -208
- flare-ui/src/app/components/main/main.component.scss +144 -144
- flare-ui/src/app/components/main/main.component.ts +301 -301
- flare-ui/src/app/components/projects/projects.component.html +184 -184
- flare-ui/src/app/components/projects/projects.component.scss +274 -274
- flare-ui/src/app/components/projects/projects.component.ts +448 -448
- flare-ui/src/app/components/spark/spark.component.ts +549 -549
- flare-ui/src/app/components/test/test.component.html +115 -115
- flare-ui/src/app/components/test/test.component.scss +257 -257
- flare-ui/src/app/components/test/test.component.ts +709 -709
- flare-ui/src/app/components/user-info/user-info.component.html +82 -82
- flare-ui/src/app/components/user-info/user-info.component.ts +174 -174
- flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.html +481 -481
- flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.scss +231 -231
- flare-ui/src/app/dialogs/api-edit-dialog/api-edit-dialog.component.ts +577 -577
- flare-ui/src/app/dialogs/confirm-dialog/confirm-dialog.component.ts +136 -136
- flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.html +242 -242
- flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.scss +148 -148
- flare-ui/src/app/dialogs/intent-edit-dialog/intent-edit-dialog.component.ts +340 -340
- flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.scss +91 -91
- flare-ui/src/app/dialogs/project-edit-dialog/project-edit-dialog.component.ts +485 -485
- flare-ui/src/app/dialogs/version-compare-dialog/version-compare-dialog.component.ts +610 -610
- flare-ui/src/app/dialogs/version-edit-dialog/version-edit-dialog.component.html +335 -335
- 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": "745400736051-compute@developer.gserviceaccount.com",
|
| 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": "745400736051-compute@developer.gserviceaccount.com",
|
| 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 |
}
|