Merge branch 'release/v3.1.15-beta'

This commit is contained in:
Josako
2025-11-13 10:20:24 +01:00
59 changed files with 1552 additions and 1332 deletions

View File

@@ -122,6 +122,8 @@ class EveAIAgent(db.Model):
role = db.Column(db.Text, nullable=True) role = db.Column(db.Text, nullable=True)
goal = db.Column(db.Text, nullable=True) goal = db.Column(db.Text, nullable=True)
backstory = db.Column(db.Text, nullable=True) backstory = db.Column(db.Text, nullable=True)
temperature = db.Column(db.Float, nullable=True)
llm_model = db.Column(db.String(50), nullable=True)
tuning = db.Column(db.Boolean, nullable=True, default=False) tuning = db.Column(db.Boolean, nullable=True, default=False)
configuration = db.Column(JSONB, nullable=True) configuration = db.Column(JSONB, nullable=True)
arguments = db.Column(JSONB, nullable=True) arguments = db.Column(JSONB, nullable=True)

View File

@@ -386,14 +386,14 @@ class TranslationCache(db.Model):
last_used_at = db.Column(db.DateTime, nullable=True) last_used_at = db.Column(db.DateTime, nullable=True)
class PartnerRAGRetriever(db.Model): # class PartnerRAGRetriever(db.Model):
__bind_key__ = 'public' # __bind_key__ = 'public'
__table_args__ = ( # __table_args__ = (
db.PrimaryKeyConstraint('tenant_id', 'retriever_id'), # db.PrimaryKeyConstraint('tenant_id', 'retriever_id'),
db.UniqueConstraint('partner_id', 'tenant_id', 'retriever_id'), # db.UniqueConstraint('partner_id', 'tenant_id', 'retriever_id'),
{'schema': 'public'}, # {'schema': 'public'},
) # )
#
partner_id = db.Column(db.Integer, db.ForeignKey('public.partner.id'), nullable=False) # partner_id = db.Column(db.Integer, db.ForeignKey('public.partner.id'), nullable=False)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False) # tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
retriever_id = db.Column(db.Integer, nullable=False) # retriever_id = db.Column(db.Integer, nullable=False)

View File

@@ -121,7 +121,7 @@ class CacheHandler(Generic[T]):
region_name = getattr(self.region, 'name', 'default_region') region_name = getattr(self.region, 'name', 'default_region')
key = CacheKey({k: identifiers[k] for k in self._key_components}) key = CacheKey({k: identifiers[k] for k in self._key_components})
return f"{region_name}_{self.prefix}:{str(key)}" return f"{region_name}:{self.prefix}:{str(key)}"
def get(self, creator_func, **identifiers) -> T: def get(self, creator_func, **identifiers) -> T:
""" """
@@ -179,7 +179,7 @@ class CacheHandler(Generic[T]):
Deletes all keys that start with the region prefix. Deletes all keys that start with the region prefix.
""" """
# Construct the pattern for all keys in this region # Construct the pattern for all keys in this region
pattern = f"{self.region}_{self.prefix}:*" pattern = f"{self.region}:{self.prefix}:*"
# Assuming Redis backend with dogpile, use `delete_multi` or direct Redis access # Assuming Redis backend with dogpile, use `delete_multi` or direct Redis access
if hasattr(self.region.backend, 'client'): if hasattr(self.region.backend, 'client'):

View File

