airflow-chat 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airflow_chat/__init__.py +0 -0
- airflow_chat/plugins/README.md +1 -0
- airflow_chat/plugins/__init__.py +0 -0
- airflow_chat/plugins/airflow_chat.py +482 -0
- airflow_chat/plugins/templates/chat_interface.html +833 -0
- airflow_chat-0.1.0a1.dist-info/LICENSE +21 -0
- airflow_chat-0.1.0a1.dist-info/METADATA +19 -0
- airflow_chat-0.1.0a1.dist-info/RECORD +10 -0
- airflow_chat-0.1.0a1.dist-info/WHEEL +4 -0
- airflow_chat-0.1.0a1.dist-info/entry_points.txt +3 -0
airflow_chat/__init__.py
ADDED
File without changes
|
@@ -0,0 +1 @@
|
|
1
|
+
# Airflow Chat Plugin
|
File without changes
|
@@ -0,0 +1,482 @@
|
|
1
|
+
from airflow import settings
|
2
|
+
from airflow.plugins_manager import AirflowPlugin
|
3
|
+
from flask import Blueprint, request, jsonify, Response, stream_template
|
4
|
+
from flask_login import current_user
|
5
|
+
from flask_appbuilder import expose, BaseView as AppBuilderBaseView
|
6
|
+
from functools import wraps
|
7
|
+
import json
|
8
|
+
import time
|
9
|
+
import requests
|
10
|
+
import os
|
11
|
+
from datetime import datetime, timedelta
|
12
|
+
import uuid
|
13
|
+
from sqlalchemy import Column, String, DateTime, Text, create_engine
|
14
|
+
from sqlalchemy.ext.declarative import declarative_base
|
15
|
+
from sqlalchemy.orm import sessionmaker
|
16
|
+
from sqlalchemy.exc import SQLAlchemyError
|
17
|
+
|
18
|
+
# Blueprint for the chat plugin
|
19
|
+
bp = Blueprint(
|
20
|
+
"airflow_chat",
|
21
|
+
__name__,
|
22
|
+
template_folder="templates",
|
23
|
+
static_folder="static",
|
24
|
+
static_url_path="/static/airflow_chat_plugin",
|
25
|
+
)
|
26
|
+
|
27
|
+
# Database model for storing chat sessions
|
28
|
+
Base = declarative_base()
|
29
|
+
|
30
|
+
|
31
|
+
class ChatSession(Base):
|
32
|
+
__tablename__ = 'airflow_chat_sessions'
|
33
|
+
|
34
|
+
conversation_id = Column(String(255), primary_key=True)
|
35
|
+
cookies_data = Column(Text) # JSON string of cookies
|
36
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
37
|
+
last_accessed = Column(DateTime, default=datetime.utcnow)
|
38
|
+
|
39
|
+
def to_dict(self):
|
40
|
+
return {
|
41
|
+
'conversation_id': self.conversation_id,
|
42
|
+
'cookies_data': self.cookies_data,
|
43
|
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
44
|
+
'last_accessed': self.last_accessed.isoformat() if self.last_accessed else None
|
45
|
+
}
|
46
|
+
|
47
|
+
|
48
|
+
class ChatSessionManager:
|
49
|
+
def __init__(self):
|
50
|
+
# Use Airflow's metadata database
|
51
|
+
self.engine = settings.engine
|
52
|
+
self.Session = sessionmaker(bind=self.engine)
|
53
|
+
|
54
|
+
# Create the table if it doesn't exist
|
55
|
+
try:
|
56
|
+
Base.metadata.create_all(self.engine)
|
57
|
+
except Exception as e:
|
58
|
+
print(f"Error creating chat sessions table: {e}")
|
59
|
+
|
60
|
+
def cleanup_old_sessions(self):
|
61
|
+
"""Remove sessions older than 24 hours"""
|
62
|
+
try:
|
63
|
+
session = self.Session()
|
64
|
+
cutoff_time = datetime.utcnow() - timedelta(minutes=20)
|
65
|
+
|
66
|
+
deleted_count = session.query(ChatSession).filter(
|
67
|
+
ChatSession.created_at < cutoff_time
|
68
|
+
).delete()
|
69
|
+
|
70
|
+
session.commit()
|
71
|
+
session.close()
|
72
|
+
|
73
|
+
if deleted_count > 0:
|
74
|
+
print(f"Cleaned up {deleted_count} old chat sessions")
|
75
|
+
|
76
|
+
except SQLAlchemyError as e:
|
77
|
+
print(f"Error cleaning up old sessions: {e}")
|
78
|
+
if session:
|
79
|
+
session.rollback()
|
80
|
+
session.close()
|
81
|
+
|
82
|
+
def store_session(self, conversation_id, cookies_dict):
|
83
|
+
"""Store or update a chat session"""
|
84
|
+
try:
|
85
|
+
session = self.Session()
|
86
|
+
|
87
|
+
# Convert cookies to JSON string
|
88
|
+
cookies_json = json.dumps(dict(cookies_dict)) if cookies_dict else "{}"
|
89
|
+
|
90
|
+
# Check if session exists
|
91
|
+
existing_session = session.query(ChatSession).filter(
|
92
|
+
ChatSession.conversation_id == conversation_id
|
93
|
+
).first()
|
94
|
+
|
95
|
+
if existing_session:
|
96
|
+
# Update existing session
|
97
|
+
existing_session.cookies_data = cookies_json
|
98
|
+
existing_session.last_accessed = datetime.utcnow()
|
99
|
+
else:
|
100
|
+
# Create new session
|
101
|
+
new_session = ChatSession(
|
102
|
+
conversation_id=conversation_id,
|
103
|
+
cookies_data=cookies_json,
|
104
|
+
created_at=datetime.utcnow(),
|
105
|
+
last_accessed=datetime.utcnow()
|
106
|
+
)
|
107
|
+
session.add(new_session)
|
108
|
+
|
109
|
+
session.commit()
|
110
|
+
session.close()
|
111
|
+
return True
|
112
|
+
|
113
|
+
except SQLAlchemyError as e:
|
114
|
+
print(f"Error storing session: {e}")
|
115
|
+
if session:
|
116
|
+
session.rollback()
|
117
|
+
session.close()
|
118
|
+
return False
|
119
|
+
|
120
|
+
def get_session(self, conversation_id):
|
121
|
+
"""Retrieve a chat session"""
|
122
|
+
try:
|
123
|
+
session = self.Session()
|
124
|
+
|
125
|
+
chat_session = session.query(ChatSession).filter(
|
126
|
+
ChatSession.conversation_id == conversation_id
|
127
|
+
).first()
|
128
|
+
|
129
|
+
if chat_session:
|
130
|
+
# Update last accessed time
|
131
|
+
chat_session.last_accessed = datetime.utcnow()
|
132
|
+
session.commit()
|
133
|
+
|
134
|
+
# Parse cookies from JSON
|
135
|
+
cookies_dict = json.loads(chat_session.cookies_data) if chat_session.cookies_data else {}
|
136
|
+
|
137
|
+
session.close()
|
138
|
+
return cookies_dict
|
139
|
+
else:
|
140
|
+
session.close()
|
141
|
+
return None
|
142
|
+
|
143
|
+
except SQLAlchemyError as e:
|
144
|
+
print(f"Error retrieving session: {e}")
|
145
|
+
if session:
|
146
|
+
session.close()
|
147
|
+
return None
|
148
|
+
|
149
|
+
def delete_session(self, conversation_id):
|
150
|
+
"""Delete a specific chat session"""
|
151
|
+
try:
|
152
|
+
session = self.Session()
|
153
|
+
|
154
|
+
deleted_count = session.query(ChatSession).filter(
|
155
|
+
ChatSession.conversation_id == conversation_id
|
156
|
+
).delete()
|
157
|
+
|
158
|
+
session.commit()
|
159
|
+
session.close()
|
160
|
+
|
161
|
+
return deleted_count > 0
|
162
|
+
|
163
|
+
except SQLAlchemyError as e:
|
164
|
+
print(f"Error deleting session: {e}")
|
165
|
+
if session:
|
166
|
+
session.rollback()
|
167
|
+
session.close()
|
168
|
+
return False
|
169
|
+
|
170
|
+
def get_active_sessions_count(self):
|
171
|
+
"""Get count of active sessions (within 24 hours)"""
|
172
|
+
try:
|
173
|
+
session = self.Session()
|
174
|
+
cutoff_time = datetime.utcnow() - timedelta(hours=24)
|
175
|
+
|
176
|
+
count = session.query(ChatSession).filter(
|
177
|
+
ChatSession.created_at >= cutoff_time
|
178
|
+
).count()
|
179
|
+
|
180
|
+
session.close()
|
181
|
+
return count
|
182
|
+
|
183
|
+
except SQLAlchemyError as e:
|
184
|
+
print(f"Error getting session count: {e}")
|
185
|
+
if session:
|
186
|
+
session.close()
|
187
|
+
return 0
|
188
|
+
|
189
|
+
def admin_only(f):
|
190
|
+
"""Decorator to restrict access to Admin role."""
|
191
|
+
@wraps(f)
|
192
|
+
def decorated_function(*args, **kwargs):
|
193
|
+
# Check if user is authenticated
|
194
|
+
if not current_user or not hasattr(current_user, 'roles') or not current_user.roles:
|
195
|
+
return jsonify({"error": "Authentication required"}), 401
|
196
|
+
|
197
|
+
users_roles = [role.name for role in current_user.roles]
|
198
|
+
approved_roles = ["Admin"] # Adjust roles as needed
|
199
|
+
if not any(role in users_roles for role in approved_roles):
|
200
|
+
return jsonify({"error": "Access denied"}), 403
|
201
|
+
return f(*args, **kwargs)
|
202
|
+
return decorated_function
|
203
|
+
|
204
|
+
def failure_tolerant(f):
|
205
|
+
"""Decorator for error handling."""
|
206
|
+
@wraps(f)
|
207
|
+
def decorated_function(*args, **kwargs):
|
208
|
+
try:
|
209
|
+
return f(*args, **kwargs)
|
210
|
+
except Exception as e:
|
211
|
+
print(f"CHAT PLUGIN FAILURE: {e}")
|
212
|
+
return jsonify({"error": "Something went wrong"}), 500
|
213
|
+
return decorated_function
|
214
|
+
|
215
|
+
class LLMChatAgent:
|
216
|
+
def __init__(self):
|
217
|
+
self.backend_url = os.environ.get('BACKEND_URL', "http://fastapi:8080")
|
218
|
+
self.access_token = os.environ.get('FAST_API_ACCESS_SECRET_TOKEN', 'ThisIsATempAccessTokenForLocalEnvs.ReplaceInProd')
|
219
|
+
self.session_manager = ChatSessionManager()
|
220
|
+
|
221
|
+
# Perform cleanup on initialization
|
222
|
+
self.session_manager.cleanup_old_sessions()
|
223
|
+
|
224
|
+
def get_headers(self):
|
225
|
+
return {
|
226
|
+
"x-access-token": self.access_token
|
227
|
+
}
|
228
|
+
|
229
|
+
def initialize_chat_session(self, conversation_id):
|
230
|
+
"""Initialize a new chat session"""
|
231
|
+
try:
|
232
|
+
response = requests.post(
|
233
|
+
f"{self.backend_url}/chat/new",
|
234
|
+
headers=self.get_headers(),
|
235
|
+
timeout=10
|
236
|
+
)
|
237
|
+
response.raise_for_status()
|
238
|
+
|
239
|
+
# Store session cookies in database
|
240
|
+
success = self.session_manager.store_session(conversation_id, response.cookies)
|
241
|
+
|
242
|
+
if success:
|
243
|
+
print(f"Initialized and stored chat session: {conversation_id}")
|
244
|
+
return True, response.cookies
|
245
|
+
else:
|
246
|
+
print(f"Failed to store session in database: {conversation_id}")
|
247
|
+
return False, None
|
248
|
+
|
249
|
+
except requests.RequestException as e:
|
250
|
+
print(f"Failed to initialize chat session: {e}")
|
251
|
+
return False, None
|
252
|
+
except Exception as e:
|
253
|
+
print(f"Unexpected error initializing session: {e}")
|
254
|
+
return False, None
|
255
|
+
|
256
|
+
def stream_chat_response(self, message, conversation_id=None):
|
257
|
+
"""Stream response from FastAPI backend"""
|
258
|
+
try:
|
259
|
+
# Clean up old sessions periodically (every request)
|
260
|
+
self.session_manager.cleanup_old_sessions()
|
261
|
+
|
262
|
+
# Get session cookies from database
|
263
|
+
cookies_dict = self.session_manager.get_session(conversation_id)
|
264
|
+
|
265
|
+
if cookies_dict is None:
|
266
|
+
# Initialize session if not found
|
267
|
+
success, cookies = self.initialize_chat_session(conversation_id)
|
268
|
+
if not success:
|
269
|
+
yield {
|
270
|
+
"content": "Failed to initialize chat session. Please try again.",
|
271
|
+
"conversation_id": conversation_id,
|
272
|
+
"timestamp": datetime.now().isoformat(),
|
273
|
+
"error": True
|
274
|
+
}
|
275
|
+
return
|
276
|
+
cookies_dict = dict(cookies)
|
277
|
+
|
278
|
+
# Convert dict back to requests.cookies.RequestsCookieJar if needed
|
279
|
+
# For requests library, we can pass cookies as dict
|
280
|
+
|
281
|
+
# Stream chat response
|
282
|
+
with requests.post(
|
283
|
+
f"{self.backend_url}/chat/ask",
|
284
|
+
stream=True,
|
285
|
+
headers=self.get_headers(),
|
286
|
+
json={'message': message},
|
287
|
+
cookies=cookies_dict,
|
288
|
+
timeout=30
|
289
|
+
) as response:
|
290
|
+
|
291
|
+
response.raise_for_status()
|
292
|
+
|
293
|
+
# Stream response chunks
|
294
|
+
for chunk in response.iter_content(chunk_size=1024):
|
295
|
+
if chunk:
|
296
|
+
try:
|
297
|
+
# Decode the chunk
|
298
|
+
content = str(chunk, encoding="utf-8")
|
299
|
+
|
300
|
+
# Yield the content as structured response
|
301
|
+
yield {
|
302
|
+
"content": content,
|
303
|
+
"conversation_id": conversation_id,
|
304
|
+
"timestamp": datetime.now().isoformat()
|
305
|
+
}
|
306
|
+
|
307
|
+
except UnicodeDecodeError:
|
308
|
+
# Skip chunks that can't be decoded
|
309
|
+
continue
|
310
|
+
except Exception as e:
|
311
|
+
print(f"Error processing chunk: {e}")
|
312
|
+
continue
|
313
|
+
|
314
|
+
except requests.RequestException as e:
|
315
|
+
# Fallback for connection errors
|
316
|
+
yield {
|
317
|
+
"content": f"Connection error: {str(e)}. Please check if the FastAPI service is running.",
|
318
|
+
"conversation_id": conversation_id,
|
319
|
+
"timestamp": datetime.now().isoformat(),
|
320
|
+
"error": True
|
321
|
+
}
|
322
|
+
except Exception as e:
|
323
|
+
yield {
|
324
|
+
"content": f"An unexpected error occurred: {str(e)}",
|
325
|
+
"conversation_id": conversation_id,
|
326
|
+
"timestamp": datetime.now().isoformat(),
|
327
|
+
"error": True
|
328
|
+
}
|
329
|
+
|
330
|
+
def clear_session(self, conversation_id):
|
331
|
+
"""Clear session data for a conversation"""
|
332
|
+
print(f'Clearing session: {conversation_id}')
|
333
|
+
return self.session_manager.delete_session(conversation_id)
|
334
|
+
|
335
|
+
def get_session_stats(self):
|
336
|
+
"""Get statistics about active sessions"""
|
337
|
+
return {
|
338
|
+
'active_sessions': self.session_manager.get_active_sessions_count()
|
339
|
+
}
|
340
|
+
|
341
|
+
class AirflowChatView(AppBuilderBaseView):
|
342
|
+
default_view = "chat_interface"
|
343
|
+
route_base = "/airflow_chat"
|
344
|
+
|
345
|
+
def __init__(self, **kwargs):
|
346
|
+
super().__init__(**kwargs)
|
347
|
+
self.llm_agent = LLMChatAgent()
|
348
|
+
|
349
|
+
@expose("/")
|
350
|
+
@admin_only
|
351
|
+
@failure_tolerant
|
352
|
+
def chat_interface(self):
|
353
|
+
"""Main chat interface"""
|
354
|
+
return self.render_template("chat_interface.html")
|
355
|
+
|
356
|
+
@expose("/api/chat", methods=["POST"])
|
357
|
+
@admin_only
|
358
|
+
@failure_tolerant
|
359
|
+
def chat_api(self):
|
360
|
+
"""API endpoint for chat messages"""
|
361
|
+
data = request.get_json()
|
362
|
+
message = data.get("message", "").strip()
|
363
|
+
conversation_id = data.get("conversation_id")
|
364
|
+
print(f'conversation_id: {conversation_id}')
|
365
|
+
|
366
|
+
if not message:
|
367
|
+
return jsonify({"error": "Message is required"}), 400
|
368
|
+
|
369
|
+
# Return streaming response
|
370
|
+
def generate():
|
371
|
+
try:
|
372
|
+
for chunk in self.llm_agent.stream_chat_response(
|
373
|
+
message,
|
374
|
+
conversation_id
|
375
|
+
):
|
376
|
+
yield f"data: {json.dumps(chunk)}\n\n"
|
377
|
+
yield "data: [DONE]\n\n"
|
378
|
+
except Exception as e:
|
379
|
+
error_chunk = {
|
380
|
+
"content": f"Error: {str(e)}",
|
381
|
+
"conversation_id": conversation_id or str(uuid.uuid4()),
|
382
|
+
"timestamp": datetime.now().isoformat(),
|
383
|
+
"error": True
|
384
|
+
}
|
385
|
+
yield f"data: {json.dumps(error_chunk)}\n\n"
|
386
|
+
yield "data: [DONE]\n\n"
|
387
|
+
|
388
|
+
return Response(
|
389
|
+
generate(),
|
390
|
+
mimetype="text/plain",
|
391
|
+
headers={
|
392
|
+
"Cache-Control": "no-cache",
|
393
|
+
"Connection": "keep-alive",
|
394
|
+
"Access-Control-Allow-Origin": "*"
|
395
|
+
}
|
396
|
+
)
|
397
|
+
|
398
|
+
@expose("/api/new_chat", methods=["POST"])
|
399
|
+
@admin_only
|
400
|
+
@failure_tolerant
|
401
|
+
def new_chat(self):
|
402
|
+
"""Initialize a new chat session"""
|
403
|
+
conversation_id = str(uuid.uuid4())
|
404
|
+
|
405
|
+
try:
|
406
|
+
success, cookies = self.llm_agent.initialize_chat_session(conversation_id)
|
407
|
+
|
408
|
+
if success:
|
409
|
+
return jsonify({
|
410
|
+
"conversation_id": conversation_id,
|
411
|
+
"status": "initialized",
|
412
|
+
"message": "New chat session created"
|
413
|
+
})
|
414
|
+
else:
|
415
|
+
return jsonify({
|
416
|
+
"error": "Failed to initialize chat session"
|
417
|
+
}), 500
|
418
|
+
|
419
|
+
except Exception as e:
|
420
|
+
return jsonify({
|
421
|
+
"error": f"Error creating new chat: {str(e)}"
|
422
|
+
}), 500
|
423
|
+
|
424
|
+
@expose("/api/clear_session", methods=["POST"])
|
425
|
+
@admin_only
|
426
|
+
@failure_tolerant
|
427
|
+
def clear_session(self):
|
428
|
+
"""Clear a specific chat session"""
|
429
|
+
data = request.get_json()
|
430
|
+
conversation_id = data.get("conversation_id")
|
431
|
+
|
432
|
+
if not conversation_id:
|
433
|
+
return jsonify({"error": "Conversation ID is required"}), 400
|
434
|
+
|
435
|
+
try:
|
436
|
+
success = self.llm_agent.clear_session(conversation_id)
|
437
|
+
|
438
|
+
if success:
|
439
|
+
return jsonify({
|
440
|
+
"message": "Session cleared successfully",
|
441
|
+
"conversation_id": conversation_id
|
442
|
+
})
|
443
|
+
else:
|
444
|
+
return jsonify({
|
445
|
+
"error": "Session not found or could not be cleared"
|
446
|
+
}), 404
|
447
|
+
|
448
|
+
except Exception as e:
|
449
|
+
return jsonify({
|
450
|
+
"error": f"Error clearing session: {str(e)}"
|
451
|
+
}), 500
|
452
|
+
|
453
|
+
@expose("/api/session_stats", methods=["GET"])
|
454
|
+
@admin_only
|
455
|
+
@failure_tolerant
|
456
|
+
def session_stats(self):
|
457
|
+
"""Get session statistics"""
|
458
|
+
try:
|
459
|
+
stats = self.llm_agent.get_session_stats()
|
460
|
+
return jsonify(stats)
|
461
|
+
except Exception as e:
|
462
|
+
return jsonify({
|
463
|
+
"error": f"Error getting session stats: {str(e)}"
|
464
|
+
}), 500
|
465
|
+
|
466
|
+
# Create the view instance
|
467
|
+
chat_view = AirflowChatView()
|
468
|
+
|
469
|
+
# Package for Airflow
|
470
|
+
chat_package = {
|
471
|
+
"name": "AI Chat Assistant",
|
472
|
+
"category": "Tools",
|
473
|
+
"view": chat_view,
|
474
|
+
}
|
475
|
+
|
476
|
+
class AirflowChatPlugin(AirflowPlugin):
|
477
|
+
name = "airflow_chat_plugin"
|
478
|
+
hooks = []
|
479
|
+
macros = []
|
480
|
+
flask_blueprints = [bp]
|
481
|
+
appbuilder_views = [chat_package]
|
482
|
+
appbuilder_menu_items = []
|
@@ -0,0 +1,833 @@
|
|
1
|
+
{% extends base_template %}
|
2
|
+
{% block content %}
|
3
|
+
<div class="chat-container">
|
4
|
+
<div class="chat-header">
|
5
|
+
<h1><i class="fa fa-comments"></i> AI Assistant</h1>
|
6
|
+
<div class="chat-controls">
|
7
|
+
<button id="newChat" class="btn btn-sm btn-primary">
|
8
|
+
<i class="fa fa-plus"></i> New Chat
|
9
|
+
</button>
|
10
|
+
</div>
|
11
|
+
</div>
|
12
|
+
|
13
|
+
<div class="chat-messages" id="chatMessages">
|
14
|
+
<div class="welcome-message">
|
15
|
+
<div class="message assistant-message">
|
16
|
+
<div class="message-avatar">
|
17
|
+
<i class="fa fa-robot"></i>
|
18
|
+
</div>
|
19
|
+
<div class="message-content">
|
20
|
+
<div class="message-text">
|
21
|
+
Hello! I'm your Airflow AI assistant. I can help you with:
|
22
|
+
<ul>
|
23
|
+
<li>DAG management and troubleshooting</li>
|
24
|
+
<li>Task configuration and dependencies</li>
|
25
|
+
<li>Airflow best practices</li>
|
26
|
+
<li>Connection and variable management</li>
|
27
|
+
<li>General workflow questions</li>
|
28
|
+
</ul>
|
29
|
+
What would you like to know?
|
30
|
+
</div>
|
31
|
+
<div class="message-time">Just now</div>
|
32
|
+
</div>
|
33
|
+
</div>
|
34
|
+
</div>
|
35
|
+
</div>
|
36
|
+
|
37
|
+
<div class="chat-input-container">
|
38
|
+
<div class="chat-input-wrapper">
|
39
|
+
<textarea
|
40
|
+
id="chatInput"
|
41
|
+
placeholder="Ask me anything about Airflow..."
|
42
|
+
rows="1"
|
43
|
+
maxlength="2000"></textarea>
|
44
|
+
<button id="sendButton" class="btn btn-primary" disabled>
|
45
|
+
<i class="fa fa-paper-plane"></i>
|
46
|
+
</button>
|
47
|
+
</div>
|
48
|
+
<div class="input-footer">
|
49
|
+
<span class="char-count">0/2000</span>
|
50
|
+
<span class="status" id="connectionStatus">Ready</span>
|
51
|
+
</div>
|
52
|
+
</div>
|
53
|
+
</div>
|
54
|
+
|
55
|
+
<style>
|
56
|
+
.chat-container {
|
57
|
+
max-width: 1000px;
|
58
|
+
margin: 0 auto;
|
59
|
+
height: calc(100vh - 120px);
|
60
|
+
display: flex;
|
61
|
+
flex-direction: column;
|
62
|
+
background: #f8f9fa;
|
63
|
+
border-radius: 10px;
|
64
|
+
overflow: hidden;
|
65
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
66
|
+
}
|
67
|
+
|
68
|
+
.chat-header {
|
69
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
70
|
+
color: white;
|
71
|
+
padding: 20px;
|
72
|
+
display: flex;
|
73
|
+
justify-content: space-between;
|
74
|
+
align-items: center;
|
75
|
+
}
|
76
|
+
|
77
|
+
.chat-header h1 {
|
78
|
+
margin: 0;
|
79
|
+
font-size: 24px;
|
80
|
+
font-weight: 600;
|
81
|
+
}
|
82
|
+
|
83
|
+
.chat-controls {
|
84
|
+
display: flex;
|
85
|
+
gap: 10px;
|
86
|
+
}
|
87
|
+
|
88
|
+
.chat-messages {
|
89
|
+
flex: 1;
|
90
|
+
overflow-y: auto;
|
91
|
+
padding: 20px;
|
92
|
+
background: white;
|
93
|
+
}
|
94
|
+
|
95
|
+
.message {
|
96
|
+
display: flex;
|
97
|
+
margin-bottom: 20px;
|
98
|
+
max-width: 80%;
|
99
|
+
}
|
100
|
+
|
101
|
+
.user-message {
|
102
|
+
margin-left: auto;
|
103
|
+
flex-direction: row-reverse;
|
104
|
+
}
|
105
|
+
|
106
|
+
.assistant-message {
|
107
|
+
margin-right: auto;
|
108
|
+
}
|
109
|
+
|
110
|
+
.message-avatar {
|
111
|
+
width: 40px;
|
112
|
+
height: 40px;
|
113
|
+
border-radius: 50%;
|
114
|
+
display: flex;
|
115
|
+
align-items: center;
|
116
|
+
justify-content: center;
|
117
|
+
margin: 0 10px;
|
118
|
+
font-size: 18px;
|
119
|
+
}
|
120
|
+
|
121
|
+
.user-message .message-avatar {
|
122
|
+
background: #007bff;
|
123
|
+
color: white;
|
124
|
+
}
|
125
|
+
|
126
|
+
.assistant-message .message-avatar {
|
127
|
+
background: #6f42c1;
|
128
|
+
color: white;
|
129
|
+
}
|
130
|
+
|
131
|
+
.message-content {
|
132
|
+
flex: 1;
|
133
|
+
}
|
134
|
+
|
135
|
+
.message-text {
|
136
|
+
background: #f1f3f4;
|
137
|
+
padding: 12px 16px;
|
138
|
+
border-radius: 18px;
|
139
|
+
word-wrap: break-word;
|
140
|
+
line-height: 1.4;
|
141
|
+
}
|
142
|
+
|
143
|
+
.user-message .message-text {
|
144
|
+
background: #007bff;
|
145
|
+
color: white;
|
146
|
+
}
|
147
|
+
|
148
|
+
.assistant-message .message-text {
|
149
|
+
background: #f1f3f4;
|
150
|
+
color: #333;
|
151
|
+
}
|
152
|
+
|
153
|
+
.message-time {
|
154
|
+
font-size: 11px;
|
155
|
+
color: #666;
|
156
|
+
margin-top: 4px;
|
157
|
+
text-align: right;
|
158
|
+
}
|
159
|
+
|
160
|
+
.user-message .message-time {
|
161
|
+
text-align: left;
|
162
|
+
}
|
163
|
+
|
164
|
+
.chat-input-container {
|
165
|
+
background: white;
|
166
|
+
border-top: 1px solid #e0e0e0;
|
167
|
+
padding: 20px;
|
168
|
+
}
|
169
|
+
|
170
|
+
.chat-input-wrapper {
|
171
|
+
display: flex;
|
172
|
+
align-items: flex-end;
|
173
|
+
gap: 10px;
|
174
|
+
max-width: 100%;
|
175
|
+
}
|
176
|
+
|
177
|
+
#chatInput {
|
178
|
+
flex: 1;
|
179
|
+
border: 2px solid #e0e0e0;
|
180
|
+
border-radius: 20px;
|
181
|
+
padding: 12px 16px;
|
182
|
+
resize: none;
|
183
|
+
font-family: inherit;
|
184
|
+
font-size: 14px;
|
185
|
+
line-height: 1.4;
|
186
|
+
max-height: 120px;
|
187
|
+
transition: border-color 0.3s;
|
188
|
+
}
|
189
|
+
|
190
|
+
#chatInput:focus {
|
191
|
+
outline: none;
|
192
|
+
border-color: #007bff;
|
193
|
+
}
|
194
|
+
|
195
|
+
#sendButton {
|
196
|
+
width: 44px;
|
197
|
+
height: 44px;
|
198
|
+
border-radius: 50%;
|
199
|
+
border: none;
|
200
|
+
background: #007bff;
|
201
|
+
color: white;
|
202
|
+
display: flex;
|
203
|
+
align-items: center;
|
204
|
+
justify-content: center;
|
205
|
+
transition: all 0.3s;
|
206
|
+
}
|
207
|
+
|
208
|
+
#sendButton:hover:not(:disabled) {
|
209
|
+
background: #0056b3;
|
210
|
+
transform: scale(1.05);
|
211
|
+
}
|
212
|
+
|
213
|
+
#sendButton:disabled {
|
214
|
+
background: #ccc;
|
215
|
+
cursor: not-allowed;
|
216
|
+
}
|
217
|
+
|
218
|
+
.input-footer {
|
219
|
+
display: flex;
|
220
|
+
justify-content: space-between;
|
221
|
+
align-items: center;
|
222
|
+
margin-top: 8px;
|
223
|
+
font-size: 12px;
|
224
|
+
color: #666;
|
225
|
+
}
|
226
|
+
|
227
|
+
.char-count {
|
228
|
+
font-size: 11px;
|
229
|
+
}
|
230
|
+
|
231
|
+
.status {
|
232
|
+
font-weight: 500;
|
233
|
+
}
|
234
|
+
|
235
|
+
.status.connecting {
|
236
|
+
color: #ffc107;
|
237
|
+
}
|
238
|
+
|
239
|
+
.status.ready {
|
240
|
+
color: #28a745;
|
241
|
+
}
|
242
|
+
|
243
|
+
.status.error {
|
244
|
+
color: #dc3545;
|
245
|
+
}
|
246
|
+
|
247
|
+
.typing-indicator {
|
248
|
+
display: flex;
|
249
|
+
align-items: center;
|
250
|
+
gap: 4px;
|
251
|
+
padding: 8px 16px;
|
252
|
+
background: #f1f3f4;
|
253
|
+
border-radius: 18px;
|
254
|
+
margin: 8px 0;
|
255
|
+
}
|
256
|
+
|
257
|
+
.typing-dots {
|
258
|
+
display: flex;
|
259
|
+
gap: 2px;
|
260
|
+
}
|
261
|
+
|
262
|
+
.typing-dot {
|
263
|
+
width: 6px;
|
264
|
+
height: 6px;
|
265
|
+
background: #666;
|
266
|
+
border-radius: 50%;
|
267
|
+
animation: typing 1.4s infinite;
|
268
|
+
}
|
269
|
+
|
270
|
+
.typing-dot:nth-child(2) {
|
271
|
+
animation-delay: 0.2s;
|
272
|
+
}
|
273
|
+
|
274
|
+
.typing-dot:nth-child(3) {
|
275
|
+
animation-delay: 0.4s;
|
276
|
+
}
|
277
|
+
|
278
|
+
@keyframes typing {
|
279
|
+
0%, 60%, 100% {
|
280
|
+
transform: translateY(0);
|
281
|
+
opacity: 0.4;
|
282
|
+
}
|
283
|
+
30% {
|
284
|
+
transform: translateY(-10px);
|
285
|
+
opacity: 1;
|
286
|
+
}
|
287
|
+
}
|
288
|
+
|
289
|
+
.welcome-message ul {
|
290
|
+
margin: 10px 0;
|
291
|
+
padding-left: 20px;
|
292
|
+
}
|
293
|
+
|
294
|
+
.welcome-message li {
|
295
|
+
margin-bottom: 5px;
|
296
|
+
}
|
297
|
+
|
298
|
+
/* Tool execution styling */
|
299
|
+
.tool-execution {
|
300
|
+
margin: 10px 0;
|
301
|
+
border: 1px solid #e0e0e0;
|
302
|
+
border-radius: 8px;
|
303
|
+
background: #f8f9fa;
|
304
|
+
}
|
305
|
+
|
306
|
+
.tool-execution[open] {
|
307
|
+
border-color: #007bff;
|
308
|
+
background: #f0f8ff;
|
309
|
+
}
|
310
|
+
|
311
|
+
.tool-summary {
|
312
|
+
padding: 12px 16px;
|
313
|
+
cursor: pointer;
|
314
|
+
font-weight: 600;
|
315
|
+
color: #007bff;
|
316
|
+
background: #f8f9fa;
|
317
|
+
border-radius: 8px;
|
318
|
+
user-select: none;
|
319
|
+
transition: background-color 0.2s;
|
320
|
+
}
|
321
|
+
|
322
|
+
.tool-summary:hover {
|
323
|
+
background: #e9ecef;
|
324
|
+
}
|
325
|
+
|
326
|
+
.tool-execution[open] .tool-summary {
|
327
|
+
border-bottom: 1px solid #e0e0e0;
|
328
|
+
border-radius: 8px 8px 0 0;
|
329
|
+
background: #e3f2fd;
|
330
|
+
}
|
331
|
+
|
332
|
+
.tool-execution > div:not(.tool-summary) {
|
333
|
+
padding: 16px;
|
334
|
+
}
|
335
|
+
|
336
|
+
.tool-execution pre {
|
337
|
+
background: #2d3748;
|
338
|
+
color: #e2e8f0;
|
339
|
+
padding: 12px;
|
340
|
+
border-radius: 6px;
|
341
|
+
overflow-x: auto;
|
342
|
+
margin: 8px 0;
|
343
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
344
|
+
font-size: 13px;
|
345
|
+
line-height: 1.4;
|
346
|
+
}
|
347
|
+
|
348
|
+
.tool-execution code {
|
349
|
+
background: #f1f3f4;
|
350
|
+
padding: 2px 6px;
|
351
|
+
border-radius: 4px;
|
352
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
353
|
+
font-size: 13px;
|
354
|
+
}
|
355
|
+
|
356
|
+
.tool-execution h1,
|
357
|
+
.tool-execution h2,
|
358
|
+
.tool-execution h3 {
|
359
|
+
margin: 16px 0 8px 0;
|
360
|
+
color: #333;
|
361
|
+
}
|
362
|
+
|
363
|
+
.tool-execution ul {
|
364
|
+
margin: 8px 0;
|
365
|
+
padding-left: 20px;
|
366
|
+
}
|
367
|
+
|
368
|
+
.tool-execution li {
|
369
|
+
margin: 4px 0;
|
370
|
+
list-style-type: disc;
|
371
|
+
}
|
372
|
+
|
373
|
+
/* Formatting styles */
|
374
|
+
.message-text h1,
|
375
|
+
.message-text h2,
|
376
|
+
.message-text h3 {
|
377
|
+
margin: 16px 0 8px 0;
|
378
|
+
color: #333;
|
379
|
+
line-height: 1.3;
|
380
|
+
}
|
381
|
+
|
382
|
+
.message-text h1 { font-size: 1.5em; font-weight: 700; }
|
383
|
+
.message-text h2 { font-size: 1.3em; font-weight: 600; }
|
384
|
+
.message-text h3 { font-size: 1.1em; font-weight: 600; }
|
385
|
+
|
386
|
+
.message-text strong {
|
387
|
+
font-weight: 600;
|
388
|
+
color: #333;
|
389
|
+
}
|
390
|
+
|
391
|
+
.message-text code {
|
392
|
+
background: #f1f3f4;
|
393
|
+
padding: 2px 6px;
|
394
|
+
border-radius: 4px;
|
395
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
396
|
+
font-size: 13px;
|
397
|
+
color: #d63384;
|
398
|
+
}
|
399
|
+
|
400
|
+
.message-text pre {
|
401
|
+
background: #2d3748;
|
402
|
+
color: #e2e8f0;
|
403
|
+
padding: 12px;
|
404
|
+
border-radius: 6px;
|
405
|
+
overflow-x: auto;
|
406
|
+
margin: 12px 0;
|
407
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
408
|
+
font-size: 13px;
|
409
|
+
line-height: 1.4;
|
410
|
+
}
|
411
|
+
|
412
|
+
.message-text pre code {
|
413
|
+
background: none;
|
414
|
+
padding: 0;
|
415
|
+
color: inherit;
|
416
|
+
border-radius: 0;
|
417
|
+
}
|
418
|
+
|
419
|
+
.message-text ul,
|
420
|
+
.message-text ol {
|
421
|
+
margin: 8px 0;
|
422
|
+
padding-left: 20px;
|
423
|
+
}
|
424
|
+
|
425
|
+
.message-text li {
|
426
|
+
margin: 4px 0;
|
427
|
+
line-height: 1.4;
|
428
|
+
}
|
429
|
+
|
430
|
+
.message-text ul li {
|
431
|
+
list-style-type: disc;
|
432
|
+
}
|
433
|
+
|
434
|
+
.message-text ol li {
|
435
|
+
list-style-type: decimal;
|
436
|
+
}
|
437
|
+
|
438
|
+
@media (max-width: 768px) {
|
439
|
+
.chat-container {
|
440
|
+
height: calc(100vh - 80px);
|
441
|
+
margin: 0;
|
442
|
+
border-radius: 0;
|
443
|
+
}
|
444
|
+
|
445
|
+
.message {
|
446
|
+
max-width: 90%;
|
447
|
+
}
|
448
|
+
|
449
|
+
.chat-header {
|
450
|
+
padding: 15px;
|
451
|
+
}
|
452
|
+
|
453
|
+
.chat-header h1 {
|
454
|
+
font-size: 20px;
|
455
|
+
}
|
456
|
+
}
|
457
|
+
</style>
|
458
|
+
|
459
|
+
<script>
|
460
|
+
class AirflowChatClient {
|
461
|
+
constructor() {
|
462
|
+
this.conversationId = null;
|
463
|
+
this.isConnected = true;
|
464
|
+
this.currentStream = null;
|
465
|
+
this.isStreaming = false; // Track streaming state
|
466
|
+
|
467
|
+
this.initializeElements();
|
468
|
+
this.attachEventListeners();
|
469
|
+
this.autoResize();
|
470
|
+
}
|
471
|
+
|
472
|
+
initializeElements() {
|
473
|
+
this.chatInput = document.getElementById('chatInput');
|
474
|
+
this.sendButton = document.getElementById('sendButton');
|
475
|
+
this.chatMessages = document.getElementById('chatMessages');
|
476
|
+
this.newChatBtn = document.getElementById('newChat');
|
477
|
+
this.charCount = document.querySelector('.char-count');
|
478
|
+
this.status = document.getElementById('connectionStatus');
|
479
|
+
}
|
480
|
+
|
481
|
+
attachEventListeners() {
|
482
|
+
// Send message events
|
483
|
+
this.sendButton.addEventListener('click', () => this.sendMessage());
|
484
|
+
this.chatInput.addEventListener('keydown', (e) => {
|
485
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
486
|
+
e.preventDefault();
|
487
|
+
this.sendMessage();
|
488
|
+
}
|
489
|
+
});
|
490
|
+
|
491
|
+
// Input events
|
492
|
+
this.chatInput.addEventListener('input', () => {
|
493
|
+
this.updateCharCount();
|
494
|
+
this.updateSendButton();
|
495
|
+
this.autoResize();
|
496
|
+
});
|
497
|
+
|
498
|
+
this.newChatBtn.addEventListener('click', () => this.newChat());
|
499
|
+
}
|
500
|
+
|
501
|
+
updateCharCount() {
|
502
|
+
const length = this.chatInput.value.length;
|
503
|
+
this.charCount.textContent = `${length}/2000`;
|
504
|
+
|
505
|
+
if (length > 1800) {
|
506
|
+
this.charCount.style.color = '#dc3545';
|
507
|
+
} else if (length > 1500) {
|
508
|
+
this.charCount.style.color = '#ffc107';
|
509
|
+
} else {
|
510
|
+
this.charCount.style.color = '#666';
|
511
|
+
}
|
512
|
+
}
|
513
|
+
|
514
|
+
updateSendButton() {
|
515
|
+
const hasText = this.chatInput.value.trim().length > 0;
|
516
|
+
// Disable button if no text, not connected, or currently streaming
|
517
|
+
this.sendButton.disabled = !hasText || !this.isConnected || this.isStreaming;
|
518
|
+
}
|
519
|
+
|
520
|
+
autoResize() {
|
521
|
+
this.chatInput.style.height = 'auto';
|
522
|
+
this.chatInput.style.height = Math.min(this.chatInput.scrollHeight, 120) + 'px';
|
523
|
+
}
|
524
|
+
|
525
|
+
setStatus(text, className = 'ready') {
|
526
|
+
this.status.textContent = text;
|
527
|
+
this.status.className = `status ${className}`;
|
528
|
+
}
|
529
|
+
|
530
|
+
setStreaming(streaming) {
|
531
|
+
this.isStreaming = streaming;
|
532
|
+
this.updateSendButton();
|
533
|
+
}
|
534
|
+
|
535
|
+
async sendMessage() {
|
536
|
+
const message = this.chatInput.value.trim();
|
537
|
+
if (!message || !this.isConnected || this.isStreaming) return;
|
538
|
+
|
539
|
+
// Set streaming state to true
|
540
|
+
this.setStreaming(true);
|
541
|
+
|
542
|
+
// Initialize conversation if not exists
|
543
|
+
if (!this.conversationId) {
|
544
|
+
this.setStatus('Initializing chat...', 'connecting');
|
545
|
+
try {
|
546
|
+
const initResponse = await fetch("{{ url_for('AirflowChatView.new_chat') }}", {
|
547
|
+
method: 'POST',
|
548
|
+
headers: {
|
549
|
+
'Content-Type': 'application/json',
|
550
|
+
'X-CSRFToken': '{{ csrf_token() }}'
|
551
|
+
}
|
552
|
+
});
|
553
|
+
|
554
|
+
const initData = await initResponse.json();
|
555
|
+
if (initData.conversation_id) {
|
556
|
+
this.conversationId = initData.conversation_id;
|
557
|
+
} else {
|
558
|
+
throw new Error('Failed to initialize chat session');
|
559
|
+
}
|
560
|
+
} catch (error) {
|
561
|
+
console.error('Failed to initialize chat:', error);
|
562
|
+
this.setStatus('Failed to initialize chat', 'error');
|
563
|
+
this.setStreaming(false); // Re-enable button on error
|
564
|
+
return;
|
565
|
+
}
|
566
|
+
}
|
567
|
+
|
568
|
+
// Add user message to chat
|
569
|
+
this.addMessage(message, 'user');
|
570
|
+
|
571
|
+
// Clear input
|
572
|
+
this.chatInput.value = '';
|
573
|
+
this.updateCharCount();
|
574
|
+
this.updateSendButton();
|
575
|
+
this.autoResize();
|
576
|
+
|
577
|
+
// Show typing indicator
|
578
|
+
const typingElement = this.showTypingIndicator();
|
579
|
+
|
580
|
+
try {
|
581
|
+
this.setStatus('Sending...', 'connecting');
|
582
|
+
console.log('this.conversationId')
|
583
|
+
console.log(this.conversationId)
|
584
|
+
const response = await fetch("{{ url_for('AirflowChatView.chat_api') }}", {
|
585
|
+
method: 'POST',
|
586
|
+
headers: {
|
587
|
+
'Content-Type': 'application/json',
|
588
|
+
'X-CSRFToken': '{{ csrf_token() }}'
|
589
|
+
},
|
590
|
+
body: JSON.stringify({
|
591
|
+
message: message,
|
592
|
+
conversation_id: this.conversationId
|
593
|
+
})
|
594
|
+
});
|
595
|
+
|
596
|
+
if (!response.ok) {
|
597
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
598
|
+
}
|
599
|
+
|
600
|
+
// Remove typing indicator
|
601
|
+
this.removeTypingIndicator(typingElement);
|
602
|
+
|
603
|
+
// Handle streaming response
|
604
|
+
await this.handleStreamingResponse(response);
|
605
|
+
|
606
|
+
} catch (error) {
|
607
|
+
console.error('Chat error:', error);
|
608
|
+
this.removeTypingIndicator(typingElement);
|
609
|
+
this.addMessage(`Sorry, I encountered an error: ${error.message}`, 'assistant', true);
|
610
|
+
this.setStatus('Error', 'error');
|
611
|
+
} finally {
|
612
|
+
// Always re-enable button when done
|
613
|
+
this.setStreaming(false);
|
614
|
+
}
|
615
|
+
}
|
616
|
+
|
617
|
+
async handleStreamingResponse(response) {
|
618
|
+
const reader = response.body.getReader();
|
619
|
+
const decoder = new TextDecoder();
|
620
|
+
let assistantMessage = '';
|
621
|
+
let messageElement = null;
|
622
|
+
|
623
|
+
this.setStatus('Receiving...', 'connecting');
|
624
|
+
|
625
|
+
try {
|
626
|
+
while (true) {
|
627
|
+
const { done, value } = await reader.read();
|
628
|
+
|
629
|
+
if (done) break;
|
630
|
+
|
631
|
+
const chunk = decoder.decode(value);
|
632
|
+
const lines = chunk.split('\n');
|
633
|
+
|
634
|
+
for (const line of lines) {
|
635
|
+
if (line.startsWith('data: ')) {
|
636
|
+
const data = line.slice(6);
|
637
|
+
|
638
|
+
if (data === '[DONE]') {
|
639
|
+
this.setStatus('Ready', 'ready');
|
640
|
+
return;
|
641
|
+
}
|
642
|
+
|
643
|
+
try {
|
644
|
+
const parsed = JSON.parse(data);
|
645
|
+
|
646
|
+
// if (parsed.conversation_id && !this.conversationId) {
|
647
|
+
// this.conversationId = parsed.conversation_id;
|
648
|
+
// }
|
649
|
+
|
650
|
+
if (parsed.content) {
|
651
|
+
assistantMessage += parsed.content;
|
652
|
+
|
653
|
+
if (!messageElement) {
|
654
|
+
messageElement = this.addMessage('', 'assistant');
|
655
|
+
}
|
656
|
+
|
657
|
+
this.updateMessage(messageElement, assistantMessage);
|
658
|
+
}
|
659
|
+
|
660
|
+
if (parsed.error) {
|
661
|
+
this.setStatus('Error', 'error');
|
662
|
+
}
|
663
|
+
|
664
|
+
} catch (e) {
|
665
|
+
console.warn('Failed to parse chunk:', data);
|
666
|
+
}
|
667
|
+
}
|
668
|
+
}
|
669
|
+
}
|
670
|
+
} catch (error) {
|
671
|
+
console.error('Streaming error:', error);
|
672
|
+
this.addMessage('Sorry, the connection was interrupted.', 'assistant', true);
|
673
|
+
} finally {
|
674
|
+
this.setStatus('Ready', 'ready');
|
675
|
+
}
|
676
|
+
}
|
677
|
+
|
678
|
+
addMessage(text, sender, isError = false) {
|
679
|
+
const messageDiv = document.createElement('div');
|
680
|
+
messageDiv.className = `message ${sender}-message`;
|
681
|
+
|
682
|
+
const now = new Date();
|
683
|
+
const timeStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
684
|
+
|
685
|
+
// Parse markdown for better formatting
|
686
|
+
const formattedText = this.parseMarkdown(text);
|
687
|
+
|
688
|
+
messageDiv.innerHTML = `
|
689
|
+
<div class="message-avatar">
|
690
|
+
<i class="fa fa-${sender === 'user' ? 'user' : 'robot'}"></i>
|
691
|
+
</div>
|
692
|
+
<div class="message-content">
|
693
|
+
<div class="message-text${isError ? ' error' : ''}">${formattedText}</div>
|
694
|
+
<div class="message-time">${timeStr}</div>
|
695
|
+
</div>
|
696
|
+
`;
|
697
|
+
|
698
|
+
this.chatMessages.appendChild(messageDiv);
|
699
|
+
this.scrollToBottom();
|
700
|
+
|
701
|
+
return messageDiv;
|
702
|
+
}
|
703
|
+
|
704
|
+
updateMessage(messageElement, text) {
|
705
|
+
const textElement = messageElement.querySelector('.message-text');
|
706
|
+
|
707
|
+
// Parse markdown-like formatting
|
708
|
+
const formattedText = this.parseMarkdown(text);
|
709
|
+
textElement.innerHTML = formattedText;
|
710
|
+
|
711
|
+
this.scrollToBottom();
|
712
|
+
}
|
713
|
+
|
714
|
+
parseMarkdown(text) {
|
715
|
+
// Convert basic markdown to HTML
|
716
|
+
let html = text
|
717
|
+
// Headers
|
718
|
+
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
719
|
+
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
720
|
+
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
721
|
+
|
722
|
+
// Bold
|
723
|
+
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
724
|
+
|
725
|
+
// Code blocks
|
726
|
+
.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
727
|
+
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
728
|
+
|
729
|
+
// Inline code
|
730
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
731
|
+
|
732
|
+
// Lists
|
733
|
+
.replace(/^\d+\.\s+(.*)$/gm, '<li>$1</li>')
|
734
|
+
.replace(/^[\*\-]\s+(.*)$/gm, '<li>$1</li>')
|
735
|
+
|
736
|
+
// Line breaks
|
737
|
+
.replace(/\n/g, '<br>');
|
738
|
+
|
739
|
+
// Wrap lists
|
740
|
+
html = html.replace(/(<li>.*<\/li>)/g, '<ul>$1</ul>');
|
741
|
+
|
742
|
+
// Handle details/summary (collapsible sections)
|
743
|
+
html = html.replace(/<details>/g, '<details class="tool-execution">')
|
744
|
+
.replace(/<summary>/g, '<summary class="tool-summary">');
|
745
|
+
|
746
|
+
return html;
|
747
|
+
}
|
748
|
+
|
749
|
+
showTypingIndicator() {
|
750
|
+
const typingDiv = document.createElement('div');
|
751
|
+
typingDiv.className = 'message assistant-message typing-message';
|
752
|
+
typingDiv.innerHTML = `
|
753
|
+
<div class="message-avatar">
|
754
|
+
<i class="fa fa-robot"></i>
|
755
|
+
</div>
|
756
|
+
<div class="message-content">
|
757
|
+
<div class="typing-indicator">
|
758
|
+
<span>AI is thinking</span>
|
759
|
+
<div class="typing-dots">
|
760
|
+
<div class="typing-dot"></div>
|
761
|
+
<div class="typing-dot"></div>
|
762
|
+
<div class="typing-dot"></div>
|
763
|
+
</div>
|
764
|
+
</div>
|
765
|
+
</div>
|
766
|
+
`;
|
767
|
+
|
768
|
+
this.chatMessages.appendChild(typingDiv);
|
769
|
+
this.scrollToBottom();
|
770
|
+
return typingDiv;
|
771
|
+
}
|
772
|
+
|
773
|
+
removeTypingIndicator(element) {
|
774
|
+
if (element && element.parentNode) {
|
775
|
+
element.parentNode.removeChild(element);
|
776
|
+
}
|
777
|
+
}
|
778
|
+
|
779
|
+
clearChat() {
|
780
|
+
if (confirm('Are you sure you want to clear the chat history?')) {
|
781
|
+
// Keep the welcome message
|
782
|
+
const welcomeMessage = this.chatMessages.querySelector('.welcome-message');
|
783
|
+
this.chatMessages.innerHTML = '';
|
784
|
+
if (welcomeMessage) {
|
785
|
+
this.chatMessages.appendChild(welcomeMessage);
|
786
|
+
}
|
787
|
+
this.conversationId = null;
|
788
|
+
}
|
789
|
+
}
|
790
|
+
|
791
|
+
newChat() {
|
792
|
+
// Call the new chat API endpoint
|
793
|
+
fetch("{{ url_for('AirflowChatView.new_chat') }}", {
|
794
|
+
method: 'POST',
|
795
|
+
headers: {
|
796
|
+
'Content-Type': 'application/json',
|
797
|
+
'X-CSRFToken': '{{ csrf_token() }}'
|
798
|
+
}
|
799
|
+
})
|
800
|
+
.then(response => response.json())
|
801
|
+
.then(data => {
|
802
|
+
if (data.conversation_id) {
|
803
|
+
// this.conversationId = data.conversation_id;
|
804
|
+
this.clearChat();
|
805
|
+
this.setStatus('New chat initialized', 'ready');
|
806
|
+
} else {
|
807
|
+
console.error('Failed to initialize new chat:', data);
|
808
|
+
this.setStatus('Failed to create new chat', 'error');
|
809
|
+
}
|
810
|
+
})
|
811
|
+
.catch(error => {
|
812
|
+
console.error('Error creating new chat:', error);
|
813
|
+
this.setStatus('Error creating new chat', 'error');
|
814
|
+
});
|
815
|
+
}
|
816
|
+
|
817
|
+
scrollToBottom() {
|
818
|
+
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
|
819
|
+
}
|
820
|
+
|
821
|
+
escapeHtml(text) {
|
822
|
+
const div = document.createElement('div');
|
823
|
+
div.textContent = text;
|
824
|
+
return div.innerHTML;
|
825
|
+
}
|
826
|
+
}
|
827
|
+
|
828
|
+
// Initialize chat when page loads
|
829
|
+
document.addEventListener('DOMContentLoaded', () => {
|
830
|
+
new AirflowChatClient();
|
831
|
+
});
|
832
|
+
</script>
|
833
|
+
{% endblock %}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 hipposys-ltd
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
@@ -0,0 +1,19 @@
|
|
1
|
+
Metadata-Version: 2.3
|
2
|
+
Name: airflow-chat
|
3
|
+
Version: 0.1.0a1
|
4
|
+
Summary: An Apache Airflow plugin that enables AI-powered chat interactions with your Airflow instance through MCP integration and an intuitive UI.
|
5
|
+
License: MIT
|
6
|
+
Author: Hipposys
|
7
|
+
Requires-Python: >=3.10,<4.0
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
14
|
+
Requires-Dist: SQLAlchemy (>=1.4.0)
|
15
|
+
Requires-Dist: apache-airflow (>=2.4.0,<3.0.0)
|
16
|
+
Project-URL: homepage, https://github.com/hipposys-ltd/airflow-schedule-insights
|
17
|
+
Description-Content-Type: text/markdown
|
18
|
+
|
19
|
+
# Airflow Chat Plugin
|
@@ -0,0 +1,10 @@
|
|
1
|
+
airflow_chat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
airflow_chat/plugins/README.md,sha256=Hwmn7ohaPBIoeww2xQ_aSeB6Fy_EgkHXA3LYFi8beIk,21
|
3
|
+
airflow_chat/plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
4
|
+
airflow_chat/plugins/airflow_chat.py,sha256=bpygj75yfRYU7a6NNndnfSFqpYOzHWdwk0JlosdbJ-Q,17000
|
5
|
+
airflow_chat/plugins/templates/chat_interface.html,sha256=cV-_dDSKHUo1T8IQJ0iDCLDS-Dt2oJk7rHcecC8CeIc,19087
|
6
|
+
airflow_chat-0.1.0a1.dist-info/LICENSE,sha256=XFYCNJc3ykWUpIIuB6uHwgnWKWUm3iez5vxgFF352as,1069
|
7
|
+
airflow_chat-0.1.0a1.dist-info/METADATA,sha256=SaMdd-O9JaTHBhJMxQKqbKwc9ctZDAd8_fPJ_7QeT8Y,795
|
8
|
+
airflow_chat-0.1.0a1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
9
|
+
airflow_chat-0.1.0a1.dist-info/entry_points.txt,sha256=KzmeXDfhihgaLTQgh4vOz2ts7obTzZCg8CNgkHX1zaU,91
|
10
|
+
airflow_chat-0.1.0a1.dist-info/RECORD,,
|