rubyllm-observ 0.6.6 → 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.
- checksums.yaml +4 -4
- data/README.md +319 -1
- data/app/assets/javascripts/observ/controllers/config_editor_controller.js +178 -0
- data/app/assets/javascripts/observ/controllers/index.js +29 -0
- data/app/assets/javascripts/observ/controllers/message_form_controller.js +24 -2
- data/app/assets/stylesheets/observ/_chat.scss +199 -0
- data/app/assets/stylesheets/observ/_config_editor.scss +119 -0
- data/app/assets/stylesheets/observ/application.scss +1 -0
- data/app/controllers/observ/dataset_items_controller.rb +2 -2
- data/app/controllers/observ/dataset_runs_controller.rb +1 -1
- data/app/controllers/observ/datasets_controller.rb +2 -2
- data/app/controllers/observ/messages_controller.rb +5 -1
- data/app/controllers/observ/prompts_controller.rb +11 -3
- data/app/controllers/observ/scores_controller.rb +1 -1
- data/app/controllers/observ/traces_controller.rb +1 -1
- data/app/helpers/observ/application_helper.rb +1 -0
- data/app/helpers/observ/markdown_helper.rb +29 -0
- data/app/helpers/observ/prompts_helper.rb +48 -0
- data/app/jobs/observ/moderation_guardrail_job.rb +115 -0
- data/app/models/observ/embedding.rb +45 -0
- data/app/models/observ/image_generation.rb +38 -0
- data/app/models/observ/moderation.rb +40 -0
- data/app/models/observ/null_prompt.rb +49 -2
- data/app/models/observ/observation.rb +3 -1
- data/app/models/observ/session.rb +33 -0
- data/app/models/observ/trace.rb +90 -4
- data/app/models/observ/transcription.rb +38 -0
- data/app/services/observ/chat_instrumenter.rb +96 -6
- data/app/services/observ/concerns/observable_service.rb +108 -3
- data/app/services/observ/embedding_instrumenter.rb +193 -0
- data/app/services/observ/guardrail_service.rb +9 -0
- data/app/services/observ/image_generation_instrumenter.rb +243 -0
- data/app/services/observ/moderation_guardrail_service.rb +235 -0
- data/app/services/observ/moderation_instrumenter.rb +141 -0
- data/app/services/observ/transcription_instrumenter.rb +187 -0
- data/app/views/observ/chats/show.html.erb +9 -0
- data/app/views/observ/messages/_message.html.erb +1 -1
- data/app/views/observ/messages/create.turbo_stream.erb +1 -3
- data/app/views/observ/prompts/_config_editor.html.erb +115 -0
- data/app/views/observ/prompts/_form.html.erb +2 -13
- data/app/views/observ/prompts/_new_form.html.erb +2 -12
- data/lib/generators/observ/install_chat/templates/jobs/chat_response_job.rb.tt +9 -3
- data/lib/observ/engine.rb +7 -0
- data/lib/observ/version.rb +1 -1
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 446467563c1aa1279887817be0e1ac27b2cb735780366f6f14e1e07b593e4941
|
|
4
|
+
data.tar.gz: d9cc8c06d4f4d9872a3977ddb7c2823c4065b129adf1df4021f1a320d8669651
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d05759ba80f8441218eb7cac5d686c025febdb8d8794380893327d710c2fb24a521f8270ca90a54b0de40073ae782f41247e923281da6cf9a3febf03ca7b6aa9
|
|
7
|
+
data.tar.gz: 929f994a3578971668c158bc1d341e2feddf8c9213576b3c860e809d9fadfd12fc6caffdc2a4957a01db70a9a51f8bcc7c656452bd4db03d5181b3e18d2d63df
|
data/README.md
CHANGED
|
@@ -482,6 +482,324 @@ trace.update(
|
|
|
482
482
|
)
|
|
483
483
|
```
|
|
484
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
|
+
|
|
485
803
|
### Prompt Management
|
|
486
804
|
|
|
487
805
|
Fetch prompts in your code:
|
|
@@ -653,7 +971,7 @@ bundle exec rspec
|
|
|
653
971
|
Observ uses:
|
|
654
972
|
- **Isolated namespace**: All classes under `Observ::` module
|
|
655
973
|
- **Engine pattern**: Mountable Rails engine for easy integration
|
|
656
|
-
- **STI for observations**: `Observ::Generation` and `Observ::
|
|
974
|
+
- **STI for observations**: `Observ::Generation`, `Observ::Span`, `Observ::Embedding`, `Observ::ImageGeneration`, `Observ::Transcription`, and `Observ::Moderation` inherit from `Observ::Observation`
|
|
657
975
|
- **AASM for state machine**: Prompt lifecycle management
|
|
658
976
|
- **Kaminari for pagination**: Session and trace listings
|
|
659
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">✓ 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">✗ 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
|
-
|
|
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
|
}
|