diff --git a/bots/admin.py b/bots/admin.py index b3e02bff9..6749d802f 100644 --- a/bots/admin.py +++ b/bots/admin.py @@ -16,6 +16,8 @@ FeedbackComment, CHATML_ROLE_ASSISSTANT, SavedRun, + PublishedRun, + PublishedRunVersion, Message, Platform, Feedback, @@ -94,13 +96,14 @@ class BotIntegrationAdmin(admin.ModelAdmin): "updated_at", "billing_account_uid", "saved_run", + "published_run", "analysis_run", ] list_filter = ["platform"] form = BotIntegrationAdminForm - autocomplete_fields = ["saved_run", "analysis_run"] + autocomplete_fields = ["saved_run", "published_run", "analysis_run"] readonly_fields = [ "fb_page_access_token", @@ -120,6 +123,7 @@ class BotIntegrationAdmin(admin.ModelAdmin): "fields": [ "name", "saved_run", + "published_run", "billing_account_uid", "user_language", ], @@ -206,6 +210,38 @@ def view_analysis_results(self, bi: BotIntegration): return html +@admin.register(PublishedRun) +class PublishedRunAdmin(admin.ModelAdmin): + list_display = [ + "__str__", + "published_run_id", + "view_user", + "view_saved_run", + "created_at", + "updated_at", + ] + list_filter = ["workflow"] + search_fields = ["workflow", "published_run_id"] + autocomplete_fields = ["saved_run", "created_by", "last_edited_by"] + readonly_fields = [ + "open_in_gooey", + "created_at", + "updated_at", + ] + + def view_user(self, published_run: PublishedRun): + if published_run.created_by is None: + return None + return change_obj_url(published_run.created_by) + + view_user.short_description = "View User" + + def view_saved_run(self, published_run: PublishedRun): + return change_obj_url(published_run.saved_run) + + view_saved_run.short_description = "View Saved Run" + + @admin.register(SavedRun) class SavedRunAdmin(admin.ModelAdmin): list_display = [ @@ -221,6 +257,7 @@ class SavedRunAdmin(admin.ModelAdmin): ] list_filter = ["workflow"] search_fields = ["workflow", "example_id", "run_id", "uid"] + autocomplete_fields = ["parent_version"] readonly_fields = [ "open_in_gooey", @@ -257,6 +294,12 @@ def preview_input(self, saved_run: SavedRun): return truncate_text_words(BasePage.preview_input(saved_run.state) or "", 100) +@admin.register(PublishedRunVersion) +class PublishedRunVersionAdmin(admin.ModelAdmin): + search_fields = ["id", "version_id", "published_run__published_run_id"] + autocomplete_fields = ["published_run", "saved_run", "changed_by"] + + class LastActiveDeltaFilter(admin.SimpleListFilter): title = Conversation.last_active_delta.short_description parameter_name = Conversation.last_active_delta.__name__ diff --git a/bots/migrations/0049_publishedrun_publishedrunversion.py b/bots/migrations/0049_publishedrun_publishedrunversion.py new file mode 100644 index 000000000..51c209825 --- /dev/null +++ b/bots/migrations/0049_publishedrun_publishedrunversion.py @@ -0,0 +1,265 @@ +# Generated by Django 4.2.7 on 2023-12-05 13:39 + +from django.db import migrations, models +import django.db.models.deletion + +from bots.models import PublishedRunVisibility +from daras_ai_v2.crypto import get_random_doc_id + + +def set_field_attribute(instance, field_name, **attrs): + for field in instance._meta.local_fields: + if field.name == field_name: + for attr, value in attrs.items(): + setattr(field, attr, value) + + +def create_published_run_from_example( + *, + published_run_model, + published_run_version_model, + saved_run, + user, + published_run_id, +): + published_run = published_run_model( + workflow=saved_run.workflow, + published_run_id=published_run_id, + created_by=user, + last_edited_by=user, + saved_run=saved_run, + title=saved_run.page_title, + notes=saved_run.page_notes, + visibility=PublishedRunVisibility.PUBLIC, + is_approved_example=not saved_run.hidden, + ) + set_field_attribute(published_run, "created_at", auto_now_add=False) + set_field_attribute(published_run, "updated_at", auto_now=False) + published_run.created_at = saved_run.created_at + published_run.updated_at = saved_run.updated_at + published_run.save() + set_field_attribute(published_run, "created_at", auto_now_add=True) + set_field_attribute(published_run, "updated_at", auto_now=True) + + version = published_run_version_model( + published_run=published_run, + version_id=get_random_doc_id(), + saved_run=saved_run, + changed_by=user, + title=saved_run.page_title, + notes=saved_run.page_notes, + visibility=PublishedRunVisibility.PUBLIC, + ) + set_field_attribute(published_run, "created_at", auto_now_add=False) + version.created_at = saved_run.updated_at + version.save() + set_field_attribute(published_run, "created_at", auto_now_add=True) + + return published_run + + +def forwards_func(apps, schema_editor): + # if example_id is not null, create published run with + # is_approved_example to True and visibility to Public + saved_run_model = apps.get_model("bots", "SavedRun") + published_run_model = apps.get_model("bots", "PublishedRun") + published_run_version_model = apps.get_model("bots", "PublishedRunVersion") + db_alias = schema_editor.connection.alias + + # all examples + for saved_run in saved_run_model.objects.using(db_alias).filter( + example_id__isnull=False, + ): + create_published_run_from_example( + published_run_model=published_run_model, + published_run_version_model=published_run_version_model, + saved_run=saved_run, + user=None, # TODO: use gooey-support user instead? + published_run_id=saved_run.example_id, + ) + + # recipe root examples + for saved_run in saved_run_model.objects.using(db_alias).filter( + example_id__isnull=True, + run_id__isnull=True, + uid__isnull=True, + ): + create_published_run_from_example( + published_run_model=published_run_model, + published_run_version_model=published_run_version_model, + saved_run=saved_run, + user=None, + published_run_id="", + ) + + +def backwards_func(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("app_users", "0010_alter_appuser_balance_alter_appuser_created_at_and_more"), + ("bots", "0048_alter_messageattachment_url"), + ] + + operations = [ + migrations.CreateModel( + name="PublishedRun", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("published_run_id", models.CharField(blank=True, max_length=128)), + ( + "workflow", + models.IntegerField( + choices=[ + (1, "Doc Search"), + (2, "Doc Summary"), + (3, "Google GPT"), + (4, "Copilot"), + (5, "Lipysnc + TTS"), + (6, "Text to Speech"), + (7, "Speech Recognition"), + (8, "Lipsync"), + (9, "Deforum Animation"), + (10, "Compare Text2Img"), + (11, "Text2Audio"), + (12, "Img2Img"), + (13, "Face Inpainting"), + (14, "Google Image Gen"), + (15, "Compare AI Upscalers"), + (16, "SEO Summary"), + (17, "Email Face Inpainting"), + (18, "Social Lookup Email"), + (19, "Object Inpainting"), + (20, "Image Segmentation"), + (21, "Compare LLM"), + (22, "Chyron Plant"), + (23, "Letter Writer"), + (24, "Smart GPT"), + (25, "AI QR Code"), + (26, "Doc Extract"), + (27, "Related QnA Maker"), + (28, "Related QnA Maker Doc"), + (29, "Embeddings"), + (30, "Bulk Runner"), + ] + ), + ), + ("title", models.TextField(blank=True, default="")), + ("notes", models.TextField(blank=True, default="")), + ( + "visibility", + models.IntegerField( + choices=[(1, "Unlisted"), (2, "Public")], default=1 + ), + ), + ("is_approved_example", models.BooleanField(default=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="published_runs", + to="app_users.appuser", + ), + ), + ( + "last_edited_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="app_users.appuser", + ), + ), + ( + "saved_run", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="published_runs", + to="bots.savedrun", + ), + ), + ], + options={ + "ordering": ["-updated_at"], + "unique_together": {("workflow", "published_run_id")}, + }, + ), + migrations.CreateModel( + name="PublishedRunVersion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("version_id", models.CharField(max_length=128, unique=True)), + ("title", models.TextField(blank=True, default="")), + ("notes", models.TextField(blank=True, default="")), + ( + "visibility", + models.IntegerField( + choices=[(1, "Unlisted"), (2, "Public")], default=1 + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "changed_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="app_users.appuser", + ), + ), + ( + "published_run", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="versions", + to="bots.publishedrun", + ), + ), + ( + "saved_run", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="published_run_versions", + to="bots.savedrun", + ), + ), + ], + options={ + "ordering": ["-created_at"], + "get_latest_by": "created_at", + "indexes": [ + models.Index( + fields=["published_run", "-created_at"], + name="bots_publis_publish_9cd246_idx", + ), + models.Index( + fields=["version_id"], name="bots_publis_version_c121d4_idx" + ), + ], + }, + ), + migrations.RunPython( + forwards_func, + backwards_func, + ), + ] diff --git a/bots/migrations/0050_botintegration_published_run.py b/bots/migrations/0050_botintegration_published_run.py new file mode 100644 index 000000000..2b8e2035d --- /dev/null +++ b/bots/migrations/0050_botintegration_published_run.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.7 on 2023-12-05 13:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0049_publishedrun_publishedrunversion"), + ] + + operations = [ + migrations.AddField( + model_name="botintegration", + name="published_run", + field=models.ForeignKey( + blank=True, + default=None, + help_text="The saved run that the bot is based on", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="botintegrations", + to="bots.publishedrun", + ), + ), + ] diff --git a/bots/migrations/0051_savedrun_parent_version.py b/bots/migrations/0051_savedrun_parent_version.py new file mode 100644 index 000000000..3c2c16b18 --- /dev/null +++ b/bots/migrations/0051_savedrun_parent_version.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.7 on 2023-12-08 10:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0050_botintegration_published_run"), + ] + + operations = [ + migrations.AddField( + model_name="savedrun", + name="parent_version", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children_runs", + to="bots.publishedrunversion", + ), + ), + ] diff --git a/bots/migrations/0052_alter_publishedrun_options_and_more.py b/bots/migrations/0052_alter_publishedrun_options_and_more.py new file mode 100644 index 000000000..4d6dfe2f8 --- /dev/null +++ b/bots/migrations/0052_alter_publishedrun_options_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.7 on 2023-12-11 05:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0051_savedrun_parent_version"), + ] + + operations = [ + migrations.AlterModelOptions( + name="publishedrun", + options={"get_latest_by": "updated_at", "ordering": ["-updated_at"]}, + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow"], name="bots_publis_workflo_a0953a_idx" + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow", "created_by"], name="bots_publis_workflo_c75a55_idx" + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow", "published_run_id"], + name="bots_publis_workflo_87bece_idx", + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=["workflow", "visibility", "is_approved_example"], + name="bots_publis_workflo_36a83a_idx", + ), + ), + ] diff --git a/bots/migrations/0053_alter_publishedrun_workflow_alter_savedrun_workflow_and_more.py b/bots/migrations/0053_alter_publishedrun_workflow_alter_savedrun_workflow_and_more.py new file mode 100644 index 000000000..4398af91d --- /dev/null +++ b/bots/migrations/0053_alter_publishedrun_workflow_alter_savedrun_workflow_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.7 on 2023-12-21 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bots", "0052_alter_publishedrun_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="publishedrun", + name="workflow", + field=models.IntegerField( + choices=[ + (1, "Doc Search"), + (2, "Doc Summary"), + (3, "Google GPT"), + (4, "Copilot"), + (5, "Lipysnc + TTS"), + (6, "Text to Speech"), + (7, "Speech Recognition"), + (8, "Lipsync"), + (9, "Deforum Animation"), + (10, "Compare Text2Img"), + (11, "Text2Audio"), + (12, "Img2Img"), + (13, "Face Inpainting"), + (14, "Google Image Gen"), + (15, "Compare AI Upscalers"), + (16, "SEO Summary"), + (17, "Email Face Inpainting"), + (18, "Social Lookup Email"), + (19, "Object Inpainting"), + (20, "Image Segmentation"), + (21, "Compare LLM"), + (22, "Chyron Plant"), + (23, "Letter Writer"), + (24, "Smart GPT"), + (25, "AI QR Code"), + (26, "Doc Extract"), + (27, "Related QnA Maker"), + (28, "Related QnA Maker Doc"), + (29, "Embeddings"), + (30, "Bulk Runner"), + (31, "Bulk Evaluator"), + ] + ), + ), + migrations.AlterField( + model_name="savedrun", + name="workflow", + field=models.IntegerField( + choices=[ + (1, "Doc Search"), + (2, "Doc Summary"), + (3, "Google GPT"), + (4, "Copilot"), + (5, "Lipysnc + TTS"), + (6, "Text to Speech"), + (7, "Speech Recognition"), + (8, "Lipsync"), + (9, "Deforum Animation"), + (10, "Compare Text2Img"), + (11, "Text2Audio"), + (12, "Img2Img"), + (13, "Face Inpainting"), + (14, "Google Image Gen"), + (15, "Compare AI Upscalers"), + (16, "SEO Summary"), + (17, "Email Face Inpainting"), + (18, "Social Lookup Email"), + (19, "Object Inpainting"), + (20, "Image Segmentation"), + (21, "Compare LLM"), + (22, "Chyron Plant"), + (23, "Letter Writer"), + (24, "Smart GPT"), + (25, "AI QR Code"), + (26, "Doc Extract"), + (27, "Related QnA Maker"), + (28, "Related QnA Maker Doc"), + (29, "Embeddings"), + (30, "Bulk Runner"), + (31, "Bulk Evaluator"), + ], + default=4, + ), + ), + migrations.AddIndex( + model_name="publishedrun", + index=models.Index( + fields=[ + "workflow", + "visibility", + "is_approved_example", + "published_run_id", + ], + name="bots_publis_workflo_d3ad4e_idx", + ), + ), + ] diff --git a/bots/models.py b/bots/models.py index 2804d2fce..1968569c1 100644 --- a/bots/models.py +++ b/bots/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import typing from multiprocessing.pool import ThreadPool @@ -16,6 +18,7 @@ from bots.admin_links import open_in_new_tab from bots.custom_fields import PostgresJSONEncoder, CustomURLField from daras_ai_v2.language_model import format_chat_entry +from daras_ai_v2.crypto import get_random_doc_id if typing.TYPE_CHECKING: from daras_ai_v2.base import BasePage @@ -28,6 +31,20 @@ EPOCH = datetime.datetime.utcfromtimestamp(0) +class PublishedRunVisibility(models.IntegerChoices): + UNLISTED = 1 + PUBLIC = 2 + + def help_text(self): + match self: + case PublishedRunVisibility.UNLISTED: + return "Only me + people with a link" + case PublishedRunVisibility.PUBLIC: + return "Public" + case _: + return self.label + + class Platform(models.IntegerChoices): FACEBOOK = 1 INSTAGRAM = (2, "Instagram & FB") @@ -124,6 +141,13 @@ class SavedRun(models.Model): blank=True, related_name="children", ) + parent_version = models.ForeignKey( + "bots.PublishedRunVersion", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="children_runs", + ) workflow = models.IntegerField( choices=Workflow.choices, default=Workflow.VIDEO_BOTS @@ -202,10 +226,6 @@ def to_dict(self) -> dict: ret[StateKeys.run_time] = self.run_time.total_seconds() if self.run_status: ret[StateKeys.run_status] = self.run_status - if self.page_title: - ret[StateKeys.page_title] = self.page_title - if self.page_notes: - ret[StateKeys.page_notes] = self.page_notes if self.hidden: ret[StateKeys.hidden] = self.hidden if self.is_flagged: @@ -236,9 +256,6 @@ def copy_from_firebase_state(self, state: dict) -> "SavedRun": seconds=state.pop(StateKeys.run_time, None) or 0 ) self.run_status = state.pop(StateKeys.run_status, None) or "" - self.page_title = state.pop(StateKeys.page_title, None) or "" - self.page_notes = state.pop(StateKeys.page_notes, None) or "" - # self.hidden = state.pop(StateKeys.hidden, False) self.is_flagged = state.pop("is_flagged", False) self.state = state @@ -267,6 +284,12 @@ def submit_api_call( ) return result, page.run_doc_sr(run_id, uid) + def get_creator(self) -> AppUser | None: + if self.uid: + return AppUser.objects.filter(uid=self.uid).first() + else: + return None + @admin.display(description="Open in Gooey") def open_in_gooey(self): return open_in_new_tab(self.get_app_url(), label=self.get_app_url()) @@ -284,7 +307,7 @@ class BotIntegrationQuerySet(models.QuerySet): @transaction.atomic() def reset_fb_pages_for_user( self, uid: str, fb_pages: list[dict] - ) -> list["BotIntegration"]: + ) -> list[BotIntegration]: saved = [] for fb_page in fb_pages: fb_page_id = fb_page["id"] @@ -339,6 +362,15 @@ class BotIntegration(models.Model): blank=True, help_text="The saved run that the bot is based on", ) + published_run = models.ForeignKey( + "bots.PublishedRun", + on_delete=models.SET_NULL, + related_name="botintegrations", + null=True, + default=None, + blank=True, + help_text="The saved run that the bot is based on", + ) billing_account_uid = models.TextField( help_text="The gooey account uid where the credits will be deducted from", db_index=True, @@ -479,6 +511,14 @@ def __str__(self): else: return self.name or platform_name + def get_active_saved_run(self) -> SavedRun | None: + if self.published_run: + return self.published_run.saved_run + elif self.saved_run: + return self.saved_run + else: + return None + def get_display_name(self): return ( (self.wa_phone_number and self.wa_phone_number.as_international) @@ -940,3 +980,204 @@ class FeedbackComment(models.Model): author = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) comment = models.TextField() created_at = models.DateTimeField(auto_now_add=True) + + +class PublishedRun(models.Model): + # published_run_id was earlier SavedRun.example_id + published_run_id = models.CharField( + max_length=128, + blank=True, + ) + + saved_run = models.ForeignKey( + "bots.SavedRun", + on_delete=models.PROTECT, + related_name="published_runs", + null=True, + ) + workflow = models.IntegerField( + choices=Workflow.choices, + ) + title = models.TextField(blank=True, default="") + notes = models.TextField(blank=True, default="") + visibility = models.IntegerField( + choices=PublishedRunVisibility.choices, + default=PublishedRunVisibility.UNLISTED, + ) + is_approved_example = models.BooleanField(default=False) + + created_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, # TODO: set to sentinel instead (e.g. github's ghost user) + null=True, + related_name="published_runs", + ) + last_edited_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, # TODO: set to sentinel instead (e.g. github's ghost user) + null=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + get_latest_by = "updated_at" + + ordering = ["-updated_at"] + unique_together = [ + ["workflow", "published_run_id"], + ] + + indexes = [ + models.Index(fields=["workflow"]), + models.Index(fields=["workflow", "created_by"]), + models.Index(fields=["workflow", "published_run_id"]), + models.Index(fields=["workflow", "visibility", "is_approved_example"]), + models.Index( + fields=[ + "workflow", + "visibility", + "is_approved_example", + "published_run_id", + ] + ), + ] + + def __str__(self): + return self.get_app_url() + + @admin.display(description="Open in Gooey") + def open_in_gooey(self): + return open_in_new_tab(self.get_app_url(), label=self.get_app_url()) + + @classmethod + def create_published_run( + cls, + *, + workflow: Workflow, + published_run_id: str, + saved_run: SavedRun, + user: AppUser, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ): + with transaction.atomic(): + published_run = PublishedRun( + workflow=workflow, + published_run_id=published_run_id, + created_by=user, + last_edited_by=user, + title=title, + ) + published_run.save() + published_run.add_version( + user=user, + saved_run=saved_run, + title=title, + visibility=visibility, + notes=notes, + ) + return published_run + + def duplicate( + self, + *, + user: AppUser, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ) -> PublishedRun: + return PublishedRun.create_published_run( + workflow=Workflow(self.workflow), + published_run_id=get_random_doc_id(), + saved_run=self.saved_run, + user=user, + title=title, + notes=notes, + visibility=visibility, + ) + + def get_app_url(self): + return Workflow(self.workflow).get_app_url( + example_id=self.published_run_id, run_id="", uid="" + ) + + def add_version( + self, + *, + user: AppUser, + saved_run: SavedRun, + visibility: PublishedRunVisibility, + title: str, + notes: str, + ): + assert saved_run.workflow == self.workflow + + with transaction.atomic(): + version = PublishedRunVersion( + published_run=self, + version_id=get_random_doc_id(), + saved_run=saved_run, + changed_by=user, + title=title, + notes=notes, + visibility=visibility, + ) + version.save() + self.update_fields_to_latest_version() + + def is_editor(self, user: AppUser): + return self.created_by == user + + def is_root_example(self): + return not self.published_run_id + + def update_fields_to_latest_version(self): + latest_version = self.versions.latest() + self.saved_run = latest_version.saved_run + self.last_edited_by = latest_version.changed_by + self.title = latest_version.title + self.notes = latest_version.notes + self.visibility = latest_version.visibility + + self.save() + + +class PublishedRunVersion(models.Model): + version_id = models.CharField(max_length=128, unique=True) + + published_run = models.ForeignKey( + PublishedRun, + on_delete=models.CASCADE, + related_name="versions", + ) + saved_run = models.ForeignKey( + SavedRun, + on_delete=models.PROTECT, + related_name="published_run_versions", + ) + changed_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, # TODO: set to sentinel instead (e.g. github's ghost user) + null=True, + ) + title = models.TextField(blank=True, default="") + notes = models.TextField(blank=True, default="") + visibility = models.IntegerField( + choices=PublishedRunVisibility.choices, + default=PublishedRunVisibility.UNLISTED, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + get_latest_by = "created_at" + indexes = [ + models.Index(fields=["published_run", "-created_at"]), + models.Index(fields=["version_id"]), + ] + + def __str__(self): + return f"{self.published_run} - {self.version_id}" diff --git a/daras_ai_v2/base.py b/daras_ai_v2/base.py index 644e0f04c..3368a36dd 100644 --- a/daras_ai_v2/base.py +++ b/daras_ai_v2/base.py @@ -6,6 +6,8 @@ import urllib.parse import uuid from copy import deepcopy +from enum import Enum +from itertools import pairwise from random import Random from time import sleep from types import SimpleNamespace @@ -14,7 +16,6 @@ import requests import sentry_sdk from django.utils import timezone -from enum import Enum from fastapi import HTTPException from firebase_admin import auth from furl import furl @@ -26,7 +27,13 @@ import gooey_ui as st from app_users.models import AppUser, AppUserTransaction -from bots.models import SavedRun, Workflow +from bots.models import ( + SavedRun, + PublishedRun, + PublishedRunVersion, + PublishedRunVisibility, + Workflow, +) from daras_ai.image_input import truncate_text_words from daras_ai_v2 import settings from daras_ai_v2.api_examples_widget import api_example_generator @@ -54,9 +61,14 @@ ) from daras_ai_v2.send_email import send_reported_run_email from daras_ai_v2.tabs_widget import MenuTabs -from daras_ai_v2.user_date_widgets import render_js_dynamic_dates, js_dynamic_date +from daras_ai_v2.user_date_widgets import ( + render_js_dynamic_dates, + re_render_js_dynamic_dates, + js_dynamic_date, +) from gooey_ui import realtime_clear_subs from gooey_ui.pubsub import realtime_pull +from gooey_ui.components.modal import Modal DEFAULT_META_IMG = ( # Small @@ -80,9 +92,6 @@ class RecipeRunState(Enum): class StateKeys: - page_title = "__title" - page_notes = "__notes" - created_at = "created_at" updated_at = "updated_at" @@ -190,13 +199,76 @@ def render(self): self.render_report_form() return - st.session_state.setdefault(StateKeys.page_title, self.title) - st.session_state.setdefault( - StateKeys.page_notes, self.preview_description(st.session_state) + example_id, run_id, uid = extract_query_params(gooey_get_query_params()) + current_run = self.get_sr_from_query_params(example_id, run_id, uid) + published_run = self.get_current_published_run() + is_root_example = published_run and published_run.is_root_example() + title, breadcrumbs = self._get_title_and_breadcrumbs( + current_run=current_run, + published_run=published_run, ) + with st.div(className="d-flex justify-content-between mt-4"): + with st.div(className="d-lg-flex d-block align-items-center"): + if not breadcrumbs and not self.run_user: + self._render_title(title) + + if breadcrumbs: + with st.tag("div", className="me-3 mb-1 mb-lg-0 py-2 py-lg-0"): + self._render_breadcrumbs(breadcrumbs) + + author = self.run_user or current_run.get_creator() + if not is_root_example: + self.render_author( + author, + show_as_link=self.is_current_user_admin(), + ) + + with st.div(className="d-flex align-items-center"): + is_current_user_creator = ( + self.request + and self.request.user + and current_run.get_creator() == self.request.user + ) + has_unpublished_changes = ( + published_run + and published_run.saved_run != current_run + and self.request + and self.request.user + and published_run.is_editor(self.request.user) + ) + + if is_current_user_creator and has_unpublished_changes: + self._render_unpublished_changes_indicator() + + with st.div(className="d-flex align-items-start right-action-icons"): + st.html( + """ + + """ + ) + + if is_current_user_creator: + self._render_published_run_buttons( + current_run=current_run, + published_run=published_run, + ) - self._render_page_title_with_breadcrumbs(example_id, run_id, uid) - st.write(st.session_state.get(StateKeys.page_notes)) + self._render_social_buttons( + show_button_text=not is_current_user_creator + ) + + with st.div(): + if breadcrumbs or self.run_user: + # only render title here if the above row was not empty + self._render_title(title) + if published_run and published_run.notes: + st.write(published_run.notes) + elif is_root_example: + st.write(self.preview_description(current_run.to_dict())) try: selected_tab = MenuTabs.paths_reverse[self.tab] @@ -213,57 +285,419 @@ def render(self): with st.nav_tab_content(): self.render_selected_tab(selected_tab) - def _render_page_title_with_breadcrumbs( - self, example_id: str, run_id: str, uid: str + def _render_title(self, title: str): + st.write(f"# {title}") + + def _render_unpublished_changes_indicator(self): + with st.div( + className="d-none d-lg-flex h-100 align-items-center text-muted ms-2" + ): + with st.tag("span", className="d-inline-block"): + st.html("Unpublished changes") + + def _render_social_buttons(self, show_button_text: bool = False): + button_text = ( + ' Copy Link' + if show_button_text + else "" + ) + + copy_to_clipboard_button( + f'{button_text}', + value=self._get_current_app_url(), + type="secondary", + className="mb-0 ms-lg-2", + ) + + def _render_published_run_buttons( + self, + *, + current_run: SavedRun, + published_run: PublishedRun | None, ): - if example_id or run_id: - # the title on the saved root / the hardcoded title - recipe_title = ( - self.recipe_doc_sr().to_dict().get(StateKeys.page_title) or self.title + is_update_mode = bool( + published_run + and ( + published_run.is_editor(self.request.user) + or self.is_current_user_admin() ) + ) - # the user saved title for the current run (if its not the same as the recipe title) - current_title = st.session_state.get(StateKeys.page_title) - if current_title == recipe_title: - current_title = "" + with st.div(): + with st.div(className="d-flex justify-content-end"): + st.html( + """ + + """ + ) - # prefer the prompt as h1 title for runs, but not for examples - prompt_title = truncate_text_words( - self.preview_input(st.session_state) or "", maxlen=60 - ).replace("\n", " ") - if run_id: - h1_title = prompt_title or current_title or recipe_title - else: - h1_title = current_title or prompt_title or recipe_title - - # render recipe title if it doesn't clash with the h1 title - render_item1 = recipe_title and recipe_title != h1_title - # render current title if it doesn't clash with the h1 title - render_item2 = current_title and current_title != h1_title - if render_item1 or render_item2: # avoids empty space - with st.breadcrumbs(className="mt-4"): - if render_item1: - st.breadcrumb_item( - recipe_title, - link_to=self.app_url(), - className="text-muted", + run_actions_button = ( + st.button( + '', + className="mb-0 ms-lg-2", + type="tertiary", + ) + if is_update_mode + else None + ) + run_actions_modal = Modal("Options", key="published-run-options-modal") + if run_actions_button: + run_actions_modal.open() + + save_icon = '' + save_text = "Update" if is_update_mode else "Save" + save_button = st.button( + f'{save_icon} {save_text}', + className="mb-0 ms-lg-2 px-lg-4", + type="primary", + ) + publish_modal = Modal("", key="publish-modal") + if save_button: + if self.request.user.is_anonymous: + redirect_url = furl( + "/login", + query_params={ + "next": furl(self.request.url).set(origin=None) + }, ) - if render_item2: - current_sr = self.get_sr_from_query_params( - example_id, run_id, uid + # TODO: investigate why RedirectException does not work here + force_redirect(redirect_url) + return + else: + publish_modal.open() + + if publish_modal.is_open(): + with publish_modal.container( + style={"min-width": "min(500px, 100vw)"} + ): + self._render_publish_modal( + current_run=current_run, + published_run=published_run, + is_update_mode=is_update_mode, ) - st.breadcrumb_item( - current_title, - link_to=current_sr.parent.get_app_url() - if current_sr.parent_id - else None, + + if run_actions_modal.is_open(): + with run_actions_modal.container( + style={"min-width": "min(300px, 100vw)"} + ): + self._render_run_actions_modal( + current_run=current_run, + published_run=published_run, + modal=run_actions_modal, ) - st.write(f"# {h1_title}") + + def _render_publish_modal( + self, + *, + current_run: SavedRun, + published_run: PublishedRun | None, + is_update_mode: bool = False, + ): + if is_update_mode: + assert published_run is not None, "published_run must be set in update mode" + + with st.div(className="visibility-radio"): + st.write("### Publish to") + convert_state_type(st.session_state, "published_run_visibility", int) + if is_update_mode: + st.session_state.setdefault( + "published_run_visibility", published_run.visibility + ) + published_run_visibility = st.radio( + "", + key="published_run_visibility", + options=PublishedRunVisibility.values, + format_func=lambda x: PublishedRunVisibility(x).help_text(), + ) + st.radio( + "", + options=[ + 'Anyone at my org (coming soon)', + ], + disabled=True, + checked_by_default=False, + ) + + with st.div(className="mt-4"): + recipe_title = self.get_root_published_run().title or self.title + default_title = ( + published_run.title + if is_update_mode + else f"{self.request.user.display_name}'s {recipe_title}" + ) + published_run_title = st.text_input( + "Title", + key="published_run_title", + value=default_title, + ) + st.session_state.setdefault( + "published_run_notes", + published_run and published_run.notes or "", + ) + published_run_notes = st.text_area( + "Notes", + key="published_run_notes", + ) + + with st.div(className="mt-4 d-flex justify-content-center"): + save_icon = '' + publish_button = st.button( + f"{save_icon} Save", className="px-4", type="primary" + ) + + if publish_button: + recipe_title = self.get_root_published_run().title or self.title + is_root_published_run = is_update_mode and published_run.is_root_example() + if ( + not is_root_published_run + and published_run_title.strip() == recipe_title.strip() + ): + st.error("Title can't be the same as the recipe title") + return + if not is_update_mode: + published_run = self.create_published_run( + published_run_id=get_random_doc_id(), + saved_run=current_run, + user=self.request.user, + title=published_run_title.strip(), + notes=published_run_notes.strip(), + visibility=PublishedRunVisibility(published_run_visibility), + ) + else: + updates = dict( + saved_run=current_run, + title=published_run_title.strip(), + notes=published_run_notes.strip(), + visibility=PublishedRunVisibility(published_run_visibility), + ) + if self._has_published_run_changed( + published_run=published_run, **updates + ): + published_run.add_version( + user=self.request.user, + **updates, + ) + else: + st.error("No changes to publish") + return + + force_redirect(published_run.get_app_url()) + + def _has_published_run_changed( + self, + *, + published_run: PublishedRun, + saved_run: SavedRun, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ): + return ( + published_run.title != title + or published_run.notes != notes + or published_run.visibility != visibility + or published_run.saved_run != saved_run + ) + + def _render_run_actions_modal( + self, + *, + current_run: SavedRun, + published_run: PublishedRun, + modal: Modal, + ): + assert published_run is not None + + is_latest_version = published_run.saved_run == current_run + + with st.div(className="mt-4"): + duplicate_button = None + save_as_new_button = None + duplicate_icon = save_as_new_icon = '' + if is_latest_version: + duplicate_button = st.button( + f"{duplicate_icon} Duplicate", type="secondary", className="w-100" + ) + else: + save_as_new_button = st.button( + f"{save_as_new_icon} Save as New", + type="secondary", + className="w-100", + ) + delete_icon = '' + delete_button = st.button( + f"{delete_icon} Delete", type="secondary", className="w-100 text-danger" + ) + + if duplicate_button: + duplicate_pr = self.duplicate_published_run( + published_run, + title=f"{published_run.title} (Copy)", + notes=published_run.notes, + visibility=PublishedRunVisibility(PublishedRunVisibility.UNLISTED), + ) + raise QueryParamsRedirectException( + query_params=dict(example_id=duplicate_pr.published_run_id), + ) + + if save_as_new_button: + new_pr = self.create_published_run( + published_run_id=get_random_doc_id(), + saved_run=current_run, + user=self.request.user, + title=f"{published_run.title} (Copy)", + notes=published_run.notes, + visibility=PublishedRunVisibility(PublishedRunVisibility.UNLISTED), + ) + raise QueryParamsRedirectException( + query_params=dict(example_id=new_pr.published_run_id) + ) + + confirm_delete_modal = Modal("Confirm Delete", key="confirm-delete-modal") + if delete_button: + if not published_run.published_run_id: + st.error("Cannot delete root example") + return + confirm_delete_modal.open() + + with st.div(className="mt-4"): + st.write("#### Version History", className="mb-4") + self._render_version_history() + + if confirm_delete_modal.is_open(): + modal.empty() + with confirm_delete_modal.container(): + self._render_confirm_delete_modal( + published_run=published_run, + modal=confirm_delete_modal, + ) + + def _render_confirm_delete_modal( + self, + *, + published_run: PublishedRun, + modal: Modal, + ): + st.write( + "Are you sure you want to delete this published run? " + f"_({published_run.title})_" + ) + st.caption("This will also delete all the associated versions.") + with st.div(className="d-flex"): + confirm_button = st.button( + 'Confirm', + type="secondary", + className="w-100", + ) + cancel_button = st.button( + "Cancel", + type="secondary", + className="w-100", + ) + + if confirm_button: + published_run.delete() + raise QueryParamsRedirectException(query_params={}) + + if cancel_button: + modal.close() + + def _get_title_and_breadcrumbs( + self, + current_run: SavedRun, + published_run: PublishedRun | None, + ) -> tuple[str, list[tuple[str, str | None]]]: + if ( + published_run + and not published_run.published_run_id + and current_run == published_run.saved_run + ): + # when published_run.published_run_id is blank, the run is the root example + return self.get_recipe_title(), [] else: - st.write(f"# {self.get_recipe_title(st.session_state)}") + # the title on the saved root / the hardcoded title + recipe_title = self.get_root_published_run().title or self.title + prompt_title = truncate_text_words( + self.preview_input(current_run.to_dict()) or "", + maxlen=60, + ).replace("\n", " ") - def get_recipe_title(self, state: dict) -> str: - return state.get(StateKeys.page_title) or self.title or self.workflow.label + recipe_breadcrumb = (recipe_title, self.app_url()) + if published_run and current_run == published_run.saved_run: + # recipe root + return published_run.title or prompt_title or recipe_title, [ + recipe_breadcrumb + ] + else: + if not published_run or not published_run.published_run_id: + # run created directly from recipe root + h1_title = prompt_title or f"Run: {recipe_title}" + return h1_title, [recipe_breadcrumb] + else: + h1_title = ( + prompt_title or f"Run: {published_run.title or recipe_title}" + ) + return h1_title, [ + recipe_breadcrumb, + ( + published_run.title + or f"Fork {published_run.published_run_id}", + published_run.get_app_url(), + ), + ] + + def _render_breadcrumbs(self, items: list[tuple[str, str | None]]): + st.html( + """ + + """ + ) + + render_item1 = items and items[0] + render_item2 = items[1:] and items[1] + if render_item1 or render_item2: # avoids empty space + with st.breadcrumbs(): + if render_item1: + text, link = render_item1 + st.breadcrumb_item( + text, + link_to=link, + className="text-muted", + ) + if render_item2: + text, link = render_item2 + st.breadcrumb_item( + text, + link_to=link, + ) + + def get_recipe_title(self) -> str: + return ( + self.get_or_create_root_published_run().title + or self.title + or self.workflow.label + ) def get_explore_image(self, state: dict) -> str: return self.explore_image or "" @@ -281,6 +715,8 @@ def get_tabs(self): tabs = [MenuTabs.run, MenuTabs.examples, MenuTabs.run_as_api] if self.request.user: tabs.extend([MenuTabs.history]) + if self.request.user and not self.request.user.is_anonymous: + tabs.extend([MenuTabs.saved]) return tabs def render_selected_tab(self, selected_tab: str): @@ -301,6 +737,7 @@ def render_selected_tab(self, selected_tab: str): self._render_save_options() self.render_related_workflows() + render_js_dynamic_dates() case MenuTabs.examples: self._examples_tab() @@ -313,6 +750,76 @@ def render_selected_tab(self, selected_tab: str): case MenuTabs.run_as_api: self.run_as_api_tab() + case MenuTabs.saved: + self._saved_tab() + render_js_dynamic_dates() + + def _render_version_history(self): + published_run = self.get_current_published_run() + + if published_run: + versions = published_run.versions.all() + first_version = versions[0] + for version, older_version in pairwise(versions): + first_version = older_version + self._render_version_row(version, older_version) + self._render_version_row(first_version, None) + re_render_js_dynamic_dates() + + def _render_version_row( + self, + version: PublishedRunVersion, + older_version: PublishedRunVersion | None, + ): + st.html( + """ + + """ + ) + url = self.app_url( + example_id=version.published_run.published_run_id, + run_id=version.saved_run.run_id, + uid=version.saved_run.uid, + ) + with st.link(to=url, className="text-decoration-none"): + with st.div( + className="d-flex mb-4 disable-p-margin", + style={"min-width": "min(100vw, 500px)"}, + ): + col1 = st.div(className="me-4") + col2 = st.div() + with col1: + with st.div(className="fs-5 mt-1"): + st.html('') + with col2: + is_first_version = not older_version + with st.div(className="fs-5 d-flex align-items-center"): + js_dynamic_date( + version.created_at, + container=self._render_version_history_date, + date_options={"month": "short", "day": "numeric"}, + ) + if is_first_version: + with st.tag("span", className="badge bg-secondary px-3 ms-2"): + st.write("FIRST VERSION") + with st.div(className="text-muted"): + if older_version and older_version.title != version.title: + st.write(f"Renamed: {version.title}") + elif not older_version: + st.write(version.title) + with st.div(className="mt-1", style={"font-size": "0.85rem"}): + self.render_author( + version.changed_by, image_size="18px", responsive=False + ) + + def _render_version_history_date(self, text, **props): + with st.tag("span", **props): + st.html(text) + def render_related_workflows(self): page_clses = self.related_workflows() if not page_clses: @@ -321,9 +828,10 @@ def render_related_workflows(self): with st.link(to="/explore/"): st.html("

Related Workflows

") - def _render(page_cls): + def _render(page_cls: typing.Type[BasePage]): page = page_cls() - state = page_cls().recipe_doc_sr().to_dict() + root_run = page.get_root_published_run() + state = root_run.saved_run.to_dict() preview_image = meta_preview_url( page.get_explore_image(state), page.fallback_preivew_image() ) @@ -335,7 +843,7 @@ def _render(page_cls):
""" ) - st.markdown(f"###### {page.title}") + st.markdown(f"###### {root_run.title or page.title}") st.caption(page.preview_description(state)) grid_layout(4, page_clses, _render) @@ -443,6 +951,19 @@ def get_sr_from_query_params_dict(self, query_params) -> SavedRun: example_id, run_id, uid = extract_query_params(query_params) return self.get_sr_from_query_params(example_id, run_id, uid) + def get_current_published_run(self) -> PublishedRun | None: + example_id, run_id, uid = extract_query_params(gooey_get_query_params()) + if run_id: + current_run = self.get_sr_from_query_params(example_id, run_id, uid) + if current_run.parent_version: + return current_run.parent_version.published_run + else: + return None + elif example_id: + return self.get_published_run_from_query_params(example_id, "", "") + else: + return self.get_root_published_run() + @classmethod def get_sr_from_query_params( cls, example_id: str, run_id: str, uid: str @@ -451,45 +972,128 @@ def get_sr_from_query_params( if run_id and uid: sr = cls.run_doc_sr(run_id, uid) elif example_id: - sr = cls.example_doc_sr(example_id) + pr = cls.get_published_run(published_run_id=example_id) + assert ( + pr.saved_run is not None + ), "invalid published run: without a saved run" + sr = pr.saved_run else: sr = cls.recipe_doc_sr() return sr - except SavedRun.DoesNotExist: + except (SavedRun.DoesNotExist, PublishedRun.DoesNotExist): raise HTTPException(status_code=404) @classmethod def get_total_runs(cls) -> int: + # TODO: fix to also handle published run case return SavedRun.objects.filter(workflow=cls.workflow).count() @classmethod - def recipe_doc_sr(cls) -> SavedRun: - return SavedRun.objects.get_or_create( - workflow=cls.workflow, - run_id__isnull=True, - uid__isnull=True, - example_id__isnull=True, - )[0] + def get_published_run_from_query_params( + cls, + example_id: str, + run_id: str, + uid: str, + ) -> PublishedRun | None: + if not example_id and not run_id: + return cls.get_root_published_run() + elif example_id: + return cls.get_published_run(published_run_id=example_id) + else: + return None + + @classmethod + def get_root_published_run(cls) -> PublishedRun: + return cls.get_published_run(published_run_id="") + + @classmethod + def get_or_create_root_published_run(cls) -> PublishedRun: + try: + return cls.get_root_published_run() + except PublishedRun.DoesNotExist: + saved_run = cls.run_doc_sr( + run_id="", + uid="", + create=True, + parent=None, + parent_version=None, + ) + return cls.create_published_run( + published_run_id="", + saved_run=saved_run, + user=None, + title=cls.title, + notes=cls().preview_description(state=saved_run.to_dict()), + visibility=PublishedRunVisibility(PublishedRunVisibility.PUBLIC), + ) + + @classmethod + def recipe_doc_sr(cls, create: bool = False) -> SavedRun: + if create: + return cls.get_or_create_root_published_run().saved_run + else: + return cls.get_root_published_run().saved_run @classmethod def run_doc_sr( - cls, run_id: str, uid: str, create: bool = False, parent: SavedRun = None + cls, + run_id: str, + uid: str, + create: bool = False, + parent: SavedRun | None = None, + parent_version: PublishedRunVersion | None = None, ) -> SavedRun: config = dict(workflow=cls.workflow, uid=uid, run_id=run_id) if create: return SavedRun.objects.get_or_create( - **config, defaults=dict(parent=parent) + **config, + defaults=dict(parent=parent, parent_version=parent_version), )[0] else: return SavedRun.objects.get(**config) @classmethod - def example_doc_sr(cls, example_id: str, create: bool = False) -> SavedRun: - config = dict(workflow=cls.workflow, example_id=example_id) - if create: - return SavedRun.objects.get_or_create(**config)[0] - else: - return SavedRun.objects.get(**config) + def create_published_run( + cls, + *, + published_run_id: str, + saved_run: SavedRun, + user: AppUser, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ): + return PublishedRun.create_published_run( + workflow=cls.workflow, + published_run_id=published_run_id, + saved_run=saved_run, + user=user, + title=title, + notes=notes, + visibility=visibility, + ) + + @classmethod + def get_published_run(cls, *, published_run_id: str): + return PublishedRun.objects.get( + workflow=cls.workflow, + published_run_id=published_run_id, + ) + + def duplicate_published_run( + self, + published_run: PublishedRun, + *, + title: str, + notes: str, + visibility: PublishedRunVisibility, + ): + return published_run.duplicate( + user=self.request.user, + title=title, + notes=notes, + visibility=visibility, + ) def render_description(self): pass @@ -506,27 +1110,60 @@ def render_form_v2(self): def validate_form_v2(self): pass - def render_author(self): - if not self.run_user or ( - not self.run_user.photo_url and not self.run_user.display_name - ): + def render_author( + self, + user: AppUser, + *, + image_size: str = "30px", + responsive: bool = True, + show_as_link: bool = False, + ): + if not user or (not user.photo_url and not user.display_name): return - html = "
" - if self.run_user.photo_url: + responsive_image_size = ( + f"calc({image_size} * 0.67)" if responsive else image_size + ) + + # new class name so that different ones don't conflict + class_name = f"author-image-{image_size}" + if responsive: + class_name += "-responsive" + + html = "
" + if user.photo_url: + st.html( + f""" + + """ + ) html += f""" - -
+ """ - if self.run_user.display_name: - html += f"
{self.run_user.display_name}
" + if user.display_name: + html += f"{user.display_name}" html += "
" - if self.is_current_user_admin(): + if show_as_link: linkto = lambda: st.link( to=self.app_url( tab_name=MenuTabs.paths[MenuTabs.history], - query_params={"uid": self.run_user.uid}, + query_params={"uid": user.uid}, ) ) else: @@ -670,20 +1307,6 @@ def _render_before_output(self): if not url: return - with st.div(className="d-flex gap-1"): - with st.div(className="flex-grow-1"): - st.text_input( - "recipe url", - label_visibility="collapsed", - disabled=True, - value=url.split("://")[1].rstrip("/"), - ) - copy_to_clipboard_button( - "πŸ”— Copy URL", - value=url, - style="height: 3.2rem", - ) - def _get_current_app_url(self) -> str | None: example_id, run_id, uid = extract_query_params(gooey_get_query_params()) return self.app_url(example_id, run_id, uid) @@ -699,14 +1322,10 @@ def update_flag_for_run(self, run_id: str, uid: str, is_flagged: bool): st.session_state["is_flagged"] = is_flagged def _render_input_col(self): - self.render_author() self.render_form_v2() with st.expander("βš™οΈ Settings"): self.render_settings() st.write("---") - st.write("##### πŸ–ŒοΈ Personalize") - st.text_input("Title", key=StateKeys.page_title) - st.text_area("Notes", key=StateKeys.page_notes) submitted = self.render_submit_button() with st.div(style={"textAlign": "right"}): st.caption( @@ -806,7 +1425,7 @@ def on_submit(self): else: self.call_runner_task(example_id, run_id, uid) raise QueryParamsRedirectException( - self.clean_query_params(example_id=example_id, run_id=run_id, uid=uid) + self.clean_query_params(example_id=None, run_id=run_id, uid=uid) ) def should_submit_after_login(self) -> bool: @@ -842,12 +1461,18 @@ def create_new_run(self): parent = self.get_sr_from_query_params( parent_example_id, parent_run_id, parent_uid ) + published_run = self.get_current_published_run() + parent_version = published_run and published_run.versions.latest() - self.run_doc_sr(run_id, uid, create=True, parent=parent).set( - self.state_to_doc(st.session_state) - ) + self.run_doc_sr( + run_id, + uid, + create=True, + parent=parent, + parent_version=parent_version, + ).set(self.state_to_doc(st.session_state)) - return parent_example_id, run_id, uid + return None, run_id, uid def call_runner_task(self, example_id, run_id, uid, is_api_call=False): from celeryapp.tasks import gui_runner @@ -930,47 +1555,41 @@ def _render_save_options(self): if not self.is_current_user_admin(): return - parent_example_id, parent_run_id, parent_uid = extract_query_params( - gooey_get_query_params() - ) - current_sr = self.get_sr_from_query_params( - parent_example_id, parent_run_id, parent_uid - ) + example_id, run_id, uid = extract_query_params(gooey_get_query_params()) + current_sr = self.get_sr_from_query_params(example_id, run_id, uid) + published_run = self.get_current_published_run() with st.expander("πŸ› οΈ Admin Options"): - sr_to_save = None - if st.button("⭐️ Save Workflow"): - sr_to_save = self.recipe_doc_sr() - - if st.button("πŸ”– Create new Example"): - sr_to_save = self.example_doc_sr(get_random_doc_id(), create=True) - - if parent_example_id: - if st.button("πŸ’Ύ Save this Example"): - sr_to_save = self.example_doc_sr(parent_example_id) - - if current_sr.example_id: - hidden = st.session_state.get(StateKeys.hidden) - if st.button("πŸ‘οΈ Make Public" if hidden else "πŸ™ˆοΈ Hide"): - self.set_hidden( - example_id=current_sr.example_id, - doc=st.session_state, - hidden=not hidden, - ) - - if sr_to_save: - if current_sr != sr_to_save: # ensure parent != child - sr_to_save.parent = current_sr - sr_to_save.set(self.state_to_doc(st.session_state)) - ## TODO: pass the success message to the redirect - # st.success("Saved", icon="βœ…") + root_run = self.get_root_published_run() + root_run.add_version( + user=self.request.user, + saved_run=current_sr, + visibility=PublishedRunVisibility.PUBLIC, + title=published_run.title if published_run else root_run.title, + notes=published_run.notes if published_run else root_run.notes, + ) raise QueryParamsRedirectException( - dict(example_id=sr_to_save.example_id) + dict(example_id=root_run.published_run_id) ) - if current_sr.parent_id: - st.write(f"Parent: {current_sr.parent.get_app_url()}") + if ( + published_run + and published_run.visibility == PublishedRunVisibility.PUBLIC + ): + hidden = not published_run.is_approved_example + if st.button("βœ… Approve as Example" if hidden else "πŸ™ˆοΈ Hide"): + if published_run.saved_run != current_sr: + st.error("There are unpublished changes in this run.") + else: + self.set_hidden( + published_run=published_run, + hidden=not hidden, + ) + else: + st.write( + "Note: To approve a run as an example, it must be published publicly first." + ) def state_to_doc(self, state: dict): ret = { @@ -979,13 +1598,6 @@ def state_to_doc(self, state: dict): if field_name in state } - title = state.get(StateKeys.page_title) - notes = state.get(StateKeys.page_notes) - if title and title.strip() != self.title.strip(): - ret[StateKeys.page_title] = title - if notes and notes.strip() != self.preview_description(state).strip(): - ret[StateKeys.page_notes] = notes - return ret def fields_to_save(self) -> [str]: @@ -1001,28 +1613,44 @@ def fields_to_save(self) -> [str]: ] def _examples_tab(self): - allow_delete = self.is_current_user_admin() + allow_hide = self.is_current_user_admin() - def _render(sr: SavedRun): - url = str( - furl( - self.app_url(), query_params={EXAMPLE_ID_QUERY_PARAM: sr.example_id} - ) + def _render(pr: PublishedRun): + self._render_example_preview( + published_run=pr, + allow_hide=allow_hide, ) - self._render_doc_example( - allow_delete=allow_delete, - doc=sr.to_dict(), - url=url, - query_params=dict(example_id=sr.example_id), + + example_runs = PublishedRun.objects.filter( + workflow=self.workflow, + visibility=PublishedRunVisibility.PUBLIC, + is_approved_example=True, + ).exclude(published_run_id="")[:50] + + grid_layout(3, example_runs, _render) + + def _saved_tab(self): + if not self.request.user or self.request.user.is_anonymous: + redirect_url = furl( + "/login", query_params={"next": furl(self.request.url).set(origin=None)} ) + raise RedirectException(str(redirect_url)) - example_runs = SavedRun.objects.filter( + published_runs = PublishedRun.objects.filter( workflow=self.workflow, - hidden=False, - example_id__isnull=False, + created_by=self.request.user, )[:50] + if not published_runs: + st.write("No published runs yet") + return - grid_layout(3, example_runs, _render) + def _render(pr: PublishedRun): + self._render_example_preview( + published_run=pr, + allow_hide=False, + ) + + grid_layout(3, published_runs, _render) def _history_tab(self): assert self.request, "request must be set to render history tab" @@ -1051,29 +1679,7 @@ def _history_tab(self): st.write("No history yet") return - def _render(sr: SavedRun): - url = str( - furl( - self.app_url(), - query_params={ - RUN_ID_QUERY_PARAM: sr.run_id, - USER_ID_QUERY_PARAM: uid, - }, - ) - ) - - self._render_doc_example( - allow_delete=False, - doc=sr.to_dict(), - url=url, - query_params=dict(run_id=sr.run_id, uid=uid), - ) - if sr.run_status: - html_spinner(sr.run_status) - elif sr.error_msg: - st.error(sr.error_msg, unsafe_allow_html=True) - - grid_layout(3, run_history, _render) + grid_layout(3, run_history, self._render_run_preview) next_url = ( furl(self._get_current_app_url(), query_params=self.request.query_params) @@ -1089,52 +1695,71 @@ def _render(sr: SavedRun): f"""""" ) - def _render_doc_example( - self, *, allow_delete: bool, doc: dict, url: str, query_params: dict + def _render_run_preview(self, saved_run: SavedRun): + url = saved_run.get_app_url() + with st.link(to=url): + st.html( + # language=HTML + f"""""" + ) + copy_to_clipboard_button("πŸ”— Copy URL", value=url) + + updated_at = saved_run.updated_at + if updated_at and isinstance(updated_at, datetime.datetime): + js_dynamic_date(updated_at) + + if saved_run.run_status: + html_spinner(saved_run.run_status) + elif saved_run.error_msg: + st.error(saved_run.error_msg, unsafe_allow_html=True) + + return self.render_example(saved_run.to_dict()) + + def _render_example_preview( + self, + *, + published_run: PublishedRun, + allow_hide: bool, ): + url = published_run.get_app_url() with st.link(to=url): st.html( # language=HTML f"""""" ) copy_to_clipboard_button("πŸ”— Copy URL", value=url) - if allow_delete: - self._example_delete_button(**query_params, doc=doc) - updated_at = doc.get("updated_at") + if allow_hide: + self._example_hide_button(published_run=published_run) + + updated_at = published_run.updated_at if updated_at and isinstance(updated_at, datetime.datetime): js_dynamic_date(updated_at) - title = doc.get(StateKeys.page_title) + title = published_run.title if title and title.strip() != self.title.strip(): st.write("#### " + title) - notes = doc.get(StateKeys.page_notes) - if ( - notes - and notes.strip() != self.preview_description(st.session_state).strip() - ): - st.write(notes) + if published_run.notes: + st.write(published_run.notes) + doc = published_run.saved_run.to_dict() self.render_example(doc) - def _example_delete_button(self, example_id, doc): + def _example_hide_button(self, published_run: PublishedRun): pressed_delete = st.button( "πŸ™ˆοΈ Hide", - key=f"delete_example_{example_id}", + key=f"delete_example_{published_run.published_run_id}", style={"color": "red"}, ) if not pressed_delete: return - self.set_hidden(example_id=example_id, doc=doc, hidden=True) - - def set_hidden(self, *, example_id, doc, hidden: bool): - sr = self.example_doc_sr(example_id) + self.set_hidden(published_run=published_run, hidden=True) + def set_hidden(self, *, published_run: PublishedRun, hidden: bool): with st.spinner("Hiding..."): - doc[StateKeys.hidden] = hidden - sr.hidden = hidden - sr.save(update_fields=["hidden", "updated_at"]) + published_run.is_approved_example = not hidden + published_run.save() st.experimental_rerun() @@ -1353,3 +1978,23 @@ def __init__(self, query_params: dict, status_code=303): query_params = {k: v for k, v in query_params.items() if v is not None} url = "?" + urllib.parse.urlencode(query_params) super().__init__(url, status_code) + + +def force_redirect(url: str): + # note: assumes sanitized URLs + st.html( + f""" + + """ + ) + + +def convert_state_type(state, key, fn): + if key in state: + state[key] = fn(state[key]) + + +def reverse_enumerate(start, iterator): + return zip(range(start, -1, -1), iterator) diff --git a/daras_ai_v2/bots.py b/daras_ai_v2/bots.py index 44d5c8531..26d79fee0 100644 --- a/daras_ai_v2/bots.py +++ b/daras_ai_v2/bots.py @@ -138,7 +138,17 @@ def nice_filename(self, mime_type: str) -> str: def _unpack_bot_integration(self): bi = self.convo.bot_integration - if bi.saved_run: + if bi.published_run: + self.page_cls = Workflow(bi.published_run.workflow).page_cls + self.query_params = self.page_cls.clean_query_params( + example_id=bi.published_run.published_run_id, + run_id="", + uid="", + ) + saved_run = bi.published_run.saved_run + self.input_glossary = saved_run.state.get("input_glossary_document") + self.output_glossary = saved_run.state.get("output_glossary_document") + elif bi.saved_run: self.page_cls = Workflow(bi.saved_run.workflow).page_cls self.query_params = self.page_cls.clean_query_params( example_id=bi.saved_run.example_id, diff --git a/daras_ai_v2/copy_to_clipboard_button_widget.py b/daras_ai_v2/copy_to_clipboard_button_widget.py index c3efbe47a..55222edf2 100644 --- a/daras_ai_v2/copy_to_clipboard_button_widget.py +++ b/daras_ai_v2/copy_to_clipboard_button_widget.py @@ -1,3 +1,4 @@ +import typing import gooey_ui as gui # language="html" @@ -5,10 +6,10 @@ @@ -20,16 +21,18 @@ def copy_to_clipboard_button( *, value: str, style: str = "", + className: str = "", + type: typing.Literal["primary", "secondary", "tertiary", "link"] = "primary", ): return gui.html( # language="html" f""" - """, diff --git a/daras_ai_v2/safety_checker.py b/daras_ai_v2/safety_checker.py index 4e55211d9..541e41b97 100644 --- a/daras_ai_v2/safety_checker.py +++ b/daras_ai_v2/safety_checker.py @@ -23,8 +23,8 @@ def safety_checker_text(text_input: str): # run in a thread to avoid messing up threadlocals result, sr = ( CompareLLMPage() - .example_doc_sr(settings.SAFTY_CHECKER_EXAMPLE_ID) - .submit_api_call( + .get_published_run(published_run_id=settings.SAFTY_CHECKER_EXAMPLE_ID) + .saved_run.submit_api_call( current_user=billing_account, request_body=dict(variables=dict(input=text_input)), ) diff --git a/daras_ai_v2/tabs_widget.py b/daras_ai_v2/tabs_widget.py index 235dd4039..f6511e58e 100644 --- a/daras_ai_v2/tabs_widget.py +++ b/daras_ai_v2/tabs_widget.py @@ -10,6 +10,7 @@ class MenuTabs: run_as_api = "πŸš€ API" history = "πŸ“– History" integrations = "πŸ”Œ Integrations" + saved = "πŸ“ Saved" paths = { run: "", @@ -17,6 +18,7 @@ class MenuTabs: run_as_api: "api", history: "history", integrations: "integrations", + saved: "saved", } paths_reverse = {v: k for k, v in paths.items()} diff --git a/daras_ai_v2/user_date_widgets.py b/daras_ai_v2/user_date_widgets.py index 91d6c001e..a4c510b56 100644 --- a/daras_ai_v2/user_date_widgets.py +++ b/daras_ai_v2/user_date_widgets.py @@ -1,35 +1,58 @@ import datetime +import json +from typing import Any, Callable import gooey_ui as gui -def js_dynamic_date(dt: datetime.datetime): +def js_dynamic_date( + dt: datetime.datetime, + *, + container: Callable = gui.caption, + date_options: dict[str, Any] | None = None, + time_options: dict[str, Any] | None = None, +): timestamp_ms = dt.timestamp() * 1000 - gui.caption("Loading...", **{"data-id-dynamic-date": str(timestamp_ms)}) + attrs = {"data-id-dynamic-date": str(timestamp_ms)} + if date_options: + attrs["data-id-date-options"] = json.dumps(date_options) + if time_options: + attrs["data-id-time-options"] = json.dumps(time_options) + container("Loading...", **attrs) def render_js_dynamic_dates(): + default_date_options = { + "weekday": "short", + "day": "numeric", + "month": "short", + } + default_time_options = { + "hour": "numeric", + "hour12": True, + "minute": "numeric", + } gui.html( # language=HTML """ + """ + % { + "date_options_json": json.dumps(default_date_options), + "time_options_json": json.dumps(default_time_options), + }, + ) + + +def re_render_js_dynamic_dates(): + gui.html( + # language=HTML + """ + """, ) diff --git a/explore.py b/explore.py index f3950bdbb..f47f6fc23 100644 --- a/explore.py +++ b/explore.py @@ -17,7 +17,7 @@ def render(): def _render_non_featured(page_cls): page = page_cls() - state = page.recipe_doc_sr().to_dict() + state = page.recipe_doc_sr(create=True).to_dict() # total_runs = page.get_total_runs() col1, col2 = gui.columns([1, 2]) @@ -30,7 +30,7 @@ def _render_non_featured(page_cls): def _render_as_featured(page_cls: typing.Type[BasePage]): page = page_cls() - state = page.recipe_doc_sr().to_dict() + state = page.recipe_doc_sr(create=True).to_dict() # total_runs = page.get_total_runs() render_image(page, state) # render_description(page, state, total_runs) @@ -45,7 +45,7 @@ def render_image(page: BasePage, state: dict): def render_description(page, state): with gui.link(to=page.app_url()): - gui.markdown(f"#### {page.get_recipe_title(state)}") + gui.markdown(f"#### {page.get_recipe_title()}") preview = page.preview_description(state) if preview: with gui.tag("p", style={"margin-bottom": "25px"}): diff --git a/gooey_ui/components.py b/gooey_ui/components/__init__.py similarity index 94% rename from gooey_ui/components.py rename to gooey_ui/components/__init__.py index 67265c420..207620c1f 100644 --- a/gooey_ui/components.py +++ b/gooey_ui/components/__init__.py @@ -435,7 +435,7 @@ def button( """ if not key: key = md5_values("button", label, help, type, props) - props["className"] = props.get("className", "") + " btn-" + type + className = f"btn-{type} " + props.pop("className", "") state.RenderTreeNode( name="gui-button", props=dict( @@ -445,6 +445,7 @@ def button( label=dedent(label), help=help, disabled=disabled, + className=className, **props, ), ).mount() @@ -619,6 +620,42 @@ def horizontal_radio( return value +def horizontal_radio( + label: str, + options: typing.Sequence[T], + format_func: typing.Callable[[T], typing.Any] = _default_format, + key: str = None, + help: str = None, + *, + disabled: bool = False, + checked_by_default: bool = True, + label_visibility: LabelVisibility = "visible", +) -> T | None: + if not options: + return None + options = list(options) + if not key: + key = md5_values("horizontal_radio", label, options, help, label_visibility) + value = state.session_state.get(key) + if (key not in state.session_state or value not in options) and checked_by_default: + value = options[0] + state.session_state.setdefault(key, value) + if label_visibility != "visible": + label = None + markdown(label) + for option in options: + if button( + format_func(option), + key=f"tab-{key}-{option}", + type="primary", + className="replicate-nav " + ("active" if value == option else ""), + disabled=disabled, + ): + state.session_state[key] = value = option + state.experimental_rerun() + return value + + def radio( label: str, options: typing.Sequence[T], @@ -627,6 +664,7 @@ def radio( help: str = None, *, disabled: bool = False, + checked_by_default: bool = True, label_visibility: LabelVisibility = "visible", ) -> T | None: if not options: @@ -635,7 +673,7 @@ def radio( if not key: key = md5_values("radio", label, options, help, label_visibility) value = state.session_state.get(key) - if key not in state.session_state or value not in options: + if (key not in state.session_state or value not in options) and checked_by_default: value = options[0] state.session_state.setdefault(key, value) if label_visibility != "visible": @@ -837,7 +875,7 @@ def breadcrumbs(divider: str = "/", **props) -> state.NestingCtx: def breadcrumb_item(inner_html: str, link_to: str | None = None, **props): - className = "breadcrumb-item lead " + props.pop("className", "") + className = "breadcrumb-item " + props.pop("className", "") with tag("li", className=className, **props): if link_to: with tag("a", href=link_to): diff --git a/gooey_ui/components/modal.py b/gooey_ui/components/modal.py new file mode 100644 index 000000000..149187a4b --- /dev/null +++ b/gooey_ui/components/modal.py @@ -0,0 +1,93 @@ +from contextlib import contextmanager + +import gooey_ui as st +from gooey_ui import experimental_rerun as rerun + + +class Modal: + def __init__(self, title, key, padding=20, max_width=744): + """ + :param title: title of the Modal shown in the h1 + :param key: unique key identifying this modal instance + :param padding: padding of the content within the modal + :param max_width: maximum width this modal should use + """ + self.title = title + self.padding = padding + self.max_width = str(max_width) + "px" + self.key = key + + self._container = None + + def is_open(self): + return st.session_state.get(f"{self.key}-opened", False) + + def open(self): + st.session_state[f"{self.key}-opened"] = True + rerun() + + def close(self, rerun_condition=True): + st.session_state[f"{self.key}-opened"] = False + if rerun_condition: + rerun() + + def empty(self): + if self._container: + self._container.empty() + + @contextmanager + def container(self, **props): + st.html( + f""" + + """ + ) + + with st.div(className="blur-background"): + with st.div(className="modal-parent"): + container_class = "modal-container " + props.pop("className", "") + self._container = st.div(className=container_class, **props) + + with self._container: + with st.div(className="d-flex justify-content-between align-items-center"): + st.markdown(f"### {self.title or ''}") + + close_ = st.button( + "✖", + type="tertiary", + key=f"{self.key}-close", + style={"padding": "0.375rem 0.75rem"}, + ) + if close_: + self.close() + yield self._container diff --git a/recipes/BulkRunner.py b/recipes/BulkRunner.py index 7c133925a..9cdbd82d4 100644 --- a/recipes/BulkRunner.py +++ b/recipes/BulkRunner.py @@ -34,15 +34,15 @@ class RequestModel(BaseModel): documents: list[str] = Field( title="Input Data Spreadsheet", description=""" -Upload or link to a CSV or google sheet that contains your sample input data. -For example, for Copilot, this would sample questions or for Art QR Code, would would be pairs of image descriptions and URLs. +Upload or link to a CSV or google sheet that contains your sample input data. +For example, for Copilot, this would sample questions or for Art QR Code, would would be pairs of image descriptions and URLs. Remember to includes header names in your CSV too. """, ) run_urls: list[str] = Field( title="Gooey Workflows", description=""" -Provide one or more Gooey.AI workflow runs. +Provide one or more Gooey.AI workflow runs. You can add multiple runs from the same recipe (e.g. two versions of your copilot) and we'll run the inputs over both of them. """, ) @@ -142,7 +142,7 @@ def render_form_v2(self): st.write( """ -###### **Preview**: Here's what you uploaded +###### **Preview**: Here's what you uploaded """ ) for file in files: @@ -155,8 +155,8 @@ def render_form_v2(self): st.write( """ ###### **Columns** -Please select which CSV column corresponds to your workflow's input fields. -For the outputs, select the fields that should be included in the output CSV. +Please select which CSV column corresponds to your workflow's input fields. +For the outputs, select the fields that should be included in the output CSV. To understand what each field represents, check out our [API docs](https://api.gooey.ai/docs). """, ) @@ -358,7 +358,7 @@ def run_v2( response.eval_runs = [] for url in request.eval_urls: page_cls, sr = url_to_sr(url) - yield f"Running {page_cls().get_recipe_title(sr.state)}..." + yield f"Running {page_cls().get_recipe_title()}..." request_body = page_cls.RequestModel( documents=response.output_documents ).dict(exclude_unset=True) @@ -371,30 +371,30 @@ def run_v2( def preview_description(self, state: dict) -> str: return """ -Which AI model actually works best for your needs? -Upload your own data and evaluate any Gooey.AI workflow, LLM or AI model against any other. -Great for large data sets, AI model evaluation, task automation, parallel processing and automated testing. -To get started, paste in a Gooey.AI workflow, upload a CSV of your test data (with header names!), check the mapping of headers to workflow inputs and tap Submit. -More tips in the Details below. +Which AI model actually works best for your needs? +Upload your own data and evaluate any Gooey.AI workflow, LLM or AI model against any other. +Great for large data sets, AI model evaluation, task automation, parallel processing and automated testing. +To get started, paste in a Gooey.AI workflow, upload a CSV of your test data (with header names!), check the mapping of headers to workflow inputs and tap Submit. +More tips in the Details below. """ def render_description(self): st.write( """ -Building complex AI workflows like copilot) and then evaluating each iteration is complex. -Workflows are affected by the particular LLM used (GPT4 vs PalM2), their vector DB knowledge sets (e.g. your google docs), how synthetic data creation happened (e.g. how you transformed your video transcript or PDF into structured data), which translation or speech engine you used and your LLM prompts. Every change can affect the quality of your outputs. +Building complex AI workflows like copilot) and then evaluating each iteration is complex. +Workflows are affected by the particular LLM used (GPT4 vs PalM2), their vector DB knowledge sets (e.g. your google docs), how synthetic data creation happened (e.g. how you transformed your video transcript or PDF into structured data), which translation or speech engine you used and your LLM prompts. Every change can affect the quality of your outputs. 1. This bulk tool enables you to do two incredible things: -2. Upload your own set of inputs (e.g. typical questions to your bot) to any gooey workflow (e.g. /copilot) and run them in bulk to generate outputs or answers. -3. Compare the results of competing workflows to determine which one generates better outputs. +2. Upload your own set of inputs (e.g. typical questions to your bot) to any gooey workflow (e.g. /copilot) and run them in bulk to generate outputs or answers. +3. Compare the results of competing workflows to determine which one generates better outputs. To get started: 1. Enter the Gooey.AI Workflow URLs that you'd like to run in bulk 2. Enter a csv of sample inputs to run in bulk -3. Ensure that the mapping between your inputs and API parameters of the Gooey.AI workflow are correctly mapped. -4. Tap Submit. +3. Ensure that the mapping between your inputs and API parameters of the Gooey.AI workflow are correctly mapped. +4. Tap Submit. 5. Wait for results -6. Make a change to your Gooey Workflow, copy its URL and repeat Step 1 (or just add the link to see the results of both workflows together) +6. Make a change to your Gooey Workflow, copy its URL and repeat Step 1 (or just add the link to see the results of both workflows together) """ ) @@ -419,9 +419,7 @@ def render_run_url_inputs(key: str, del_key: str, d: dict): with scol1: with st.div(className="pt-1"): options = { - page_cls.workflow: page_cls().get_recipe_title( - page_cls.recipe_doc_sr().state - ) + page_cls.workflow: page_cls().get_recipe_title() for page_cls in all_home_pages } last_workflow_key = "__last_run_url_workflow" diff --git a/recipes/VideoBots.py b/recipes/VideoBots.py index 348aeef07..952df781c 100644 --- a/recipes/VideoBots.py +++ b/recipes/VideoBots.py @@ -20,7 +20,7 @@ from daras_ai_v2.azure_doc_extract import ( azure_form_recognizer, ) -from daras_ai_v2.base import BasePage, MenuTabs, StateKeys +from daras_ai_v2.base import BasePage, MenuTabs from daras_ai_v2.doc_search_settings_widgets import ( doc_search_settings, document_uploader, @@ -207,13 +207,13 @@ class RequestModel(BaseModel): input_glossary_document: str | None = Field( title="Input Glossary", description=""" -Translation Glossary for User Langauge -> LLM Language (English) +Translation Glossary for User Langauge -> LLM Language (English) """, ) output_glossary_document: str | None = Field( title="Output Glossary", description=""" -Translation Glossary for LLM Language (English) -> User Langauge +Translation Glossary for LLM Language (English) -> User Langauge """, ) @@ -275,20 +275,20 @@ def get_submit_container_props(self): def render_description(self): st.write( """ -Have you ever wanted to create a bot that you could talk to about anything? Ever wanted to create your own https://dara.network/RadBots or https://Farmer.CHAT? This is how. +Have you ever wanted to create a bot that you could talk to about anything? Ever wanted to create your own https://dara.network/RadBots or https://Farmer.CHAT? This is how. -This workflow takes a dialog LLM prompt describing your character, a collection of docs & links and optional an video clip of your bot’s face and voice settings. - -We use all these to build a bot that anyone can speak to about anything and you can host directly in your own site or app, or simply connect to your Facebook, WhatsApp or Instagram page. +This workflow takes a dialog LLM prompt describing your character, a collection of docs & links and optional an video clip of your bot’s face and voice settings. + +We use all these to build a bot that anyone can speak to about anything and you can host directly in your own site or app, or simply connect to your Facebook, WhatsApp or Instagram page. How It Works: -1. Appends the user's question to the bottom of your dialog script. +1. Appends the user's question to the bottom of your dialog script. 2. Sends the appended script to OpenAI’s GPT3 asking it to respond to the question in the style of your character 3. Synthesizes your character's response as audio using your voice settings (using Google Text-To-Speech or Uberduck) 4. Lip syncs the face video clip to the voice clip 5. Shows the resulting video to the user -PS. This is the workflow that we used to create RadBots - a collection of Turing-test videobots, authored by leading international writers, singers and playwrights - and really inspired us to create Gooey.AI so that every person and organization could create their own fantastic characters, in any personality of their choosing. It's also the workflow that powers https://Farmer.CHAT and was demo'd at the UN General Assembly in April 2023 as a multi-lingual WhatsApp bot for Indian, Ethiopian and Kenyan farmers. +PS. This is the workflow that we used to create RadBots - a collection of Turing-test videobots, authored by leading international writers, singers and playwrights - and really inspired us to create Gooey.AI so that every person and organization could create their own fantastic characters, in any personality of their choosing. It's also the workflow that powers https://Farmer.CHAT and was demo'd at the UN General Assembly in April 2023 as a multi-lingual WhatsApp bot for Indian, Ethiopian and Kenyan farmers. """ ) @@ -355,7 +355,7 @@ def render_settings(self): """ ###### πŸ“– Customize with Glossary Provide a glossary to customize translation and improve accuracy of domain-specific terms. - If not specified or invalid, no glossary will be used. Read about the expected format [here](https://docs.google.com/document/d/1TwzAvFmFYekloRKql2PXNPIyqCbsHRL8ZtnWkzAYrh8/edit?usp=sharing). + If not specified or invalid, no glossary will be used. Read about the expected format [here](https://docs.google.com/document/d/1TwzAvFmFYekloRKql2PXNPIyqCbsHRL8ZtnWkzAYrh8/edit?usp=sharing). """ ) glossary_input( @@ -391,8 +391,8 @@ def render_settings(self): st.file_uploader( """ #### πŸ‘©β€πŸ¦° Input Face - Upload a video/image that contains faces to use - *Recommended - mp4 / mov / png / jpg / gif* + Upload a video/image that contains faces to use + *Recommended - mp4 / mov / png / jpg / gif* """, key="input_face", ) @@ -867,7 +867,7 @@ def render_selected_tab(self, selected_tab): with col2: st.write( """ - + #### Part 2: [Interactive Chatbots for your Content - Part 2: Make your Chatbot - How to use Gooey.AI Workflows ](https://youtu.be/h817RolPjq4) """ @@ -876,7 +876,7 @@ def render_selected_tab(self, selected_tab): """
+
""", unsafe_allow_html=True, @@ -896,7 +896,7 @@ def messenger_bot_integration(self): st.markdown( # language=html f""" -

Connect this bot to your Website, Instagram, Whatsapp & More

+

Connect this bot to your Website, Instagram, Whatsapp & More

Your can connect your FB Messenger account and Slack Workspace here directly.
If you ping us at support@gooey.ai, we'll add your other accounts too! @@ -905,26 +905,26 @@ def messenger_bot_integration(self):
️ -   +   Add Your Instagram Page
-->
- ️ -   + ️ +   Add Your Facebook Page
- -   + +   Add Your Slack Workspace - + ℹ️
@@ -979,9 +979,8 @@ def messenger_bot_integration(self): placeholder=bi.name, ) if st.button("Reset to Default"): - bi.name = st.session_state.get( - StateKeys.page_title, bi.name - ) + title = self.get_current_published_run().title + bi.name = title or bi.name bi.slack_read_receipt_msg = BotIntegration._meta.get_field( "slack_read_receipt_msg" ).default