@@ -1,9 +1,9 @@
"""Database related functions""" """Database related functions"""
from os import popen from os import popen
from sqlalchemy import text from sqlalchemy import text, event
from sqlalchemy.schema import CreateSchema from sqlalchemy.schema import CreateSchema
from sqlalchemy.exc import InternalError from sqlalchemy.exc import InternalError
from sqlalchemy.orm import sessionmaker, scoped_session from sqlalchemy.orm import sessionmaker, scoped_session, Session as SASession
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from flask import current_app from flask import current_app
@@ -16,6 +16,66 @@ class Database:
def __init__(self, tenant: str) -> None: def __init__(self, tenant: str) -> None:
self.schema = str(tenant) self.schema = str(tenant)
# --- Session / Transaction events to ensure correct search_path per transaction ---
@event.listens_for(SASession, "after_begin")
def _set_search_path_per_tx(session, transaction, connection):
"""Ensure each transaction sees the right tenant schema, regardless of
which pooled connection is used. Uses SET LOCAL so it is scoped to the tx.
"""
schema = session.info.get("tenant_schema")
if schema:
try:
connection.exec_driver_sql(f'SET LOCAL search_path TO "{schema}", public')
# Optional visibility/logging for debugging
sp = connection.exec_driver_sql("SHOW search_path").scalar()
try:
current_app.logger.info(f"DBCTX tx_begin conn_id={id(connection.connection)} search_path={sp}")
except Exception:
pass
except Exception as e:
try:
current_app.logger.error(f"Failed to SET LOCAL search_path for schema {schema}: {e!r}")
except Exception:
pass
def _log_db_context(self, origin: str = "") -> None:
"""Log key DB context info to diagnose schema/search_path issues.
Collects and logs in a single structured line:
- current_database()
- inet_server_addr(), inet_server_port()
- SHOW search_path
- current_schema()
- to_regclass('interaction')
- to_regclass('<tenant>.interaction')
"""
try:
db_name = db.session.execute(text("SELECT current_database()"))\
.scalar()
host = db.session.execute(text("SELECT inet_server_addr()"))\
.scalar()
port = db.session.execute(text("SELECT inet_server_port()"))\
.scalar()
search_path = db.session.execute(text("SHOW search_path"))\
.scalar()
current_schema = db.session.execute(text("SELECT current_schema()"))\
.scalar()
reg_unqualified = db.session.execute(text("SELECT to_regclass('interaction')"))\
.scalar()
qualified = f"{self.schema}.interaction"
reg_qualified = db.session.execute(
text("SELECT to_regclass(:qn)"),
{"qn": qualified}
).scalar()
current_app.logger.info(
"DBCTX origin=%s db=%s host=%s port=%s search_path=%s current_schema=%s to_regclass(interaction)=%s to_regclass(%s)=%s",
origin, db_name, host, port, search_path, current_schema, reg_unqualified, qualified, reg_qualified
)
except SQLAlchemyError as e:
current_app.logger.error(
f"DBCTX logging failed at {origin} for schema {self.schema}: {e!r}"
)
def get_engine(self): def get_engine(self):
"""create new schema engine""" """create new schema engine"""
return db.engine.execution_options( return db.engine.execution_options(
@@ -52,9 +112,32 @@ class Database:
current_app.logger.error(f"💔 Error creating tables for schema {self.schema}: {e.args}") current_app.logger.error(f"💔 Error creating tables for schema {self.schema}: {e.args}")
def switch_schema(self): def switch_schema(self):
"""switch between tenant/public database schema""" """switch between tenant/public database schema with diagnostics logging"""
db.session.execute(text(f'set search_path to "{self.schema}", public')) # Record the desired tenant schema on the active Session so events can use it
db.session.commit() try:
db.session.info["tenant_schema"] = self.schema
except Exception:
pass
# Log the context before switching
self._log_db_context("before_switch")
try:
db.session.execute(text(f'set search_path to "{self.schema}", public'))
db.session.commit()
except SQLAlchemyError as e:
# Rollback on error to avoid InFailedSqlTransaction and log details
try:
db.session.rollback()
except Exception:
pass
current_app.logger.error(
f"Error switching search_path to {self.schema}: {e!r}"
)
# Also log context after failure
self._log_db_context("after_switch_failed")
# Re-raise to let caller decide handling if needed
raise
# Log the context after successful switch
self._log_db_context("after_switch")
def migrate_tenant_schema(self): def migrate_tenant_schema(self):
"""migrate tenant database schema for new tenant""" """migrate tenant database schema for new tenant"""

View File

@@ -10,6 +10,13 @@ import time
class ExecutionProgressTracker: class ExecutionProgressTracker:
"""Tracks progress of specialist executions using Redis""" """Tracks progress of specialist executions using Redis"""
# Normalized processing types and aliases
PT_COMPLETE = 'EVEAI_COMPLETE'
PT_ERROR = 'EVEAI_ERROR'
_COMPLETE_ALIASES = {'EveAI Specialist Complete', 'Task Complete', 'task complete'}
_ERROR_ALIASES = {'EveAI Specialist Error', 'Task Error', 'task error'}
def __init__(self): def __init__(self):
try: try:
# Use shared pubsub pool (lazy connect; no eager ping) # Use shared pubsub pool (lazy connect; no eager ping)
@@ -40,6 +47,16 @@ class ExecutionProgressTracker:
# Exhausted retries # Exhausted retries
raise last_exc raise last_exc
def _normalize_processing_type(self, processing_type: str) -> str:
if not processing_type:
return processing_type
p = str(processing_type).strip()
if p in self._COMPLETE_ALIASES:
return self.PT_COMPLETE
if p in self._ERROR_ALIASES:
return self.PT_ERROR
return p
def send_update(self, ctask_id: str, processing_type: str, data: dict): def send_update(self, ctask_id: str, processing_type: str, data: dict):
"""Send an update about execution progress""" """Send an update about execution progress"""
try: try:
@@ -47,7 +64,7 @@ class ExecutionProgressTracker:
f"{data}") f"{data}")
key = self._get_key(ctask_id) key = self._get_key(ctask_id)
processing_type = self._normalize_processing_type(processing_type)
update = { update = {
'processing_type': processing_type, 'processing_type': processing_type,
'data': data, 'data': data,
@@ -96,14 +113,16 @@ class ExecutionProgressTracker:
self._retry(lambda: pubsub.subscribe(key)) self._retry(lambda: pubsub.subscribe(key))
try: try:
# Hint client reconnect interval (optional but helpful)
yield "retry: 3000\n\n"
# First yield any existing updates # First yield any existing updates
length = self._retry(lambda: self.redis.llen(key)) length = self._retry(lambda: self.redis.llen(key))
if length > 0: if length > 0:
updates = self._retry(lambda: self.redis.lrange(key, 0, -1)) updates = self._retry(lambda: self.redis.lrange(key, 0, -1))
for update in updates: for update in updates:
update_data = json.loads(update.decode('utf-8')) update_data = json.loads(update.decode('utf-8'))
# Use processing_type for the event update_data['processing_type'] = self._normalize_processing_type(update_data.get('processing_type'))
yield f"event: {update_data['processing_type']}\n"
yield f"data: {json.dumps(update_data)}\n\n" yield f"data: {json.dumps(update_data)}\n\n"
# Then listen for new updates # Then listen for new updates
@@ -121,13 +140,20 @@ class ExecutionProgressTracker:
if message['type'] == 'message': # This is Redis pub/sub type if message['type'] == 'message': # This is Redis pub/sub type
update_data = json.loads(message['data'].decode('utf-8')) update_data = json.loads(message['data'].decode('utf-8'))
yield f"data: {message['data'].decode('utf-8')}\n\n" update_data['processing_type'] = self._normalize_processing_type(update_data.get('processing_type'))
yield f"data: {json.dumps(update_data)}\n\n"
# Check processing_type for completion # Unified completion check
if update_data['processing_type'] in ['Task Complete', 'Task Error', 'EveAI Specialist Complete']: if update_data['processing_type'] in [self.PT_COMPLETE, self.PT_ERROR]:
# Give proxies/clients a chance to flush
yield ": closing\n\n"
break break
finally: finally:
try: try:
pubsub.unsubscribe() pubsub.unsubscribe()
except Exception: except Exception:
pass pass
try:
pubsub.close()
except Exception:
pass

View File

@@ -140,21 +140,17 @@ def enforce_tenant_consent_ui():
"""Check if the user has consented to the terms of service""" """Check if the user has consented to the terms of service"""
path = getattr(request, 'path', '') or '' path = getattr(request, 'path', '') or ''
if path.startswith('/healthz') or path.startswith('/_healthz'): if path.startswith('/healthz') or path.startswith('/_healthz'):
current_app.logger.debug(f'Health check request, bypassing consent guard: {path}')
return None return None
if not current_user.is_authenticated: if not current_user.is_authenticated:
current_app.logger.debug('Not authenticated, bypassing consent guard')
return None return None
endpoint = request.endpoint or '' endpoint = request.endpoint or ''
if is_exempt_endpoint(endpoint) or request.method == 'OPTIONS': if is_exempt_endpoint(endpoint) or request.method == 'OPTIONS':
current_app.logger.debug(f'Endpoint exempt from consent guard: {endpoint}')
return None return None
# Global bypass: Super User and Partner Admin always allowed # Global bypass: Super User and Partner Admin always allowed
if current_user.has_roles('Super User') or current_user.has_roles('Partner Admin'): if current_user.has_roles('Super User') or current_user.has_roles('Partner Admin'):
current_app.logger.debug('Global bypass: Super User or Partner Admin')
return None return None
tenant_id = getattr(current_user, 'tenant_id', None) tenant_id = getattr(current_user, 'tenant_id', None)
@@ -176,16 +172,13 @@ def enforce_tenant_consent_ui():
status = ConsentStatus.NOT_CONSENTED status = ConsentStatus.NOT_CONSENTED
if status == ConsentStatus.CONSENTED: if status == ConsentStatus.CONSENTED:
current_app.logger.debug('User has consented')
return None return None
if status == ConsentStatus.NOT_CONSENTED: if status == ConsentStatus.NOT_CONSENTED:
current_app.logger.debug('User has not consented')
if current_user.has_roles('Tenant Admin'): if current_user.has_roles('Tenant Admin'):
return redirect(prefixed_url_for('user_bp.tenant_consent', for_redirect=True)) return redirect(prefixed_url_for('user_bp.tenant_consent', for_redirect=True))
return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True)) return redirect(prefixed_url_for('user_bp.no_consent', for_redirect=True))
if status == ConsentStatus.RENEWAL_REQUIRED: if status == ConsentStatus.RENEWAL_REQUIRED:
current_app.logger.debug('Consent renewal required')
if current_user.has_roles('Tenant Admin'): if current_user.has_roles('Tenant Admin'):
flash( flash(
"You need to renew your consent to our DPA or T&Cs. Failing to do so in time will stop you from accessing our services.", "You need to renew your consent to our DPA or T&Cs. Failing to do so in time will stop you from accessing our services.",

View File

@@ -1,17 +0,0 @@
version: "1.0.0"
name: "Email Content Agent"
role: >
Email Content Writer
goal: >
Craft a highly personalized email that resonates with the {end_user_role}'s context and identification (personal and
company if available).
{custom_goal}
backstory: >
You are an expert in writing compelling, personalized emails that capture the {end_user_role}'s attention and drive
engagement. You are perfectly multilingual, and can write the mail in the native language of the {end_user_role}.
{custom_backstory}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that writes engaging emails."
changes: "Initial version"

View File

@@ -1,16 +0,0 @@
version: "1.0.0"
name: "Email Engagement Agent"
role: >
Engagement Optimization Specialist {custom_role}
goal: >
You ensure that the email includes strong CTAs and strategically placed engagement hooks that encourage the
{end_user_role} to take immediate action. {custom_goal}
backstory: >
You specialize in optimizing content to ensure that it not only resonates with the recipient but also encourages them
to take the desired action.
{custom_backstory}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that ensures the email is engaging and lead to maximal desired action"
changes: "Initial version"

View File

@@ -1,20 +0,0 @@
version: "1.0.0"
name: "Identification Agent"
role: >
Identification Administrative force. {custom_role}
goal: >
You are an administrative force that tries to gather identification information to complete the administration of an
end-user, the company he or she works for, through monitoring conversations and advising on questions to help you do
your job. You are responsible for completing the company's backend systems (like CRM, ERP, ...) with inputs from the
end user in the conversation.
{custom_goal}
backstory: >
You are and administrative force for {company}, and very proficient in gathering information for the company's backend
systems. You do so by monitoring conversations between one of your colleagues (e.g. sales, finance, support, ...) and
an end user. You ask your colleagues to request additional information to complete your task.
{custom_backstory}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that gathers administrative information"
changes: "Initial version"

View File

@@ -1,4 +1,4 @@
version: "1.0.0" version: "1.1.0"
name: "Rag Agent" name: "Rag Agent"
role: > role: >
{tenant_name} Spokesperson. {custom_role} {tenant_name} Spokesperson. {custom_role}
@@ -7,7 +7,7 @@ goal: >
of the current conversation. of the current conversation.
{custom_goal} {custom_goal}
backstory: > backstory: >
You are the primary contact for {tenant_name}. You are known by {name}, and can be addressed by this name, or you. You are You are the primary contact for {tenant_name}. You are known by {name}, and can be addressed by this name, or 'you'. You are
a very good communicator, and adapt to the style used by the human asking for information (e.g. formal or informal). a very good communicator, and adapt to the style used by the human asking for information (e.g. formal or informal).
You always stay correct and polite, whatever happens. And you ensure no discriminating language is used. You always stay correct and polite, whatever happens. And you ensure no discriminating language is used.
You are perfectly multilingual in all known languages, and do your best to answer questions in {language}, whatever You are perfectly multilingual in all known languages, and do your best to answer questions in {language}, whatever

View File

@@ -0,0 +1,29 @@
version: "1.2.0"
name: "Rag Agent"
role: >
{tenant_name}'s Spokesperson. {custom_role}
goal: >
You get questions by a human correspondent, and give answers based on a given context, taking into account the history
of the current conversation.
{custom_goal}
backstory: >
You are the primary contact for {tenant_name}, and have been it's spokesperson for a very long time. You are used to
addressing customers, prospects, press, ...
You are known by {name}, and can be addressed by this name, or 'you'.
You are a very good communicator, that knows how to adapt his style to the public your interacting with.
You always stay correct and polite, whatever happens. And you ensure no discriminating language is used.
You are perfectly multilingual in all known languages, and do your best to answer questions in {language}, whatever
language the context provided to you is in. You are participating in a conversation, not writing e.g. an email or
essay. Do not include a salutation or closing greeting in your answer.
{custom_backstory}
full_model_name: "mistral.mistral-medium-latest"
allowed_models:
- "mistral.mistral-small-latest"
- "mistral.mistral-medium-latest"
- "mistral.magistral-medium-latest"
temperature: 0.3
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that does RAG based on a user's question, RAG content & history"
changes: "Initial version"

View File

@@ -1,26 +0,0 @@
version: "1.0.0"
name: "Rag Communication Agent"
role: >
{company} Interaction Responsible. {custom_role}
goal: >
Your team has collected answers to a question asked. But it also created some additional questions to be asked. You
ensure the necessary answers are returned, and make an informed selection of the additional questions that can be
asked (combining them when appropriate), ensuring the human you're communicating to does not get overwhelmed.
{custom_goal}
backstory: >
You are the online communication expert for {company}. You handled a lot of online communications with both customers
and internal employees. You are a master in redacting one coherent reply in a conversation that includes all the
answers, and a selection of additional questions to be asked in a conversation. Although your backoffice team might
want to ask a myriad of questions, you understand that doesn't fit with the way humans communicate. You know how to
combine multiple related questions, and understand how to interweave the questions in the answers when related.
You are perfectly multilingual in all known languages, and do your best to answer questions in {language}, whatever
language the context provided to you is in. Also, ensure that questions asked do not contradict with the answers
given, or aren't obsolete given the answer provided.
You are participating in a conversation, not writing e.g. an email. Do not include a salutation or closing greeting
in your answer.
{custom_backstory}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that consolidates both answers and questions in a consistent reply"
changes: "Initial version"

View File

@@ -0,0 +1,24 @@
version: "1.0.0"
name: "Rag Proofreader Agent"
role: >
Proofreader for {tenant_name}. {custom_role}
goal: >
You get a prepared answer to be send out, and adapt it to comply to best practices.
{custom_goal}
backstory: >
You are the primary contact for {tenant_name}, and have been it's spokesperson for a very long time. You are used to
addressing customers, prospects, press, ...
You are known by {name}, and can be addressed by this name, or 'you'.
You review communications and ensure they are clear and follow best practices.
{custom_backstory}
full_model_name: "mistral.mistral-medium-latest"
allowed_models:
- "mistral.mistral-small-latest"
- "mistral.mistral-medium-latest"
- "mistral.magistral-medium-latest"
temperature: 0.4
metadata:
author: "Josako"
date_added: "2025-10-22"
description: "An Agent that does QA Activities on provided answers"
changes: "Initial version"

View File

@@ -1,22 +0,0 @@
version: "1.0.0"
name: "SPIN Sales Assistant"
role: >
Sales Assistant for {company} on {products}. {custom_role}
goal: >
Your main job is to help your sales specialist to analyze an ongoing conversation with a customer, and detect
SPIN-related information. {custom_goal}
backstory: >
You are a sales assistant for {company} on {products}. You are known by {name}, and can be addressed by this name, or you. You are
trained to understand an analyse ongoing conversations. Your are proficient in detecting SPIN-related information in a
conversation.
SPIN stands for:
- Situation information - Understanding the customer's current context
- Problem information - Uncovering challenges and pain points
- Implication information - Exploring consequences of those problems
- Need-payoff information - Helping customers realize value of solutions
{custom_backstory}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that detects SPIN information in an ongoing conversation"
changes: "Initial version"

View File

@@ -1,25 +0,0 @@
version: "1.0.0"
name: "SPIN Sales Specialist"
role: >
Sales Specialist for {company} on {products}. {custom_role}
goal: >
Your main job is to do sales using the SPIN selling methodology in a first conversation with a potential customer.
{custom_goal}
backstory: >
You are a sales specialist for {company} on {products}. You are known by {name}, and can be addressed by this name,
or you. You have an assistant that provides you with already detected SPIN-information in an ongoing conversation. You
decide on follow-up questions for more in-depth information to ensure we get the required information that may lead to
selling {products}.
SPIN stands for:
- Situation information - Understanding the customer's current context
- Problem information - Uncovering challenges and pain points
- Implication information - Exploring consequences of those problems
- Need-payoff information - Helping customers realize value of solutions
{custom_backstory}
You are acquainted with the following product information:
{product_information}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "An Agent that asks for Follow-up questions for SPIN-process"
changes: "Initial version"

View File

@@ -11,7 +11,7 @@ fields:
email: email:
name: "Email" name: "Email"
type: "str" type: "str"
description: "Your Name" description: "Your Email"
required: true required: true
phone: phone:
name: "Phone Number" name: "Phone Number"
@@ -28,16 +28,6 @@ fields:
type: "str" type: "str"
description: "Job Title" description: "Job Title"
required: false required: false
address:
name: "Address"
type: "str"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "str"
description: "Postal Code"
required: false
city: city:
name: "City" name: "City"
type: "str" type: "str"

View File

@@ -0,0 +1,81 @@
version: "1.2.0"
name: "RAG Specialist"
framework: "crewai"
chat: true
configuration:
name:
name: "name"
type: "str"
description: "The name the specialist is called upon."
required: true
tone_of_voice:
name: "Tone of Voice"
description: "The tone of voice the specialist uses to communicate"
type: "enum"
allowed_values: [ "Professional & Neutral", "Warm & Empathetic", "Energetic & Enthusiastic", "Accessible & Informal", "Expert & Trustworthy", "No-nonsense & Goal-driven" ]
default: "Professional & Neutral"
required: true
language_level:
name: "Language Level"
description: "Language level to be used when communicating, relating to CEFR levels"
type: "enum"
allowed_values: [ "Basic", "Standard", "Professional" ]
default: "Standard"
required: true
response_depth:
name: "Response Depth"
description: "Response depth to be used when communicating"
type: "enum"
allowed_values: [ "Concise", "Balanced", "Detailed",]
default: "Balanced"
required: true
conversation_purpose:
name: "Conversation Purpose"
description: "Purpose of the conversation, resulting in communication style"
type: "enum"
allowed_values: [ "Informative", "Persuasive", "Supportive", "Collaborative" ]
default: "Informative"
required: true
welcome_message:
name: "Welcome Message"
type: "string"
description: "Welcome Message to be given to the end user"
required: false
arguments:
language:
name: "Language"
type: "str"
description: "Language code to be used for receiving questions and giving answers"
required: true
results:
rag_output:
answer:
name: "answer"
type: "str"
description: "Answer to the query"
required: true
citations:
name: "citations"
type: "List[str]"
description: "List of citations"
required: false
insufficient_info:
name: "insufficient_info"
type: "bool"
description: "Whether or not the query is insufficient info"
required: true
agents:
- type: "RAG_AGENT"
version: "1.2"
- type: "RAG_PROOFREADER_AGENT"
version: "1.0"
tasks:
- type: "RAG_TASK"
version: "1.1"
- type: "RAG_PROOFREADING_TASK"
version: "1.0"
metadata:
author: "Josako"
date_added: "2025-01-08"
changes: "Initial version"
description: "A Specialist that performs Q&A activities"

View File

@@ -1,183 +0,0 @@
version: "1.0.0"
name: "Spin Sales Specialist"
framework: "crewai"
chat: true
configuration:
name:
name: "name"
type: "str"
description: "The name the specialist is called upon."
required: true
company:
name: "company"
type: "str"
description: "The name of your company. If not provided, your tenant's name will be used."
required: false
products:
name: "products"
type: "List[str]"
description: "The products or services you're providing"
required: false
product_information:
name: "product_information"
type: "text"
description: "Information on the products you are selling, such as ICP (Ideal Customer Profile), Pitch, ..."
required: false
engagement_options:
name: "engagement_options"
type: "text"
description: "Engagement options such as email, phone number, booking link, ..."
tenant_language:
name: "tenant_language"
type: "str"
description: "The language code used for internal information. If not provided, the tenant's default language will be used"
required: false
nr_of_questions:
name: "nr_of_questions"
type: "int"
description: "The maximum number of questions to formulate extra questions"
required: true
default: 3
arguments:
language:
name: "Language"
type: "str"
description: "Language code to be used for receiving questions and giving answers"
required: true
query:
name: "query"
type: "str"
description: "Query or response to process"
required: true
identification:
name: "identification"
type: "text"
description: "Initial identification information when available"
required: false
results:
rag_output:
answer:
name: "answer"
type: "str"
description: "Answer to the query"
required: true
citations:
name: "citations"
type: "List[str]"
description: "List of citations"
required: false
insufficient_info:
name: "insufficient_info"
type: "bool"
description: "Whether or not the query is insufficient info"
required: true
spin:
situation:
name: "situation"
type: "str"
description: "A description of the customer's current situation / context"
required: false
problem:
name: "problem"
type: "str"
description: "The current problems the customer is facing, for which he/she seeks a solution"
required: false
implication:
name: "implication"
type: "str"
description: "A list of implications"
required: false
needs:
name: "needs"
type: "str"
description: "A list of needs"
required: false
additional_info:
name: "additional_info"
type: "str"
description: "Additional information that may be commercially interesting"
required: false
lead_info:
lead_personal_info:
name:
name: "name"
type: "str"
description: "name of the lead"
required: "true"
job_title:
name: "job_title"
type: "str"
description: "job title"
required: false
email:
name: "email"
type: "str"
description: "lead email"
required: "false"
phone:
name: "phone"
type: "str"
description: "lead phone"
required: false
additional_info:
name: "additional_info"
type: "str"
description: "additional info on the lead"
required: false
lead_company_info:
company_name:
name: "company_name"
type: "str"
description: "Name of the lead company"
required: false
industry:
name: "industry"
type: "str"
description: "The industry of the company"
required: false
company_size:
name: "company_size"
type: "int"
description: "The size of the company"
required: false
company_website:
name: "company_website"
type: "str"
description: "The main website for the company"
required: false
additional_info:
name: "additional_info"
type: "str"
description: "Additional information that may be commercially interesting"
required: false
agents:
- type: "RAG_AGENT"
version: "1.0"
- type: "RAG_COMMUNICATION_AGENT"
version: "1.0"
- type: "SPIN_DETECTION_AGENT"
version: "1.0"
- type: "SPIN_SALES_SPECIALIST_AGENT"
version: "1.0"
- type: "IDENTIFICATION_AGENT"
version: "1.0"
- type: "RAG_COMMUNICATION_AGENT"
version: "1.0"
tasks:
- type: "RAG_TASK"
version: "1.0"
- type: "SPIN_DETECT_TASK"
version: "1.0"
- type: "SPIN_QUESTIONS_TASK"
version: "1.0"
- type: "IDENTIFICATION_DETECTION_TASK"
version: "1.0"
- type: "IDENTIFICATION_QUESTIONS_TASK"
version: "1.0"
- type: "RAG_CONSOLIDATION_TASK"
version: "1.0"
metadata:
author: "Josako"
date_added: "2025-01-08"
changes: "Initial version"
description: "A Specialist that performs both Q&A as SPIN (Sales Process) activities"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 387 KiB

View File

@@ -1,6 +1,6 @@
{ {
"dist/chat-client.js": "dist/chat-client.59b28883.js", "dist/chat-client.js": "dist/chat-client.f7f06623.js",
"dist/chat-client.css": "dist/chat-client.79757200.css", "dist/chat-client.css": "dist/chat-client.cf7bc0ef.css",
"dist/main.js": "dist/main.c5b0c81d.js", "dist/main.js": "dist/main.6a617099.js",
"dist/main.css": "dist/main.06893f70.css" "dist/main.css": "dist/main.06893f70.css"
} }

View File

@@ -1,35 +0,0 @@
version: "1.0.0"
name: "Email Lead Draft Creation"
task_description: >
Craft a highly personalized email using the lead's name, job title, company information, and any relevant personal or
company achievements when available. The email should speak directly to the lead's interests and the needs
of their company.
This mail is the consequence of a first conversation. You have information available from that conversation in the
- SPIN-context (in between triple %)
- personal and company information (in between triple $)
Information might be missing however, as it might not be gathered in that first conversation.
Don't use any salutations or closing remarks, nor too complex sentences.
Our Company and Product:
- Company Name: {company}
- Products: {products}
- Product information: {product_information}
{customer_role}'s Identification:
$$${Identification}$$$
SPIN context:
%%%{SPIN}%%%
{custom_description}
expected_output: >
A personalized email draft that:
- Addresses the lead by name
- Acknowledges their role and company
- Highlights how {company} can meet their specific needs or interests
{customer_expected_output}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "Email Drafting Task towards a Lead"
changes: "Initial version"

View File

@@ -1,28 +0,0 @@
version: "1.0.0"
name: "Email Lead Engagement Creation"
task_description: >
Review a personalized email and optimize it with strong CTAs and engagement hooks. Keep in mind that this email is
the consequence of a first conversation.
Don't use any salutations or closing remarks, nor too complex sentences. Keep it short and to the point.
Don't use any salutations or closing remarks, nor too complex sentences.
Ensure the email encourages the lead to schedule a meeting or take
another desired action immediately.
Our Company and Product:
- Company Name: {company}
- Products: {products}
- Product information: {product_information}
Engagement options:
{engagement_options}
{custom_description}
expected_output: >
An optimized email ready for sending, complete with:
- Strong CTAs
- Strategically placed engagement hooks that encourage immediate action
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "Make an Email draft more engaging"
changes: "Initial version"

View File

@@ -1,24 +0,0 @@
version: "1.0.0"
name: "Identification Gathering"
task_description: >
You are asked to gather lead information in a conversation with a new prospect. This is information about the person
participating in the conversation, and information on the company he or she is working for. Try to be as precise as
possible.
Take into account information already gathered in the historic lead info (between triple backquotes) and add
information found in the latest reply. Also, some identification information may be given by the end user.
historic lead info:
```{historic_lead_info}```
latest reply:
{query}
identification:
{identification}
{custom_description}
expected_output: >
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "A Task that gathers identification information from a conversation"
changes: "Initial version"

View File

@@ -1,19 +0,0 @@
version: "1.0.0"
name: "Define Identification Questions"
task_description: >
Gather the identification information gathered by your team mates. Ensure no information in the historic lead
information (in between triple backquotes) and the latest reply of the user is lost.
Define questions to be asked to complete the personal and company information for the end user in the conversation.
historic lead info:
```{historic_lead_info}```
latest reply:
{query}
{custom_description}
expected_output: >
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "A Task to define identification (person & company) questions"
changes: "Initial version"

View File

@@ -1,27 +0,0 @@
version: "1.0.0"
name: "Rag Consolidation"
task_description: >
Your teams have collected answers to a user's query (in between triple backquotes), and collected additional follow-up
questions (in between triple %) to reach their goals. Ensure the answers are provided, and select a maximum of
{nr_of_questions} out of the additional questions to be asked in order not to overwhelm the user. The questions are
in no specific order, so don't just pick the first ones. Make a good mixture of different types of questions,
different topics or subjects!
Questions are to be asked when your team proposes questions. You ensure both answers and additional questions are
bundled into 1 clear communication back to the user. Use {language} for your consolidated communication.
Be sure to format your answer in markdown when appropriate. Ensure enumerations or bulleted lists are formatted as
lists in markdown.
{custom_description}
Anwers:
```{prepared_answers}```
Additional Questions:
%%%{additional_questions}%%%
expected_output: >
{custom_expected_output}
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "A Task to consolidate questions and answers"
changes: "Initial version"

View File

@@ -0,0 +1,26 @@
version: "1.0.0"
name: "RAG QA Task"
task_description: >
You have to improve this first draft answering the following question:
£££
{question}
£££
We want you to pay extra attention and adapt to the following requirements:
- The answer uses the following Tone of Voice: {tone_of_voice}, i.e. {tone_of_voice_context}
- The answer is adapted to the following Language Level: {language_level}, i.e. {language_level_context}
- The answer is suited to be {conversation_purpose}, i.e. {conversation_purpose_context}
- And we want the answer to have the following depth: {response_depth}, i.e. {response_depth_context}
Ensure the following {language} is used.
If there was insufficient information to answer, answer "I have insufficient information to answer this
question." and give the appropriate indication.
expected_output: >
Your answer.
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "A Task that gives RAG-based answers"
changes: "Initial version"

View File

@@ -3,30 +3,43 @@ name: "RAG Task"
task_description: > task_description: >
Answer the following question (in between triple £): Answer the following question (in between triple £):
£££{question}£££ £££
{question}
£££
Base your answer on the following context (in between triple $): Base your answer on the context below, in between triple '$'.
Take into account the history of the conversion , in between triple '€'. The parts in the history preceded by 'HUMAN'
$$${context}$$$ indicate the interactions by the end user, the parts preceded with 'AI' are your interactions.
Take into account the following history of the conversation (in between triple €):
€€€{history}€€€
The HUMAN parts indicate the interactions by the end user, the AI parts are your interactions.
Best Practices are: Best Practices are:
- Answer the provided question as precisely and directly as you can, combining elements of the provided context. - Answer the provided question, combining elements of the provided context.
- Always focus your answer on the actual HUMAN question. - Always focus your answer on the actual question.
- Try not to repeat your answers (preceded by AI), unless absolutely necessary. - Try not to repeat your historic answers, unless absolutely necessary.
- Focus your answer on the question at hand.
- Always be friendly and helpful for the end user. - Always be friendly and helpful for the end user.
Tune your answer with the following:
- You use the following Tone of Voice for your answer: {tone_of_voice}, i.e. {tone_of_voice_context}
- You use the following Language Level for your answer: {language_level}, i.e. {language_level_context}
- The purpose of the conversation is to be {conversation_purpose}, i.e. {conversation_purpose_context}
- We expect you to answer with the following depth: {response_depth}, i.e. {response_depth_context}
{custom_description} {custom_description}
Use the following {language} in your communication. Use the following {language} in your communication.
If the question cannot be answered using the given context, answer "I have insufficient information to answer this If the question cannot be answered using the given context, answer "I have insufficient information to answer this
question." and give the appropriate indication. question." and give the appropriate indication.
Context:
$$$
{context}
$$$
History:
€€€
{history}
€€€
expected_output: > expected_output: >
Your answer. Your answer.
metadata: metadata:

View File

@@ -1,18 +0,0 @@
version: "1.0.0"
name: "SPIN Information Detection"
task_description: >
Complement the historic SPIN context (in between triple backquotes) with information found in the latest reply of the
end user.
{custom_description}
Use the following {tenant_language} to define the SPIN-elements.
Historic SPIN:
```{historic_spin}```
Latest reply:
{query}
expected_output: >
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "A Task that performs SPIN Information Detection"
changes: "Initial version"

View File

@@ -1,20 +0,0 @@
version: "1.0.0"
name: "SPIN Question Identification"
task_description: >
Revise the final SPIN provided by your colleague, and ensure no information is lost from the histoic SPIN and the
latest reply from the user. Define the top questions that need to be asked to understand the full SPIN context
of the customer. If you think this user could be a potential customer, please indicate so.
{custom_description}
Use the following {tenant_language} to define the SPIN-elements. If you have a satisfying SPIN context, just skip and
don't ask for more information or confirmation.
Historic SPIN:
```{historic_spin}```
Latest reply:
{query}
expected_output: >
metadata:
author: "Josako"
date_added: "2025-01-08"
description: "A Task that identifies questions to complete the SPIN context in a conversation"
changes: "Initial version"

View File

@@ -1,32 +1,12 @@
# Agent Types # Agent Types
AGENT_TYPES = { AGENT_TYPES = {
"EMAIL_CONTENT_AGENT": {
"name": "Email Content Agent",
"description": "An Agent that writes engaging emails.",
},
"EMAIL_ENGAGEMENT_AGENT": {
"name": "Email Engagement Agent",
"description": "An Agent that ensures the email is engaging and lead to maximal desired action",
},
"IDENTIFICATION_AGENT": {
"name": "Identification Agent",
"description": "An Agent that gathers identification information",
},
"RAG_AGENT": { "RAG_AGENT": {
"name": "Rag Agent", "name": "Rag Agent",
"description": "An Agent that does RAG based on a user's question, RAG content & history", "description": "An Agent that does RAG based on a user's question, RAG content & history",
}, },
"RAG_COMMUNICATION_AGENT": { "RAG_PROOFREADER_AGENT": {
"name": "Rag Communication Agent", "name": "Rag Proofreader Agent",
"description": "An Agent that consolidates both answers and questions in a consistent reply", "description": "An Agent that checks the quality of RAG answers and adapts when required",
},
"SPIN_DETECTION_AGENT": {
"name": "SPIN Sales Assistant",
"description": "An Agent that detects SPIN information in an ongoing conversation",
},
"SPIN_SALES_SPECIALIST_AGENT": {
"name": "SPIN Sales Specialist",
"description": "An Agent that asks for Follow-up questions for SPIN-process",
}, },
"TRAICIE_HR_BP_AGENT": { "TRAICIE_HR_BP_AGENT": {
"name": "Traicie HR BP Agent", "name": "Traicie HR BP Agent",

View File

@@ -9,10 +9,6 @@ SPECIALIST_TYPES = {
"description": "Q&A through Partner RAG Specialist (for documentation purposes)", "description": "Q&A through Partner RAG Specialist (for documentation purposes)",
"partner": "evie_partner" "partner": "evie_partner"
}, },
"SPIN_SPECIALIST": {
"name": "Spin Sales Specialist",
"description": "A specialist that allows to answer user queries, try to get SPIN-information and Identification",
},
"TRAICIE_ROLE_DEFINITION_SPECIALIST": { "TRAICIE_ROLE_DEFINITION_SPECIALIST": {
"name": "Traicie Role Definition Specialist", "name": "Traicie Role Definition Specialist",
"description": "Assistant Defining Competencies and KO Criteria", "description": "Assistant Defining Competencies and KO Criteria",

View File

@@ -1,36 +1,16 @@
# Agent Types # Agent Types
TASK_TYPES = { TASK_TYPES = {
"EMAIL_LEAD_DRAFTING_TASK": {
"name": "Email Lead Draft Creation",
"description": "Email Drafting Task towards a Lead",
},
"EMAIL_LEAD_ENGAGEMENT_TASK": {
"name": "Email Lead Engagement Creation",
"description": "Make an Email draft more engaging",
},
"IDENTIFICATION_DETECTION_TASK": {
"name": "Identification Gathering",
"description": "A Task that gathers identification information from a conversation",
},
"IDENTIFICATION_QUESTIONS_TASK": {
"name": "Define Identification Questions",
"description": "A Task to define identification (person & company) questions",
},
"RAG_TASK": { "RAG_TASK": {
"name": "RAG Task", "name": "RAG Task",
"description": "A Task that gives RAG-based answers", "description": "A Task that gives RAG-based answers",
}, },
"SPIN_DETECT_TASK": { "ADVANCED_RAG_TASK": {
"name": "SPIN Information Detection", "name": "Advanced RAG Task",
"description": "A Task that performs SPIN Information Detection", "description": "A Task that gives RAG-based answers taking into account previous questions, tone of voice and language level",
}, },
"SPIN_QUESTIONS_TASK": { "RAG_PROOFREADING_TASK": {
"name": "SPIN Question Identification", "name": "Rag Proofreading Task",
"description": "A Task that identifies questions to complete the SPIN context in a conversation", "description": "A Task that performs RAG Proofreading",
},
"RAG_CONSOLIDATION_TASK": {
"name": "RAG Consolidation",
"description": "A Task to consolidate questions and answers",
}, },
"TRAICIE_GET_COMPETENCIES_TASK": { "TRAICIE_GET_COMPETENCIES_TASK": {
"name": "Traicie Get Competencies", "name": "Traicie Get Competencies",

View File

@@ -0,0 +1,9 @@
type: "SHARE_PROFESSIONAL_CONTACT_DATA"
version: "1.0.0"
name: "Share Professional Contact Data"
icon: "account_circle"
title: "Share Contact Data"
action_type: "specialist_form"
configuration:
specialist_form_name: "PROFESSIONAL_CONTACT_FORM"
specialist_form_version: "1.0.0"

View File

@@ -5,6 +5,31 @@ All notable changes to EveAI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 3.1.15-beta
Release date: 2025-10-29
### Fixed
- small bugfix where an old form being shown when no form was sent back.
## 3.1.14-beta
Release date: 2025-10-28
### Added
- Introduction of User Actions - TBC
- Additional configuration options for Agents: temperature and llm_model can now be configured (if allowed in Agent configuration)
### Changed
- Improvement of RAG Specialist, including proofreading on generated output.
- Specialist Editor - separate modal editors for Agents, Tasks and Tools to allow for more complex configuration.
### Removed
- PartnerRagRetriever model - not used.
### Fixed
- Bug fix where client appears to return no result on an interaction, due to connections without correct search path (out of the connection pool)
## 3.1.13-beta ## 3.1.13-beta
Release date: 2025-10-17 Release date: 2025-10-17

View File

@@ -2,3 +2,4 @@ FROM registry.ask-eve-ai-local.com/josakola/eveai-base:latest
# Copy the source code into the container. # Copy the source code into the container.
COPY eveai_app /app/eveai_app COPY eveai_app /app/eveai_app
COPY content /app/content COPY content /app/content
COPY migrations /app/migrations

View File

@@ -113,7 +113,6 @@ def create_app(config_file=None):
# Register global consent guard via extension # Register global consent guard via extension
@app.before_request @app.before_request
def enforce_tenant_consent(): def enforce_tenant_consent():
app.logger.debug("Enforcing tenant consent")
return enforce_tenant_consent_ui() return enforce_tenant_consent_ui()
# @app.before_request # @app.before_request

View File

@@ -12,6 +12,21 @@ if (typeof window.EveAI === 'undefined') {
window.EveAI.ListView = { window.EveAI.ListView = {
// Opslag voor lijst-view instanties // Opslag voor lijst-view instanties
instances: {}, instances: {},
// Registry voor custom formatters (kan uitgebreid worden door templates)
formatters: {
// typeBadge: toont een badge voor agent/task/tool (robuust met Bootstrap 5 classes)
typeBadge: function(cell) {
const raw = (cell.getValue() || '').toString();
const val = raw.toLowerCase();
const map = {
'agent': { cls: 'badge text-bg-primary', label: 'Agent' },
'task': { cls: 'badge text-bg-warning', label: 'Task' },
'tool': { cls: 'badge text-bg-info', label: 'Tool' },
};
const conf = map[val] || { cls: 'badge text-bg-secondary', label: (raw ? raw : 'Item') };
return `<span class="${conf.cls}">${conf.label}</span>`;
}
},
/** /**
* Initialiseer een Tabulator lijst-view * Initialiseer een Tabulator lijst-view
@@ -24,19 +39,50 @@ window.EveAI.ListView = {
const defaultConfig = { const defaultConfig = {
height: 600, height: 600,
layout: "fitColumns", layout: "fitColumns",
selectable: true, selectable: 1, // single-row selection for consistent UX across Tabulator versions
movableColumns: true, movableColumns: true,
pagination: "local", pagination: "local",
paginationSize: 15, paginationSize: 15,
paginationSizeSelector: [10, 15, 20, 50, 100], paginationSizeSelector: [10, 15, 20, 50, 100],
}; };
// Respecteer eventueel meegegeven tableHeight alias
if (config && typeof config.tableHeight !== 'undefined' && typeof config.height === 'undefined') {
config.height = config.tableHeight;
}
// Los string-formatters op naar functies via registry
if (config && Array.isArray(config.columns)) {
config.columns = config.columns.map(col => {
const newCol = { ...col };
if (typeof newCol.formatter === 'string' && window.EveAI && window.EveAI.ListView && window.EveAI.ListView.formatters) {
const key = newCol.formatter.trim();
const fmt = window.EveAI.ListView.formatters[key];
if (typeof fmt === 'function') {
newCol.formatter = fmt;
}
}
return newCol;
});
}
const tableConfig = {...defaultConfig, ...config}; const tableConfig = {...defaultConfig, ...config};
// Enforce single-row selection across Tabulator versions
if (tableConfig.selectable === true) {
tableConfig.selectable = 1;
}
// Respect and enforce unique row index across Tabulator versions
if (config && typeof config.index === 'string' && config.index) {
// Tabulator v4/v5
tableConfig.index = config.index;
// Tabulator v6+ (alias)
tableConfig.indexField = config.index;
}
// Voeg rij selectie event toe // Voeg rij selectie event toe
tableConfig.rowSelectionChanged = (data, rows) => { tableConfig.rowSelectionChanged = (data, rows) => {
console.log("Rij selectie gewijzigd:", rows.length, "rijen geselecteerd");
// Update de geselecteerde rij in onze instance // Update de geselecteerde rij in onze instance
if (this.instances[elementId]) { if (this.instances[elementId]) {
this.instances[elementId].selectedRow = rows.length > 0 ? rows[0].getData() : null; this.instances[elementId].selectedRow = rows.length > 0 ? rows[0].getData() : null;
@@ -60,6 +106,26 @@ window.EveAI.ListView = {
this.updateActionButtons(elementId); this.updateActionButtons(elementId);
}, 0); }, 0);
// Forceer enkelvoudige selectie op klik voor consistente UX
try {
table.on('rowClick', function(e, row) {
// voorkom multi-select: altijd eerst deselecteren
row.getTable().deselectRow();
row.select();
});
table.on('cellClick', function(e, cell) {
const row = cell.getRow();
row.getTable().deselectRow();
row.select();
});
// Optioneel: cursor als pointer bij hover
table.on('rowFormatter', function(row) {
row.getElement().style.cursor = 'pointer';
});
} catch (e) {
console.warn('Kon click-selectie handlers niet registreren:', e);
}
return table; return table;
} catch (error) { } catch (error) {
console.error(`Fout bij het initialiseren van Tabulator voor ${elementId}:`, error); console.error(`Fout bij het initialiseren van Tabulator voor ${elementId}:`, error);
@@ -168,16 +234,94 @@ window.EveAI.ListView = {
} }
}; };
// Functie om beschikbaar te maken in templates // Functie om beschikbaar te maken in templates (met guard en expliciete event-parameter)
function handleListViewAction(action, requiresSelection) { if (typeof window.handleListViewAction !== 'function') {
// Vind het tableId op basis van de button die is aangeklikt window.handleListViewAction = function(action, requiresSelection, e) {
const target = event?.target || event?.srcElement; const evt = e || undefined; // geen gebruik van deprecated window.event
const target = evt && (evt.target || evt.srcElement);
// Vind het formulier en tableId op basis daarvan // 1) Bepaal tableId zo robuust mogelijk
const form = target ? target.closest('form') : null; let tableId = null;
const tableId = form ? form.id.replace('-form', '') : 'unknown_table'; if (target) {
// Zoek het werkelijke trigger element (button/anchor) i.p.v. een child node
const trigger = (typeof target.closest === 'function') ? target.closest('button, a') : target;
return window.EveAI.ListView.handleAction(action, requiresSelection, tableId); // a) Respecteer expliciete data-attribute op knop
tableId = trigger && trigger.getAttribute ? trigger.getAttribute('data-table-id') : null;
if (!tableId) {
// b) Zoek dichtstbijzijnde container met een tabulator-list-view erin
const containerEl = trigger && typeof trigger.closest === 'function' ? trigger.closest('.container') : null;
const scopedTable = containerEl ? containerEl.querySelector('.tabulator-list-view') : null;
tableId = scopedTable ? scopedTable.id : null;
}
if (!tableId) {
// c) Val terug op dichtstbijzijnde form id-afleiding (enkel als het een -form suffix heeft)
const form = trigger && typeof trigger.closest === 'function' ? trigger.closest('form') : null;
if (form && typeof form.id === 'string' && form.id.endsWith('-form')) {
tableId = form.id.slice(0, -'-form'.length);
}
}
}
if (!tableId) {
// d) Laatste redmiddel: pak de eerste tabulator-list-view op de pagina
const anyTable = document.querySelector('.tabulator-list-view');
tableId = anyTable ? anyTable.id : null;
}
if (!tableId) {
console.error('Kan tableId niet bepalen voor action:', action);
return false;
}
const listView = window.EveAI && window.EveAI.ListView ? window.EveAI.ListView : null;
const instance = listView && listView.instances ? listView.instances[tableId] : null;
// 2) Indien selectie vereist, enforce
if (requiresSelection === true) {
if (!instance || !instance.selectedRow) {
// Probeer nog de Tabulator API als instance ontbreekt
try {
const table = Tabulator.findTable(`#${tableId}`)[0];
const rows = table ? table.getSelectedRows() : [];
if (!rows || rows.length === 0) {
alert('Selecteer eerst een item uit de lijst.');
return false;
}
if (instance) instance.selectedRow = rows[0].getData();
} catch (_) {
alert('Selecteer eerst een item uit de lijst.');
return false;
}
}
}
// 3) Embedded handler krijgt voorrang
const embeddedHandlers = listView && listView.embeddedHandlers ? listView.embeddedHandlers : null;
const embedded = embeddedHandlers && embeddedHandlers[tableId];
if (typeof embedded === 'function') {
try {
embedded(action, instance ? instance.selectedRow : null, tableId);
return true;
} catch (err) {
console.error('Embedded handler error:', err);
return false;
}
}
// 4) Vervallen naar legacy form submit/JS handler
if (listView && typeof listView.handleAction === 'function') {
return listView.handleAction(action, requiresSelection, tableId);
}
// 5) Allerbeste laatste fallback probeer form submit met hidden inputs
const actionInput = document.getElementById(`${tableId}-action`);
if (actionInput) actionInput.value = action;
const form = document.getElementById(`${tableId}-form`);
if (form) { form.submit(); return true; }
console.error('Geen geldige handler gevonden voor action:', action);
return false;
}
} }
console.log('EveAI List View component geladen'); console.log('EveAI List View component geladen');

View File

@@ -16,7 +16,7 @@
<div class="{% if right_actions %}col{% else %}col-12{% endif %}"> <div class="{% if right_actions %}col{% else %}col-12{% endif %}">
{% for action in actions if action.position != 'right' %} {% for action in actions if action.position != 'right' %}
<button type="button" <button type="button"
onclick="handleListViewAction('{{ action.value }}', {{ action.requiresSelection|tojson }})" onclick="handleListViewAction('{{ action.value }}', {{ action.requiresSelection|tojson }}, event)"
class="btn {{ action.class|default('btn-primary') }} me-2 {% if action.requiresSelection %}requires-selection{% endif %}" class="btn {{ action.class|default('btn-primary') }} me-2 {% if action.requiresSelection %}requires-selection{% endif %}"
{% if action.requiresSelection %}disabled{% endif %}> {% if action.requiresSelection %}disabled{% endif %}>
{{ action.text }} {{ action.text }}
@@ -27,7 +27,7 @@
<div class="col-auto text-end"> <div class="col-auto text-end">
{% for action in actions if action.position == 'right' %} {% for action in actions if action.position == 'right' %}
<button type="button" <button type="button"
onclick="handleListViewAction('{{ action.value }}', {{ action.requiresSelection|tojson }})" onclick="handleListViewAction('{{ action.value }}', {{ action.requiresSelection|tojson }}, event)"
class="btn {{ action.class|default('btn-primary') }} ms-2 {% if action.requiresSelection %}requires-selection{% endif %}" class="btn {{ action.class|default('btn-primary') }} ms-2 {% if action.requiresSelection %}requires-selection{% endif %}"
{% if action.requiresSelection %}disabled{% endif %}> {% if action.requiresSelection %}disabled{% endif %}>
{{ action.text }} {{ action.text }}
@@ -59,7 +59,7 @@ document.addEventListener('DOMContentLoaded', function() {
button.disabled = true; button.disabled = true;
button.classList.add('disabled'); button.classList.add('disabled');
} }
}); })
// Voeg de benodigde functies toe als ze nog niet bestaan // Voeg de benodigde functies toe als ze nog niet bestaan
if (!window.EveAI.ListView.initialize) { if (!window.EveAI.ListView.initialize) {
@@ -96,6 +96,21 @@ document.addEventListener('DOMContentLoaded', function() {
return null; return null;
} }
// Register embedded handlers registry and custom formatters if not present
window.EveAI.ListView.embeddedHandlers = window.EveAI.ListView.embeddedHandlers || {};
window.EveAI.ListView.formatters = window.EveAI.ListView.formatters || {};
// Custom formatter: typeBadge (agent/task/tool)
window.EveAI.ListView.formatters.typeBadge = function(cell, formatterParams, onRendered) {
const val = (cell.getValue() || '').toString();
const map = {
'agent': { cls: 'badge bg-purple', label: 'Agent' },
'task': { cls: 'badge bg-orange', label: 'Task' },
'tool': { cls: 'badge bg-teal', label: 'Tool' },
};
const conf = map[val] || { cls: 'badge bg-secondary', label: val };
return `<span class="${conf.cls}">${conf.label}</span>`;
};
try { try {
// Create Tabulator table // Create Tabulator table
const tableContainer = document.createElement('div'); const tableContainer = document.createElement('div');
@@ -296,11 +311,13 @@ document.addEventListener('DOMContentLoaded', function() {
// Voeg formattering toe volgens de juiste manier per versie // Voeg formattering toe volgens de juiste manier per versie
if (col.formatter) { if (col.formatter) {
if (isTabulator6Plus) { // Resolve custom formatter name from registry when provided as string
column.formatterParams = { formatter: col.formatter }; let fmt = col.formatter;
} else { if (typeof fmt === 'string' && window.EveAI && window.EveAI.ListView && window.EveAI.ListView.formatters && window.EveAI.ListView.formatters[fmt]) {
column.formatter = col.formatter; fmt = window.EveAI.ListView.formatters[fmt];
} }
// Apply formatter for both Tabulator 5 and 6
column.formatter = fmt;
} }
// Voeg filtering toe volgens de juiste manier per versie // Voeg filtering toe volgens de juiste manier per versie
@@ -423,67 +440,62 @@ document.addEventListener('DOMContentLoaded', function() {
// Definieer de handleListViewAction functie als deze nog niet bestaat // Definieer de handleListViewAction functie als deze nog niet bestaat
if (typeof window.handleListViewAction !== 'function') { if (typeof window.handleListViewAction !== 'function') {
window.handleListViewAction = function(action, requiresSelection, e) { window.handleListViewAction = function(action, requiresSelection, e) {
// Gebruik explicit de event parameter om de browser event warning te vermijden // Prefer embedded handler when available (embedded mode)
const evt = e || window.event; const evt = e || window.event;
const target = evt?.target || evt?.srcElement; const target = evt?.target || evt?.srcElement;
// Voorkom acties vanuit gedisabled buttons // Determine tableId scoped to the closest container of this partial
if (target && (target.disabled || target.classList.contains('disabled'))) { let tableId = null;
console.log('Button actie geblokkeerd: button is disabled'); if (target) {
return false; const containerEl = target.closest('.container');
const scopedTable = containerEl ? containerEl.querySelector('.tabulator-list-view') : null;
tableId = scopedTable ? scopedTable.id : null;
} }
// Vind het tableId op basis van het formulier waarin we zitten if (!tableId) {
const form = target ? target.closest('form') : null; // fallback to previous behavior
const tableId = form ? form.id.replace('-form', '') : document.querySelector('.tabulator-list-view')?.id; const form = target ? target.closest('form') : null;
tableId = form ? form.id.replace('-form', '') : document.querySelector('.tabulator-list-view')?.id;
}
if (!tableId) { if (!tableId) {
console.error('Kan tableId niet bepalen voor action:', action); console.error('Kan tableId niet bepalen voor action:', action);
return false; return false;
} }
// Controleer direct of de button disabled zou moeten zijn // Enforce selection when required
if (requiresSelection === true) { if (requiresSelection === true) {
const instance = window.EveAI.ListView.instances[tableId]; const instance = window.EveAI.ListView.instances[tableId];
if (!instance || !instance.selectedRow) { if (!instance || !instance.selectedRow) {
console.log('Button actie geblokkeerd: geen rij geselecteerd');
return false;
}
}
// Als EveAI.ListView beschikbaar is, gebruik dan de handleAction functie
if (window.EveAI && window.EveAI.ListView && typeof window.EveAI.ListView.handleAction === 'function') {
return window.EveAI.ListView.handleAction(action, requiresSelection, tableId);
}
// Fallback naar de originele implementatie
const actionInput = document.getElementById(`${tableId}-action`);
if (actionInput) {
actionInput.value = action;
}
// Controleer of er een rij geselecteerd is indien nodig
if (requiresSelection) {
const selectedRowInput = document.getElementById(`${tableId}-selected-row`);
if (!selectedRowInput || !selectedRowInput.value) {
alert('Selecteer eerst een item uit de lijst.'); alert('Selecteer eerst een item uit de lijst.');
return false; return false;
} }
} }
// Verstuur het formulier met behoud van de originele form action // Embedded handler first
if (form) { const embedded = window.EveAI.ListView.embeddedHandlers && window.EveAI.ListView.embeddedHandlers[tableId];
// Controleer of de form action correct is ingesteld if (typeof embedded === 'function') {
if (!form.action || form.action === '' || form.action === window.location.href || form.action === window.location.pathname) { const instance = window.EveAI.ListView.instances[tableId];
console.warn('Form action is mogelijk niet correct ingesteld:', form.action); try {
// Als er geen action is ingesteld, gebruik dan de huidige URL embedded(action, instance ? instance.selectedRow : null, tableId);
form.action = window.location.href; return true;
} catch (err) {
console.error('Embedded handler error:', err);
return false;
} }
console.log(`Form action is: ${form.action}`);
form.submit();
return true;
} }
// Fallback to global handler (legacy behavior)
if (window.EveAI && window.EveAI.ListView && typeof window.EveAI.ListView.handleAction === 'function') {
return window.EveAI.ListView.handleAction(action, requiresSelection, tableId);
}
// Final fallback to form submit (legacy)
const actionInput = document.getElementById(`${tableId}-action`);
if (actionInput) actionInput.value = action;
const form = document.getElementById(`${tableId}-form`);
if (form) { form.submit(); return true; }
return false; return false;
}; };
} }

View File

@@ -4,25 +4,51 @@
{% block content_description %}{{ description }}{% endblock %} {% block content_description %}{{ description }}{% endblock %}
{% block content %} {% block content %}
{% set disabled_fields = [] %} <form id="componentEditForm" method="post">
{% set exclude_fields = [] %} {{ form.hidden_tag() }}
{% for field in form.get_static_fields() %} {% set disabled_fields = [] %}
{{ render_field(field, disabled_fields, exclude_fields) }} {% set exclude_fields = [] %}
{% endfor %} {% for field in form.get_static_fields() %}
{% if form.get_dynamic_fields is defined %} {{ render_field(field, disabled_fields, exclude_fields) }}
{% for collection_name, fields in form.get_dynamic_fields().items() %}
{% if fields|length > 0 %}
<h4 class="mt-4">{{ collection_name }}</h4>
{% endif %}
{% for field in fields %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
{% endfor %} {% endfor %}
{% if form.get_dynamic_fields is defined %}
{% for collection_name, fields in form.get_dynamic_fields().items() %}
{% if fields|length > 0 %}
<h4 class="mt-4">{{ collection_name }}</h4>
{% endif %}
{% for field in fields %}
{{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %}
{% endfor %}
{% endif %}
<div class="btn-group mt-3">
{% if enable_reset_defaults %}
<button type="button" class="btn btn-outline-secondary" id="resetDefaults"
data-model-default="{{ model_default | default('') }}"
data-temperature-default="{{ temperature_default | default('') }}">Reset to defaults</button>
{% endif %}
<button type="submit" class="btn btn-primary ms-2">{{ submit_text }}</button>
<button type="button" class="btn btn-secondary ms-2" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
{% if enable_reset_defaults %}
<script>
(function(){
const btn = document.getElementById('resetDefaults');
if (!btn) return;
btn.addEventListener('click', function(){
const modelDef = btn.getAttribute('data-model-default');
const tempDef = btn.getAttribute('data-temperature-default');
const modelEl = document.querySelector('#componentEditForm select[name="llm_model"]');
const tempEl = document.querySelector('#componentEditForm input[name="temperature"]');
if (modelEl && modelDef) { modelEl.value = modelDef; }
if (tempEl && tempDef) {
tempEl.value = tempDef;
}
});
})();
</script>
{% endif %} {% endif %}
<div class="btn-group mt-3">
<button type="submit" class="btn btn-primary component-submit">{{ submit_text }}</button>
<button type="button" class="btn btn-secondary ms-2" id="cancelEdit">Cancel</button>
</div>
{% endblock %} {% endblock %}
{% block content_footer %} {% block content_footer %}

View File

@@ -32,23 +32,8 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#agents-tab" role="tab"> <a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#components-tab" role="tab">
Agents Components
</a>
</li>
<li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#tasks-tab" role="tab">
Tasks
</a>
</li>
<li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#tools-tab" role="tab">
Tools
</a>
</li>
<li class="nav-item">
<a class="nav-link mb-0 px-0 py-1 d-none" id="editor-tab-link" data-bs-toggle="tab" href="#editor-tab" role="tab">
Editor
</a> </a>
</li> </li>
</ul> </ul>
@@ -63,17 +48,17 @@
{% endfor %} {% endfor %}
<!-- Overview Section --> <!-- Overview Section -->
<div class="row mb-4"> {# <div class="row mb-4">#}
<div class="col-12"> {# <div class="col-12">#}
<div class="card"> {# <div class="card">#}
<div class="card-body"> {# <div class="card-body">#}
<div class="specialist-overview" id="specialist-svg"> {# <div class="specialist-overview" id="specialist-svg">#}
<img src="{{ svg_path }}" alt="Specialist Overview" class="w-100"> {# <img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">#}
</div> {# </div>#}
</div> {# </div>#}
</div> {# </div>#}
</div> {# </div>#}
</div> {# </div>#}
</div> </div>
<!-- Configuration Tab --> <!-- Configuration Tab -->
@@ -88,79 +73,28 @@
{% endfor %} {% endfor %}
</div> </div>
<!-- Agents Tab --> <!-- Components Tab (Unified list view) -->
<div class="tab-pane fade" id="agents-tab" role="tabpanel"> <div class="tab-pane fade" id="components-tab" role="tabpanel">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{{ render_selectable_table( <div class="container">
headers=["Agent ID", "Name", "Type", "Type Version"], <input type="hidden" id="{{ components_table_id }}-selected-row" name="selected_row" value="">
rows=agent_rows if agent_rows else [], <input type="hidden" id="{{ components_table_id }}-action" name="action" value="">
selectable=True, <div id="{{ components_table_id }}" class="tabulator-list-view"></div>
id="agentsTable", <div class="row mt-3">
is_component_selector=True <div class="col-12">
) }} <button type="button"
<div class="form-group mt-3"> data-table-id="{{ components_table_id }}"
<button type="button" class="btn btn-primary edit-component" onclick="handleListViewAction('edit_component', true, event)"
data-component-type="agent" class="btn btn-primary requires-selection" disabled>
data-edit-url="{{ prefixed_url_for('interaction_bp.edit_agent', agent_id=0) }}">Edit Agent Edit
</button> </button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Tasks Tab -->
<div class="tab-pane fade" id="tasks-tab" role="tabpanel">
<div class="card">
<div class="card-body">
{{ render_selectable_table(
headers=["Task ID", "Name", "Type", "Type Version"],
rows=task_rows if task_rows else [],
selectable=True,
id="tasksTable",
is_component_selector=True
) }}
<div class="form-group mt-3">
<button type="button" class="btn btn-primary edit-component"
data-component-type="task"
data-edit-url="{{ prefixed_url_for('interaction_bp.edit_task', task_id=0) }}">Edit Task
</button>
</div>
</div>
</div>
</div>
<!-- Tools Tab -->
<div class="tab-pane fade" id="tools-tab" role="tabpanel">
<div class="card">
<div class="card-body">
{{ render_selectable_table(
headers=["Tool ID", "Name", "Type", "Type Version"],
rows=tool_rows if tool_rows else [],
selectable=True,
id="toolsTable",
is_component_selector=True
) }}
<div class="form-group mt-3">
<button type="button" class="btn btn-primary edit-component"
data-component-type="tool"
data-edit-url="{{ prefixed_url_for('interaction_bp.edit_tool', tool_id=0) }}">Edit Tool
</button>
</div>
</div>
</div>
</div>
<!-- Editor Tab -->
<div class="tab-pane fade" id="editor-tab" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0" id="editorTitle"></h5>
</div>
<div class="card-body" id="editorContent">
<!-- Component editor will be loaded here -->
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -170,245 +104,219 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Component Editor Modal (intentionally placed outside the main form to avoid nested forms) -->
<div class="modal fade" id="componentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="componentModalLabel">Edit Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="componentModalBody">
<!-- Partial form will be injected here -->
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ url_for('static', filename='assets/js/eveai-list-view.js') }}"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const editorTab = document.getElementById('editor-tab'); const componentModalEl = document.getElementById('componentModal');
const editorTabLink = document.getElementById('editor-tab-link'); const componentModalBody = document.getElementById('componentModalBody');
const editorTitle = document.getElementById('editorTitle'); const componentModalLabel = document.getElementById('componentModalLabel');
const editorContent = document.getElementById('editorContent'); let componentModal;
let previousTab = null;
// Add color classes to the tabs // Initialize the combined components list using EveAI.ListView
const agentsTabLink = document.querySelector('[href="#agents-tab"]'); function initComponentsList() {
const tasksTabLink = document.querySelector('[href="#tasks-tab"]'); function tryInit() {
const toolsTabLink = document.querySelector('[href="#tools-tab"]'); if (window.EveAI && window.EveAI.ListView && typeof window.EveAI.ListView.initialize === 'function') {
const cfg = {
data: {{ components_data | tojson }},
columns: {{ components_columns | tojson }},
initialSort: {{ components_initial_sort | tojson }},
index: {{ components_index | tojson }},
actions: {{ components_actions | tojson }},
tableHeight: {{ components_table_height|default(600) }},
selectable: true
};
// Resolve string formatters (e.g., 'typeBadge') to functions before initializing Tabulator
if (window.EveAI && window.EveAI.ListView && window.EveAI.ListView.formatters && Array.isArray(cfg.columns)) {
cfg.columns = cfg.columns.map(col => {
if (typeof col.formatter === 'string' && window.EveAI.ListView.formatters[col.formatter]) {
return { ...col, formatter: window.EveAI.ListView.formatters[col.formatter] };
}
return col;
});
}
const table = window.EveAI.ListView.initialize('{{ components_table_id }}', cfg);
// Expose for quick debugging
window.__componentsTable = table;
agentsTabLink.classList.add('component-agent'); // Fallback: ensure instance registry is populated even if the module didn't store it yet
tasksTabLink.classList.add('component-task'); window.EveAI.ListView.instances = window.EveAI.ListView.instances || {};
toolsTabLink.classList.add('component-tool'); if (!window.EveAI.ListView.instances['{{ components_table_id }}'] && table) {
window.EveAI.ListView.instances['{{ components_table_id }}'] = {
table: table,
config: cfg,
selectedRow: null
};
}
// Ensure single-click selects exactly one row (defensive)
if (table && typeof table.on === 'function') {
try {
table.on('rowClick', function(e, row){ row.getTable().deselectRow(); row.select(); });
table.on('cellClick', function(e, cell){ const r = cell.getRow(); r.getTable().deselectRow(); r.select(); });
table.on('rowSelectionChanged', function(data, rows){
const inst = window.EveAI.ListView.instances['{{ components_table_id }}'];
if (inst) { inst.selectedRow = rows.length ? rows[0].getData() : null; }
if (typeof window.EveAI.ListView.updateActionButtons === 'function') {
window.EveAI.ListView.updateActionButtons('{{ components_table_id }}');
}
});
} catch (err) { console.warn('Could not attach selection handlers:', err); }
}
// Add background colors to the tab panes // Register embedded action handler for this table
const agentsTab = document.getElementById('agents-tab'); window.EveAI.ListView.embeddedHandlers = window.EveAI.ListView.embeddedHandlers || {};
const tasksTab = document.getElementById('tasks-tab'); window.EveAI.ListView.embeddedHandlers['{{ components_table_id }}'] = function(action, row, tableId){
const toolsTab = document.getElementById('tools-tab'); if (action !== 'edit_component' || !row) return;
const id = row.id;
const type = row.type_name; // 'agent' | 'task' | 'tool'
agentsTab.classList.add('component-agent-bg'); // Build edit URL from server-side templates with placeholder 0
tasksTab.classList.add('component-task-bg'); const editUrls = {
toolsTab.classList.add('component-tool-bg'); agent: "{{ prefixed_url_for('interaction_bp.edit_agent', agent_id=0) }}",
task: "{{ prefixed_url_for('interaction_bp.edit_task', task_id=0) }}",
tool: "{{ prefixed_url_for('interaction_bp.edit_tool', tool_id=0) }}",
};
const url = (editUrls[type] || '').replace('/0', `/${id}`);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(resp => { if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.text(); })
.then(html => {
componentModalBody.innerHTML = html;
componentModalLabel.textContent = `Edit ${type.charAt(0).toUpperCase()+type.slice(1)}`;
componentModalEl.dataset.componentType = type;
componentModalEl.dataset.componentId = id;
// Ensure component selectors don't interfere with form submission // Helper to open modal once Bootstrap is available
const form = document.getElementById('specialistForm'); function openModal() {
try {
if (!componentModal) componentModal = new bootstrap.Modal(componentModalEl);
componentModal.show();
} catch (e) {
console.error('Failed to open Bootstrap modal:', e);
alert('Kan de editor niet openen (Bootstrap modal ontbreekt).');
}
}
form.addEventListener('submit', function(e) { if (window.bootstrap && typeof bootstrap.Modal === 'function') {
// Remove component selectors from form validation openModal();
const componentSelectors = form.querySelectorAll('input[data-component-selector]'); } else {
componentSelectors.forEach(selector => { // Fallback: laad Bootstrap bundle dynamisch en open daarna
selector.removeAttribute('required'); const script = document.createElement('script');
}); script.src = 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js';
}); script.onload = () => openModal();
script.onerror = () => {
// Get all tab links except the editor tab console.error('Kon Bootstrap bundle niet laden');
const tabLinks = Array.from(document.querySelectorAll('.nav-link')).filter(link => link.id !== 'editor-tab-link'); alert('Kan de editor niet openen omdat Bootstrap JS ontbreekt.');
};
// Function to toggle other tabs' disabled state document.head.appendChild(script);
function toggleOtherTabs(disable) { }
tabLinks.forEach(link => { })
if (disable) { .catch(err => {
link.classList.add('disabled'); console.error('Error loading editor:', err);
alert('Error loading editor. Please try again.');
});
};
} else { } else {
link.classList.remove('disabled'); setTimeout(tryInit, 100);
} }
});
}
// Function to toggle main form elements
const mainSubmitButton = document.querySelector('#specialistForm > .btn-primary');
function toggleMainFormElements(disable) {
// Toggle tabs
document.querySelectorAll('.nav-link').forEach(link => {
if (link.id !== 'editor-tab-link') {
if (disable) {
link.classList.add('disabled');
} else {
link.classList.remove('disabled');
}
}
});
// Toggle main submit button
if (mainSubmitButton) {
mainSubmitButton.disabled = disable;
} }
tryInit();
} }
// Handle edit buttons initComponentsList();
document.querySelectorAll('.edit-component').forEach(button => {
button.addEventListener('click', function() {
const componentType = this.dataset.componentType;
const form = this.closest('form');
const selectedRow = form.querySelector('input[type="radio"]:checked');
console.log("I'm in the custom click event listener!") // Note: we removed the modal footer submit button. The partial provides its own buttons.
// Submissions are intercepted via the submit listener on componentModalBody below.
if (!selectedRow) { // Refresh the components list data after a successful save
alert('Please select a component to edit'); function refreshComponentsData() {
const url = "{{ prefixed_url_for('interaction_bp.specialist_components_data', specialist_id=specialist_id) }}";
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
.then(resp => resp.json())
.then(payload => {
const instance = window.EveAI.ListView.instances['{{ components_table_id }}'];
if (instance && instance.table && Array.isArray(payload.data)) {
instance.table.setData(payload.data);
}
})
.catch(err => console.error('Failed to refresh components data', err));
}
// Intercept native form submit events within the modal (handles Enter key too)
componentModalBody.addEventListener('submit', function(e) {
const formEl = e.target.closest('#componentEditForm');
if (!formEl) return; // Not our form
e.preventDefault();
const formData = new FormData(formEl);
const componentType = componentModalEl.dataset.componentType;
const componentId = componentModalEl.dataset.componentId;
// Build robust, prefix-aware absolute save URL from server-side templates
const saveUrls = {
agent: "{{ prefixed_url_for('interaction_bp.save_agent', agent_id=0) }}",
task: "{{ prefixed_url_for('interaction_bp.save_task', task_id=0) }}",
tool: "{{ prefixed_url_for('interaction_bp.save_tool', tool_id=0) }}",
};
const urlTemplate = saveUrls[componentType];
const saveUrl = urlTemplate.replace('/0', `/${componentId}`);
fetch(saveUrl, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(async resp => {
const ct = resp.headers.get('Content-Type') || '';
if (resp.ok) {
// Expect JSON success
let data = null;
try { data = await resp.json(); } catch (_) {}
if (data && data.success) {
componentModal.hide();
refreshComponentsData();
return;
}
throw new Error(data && data.message ? data.message : 'Save failed');
}
// For validation errors (400), server returns HTML partial -> replace modal body
if (resp.status === 400 && ct.includes('text/html')) {
const html = await resp.text();
componentModalBody.innerHTML = html;
return; return;
} }
// Other errors
const valueMatch = selectedRow.value.match(/'value':\s*(\d+)/); let message = 'Save failed';
const selectedId = valueMatch ? valueMatch[1] : null; if (ct.includes('application/json')) {
try {
if (!selectedId) { const data = await resp.json();
console.error('Could not extract ID from value:', selectedRow.value); if (data && data.message) message = data.message;
alert('Error: Could not determine component ID'); } catch (_) {}
return;
} }
throw new Error(message + ` (HTTP ${resp.status})`);
// Make AJAX call to get component editor })
const urlTemplate = this.dataset.editUrl.replace('/0', `/${selectedId}`); .catch(err => {
fetch(urlTemplate, { console.error('Error saving component:', err);
headers: { alert(err.message || 'Error saving component');
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
})
.then(html => {
// Store the current active tab
previousTab = document.querySelector('.nav-link.active');
// Update editor content
editorTitle.textContent = `Edit ${componentType.charAt(0).toUpperCase() + componentType.slice(1)}`;
editorContent.innerHTML = html;
// Apply the appropriate color class to the editor tab
editorTabLink.classList.remove('component-agent', 'component-task', 'component-tool');
editorTab.classList.remove('component-agent-bg', 'component-task-bg', 'component-tool-bg');
editorTabLink.classList.add(`component-${componentType}`);
editorTab.classList.add(`component-${componentType}-bg`);
// Disable other tabs & main form elements
toggleOtherTabs(true);
toggleMainFormElements(true)
// Show editor tab and switch to it
editorTabLink.classList.remove('d-none');
editorTabLink.click();
})
.catch(error => {
console.error('Error fetching editor:', error);
alert('Error loading editor. Please try again.');
});
}); });
// Clean up color classes when returning from editor
editorTabLink.addEventListener('hide.bs.tab', function() {
editorTabLink.classList.remove('component-agent', 'component-task', 'component-tool');
editorTab.classList.remove('component-agent-bg', 'component-task-bg', 'component-tool-bg');
});
});
// Handle component editor form submissions
editorContent.addEventListener('click', function(e) {
if (e.target && e.target.classList.contains('component-submit')) {
e.preventDefault();
console.log('Submit button clicked');
// Get all form fields from the editor content
const formData = new FormData();
editorContent.querySelectorAll('input, textarea, select').forEach(field => {
if (field.type === 'checkbox') {
formData.append(field.name, field.checked ? 'y' : 'n');
} else {
formData.append(field.name, field.value);
}
});
// Add CSRF token
const csrfToken = document.querySelector('input[name="csrf_token"]').value;
formData.append('csrf_token', csrfToken);
// Get the component ID from the current state
const selectedRow = document.querySelector('input[name="selected_row"]:checked');
const componentData = JSON.parse(selectedRow.value.replace(/'/g, '"'));
const componentId = componentData.value;
// Determine the component type (agent, task, or tool)
const componentType = editorTabLink.classList.contains('component-agent') ? 'agent' :
editorTabLink.classList.contains('component-task') ? 'task' : 'tool';
// Submit the data
fetch(`interaction/${componentType}/${componentId}/save`, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Handle success - reload the page
location.reload();
} else {
// Handle error
alert(data.message || 'Error saving component');
}
})
.catch(error => {
console.error('Error:', error);
alert('Error saving component');
});
}
});
// Handle case when editor tab is hidden
editorTabLink.addEventListener('hide.bs.tab', function() {
// Re-enable all tabs & main form elements
toggleOtherTabs(false);
toggleMainFormElements(false)
// Remove color classes
editorTabLink.classList.remove('component-agent', 'component-task', 'component-tool');
editorTab.classList.remove('component-agent-bg', 'component-task-bg', 'component-tool-bg');
});
// Function to handle canceling edit
function cancelEdit() {
// Re-enable all tabs & main form elements
toggleOtherTabs(false);
toggleMainFormElements()
// Return to previous tab
if (previousTab) {
previousTab.click();
}
// Hide the editor tab
editorTabLink.classList.add('d-none');
}
// Handle cancel button in editor
document.addEventListener('click', function(e) {
if (e.target && e.target.id === 'cancelEdit') {
// Get the previously active tab (stored before switching to editor)
const previousTab = document.querySelector('[href="#configuration-tab"]'); // default to configuration tab
cancelEdit()
// Hide the editor tab
document.getElementById('editor-tab-link').classList.add('d-none');
}
}); });
}); });
</script> </script>

View File

@@ -122,21 +122,41 @@ function validateTableSelection(formId) {
} }
</script> </script>
<script> <script>
$(document).ready(function() { (function(){
// Maak tabelrijen klikbaar (voor tabellen met radio-buttons) function initClickableRowsWithjQuery(){
$(document).on('click', 'table tbody tr', function(e) { // Maak tabelrijen klikbaar (voor tabellen met radio-buttons)
// Voorkom dat dit gedrag optreedt als er direct op de radio-button of een link wordt geklikt $(document).on('click', 'table tbody tr', function(e) {
if (!$(e.target).is('input[type="radio"], a')) { // Voorkom dat dit gedrag optreedt als er direct op de radio-button of een link wordt geklikt
// Vind de radio-button in deze rij if (!$(e.target).is('input[type="radio"], a')) {
const radio = $(this).find('input[type="radio"]'); // Vind de radio-button in deze rij
// Selecteer de radio-button const radio = $(this).find('input[type="radio"]');
radio.prop('checked', true); // Selecteer de radio-button
// Voeg visuele feedback toe voor de gebruiker radio.prop('checked', true);
$('table tbody tr').removeClass('table-active'); // Voeg visuele feedback toe voor de gebruiker
$(this).addClass('table-active'); $('table tbody tr').removeClass('table-active');
} $(this).addClass('table-active');
}); }
}); });
}
if (window.$) {
$(document).ready(initClickableRowsWithjQuery);
} else {
// Fallback zonder jQuery: beperkte ondersteuning
document.addEventListener('click', function(e){
const row = e.target.closest('table tbody tr');
if (!row) return;
// klik op radio/link niet overrulen
if (e.target.closest('input[type="radio"], a')) return;
const radio = row.querySelector('input[type="radio"]');
if (radio) {
radio.checked = true;
// visuele feedback
row.closest('tbody')?.querySelectorAll('tr').forEach(tr => tr.classList.remove('table-active'));
row.classList.add('table-active');
}
});
}
})();
</script> </script>
<style> <style>

View File

@@ -155,7 +155,7 @@ class EditRetrieverForm(DynamicFormBase):
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
# Select Field for Retriever Type (Uses the RETRIEVER_TYPES defined in config) # Select Field for Retriever Type (Uses the RETRIEVER_TYPES defined in config)
type = StringField('Processor Type', validators=[DataRequired()], render_kw={'readonly': True}) type = StringField('Retriever Type', validators=[DataRequired()], render_kw={'readonly': True})
type_version = StringField('Retriever Type Version', validators=[DataRequired()], render_kw={'readonly': True}) type_version = StringField('Retriever Type Version', validators=[DataRequired()], render_kw={'readonly': True})
tuning = BooleanField('Enable Tuning', default=False) tuning = BooleanField('Enable Tuning', default=False)

View File

@@ -97,7 +97,8 @@ class OrderedListField(TextAreaField):
class DynamicFormBase(FlaskForm): class DynamicFormBase(FlaskForm):
def __init__(self, formdata=None, *args, **kwargs): def __init__(self, formdata=None, *args, **kwargs):
super(DynamicFormBase, self).__init__(*args, **kwargs) # Belangrijk: formdata doorgeven aan FlaskForm zodat WTForms POST-data kan binden
super(DynamicFormBase, self).__init__(formdata=formdata, *args, **kwargs)
# Maps collection names to lists of field names # Maps collection names to lists of field names
self.dynamic_fields = {} self.dynamic_fields = {}
# Store formdata for later use # Store formdata for later use

View File

@@ -1,9 +1,9 @@
from flask import session from flask import session, current_app
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (StringField, BooleanField, SelectField, TextAreaField) from wtforms import (StringField, BooleanField, SelectField, TextAreaField)
from wtforms.fields.datetime import DateField from wtforms.fields.datetime import DateField
from wtforms.fields.numeric import IntegerField from wtforms.fields.numeric import IntegerField, FloatField
from wtforms.validators import DataRequired, Length, Optional from wtforms.validators import DataRequired, Length, Optional, NumberRange
from wtforms_sqlalchemy.fields import QuerySelectMultipleField from wtforms_sqlalchemy.fields import QuerySelectMultipleField
@@ -91,6 +91,77 @@ class EditEveAIAgentForm(BaseEditComponentForm):
role = TextAreaField('Role', validators=[Optional()]) role = TextAreaField('Role', validators=[Optional()])
goal = TextAreaField('Goal', validators=[Optional()]) goal = TextAreaField('Goal', validators=[Optional()])
backstory = TextAreaField('Backstory', validators=[Optional()]) backstory = TextAreaField('Backstory', validators=[Optional()])
temperature = FloatField('Temperature', validators=[Optional(), NumberRange(min=0, max=1)])
llm_model = SelectField('LLM Model', validators=[Optional()])
def __init__(self, *args, **kwargs):
obj = kwargs.get('obj')
agent_type = None
agent_type_version = None
current_llm_model = None
current_temperature = None
if obj:
agent_type = obj.type
agent_type_version = obj.type_version
current_llm_model = obj.llm_model
current_temperature = obj.temperature
# Bewaar flags over oorspronkelijke None-status voor optionele normalisatie in populate_obj
self._was_llm_model_none = (current_llm_model is None)
self._was_temperature_none = (current_temperature is None)
super().__init__(*args, **kwargs)
# Choices instellen
if agent_type and agent_type_version:
current_app.logger.info(f"Loading agent config for {agent_type} {agent_type_version}")
self._agent_config = cache_manager.agents_config_cache.get_config(agent_type, agent_type_version)
allowed_models = self._agent_config.get('allowed_models', None)
full_model_name = self._agent_config.get('full_model_name', 'mistral.mistral-medium-latest')
default_temperature = self._agent_config.get('temperature', 0.7)
if allowed_models:
# Converteer lijst van strings naar lijst van tuples (value, label)
self.llm_model.choices = [(model, model) for model in allowed_models]
# Als er een waarde in de database staat, voeg die dan toe als die niet in de lijst zou voorkomen
if current_llm_model and current_llm_model not in allowed_models:
current_app.logger.warning(
f"Current model {current_llm_model} not in allowed models, adding it to choices"
)
self.llm_model.choices.append((current_llm_model, f"{current_llm_model} (legacy)"))
else:
# Gebruik full_model_name als fallback
self.llm_model.choices = [(full_model_name, full_model_name)]
# Defaults alleen instellen wanneer er geen formdata is (GET render of programmatic constructie)
is_post = bool(getattr(self, 'formdata', None))
if not is_post:
if current_llm_model is None:
self.llm_model.data = full_model_name
if current_temperature is None:
self.temperature.data = default_temperature
else:
self.llm_model.choices = [('mistral.mistral-medium-latest', 'mistral.mistral-medium-latest')]
def populate_obj(self, obj):
"""Override populate_obj om de None waarde te behouden indien nodig"""
# Roep de parent populate_obj aan
current_app.logger.info(f"populate_obj called with obj: {obj}")
super().populate_obj(obj)
current_app.logger.info(f"populate_obj done with obj: {obj}")
# Als de originele waarde None was EN de nieuwe waarde gelijk is aan de config default,
# herstel dan de None waarde (alleen als het eerder None was)
if getattr(self, '_agent_config', None):
full_model_name = self._agent_config.get('full_model_name', 'mistral.mistral-medium-latest')
if self._was_llm_model_none and obj.llm_model == full_model_name:
obj.llm_model = None
default_temperature = self._agent_config.get('temperature', 0.7)
if self._was_temperature_none and obj.temperature == default_temperature:
obj.temperature = None
current_app.logger.info(f"populate_obj default check results in obj: {obj}")
class EditEveAITaskForm(BaseEditComponentForm): class EditEveAITaskForm(BaseEditComponentForm):

View File

@@ -262,23 +262,42 @@ def edit_specialist(specialist_id):
db.session.rollback() db.session.rollback()
flash(f'Failed to update specialist. Error: {str(e)}', 'danger') flash(f'Failed to update specialist. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to update specialist {specialist_id}. Error: {str(e)}') current_app.logger.error(f'Failed to update specialist {specialist_id}. Error: {str(e)}')
# On error, re-render with components list config
from eveai_app.views.list_views.interaction_list_views import get_specialist_components_list_view
components_config = get_specialist_components_list_view(specialist)
return render_template('interaction/edit_specialist.html', return render_template('interaction/edit_specialist.html',
form=form, form=form,
specialist_id=specialist_id, specialist_id=specialist_id,
agent_rows=agent_rows, components_title=components_config.get('title'),
task_rows=task_rows, components_data=components_config.get('data'),
tool_rows=tool_rows, components_columns=components_config.get('columns'),
components_actions=components_config.get('actions'),
components_initial_sort=components_config.get('initial_sort'),
components_table_id=components_config.get('table_id'),
components_table_height=components_config.get('table_height'),
components_description=components_config.get('description'),
components_index=components_config.get('index'),
prefixed_url_for=prefixed_url_for, prefixed_url_for=prefixed_url_for,
svg_path=svg_path, ) svg_path=svg_path, )
else: else:
form_validation_failed(request, form) form_validation_failed(request, form)
# Build combined components list view config for embedding
from eveai_app.views.list_views.interaction_list_views import get_specialist_components_list_view
components_config = get_specialist_components_list_view(specialist)
return render_template('interaction/edit_specialist.html', return render_template('interaction/edit_specialist.html',
form=form, form=form,
specialist_id=specialist_id, specialist_id=specialist_id,
agent_rows=agent_rows, components_title=components_config.get('title'),
task_rows=task_rows, components_data=components_config.get('data'),
tool_rows=tool_rows, components_columns=components_config.get('columns'),
components_actions=components_config.get('actions'),
components_initial_sort=components_config.get('initial_sort'),
components_table_id=components_config.get('table_id'),
components_table_height=components_config.get('table_height'),
components_description=components_config.get('description'),
components_index=components_config.get('index'),
prefixed_url_for=prefixed_url_for, prefixed_url_for=prefixed_url_for,
svg_path=svg_path, ) svg_path=svg_path, )
@@ -310,6 +329,15 @@ def handle_specialist_selection():
return redirect(prefixed_url_for('interaction_bp.specialists', for_redirect=True)) return redirect(prefixed_url_for('interaction_bp.specialists', for_redirect=True))
@interaction_bp.route('/specialist/<int:specialist_id>/components_data', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def specialist_components_data(specialist_id):
"""Return JSON data for the specialist's combined components list (agents, tasks, tools)."""
specialist = Specialist.query.get_or_404(specialist_id)
from eveai_app.views.list_views.interaction_list_views import get_specialist_components_list_view
config = get_specialist_components_list_view(specialist)
return jsonify({'data': config.get('data', [])})
# Routes for Agent management --------------------------------------------------------------------- # Routes for Agent management ---------------------------------------------------------------------
@interaction_bp.route('/agent/<int:agent_id>/edit', methods=['GET']) @interaction_bp.route('/agent/<int:agent_id>/edit', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
@@ -318,22 +346,34 @@ def edit_agent(agent_id):
form = EditEveAIAgentForm(obj=agent) form = EditEveAIAgentForm(obj=agent)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest': if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# Determine defaults for reset button if available
enable_reset_defaults = False
model_default = ''
temperature_default = ''
if getattr(form, '_agent_config', None):
model_default = form._agent_config.get('full_model_name', 'mistral.mistral-medium-latest')
temperature_default = form._agent_config.get('temperature', 0.7)
enable_reset_defaults = True
# Return just the form portion for AJAX requests # Return just the form portion for AJAX requests
return render_template('interaction/components/edit_agent.html', return render_template('interaction/components/edit_agent.html',
form=form, form=form,
agent=agent, agent=agent,
title="Edit Agent", title="Edit Agent",
description="Configure the agent with company-specific details if required", description="Configure the agent with company-specific details if required",
submit_text="Save Agent") submit_text="Save Agent",
enable_reset_defaults=enable_reset_defaults,
model_default=model_default,
temperature_default=temperature_default)
return None return None
@interaction_bp.route('/agent/<int:agent_id>/save', methods=['POST']) @interaction_bp.route('/agent/<int:agent_id>/save', methods=['POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def save_agent(agent_id): def save_agent(agent_id):
current_app.logger.info(f'Trying to save agent {agent_id} -------------------------------------------')
agent = EveAIAgent.query.get_or_404(agent_id) if agent_id else EveAIAgent() agent = EveAIAgent.query.get_or_404(agent_id) if agent_id else EveAIAgent()
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
form = EditEveAIAgentForm(obj=agent) form = EditEveAIAgentForm(formdata=request.form, obj=agent)
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -347,8 +387,25 @@ def save_agent(agent_id):
db.session.rollback() db.session.rollback()
current_app.logger.error(f'Failed to save agent {agent_id} for tenant {tenant_id}. Error: {str(e)}') current_app.logger.error(f'Failed to save agent {agent_id} for tenant {tenant_id}. Error: {str(e)}')
return jsonify({'success': False, 'message': f"Failed to save agent {agent_id}: {str(e)}"}) return jsonify({'success': False, 'message': f"Failed to save agent {agent_id}: {str(e)}"})
else:
return jsonify({'success': False, 'message': 'Validation failed'}) # On validation errors, return the editor partial HTML so the frontend can display inline errors in the modal
form_validation_failed(request, form)
enable_reset_defaults = False
model_default = ''
temperature_default = ''
if getattr(form, '_agent_config', None):
model_default = form._agent_config.get('full_model_name', 'mistral.mistral-medium-latest')
temperature_default = form._agent_config.get('temperature', 0.7)
enable_reset_defaults = True
return render_template('interaction/components/edit_agent.html',
form=form,
agent=agent,
title="Edit Agent",
description="Configure the agent with company-specific details if required",
submit_text="Save Agent",
enable_reset_defaults=enable_reset_defaults,
model_default=model_default,
temperature_default=temperature_default), 400
# Routes for Task management ---------------------------------------------------------------------- # Routes for Task management ----------------------------------------------------------------------
@@ -374,7 +431,7 @@ def edit_task(task_id):
def save_task(task_id): def save_task(task_id):
task = EveAITask.query.get_or_404(task_id) if task_id else EveAITask() task = EveAITask.query.get_or_404(task_id) if task_id else EveAITask()
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
form = EditEveAITaskForm(obj=task) # Replace with actual task form form = EditEveAITaskForm(formdata=request.form, obj=task) # Bind explicit formdata
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -389,7 +446,14 @@ def save_task(task_id):
current_app.logger.error(f'Failed to save task {task_id} for tenant {tenant_id}. Error: {str(e)}') current_app.logger.error(f'Failed to save task {task_id} for tenant {tenant_id}. Error: {str(e)}')
return jsonify({'success': False, 'message': f"Failed to save task {task_id}: {str(e)}"}) return jsonify({'success': False, 'message': f"Failed to save task {task_id}: {str(e)}"})
return jsonify({'success': False, 'message': 'Validation failed'}) # On validation errors, return the editor partial HTML (400) so frontend can show inline errors
form_validation_failed(request, form)
return render_template('interaction/components/edit_task.html',
form=form,
task=task,
title="Edit Task",
description="Configure the task with company-specific details if required",
submit_text="Save Task"), 400
# Routes for Tool management ---------------------------------------------------------------------- # Routes for Tool management ----------------------------------------------------------------------
@@ -415,7 +479,7 @@ def edit_tool(tool_id):
def save_tool(tool_id): def save_tool(tool_id):
tool = EveAITool.query.get_or_404(tool_id) if tool_id else EveAITool() tool = EveAITool.query.get_or_404(tool_id) if tool_id else EveAITool()
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
form = EditEveAIToolForm(obj=tool) # Replace with actual tool form form = EditEveAIToolForm(formdata=request.form, obj=tool)
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -430,7 +494,14 @@ def save_tool(tool_id):
current_app.logger.error(f'Failed to save tool {tool_id} for tenant {tenant_id}. Error: {str(e)}') current_app.logger.error(f'Failed to save tool {tool_id} for tenant {tenant_id}. Error: {str(e)}')
return jsonify({'success': False, 'message': f"Failed to save tool {tool_id}: {str(e)}"}) return jsonify({'success': False, 'message': f"Failed to save tool {tool_id}: {str(e)}"})
return jsonify({'success': False, 'message': 'Validation failed'}) # On validation errors, return the editor partial HTML (400)
form_validation_failed(request, form)
return render_template('interaction/components/edit_tool.html',
form=form,
tool=tool,
title="Edit Tool",
description="Configure the tool with company-specific details if required",
submit_text="Save Tool"), 400
# Component selection handlers -------------------------------------------------------------------- # Component selection handlers --------------------------------------------------------------------

View File

@@ -245,3 +245,74 @@ def get_eveai_data_capsules_list_view():
'table_height': 800 'table_height': 800
} }
# Combined specialist components list view helper
def get_specialist_components_list_view(specialist):
"""Generate a combined list view configuration for a specialist's agents, tasks, and tools"""
# Build unified data rows: id, name, type_name (agent|task|tool), type, type_version
data = []
# Agents
for agent in getattr(specialist, 'agents', []) or []:
data.append({
'id': agent.id,
'name': getattr(agent, 'name', f'Agent {agent.id}'),
'type_name': 'agent',
'type': agent.type,
'type_version': agent.type_version,
'row_key': f"agent:{agent.id}",
})
# Tasks
for task in getattr(specialist, 'tasks', []) or []:
data.append({
'id': task.id,
'name': getattr(task, 'name', f'Task {task.id}'),
'type_name': 'task',
'type': task.type,
'type_version': task.type_version,
'row_key': f"task:{task.id}",
})
# Tools
for tool in getattr(specialist, 'tools', []) or []:
data.append({
'id': tool.id,
'name': getattr(tool, 'name', f'Tool {tool.id}'),
'type_name': 'tool',
'type': tool.type,
'type_version': tool.type_version,
'row_key': f"tool:{tool.id}",
})
current_app.logger.debug(f'Combined specialist components list view data: \n{data}')
# Sort ascending by id as requested
data.sort(key=lambda r: (r.get('id') or 0))
columns = [
{'title': 'ID', 'field': 'id', 'width': 80},
{'title': 'Name', 'field': 'name'},
{'title': 'Kind', 'field': 'type_name', 'formatter': 'typeBadge'},
{'title': 'Type', 'field': 'type'},
{'title': 'Type Version', 'field': 'type_version'},
]
actions = [
{'value': 'edit_component', 'text': 'Edit', 'class': 'btn-primary', 'requiresSelection': True},
]
initial_sort = [{'column': 'id', 'dir': 'asc'}]
return {
'title': 'Components',
'data': data,
'columns': columns,
'actions': actions,
'initial_sort': initial_sort,
'table_id': 'specialist_components_table',
'description': 'Agents, Tasks, and Tools associated with this specialist',
'table_height': 600,
'index': 'row_key',
}

View File

@@ -480,10 +480,16 @@ export default {
this.allMessages[messageIndex].content = eventData.answer; this.allMessages[messageIndex].content = eventData.answer;
this.allMessages[messageIndex].status = 'completed'; this.allMessages[messageIndex].status = 'completed';
// Handle form request if present // Handle form request: set when present, otherwise clear previous form
if (eventData.form_request) { if (eventData.form_request) {
console.log('Form request received:', eventData.form_request); console.log('Form request received:', eventData.form_request);
this.currentInputFormData = eventData.form_request; this.currentInputFormData = eventData.form_request;
} else {
console.log('Clearing currentInputFormData due to null/absent form_request');
this.currentInputFormData = null;
if (this.formValues) {
this.formValues = {};
}
} }
} }

View File

@@ -240,15 +240,15 @@ export default {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log('Progress update:', data); console.log('Progress update:', data);
// Check voor processing_type om te bepalen welke handler te gebruiken // Check voor processing_type om te bepalen welke handler te gebruiken (ondersteun legacy en genormaliseerde waarden)
if (data.processing_type === 'EveAI Specialist Complete') { if (data.processing_type === 'EveAI Specialist Complete' || data.processing_type === 'EVEAI_COMPLETE') {
console.log('Detected specialist complete via processing_type'); console.log('Detected specialist complete via processing_type');
this.handleSpecialistComplete(event); this.handleSpecialistComplete(event);
return; return;
} }
// Check voor andere completion statuses en errors // Check voor andere completion statuses en errors
if (data.processing_type === 'EveAI Specialist Error') if (data.processing_type === 'EveAI Specialist Error' || data.processing_type === 'EVEAI_ERROR')
{ {
console.log('Detected specialist error via processing_type or status'); console.log('Detected specialist error via processing_type or status');
this.handleSpecialistError(event); this.handleSpecialistError(event);

View File

@@ -301,7 +301,8 @@ def task_progress_stream(task_id):
mimetype='text/event-stream', mimetype='text/event-stream',
headers={ headers={
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no' 'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'
} }
) )
except Exception as e: except Exception as e:

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
from flask import current_app from flask import current_app
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy import text
from common.extensions import db, cache_manager from common.extensions import db, cache_manager
from common.models.interaction import ChatSession, Interaction from common.models.interaction import ChatSession, Interaction
@@ -111,6 +112,14 @@ class ChatSessionCacheHandler(CacheHandler[CachedSession]):
Note: Note:
Only adds the interaction if it has an answer Only adds the interaction if it has an answer
""" """
# Log connection context right before any potential lazy load of interaction properties
try:
sp = db.session.execute(text("SHOW search_path")).scalar()
cid = id(db.session.connection().connection)
current_app.logger.info(f"DBCTX before_lazy_load conn_id={cid} search_path={sp}")
except Exception:
pass
if not interaction.specialist_results: if not interaction.specialist_results:
return # Skip incomplete interactions return # Skip incomplete interactions

View File

@@ -0,0 +1,30 @@
CHANNEL_ADAPTATION = [
{
"name": "Mobile Chat/Text",
"description": "Short, scannable messages. Uses bullet points, emojis, and concise language.",
"when_to_use": "Instant messaging, SMS, or chatbots."
},
{
"name": "Voice/Spoken",
"description": "Natural, conversational language. Includes pauses, intonation, and simple sentences.",
"when_to_use": "Voice assistants, phone calls, or voice-enabled apps."
},
{
"name": "Email/Formal Text",
"description": "Structured, polished, and professional. Uses paragraphs and clear formatting.",
"when_to_use": "Emails, reports, or official documentation."
},
{
"name": "Multimodal",
"description": "Combines text with visuals, buttons, or interactive elements for clarity and engagement.",
"when_to_use": "Websites, apps, or platforms supporting rich media."
}
]
def get_channel_adaptation_context(channel_adaptation:str) -> str:
selected_channel_adaptation = next(
(item for item in CHANNEL_ADAPTATION if item["name"] == channel_adaptation),
None
)
channel_adaptation_context = f"{selected_channel_adaptation['description']}"
return channel_adaptation_context

View File

@@ -0,0 +1,30 @@
CONVERSATION_PURPOSE = [
{
"name": "Informative",
"description": "Focus on sharing facts, explanations, or instructions.",
"when_to_use": "User seeks knowledge, clarification, or guidance."
},
{
"name": "Persuasive",
"description": "Aim to convince, motivate, or drive action. Highlights benefits and calls to action.",
"when_to_use": "Sales, marketing, or when encouraging the user to take a specific step."
},
{
"name": "Supportive",
"description": "Empathetic, solution-oriented, and reassuring. Prioritizes the user's needs and emotions.",
"when_to_use": "Customer support, healthcare, or emotionally sensitive situations."
},
{
"name": "Collaborative",
"description": "Encourages dialogue, brainstorming, and co-creation. Invites user input and ideas.",
"when_to_use": "Teamwork, creative processes, or open-ended discussions."
}
]
def get_conversation_purpose_context(conversation_purpose:str) -> str:
selected_conversation_purpose = next(
(item for item in CONVERSATION_PURPOSE if item["name"] == conversation_purpose),
None
)
conversation_purpose_context = f"{selected_conversation_purpose['description']}"
return conversation_purpose_context

View File

@@ -0,0 +1,25 @@
RESPONSE_DEPTH = [
{
"name": "Concise",
"description": "Short, direct answers. No extra explanation or context. Remove unnecessary details. Only a few sentences.",
"when_to_use": "Quick queries, urgent requests, or when the user prefers brevity."
},
{
"name": "Balanced",
"description": "Clear answers with minimal context or next steps. Not too short, not too detailed. Max 2 paragraphs.",
"when_to_use": "General interactions, FAQs, or when the user needs a mix of speed and clarity."
},
{
"name": "Detailed",
"description": "Comprehensive answers with background, examples, ... the answer can be detailed with chapters, paragraphs, ...",
"when_to_use": "Complex topics, tutorials, or when the user requests in-depth information."
}
]
def get_response_depth_context(response_depth:str) -> str:
selected_response_depth = next(
(item for item in RESPONSE_DEPTH if item["name"] == response_depth),
None
)
response_depth_context = f"{selected_response_depth['description']}"
return response_depth_context

View File

@@ -4,7 +4,7 @@ from pydantic import BaseModel, Field
class RAGOutput(BaseModel): class RAGOutput(BaseModel):
answer: str = Field(None, description="Answer to the questions asked, in Markdown format.") answer: str = Field(None, description="Final answer to the question asked, in Markdown format.")
insufficient_info: bool = Field(None, description="An indication if there's insufficient information to answer") insufficient_info: bool = Field(None, description="An indication if there's insufficient information to answer")
model_config = { model_config = {

View File

@@ -0,0 +1,234 @@
import json
import random
from os import wait
from typing import Optional, List, Dict, Any
from crewai.flow.flow import start, listen, and_
from flask import current_app
from pydantic import BaseModel, Field
from common.services.utils.translation_services import TranslationServices
from common.utils.business_event_context import current_event
from eveai_chat_workers.definitions.conversation_purpose.conversation_purpose_v1_0 import \
get_conversation_purpose_context
from eveai_chat_workers.definitions.language_level.language_level_v1_0 import get_language_level_context
from eveai_chat_workers.definitions.response_depth.response_depth_v1_0 import get_response_depth_context
from eveai_chat_workers.definitions.tone_of_voice.tone_of_voice_v1_0 import get_tone_of_voice_context
from eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments
from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
from eveai_chat_workers.outputs.globals.rag.rag_v1_0 import RAGOutput
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
INSUFFICIENT_INFORMATION_MESSAGES = [
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
"There isnt enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
"I dont have enough details to give you a confident answer. You can always ask another question if youd like.",
"Unfortunately, I cant answer that accurately with the information at hand. Please feel free to ask something else.",
"Thats a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
"I wish I could help more, but the data I have isn't sufficient to answer this. Youre welcome to explore other questions.",
"Theres not enough context for me to provide a good answer. Dont hesitate to ask another question if you'd like!",
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
"Thanks for your question. At the moment, I cant give a solid answer — but I'm here if you want to ask something else!"
]
class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
"""
type: RAG_SPECIALIST
type_version: 1.0
RAG Specialist Executor class
"""
def __init__(self, tenant_id, specialist_id, session_id, task_id, **kwargs):
self.rag_crew = None
super().__init__(tenant_id, specialist_id, session_id, task_id)
@property
def type(self) -> str:
return "RAG_SPECIALIST"
@property
def type_version(self) -> str:
return "1.2"
def _config_task_agents(self):
self._add_task_agent("rag_task", "rag_agent")
def _config_pydantic_outputs(self):
self._add_pydantic_output("rag_task", RAGOutput, "rag_output")
def _config_state_result_relations(self):
self._add_state_result_relation("rag_output")
self._add_state_result_relation("citations")
def _instantiate_specialist(self):
verbose = self.tuning
rag_agents = [self.rag_agent]
rag_tasks = [self.rag_task]
self.rag_crew = EveAICrewAICrew(
self,
"Rag Crew",
agents=rag_agents,
tasks=rag_tasks,
verbose=verbose,
)
self.flow = RAGFlow(
self,
self.rag_crew,
)
def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("RAG Specialist execution started", {})
if not self._cached_session.interactions:
specialist_phase = "initial"
else:
specialist_phase = self._cached_session.interactions[-1].specialist_results.get('phase', 'initial')
results = None
match specialist_phase:
case "initial":
results = self.execute_initial_state(arguments, formatted_context, citations)
case "rag":
results = self.execute_rag_state(arguments, formatted_context, citations)
self.log_tuning(f"RAG Specialist execution ended", {"Results": results.model_dump()})
return results
def execute_initial_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("RAG Specialist initial_state execution started", {})
welcome_message = self.specialist.configuration.get('welcome_message', 'Welcome! You can start asking questions')
welcome_message = TranslationServices.translate(self.tenant_id, welcome_message, arguments.language)
self.flow.state.answer = welcome_message
self.flow.state.phase = "rag"
results = RAGSpecialistResult.create_for_type(self.type, self.type_version)
return results
def execute_rag_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("RAG Specialist rag_state execution started", {})
insufficient_info_message = TranslationServices.translate(self.tenant_id,
random.choice(INSUFFICIENT_INFORMATION_MESSAGES),
arguments.language)
formatted_context, citations = self._retrieve_context(arguments)
self.flow.state.citations = citations
tone_of_voice = self.specialist.configuration.get('tone_of_voice', 'Professional & Neutral')
tone_of_voice_context = get_tone_of_voice_context(tone_of_voice)
language_level = self.specialist.configuration.get('language_level', 'Standard')
language_level_context = get_language_level_context(language_level)
response_depth = self.specialist.configuration.get('response_depth', 'Balanced')
response_depth_context = get_response_depth_context(response_depth)
conversation_purpose = self.specialist.configuration.get('conversation_purpose', 'Informative')
conversation_purpose_context = get_conversation_purpose_context(conversation_purpose)
if formatted_context:
flow_inputs = {
"language": arguments.language,
"question": arguments.question,
"context": formatted_context,
"history": self.formatted_history,
"name": self.specialist.configuration.get('name', ''),
"welcome_message": self.specialist.configuration.get('welcome_message', ''),
"tone_of_voice": tone_of_voice,
"tone_of_voice_context": tone_of_voice_context,
"language_level": language_level,
"language_level_context": language_level_context,
"response_depth": response_depth,
"response_depth_context": response_depth_context,
"conversation_purpose": conversation_purpose,
"conversation_purpose_context": conversation_purpose_context,
}
flow_results = self.flow.kickoff(inputs=flow_inputs)
if flow_results.rag_output.insufficient_info:
flow_results.rag_output.answer = insufficient_info_message
rag_output = flow_results.rag_output
else:
rag_output = RAGOutput(answer=insufficient_info_message, insufficient_info=True)
self.flow.state.rag_output = rag_output
self.flow.state.citations = citations
self.flow.state.answer = rag_output.answer
self.flow.state.phase = "rag"
results = RAGSpecialistResult.create_for_type(self.type, self.type_version)
return results
class RAGSpecialistInput(BaseModel):
language: Optional[str] = Field(None, alias="language")
question: Optional[str] = Field(None, alias="question")
context: Optional[str] = Field(None, alias="context")
history: Optional[str] = Field(None, alias="history")
name: Optional[str] = Field(None, alias="name")
welcome_message: Optional[str] = Field(None, alias="welcome_message")
tone_of_voice: Optional[str] = Field(None, alias="tone_of_voice")
tone_of_voice_context: Optional[str] = Field(None, alias="tone_of_voice_context")
language_level: Optional[str] = Field(None, alias="language_level")
language_level_context: Optional[str] = Field(None, alias="language_level_context")
response_depth: Optional[str] = Field(None, alias="response_depth")
response_depth_context: Optional[str] = Field(None, alias="response_depth_context")
conversation_purpose: Optional[str] = Field(None, alias="conversation_purpose")
conversation_purpose_context: Optional[str] = Field(None, alias="conversation_purpose_context")
class RAGSpecialistResult(SpecialistResult):
rag_output: Optional[RAGOutput] = Field(None, alias="Rag Output")
class RAGFlowState(EveAIFlowState):
"""Flow state for RAG specialist that automatically updates from task outputs"""
input: Optional[RAGSpecialistInput] = None
rag_output: Optional[RAGOutput] = None
citations: Optional[List[Dict[str, Any]]] = None
class RAGFlow(EveAICrewAIFlow[RAGFlowState]):
def __init__(self,
specialist_executor: CrewAIBaseSpecialistExecutor,
rag_crew: EveAICrewAICrew,
**kwargs):
super().__init__(specialist_executor, "RAG Specialist Flow", **kwargs)
self.specialist_executor = specialist_executor
self.rag_crew = rag_crew
self.exception_raised = False
@start()
def process_inputs(self):
return ""
@listen(process_inputs)
async def execute_rag(self):
inputs = self.state.input.model_dump()
try:
crew_output = await self.rag_crew.kickoff_async(inputs=inputs)
self.specialist_executor.log_tuning("RAG Crew Output", crew_output.model_dump())
output_pydantic = crew_output.pydantic
if not output_pydantic:
raw_json = json.loads(crew_output.raw)
output_pydantic = RAGOutput.model_validate(raw_json)
self.state.rag_output = output_pydantic
return crew_output
except Exception as e:
current_app.logger.error(f"CREW rag_crew Kickoff Error: {str(e)}")
self.exception_raised = True
raise e
async def kickoff_async(self, inputs=None):
self.state.input = RAGSpecialistInput.model_validate(inputs)
result = await super().kickoff_async(inputs)
return self.state

View File

@@ -351,16 +351,27 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict
return response return response
except Exception as e: except Exception as e:
# Ensure DB session is usable after an error
try:
db.session.rollback()
except Exception:
pass
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
ept.send_update(task_id, "EveAI Specialist Error", {'Error': str(e)}) ept.send_update(task_id, "EveAI Specialist Error", {'Error': str(e)})
current_app.logger.error(f'execute_specialist: Error executing specialist: {e}\n{stacktrace}') current_app.logger.error(f'execute_specialist: Error executing specialist: {e}\n{stacktrace}')
new_interaction.processing_error = str(e)[:255] if new_interaction is not None:
try: new_interaction.processing_error = str(e)[:255]
db.session.add(new_interaction) try:
db.session.commit() db.session.add(new_interaction)
except SQLAlchemyError as e: db.session.commit()
stacktrace = traceback.format_exc() except SQLAlchemyError as e:
current_app.logger.error(f'execute_specialist: Error updating interaction: {e}\n{stacktrace}') # On failure to update, rollback and log
try:
db.session.rollback()
except Exception:
pass
stacktrace = traceback.format_exc()
current_app.logger.error(f'execute_specialist: Error updating interaction: {e}\n{stacktrace}')
self.update_state(state=states.FAILURE) self.update_state(state=states.FAILURE)
raise raise

View File

@@ -70,10 +70,12 @@ target_db = current_app.extensions['migrate'].db
def get_public_table_names(): def get_public_table_names():
# TODO: This function should include the necessary functionality to automatically retrieve table names # TODO: This function should include the necessary functionality to automatically retrieve table names
return ['role', 'roles_users', 'tenant', 'user', 'tenant_domain','license_tier', 'license', 'license_usage', return ['tenant', 'role', 'roles_users', 'user', 'tenant_domain', 'tenant_project', 'tenant_make', 'partner',
'business_event_log', 'tenant_project', 'partner', 'partner_service', 'invoice', 'license_period', 'partner_service', 'partner_tenant', 'tenant_consent', 'consent_version', 'specialist_magic_link_tenant',
'license_change_log', 'partner_service_license_tier', 'payment', 'partner_tenant', 'tenant_make', 'translation_cache',
'specialist_magic_link_tenant', 'translation_cache'] 'business_event_log', 'license', 'license_tier', 'partner_service_license_tier', 'license_period',
'license_usage', 'payment', 'invoice', 'license_change_log',
]
PUBLIC_TABLES = get_public_table_names() PUBLIC_TABLES = get_public_table_names()
logger.info(f"Public tables: {PUBLIC_TABLES}") logger.info(f"Public tables: {PUBLIC_TABLES}")
@@ -147,7 +149,7 @@ def run_migrations_online():
for tenant in tenants: for tenant in tenants:
try: try:
os.environ['TENANT_ID'] = str(tenant) os.environ['TENANT_ID'] = str(tenant)
logger.info(f"Migrating tenant: {tenant}") logger.info(f"🚧 Migrating tenant: {tenant}")
# set search path on the connection, which ensures that # set search path on the connection, which ensures that
# PostgreSQL will emit all CREATE / ALTER / DROP statements # PostgreSQL will emit all CREATE / ALTER / DROP statements
# in terms of this schema by default # in terms of this schema by default
@@ -169,11 +171,13 @@ def run_migrations_online():
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
logger.info(f"✅ Migration successfully completed for tenant: {tenant}")
# for checking migrate or upgrade is running # for checking migrate or upgrade is running
if getattr(config.cmd_opts, "autogenerate", False): if getattr(config.cmd_opts, "autogenerate", False):
break break
except Exception as e: except Exception as e:
continue logger.error(f"🚨 An error occurred during migration: \n{e}")
if context.is_offline_mode(): if context.is_offline_mode():

View File

@@ -0,0 +1,31 @@
"""Add Temperature and Allowed Models configuration to EveAIAgent
Revision ID: 0e3dd4d218be
Revises: 5e3dd539e5c1
Create Date: 2025-10-22 14:40:15.686495
"""
from alembic import op
import sqlalchemy as sa
import pgvector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0e3dd4d218be'
down_revision = '5e3dd539e5c1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('eve_ai_agent', sa.Column('temperature', sa.Float(), nullable=True))
op.add_column('eve_ai_agent', sa.Column('llm_model', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('eve_ai_agent', 'llm_model')
op.drop_column('eve_ai_agent', 'temperature')
# ### end Alembic commands ###

View File

@@ -28,3 +28,40 @@ if (typeof TabulatorFull.prototype.moduleRegistered !== 'function' ||
TabulatorFull.modules.format = TabulatorFull.modules.format || {}; TabulatorFull.modules.format = TabulatorFull.modules.format || {};
TabulatorFull.modules.format.formatters = TabulatorFull.modules.format.formatters || {}; TabulatorFull.modules.format.formatters = TabulatorFull.modules.format.formatters || {};
} }
// Registreer een universele formatter 'typeBadge' zodat string-formatters altijd werken
try {
if (typeof TabulatorFull.prototype.extendModule === 'function') {
TabulatorFull.prototype.extendModule('format', 'formatters', {
typeBadge: function(cell) {
const raw = (cell.getValue() || '').toString();
const val = raw.toLowerCase();
const map = {
'agent': { cls: 'badge text-bg-primary', label: raw || 'Agent' },
'task': { cls: 'badge text-bg-warning', label: raw || 'Task' },
'tool': { cls: 'badge text-bg-info', label: raw || 'Tool' },
};
const conf = map[val] || { cls: 'badge text-bg-secondary', label: raw };
return `<span class="${conf.cls}">${conf.label}</span>`;
}
});
} else {
// Fallback voor oudere Tabulator builds zonder extendModule
TabulatorFull.modules = TabulatorFull.modules || {};
TabulatorFull.modules.format = TabulatorFull.modules.format || {};
TabulatorFull.modules.format.formatters = TabulatorFull.modules.format.formatters || {};
TabulatorFull.modules.format.formatters.typeBadge = function(cell) {
const raw = (cell.getValue() || '').toString();
const val = raw.toLowerCase();
const map = {
'agent': { cls: 'badge text-bg-primary', label: raw || 'Agent' },
'task': { cls: 'badge text-bg-warning', label: raw || 'Task' },
'tool': { cls: 'badge text-bg-info', label: raw || 'Tool' },
};
const conf = map[val] || { cls: 'badge text-bg-secondary', label: raw };
return `<span class="${conf.cls}">${conf.label}</span>`;
};
}
} catch (e) {
console.warn('Kon typeBadge formatter niet registreren:', e);
}