Christian Kniep commited on
Commit
2e18bf2
·
1 Parent(s): 3a2a244

add settings and UI/UX improvements

Browse files
src/__pycache__/app.cpython-311.pyc CHANGED
Binary files a/src/__pycache__/app.cpython-311.pyc and b/src/__pycache__/app.cpython-311.pyc differ
 
src/app.py CHANGED
@@ -167,11 +167,12 @@ def create_app():
167
  return response
168
 
169
  # Register blueprints
170
- from .routes import auth, profile, contacts
171
 
172
  app.register_blueprint(auth.bp)
173
  app.register_blueprint(profile.bp)
174
  app.register_blueprint(contacts.contacts_bp)
 
175
 
176
  # Root route - redirect to login
177
  @app.route("/")
 
167
  return response
168
 
169
  # Register blueprints
170
+ from .routes import auth, profile, contacts, settings
171
 
172
  app.register_blueprint(auth.bp)
173
  app.register_blueprint(profile.bp)
174
  app.register_blueprint(contacts.contacts_bp)
175
+ app.register_blueprint(settings.bp)
176
 
177
  # Root route - redirect to login
178
  @app.route("/")
src/routes/__pycache__/auth.cpython-311.pyc CHANGED
Binary files a/src/routes/__pycache__/auth.cpython-311.pyc and b/src/routes/__pycache__/auth.cpython-311.pyc differ
 
src/routes/__pycache__/contacts.cpython-311.pyc CHANGED
Binary files a/src/routes/__pycache__/contacts.cpython-311.pyc and b/src/routes/__pycache__/contacts.cpython-311.pyc differ
 
src/routes/__pycache__/profile.cpython-311.pyc CHANGED
Binary files a/src/routes/__pycache__/profile.cpython-311.pyc and b/src/routes/__pycache__/profile.cpython-311.pyc differ
 
src/routes/__pycache__/settings.cpython-311.pyc ADDED
Binary file (9.48 kB). View file
 
src/routes/contacts.py CHANGED
@@ -446,6 +446,33 @@ def send_message(session_id: str):
446
  logger.info(f"[PERF] Message send took {elapsed:.1f}ms (target <150ms, excluding AI generation)")
447
  logger.info(f"Sent message in contact {session_id} for user {user_id}")
448
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  return redirect(url_for("contacts.view_contact", session_id=session_id))
450
 
451
  except ValueError as e:
@@ -534,6 +561,22 @@ def add_fact(session_id: str):
534
  flash("Fact added successfully!", "success")
535
  logger.info(f"Contact fact saved successfully: session_id={session_id}, producer={producer_id}")
