diff --git a/daras_ai_v2/base.py b/daras_ai_v2/base.py index 436c6eaf8..74f556aa2 100644 --- a/daras_ai_v2/base.py +++ b/daras_ai_v2/base.py @@ -48,7 +48,10 @@ from daras_ai_v2.exceptions import InsufficientCredits from daras_ai_v2.fastapi_tricks import get_route_path from daras_ai_v2.github_tools import github_url_for_file -from daras_ai_v2.gooey_builder import render_gooey_builder +from daras_ai_v2.gooey_builder import ( + render_gooey_builder_inline, + render_gooey_builder_launcher, +) from daras_ai_v2.grid_layout_widget import grid_layout from daras_ai_v2.html_spinner_widget import html_spinner from daras_ai_v2.manage_api_keys_widget import manage_api_keys @@ -94,6 +97,7 @@ ) from widgets.publish_form import clear_publish_form from widgets.saved_workflow import render_saved_workflow_preview +from widgets.sidebar import sidebar_layout, use_sidebar from widgets.workflow_image import ( render_change_notes_input, render_workflow_photo_uploader, @@ -427,6 +431,9 @@ def render(self): with header_placeholder: self._render_header() + def render_sidebar(self): + self._render_gooey_builder() + def _render_header(self): from widgets.workflow_image import CIRCLE_IMAGE_WORKFLOWS @@ -1195,8 +1202,6 @@ def render_selected_tab(self): self.render_deleted_output() return - self._render_gooey_builder() - with gui.styled(INPUT_OUTPUT_COLS_CSS): input_col, output_col = gui.columns([3, 2], gap="medium") with input_col: @@ -1225,54 +1230,65 @@ def render_selected_tab(self): self._saved_tab() def _render_gooey_builder(self): - update_gui_state: dict | None = gui.session_state.pop("update_gui_state", None) - if update_gui_state: - new_state = ( - { - k: v - for k, v in gui.session_state.items() - if k in self.fields_to_save() - } - | { - "--has-request-changed": True, - } - | update_gui_state - ) - gui.session_state.clear() - gui.session_state.update(new_state) - - enable_bot_builder = ( - self.request.user - and not self.request.user.is_anonymous - and ( - self.request.user.is_admin() - or self.current_workspace.enable_bot_builder - ) - ) + sidebar_ref = use_sidebar("builder-sidebar", self.request.session) - if not enable_bot_builder: + # close the sidebar for other tabs + if self.tab != RecipeTabs.run and self.tab != RecipeTabs.preview: + if sidebar_ref.is_open or sidebar_ref.is_mobile_open: + sidebar_ref.set_open(False) + sidebar_ref.set_mobile_open(False) + raise gui.RerunException() return - render_gooey_builder( - page_slug=self.slug_versions[-1], - builder_state=dict( - status=dict( - error_msg=gui.session_state.get(StateKeys.error_msg), - run_status=gui.session_state.get(StateKeys.run_status), - run_time=gui.session_state.get(StateKeys.run_time), - ), - request=extract_model_fields( - model=self.RequestModel, state=gui.session_state - ), - response=extract_model_fields( - model=self.ResponseModel, state=gui.session_state - ), - metadata=dict( - title=self.current_pr.title, - description=self.current_pr.notes, - ), - ), - ) + # render the launcher if the sidebar is not open + if not sidebar_ref.is_open and not sidebar_ref.is_mobile_open: + current_workspace = self.current_workspace if self.request.user else None + render_gooey_builder_launcher( + self.request, + current_workspace=current_workspace, + is_fab_button=True, + ) + else: # open the sidebar for the builder + with gui.div(className="w-100 h-100"): + update_gui_state: dict | None = gui.session_state.pop( + "update_gui_state", None + ) + if update_gui_state: + new_state = ( + { + k: v + for k, v in gui.session_state.items() + if k in self.fields_to_save() + } + | { + "--has-request-changed": True, + } + | update_gui_state + ) + gui.session_state.clear() + gui.session_state.update(new_state) + + render_gooey_builder_inline( + page_slug=self.slug_versions[-1], + builder_state=dict( + status=dict( + error_msg=gui.session_state.get(StateKeys.error_msg), + run_status=gui.session_state.get(StateKeys.run_status), + run_time=gui.session_state.get(StateKeys.run_time), + ), + request=extract_model_fields( + model=self.RequestModel, state=gui.session_state + ), + response=extract_model_fields( + model=self.ResponseModel, state=gui.session_state + ), + metadata=dict( + title=self.current_pr.title, + description=self.current_pr.notes, + ), + ), + sidebar_ref=sidebar_ref, + ) def _render_version_history(self): versions = self.current_pr.versions.all() diff --git a/daras_ai_v2/gooey_builder.py b/daras_ai_v2/gooey_builder.py index 7bd55a71e..866c6ff06 100644 --- a/daras_ai_v2/gooey_builder.py +++ b/daras_ai_v2/gooey_builder.py @@ -2,17 +2,105 @@ from bots.models import BotIntegration from daras_ai_v2 import settings +from widgets.sidebar import SidebarRef, use_sidebar +from starlette.requests import Request +from workspaces.models import Workspace -def render_gooey_builder(page_slug: str, builder_state: dict): +DEFAULT_GOOEY_BUILDER_PHOTO_URL = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/63bdb560-b891-11f0-b9bc-02420a00014a/generate-ai-abstract-symbol-artificial-intelligence-colorful-stars-icon-vector%201.jpg" + + +def can_launch_gooey_builder( + request: Request, current_workspace: Workspace | None +) -> bool: + if not request.user or request.user.is_anonymous: + return False + if request.user.is_admin(): + return True + return current_workspace and current_workspace.enable_bot_builder + + +def render_gooey_builder_launcher( + request: Request, + current_workspace: Workspace | None = None, + is_fab_button: bool = False, +): + if not can_launch_gooey_builder(request, current_workspace): + return + + sidebar_ref = use_sidebar("builder-sidebar", request.session) + try: + bi = BotIntegration.objects.get(id=settings.GOOEY_BUILDER_INTEGRATION_ID) + except BotIntegration.DoesNotExist: + return + branding = bi.get_web_widget_branding() + photo_url = branding.get( + "photoUrl", + DEFAULT_GOOEY_BUILDER_PHOTO_URL, + ) + branding["showPoweredByGooey"] = False + if is_fab_button: + with gui.styled("& .gooey-builder-open-button:hover { scale: 1.2; }"): + with gui.div( + className="w-100 position-absolute", + style={"bottom": "24px", "left": "16px", "zIndex": "1000"}, + ): + gooey_builder_open_button = gui.button( + label=f"", + className="btn btn-secondary border-0 d-none d-md-block p-0 gooey-builder-open-button", + style={ + "width": "56px", + "height": "56px", + "borderRadius": "50%", + "boxShadow": "#0000001a 0 1px 4px, #0003 0 2px 12px", + }, + ) + if gooey_builder_open_button: + sidebar_ref.set_open(True) + raise gui.RerunException() + else: + gooey_builder_mobile_open_button = gui.button( + label=f"", + className="border-0 m-0 btn btn-secondary rounded-pill d-md-none gooey-builder-open-button p-0", + style={ + "width": "36px", + "height": "36px", + "borderRadius": "50%", + }, + ) + if gooey_builder_mobile_open_button: + sidebar_ref.set_mobile_open(True) + raise gui.RerunException() + + +def render_gooey_builder_inline( + page_slug: str, builder_state: dict, sidebar_ref: SidebarRef +): if not settings.GOOEY_BUILDER_INTEGRATION_ID: return + # hidden button to trigger the onClose event passed in the widget config + gui.tag( + "button", + type="submit", + name="onCloseGooeyBuilder", + value="yes", + hidden=True, + id="onClose", + ) + + if gui.session_state.pop("onCloseGooeyBuilder", None): + sidebar_ref.set_open(False) + sidebar_ref.set_mobile_open(False) + raise gui.RerunException() + bi = BotIntegration.objects.get(id=settings.GOOEY_BUILDER_INTEGRATION_ID) config = bi.get_web_widget_config( hostname="gooey.ai", target="#gooey-builder-embed" ) + config["mode"] = "inline" + config["showRunLink"] = True branding = config.setdefault("branding", {}) branding["showPoweredByGooey"] = False @@ -25,7 +113,7 @@ def render_gooey_builder(page_slug: str, builder_state: dict): gui.html( # language=html f""" -
+
""" ) @@ -42,8 +130,12 @@ def render_gooey_builder(page_slug: str, builder_state: dict): GooeyEmbed.setGooeyBuilderVariables = (value) => { config.payload.variables = value; }; + GooeyEmbed.setGooeyBuilderVariables(variables); + config.onClose = function() { + document.getElementById("onClose").click(); + }; GooeyEmbed.mount(config); } diff --git a/routers/root.py b/routers/root.py index f3829180a..3b9e55f49 100644 --- a/routers/root.py +++ b/routers/root.py @@ -7,6 +7,7 @@ from enum import Enum from time import time +from daras_ai_v2.gooey_builder import render_gooey_builder_launcher import gooey_gui as gui import sentry_sdk from fastapi import Depends, HTTPException, Query @@ -46,8 +47,16 @@ from handles.models import Handle from routers.custom_api_router import CustomAPIRouter from routers.static_pages import serve_static_file +from widgets.sidebar import sidebar_layout, use_sidebar from widgets.workflow_search import SearchFilters, render_search_bar_with_redirect -from workspaces.widgets import global_workspace_selector, workspace_selector_link +from workspaces.widgets import ( + get_current_workspace, + global_workspace_selector, + workspace_selector_link, +) + +if typing.TYPE_CHECKING: + from daras_ai_v2.base import BasePage app = CustomAPIRouter() @@ -711,7 +720,7 @@ def render_recipe_page( if not gui.session_state: gui.session_state.update(page.current_sr_to_session_state()) - with page_wrapper(request): + with page_wrapper(request, page=page, is_recipe_page=True): page.render() return dict( @@ -730,81 +739,123 @@ def get_og_url_path(request) -> str: @contextmanager def page_wrapper( request: Request, + page: typing.Optional["BasePage"] = None, className="", search_filters: typing.Optional[SearchFilters] = None, show_search_bar: bool = True, + is_recipe_page: bool = False, ): from routers.account import explore_in_current_workspace context = {"request": request, "block_incognito": True} - with gui.div(className="d-flex flex-column min-vh-100"): - gui.html(templates.get_template("gtag.html").render(**context)) + container = page if page else None + sidebar_ref = use_sidebar("builder-sidebar", request.session, default_open=False) + sidebar_content, pane_content = sidebar_layout(sidebar_ref) - with ( - gui.div(className="header"), - gui.div(className="navbar navbar-expand-xl bg-transparent p-0 m-0"), - gui.div(className="container-xxl my-2"), - gui.div( - className="position-relative w-100 d-flex justify-content-between gap-2" - ), - ): - with ( - gui.div(className="d-md-block"), - gui.tag("a", href="/"), - ): - gui.tag( - "img", - src=settings.GOOEY_LOGO_IMG, - width="300", - height="142", - className="img-fluid logo d-none d-sm-block", - ) - gui.tag( - "img", - src=settings.GOOEY_LOGO_RECT, - width="145", - height="40", - className="img-fluid logo d-sm-none", - ) + is_builder_sidebar_open = sidebar_ref.is_open + if not is_recipe_page and is_builder_sidebar_open: + sidebar_ref.set_open(False) + sidebar_ref.set_mobile_open(False) + raise gui.RerunException() - if show_search_bar: - _render_mobile_search_button(request, search_filters) + with sidebar_content: + if container: + container.render_sidebar() - with gui.div( - className="d-flex gap-2 justify-content-end flex-wrap align-items-center" - ): - for url, label in settings.HEADER_LINKS: - render_header_link( - url=url, label=label, icon=settings.HEADER_ICONS.get(url) - ) + with pane_content: + with gui.div(className="d-flex flex-column min-vh-100 w-100"): + gui.html(templates.get_template("gtag.html").render(**context)) - if request.user and not request.user.is_anonymous: - render_header_link( - url=get_route_path(explore_in_current_workspace), - label="Saved", - icon=icons.save, + with ( + gui.div(className="header"), + gui.div(className="navbar navbar-expand-xl bg-transparent p-0 m-0"), + gui.div( + className="container-xxl my-2" + if not is_builder_sidebar_open + else "my-2 mx-2 w-100" + ), + gui.div( + className="position-relative w-100 d-flex justify-content-between gap-2" + ), + ): + with ( + gui.div(className="d-md-block"), + gui.tag("a", href="/"), + ): + gui.tag( + "img", + src=settings.GOOEY_LOGO_IMG, + width="300", + height="142", + className="img-fluid logo d-none d-sm-block", ) - - current_workspace = global_workspace_selector( - request.user, request.session + gui.tag( + "img", + src=settings.GOOEY_LOGO_RECT, + width="145", + height="40", + className="img-fluid logo d-sm-none", ) - else: - current_workspace = None - anonymous_login_container(request, context) - gui.html(copy_to_clipboard_scripts) + with gui.div( + className="d-flex justify-content-end flex-grow-1 align-items-center" + ): + if is_recipe_page: + render_gooey_builder_launcher( + request=request, + current_workspace=get_current_workspace( + request.user, request.session + ) + if request.user + else None, + is_fab_button=False, + ) + + if show_search_bar: + _render_mobile_search_button(request, search_filters) + + with gui.div( + className="d-flex gap-2 justify-content-end flex-wrap align-items-center" + ): + for url, label in settings.HEADER_LINKS: + render_header_link( + url=url, + label=label, + icon=settings.HEADER_ICONS.get(url), + ) + + if request.user and not request.user.is_anonymous: + render_header_link( + url=get_route_path(explore_in_current_workspace), + label="Saved", + icon=icons.save, + ) + + current_workspace = global_workspace_selector( + request.user, request.session + ) + else: + current_workspace = None + anonymous_login_container(request, context) + + gui.html(copy_to_clipboard_scripts) - with gui.div(id="main-content", className="container-xxl " + className): - yield current_workspace + with gui.div( + id="main-content", + className="container-xxl " + if not is_builder_sidebar_open + else "mx-2 w-100 " + className, + ): + yield current_workspace - gui.html(templates.get_template("footer.html").render(**context)) - gui.html(templates.get_template("login_scripts.html").render(**context)) + gui.html(templates.get_template("footer.html").render(**context)) + gui.html(templates.get_template("login_scripts.html").render(**context)) def _render_mobile_search_button(request: Request, search_filters: SearchFilters): with gui.div( - className="d-flex d-md-none flex-grow-1 justify-content-end", + className="d-flex d-md-none justify-content-end", ): gui.button( icons.search, diff --git a/widgets/sidebar.py b/widgets/sidebar.py new file mode 100644 index 000000000..f20c1533e --- /dev/null +++ b/widgets/sidebar.py @@ -0,0 +1,163 @@ +import gooey_gui as gui +from textwrap import dedent + + +class SidebarRef: + def __init__( + self, + key: str, + session: dict, + is_open: bool = True, + is_mobile_open: bool = False, + ): + self.key = key + self.session = session + self.is_open = is_open + self.is_mobile_open = is_mobile_open + + def set_open(self, value: bool): + self.is_open = self.session[self.key] = value + + def set_mobile_open(self, value: bool): + self.is_mobile_open = self.session[self.mobile_key] = value + # self.set_open(value) + + @property + def mobile_key(self): + return self.key + ":mobile" + + +def use_sidebar(key: str, session: dict, default_open: bool = True) -> SidebarRef: + """Create or get a sidebar reference with state management.""" + import time + + ## HUGE HACK HERE + # Check if this is a fresh page load by comparing timestamps + last_load_time = session.get(f"{key}:last_load_time", 0) + current_time = time.time() + + # If more than 0.5 second has passed since last load, consider it a fresh page load + if current_time - last_load_time > 0.5: + # Fresh page load - clear mobile state + mobile_key = key + ":mobile" + session.pop(mobile_key, None) + + # Update the last load time + session[f"{key}:last_load_time"] = current_time + + # set the default open state in session here + session[key] = bool(session.get(key, default_open)) + ref = SidebarRef( + key=key, + session=session, + is_open=bool(session.get(key, default_open)), + is_mobile_open=bool(session.get(key + ":mobile", False)), + ) + + return ref + + +# Sidebar width variables +sidebar_open_width = "340px" +sidebar_closed_width = "0px" +sidebar_mobile_width = "100vw" + + +def sidebar_layout(sidebar_ref: SidebarRef): + is_mobile_open = sidebar_ref.is_mobile_open + sidebar_function_classes = ( + "gooey-sidebar-open" + if sidebar_ref.is_open or sidebar_ref.is_mobile_open + else "gooey-sidebar-closed" + ) + + side_bar_styles = dedent( + f""" + html {{ + /* override margin-left from app.css */ + margin-left: 0 !important; + }} + & .gooey-btn {{ + padding: 6px 10px !important; + }} + & .gooey-btn:hover {{ + background-color: #f0f0f0 !important; + }} + + & .gooey-sidebar {{ + background-color: #f9f9f9; + position: sticky; + top: 0; + left: 0; + bottom: 0; + z-index: 999; + border-right: 1px solid #e0e0e0; + height: 100dvh; + }} + + & .gooey-sidebar-open {{ + min-width: {sidebar_open_width}; + width: {sidebar_open_width}; + max-width: {sidebar_open_width}; + }} + & .gooey-sidebar-closed {{ + min-width: {sidebar_closed_width}; + width: {sidebar_closed_width}; + max-width: {sidebar_closed_width}; + }} + + & .gooey-sidebar-closed:hover {{ + cursor: e-resize; + }} + + @media (max-width: 990px) {{ + & .gooey-sidebar-open {{ + position: fixed; + left: 0; + bottom: 0; + min-width: {sidebar_mobile_width}; + width: {sidebar_mobile_width}; + max-width: {sidebar_mobile_width}; + z-index: 100; + border-left: 1px solid #e0e0e0; + border-right: none; + height: calc(100dvh); /* 4px for the progress bar */ + margin-top: auto; + }} + & .gooey-sidebar-closed {{ + position: sticky; + right: 0; + left: auto; + min-width: 0px; + width: 0px; + max-width: 0px; + overflow: visible; + }} + }} + """ + ) + if not is_mobile_open: + side_bar_styles += dedent( + """ + @media (max-width: 767px) { + & .gooey-sidebar-open { + display: none !important; + position: fixed; + max-width: 0px !important; + } + } + """ + ) + + with ( + gui.styled(side_bar_styles), + gui.div( + className="d-flex w-100 h-100 position-relative", + style={"height": "100dvh"}, + ), + ): + sidebar_content_placeholder = gui.div( + className=f"d-flex flex-column flex-grow-1 gooey-sidebar {sidebar_function_classes}", + ) + pane_content_placeholder = gui.div(className="d-flex flex-grow-1 mw-100") + return sidebar_content_placeholder, pane_content_placeholder