rubyllm-observ 0.6.5 → 0.6.7

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +319 -2
  3. data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
  4. data/app/assets/javascripts/observ/controllers/index.js +29 -0
  5. data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
  6. data/app/assets/stylesheets/observ/_chat.scss +199 -0
  7. data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
  8. data/app/assets/stylesheets/observ/application.scss +1 -0
  9. data/app/controllers/observ/dataset_items_controller.rb +2 -2
  10. data/app/controllers/observ/dataset_runs_controller.rb +1 -1
  11. data/app/controllers/observ/datasets_controller.rb +2 -2
  12. data/app/controllers/observ/messages_controller.rb +5 -1
  13. data/app/controllers/observ/prompts_controller.rb +11 -3
  14. data/app/controllers/observ/scores_controller.rb +1 -1
  15. data/app/controllers/observ/traces_controller.rb +1 -1
  16. data/app/helpers/observ/application_helper.rb +1 -0
  17. data/app/helpers/observ/markdown_helper.rb +29 -0
  18. data/app/helpers/observ/prompts_helper.rb +48 -0
  19. data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
  20. data/app/models/observ/embedding.rb +45 -0
  21. data/app/models/observ/image_generation.rb +38 -0
  22. data/app/models/observ/moderation.rb +40 -0
  23. data/app/models/observ/null_prompt.rb +49 -2
  24. data/app/models/observ/observation.rb +3 -1
  25. data/app/models/observ/session.rb +33 -0
  26. data/app/models/observ/trace.rb +90 -4
  27. data/app/models/observ/transcription.rb +38 -0
  28. data/app/services/observ/chat_instrumenter.rb +96 -6
  29. data/app/services/observ/concerns/observable_service.rb +108 -3
  30. data/app/services/observ/embedding_instrumenter.rb +193 -0
  31. data/app/services/observ/guardrail_service.rb +9 -0
  32. data/app/services/observ/image_generation_instrumenter.rb +243 -0
  33. data/app/services/observ/moderation_guardrail_service.rb +235 -0
  34. data/app/services/observ/moderation_instrumenter.rb +141 -0
  35. data/app/services/observ/transcription_instrumenter.rb +187 -0
  36. data/app/views/layouts/observ/application.html.erb +1 -1
  37. data/app/views/observ/chats/show.html.erb +9 -0
  38. data/app/views/observ/messages/_message.html.erb +1 -1
  39. data/app/views/observ/messages/create.turbo_stream.erb +1 -3
  40. data/app/views/observ/prompts/_config_editor.html.erb +115 -0
  41. data/app/views/observ/prompts/_form.html.erb +2 -13
  42. data/app/views/observ/prompts/_new_form.html.erb +2 -12
  43. data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
  44. data/lib/observ/configuration.rb +0 -2
  45. data/lib/observ/engine.rb +7 -0
  46. data/lib/observ/version.rb +1 -1
  47. metadata +31 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94f902979484dc837e40bf42dbfc66381eaf4326b3f03831b31aa36478a55c81
4
- data.tar.gz: bfbdeaf7ecf11149bcb6d22832267d8959d827b4680aac05c25d762d48310293
3
+ metadata.gz: 446467563c1aa1279887817be0e1ac27b2cb735780366f6f14e1e07b593e4941
4
+ data.tar.gz: d9cc8c06d4f4d9872a3977ddb7c2823c4065b129adf1df4021f1a320d8669651
5
5
  SHA512:
6
- metadata.gz: e6d87df6d6e07ed28f8b5df8ed07d25077705764c5535176e9a142e58a59a6f023b313add55a8e25e37f0aebe3400d7d1631b326554431f9087c37806e8d93b0
7
- data.tar.gz: 86c2026e946ee56ff3089294a53952eee7c93c4a238ca9553596bd51e8fe098e36fd3c50db586e6d2d50b69515a07f71b11ddcc8374cb0a18ae118e967766404
6
+ metadata.gz: d05759ba80f8441218eb7cac5d686c025febdb8d8794380893327d710c2fb24a521f8270ca90a54b0de40073ae782f41247e923281da6cf9a3febf03ca7b6aa9
7
+ data.tar.gz: 929f994a3578971668c158bc1d341e2feddf8c9213576b3c860e809d9fadfd12fc6caffdc2a4957a01db70a9a51f8bcc7c656452bd4db03d5181b3e18d2d63df
data/README.md CHANGED
@@ -196,7 +196,6 @@ Observ.configure do |config|
196
196
 