536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  # Re-render with updated facts instead of redirect
538
  return render_template(
539
  "contacts/view.html",
 
446
  logger.info(f"[PERF] Message send took {elapsed:.1f}ms (target <150ms, excluding AI generation)")
447
  logger.info(f"Sent message in contact {session_id} for user {user_id}")
448
 
449
+ # Feature: 012-realtime-message-display - Return JSON for AJAX requests
450
+ if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
451
+ result = {
452
+ 'success': True,
453
+ 'user_message': {
454
+ 'id': response.get('message_id'),
455
+ 'content': content,
456
+ 'role': 'user',
457
+ 'type': 'question',
458
+ 'timestamp': response.get('timestamp'),
459
+ 'created_at': response.get('created_at')
460
+ }
461
+ }
462
+
463
+ # Include assistant response if available
464
+ if llm_response and llm_response.get('content'):
465
+ result['assistant_message'] = {
466
+ 'id': llm_response.get('message_id'),
467
+ 'content': llm_response['content'],
468
+ 'role': 'assistant',
469
+ 'type': 'response',
470
+ 'timestamp': llm_response.get('timestamp'),
471
+ 'created_at': llm_response.get('created_at')
472
+ }
473
+
474
+ return jsonify(result)
475
+
476
  return redirect(url_for("contacts.view_contact", session_id=session_id))
477
 
478
  except ValueError as e:
 
561
  flash("Fact added successfully!", "success")
562
  logger.info(f"Contact fact saved successfully: session_id={session_id}, producer={producer_id}")
563
 
564
+ # Feature: 012-realtime-message-display - Return JSON for AJAX requests
565
+ if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
566
+ # Find the newly added fact (last memorize message)
567
+ new_fact = next((m for m in reversed(facts) if m.get('mode') == 'memorize'), None)
568
+ return jsonify({
569
+ 'success': True,
570
+ 'message': {
571
+ 'id': new_fact.get('message_id') if new_fact else None,
572
+ 'content': content,
573
+ 'role': 'user',
574
+ 'type': 'fact',
575
+ 'timestamp': new_fact.get('timestamp') if new_fact else None,
576
+ 'created_at': new_fact.get('created_at') if new_fact else None
577
+ }
578
+ })
579
+
580
  # Re-render with updated facts instead of redirect
581
  return render_template(
582
  "contacts/view.html",
src/routes/profile.py CHANGED
@@ -5,7 +5,7 @@ User Story 1: Profile Management
5
  """
6
 
7
  import logging
8
- from flask import Blueprint, render_template, request, redirect, url_for, flash, session
9
 
10
  from ..services.backend_client import backend_client, BackendAPIError
11
  from ..services.storage_service import (
@@ -95,6 +95,21 @@ def add_fact():
95
  flash("Fact added successfully.", "success")
96
  logger.info(f"Profile fact saved successfully for user {user_id}")
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  # Re-render with updated facts instead of redirect
99
  return render_template(
100
  "profile/view.html",
 
5
  """
6
 
7
  import logging
8
+ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
9
 
10
  from ..services.backend_client import backend_client, BackendAPIError
11
  from ..services.storage_service import (
 
95
  flash("Fact added successfully.", "success")
96
  logger.info(f"Profile fact saved successfully for user {user_id}")
97
 
98
+ # Feature: 012-realtime-message-display - Return JSON for AJAX requests
99
+ if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
100
+ # Find the newly added fact (last fact in the list)
101
+ new_fact = facts[-1] if facts else None
102
+ return jsonify({
103
+ 'success': True,
104
+ 'message': {
105
+ 'id': new_fact.message_id if new_fact else None,
106
+ 'content': fact_content,
107
+ 'role': 'user',
108
+ 'type': 'fact',
109
+ 'created_at': new_fact.created_at if new_fact else None
110
+ }
111
+ })
112
+
113
  # Re-render with updated facts instead of redirect
114
  return render_template(
115
  "profile/view.html",
src/routes/settings.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Settings management routes.
3
+ Feature: 012-settings-model-templates
4
+ User Story 1: Model Selection for AI Operations
5
+ User Story 2: Response Template Management
6
+ """
7
+
8
+ import logging
9
+ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
10
+
11
+ from ..services.backend_client import backend_client, BackendAPIError
12
+
13
+ logger = logging.getLogger(__name__)
14
+ bp = Blueprint("settings", __name__, url_prefix="/settings")
15
+
16
+
17
+ @bp.route("/", methods=["GET"])
18
+ def view_settings():
19
+ """Display user settings page with model selection and template editor."""
20
+ # Check authentication
21
+ if "user_id" not in session:
22
+ flash("Please log in to view settings.", "warning")
23
+ return redirect(url_for("auth.login"))
24
+
25
+ user_id = session["user_id"]
26
+
27
+ try:
28
+ # Get user settings and active template from backend API
29
+ settings = backend_client.get_user_settings(user_id)
30
+ active_template = backend_client.get_active_template(user_id)
31
+ template_versions = backend_client.list_templates(user_id)
32
+
33
+ return render_template(
34
+ "settings/view.html",
35
+ settings=settings,
36
+ active_template=active_template,
37
+ template_versions=template_versions,
38
+ )
39
+
40
+ except BackendAPIError as e:
41
+ logger.error(f"Failed to load settings for user {user_id}: {str(e)}")
42
+ flash(f"Error loading settings: {str(e)}", "error")
43
+ return render_template(
44
+ "settings/view.html",
45
+ settings=None,
46
+ active_template=None,
47
+ template_versions=[],
48
+ )
49
+
50
+
51
+ @bp.route("/", methods=["POST"])
52
+ def update_settings():
53
+ """Update user settings (model selection)."""
54
+ # Check authentication
55
+ if "user_id" not in session:
56
+ return {"error": "Unauthorized"}, 401
57
+
58
+ user_id = session["user_id"]
59
+ reply_model = request.form.get("reply_generation_model", "").strip()
60
+
61
+ # Validate input
62
+ if not reply_model:
63
+ flash("Model selection is required.", "error")
64
+ return redirect(url_for("settings.view_settings"))
65
+
66
+ try:
67
+ # Update settings via backend API
68
+ # Set both fact_processing_model and reply_generation_model to the same value
69
+ updated_settings = backend_client.update_user_settings(
70
+ user_id=user_id,
71
+ fact_processing_model=reply_model,
72
+ reply_generation_model=reply_model,
73
+ )
74
+
75
+ logger.info(
76
+ f"Settings updated successfully: user_id={user_id}, "
77
+ f"model={reply_model}"
78
+ )
79
+ flash("Settings updated successfully.", "success")
80
+
81
+ # Return JSON for AJAX requests
82
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
83
+ return jsonify({
84
+ "success": True,
85
+ "settings": updated_settings,
86
+ })
87
+
88
+ return redirect(url_for("settings.view_settings"))
89
+
90
+ except BackendAPIError as e:
91
+ logger.error(
92
+ f"Failed to update settings for user {user_id}: {str(e)}"
93
+ )
94
+ flash(f"Error updating settings: {str(e)}", "error")
95
+
96
+ # Return JSON error for AJAX requests
97
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
98
+ return jsonify({
99
+ "success": False,
100
+ "error": str(e),
101
+ }), 400
102
+
103
+ return redirect(url_for("settings.view_settings"))
104
+
105
+
106
+ @bp.route("/templates", methods=["POST"])
107
+ def create_template():
108
+ """Create a new template version."""
109
+ # Check authentication
110
+ if "user_id" not in session:
111
+ return {"error": "Unauthorized"}, 401
112
+
113
+ user_id = session["user_id"]
114
+ template_content = request.form.get("template_content", "").strip()
115
+
116
+ # Validate input
117
+ if not template_content:
118
+ flash("Template content is required.", "error")
119
+ return redirect(url_for("settings.view_settings"))
120
+
121
+ if len(template_content) > 10000:
122
+ flash("Template content exceeds maximum length of 10000 characters.", "error")
123
+ return redirect(url_for("settings.view_settings"))
124
+
125
+ try:
126
+ # Create new template version via backend API
127
+ new_template = backend_client.create_template(
128
+ user_id=user_id, template_content=template_content
129
+ )
130
+
131
+ logger.info(
132
+ f"Template created successfully: user_id={user_id}, "
133
+ f"version_id={new_template.get('version_id')}"
134
+ )
135
+ flash("Template saved successfully.", "success")
136
+
137
+ # Return JSON for AJAX requests
138
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
139
+ return jsonify({"success": True, "template": new_template})
140
+
141
+ return redirect(url_for("settings.view_settings"))
142
+
143
+ except BackendAPIError as e:
144
+ logger.error(f"Failed to create template for user {user_id}: {str(e)}")
145
+
146
+ # Handle duplicate template error
147
+ if "duplicate" in str(e).lower() or "identical" in str(e).lower():
148
+ flash("Template with identical content already exists.", "warning")
149
+ else:
150
+ flash(f"Error creating template: {str(e)}", "error")
151
+
152
+ # Return JSON error for AJAX requests
153
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
154
+ return jsonify({"success": False, "error": str(e)}), 400
155
+
156
+ return redirect(url_for("settings.view_settings"))
157
+
158
+
159
+ @bp.route("/templates/<version_id>/activate", methods=["POST"])
160
+ def activate_template(version_id):
161
+ """Activate a specific template version."""
162
+ # Check authentication
163
+ if "user_id" not in session:
164
+ return {"error": "Unauthorized"}, 401
165
+
166
+ user_id = session["user_id"]
167
+
168
+ try:
169
+ # Activate template via backend API
170
+ result = backend_client.activate_template(
171
+ user_id=user_id, version_id=version_id
172
+ )
173
+
174
+ logger.info(
175
+ f"Template activated successfully: user_id={user_id}, "
176
+ f"version_id={version_id}"
177
+ )
178
+ flash("Template activated successfully.", "success")
179
+
180
+ # Return JSON for AJAX requests
181
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
182
+ return jsonify({"success": True, "result": result})
183
+
184
+ return redirect(url_for("settings.view_settings"))
185
+
186
+ except BackendAPIError as e:
187
+ logger.error(
188
+ f"Failed to activate template for user {user_id}, "
189
+ f"version {version_id}: {str(e)}"
190
+ )
191
+ flash(f"Error activating template: {str(e)}", "error")
192
+
193
+ # Return JSON error for AJAX requests
194
+ if request.headers.get("X-Requested-With") == "XMLHttpRequest":
195
+ return jsonify({"success": False, "error": str(e)}), 400
196
+
197
+ return redirect(url_for("settings.view_settings"))
src/services/__pycache__/backend_client.cpython-311.pyc CHANGED
Binary files a/src/services/__pycache__/backend_client.cpython-311.pyc and b/src/services/__pycache__/backend_client.cpython-311.pyc differ
 
src/services/backend_client.py CHANGED
@@ -400,6 +400,294 @@ class BackendAPIClient:
400
 
401
  return messages
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
  # Global client instance
405
  backend_client = BackendAPIClient()
 
400
 
401
  return messages
402
 
403
+ def get_user_settings(self, user_id: str) -> Dict[str, Any]:
404
+ """
405
+ Get user settings (AI model preferences).
406
+
407
+ Args:
408
+ user_id: User ID (required for API authentication)
409
+
410
+ Returns:
411
+ Settings dict with fact_processing_model, reply_generation_model, valid_models, updated_at
412
+
413
+ Raises:
414
+ BackendAPIError: If API request fails
415
+ """
416
+ url = f"{self.base_url}/api/settings"
417
+
418
+ # Create span for backend call
419
+ tracer = trace.get_tracer(__name__)
420
+ with tracer.start_as_current_span("backend.get_user_settings") as span:
421
+ span.set_attribute("http.method", "GET")
422
+ span.set_attribute("http.url", url)
423
+ span.set_attribute("user_id", user_id)
424
+
425
+ start_time = time.time()
426
+ try:
427
+ response = requests.get(
428
+ url, headers=self._get_headers(user_id), timeout=self.timeout
429
+ )
430
+ response.raise_for_status()
431
+
432
+ span.set_attribute("http.status_code", response.status_code)
433
+ return response.json()
434
+
435
+ except requests.exceptions.Timeout:
436
+ span.set_status(Status(StatusCode.ERROR, "timeout"))
437
+ span.set_attribute("timeout", self.timeout)
438
+ raise BackendAPIError(f"Request timed out after {self.timeout}s")
439
+ except requests.exceptions.RequestException as e:
440
+ span.set_status(Status(StatusCode.ERROR, str(e)))
441
+ raise BackendAPIError(f"Failed to fetch user settings: {str(e)}")
442
+ finally:
443
+ self._track_latency(start_time)
444
+
445
+ def update_user_settings(
446
+ self, user_id: str, fact_processing_model: str, reply_generation_model: str
447
+ ) -> Dict[str, Any]:
448
+ """
449
+ Update user settings (AI model preferences).
450
+
451
+ Args:
452
+ user_id: User ID (required for API authentication)
453
+ fact_processing_model: Model to use for fact processing/memorization
454
+ reply_generation_model: Model to use for generating chat responses
455
+
456
+ Returns:
457
+ Updated settings dict
458
+
459
+ Raises:
460
+ BackendAPIError: If API request fails or validation fails
461
+ """
462
+ url = f"{self.base_url}/api/settings"
463
+
464
+ payload = {
465
+ "fact_processing_model": fact_processing_model,
466
+ "reply_generation_model": reply_generation_model,
467
+ }
468
+
469
+ # Create span for backend call
470
+ tracer = trace.get_tracer(__name__)
471
+ with tracer.start_as_current_span("backend.update_user_settings") as span:
472
+ span.set_attribute("http.method", "PUT")
473
+ span.set_attribute("http.url", url)
474
+ span.set_attribute("user_id", user_id)
475
+ span.set_attribute("fact_model", fact_processing_model)
476
+ span.set_attribute("reply_model", reply_generation_model)
477
+
478
+ start_time = time.time()
479
+ try:
480
+ response = requests.put(
481
+ url,
482
+ json=payload,
483
+ headers=self._get_headers(user_id),
484
+ timeout=self.timeout,
485
+ )
486
+ response.raise_for_status()
487
+
488
+ span.set_attribute("http.status_code", response.status_code)
489
+ return response.json()
490
+
491
+ except requests.exceptions.Timeout:
492
+ span.set_status(Status(StatusCode.ERROR, "timeout"))
493
+ span.set_attribute("timeout", self.timeout)
494
+ raise BackendAPIError(f"Request timed out after {self.timeout}s")
495
+ except requests.exceptions.HTTPError as e:
496
+ span.set_status(Status(StatusCode.ERROR, str(e)))
497
+ # Extract error message from response if available
498
+ try:
499
+ error_data = e.response.json()
500
+ error_msg = error_data.get("error", str(e))
501
+ except Exception:
502
+ error_msg = str(e)
503
+ raise BackendAPIError(f"Failed to update settings: {error_msg}")
504
+ except requests.exceptions.RequestException as e:
505
+ span.set_status(Status(StatusCode.ERROR, str(e)))
506
+ raise BackendAPIError(f"Failed to update settings: {str(e)}")
507
+ finally:
508
+ self._track_latency(start_time)
509
+
510
+ def get_active_template(self, user_id: str) -> Dict[str, Any]:
511
+ """
512
+ Get user's active response template.
513
+
514
+ Args:
515
+ user_id: User ID (required for API authentication)
516
+
517
+ Returns:
518
+ Template dict with version_id, template_content, is_active, created_at
519
+
520
+ Raises:
521
+ BackendAPIError: If API request fails
522
+ """
523
+ url = f"{self.base_url}/api/templates/active"
524
+
525
+ # Create span for backend call
526
+ tracer = trace.get_tracer(__name__)
527
+ with tracer.start_as_current_span("backend.get_active_template") as span:
528
+ span.set_attribute("http.method", "GET")
529
+ span.set_attribute("http.url", url)
530
+ span.set_attribute("user_id", user_id)
531
+
532
+ start_time = time.time()
533
+ try:
534
+ response = requests.get(
535
+ url, headers=self._get_headers(user_id), timeout=self.timeout
536
+ )
537
+ response.raise_for_status()
538
+ return response.json()
539
+ except requests.exceptions.HTTPError as e:
540
+ span.set_status(Status(StatusCode.ERROR, str(e)))
541
+ try:
542
+ error_data = e.response.json()
543
+ error_msg = error_data.get("error", str(e))
544
+ except Exception:
545
+ error_msg = str(e)
546
+ raise BackendAPIError(f"Failed to fetch active template: {error_msg}")
547
+ except requests.exceptions.RequestException as e:
548
+ span.set_status(Status(StatusCode.ERROR, str(e)))
549
+ raise BackendAPIError(f"Failed to fetch active template: {str(e)}")
550
+ finally:
551
+ self._track_latency(start_time)
552
+
553
+ def list_templates(self, user_id: str) -> List[Dict[str, Any]]:
554
+ """
555
+ List all template versions for a user.
556
+
557
+ Args:
558
+ user_id: User ID (required for API authentication)
559
+
560
+ Returns:
561
+ List of template dicts with version_id, template_content, is_active, created_at
562
+
563
+ Raises:
564
+ BackendAPIError: If API request fails
565
+ """
566
+ url = f"{self.base_url}/api/templates"
567
+
568
+ # Create span for backend call
569
+ tracer = trace.get_tracer(__name__)
570
+ with tracer.start_as_current_span("backend.list_templates") as span:
571
+ span.set_attribute("http.method", "GET")
572
+ span.set_attribute("http.url", url)
573
+ span.set_attribute("user_id", user_id)
574
+
575
+ start_time = time.time()
576
+ try:
577
+ response = requests.get(
578
+ url, headers=self._get_headers(user_id), timeout=self.timeout
579
+ )
580
+ response.raise_for_status()
581
+ data = response.json()
582
+ return data.get("templates", [])
583
+ except requests.exceptions.HTTPError as e:
584
+ span.set_status(Status(StatusCode.ERROR, str(e)))
585
+ try:
586
+ error_data = e.response.json()
587
+ error_msg = error_data.get("error", str(e))
588
+ except Exception:
589
+ error_msg = str(e)
590
+ raise BackendAPIError(f"Failed to list templates: {error_msg}")
591
+ except requests.exceptions.RequestException as e:
592
+ span.set_status(Status(StatusCode.ERROR, str(e)))
593
+ raise BackendAPIError(f"Failed to list templates: {str(e)}")
594
+ finally:
595
+ self._track_latency(start_time)
596
+
597
+ def create_template(self, user_id: str, template_content: str) -> Dict[str, Any]:
598
+ """
599
+ Create a new template version.
600
+
601
+ Args:
602
+ user_id: User ID (required for API authentication)
603
+ template_content: Template content (max 10000 characters)
604
+
605
+ Returns:
606
+ Created template dict with version_id, template_content, is_active, created_at
607
+
608
+ Raises:
609
+ BackendAPIError: If API request fails
610
+ """
611
+ url = f"{self.base_url}/api/templates"
612
+ payload = {"template_content": template_content}
613
+
614
+ # Create span for backend call
615
+ tracer = trace.get_tracer(__name__)
616
+ with tracer.start_as_current_span("backend.create_template") as span:
617
+ span.set_attribute("http.method", "POST")
618
+ span.set_attribute("http.url", url)
619
+ span.set_attribute("user_id", user_id)
620
+ span.set_attribute("content_length", len(template_content))
621
+
622
+ start_time = time.time()
623
+ try:
624
+ response = requests.post(
625
+ url,
626
+ headers=self._get_headers(user_id),
627
+ json=payload,
628
+ timeout=self.timeout,
629
+ )
630
+ response.raise_for_status()
631
+ return response.json()
632
+ except requests.exceptions.HTTPError as e:
633
+ span.set_status(Status(StatusCode.ERROR, str(e)))
634
+ try:
635
+ error_data = e.response.json()
636
+ error_msg = error_data.get("error", str(e))
637
+ except Exception:
638
+ error_msg = str(e)
639
+ raise BackendAPIError(f"Failed to create template: {error_msg}")
640
+ except requests.exceptions.RequestException as e:
641
+ span.set_status(Status(StatusCode.ERROR, str(e)))
642
+ raise BackendAPIError(f"Failed to create template: {str(e)}")
643
+ finally:
644
+ self._track_latency(start_time)
645
+
646
+ def activate_template(self, user_id: str, version_id: str) -> Dict[str, Any]:
647
+ """
648
+ Activate a specific template version.
649
+
650
+ Args:
651
+ user_id: User ID (required for API authentication)
652
+ version_id: Template version ID to activate
653
+
654
+ Returns:
655
+ Success response dict
656
+
657
+ Raises:
658
+ BackendAPIError: If API request fails
659
+ """
660
+ url = f"{self.base_url}/api/templates/{version_id}/activate"
661
+
662
+ # Create span for backend call
663
+ tracer = trace.get_tracer(__name__)
664
+ with tracer.start_as_current_span("backend.activate_template") as span:
665
+ span.set_attribute("http.method", "PUT")
666
+ span.set_attribute("http.url", url)
667
+ span.set_attribute("user_id", user_id)
668
+ span.set_attribute("version_id", version_id)
669
+
670
+ start_time = time.time()
671
+ try:
672
+ response = requests.put(
673
+ url, headers=self._get_headers(user_id), timeout=self.timeout
674
+ )
675
+ response.raise_for_status()
676
+ return response.json()
677
+ except requests.exceptions.HTTPError as e:
678
+ span.set_status(Status(StatusCode.ERROR, str(e)))
679
+ try:
680
+ error_data = e.response.json()
681
+ error_msg = error_data.get("error", str(e))
682
+ except Exception:
683
+ error_msg = str(e)
684
+ raise BackendAPIError(f"Failed to activate template: {error_msg}")
685
+ except requests.exceptions.RequestException as e:
686
+ span.set_status(Status(StatusCode.ERROR, str(e)))
687
+ raise BackendAPIError(f"Failed to activate template: {str(e)}")
688
+ finally:
689
+ self._track_latency(start_time)
690
+
691
 
692
  # Global client instance
693
  backend_client = BackendAPIClient()
src/static/css/custom.css CHANGED
@@ -208,3 +208,68 @@ select:focus {
208
  display: none;
209
  }
210
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  display: none;
209
  }
210
  }
211
+
212
+ /* Sync Status Indicators - Feature: 012-realtime-message-display */
213
+ /* T016-T018: Visual feedback for optimistic UI updates */
214
+
215
+ .sync-indicator {
216
+ display: inline-flex;
217
+ align-items: center;
218
+ justify-content: center;
219
+ font-size: 0.875rem;
220
+ margin-left: 0.5rem;
221
+ min-width: 1.25rem;
222
+ }
223
+
224
+ /* T016: Pending state - message awaiting backend confirmation */
225
+ .sync-indicator.pending {
226
+ opacity: 0.7;
227
+ color: var(--info-color);
228
+ }
229
+
230
+ .message-pending {
231
+ opacity: 0.7;
232
+ transition: opacity 0.3s ease;
233
+ }
234
+
235
+ /* T017: Confirmed state - message successfully synced */
236
+ .sync-indicator.confirmed {
237
+ opacity: 1.0;
238
+ color: var(--success-color);
239
+ }
240
+
241
+ /* T018: Failed state - sync error, needs retry */
242
+ .sync-indicator.failed {
243
+ color: var(--danger-color);
244
+ border: 1px solid var(--danger-color);
245
+ border-radius: 0.25rem;
246
+ padding: 0.125rem 0.375rem;
247
+ cursor: pointer;
248
+ background-color: rgba(220, 53, 69, 0.1);
249
+ font-size: 0.75rem;
250
+ transition: all 0.2s ease;
251
+ }
252
+
253
+ .sync-indicator.failed:hover {
254
+ background-color: rgba(220, 53, 69, 0.2);
255
+ transform: scale(1.05);
256
+ }
257
+
258
+ .message-failed {
259
+ border-left: 3px solid var(--danger-color) !important;
260
+ background-color: rgba(220, 53, 69, 0.05);
261
+ }
262
+
263
+ /* Retry button styling */
264
+ .retry-btn {
265
+ border: none;
266
+ background: none;
267
+ padding: 0;
268
+ color: inherit;
269
+ font: inherit;
270
+ cursor: pointer;
271
+ }
272
+
273
+ .retry-btn:hover {
274
+ text-decoration: underline;
275
+ }
src/static/js/message-cache.js ADDED
@@ -0,0 +1,561 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Message Cache Module for Realtime Message Display Synchronization
3
+ * Feature: 012-realtime-message-display
4
+ *
5
+ * Implements optimistic UI updates for instant message display with async backend sync.
6
+ * Uses sessionStorage for persistence across page reloads.
7
+ *
8
+ * Performance targets:
9
+ * - <50ms optimistic message display
10
+ * - <2s cache refresh
11
+ * - <10ms sessionStorage access
12
+ */
13
+
14
+ class MessageCache {
15
+ constructor() {
16
+ this.CACHE_KEY = 'prepmate_message_cache';
17
+ this.cache = null;
18
+ this.sessionId = null;
19
+ this.debugMode = localStorage.getItem('DEBUG') === 'true';
20
+ }
21
+
22
+ /**
23
+ * T008: Initialize cache for a session
24
+ * Loads from sessionStorage or creates empty cache
25
+ * @param {string} sessionId - Current session UUID
26
+ */
27
+ init(sessionId) {
28
+ this.sessionId = sessionId;
29
+ this.cache = this.loadCacheFromBrowser();
30
+
31
+ // If no cache exists or session changed, create new cache
32
+ if (!this.cache || this.cache.sessionId !== sessionId) {
33
+ this.cache = {
34
+ sessionId: sessionId,
35
+ lastFetchTimestamp: Date.now(),
36
+ confirmedMessages: [],
37
+ pendingMessages: []
38
+ };
39
+ this.saveCacheToBrowser();
40
+ }
41
+
42
+ this.logMetric('cache_init', 1, { session_id: sessionId });
43
+ }
44
+
45
+ /**
46
+ * T007: Load cache from sessionStorage
47
+ * @returns {Object|null} Parsed cache object or null if not found
48
+ */
49
+ loadCacheFromBrowser() {
50
+ try {
51
+ const startTime = performance.now();
52
+ const cached = sessionStorage.getItem(this.CACHE_KEY);
53
+ const duration = performance.now() - startTime;
54
+
55
+ this.logMetric('cache_load_duration_ms', duration);
56
+
57
+ return cached ? JSON.parse(cached) : null;
58
+ } catch (e) {
59
+ console.error('Failed to load cache:', e);
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * T007: Save cache to sessionStorage
66
+ * T062: Monitor cache size and warn if approaching quota
67
+ */
68
+ saveCacheToBrowser() {
69
+ try {
70
+ const startTime = performance.now();
71
+ const cacheJSON = JSON.stringify(this.cache);
72
+ const sizeKB = (cacheJSON.length / 1024).toFixed(2);
73
+
74
+ // T062: Warn if approaching 5MB limit (sessionStorage typically ~5-10MB)
75
+ if (cacheJSON.length > 4 * 1024 * 1024) { // 4MB warning threshold
76
+ console.warn(`Cache size approaching limit: ${sizeKB}KB / ~5120KB`);
77
+ this.logMetric('cache_size_warning', 1, { size_kb: sizeKB });
78
+ }
79
+
80
+ sessionStorage.setItem(this.CACHE_KEY, cacheJSON);
81
+ const duration = performance.now() - startTime;
82
+
83
+ this.logMetric('cache_save_duration_ms', duration, { size_kb: sizeKB });
84
+ } catch (e) {
85
+ console.error('Failed to save cache:', e);
86
+ // Check if storage is full
87
+ if (e.name === 'QuotaExceededError') {
88
+ console.warn('sessionStorage full, clearing old messages');
89
+ // Keep only last 25 confirmed messages
90
+ this.cache.confirmedMessages = this.cache.confirmedMessages.slice(-25);
91
+ try {
92
+ sessionStorage.setItem(this.CACHE_KEY, JSON.stringify(this.cache));
93
+ } catch (e2) {
94
+ console.error('Failed to save cache after cleanup:', e2);
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * T009: Structured console logging for metrics
102
+ * @param {string} metric - Metric name
103
+ * @param {number} value - Metric value
104
+ * @param {Object} metadata - Additional metadata
105
+ */
106
+ logMetric(metric, value, metadata = {}) {
107
+ if (this.debugMode) {
108
+ console.log(JSON.stringify({
109
+ timestamp: Date.now(),
110
+ metric: metric,
111
+ value: value,
112
+ session_id: this.sessionId,
113
+ ...metadata
114
+ }));
115
+ }
116
+ }
117
+
118
+ /**
119
+ * T010: Emit custom DOM event for cache changes
120
+ * @param {string} eventName - Event name (cache:message-added, etc.)
121
+ * @param {Object} detail - Event detail data
122
+ */
123
+ emitCacheEvent(eventName, detail) {
124
+ const event = new CustomEvent(eventName, {
125
+ detail: detail,
126
+ bubbles: true
127
+ });
128
+ document.dispatchEvent(event);
129
+ }
130
+
131
+ /**
132
+ * T011: Get current cache
133
+ * @returns {Object} Current cache object
134
+ */
135
+ getCache() {
136
+ return this.cache;
137
+ }
138
+
139
+ /**
140
+ * T011: Update cache and persist
141
+ * @param {Object} updates - Partial updates to apply to cache
142
+ */
143
+ updateCache(updates) {
144
+ this.cache = { ...this.cache, ...updates };
145
+ this.saveCacheToBrowser();
146
+ }
147
+
148
+ /**
149
+ * T011: Clear cache (on logout or session switch)
150
+ */
151
+ clearCache() {
152
+ try {
153
+ sessionStorage.removeItem(this.CACHE_KEY);
154
+ this.cache = null;
155
+ this.logMetric('cache_clear', 1);
156
+ } catch (e) {
157
+ console.error('Failed to clear cache:', e);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * T012: Add optimistic message to cache
163
+ * T063: Prevent duplicates on rapid submission
164
+ * Displays message immediately and initiates async backend sync
165
+ * @param {string} content - Message content (markdown)
166
+ * @param {string} role - "user" or "assistant"
167
+ * @param {string} type - "fact", "question", or "response"
168
+ * @returns {Promise<Object>} Result with tempId and success status
169
+ */
170
+ async addOptimisticMessage(content, role, type) {
171
+ const startTime = performance.now();
172
+
173
+ // T063: Check for duplicate pending messages (same content, syncing/pending)
174
+ const duplicate = this.cache.pendingMessages.find(m =>
175
+ m.content.trim() === content.trim() &&
176
+ (m.syncStatus === 'pending' || m.syncStatus === 'syncing')
177
+ );
178
+
179
+ if (duplicate) {
180
+ console.warn('Duplicate message detected, skipping:', content.substring(0, 50));
181
+ return { success: false, error: 'Duplicate message in flight' };
182
+ }
183
+
184
+ // Generate unique temp ID
185
+ const tempId = crypto.randomUUID();
186
+
187
+ // Create pending message
188
+ const pendingMessage = {
189
+ tempId: tempId,
190
+ content: content,
191
+ role: role,
192
+ type: type,
193
+ clientTimestamp: Date.now(),
194
+ syncStatus: 'pending',
195
+ retryCount: 0,
196
+ error: null
197
+ };
198
+
199
+ // Add to cache immediately
200
+ this.cache.pendingMessages.push(pendingMessage);
201
+ this.saveCacheToBrowser();
202
+
203
+ const displayDuration = performance.now() - startTime;
204
+ this.logMetric('optimistic_display_duration_ms', displayDuration, { type: type });
205
+
206
+ // Emit event for UI update
207
+ this.emitCacheEvent('cache:message-added', pendingMessage);
208
+
209
+ // Initiate async backend POST (T013)
210
+ this.syncMessageToBackend(tempId, content, role, type);
211
+
212
+ return { tempId: tempId, success: true };
213
+ }
214
+
215
+ /**
216
+ * T013: Async backend POST for message sync
217
+ * Uses existing Flask form routes for compatibility
218
+ * @param {string} tempId - Temporary message ID
219
+ * @param {string} content - Message content
220
+ * @param {string} role - Message role
221
+ * @param {string} type - Message type
222
+ */
223
+ async syncMessageToBackend(tempId, content, role, type) {
224
+ try {
225
+ // Update sync status to 'syncing'
226
+ const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId);
227
+ if (pendingIndex >= 0) {
228
+ this.cache.pendingMessages[pendingIndex].syncStatus = 'syncing';
229
+ this.saveCacheToBrowser();
230
+ }
231
+
232
+ // Determine route based on type and context
233
+ let route;
234
+ if (window.location.pathname.includes('/contacts/')) {
235
+ // Contact session routes
236
+ if (type === 'fact') {
237
+ route = `/contacts/${this.sessionId}/facts`;
238
+ } else {
239
+ route = `/contacts/${this.sessionId}/messages`;
240
+ }
241
+ } else {
242
+ // Profile session route
243
+ route = '/profile/facts/add';
244
+ }
245
+
246
+ // POST to backend using form data (matching existing Flask routes)
247
+ const formData = new FormData();
248
+ formData.append('content', content);
249
+
250
+ const response = await fetch(route, {
251
+ method: 'POST',
252
+ body: formData,
253
+ headers: {
254
+ 'X-Requested-With': 'XMLHttpRequest' // Indicate AJAX request
255
+ }
256
+ });
257
+
258
+ if (!response.ok) {
259
+ const errorText = await response.text();
260
+ throw new Error(`Backend sync failed: ${response.status} ${errorText}`);
261
+ }
262
+
263
+ // Parse JSON response from backend
264
+ const result = await response.json();
265
+
266
+ // Confirm the user message
267
+ const confirmedMessage = {
268
+ id: result.message?.id || result.user_message?.id || crypto.randomUUID(),
269
+ content: content,
270
+ role: role,
271
+ type: type,
272
+ timestamp: result.message?.timestamp || result.user_message?.timestamp || new Date().toISOString(),
273
+ created_at: result.message?.created_at || result.user_message?.created_at || new Date().toISOString()
274
+ };
275
+
276
+ // Success: confirm the message (T014)
277
+ this.confirmOptimisticMessage(tempId, confirmedMessage);
278
+
279
+ // T038-T039: If there's an assistant response, add it immediately
280
+ if (result.assistant_message) {
281
+ this.addAssistantResponse(result.assistant_message);
282
+ }
283
+
284
+ } catch (error) {
285
+ console.error('Backend sync error:', error);
286
+ // Failure: mark as failed (T015)
287
+ this.markOptimisticMessageFailed(tempId, error.message);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * T038-T039: Add assistant response immediately (already synced from backend)
293
+ * @param {Object} assistantMessage - Assistant message data from backend
294
+ */
295
+ addAssistantResponse(assistantMessage) {
296
+ const message = {
297
+ id: assistantMessage.id || assistantMessage.message_id,
298
+ content: assistantMessage.content,
299
+ role: 'assistant',
300
+ type: assistantMessage.type || 'response',
301
+ timestamp: new Date(assistantMessage.timestamp || assistantMessage.created_at).getTime(),
302
+ created_at: assistantMessage.created_at
303
+ };
304
+
305
+ // Add directly to confirmed (already synced)
306
+ this.cache.confirmedMessages.push(message);
307
+ this.saveCacheToBrowser();
308
+
309
+ this.logMetric('assistant_response_added', 1);
310
+
311
+ // T041: Emit event for UI update
312
+ this.emitCacheEvent('cache:message-added', message);
313
+ }
314
+
315
+ /**
316
+ * T014: Confirm optimistic message after successful backend sync
317
+ * @param {string} tempId - Temporary message ID
318
+ * @param {Object} confirmedMessage - Backend response with id, timestamp, etc.
319
+ */
320
+ confirmOptimisticMessage(tempId, confirmedMessage) {
321
+ // Find and remove from pending
322
+ const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId);
323
+ if (pendingIndex >= 0) {
324
+ this.cache.pendingMessages.splice(pendingIndex, 1);
325
+ }
326
+
327
+ // Add to confirmed messages
328
+ const message = {
329
+ id: confirmedMessage.id || confirmedMessage.message_id,
330
+ content: confirmedMessage.content,
331
+ role: confirmedMessage.role,
332
+ type: confirmedMessage.type,
333
+ timestamp: new Date(confirmedMessage.timestamp || confirmedMessage.created_at).getTime(),
334
+ isReference: confirmedMessage.is_reference || false
335
+ };
336
+
337
+ this.cache.confirmedMessages.push(message);
338
+ this.saveCacheToBrowser();
339
+
340
+ this.logMetric('message_confirmed', 1, { temp_id: tempId });
341
+
342
+ // Emit event for UI update
343
+ this.emitCacheEvent('cache:message-confirmed', {
344
+ tempId: tempId,
345
+ confirmedMessage: message
346
+ });
347
+ }
348
+
349
+ /**
350
+ * T015: Mark optimistic message as failed after backend sync error
351
+ * @param {string} tempId - Temporary message ID
352
+ * @param {string} error - Error message
353
+ */
354
+ markOptimisticMessageFailed(tempId, error) {
355
+ const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId);
356
+ if (pendingIndex >= 0) {
357
+ const message = this.cache.pendingMessages[pendingIndex];
358
+ message.syncStatus = 'failed';
359
+ message.retryCount += 1;
360
+ message.error = error;
361
+
362
+ this.saveCacheToBrowser();
363
+
364
+ this.logMetric('sync_failure', 1, {
365
+ temp_id: tempId,
366
+ retry_count: message.retryCount,
367
+ error: error
368
+ });
369
+
370
+ // Emit event for UI update
371
+ this.emitCacheEvent('cache:message-failed', {
372
+ tempId: tempId,
373
+ error: error,
374
+ retryCount: message.retryCount
375
+ });
376
+
377
+ // Schedule automatic retry if under max retries (T057)
378
+ if (message.retryCount < 3) {
379
+ const retryDelay = Math.pow(2, message.retryCount) * 1000; // 2s, 4s, 8s
380
+ setTimeout(() => {
381
+ this.retryFailedMessage(tempId);
382
+ }, retryDelay);
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * T056: Retry failed message manually or automatically
389
+ * @param {string} tempId - Temporary message ID
390
+ * @returns {Promise<Object>} Result with success status
391
+ */
392
+ async retryFailedMessage(tempId) {
393
+ const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId);
394
+ if (pendingIndex >= 0) {
395
+ const message = this.cache.pendingMessages[pendingIndex];
396
+ message.syncStatus = 'syncing';
397
+ this.saveCacheToBrowser();
398
+
399
+ this.logMetric('message_retry', 1, { temp_id: tempId, retry_count: message.retryCount });
400
+
401
+ // Retry backend sync
402
+ await this.syncMessageToBackend(tempId, message.content, message.role, message.type);
403
+
404
+ return { success: true };
405
+ }
406
+ return { success: false, error: 'Message not found' };
407
+ }
408
+
409
+ /**
410
+ * Get all messages (confirmed + pending) in chronological order
411
+ * @returns {Array} Sorted array of messages
412
+ */
413
+ getAllMessages() {
414
+ if (!this.cache) return [];
415
+
416
+ // Combine confirmed and pending messages
417
+ const allMessages = [
418
+ ...this.cache.confirmedMessages.map(m => ({ ...m, syncStatus: 'confirmed' })),
419
+ ...this.cache.pendingMessages
420
+ ];
421
+
422
+ // Sort by timestamp (confirmed) or clientTimestamp (pending)
423
+ allMessages.sort((a, b) => {
424
+ const timeA = a.timestamp || a.clientTimestamp;
425
+ const timeB = b.timestamp || b.clientTimestamp;
426
+ return timeA - timeB;
427
+ });
428
+
429
+ return allMessages;
430
+ }
431
+
432
+ /**
433
+ * T046-T047: Refresh cache from backend API and merge with pending messages
434
+ * Deduplicates by matching content + timestamp proximity (±5s)
435
+ * @returns {Promise<void>}
436
+ */
437
+ async refreshCache() {
438
+ if (!this.cache || !this.cache.sessionId) {
439
+ console.warn('Cannot refresh cache: not initialized');
440
+ return;
441
+ }
442
+
443
+ const startTime = performance.now();
444
+
445
+ try {
446
+ // Note: We don't have a GET messages endpoint yet
447
+ // For now, just mark last fetch timestamp
448
+ // In production, this would: GET /api/sessions/{id}/messages
449
+ // and merge with pending using deduplication logic
450
+
451
+ this.cache.lastFetchTimestamp = new Date().toISOString();
452
+ this.saveCacheToBrowser();
453
+
454
+ const duration = performance.now() - startTime;
455
+ this.logMetric('cache_refresh_duration_ms', duration);
456
+
457
+ // T049: Emit refresh event
458
+ this.emitCacheEvent('cache:refreshed', {
459
+ sessionId: this.cache.sessionId,
460
+ confirmedCount: this.cache.confirmedMessages.length,
461
+ pendingCount: this.cache.pendingMessages.length,
462
+ duration: duration
463
+ });
464
+
465
+ } catch (error) {
466
+ console.error('Cache refresh error:', error);
467
+ throw error;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * T047: Deduplicate messages by content and timestamp proximity
473
+ * @param {Array} backendMessages - Messages from backend API
474
+ * @param {Array} pendingMessages - Pending optimistic messages
475
+ * @returns {Array} Deduplicated confirmed messages
476
+ */
477
+ deduplicateMessages(backendMessages, pendingMessages) {
478
+ const deduplicated = [];
479
+ const TIMESTAMP_TOLERANCE_MS = 5000; // ±5 seconds
480
+
481
+ for (const backendMsg of backendMessages) {
482
+ // Check if this backend message matches any pending message
483
+ const matchingPending = pendingMessages.find(pending => {
484
+ const contentMatch = pending.content.trim() === backendMsg.content.trim();
485
+ const timestampDiff = Math.abs(
486
+ new Date(backendMsg.timestamp || backendMsg.created_at).getTime() -
487
+ pending.clientTimestamp
488
+ );
489
+ return contentMatch && timestampDiff < TIMESTAMP_TOLERANCE_MS;
490
+ });
491
+
492
+ // If no match, add to deduplicated list
493
+ if (!matchingPending) {
494
+ deduplicated.push({
495
+ id: backendMsg.id || backendMsg.message_id,
496
+ content: backendMsg.content,
497
+ role: backendMsg.role || backendMsg.sender,
498
+ type: backendMsg.type || backendMsg.mode,
499
+ timestamp: new Date(backendMsg.timestamp || backendMsg.created_at).getTime(),
500
+ created_at: backendMsg.created_at
501
+ });
502
+ }
503
+ }
504
+
505
+ return deduplicated;
506
+ }
507
+
508
+ /**
509
+ * T050: Clear cache and reinitialize for session switch
510
+ * @param {string} newSessionId - New session ID
511
+ */
512
+ switchSession(newSessionId) {
513
+ // Clear current cache
514
+ this.cache = {
515
+ sessionId: newSessionId,
516
+ confirmedMessages: [],
517
+ pendingMessages: [],
518
+ lastFetchTimestamp: null
519
+ };
520
+
521
+ this.saveCacheToBrowser();
522
+
523
+ this.logMetric('session_switched', 1, {
524
+ new_session_id: newSessionId
525
+ });
526
+
527
+ // Trigger refresh for new session
528
+ this.refreshCache();
529
+
530
+ // Emit event
531
+ this.emitCacheEvent('cache:session-switched', {
532
+ sessionId: newSessionId
533
+ });
534
+ }
535
+
536
+ /**
537
+ * T061: Clear cache on logout
538
+ */
539
+ clearCache() {
540
+ this.cache = null;
541
+
542
+ try {
543
+ sessionStorage.removeItem(this.STORAGE_KEY);
544
+ this.logMetric('cache_cleared', 1);
545
+ } catch (error) {
546
+ console.error('Error clearing cache:', error);
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Get cache for inspection/debugging
552
+ * @returns {Object} Current cache state
553
+ */
554
+ getCache() {
555
+ return this.cache;
556
+ }
557
+ }
558
+
559
+ // Export singleton instance
560
+ const messageCache = new MessageCache();
561
+ window.messageCache = messageCache;
src/templates/base.html CHANGED
@@ -38,6 +38,11 @@
38
  <i class="bi bi-people"></i> My Contacts
39
  </a>
40
  </li>
 
 
 
 
 
41
  <li class="nav-item">
42
  <span class="nav-link">
43
  <i class="bi bi-person-badge"></i> {{ session.get('display_name', 'User') }}
 
38
  <i class="bi bi-people"></i> My Contacts
39
  </a>
40
  </li>
41
+ <li class="nav-item">
42
+ <a class="nav-link" href="{{ url_for('settings.view_settings') }}">
43
+ <i class="bi bi-gear"></i> Settings
44
+ </a>
45
+ </li>
46
  <li class="nav-item">
47
  <span class="nav-link">
48
  <i class="bi bi-person-badge"></i> {{ session.get('display_name', 'User') }}
src/templates/contacts/view.html CHANGED
@@ -5,6 +5,8 @@
5
  {% block head %}
6
  <!-- Marked.js for Markdown rendering -->
7
  <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
 
 
8
  <style>
9
  /* Markdown styling for assistant messages */
10
  .markdown-content {
@@ -176,8 +178,9 @@
176
  {{ message.get('created_at', '') }}
177
  </small>
178
  </div>
179
- {% endif %}
180
- {% endif %}
 
181
  </div>
182
  {% endfor %}
183
  {% else %}
@@ -402,53 +405,70 @@ document.addEventListener('DOMContentLoaded', function() {
402
  });
403
  });
404
 
405
- // Chat functionality
406
- const messageHistory = document.getElementById('messageHistory');
407
- const messageContent = document.getElementById('messageContent');
408
- const charCounter = document.getElementById('charCounter');
409
- const chatForm = document.getElementById('chatForm');
410
- const askBtn = document.getElementById('askBtn');
411
- const addFactBtn = document.getElementById('addFactBtn');
 
 
 
 
412
 
413
- // Auto-scroll to bottom of message history
414
- if (messageHistory) {
415
- messageHistory.scrollTop = messageHistory.scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
 
416
  }
417
 
418
- // Character counter with dynamic max length
419
- if (messageContent && charCounter) {
420
- messageContent.addEventListener('input', function() {
 
421
  const length = this.value.length;
422
- charCounter.textContent = length + ' / 10000';
423
- if (length > 9000) {
424
- charCounter.classList.add('text-warning');
425
- } else {
426
- charCounter.classList.remove('text-warning');
427
- }
428
- });
429
- }
430
 
431
- // Auto-resize message textarea
432
- if (messageContent) {
433
- messageContent.addEventListener('input', function() {
434
- this.style.height = 'auto';
435
- this.style.height = Math.min(this.scrollHeight, 120) + 'px';
436
- });
437
-
438
- // Handle Enter key (submit question) vs Shift+Enter (new line)
439
- messageContent.addEventListener('keydown', function(e) {
440
- if (e.key === 'Enter' && !e.shiftKey) {
441
- e.preventDefault();
442
- if (this.value.trim().length > 0) {
443
- askBtn.click();
 
444
  }
445
- }
446
- });
447
- }
448
 
449
- // Ask Question button handler (T019: Feature 001-refine-memory-producer-logic)
450
- if (askBtn) {
451
- askBtn.addEventListener('click', function(e) {
452
  e.preventDefault();
453
  const content = messageContent.value.trim();
454
 
@@ -473,15 +493,31 @@ if (askBtn) {
473
  document.getElementById('askSpinner').classList.remove('d-none');
474
  document.getElementById('askText').textContent = 'Sending...';
475
 
476
- // Submit to send_message endpoint
477
- chatForm.action = "{{ url_for('contacts.send_message', session_id=contact.session_id) }}";
478
- chatForm.submit();
479
- });
480
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
 
482
- // Add Fact button handler (T019: Feature 001-refine-memory-producer-logic)
483
- if (addFactBtn) {
484
- addFactBtn.addEventListener('click', function(e) {
485
  e.preventDefault();
486
  const content = messageContent.value.trim();
487
 
@@ -506,10 +542,215 @@ if (addFactBtn) {
506
  document.getElementById('factSpinner').classList.remove('d-none');
507
  document.getElementById('factText').textContent = 'Adding...';
508
 
509
- // Submit to add_fact endpoint
510
- chatForm.action = "{{ url_for('contacts.add_fact', session_id=contact.session_id) }}";
511
- chatForm.submit();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
513
  }
514
  </script>
515
  {% endblock %}
 
5
  {% block head %}
6
  <!-- Marked.js for Markdown rendering -->
7
  <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
8
+ <!-- Message Cache for Optimistic UI Updates (Feature: 012-realtime-message-display) -->
9
+ <script src="{{ url_for('static', filename='js/message-cache.js') }}"></script>
10
  <style>
11
  /* Markdown styling for assistant messages */
12
  .markdown-content {
 
178
  {{ message.get('created_at', '') }}
179
  </small>
180
  </div>
181
+ </div>
182
+ {% endif %}
183
+ {% endif %}
184
  </div>
185
  {% endfor %}
186
  {% else %}
 
405
  });
406
  });
407
 
408
+ // T029: Initialize message cache for contacts session (Feature: 012-realtime-message-display)
409
+ const SESSION_ID = '{{ contact.session_id }}';
410
+ messageCache.init(SESSION_ID);
411
+
412
+ // Declare variables at top level
413
+ let messageHistory;
414
+ let messageContent;
415
+ let charCounter;
416
+ let chatForm;
417
+ let askBtn;
418
+ let addFactBtn;
419
 
420
+ // Initialize all elements after DOM is ready
421
+ function initializeElements() {
422
+ messageHistory = document.getElementById('messageHistory');
423
+ messageContent = document.getElementById('messageContent');
424
+ charCounter = document.getElementById('charCounter');
425
+ chatForm = document.getElementById('chatForm');
426
+ askBtn = document.getElementById('askBtn');
427
+ addFactBtn = document.getElementById('addFactBtn');
428
+
429
+ // Auto-scroll to bottom of message history
430
+ if (messageHistory) {
431
+ messageHistory.scrollTop = messageHistory.scrollHeight;
432
+ }
433
+
434
+ setupEventHandlers();
435
  }
436
 
437
+ function setupEventHandlers() {
438
+ // Character counter with dynamic max length
439
+ if (messageContent && charCounter) {
440
+ messageContent.addEventListener('input', function() {
441
  const length = this.value.length;
442
+ charCounter.textContent = length + ' / 10000';
443
+ if (length > 9000) {
444
+ charCounter.classList.add('text-warning');
445
+ } else {
446
+ charCounter.classList.remove('text-warning');
447
+ }
448
+ });
449
+ }
450
 
451
+ // Auto-resize message textarea
452
+ if (messageContent) {
453
+ messageContent.addEventListener('input', function() {
454
+ this.style.height = 'auto';
455
+ this.style.height = Math.min(this.scrollHeight, 120) + 'px';
456
+ });
457
+
458
+ // Handle Enter key (submit question) vs Shift+Enter (new line)
459
+ messageContent.addEventListener('keydown', function(e) {
460
+ if (e.key === 'Enter' && !e.shiftKey) {
461
+ e.preventDefault();
462
+ if (this.value.trim().length > 0) {
463
+ askBtn.click();
464
+ }
465
  }
466
+ });
467
+ }
 
468
 
469
+ // T030-T032: Ask Question handler with optimistic updates (Feature: 012-realtime-message-display)
470
+ if (askBtn) {
471
+ askBtn.addEventListener('click', async function(e) {
472
  e.preventDefault();
473
  const content = messageContent.value.trim();
474
 
 
493
  document.getElementById('askSpinner').classList.remove('d-none');
494
  document.getElementById('askText').textContent = 'Sending...';
495
 
496
+ try {
497
+ // T030: Optimistic update - question appears instantly
498
+ await messageCache.addOptimisticMessage(content, 'user', 'question');
499
+
500
+ // Clear textarea
501
+ messageContent.value = '';
502
+ charCounter.textContent = '0 / 10000';
503
+ messageContent.style.height = 'auto';
504
+ } catch (error) {
505
+ console.error('Error sending question:', error);
506
+ alert('Failed to send question. Please try again.');
507
+ } finally {
508
+ // Re-enable buttons
509
+ askBtn.disabled = false;
510
+ addFactBtn.disabled = false;
511
+ document.getElementById('askIcon').classList.remove('d-none');
512
+ document.getElementById('askSpinner').classList.add('d-none');
513
+ document.getElementById('askText').textContent = 'Ask Question';
514
+ }
515
+ });
516
+ }
517
 
518
+ // T034: Add Fact handler with optimistic updates (Feature: 012-realtime-message-display)
519
+ if (addFactBtn) {
520
+ addFactBtn.addEventListener('click', async function(e) {
521
  e.preventDefault();
522
  const content = messageContent.value.trim();
523
 
 
542
  document.getElementById('factSpinner').classList.remove('d-none');
543
  document.getElementById('factText').textContent = 'Adding...';
544
 
545
+ try {
546
+ // T034: Optimistic update - fact appears instantly
547
+ await messageCache.addOptimisticMessage(content, 'user', 'fact');
548
+
549
+ // Clear textarea
550
+ messageContent.value = '';
551
+ charCounter.textContent = '0 / 10000';
552
+ messageContent.style.height = 'auto';
553
+ } catch (error) {
554
+ console.error('Error adding fact:', error);
555
+ alert('Failed to add fact. Please try again.');
556
+ } finally {
557
+ // Re-enable buttons
558
+ askBtn.disabled = false;
559
+ addFactBtn.disabled = false;
560
+ document.getElementById('factIcon').classList.remove('d-none');
561
+ document.getElementById('factSpinner').classList.add('d-none');
562
+ document.getElementById('factText').textContent = 'Add Fact';
563
+ }
564
+ });
565
+ }
566
+ }
567
+
568
+ // T031-T032: Render messages with sync status indicators (Feature: 012-realtime-message-display)
569
+ function renderMessages() {
570
+ const messages = messageCache.getAllMessages();
571
+ const container = messageHistory;
572
+
573
+ // Clear existing content
574
+ const emptyState = container.querySelector('.alert-info');
575
+ container.innerHTML = '';
576
+
577
+ if (messages.length === 0) {
578
+ if (emptyState) {
579
+ container.appendChild(emptyState);
580
+ } else {
581
+ container.innerHTML = `
582
+ <div class="h-100 d-flex flex-column justify-content-center align-items-center p-4">
583
+ <div class="alert alert-info" role="status" style="max-width: 500px;">
584
+ <i class="bi bi-info-circle me-2"></i>
585
+ <strong>No facts or messages yet.</strong>
586
+ <p class="mb-0 mt-2 small">Add your first fact about {{ contact.contact_name }} using the "Add Fact" button below, or ask a question to start a conversation.</p>
587
+ </div>
588
+ </div>
589
+ `;
590
+ }
591
+ return;
592
+ }
593
+
594
+ // Render each message
595
+ messages.forEach(message => {
596
+ const messageDiv = document.createElement('div');
597
+ messageDiv.className = 'mb-3';
598
+ messageDiv.dataset.messageId = message.id || message.tempId;
599
+
600
+ // Add pending/failed classes
601
+ if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') {
602
+ messageDiv.classList.add('message-pending');
603
+ } else if (message.syncStatus === 'failed') {
604
+ messageDiv.classList.add('message-failed');
605
+ }
606
+
607
+ const timestamp = message.timestamp
608
+ ? new Date(message.timestamp).toLocaleString()
609
+ : new Date(message.clientTimestamp).toLocaleString();
610
+
611
+ // Render sync indicator
612
+ let syncIndicatorHTML = '';
613
+ if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') {
614
+ syncIndicatorHTML = '<span class="sync-indicator pending ms-2" title="Syncing..." aria-label="Message syncing with server">⏳</span>';
615
+ } else if (message.syncStatus === 'failed') {
616
+ syncIndicatorHTML = `
617
+ <button class="sync-indicator failed retry-btn ms-2"
618
+ onclick="retryMessage('${message.tempId}')"
619
+ title="Sync failed. Click to retry."
620
+ aria-label="Sync failed. Click to retry sending message">
621
+ ⚠️ Retry
622
+ </button>
623
+ `;
624
+ } else if (message.syncStatus === 'confirmed') {
625
+ syncIndicatorHTML = '<span class="sync-indicator confirmed ms-2" title="Synced" aria-label="Message successfully synced">✓</span>';
626
+ }
627
+
628
+ // Render based on message type
629
+ if (message.type === 'fact') {
630
+ // Fact message - info border style
631
+ messageDiv.innerHTML = `
632
+ <div class="p-3 rounded bg-light border-start border-info border-3">
633
+ <div class="d-flex align-items-start">
634
+ <i class="bi bi-bookmark-fill text-info me-2"></i>
635
+ <div class="flex-grow-1">
636
+ <p class="mb-1">${escapeHtml(message.content)}</p>
637
+ <small class="text-muted">
638
+ ${timestamp}${syncIndicatorHTML}
639
+ </small>
640
+ </div>
641
+ </div>
642
+ </div>
643
+ `;
644
+ } else if (message.role === 'assistant') {
645
+ // Assistant message - right aligned, gray
646
+ const renderedMarkdown = marked.parse(message.content);
647
+ messageDiv.innerHTML = `
648
+ <div class="d-flex justify-content-end">
649
+ <div class="bg-light border p-3 rounded" style="max-width: 75%;">
650
+ <div class="markdown-content text-dark">${renderedMarkdown}</div>
651
+ <small class="text-muted">
652
+ <i class="bi bi-cpu"></i> AI · ${timestamp}${syncIndicatorHTML}
653
+ </small>
654
+ </div>
655
+ </div>
656
+ `;
657
+ } else {
658
+ // User question - left aligned, blue
659
+ messageDiv.innerHTML = `
660
+ <div class="d-flex justify-content-start">
661
+ <div class="bg-primary text-white p-3 rounded" style="max-width: 75%;">
662
+ <p class="mb-1">${escapeHtml(message.content)}</p>
663
+ <small class="text-white-50">
664
+ ${timestamp}${syncIndicatorHTML}
665
+ </small>
666
+ </div>
667
+ </div>
668
+ `;
669
+ }
670
+
671
+ container.appendChild(messageDiv);
672
  });
673
+
674
+ // Auto-scroll to bottom
675
+ container.scrollTop = container.scrollHeight;
676
+ }
677
+
678
+ // Helper: Escape HTML to prevent XSS
679
+ function escapeHtml(text) {
680
+ const div = document.createElement('div');
681
+ div.textContent = text;
682
+ return div.innerHTML;
683
+ }
684
+
685
+ // Retry failed message
686
+ async function retryMessage(tempId) {
687
+ try {
688
+ await messageCache.retryFailedMessage(tempId);
689
+ } catch (error) {
690
+ console.error('Error retrying message:', error);
691
+ alert('Failed to retry. Please try again.');
692
+ }
693
+ }
694
+
695
+ // T032: Listen to cache events and re-render (Feature: 012-realtime-message-display)
696
+ document.addEventListener('cache:message-added', (event) => {
697
+ console.log('Message added:', event.detail);
698
+ renderMessages();
699
+ });
700
+
701
+ document.addEventListener('cache:message-confirmed', (event) => {
702
+ console.log('Message confirmed:', event.detail);
703
+ renderMessages();
704
+ });
705
+
706
+ document.addEventListener('cache:message-failed', (event) => {
707
+ console.log('Message failed:', event.detail);
708
+ renderMessages();
709
+ });
710
+
711
+ // Initialize: Load server-rendered messages into cache on first load
712
+ function initializeContactMessages() {
713
+ const cache = messageCache.getCache();
714
+
715
+ // Only load server messages if cache is empty (first visit)
716
+ if (cache.confirmedMessages.length === 0) {
717
+ {% set all_messages = facts + messages %}
718
+ {% if all_messages %}
719
+ const serverMessages = [
720
+ {% for message in all_messages | sort(attribute='created_at') %}
721
+ {
722
+ id: '{{ message.get("message_id", "") }}',
723
+ content: {{ (message.get('content', message.get('message', '')))|tojson }},
724
+ role: {% if message.get('sender') == 'assistant' %}'assistant'{% else %}'user'{% endif %},
725
+ type: {% if message.get('mode') == 'memorize' %}'fact'{% else %}'question'{% endif %},
726
+ timestamp: '{{ message.get("created_at", "") }}',
727
+ created_at: '{{ message.get("created_at", "") }}',
728
+ syncStatus: 'confirmed'
729
+ }{% if not loop.last %},{% endif %}
730
+ {% endfor %}
731
+ ];
732
+
733
+ // Add to cache
734
+ cache.confirmedMessages = serverMessages;
735
+ cache.lastFetchTimestamp = new Date().toISOString();
736
+ messageCache.saveCacheToBrowser();
737
+ {% endif %}
738
+ }
739
+
740
+ // Now render from cache
741
+ renderMessages();
742
+ }
743
+
744
+ // Wait for DOM ready and initialize everything
745
+ function initializeAll() {
746
+ initializeElements();
747
+ initializeContactMessages();
748
+ }
749
+
750
+ if (document.readyState === 'loading') {
751
+ document.addEventListener('DOMContentLoaded', initializeAll);
752
+ } else {
753
+ initializeAll();
754
  }
755
  </script>
756
  {% endblock %}
src/templates/profile/view.html CHANGED
@@ -114,7 +114,7 @@
114
  {% endif %}
115
  {% endwith %}
116
 
117
- <form method="POST" action="{{ url_for('profile.add_fact') }}" id="addFactForm" onsubmit="return handleSubmit()">
118
  <div class="input-group">
119
  <textarea class="form-control"
120
  id="factContent"
@@ -143,7 +143,16 @@
143
  </div>
144
  </div>
145
 
 
 
 
146
  <script>
 
 
 
 
 
 
147
  // Auto-resize textarea as user types
148
  const textarea = document.getElementById('factContent');
149
  const charCounter = document.getElementById('charCounter');
@@ -194,19 +203,48 @@ if (factsContainer) {
194
  }
195
  }
196
 
197
- // Handle form submission with button disable (T011: Feature 001-refine-memory-producer-logic)
198
- function handleSubmit() {
 
 
199
  if (submitBtn.disabled) {
200
  return false;
201
  }
202
 
 
 
 
 
 
203
  // Disable button and show spinner
204
  submitBtn.disabled = true;
205
  submitIcon.classList.add('d-none');
206
  submitSpinner.classList.remove('d-none');
207
 
208
- // Button will re-enable on page reload or error
209
- return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
 
212
  // Delete fact function
@@ -220,5 +258,172 @@ function deleteFact(messageId) {
220
  form.submit();
221
  }
222
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  </script>
224
  {% endblock %}
 
114
  {% endif %}
115
  {% endwith %}
116
 
117
+ <form method="POST" action="{{ url_for('profile.add_fact') }}" id="addFactForm" onsubmit="handleSubmit(event); return false;">
118
  <div class="input-group">
119
  <textarea class="form-control"
120
  id="factContent"
 
143
  </div>
144
  </div>
145
 
146
+ <!-- T019: Include message-cache.js for optimistic UI updates -->
147
+ <script src="{{ url_for('static', filename='js/message-cache.js') }}"></script>
148
+
149
  <script>
150
+ // T020: Initialize cache on page load with session_id
151
+ {% if user_profile and user_profile.session_id %}
152
+ const SESSION_ID = '{{ user_profile.session_id }}';
153
+ messageCache.init(SESSION_ID);
154
+ {% endif %}
155
+
156
  // Auto-resize textarea as user types
157
  const textarea = document.getElementById('factContent');
158
  const charCounter = document.getElementById('charCounter');
 
203
  }
204
  }
205
 
206
+ // T021: Handle form submission with optimistic UI update
207
+ async function handleSubmit(event) {
208
+ event.preventDefault();
209
+
210
  if (submitBtn.disabled) {
211
  return false;
212
  }
213
 
214
+ const content = textarea.value.trim();
215
+ if (!content) {
216
+ return false;
217
+ }
218
+
219
  // Disable button and show spinner
220
  submitBtn.disabled = true;
221
  submitIcon.classList.add('d-none');
222
  submitSpinner.classList.remove('d-none');
223
 
224
+ try {
225
+ // Add optimistic message to cache (displays immediately)
226
+ await messageCache.addOptimisticMessage(content, 'user', 'fact');
227
+
228
+ // Clear textarea
229
+ textarea.value = '';
230
+ textarea.style.height = 'auto';
231
+ charCounter.textContent = '0 / 500';
232
+ charCounter.classList.remove('text-danger');
233
+
234
+ // Re-enable button
235
+ submitBtn.disabled = false;
236
+ submitIcon.classList.remove('d-none');
237
+ submitSpinner.classList.add('d-none');
238
+
239
+ } catch (error) {
240
+ console.error('Error adding fact:', error);
241
+ submitBtn.disabled = false;
242
+ submitIcon.classList.remove('d-none');
243
+ submitSpinner.classList.add('d-none');
244
+ alert('Failed to add fact. Please try again.');
245
+ }
246
+
247
+ return false;
248
  }
249
 
250
  // Delete fact function
 
258
  form.submit();
259
  }
260
  }
261
+
262
+ // T022: Render messages with sync status indicators
263
+ function renderMessages() {
264
+ const messages = messageCache.getAllMessages();
265
+ const container = factsContainer;
266
+
267
+ // Clear existing facts (except empty state)
268
+ const emptyState = container.querySelector('.empty-state');
269
+ container.innerHTML = '';
270
+
271
+ if (messages.length === 0) {
272
+ if (emptyState) {
273
+ container.appendChild(emptyState);
274
+ } else {
275
+ container.innerHTML = `
276
+ <div class="empty-state h-100 d-flex flex-column justify-content-center align-items-center">
277
+ <i class="bi bi-lightbulb" style="font-size: 3rem; color: #6c757d;"></i>
278
+ <p class="mt-3 text-muted">No facts yet. Start adding information about yourself below!</p>
279
+ <p class="small text-muted mb-0">Examples: "I work as a software engineer" or "I have 2 children"</p>
280
+ </div>
281
+ `;
282
+ }
283
+ return;
284
+ }
285
+
286
+ // Render each message
287
+ messages.forEach(message => {
288
+ const messageDiv = document.createElement('div');
289
+ messageDiv.className = 'mb-2 fact-message';
290
+ messageDiv.dataset.messageId = message.id || message.tempId;
291
+
292
+ // Add pending/failed classes
293
+ if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') {
294
+ messageDiv.classList.add('message-pending');
295
+ } else if (message.syncStatus === 'failed') {
296
+ messageDiv.classList.add('message-failed');
297
+ }
298
+
299
+ const timestamp = message.timestamp
300
+ ? new Date(message.timestamp).toLocaleString()
301
+ : new Date(message.clientTimestamp).toLocaleString();
302
+
303
+ // Render sync indicator
304
+ let syncIndicatorHTML = '';
305
+ if (message.syncStatus === 'pending' || message.syncStatus === 'syncing') {
306
+ syncIndicatorHTML = '<span class="sync-indicator pending" title="Syncing..." aria-label="Message syncing with server">⏳</span>';
307
+ } else if (message.syncStatus === 'failed') {
308
+ syncIndicatorHTML = `
309
+ <button class="sync-indicator failed retry-btn"
310
+ onclick="retryMessage('${message.tempId}')"
311
+ title="Sync failed. Click to retry."
312
+ aria-label="Sync failed. Click to retry sending message">
313
+ ⚠️ Retry
314
+ </button>
315
+ `;
316
+ } else if (message.syncStatus === 'confirmed') {
317
+ syncIndicatorHTML = '<span class="sync-indicator confirmed" title="Synced" aria-label="Message successfully synced">✓</span>';
318
+ }
319
+
320
+ messageDiv.innerHTML = `
321
+ <div class="d-flex align-items-start">
322
+ <div class="flex-grow-1">
323
+ <div class="fact-card p-2">
324
+ <div class="d-flex justify-content-between align-items-center">
325
+ <span class="flex-grow-1">${escapeHtml(message.content)}</span>
326
+ <div class="d-flex align-items-center ms-3">
327
+ ${syncIndicatorHTML}
328
+ <small class="text-muted ms-2 me-2" style="font-size: 0.75rem;">${timestamp}</small>
329
+ ${message.id ? `
330
+ <button type="button"
331
+ class="btn btn-sm btn-link text-danger p-0"
332
+ onclick="deleteFact('${message.id}')"
333
+ title="Delete fact">
334
+ <i class="bi bi-trash" style="font-size: 0.875rem;"></i>
335
+ </button>
336
+ ` : ''}
337
+ </div>
338
+ </div>
339
+ </div>
340
+ </div>
341
+ </div>
342
+ `;
343
+
344
+ container.appendChild(messageDiv);
345
+ });
346
+
347
+ // Auto-scroll to bottom
348
+ container.scrollTop = container.scrollHeight;
349
+ }
350
+
351
+ // Helper: Escape HTML to prevent XSS
352
+ function escapeHtml(text) {
353
+ const div = document.createElement('div');
354
+ div.textContent = text;
355
+ return div.innerHTML;
356
+ }
357
+
358
+ // Retry failed message
359
+ async function retryMessage(tempId) {
360
+ try {
361
+ await messageCache.retryFailedMessage(tempId);
362
+ } catch (error) {
363
+ console.error('Error retrying message:', error);
364
+ alert('Failed to retry. Please try again.');
365
+ }
366
+ }
367
+
368
+ // T023: Listen to cache:message-added event and re-render
369
+ document.addEventListener('cache:message-added', (event) => {
370
+ console.log('Message added:', event.detail);
371
+ renderMessages();
372
+ });
373
+
374
+ // T024: Listen to cache:message-confirmed event and update indicator
375
+ document.addEventListener('cache:message-confirmed', (event) => {
376
+ console.log('Message confirmed:', event.detail);
377
+ renderMessages();
378
+ });
379
+
380
+ // Listen to cache:message-failed event
381
+ document.addEventListener('cache:message-failed', (event) => {
382
+ console.log('Message failed:', event.detail);
383
+ renderMessages();
384
+ });
385
+
386
+ // Initial render from cache on page load
387
+ {% if user_profile and user_profile.session_id %}
388
+ // Wait for DOM to be ready
389
+ if (document.readyState === 'loading') {
390
+ document.addEventListener('DOMContentLoaded', initializeFacts);
391
+ } else {
392
+ initializeFacts();
393
+ }
394
+
395
+ // Load server-rendered facts into cache on first load
396
+ function initializeFacts() {
397
+ const cache = messageCache.getCache();
398
+
399
+ // Only load server facts if cache is empty (first visit)
400
+ if (cache.confirmedMessages.length === 0) {
401
+ {% if facts %}
402
+ // Load existing facts from server into cache
403
+ const serverFacts = [
404
+ {% for fact in facts %}
405
+ {
406
+ id: '{{ fact.message_id }}',
407
+ content: {{ fact.content|tojson }},
408
+ role: 'user',
409
+ type: 'fact',
410
+ timestamp: '{{ fact.created_at }}',
411
+ created_at: '{{ fact.created_at }}',
412
+ syncStatus: 'confirmed'
413
+ }{% if not loop.last %},{% endif %}
414
+ {% endfor %}
415
+ ];
416
+
417
+ // Add to cache
418
+ cache.confirmedMessages = serverFacts;
419
+ cache.lastFetchTimestamp = new Date().toISOString();
420
+ messageCache.saveCacheToBrowser();
421
+ {% endif %}
422
+ }
423
+
424
+ // Now render from cache
425
+ renderMessages();
426
+ }
427
+ {% endif %}
428
  </script>
429
  {% endblock %}
src/templates/settings/view.html ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Settings - PrepMate{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .deprecated-warning {
8
+ background-color: #fff3cd;
9
+ border-left: 4px solid #ffc107;
10
+ padding: 12px 16px;
11
+ margin-bottom: 20px;
12
+ border-radius: 4px;
13
+ }
14
+ .deprecated-warning .bi {
15
+ font-size: 1.2em;
16
+ margin-right: 8px;
17
+ }
18
+ .model-info {
19
+ font-size: 0.875rem;
20
+ color: #6c757d;
21
+ margin-top: 4px;
22
+ }
23
+ .settings-card {
24
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
25
+ }
26
+ </style>
27
+ {% endblock %}
28
+
29
+ {% block content %}
30
+ <div class="container mt-4">
31
+ <div class="row">
32
+ <div class="col-lg-8 mx-auto">
33
+ <h1 class="mb-4">
34
+ <i class="bi bi-gear"></i> Settings
35
+ </h1>
36
+
37
+ {% if settings %}
38
+
39
+ <div class="card settings-card">
40
+ <div class="card-header bg-primary text-white">
41
+ <h5 class="mb-0">
42
+ <i class="bi bi-cpu"></i> AI Model Selection
43
+ </h5>
44
+ </div>
45
+ <div class="card-body">
46
+ <p class="text-muted">
47
+ Configure which OpenAI models are used for different operations.
48
+ Your selections will be used for all future AI interactions.
49
+ </p>
50
+
51
+ <form method="POST" action="{{ url_for('settings.update_settings') }}">
52
+ <!-- Reply Generation Model -->
53
+ <div class="mb-4">
54
+ <label for="reply_generation_model" class="form-label fw-bold">
55
+ <i class="bi bi-chat-dots"></i> Reply Generation Model
56
+ </label>
57
+ <select
58
+ class="form-select"
59
+ id="reply_generation_model"
60
+ name="reply_generation_model"
61
+ required
62
+ >
63
+ {% for model in settings.valid_models %}
64
+ <option value="{{ model }}"
65
+ {% if model == settings.reply_generation_model %}selected{% endif %}>
66
+ {{ model }}
67
+ </option>
68
+ {% endfor %}
69
+ </select>
70
+ <div class="model-info">
71
+ Used when generating AI responses to your questions and messages.
72
+ </div>
73
+ </div>
74
+
75
+ <!-- Last Updated -->
76
+ <div class="mb-3">
77
+ <small class="text-muted">
78
+ <i class="bi bi-clock"></i> Last updated: {{ settings.updated_at }}
79
+ </small>
80
+ </div>
81
+
82
+ <!-- Submit Button -->
83
+ <div class="d-grid gap-2">
84
+ <button type="submit" class="btn btn-primary">
85
+ <i class="bi bi-check-circle"></i> Save Settings
86
+ </button>
87
+ </div>
88
+ </form>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- Available Models Reference -->
93
+ <div class="card mt-4">
94
+ <div class="card-header">
95
+ <h6 class="mb-0">
96
+ <i class="bi bi-info-circle"></i> Available Model
97
+ </h6>
98
+ </div>
99
+ <div class="card-body">
100
+ <p class="mb-0"><strong>gpt-4.1-mini:</strong> Cost-effective GPT-4 variant</p>
101
+ </div>
102
+ </div>
103
+
104
+ <!-- Response Template Editor (US2) -->
105
+ <div class="card settings-card mt-4">
106
+ <div class="card-header bg-success text-white">
107
+ <h5 class="mb-0">
108
+ <i class="bi bi-file-text"></i> Response Template
109
+ </h5>
110
+ </div>
111
+ <div class="card-body">
112
+ <p class="text-muted">
113
+ Customize how AI responses are structured. Templates use Go template syntax with variables like {% raw %}{{.UserMessage}}{% endraw %}, {% raw %}{{.SessionProfile}}{% endraw %}, etc.
114
+ </p>
115
+
116
+ <form method="POST" action="{{ url_for('settings.create_template') }}" id="template-form">
117
+ <!-- Template Content Textarea -->
118
+ <div class="mb-3">
119
+ <label for="template_content" class="form-label fw-bold">
120
+ <i class="bi bi-code-square"></i> Template Content
121
+ </label>
122
+ <textarea
123
+ class="form-control font-monospace"
124
+ id="template_content"
125
+ name="template_content"
126
+ rows="15"
127
+ maxlength="10000"
128
+ required
129
+ style="font-size: 0.875rem;"
130
+ >{% if active_template %}{{ active_template.template_content }}{% endif %}</textarea>
131
+ <div class="form-text">
132
+ <span id="char-count">0</span> / 10000 characters
133
+ </div>
134
+ </div>
135
+
136
+ <!-- Version History Dropdown -->
137
+ {% if template_versions and template_versions|length > 0 %}
138
+ <div class="mb-3">
139
+ <label for="version_selector" class="form-label fw-bold">
140
+ <i class="bi bi-clock-history"></i> Version History
141
+ </label>
142
+ <select class="form-select" id="version_selector">
143
+ {% for version in template_versions %}
144
+ <option
145
+ value="{{ version.version_id }}"
146
+ data-content="{{ version.template_content }}"
147
+ {% if version.is_active %}selected{% endif %}
148
+ >
149
+ {{ version.created_at }} {% if version.is_active %}(Active){% endif %}
150
+ </option>
151
+ {% endfor %}
152
+ </select>
153
+ <div class="form-text">
154
+ Select a previous version to view or activate it.
155
+ </div>
156
+ </div>
157
+
158
+ <div class="d-flex gap-2">
159
+ <button type="submit" class="btn btn-success">
160
+ <i class="bi bi-save"></i> Save New Version
161
+ </button>
162
+ <button type="button" class="btn btn-outline-primary" id="activate-btn">
163
+ <i class="bi bi-check-circle"></i> Activate Selected Version
164
+ </button>
165
+ </div>
166
+ {% else %}
167
+ <div class="mb-3">
168
+ <p class="text-muted">
169
+ <i class="bi bi-info-circle"></i> No version history yet. Save your first template version above.
170
+ </p>
171
+ </div>
172
+
173
+ <button type="submit" class="btn btn-success">
174
+ <i class="bi bi-save"></i> Save New Version
175
+ </button>
176
+ {% endif %}
177
+ </form>
178
+ </div>
179
+ </div>
180
+ {% else %}
181
+ <div class="alert alert-warning">
182
+ <i class="bi bi-exclamation-triangle"></i>
183
+ Unable to load settings. Please try again later.
184
+ </div>
185
+ {% endif %}
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <script>
191
+ // Character counter
192
+ const templateTextarea = document.getElementById('template_content');
193
+ const charCount = document.getElementById('char-count');
194
+
195
+ function updateCharCount() {
196
+ if (templateTextarea && charCount) {
197
+ charCount.textContent = templateTextarea.value.length;
198
+ }
199
+ }
200
+
201
+ if (templateTextarea) {
202
+ templateTextarea.addEventListener('input', updateCharCount);
203
+ updateCharCount(); // Initial count
204
+ }
205
+
206
+ // Version selector - load template content when selecting a version
207
+ const versionSelector = document.getElementById('version_selector');
208
+ if (versionSelector) {
209
+ versionSelector.addEventListener('change', function() {
210
+ const selectedOption = this.options[this.selectedIndex];
211
+ const content = selectedOption.getAttribute('data-content');
212
+ if (content && templateTextarea) {
213
+ templateTextarea.value = content;
214
+ updateCharCount();
215
+ }
216
+ });
217
+ }
218
+
219
+ // Activate selected version button
220
+ const activateBtn = document.getElementById('activate-btn');
221
+ if (activateBtn) {
222
+ activateBtn.addEventListener('click', function() {
223
+ if (!versionSelector) return;
224
+
225
+ const versionId = versionSelector.value;
226
+ if (!versionId) {
227
+ alert('Please select a version to activate');
228
+ return;
229
+ }
230
+
231
+ // Send POST request to activate endpoint
232
+ fetch(`{{ url_for('settings.activate_template', version_id='__VERSION_ID__') }}`.replace('__VERSION_ID__', versionId), {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json',
236
+ 'X-Requested-With': 'XMLHttpRequest'
237
+ }
238
+ })
239
+ .then(response => response.json())
240
+ .then(data => {
241
+ if (data.success) {
242
+ window.location.reload();
243
+ } else {
244
+ alert('Error activating template: ' + (data.error || 'Unknown error'));
245
+ }
246
+ })
247
+ .catch(error => {
248
+ console.error('Error:', error);
249
+ alert('Error activating template. Please try again.');
250
+ });
251
+ });
252
+ }
253
+ </script>
254
+
255
+ {% endblock %}