This commit is contained in:
Marwan Alwali 2025-05-26 16:37:24 +03:00
parent 250e0aa7bb
commit d26f777c73
6 changed files with 1105 additions and 971 deletions

View File

@ -1,49 +1,31 @@
from django.db.models import Avg, Sum, Max, Min, ForeignKey, OneToOneField
import inspect import inspect
import hashlib
from django.db import models from django.db import models
from django.db.models import Avg, Sum, Max, Min, ForeignKey, OneToOneField, Count
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone
def _localized_keys(language): def _localized_keys(language):
""" return {
Get localized key names based on language. 'type': 'نوع' if language == 'ar' else 'type',
'model': 'النموذج' if language == 'ar' else 'model',
:param language: Language code ('en' or 'ar') 'count': 'العدد' if language == 'ar' else 'count',
:type language: str 'filters': 'الفلاتر_المطبقة' if language == 'ar' else 'filters_applied',
:return: Dictionary of localized keys 'error': 'خطأ' if language == 'ar' else 'error',
:rtype: dict 'chart_type': 'نوع_الرسم_البياني' if language == 'ar' else 'chart_type',
""" 'labels': 'التسميات' if language == 'ar' else 'labels',
if language == 'ar': 'data': 'البيانات' if language == 'ar' else 'data',
return { 'visualization_data': 'بيانات_الرسم_البياني' if language == 'ar' else 'visualization_data',
'type': 'نوع', 'model': 'النموذج', 'count': 'العدد', 'filters': 'الفلاتر_المطبقة', 'field': 'الحقل' if language == 'ar' else 'field',
'error': 'خطأ', 'chart_type': 'نوع_الرسم_البياني', 'labels': 'التسميات', 'data': 'البيانات', 'value': 'القيمة' if language == 'ar' else 'value',
'visualization_data': 'بيانات_الرسم_البياني', 'field': 'الحقل', 'value': 'القيمة', 'statistic_type': 'نوع_الإحصاء' if language == 'ar' else 'statistic_type',
'statistic_type': 'نوع_الإحصاء', 'results': 'النتائج', 'title': 'العنوان' 'results': 'النتائج' if language == 'ar' else 'results',
} 'title': 'العنوان' if language == 'ar' else 'title',
else: }
return {
'type': 'type', 'model': 'model', 'count': 'count', 'filters': 'filters_applied',
'error': 'error', 'chart_type': 'chart_type', 'labels': 'labels', 'data': 'data',
'visualization_data': 'visualization_data', 'field': 'field', 'value': 'value',
'statistic_type': 'statistic_type', 'results': 'results', 'title': 'title'
}
def generate_count_insight(models, query_params, dealer_id=None, language='en'): def generate_count_insight(models, query_params, dealer_id=None, language='en'):
"""
Generate count insights for the specified models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters for filtering
:type query_params: dict
:param dealer_id: Dealer ID for filtering
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Count insights
:rtype: dict
"""
keys = _localized_keys(language) keys = _localized_keys(language)
results = [] results = []
@ -59,7 +41,9 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
filters = {} filters = {}
for key, value in query_params.items(): for key, value in query_params.items():
if key not in ['field', 'operation'] and hasattr(model, key): if key in ['field', 'operation']:
continue
if hasattr(model, key):
try: try:
field = model._meta.get_field(key) field = model._meta.get_field(key)
if isinstance(field, models.IntegerField): if isinstance(field, models.IntegerField):
@ -76,13 +60,12 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
results.append({ results.append({
keys['model']: model.__name__, keys['model']: model.__name__,
keys['count']: queryset.count(), keys['count']: queryset.count(),
keys['filters']: filters keys['filters']: filters,
}) })
except Exception as e: except Exception as e:
results.append({ results.append({
keys['model']: model.__name__, keys['model']: model.__name__,
keys['error']: str(e) keys['error']: str(e),
}) })
return { return {
@ -91,30 +74,17 @@ def generate_count_insight(models, query_params, dealer_id=None, language='en'):
keys['visualization_data']: { keys['visualization_data']: {
keys['chart_type']: 'bar', keys['chart_type']: 'bar',
keys['labels']: [r[keys['model']] for r in results if keys['count'] in r], keys['labels']: [r[keys['model']] for r in results if keys['count'] in r],
keys['data']: [r[keys['count']] for r in results if keys['count'] in r] keys['data']: [r[keys['count']] for r in results if keys['count'] in r],
} }
} }
def generate_statistics_insight(models, query_params, dealer_id=None, language='en'): def generate_statistics_insight(models, query_params, dealer_id=None, language='en'):
"""
Generate statistical insights for the specified models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters for filtering
:type query_params: dict
:param dealer_id: Dealer ID for filtering
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Statistical insights
:rtype: dict
"""
keys = _localized_keys(language) keys = _localized_keys(language)
results = [] results = []
field = query_params.get('field') field = query_params.get('field')
operation = query_params.get('operation', 'average') operation = query_params.get('operation', 'average')
stat_map = {'average': Avg, 'sum': Sum, 'max': Max, 'min': Min}
for model in models: for model in models:
try: try:
@ -128,37 +98,28 @@ def generate_statistics_insight(models, query_params, dealer_id=None, language='
elif hasattr(model, 'dealer'): elif hasattr(model, 'dealer'):
queryset = queryset.filter(dealer=dealer_id) queryset = queryset.filter(dealer=dealer_id)
filters = {} filters = {
for k, v in query_params.items(): k: v for k, v in query_params.items()
if k not in ['field', 'operation'] and hasattr(model, k): if k not in ['field', 'operation'] and hasattr(model, k)
filters[k] = v }
if filters: if filters:
queryset = queryset.filter(**filters) queryset = queryset.filter(**filters)
stat_map = { value = queryset.aggregate(val=stat_map.get(operation, Count)(field))['val']
'average': Avg,
'sum': Sum,
'max': Max,
'min': Min
}
if operation in stat_map:
agg = queryset.aggregate(val=stat_map[operation](field))['val']
value = agg
else:
value = queryset.count()
results.append({ results.append({
keys['model']: model.__name__, keys['model']: model.__name__,
keys['field']: field, keys['field']: field,
keys['statistic_type']: operation, keys['statistic_type']: operation,
keys['value']: value, keys['value']: value,
keys['filters']: filters keys['filters']: filters,
}) })
except Exception as e: except Exception as e:
results.append({keys['model']: model.__name__, keys['error']: str(e)}) results.append({
keys['model']: model.__name__,
keys['error']: str(e),
})
return { return {
keys['type']: keys['type'] + '_analysis', keys['type']: keys['type'] + '_analysis',
@ -173,18 +134,6 @@ def generate_statistics_insight(models, query_params, dealer_id=None, language='
def generate_recommendations(model_classes, analysis_type, language='en'): def generate_recommendations(model_classes, analysis_type, language='en'):
"""
Generate recommendations based on model analysis.
:param model_classes: List of models to analyze
:type model_classes: list
:param analysis_type: Type of analysis
:type analysis_type: str
:param language: Language code ('en' or 'ar')
:type language: str
:return: List of recommendations
:rtype: list
"""
recs = [] recs = []
for model in model_classes: for model in model_classes:
for field in model._meta.fields: for field in model._meta.fields:
@ -198,34 +147,20 @@ def generate_recommendations(model_classes, analysis_type, language='en'):
def generate_model_insight(model, dealer_id=None, language='en'): def generate_model_insight(model, dealer_id=None, language='en'):
"""
Generate insights for a specific model.
:param model: Model to analyze
:type model: Model class
:param dealer_id: Dealer ID for filtering
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Model insights
:rtype: dict
"""
keys = _localized_keys(language) keys = _localized_keys(language)
fields_info = [ fields_info = [{
{ 'name': f.name,
'name': f.name, 'type': f.__class__.__name__,
'type': f.__class__.__name__, 'null': f.null,
'null': f.null, 'blank': f.blank,
'blank': f.blank, 'unique': f.unique,
'unique': f.unique, 'pk': f.primary_key
'pk': f.primary_key } for f in model._meta.fields]
} for f in model._meta.fields
]
try: try:
qs = model.objects.all() qs = model.objects.all()
if dealer_id: if dealer_id:
if hasattr(model, 'dealer_id'): if hasattr(model, 'dealer'):
qs = qs.filter(dealer_id=dealer_id) qs = qs.filter(dealer_id=dealer_id)
elif hasattr(model, 'dealer'): elif hasattr(model, 'dealer'):
qs = qs.filter(dealer=dealer_id) qs = qs.filter(dealer=dealer_id)
@ -242,20 +177,6 @@ def generate_model_insight(model, dealer_id=None, language='en'):
def generate_relationship_insight(models, query_params=None, dealer_id=None, language='en'): def generate_relationship_insight(models, query_params=None, dealer_id=None, language='en'):
"""
Generate relationship insights between models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters (unused)
:type query_params: dict
:param dealer_id: Dealer ID (unused)
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Relationship insights
:rtype: dict
"""
from_ = "من" if language == 'ar' else "from" from_ = "من" if language == 'ar' else "from"
to_ = "إلى" if language == 'ar' else "to" to_ = "إلى" if language == 'ar' else "to"
rel_type = "نوع" if language == 'ar' else "type" rel_type = "نوع" if language == 'ar' else "type"
@ -267,7 +188,7 @@ def generate_relationship_insight(models, query_params=None, dealer_id=None, lan
relationships.append({ relationships.append({
from_: model.__name__, from_: model.__name__,
to_: field.related_model.__name__, to_: field.related_model.__name__,
rel_type: field.__class__.__name__ rel_type: field.__class__.__name__,
}) })
for field in model._meta.many_to_many: for field in model._meta.many_to_many:
relationships.append({ relationships.append({
@ -283,37 +204,24 @@ def generate_relationship_insight(models, query_params=None, dealer_id=None, lan
def generate_performance_insight(models, query_params=None, dealer_id=None, language='en'): def generate_performance_insight(models, query_params=None, dealer_id=None, language='en'):
"""
Generate performance insights for models.
:param models: List of models to analyze
:type models: list
:param query_params: Query parameters (unused)
:type query_params: dict
:param dealer_id: Dealer ID (unused)
:type dealer_id: int
:param language: Language code ('en' or 'ar')
:type language: str
:return: Performance insights
:rtype: dict
"""
issues = [] issues = []
for model in models: for model in models:
for field in model._meta.fields: for field in model._meta.fields:
if isinstance(field, ForeignKey) and not field.db_index: if isinstance(field, ForeignKey) and not field.db_index:
issues.append({ issues.append({
'model': model.__name__, # 'model': model.__name__,
'field': field.name, 'field': field.name,
'issue': 'Missing index on ForeignKey' 'issue': 'Missing index on ForeignKey'
}) })
if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']: # if isinstance(field, models.CharField) and not field.db_index and field.name in ['name', 'title']:
issues.append({ # issues.append({
'model': model.__name__, # 'model': model.__name__,
'field': field.name, # 'field': field.name,
'issue': 'Unindexed CharField used in filtering' # 'issue': 'Unindexed CharField used in filtering'
}) # })
return { return {
'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis', 'type': 'تحليل_الأداء' if language == 'ar' else 'performance_analysis',
'issues': issues 'issues': issues
} }

View File

@ -1,93 +1,53 @@
import hashlib
import logging
from django.utils import timezone from django.utils import timezone
from django.db import models from django.db import models
from ..models import AnalysisCache from ..models import AnalysisCache
import hashlib
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CacheService: class CacheService:
"""
Service for handling analysis result caching operations.
This service provides methods for generating cache keys, retrieving
cached results, and storing new results in the cache.
"""
def generate_hash(self, prompt, dealer_id, language): def generate_hash(self, prompt, dealer_id, language):
""" """
Generate a unique hash for the prompt, dealer, and language combination. Generate a unique MD5 hash based on the prompt, dealer ID, and language.
:param prompt: The user's prompt text
:type prompt: str
:param dealer_id: The dealer's ID
:type dealer_id: int
:param language: The language code
:type language: str
:return: MD5 hash string
:rtype: str
""" """
cache_key = f"{prompt}:{dealer_id or 'all'}:{language}" key = f"{prompt}:{dealer_id or 'all'}:{language}"
return hashlib.md5(cache_key.encode()).hexdigest() return hashlib.md5(key.encode()).hexdigest()
def get_cached_result(self, prompt_hash, user, dealer_id): def get_cached_result(self, prompt_hash, user, dealer_id):
""" """
Retrieve a cached result if available and not expired. Retrieve a cached analysis result based on hash, dealer, and optionally user.
:param prompt_hash: The hash key for the cache entry
:type prompt_hash: str
:param user: The user making the request
:type user: User
:param dealer_id: The dealer's ID
:type dealer_id: int
:return: Cached result or None if not found
:rtype: dict or None
""" """
try: try:
cache_entry = AnalysisCache.objects.filter( # Check for user-specific cache if authenticated
prompt_hash=prompt_hash,
dealer_id=dealer_id,
expires_at__gt=timezone.now()
).first()
# If user is authenticated, also check user-specific cache
if user and user.is_authenticated: if user and user.is_authenticated:
user_cache = AnalysisCache.objects.filter( user_cache = AnalysisCache.objects.filter(
prompt_hash=prompt_hash, prompt_hash=prompt_hash,
user=user, user=user,
expires_at__gt=timezone.now() expires_at__gt=timezone.now()
).first() ).first()
# User-specific cache takes precedence
if user_cache: if user_cache:
return user_cache.result return user_cache.result
return cache_entry.result if cache_entry else None # Otherwise check for dealer-wide cache
dealer_cache = AnalysisCache.objects.filter(
prompt_hash=prompt_hash,
dealer_id=dealer_id,
expires_at__gt=timezone.now()
).first()
return dealer_cache.result if dealer_cache else None
except Exception as e: except Exception as e:
logger.warning(f"Error retrieving cache: {str(e)}") logger.warning(f"Cache retrieval failed: {str(e)}")
return None return None
def cache_result(self, prompt_hash, result, user, dealer_id, duration=3600): def cache_result(self, prompt_hash, result, user, dealer_id, duration=3600):
""" """
Store a result in the cache. Save or update a cached result with an expiration timestamp.
:param prompt_hash: The hash key for the cache entry
:type prompt_hash: str
:param result: The result to cache
:type result: dict
:param user: The user making the request
:type user: User
:param dealer_id: The dealer's ID
:type dealer_id: int
:param duration: Cache duration in seconds
:type duration: int
:return: None
""" """
try: try:
# Calculate expiration time
expires_at = timezone.now() + timezone.timedelta(seconds=duration) expires_at = timezone.now() + timezone.timedelta(seconds=duration)
# Create or update cache entry
AnalysisCache.objects.update_or_create( AnalysisCache.objects.update_or_create(
prompt_hash=prompt_hash, prompt_hash=prompt_hash,
user=user if user and user.is_authenticated else None, user=user if user and user.is_authenticated else None,
@ -98,4 +58,4 @@ class CacheService:
} }
) )
except Exception as e: except Exception as e:
logger.warning(f"Error caching result: {str(e)}") logger.warning(f"Cache saving failed: {str(e)}")

View File

@ -1,25 +1,18 @@
import json
import logging
from django.apps import apps
from django.http import JsonResponse
from django.db.models import Count, Avg, Max, Min
from langchain_ollama import OllamaLLM from langchain_ollama import OllamaLLM
from langchain.chains import LLMChain from langchain.chains import LLMChain
from langchain.prompts import PromptTemplate from langchain.prompts import PromptTemplate
from django.conf import settings from django.conf import settings
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_llm_instance(): def get_llm_instance():
"""
Initialize and return an Ollama LLM instance configured for Arabic support.
This function creates a new LLM instance with optimized parameters for
both Arabic and English language processing. It reads configuration from
Django settings or uses sensible defaults.
:return: Configured OllamaLLM instance or None if initialization fails
:rtype: OllamaLLM or None
"""
try: try:
# Get settings from Django settings or use defaults
base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434') base_url = getattr(settings, 'OLLAMA_BASE_URL', 'http://localhost:11434')
model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b') model = getattr(settings, 'OLLAMA_MODEL', 'qwen3:8b')
temperature = getattr(settings, 'OLLAMA_TEMPERATURE', 0.2) temperature = getattr(settings, 'OLLAMA_TEMPERATURE', 0.2)
@ -27,7 +20,7 @@ def get_llm_instance():
top_k = getattr(settings, 'OLLAMA_TOP_K', 40) top_k = getattr(settings, 'OLLAMA_TOP_K', 40)
num_ctx = getattr(settings, 'OLLAMA_NUM_CTX', 4096) num_ctx = getattr(settings, 'OLLAMA_NUM_CTX', 4096)
num_predict = getattr(settings, 'OLLAMA_NUM_PREDICT', 2048) num_predict = getattr(settings, 'OLLAMA_NUM_PREDICT', 2048)
return OllamaLLM( return OllamaLLM(
base_url=base_url, base_url=base_url,
model=model, model=model,
@ -45,23 +38,10 @@ def get_llm_instance():
def get_llm_chain(language='en'): def get_llm_chain(language='en'):
"""
Create a LangChain for analyzing prompts in Arabic or English.
This function creates a chain that processes user prompts and extracts
structured information about the analysis request. It supports both
Arabic and English languages.
:param language: Language code ('en' or 'ar')
:type language: str
:return: LangChain for prompt analysis or None if initialization fails
:rtype: LLMChain or None
"""
llm = get_llm_instance() llm = get_llm_instance()
if not llm: if not llm:
return None return None
# Define the prompt template based on language
if language == 'ar': if language == 'ar':
template = """ template = """
قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام. قم بتحليل الاستعلام التالي وتحديد نوع التحليل المطلوب ونماذج البيانات المستهدفة وأي معلمات استعلام.
@ -89,11 +69,82 @@ def get_llm_chain(language='en'):
} }
""" """
# Create the prompt template
prompt_template = PromptTemplate( prompt_template = PromptTemplate(
input_variables=["prompt"], input_variables=["prompt"],
template=template template=template
) )
# Create and return the LLM chain
return prompt_template | llm return prompt_template | llm
def analyze_models_with_orm(analysis_type, target_models, query_params):
results = {}
for model_name in target_models:
try:
model = apps.get_model('your_app_name', model_name)
except LookupError:
results[model_name] = {"error": f"Model '{model_name}' not found"}
continue
try:
queryset = model.objects.filter(**query_params)
if analysis_type == 'count':
results[model_name] = {'count': queryset.count()}
elif analysis_type == 'statistics':
numeric_fields = [f.name for f in model._meta.fields if f.get_internal_type() in ['IntegerField', 'FloatField', 'DecimalField']]
stats = {}
for field in numeric_fields:
stats[field] = {
'avg': queryset.aggregate(avg=Avg(field))['avg'],
'max': queryset.aggregate(max=Max(field))['max'],
'min': queryset.aggregate(min=Min(field))['min']
}
results[model_name] = stats
elif analysis_type == 'relationship':
related = {}
for field in model._meta.get_fields():
if field.is_relation and field.many_to_one:
related[field.name] = queryset.values(field.name).annotate(count=Count(field.name)).count()
results[model_name] = related
elif analysis_type == 'performance':
results[model_name] = {'note': 'Performance analysis logic not implemented.'}
else:
results[model_name] = list(queryset.values())
except Exception as e:
results[model_name] = {'error': str(e)}
return results
def analyze_prompt_and_return_json(request):
try:
prompt = request.POST.get('prompt')
language = request.POST.get('language', 'en')
chain = get_llm_chain(language)
if not chain:
return JsonResponse({'success': False, 'error': 'LLM not initialized'})
result = chain.invoke({'prompt': prompt})
parsed = json.loads(result)
analysis_type = parsed.get('analysis_type')
target_models = parsed.get('target_models', [])
query_params = parsed.get('query_params', {})
if not analysis_type or not target_models:
return JsonResponse({'success': False, 'error': 'Incomplete analysis instruction returned by LLM'})
orm_results = analyze_models_with_orm(analysis_type, target_models, query_params)
return JsonResponse({'success': True, 'data': orm_results})
except Exception as e:
return JsonResponse({'success': False, 'error': str(e)})

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -3,455 +3,521 @@
{% block title %} {% block title %}
{{ _("Haikalbot") }} {{ _("Haikalbot") }}
{% endblock %} {% endblock title %}
{% block description %} {% block description %}
AI assistant AI assistant
{% endblock %} {% endblock description %}
{% block customCSS %} {% block customCSS %}
<!-- No custom CSS as requested --> <style>
{% endblock %} .chart-container {
width: 100%;
margin-top: 1rem;
}
.chat-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.chat-textarea {
border-radius: 20px;
padding: 15px;
resize: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border: 1px solid #dee2e6;
transition: all 0.3s ease;
}
.chat-textarea:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
outline: none;
}
.send-button {
border-radius: 20px;
padding: 10px 25px;
font-weight: 500;
transition: all 0.3s ease;
}
.textarea-container {
position: relative;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 0.8rem;
color: #6c757d;
}
.character-count.warning {
color: #dc3545;
font-weight: bold;
}
</style>
{% endblock customCSS %}
{% block content %} {% block content %}
<style> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
.chat-container { <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.chat-textarea {
border-radius: 20px;
padding: 15px;
resize: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border: 1px solid #dee2e6;
transition: all 0.3s ease;
}
.chat-textarea:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
outline: none;
}
.send-button {
border-radius: 20px;
padding: 10px 25px;
font-weight: 500;
transition: all 0.3s ease;
}
.textarea-container {
position: relative;
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 0.8rem;
color: #6c757d;
}
</style>
<div class="card shadow-none mb-3"> <div class="card shadow-none mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center fw-bolder fs-3 d-inline-block"> <div class="d-flex align-items-center fw-bolder fs-3 d-inline-block">
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" alt="{% trans 'home' %}" width="32" /> <img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" alt="{% trans 'home' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" alt="{% trans 'home' %}" width="32" /> <img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" alt="{% trans 'home' %}" width="32" />
</div> </div>
<div class="d-flex gap-3">
<span id="clearChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}مسح المحادثة{% else %}Clear Chat{% endif %}">
<div class="d-flex gap-3"> <i class="fas fa-trash-alt text-danger"></i>
<span id="clearChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}مسح المحادثة{% else %}Clear Chat{% endif %}"> </span>
<i class="fas fa-trash-alt text-danger"></i> <span id="exportChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}تصدير المحادثة{% else %}Export Chat{% endif %}">
</span> <i class="fas fa-download text-success"></i>
<span id="exportChatBtn" class="translate-middle-y cursor-pointer" title="{% if LANGUAGE_CODE == 'ar' %}تصدير المحادثة{% else %}Export Chat{% endif %}"> </span>
<i class="fas fa-download text-success"></i> </div>
</span>
</div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="chatMessages" class="overflow-auto p-3" style="height: 60vh;"> <div id="chatMessages" class="overflow-auto p-3" style="height: 60vh;"></div>
<!-- Chat messages will be appended here -->
</div>
<div class="bg-100 border-top p-3"> <div class="bg-100 border-top p-3">
<div class="d-flex gap-2 flex-wrap mb-3" id="suggestionChips"> <div class="d-flex gap-2 flex-wrap mb-3" id="suggestionChips">
<button class="btn btn-sm btn-outline-primary suggestion-chip"> <button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("How many cars are in inventory")}}?</button>
{{ _("How many cars are in inventory")}}? <button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("Show me sales analysis")}}</button>
</button> <button class="btn btn-sm btn-outline-primary suggestion-chip">{{ _("What are the best-selling cars")}}?</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">
{{ _("Show me sales analysis")}}
</button>
<button class="btn btn-sm btn-outline-primary suggestion-chip">
{{ _("What are the best-selling cars")}}?
</button>
</div> </div>
<div class="chat-container"> <div class="chat-container">
<div class="textarea-container mb-3">
<label for="messageInput"></label>
<div class="textarea-container mb-3"> <textarea class="form-control chat-textarea" id="messageInput" rows="3" placeholder="{{ _("Type your message here")}}..." maxlength="400"></textarea>
<label for="messageInput"></label> <div class="textarea-footer">
<textarea class="form-control chat-textarea" id="messageInput" rows="3" <div class="character-count">
placeholder="{{ _("Type your message here")}}..."></textarea> <span id="charCount">0</span>/400
<div class="textarea-footer">
<div class="character-count">
<span id="charCount">0</span>/400
</div>
<span class="send-button position-absolute top-50 end-0 translate-middle-y cursor-pointer"
id="sendMessageBtn" disabled>
<i class="fas fa-paper-plane text-body"></i>
</span>
</div>
</div>
</div> </div>
<span class="send-button position-absolute top-50 end-0 translate-middle-y cursor-pointer" id="sendMessageBtn" disabled>
<i class="fas fa-paper-plane text-body"></i>
</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script> <script>
$(document).ready(function() { // Global configuration
const messageInput = $('#messageInput'); const MAX_MESSAGE_LENGTH = 400;
const charCount = $('#charCount'); const WARNING_THRESHOLD = 350;
const sendMessageBtn = $('#sendMessageBtn'); const isArabic = '{{ LANGUAGE_CODE }}' === 'ar';
const chatMessages = $('#chatMessages');
const clearChatBtn = $('#clearChatBtn');
const exportChatBtn = $('#exportChatBtn');
const suggestionChips = $('.suggestion-chip');
// Enable/disable send button based on input // Chart rendering function
messageInput.on('input', function() { function renderInsightChart(labels, data, chartType = 'bar', title = 'Insight Chart') {
sendMessageBtn.prop('disabled', !messageInput.val().trim()); const canvasId = 'chart_' + Date.now();
const chartHtml = `<div class="chart-container"><canvas id="${canvasId}"></canvas></div>`;
$('#chatMessages').append(chartHtml);
// Auto-resize textarea new Chart(document.getElementById(canvasId), {
this.style.height = 'auto'; type: chartType,
this.style.height = (this.scrollHeight) + 'px'; data: {
}); labels: labels,
datasets: [{
label: title,
data: data,
borderWidth: 1,
backgroundColor: 'rgba(75, 192, 192, 0.5)',
borderColor: 'rgba(75, 192, 192, 1)',
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
title: { display: true, text: title }
},
scales: {
y: { beginAtZero: true }
}
}
});
}
// Send message on Enter key (but allow Shift+Enter for new line) $(document).ready(function() {
messageInput.on('keydown', function(e) { // DOM elements
if (e.key === 'Enter' && !e.shiftKey) { const messageInput = $('#messageInput');
e.preventDefault(); const charCount = $('#charCount');
if (!sendMessageBtn.prop('disabled')) { const sendMessageBtn = $('#sendMessageBtn');
sendMessage(); const chatMessages = $('#chatMessages');
} const clearChatBtn = $('#clearChatBtn');
const exportChatBtn = $('#exportChatBtn');
const suggestionChips = $('.suggestion-chip');
// Initialize character count
updateCharacterCount();
// Event handlers
messageInput.on('input', handleInput);
messageInput.on('keydown', handleKeyDown);
sendMessageBtn.on('click', sendMessage);
suggestionChips.on('click', handleSuggestionClick);
clearChatBtn.on('click', clearChat);
exportChatBtn.on('click', exportChat);
// Input handling
function handleInput() {
updateCharacterCount();
autoResizeTextarea();
toggleSendButton();
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!sendMessageBtn.prop('disabled')) {
sendMessage();
}
}
}
function handleSuggestionClick() {
messageInput.val($(this).text().trim());
updateCharacterCount();
autoResizeTextarea();
sendMessageBtn.prop('disabled', false);
sendMessage();
}
// UI utilities
function updateCharacterCount() {
const currentLength = messageInput.val().length;
charCount.text(currentLength);
if (currentLength > WARNING_THRESHOLD) {
charCount.parent().addClass('warning');
} else {
charCount.parent().removeClass('warning');
}
}
function autoResizeTextarea() {
messageInput[0].style.height = 'auto';
messageInput[0].style.height = (messageInput[0].scrollHeight) + 'px';
}
function toggleSendButton() {
sendMessageBtn.prop('disabled', !messageInput.val().trim());
}
// Chat actions
function sendMessage() {
const message = messageInput.val().trim();
if (!message) return;
// Add user message to chat
addMessage(message, true);
// Clear input and reset UI
resetInputUI();
// Show typing indicator
showTypingIndicator();
// Send to backend
$.ajax({
url: '{% url "haikalbot:haikalbot" %}',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
prompt: message,
language: '{{ LANGUAGE_CODE }}'
}),
headers: {
'X-CSRFToken': '{{ csrf_token }}'
},
success: function(response) {
processBotResponse(response);
},
error: function(xhr, status, error) {
handleRequestError(error);
} }
}); });
}
// Send message on button click function resetInputUI() {
sendMessageBtn.on('click', sendMessage); messageInput.val('').css('height', 'auto');
charCount.text('0').parent().removeClass('warning');
sendMessageBtn.prop('disabled', true);
}
// Use suggestion chips function processBotResponse(response) {
suggestionChips.on('click', function() { hideTypingIndicator();
messageInput.val($(this).text().trim());
sendMessageBtn.prop('disabled', false);
sendMessage();
});
// Clear chat // Debug response structure
clearChatBtn.on('click', function() { console.log("API Response:", response);
if (confirm('{% if LANGUAGE_CODE == "ar" %}هل أنت متأكد من أنك تريد مسح المحادثة؟{% else %}Are you sure you want to clear the chat?{% endif %}')) {
// Keep only the first welcome message
const welcomeMessage = chatMessages.children().first();
chatMessages.empty().append(welcomeMessage);
}
});
// Export chat let botResponse = '';
exportChatBtn.on('click', function() {
let chatContent = '';
$('.message').each(function() {
const isUser = $(this).hasClass('user-message');
const sender = isUser ? '{% if LANGUAGE_CODE == "ar" %}أنت{% else %}You{% endif %}' : '{% if LANGUAGE_CODE == "ar" %}المساعد الذكي{% else %}AI Assistant{% endif %}';
const text = $(this).find('.chat-message').text().trim();
const time = $(this).find('.text-400').text().trim();
chatContent += `${sender} (${time}):\n${text}\n\n`; // Check for direct response first
}); if (response.response) {
botResponse = response.response;
// Create and trigger download }
const blob = new Blob([chatContent], { type: 'text/plain' }); // Then check for insights data
const url = URL.createObjectURL(blob); else if (hasInsightsData(response)) {
const a = document.createElement('a'); botResponse = formatInsightsResponse(response);
a.href = url; }
a.download = 'chat-export-' + new Date().toISOString().slice(0, 10) + '.txt'; // Fallback
document.body.appendChild(a); else {
a.click(); botResponse = isArabic ?
document.body.removeChild(a); 'عذرًا، لم أتمكن من معالجة طلبك. يبدو أن هيكل الاستجابة غير متوقع.' :
URL.revokeObjectURL(url); 'Sorry, I couldn\'t process your request. The response structure appears unexpected.';
}); console.error("Unexpected response structure:", response);
// Copy message text
$(document).on('click', '.copy-btn', function() {
const text = $(this).closest('.d-flex').find('.chat-message').text().trim();
navigator.clipboard.writeText(text).then(() => {
// Show temporary success indicator
const originalIcon = $(this).html();
$(this).html('<i class="fas fa-check"></i>');
setTimeout(() => {
$(this).html(originalIcon);
}, 1500);
});
});
// Function to send message
function sendMessage() {
const message = messageInput.val().trim();
if (!message) return;
// Add user message to chat
addMessage(message, true);
// Clear input and reset height
messageInput.val('').css('height', 'auto');
sendMessageBtn.prop('disabled', true);
// Show typing indicator
showTypingIndicator();
// Send to backend
$.ajax({
url: '{% url "haikalbot:haikalbot" %}',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
prompt: message,
language: '{{ LANGUAGE_CODE }}'
}),
headers: {
'X-CSRFToken': '{{ csrf_token }}'
},
success: function(response) {
// Hide typing indicator
hideTypingIndicator();
// Process response
let botResponse = '';
if (response.response) {
botResponse = response.response;
} else if (response.insights) {
// Format insights as a readable response
botResponse = formatInsightsResponse(response);
} else {
botResponse = '{% if LANGUAGE_CODE == "ar" %}عذرًا، لم أتمكن من معالجة طلبك.{% else %}Sorry, I couldn\'t process your request.{% endif %}';
}
// Add bot response to chat
addMessage(botResponse, false);
// Scroll to bottom
scrollToBottom();
},
error: function(xhr, status, error) {
// Hide typing indicator
hideTypingIndicator();
// Add error message
const errorMsg = '{% if LANGUAGE_CODE == "ar" %}عذرًا، حدث خطأ أثناء معالجة طلبك. يرجى المحاولة مرة أخرى.{% else %}Sorry, an error occurred while processing your request. Please try again.{% endif %}';
addMessage(errorMsg, false);
console.error('Error:', error);
}
});
} }
// Function to add message to chat addMessage(botResponse, false);
function addMessage(text, isUser) {
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageClass = isUser ? 'user-message justify-content-end' : '';
let avatarHtml = '';
let messageHtml = '';
if (isUser) {
// User message
avatarHtml = `
<div class="avatar avatar-l ms-3 order-1">
<div class="avatar-name rounded-circle bg-primary text-white"><span><i class="fas fa-user"></i></span></div>
</div>
`;
// Process text (no markdown for user messages)
messageHtml = `
<div class="flex-1 order-0">
<div class="w-xxl-75 ms-auto">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="hover-actions start-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="{% if LANGUAGE_CODE == 'ar' %}نسخ{% else %}Copy{% endif %}">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="chat-message p-3 rounded-2">
${text}
</div>
</div>
<div class="text-400 fs--2">
${time}
</div>
</div>
</div>
`;
} else {
// Bot message
avatarHtml = `
<div class="me-3">
<div class="d-flex align-items-center fw-bolder fs-3 d-inline-block">
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" width="32" />
</div>
</div>
`;
// Process markdown for bot messages
const processedText = marked.parse(text);
messageHtml = `
<div class="flex-1">
<div class="w-xxl-75">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="chat-message bg-200 p-3 rounded-2">
${processedText}
</div>
<div class="hover-actions end-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="{{_("Copy")}}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="text-400 fs--2 text-end">
${time}
</div>
</div>
</div>
`;
}
const fullMessageHtml = `
<div class="message d-flex mb-3 ${messageClass}">
${avatarHtml}
${messageHtml}
</div>
`;
chatMessages.append(fullMessageHtml);
scrollToBottom();
}
// Function to show typing indicator
function showTypingIndicator() {
const typingHtml = `
<div class="message d-flex mb-3" id="typingIndicator">
<div class="avatar avatar-l me-3">
<div class="avatar-name rounded-circle"><span><i class="fas fa-robot"></i></span></div>
</div>
<div class="flex-1 d-flex align-items-center">
<div class="spinner-border text-phoenix-secondary me-2" role="status"></div>
<span class="fs-9">{% if LANGUAGE_CODE == 'ar' %}جاري الكتابة...{% else %}Typing...{% endif %}</span>
</div>
</div>
`;
chatMessages.append(typingHtml);
scrollToBottom();
}
// Function to hide typing indicator
function hideTypingIndicator() {
$('#typingIndicator').remove();
}
// Function to scroll chat to bottom
function scrollToBottom() {
chatMessages.scrollTop(chatMessages[0].scrollHeight);
}
// Function to format insights response
function formatInsightsResponse(response) {
let formattedResponse = '';
const insightsKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'التحليلات' : 'insights';
const recsKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'التوصيات' : 'recommendations';
if (response[insightsKey] && response[insightsKey].length > 0) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? '## نتائج التحليل\n\n' : '## Analysis Results\n\n';
response[insightsKey].forEach(insight => {
if (insight.type) {
formattedResponse += `### ${insight.type}\n\n`;
}
if (insight.results) {
insight.results.forEach(result => {
if (result.error) {
formattedResponse += `- **${result.model || ''}**: ${result.error}\n`;
} else if (result.count !== undefined) {
formattedResponse += `- **${result.model || ''}**: ${result.count}\n`;
} else if (result.value !== undefined) {
const fieldKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'الحقل' : 'field';
const statTypeKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'نوع_الإحصاء' : 'statistic_type';
formattedResponse += `- **${result.model || ''}**: ${result[statTypeKey]} of ${result[fieldKey]} = ${result.value}\n`;
}
});
formattedResponse += '\n';
}
if (insight.relationships) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? ' العلاقات:\n\n' : ' Relationships:\n\n';
insight.relationships.forEach(rel => {
const fromKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'من' : 'from';
const toKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'إلى' : 'to';
const typeKey = '{{ LANGUAGE_CODE }}' === 'ar' ? 'نوع' : 'type';
formattedResponse += `- ${rel[fromKey]} → ${rel[toKey]} (${rel[typeKey]})\n`;
});
formattedResponse += '\n';
}
});
}
if (response[recsKey] && response[recsKey].length > 0) {
formattedResponse += '{{ LANGUAGE_CODE }}' === 'ar' ? ' التوصيات\n\n' : ' Recommendations\n\n';
response[recsKey].forEach(rec => {
formattedResponse += `- ${rec}\n`;
});
}
return formattedResponse || '{{ LANGUAGE_CODE }}' === 'ar' ? 'تم تحليل البيانات بنجاح.' : 'Data analyzed successfully.';
}
// Initialize
scrollToBottom(); scrollToBottom();
}); }
messageInput.addEventListener('input', function() {
const currentLength = this.value.length;
charCount.textContent = currentLength;
// Optional: Add warning when approaching limit function hasInsightsData(response) {
if (currentLength > 350) { return response.insights || response['التحليلات'] ||
charCount.style.color = 'red'; response.recommendations || response['التوصيات'];
} else { }
charCount.style.color = 'inherit';
} function handleRequestError(error) {
hideTypingIndicator();
const errorMsg = isArabic ?
'عذرًا، حدث خطأ أثناء معالجة طلبك. يرجى المحاولة مرة أخرى.' :
'Sorry, an error occurred while processing your request. Please try again.';
addMessage(errorMsg, false);
console.error('API Error:', error);
}
// Chat management
function clearChat() {
if (confirm(isArabic ?
'هل أنت متأكد من أنك تريد مسح المحادثة؟' :
'Are you sure you want to clear the chat?')) {
const welcomeMessage = chatMessages.children().first();
chatMessages.empty().append(welcomeMessage);
}
}
function exportChat() {
let chatContent = '';
$('.message').each(function() {
const isUser = $(this).hasClass('user-message');
const sender = isUser ?
(isArabic ? 'أنت' : 'You') :
(isArabic ? 'المساعد الذكي' : 'AI Assistant');
const text = $(this).find('.chat-message').text().trim();
const time = $(this).find('.text-400').text().trim();
chatContent += `${sender} (${time}):\n${text}\n\n`;
}); });
downloadTextFile(chatContent, 'chat-export-' + new Date().toISOString().slice(0, 10) + '.txt');
}
function downloadTextFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Message display functions
function addMessage(text, isUser) {
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageClass = isUser ? 'user-message justify-content-between' : '';
const avatarHtml = getAvatarHtml(isUser);
const messageHtml = getMessageHtml(text, isUser, time);
const fullMessageHtml = `
<div class="message d-flex mb-3 ${messageClass}">
${avatarHtml}
${messageHtml}
</div>
`;
chatMessages.append(fullMessageHtml);
scrollToBottom();
}
function getAvatarHtml(isUser) {
if (isUser) {
return `
<div class="avatar avatar-l ms-3 order-1">
<div class="avatar-name rounded-circle">
<span><i class="fas fa-user"></i></span>
</div>
</div>
`;
}
return `
<div class="me-3">
<div class="d-flex align-items-center fw-bolder fs-3 d-inline-block">
<img class="d-dark-none" src="{% static 'images/favicons/haikalbot_v1.png' %}" width="32" />
<img class="d-light-none" src="{% static 'images/favicons/haikalbot_v2.png' %}" width="32" />
</div>
</div>
`;
}
function getMessageHtml(text, isUser, time) {
if (isUser) {
return `
<div class="flex-1 order-0">
<div class="w-xxl-75 ms-auto">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="hover-actions start-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="${isArabic ? 'نسخ' : 'Copy'}">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="chat-message p-3 rounded-2">
${text}
</div>
</div>
<div class="text-400 fs--2">
${time}
</div>
</div>
</div>
`;
}
const processedText = marked.parse(text);
return `
<div class="flex-1">
<div class="w-xxl-75">
<div class="d-flex hover-actions-trigger align-items-center">
<div class="chat-message bg-200 p-3 rounded-2">
${processedText}
</div>
<div class="hover-actions end-0 top-50 translate-middle-y">
<button class="btn btn-phoenix-secondary btn-icon fs--2 round-btn copy-btn" type="button" title="${isArabic ? 'نسخ' : 'Copy'}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="text-400 fs--2 text-end">
${time}
</div>
</div>
</div>
`;
}
function showTypingIndicator() {
const typingHtml = `
<div class="message d-flex mb-3" id="typingIndicator">
<div class="avatar avatar-l me-3">
<div class="avatar-name rounded-circle">
<span><i class="fas fa-robot"></i></span>
</div>
</div>
<div class="flex-1 d-flex align-items-center">
<div class="spinner-border text-phoenix-secondary me-2" role="status"></div>
<span class="fs-9">${isArabic ? 'جاري الكتابة...' : 'Typing...'}</span>
</div>
</div>
`;
chatMessages.append(typingHtml);
scrollToBottom();
}
function hideTypingIndicator() {
$('#typingIndicator').remove();
}
function scrollToBottom() {
chatMessages.scrollTop(chatMessages[0].scrollHeight);
}
// Insights formatting
function formatInsightsResponse(response) {
console.log("Formatting insights response:", response);
let formattedResponse = '';
// Get data using both possible key formats
const insightsData = response.insights || response['التحليلات'] || [];
const recommendationsData = response.recommendations || response['التوصيات'] || [];
// Process insights
if (insightsData.length > 0) {
formattedResponse += isArabic ? '## نتائج التحليل\n\n' : '## Analysis Results\n\n';
insightsData.forEach(insight => {
if (insight.type) {
formattedResponse += `### ${insight.type}\n\n`;
}
if (insight.results && Array.isArray(insight.results)) {
insight.results.forEach(result => {
if (result.error) {
formattedResponse += `- **${result.model || ''}**: ${result.error}\n`;
} else if (result.count !== undefined) {
formattedResponse += `- **${result.model || ''}**: ${result.count}\n`;
} else if (result.value !== undefined) {
const field = getLocalizedValue(result, 'field', 'الحقل');
const statType = getLocalizedValue(result, 'statistic_type', 'نوع_الإحصاء');
formattedResponse += `- **${result.model || ''}**: ${statType} ${isArabic ? 'لـ' : 'of'} ${field} = ${result.value}\n`;
}
});
formattedResponse += '\n';
}
if (insight.relationships && Array.isArray(insight.relationships)) {
formattedResponse += isArabic ? '### العلاقات\n\n' : '### Relationships\n\n';
insight.relationships.forEach(rel => {
const from = getLocalizedValue(rel, 'from', 'من');
const to = getLocalizedValue(rel, 'to', 'إلى');
const type = getLocalizedValue(rel, 'type', 'نوع');
formattedResponse += `- ${from} → ${to} (${type})\n`;
});
formattedResponse += '\n';
}
});
}
// Process recommendations
if (recommendationsData.length > 0) {
formattedResponse += isArabic ? '## التوصيات\n\n' : '## Recommendations\n\n';
recommendationsData.forEach(rec => {
formattedResponse += `- ${rec}\n`;
});
}
return formattedResponse.trim() ||
(isArabic ? 'تم تحليل البيانات بنجاح ولكن لا توجد نتائج للعرض.' : 'Data analyzed successfully but no results to display.');
}
function getLocalizedValue(obj, englishKey, arabicKey) {
return isArabic ? (obj[arabicKey] || obj[englishKey] || '') : (obj[englishKey] || obj[arabicKey] || '');
}
// Copy message functionality
$(document).on('click', '.copy-btn', function() {
const text = $(this).closest('.d-flex').find('.chat-message').text().trim();
navigator.clipboard.writeText(text).then(() => {
showCopySuccess($(this));
});
});
function showCopySuccess(button) {
const originalIcon = button.html();
button.html('<i class="fas fa-check"></i>');
setTimeout(() => {
button.html(originalIcon);
}, 1500);
}
// Initialize
scrollToBottom();
});
</script> </script>
{% endblock %} {% endblock content %}
{% block customJS %}
<!-- JS will be loaded from static file or added separately -->
{% endblock %}