197
197
  # UI configuration
198
198
  config.back_to_app_path = -> { Rails.application.routes.url_helpers.root_path }
199
- config.back_to_app_label = "← Back to App"
200
199
 
201
200
  # Chat UI (auto-detects if Chat model exists with acts_as_chat)
202
201
  # Manually override if needed:
@@ -483,6 +482,324 @@ trace.update(
483
482
  )
484
483
  ```
485
484
 
485
+ ### Embedding Instrumentation
486
+
487
+ Observ can track `RubyLLM.embed` calls for observability. This is useful for RAG applications, semantic search, and any workflow using embeddings.
488
+
489
+ **Basic usage:**
490
+
491
+ ```ruby
492
+ # Create a session and instrument embeddings
493
+ session = Observ::Session.create!(user_id: "rag_service")
494
+ session.instrument_embedding(context: { operation: "semantic_search" })
495
+
496
+ # All RubyLLM.embed calls are now tracked
497
+ embedding = RubyLLM.embed("Ruby is a programmer's best friend")
498
+
499
+ # Batch embeddings are also tracked
500
+ embeddings = RubyLLM.embed(["text 1", "text 2", "text 3"])
501
+
502
+ session.finalize
503
+ ```
504
+
505
+ **Using with ObservableService concern:**
506
+
507
+ ```ruby
508
+ class SemanticSearchService
509
+ include Observ::Concerns::ObservableService
510
+
511
+ def initialize(observability_session: nil)
512
+ initialize_observability(
513
+ observability_session,
514
+ service_name: "semantic_search",
515
+ metadata: { version: "1.0" }
516
+ )
517
+ end
518
+
519
+ def search(query, documents)
520
+ with_observability do |session|
521
+ # Instrument embedding calls
522
+ instrument_embedding(context: { operation: "search" })
523
+
524
+ # Generate query embedding
525
+ query_embedding = RubyLLM.embed(query)
526
+
527
+ # Generate document embeddings
528
+ doc_embeddings = RubyLLM.embed(documents)
529
+
530
+ # Perform similarity search...
531
+ find_similar(query_embedding, doc_embeddings)
532
+ end
533
+ end
534
+ end
535
+ ```
536
+
537
+ **What gets tracked:**
538
+
539
+ | Metric | Description |
540
+ |--------|-------------|
541
+ | `model` | The embedding model used (e.g., `text-embedding-3-small`) |
542
+ | `input_tokens` | Number of tokens in the input text(s) |
543
+ | `cost_usd` | Calculated cost based on model pricing |
544
+ | `dimensions` | Vector dimensions (e.g., 1536) |
545
+ | `batch_size` | Number of texts embedded in a single call |
546
+ | `vectors_count` | Number of vectors returned |
547
+ | `latency` | Time taken for the embedding call |
548
+
549
+ **Viewing embedding data:**
550
+
551
+ Embedding observations appear in the trace view at `/observ/sessions`. Each embedding call creates:
552
+ - A **trace** with input/output summary
553
+ - An **embedding observation** with detailed metrics
554
+
555
+ **Cost aggregation:**
556
+
557
+ Embedding costs are automatically aggregated into trace and session totals, alongside generation costs. This gives you a complete picture of your LLM spending.
558
+
559
+ ```ruby
560
+ session.session_metrics
561
+ # => {
562
+ # total_cost: 0.0125, # Includes both chat and embedding costs
563
+ # total_tokens: 1500, # Includes embedding input tokens
564
+ # ...
565
+ # }
566
+ ```
567
+
568
+ ### Image Generation Instrumentation
569
+
570
+ Observ can track `RubyLLM.paint` calls for image generation observability. This is useful for tracking DALL-E, GPT-Image, Imagen, and other image generation models.
571
+
572
+ **Basic usage:**
573
+
574
+ ```ruby
575
+ # Create a session and instrument image generation
576
+ session = Observ::Session.create!(user_id: "image_service")
577
+ session.instrument_image_generation(context: { operation: "product_image" })
578
+
579
+ # All RubyLLM.paint calls are now tracked
580
+ image = RubyLLM.paint("A modern logo for a tech startup")
581
+
582
+ # With options
583
+ image = RubyLLM.paint(
584
+ "A panoramic mountain landscape",
585
+ model: "gpt-image-1",
586
+ size: "1792x1024"
587
+ )
588
+
589
+ session.finalize
590
+ ```
591
+
592
+ **Using with ObservableService concern:**
593
+
594
+ ```ruby
595
+ class ImageGenerationService
596
+ include Observ::Concerns::ObservableService
597
+
598
+ def initialize(observability_session: nil)
599
+ initialize_observability(
600
+ observability_session,
601
+ service_name: "image_generation",
602
+ metadata: { version: "1.0" }
603
+ )
604
+ end
605
+
606
+ def generate_product_image(prompt)
607
+ with_observability do |session|
608
+ instrument_image_generation(context: { operation: "product_image" })
609
+ RubyLLM.paint(prompt, model: "dall-e-3", size: "1024x1024")
610
+ end
611
+ end
612
+ end
613
+ ```
614
+
615
+ **What gets tracked:**
616
+
617
+ | Metric | Description |
618
+ |--------|-------------|
619
+ | `model` | The image model used (e.g., `dall-e-3`, `gpt-image-1`) |
620
+ | `prompt` | The original prompt text |
621
+ | `revised_prompt` | The model's revised/enhanced prompt (if available) |
622
+ | `size` | Image dimensions (e.g., `1024x1024`, `1792x1024`) |
623
+ | `cost_usd` | Generation cost |
624
+ | `latency_ms` | Time to generate in milliseconds |
625
+ | `output_format` | `url` or `base64` |
626
+ | `mime_type` | Image MIME type (e.g., `image/png`) |
627
+
628
+ ### Transcription Instrumentation
629
+
630
+ Observ can track `RubyLLM.transcribe` calls for audio-to-text transcription observability. This supports Whisper, GPT-4o transcription models, and other audio transcription providers.
631
+
632
+ **Basic usage:**
633
+
634
+ ```ruby
635
+ # Create a session and instrument transcription
636
+ session = Observ::Session.create!(user_id: "transcription_service")
637
+ session.instrument_transcription(context: { operation: "meeting_notes" })
638
+
639
+ # All RubyLLM.transcribe calls are now tracked
640
+ transcript = RubyLLM.transcribe("meeting.wav")
641
+
642
+ # With options
643
+ transcript = RubyLLM.transcribe(
644
+ "interview.mp3",
645
+ model: "gpt-4o-transcribe",
646
+ language: "es"
647
+ )
648
+
649
+ # With speaker diarization
650
+ transcript = RubyLLM.transcribe(
651
+ "team-meeting.wav",
652
+ model: "gpt-4o-transcribe",
653
+ speaker_names: ["Alice", "Bob"]
654
+ )
655
+
656
+ session.finalize
657
+ ```
658
+
659
+ **Using with ObservableService concern:**
660
+
661
+ ```ruby
662
+ class MeetingNotesService
663
+ include Observ::Concerns::ObservableService
664
+
665
+ def initialize(observability_session: nil)
666
+ initialize_observability(
667
+ observability_session,
668
+ service_name: "meeting_notes",
669
+ metadata: { version: "1.0" }
670
+ )
671
+ end
672
+
673
+ def transcribe_meeting(audio_path)
674
+ with_observability do |session|
675
+ instrument_transcription(context: { operation: "meeting_notes" })
676
+ RubyLLM.transcribe(audio_path, model: "whisper-1")
677
+ end
678
+ end
679
+ end
680
+ ```
681
+
682
+ **What gets tracked:**
683
+
684
+ | Metric | Description |
685
+ |--------|-------------|
686
+ | `model` | The transcription model (e.g., `whisper-1`, `gpt-4o-transcribe`) |
687
+ | `audio_duration_s` | Length of audio in seconds |
688
+ | `language` | Detected or specified language (ISO 639-1) |
689
+ | `segments_count` | Number of transcript segments |
690
+ | `speakers_count` | Number of speakers (for diarization) |
691
+ | `has_diarization` | Whether speaker diarization was used |
692
+ | `cost_usd` | Transcription cost (based on audio duration) |
693
+ | `latency_ms` | Processing time in milliseconds |
694
+
695
+ ### Content Moderation Instrumentation
696
+
697
+ Observ can track `RubyLLM.moderate` calls for content moderation observability. This is useful for safety filtering and content policy enforcement.
698
+
699
+ **Basic usage:**
700
+
701
+ ```ruby
702
+ # Create a session and instrument moderation
703
+ session = Observ::Session.create!(user_id: "content_filter")
704
+ session.instrument_moderation(context: { operation: "user_input_check" })
705
+
706
+ # All RubyLLM.moderate calls are now tracked
707
+ result = RubyLLM.moderate(user_input)
708
+
709
+ if result.flagged?
710
+ # Handle flagged content
711
+ puts "Content flagged for: #{result.flagged_categories.join(', ')}"
712
+ end
713
+
714
+ session.finalize
715
+ ```
716
+
717
+ **Using with ObservableService concern:**
718
+
719
+ ```ruby
720
+ class ContentModerationService
721
+ include Observ::Concerns::ObservableService
722
+
723
+ def initialize(observability_session: nil)
724
+ initialize_observability(
725
+ observability_session,
726
+ service_name: "content_moderation",
727
+ metadata: { version: "1.0" }
728
+ )
729
+ end
730
+
731
+ def check_content(text)
732
+ with_observability do |session|
733
+ instrument_moderation(context: { operation: "user_content_check" })
734
+ result = RubyLLM.moderate(text)
735
+
736
+ {
737
+ safe: !result.flagged?,
738
+ flagged_categories: result.flagged_categories,
739
+ highest_risk: result.flagged_categories.first
740
+ }
741
+ end
742
+ end
743
+ end
744
+ ```
745
+
746
+ **What gets tracked:**
747
+
748
+ | Metric | Description |
749
+ |--------|-------------|
750
+ | `model` | The moderation model (e.g., `omni-moderation-latest`) |
751
+ | `flagged` | Whether content was flagged |
752
+ | `categories` | Hash of category boolean flags |
753
+ | `category_scores` | Hash of category confidence scores (0.0-1.0) |
754
+ | `flagged_categories` | List of categories that triggered flagging |
755
+ | `latency_ms` | Processing time in milliseconds |
756
+
757
+ **Moderation categories tracked:**
758
+
759
+ - `sexual` - Sexually explicit content
760
+ - `hate` - Hate speech based on identity
761
+ - `harassment` - Harassing or threatening content
762
+ - `self-harm` - Self-harm promotion
763
+ - `violence` - Violence promotion
764
+ - `violence/graphic` - Graphic violent content
765
+
766
+ **Note:** Moderation is typically free (cost_usd = 0.0), but all calls are tracked for observability and audit purposes.
767
+
768
+ ### Combined Instrumentation
769
+
770
+ You can instrument multiple RubyLLM methods in the same session:
771
+
772
+ ```ruby
773
+ session = Observ::Session.create!(user_id: "multimodal_service")
774
+
775
+ # Instrument all methods you'll use
776
+ session.instrument_embedding(context: { operation: "search" })
777
+ session.instrument_image_generation(context: { operation: "illustration" })
778
+ session.instrument_transcription(context: { operation: "audio_input" })
779
+ session.instrument_moderation(context: { operation: "safety_check" })
780
+
781
+ # Now all calls are tracked
782
+ embedding = RubyLLM.embed("search query")
783
+ image = RubyLLM.paint("generate an illustration")
784
+ transcript = RubyLLM.transcribe("audio.wav")
785
+ moderation = RubyLLM.moderate(user_input)
786
+
787
+ session.finalize
788
+ ```
789
+
790
+ **Cost aggregation across all types:**
791
+
792
+ All observation types are automatically aggregated into trace and session totals:
793
+
794
+ ```ruby
795
+ session.session_metrics
796
+ # => {
797
+ # total_cost: 0.0825, # Includes chat, embedding, image, and transcription costs
798
+ # total_tokens: 1500, # Includes generation and embedding tokens
799
+ # ...
800
+ # }
801
+ ```
802
+
486
803
  ### Prompt Management
487
804
 
488
805
  Fetch prompts in your code:
@@ -654,7 +971,7 @@ bundle exec rspec
654
971
  Observ uses:
655
972
  - **Isolated namespace**: All classes under `Observ::` module
656
973
  - **Engine pattern**: Mountable Rails engine for easy integration
657
- - **STI for observations**: `Observ::Generation` and `Observ::Span` inherit from `Observ::Observation`
974
+ - **STI for observations**: `Observ::Generation`, `Observ::Span`, `Observ::Embedding`, `Observ::ImageGeneration`, `Observ::Transcription`, and `Observ::Moderation` inherit from `Observ::Observation`
658
975
  - **AASM for state machine**: Prompt lifecycle management
659
976
  - **Kaminari for pagination**: Session and trace listings
660
977
  - **Stimulus controllers**: Interactive UI components
@@ -0,0 +1,178 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Config Editor Controller
5
+ *
6
+ * Handles bi-directional sync between structured form fields and JSON textarea.
7
+ * Provides real-time JSON validation with visual feedback.
8
+ *
9
+ * Targets:
10
+ * - model: Select dropdown for model selection
11
+ * - temperature: Number input for temperature
12
+ * - maxTokens: Number input for max_tokens
13
+ * - jsonInput: Textarea for advanced JSON editing
14
+ * - status: Element to display validation status
15
+ * - hiddenField: Hidden input that submits the final JSON
16
+ *
17
+ * Values:
18
+ * - knownKeys: Array of keys managed by structured fields (default: ["model", "temperature", "max_tokens"])
19
+ */
20
+ export default class extends Controller {
21
+ static targets = ["model", "temperature", "maxTokens", "jsonInput", "status", "hiddenField"]
22
+ static values = {
23
+ knownKeys: { type: Array, default: ["model", "temperature", "max_tokens"] }
24
+ }
25
+
26
+ connect() {
27
+ console.log('[Observ] config-editor controller connected')
28
+ this.syncFromHiddenField()
29
+ }
30
+
31
+ // Called when any structured field changes
32
+ syncToJson() {
33
+ const config = this.buildConfigFromFields()
34
+ const jsonString = Object.keys(config).length > 0
35
+ ? JSON.stringify(config, null, 2)
36
+ : ""
37
+
38
+ if (this.hasJsonInputTarget) {
39
+ this.jsonInputTarget.value = jsonString
40
+ }
41
+ this.updateHiddenField(config)
42
+ this.showValidStatus()
43
+ }
44
+
45
+ // Called when JSON textarea changes
46
+ syncFromJson() {
47
+ const jsonString = this.jsonInputTarget.value.trim()
48
+
49
+ if (jsonString === "") {
50
+ this.clearStructuredFields()
51
+ this.updateHiddenField({})
52
+ this.showValidStatus()
53
+ return
54
+ }
55
+
56
+ try {
57
+ const config = JSON.parse(jsonString)
58
+ this.populateStructuredFields(config)
59
+ this.updateHiddenField(config)
60
+ this.showValidStatus()
61
+ } catch (e) {
62
+ this.showInvalidStatus(e.message)
63
+ }
64
+ }
65
+
66
+ // Build config object from structured fields
67
+ buildConfigFromFields() {
68
+ const config = this.getExtraJsonKeys()
69
+
70
+ if (this.hasModelTarget && this.modelTarget.value) {
71
+ config.model = this.modelTarget.value
72
+ }
73
+ if (this.hasTemperatureTarget && this.temperatureTarget.value !== "") {
74
+ config.temperature = parseFloat(this.temperatureTarget.value)
75
+ }
76
+ if (this.hasMaxTokensTarget && this.maxTokensTarget.value !== "") {
77
+ config.max_tokens = parseInt(this.maxTokensTarget.value, 10)
78
+ }
79
+
80
+ return config
81
+ }
82
+
83
+ // Get any extra keys from JSON that aren't in structured fields
84
+ getExtraJsonKeys() {
85
+ if (!this.hasJsonInputTarget || this.jsonInputTarget.value.trim() === "") {
86
+ return {}
87
+ }
88
+
89
+ try {
90
+ const fullConfig = JSON.parse(this.jsonInputTarget.value)
91
+ const extra = {}
92
+
93
+ Object.keys(fullConfig).forEach(key => {
94
+ if (!this.knownKeysValue.includes(key)) {
95
+ extra[key] = fullConfig[key]
96
+ }
97
+ })
98
+
99
+ return extra
100
+ } catch {
101
+ return {}
102
+ }
103
+ }
104
+
105
+ // Populate structured fields from config object
106
+ populateStructuredFields(config) {
107
+ if (this.hasModelTarget && config.model !== undefined) {
108
+ this.modelTarget.value = config.model
109
+ }
110
+ if (this.hasTemperatureTarget && config.temperature !== undefined) {
111
+ this.temperatureTarget.value = config.temperature
112
+ }
113
+ if (this.hasMaxTokensTarget && config.max_tokens !== undefined) {
114
+ this.maxTokensTarget.value = config.max_tokens
115
+ }
116
+ }
117
+
118
+ // Clear all structured fields
119
+ clearStructuredFields() {
120
+ if (this.hasModelTarget) this.modelTarget.value = ""
121
+ if (this.hasTemperatureTarget) this.temperatureTarget.value = ""
122
+ if (this.hasMaxTokensTarget) this.maxTokensTarget.value = ""
123
+ }
124
+
125
+ // Load initial state from hidden field
126
+ syncFromHiddenField() {
127
+ if (!this.hasHiddenFieldTarget) return
128
+
129
+ const jsonString = this.hiddenFieldTarget.value.trim()
130
+ if (jsonString === "") return
131
+
132
+ try {
133
+ const config = JSON.parse(jsonString)
134
+ this.populateStructuredFields(config)
135
+
136
+ if (this.hasJsonInputTarget) {
137
+ this.jsonInputTarget.value = JSON.stringify(config, null, 2)
138
+ }
139
+ this.showValidStatus()
140
+ } catch {
141
+ // If initial value is invalid, just show it in the textarea
142
+ if (this.hasJsonInputTarget) {
143
+ this.jsonInputTarget.value = jsonString
144
+ }
145
+ this.showInvalidStatus("Initial config is invalid JSON")
146
+ }
147
+ }
148
+
149
+ // Update the hidden field that gets submitted
150
+ updateHiddenField(config) {
151
+ if (!this.hasHiddenFieldTarget) return
152
+ this.hiddenFieldTarget.value = Object.keys(config).length > 0
153
+ ? JSON.stringify(config)
154
+ : ""
155
+ }
156
+
157
+ // Show valid status
158
+ showValidStatus() {
159
+ if (!this.hasStatusTarget) return
160
+ this.statusTarget.innerHTML = '<span class="observ-config-editor__status--valid">&#10003; Valid JSON</span>'
161
+ this.statusTarget.classList.remove("observ-config-editor__status--error")
162
+ this.statusTarget.classList.add("observ-config-editor__status--success")
163
+ }
164
+
165
+ // Show invalid status with error message
166
+ showInvalidStatus(message) {
167
+ if (!this.hasStatusTarget) return
168
+ this.statusTarget.innerHTML = `<span class="observ-config-editor__status--invalid">&#10007; Invalid JSON: ${this.escapeHtml(message)}</span>`
169
+ this.statusTarget.classList.remove("observ-config-editor__status--success")
170
+ this.statusTarget.classList.add("observ-config-editor__status--error")
171
+ }
172
+
173
+ escapeHtml(text) {
174
+ const div = document.createElement('div')
175
+ div.textContent = text
176
+ return div.innerHTML
177
+ }
178
+ }
@@ -0,0 +1,29 @@
1
+ // Auto-generated index file for Observ Stimulus controllers
2
+ // Register all Observ controllers with the observ-- prefix
3
+ import { application } from "../application"
4
+
5
+ import AutoscrollController from "./autoscroll_controller.js"
6
+ import ChatFormController from "./chat_form_controller.js"
7
+ import ConfigEditorController from "./config_editor_controller.js"
8
+ import CopyController from "./copy_controller.js"
9
+ import DashboardController from "./dashboard_controller.js"
10
+ import DrawerController from "./drawer_controller.js"
11
+ import ExpandableController from "./expandable_controller.js"
12
+ import FilterController from "./filter_controller.js"
13
+ import JsonViewerController from "./json_viewer_controller.js"
14
+ import MessageFormController from "./message_form_controller.js"
15
+ import PromptVariablesController from "./prompt_variables_controller.js"
16
+ import TextSelectController from "./text_select_controller.js"
17
+
18
+ application.register("observ--autoscroll", AutoscrollController)
19
+ application.register("observ--chat-form", ChatFormController)
20
+ application.register("observ--config-editor", ConfigEditorController)
21
+ application.register("observ--copy", CopyController)
22
+ application.register("observ--dashboard", DashboardController)
23
+ application.register("observ--drawer", DrawerController)
24
+ application.register("observ--expandable", ExpandableController)
25
+ application.register("observ--filter", FilterController)
26
+ application.register("observ--json-viewer", JsonViewerController)
27
+ application.register("observ--message-form", MessageFormController)
28
+ application.register("observ--prompt-variables", PromptVariablesController)
29
+ application.register("observ--text-select", TextSelectController)
@@ -38,6 +38,12 @@ export default class extends Controller {
38
38
  }
