airflow-chat 0.1.0a1__tar.gz

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.
@@ -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
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,22 @@
1
+ [tool.poetry]
2
+ name = "airflow-chat"
3
+ version = "0.1.0a1"
4
+ description = "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
+ authors = ["Hipposys"]
7
+ readme = "airflow_chat/plugins/README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.10"
11
+ apache-airflow = "^2.4.0"
12
+ SQLAlchemy = ">=1.4.0"
13
+
14
+ [tool.poetry.plugins."airflow.plugins"]
15
+ airflow-chat-plugin = "airflow_chat.plugins.airflow_chat:AirflowChatPlugin"
16
+
17
+ [build-system]
18
+ requires = ["poetry-core>=1.0.0"]
19
+ build-backend = "poetry.core.masonry.api"
20
+
21
+ [tool.poetry.urls]
22
+ homepage = "https://github.com/hipposys-ltd/airflow-schedule-insights"