39
39
 
40
40
  this.submitTarget.style.display = this.loadingValue ? 'none' : 'inline-flex'
41
+
42
+ // Show/hide typing indicator in messages area
43
+ const typingIndicator = document.getElementById('typing-indicator')
44
+ if (typingIndicator) {
45
+ typingIndicator.style.display = this.loadingValue ? 'flex' : 'none'
46
+ }
41
47
  }
42
48
 
43
49
  handleTurboSubmit(event) {
@@ -48,11 +54,27 @@ export default class extends Controller {
48
54
 
49
55
  handleTurboRender(event) {
50
56
  const streamElement = event.target
51
- if (streamElement.target === 'new_message' || streamElement.target === 'messages') {
57
+
58
+ // Only reset loading when the form is replaced (after user message submitted)
59
+ // The typing indicator will be hidden when assistant message arrives
60
+ if (streamElement.target === 'new_message') {
61
+ // Form was replaced, re-enable submit but keep typing indicator showing
52
62
  setTimeout(() => {
53
- this.loadingValue = false
54
63
  this.toggleSubmit()
55
64
  }, 100)
56
65
  }
66
+
67
+ // Hide typing indicator when an assistant message is appended
68
+ if (streamElement.target === 'messages') {
69
+ const action = streamElement.getAttribute('action')
70
+ // Check if this is an append (new message) containing assistant content
71
+ if (action === 'append') {
72
+ const content = streamElement.innerHTML
73
+ // Check if it's an assistant message (has the assistant class)
74
+ if (content.includes('observ-chat-message--assistant')) {
75
+ this.loadingValue = false
76
+ }
77
+ }
78
+ }
57
79
  }
58
80
  }