From 73b924b9c92f2af750bee2d4f1d478c06ae39ffc Mon Sep 17 00:00:00 2001 From: Ryan Kulp Date: Fri, 13 Feb 2026 18:35:53 -0500 Subject: [PATCH] Update nano_banana_dashboard plugin from core repo --- README.md | 1 + .../nano_banana_dashboard.rb | 348 ++++++++++++++++++ lib/nano_banana_dashboard/views/full.html.erb | 9 + .../views/half_horizontal.html.erb | 9 + .../views/half_vertical.html.erb | 9 + .../views/image_display/_full.html.erb | 10 + .../image_display/_half_horizontal.html.erb | 10 + .../image_display/_half_vertical.html.erb | 10 + .../views/image_display/_quadrant.html.erb | 10 + .../views/quadrant.html.erb | 9 + 10 files changed, 425 insertions(+) create mode 100644 lib/nano_banana_dashboard/nano_banana_dashboard.rb create mode 100644 lib/nano_banana_dashboard/views/full.html.erb create mode 100644 lib/nano_banana_dashboard/views/half_horizontal.html.erb create mode 100644 lib/nano_banana_dashboard/views/half_vertical.html.erb create mode 100644 lib/nano_banana_dashboard/views/image_display/_full.html.erb create mode 100644 lib/nano_banana_dashboard/views/image_display/_half_horizontal.html.erb create mode 100644 lib/nano_banana_dashboard/views/image_display/_half_vertical.html.erb create mode 100644 lib/nano_banana_dashboard/views/image_display/_quadrant.html.erb create mode 100644 lib/nano_banana_dashboard/views/quadrant.html.erb diff --git a/README.md b/README.md index e8d5067..eee9af4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ community Recipes: - [Lunar Calendar](/lib/lunar_calendar) - [Lunch Money](/lib/lunch_money) - [Mondrian](/lib/mondrian) +- [Nano Banana Dashboard](/lib/nano_banana_dashboard) - [Notion](/lib/notion) - [Parcel](/lib/parcel) - [Shopify](/lib/shopify) diff --git a/lib/nano_banana_dashboard/nano_banana_dashboard.rb b/lib/nano_banana_dashboard/nano_banana_dashboard.rb new file mode 100644 index 0000000..18d02bb --- /dev/null +++ b/lib/nano_banana_dashboard/nano_banana_dashboard.rb @@ -0,0 +1,348 @@ +module Plugins + class NanoBananaDashboard < Base + GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent'.freeze + + GEMINI_MODELS = { + 'flash' => 'gemini-2.5-flash-image', + 'pro' => 'gemini-3-pro-image-preview' + }.freeze + + VALID_ASPECT_RATIOS = %w[16:9 9:16 4:3 3:4 3:2 2:3 1:1 21:9].freeze + + # NOTE: Scenes define how data is physically integrated into an environment. + # Data should be prominent and legible, but exist as natural objects within the scene. + # The 'none' key skips scene framing — relies only on user style additions. + SCENE_PROMPTS = { + 'none' => nil, + + 'scenic landscape' => 'A natural landscape (mountains, coastline, or countryside) where data exists as ' \ + 'objects in the world — temperatures on wooden trail signs, numbers carved into stone markers, ' \ + 'text on banners strung between trees, weather shown in the sky itself.', + + 'daily briefing' => 'A newspaper front page or cork bulletin board where data fills the headlines, ' \ + 'columns, and pinned notes. Each data source is its own article or pinned card with a bold header.', + + 'retro comic' => 'A comic book page where data lives inside speech bubbles, bold captions, and screens ' \ + 'within panels. Characters hold up signs or react to the numbers. Halftone shading throughout.', + + 'technical blueprint' => 'An engineering schematic on grid paper where data appears as precise measurements, ' \ + 'gauge readouts, and specification callouts connected by leader lines to diagrams.', + + 'vintage poster' => 'A 1930s art deco poster where data is the typography — big bold headline numbers, ' \ + 'values in ribbon banners, stats in ornamental frames with decorative flourishes between them.', + + 'cozy cafe' => 'A coffee shop chalkboard where data is written in chalk — temperatures on a menu board, ' \ + 'numbers on table tent signs, schedules on a wall clock, stats in chalk-drawn frames.', + + 'space mission' => 'A mission control room where data lives on glowing monitor screens, instrument panels, ' \ + 'HUD readouts, and orbital trajectory displays. Numbers on gauges and digital readout screens.', + + 'japanese ink' => 'A sumi-e ink wash scene where data is painted in elegant brush calligraphy on hanging ' \ + 'scroll panels, carved into stone lanterns, and written on paper fans and wooden plaques.', + + 'library study' => 'A library desk where data appears on open book pages, handwritten note cards, ' \ + 'a desk calendar, spines of books, and a wall clock. Pinned index cards on a cork board.', + + 'steampunk workshop' => 'A Victorian workshop where data lives on brass pressure gauges, analog dial faces, ' \ + 'mechanical ticker tape readouts, etched metal plates, and clockwork number wheels.', + + 'ocean depths' => 'An underwater scene where data is etched on treasure chest plates, written inside ' \ + 'message bottles, displayed on diver slate boards, and formed by bioluminescent creatures.', + + 'minimalist grid' => 'A clean grid layout where data sits in precise typographic blocks separated by thin ' \ + 'rules and geometric shapes. Numbers in bold sans-serif, generous white space between sections.' + }.freeze + + def process! + @generate_screen = api_key.present? && !use_cache? + super + end + + def locals + return { errors: "Gemini API key is required. Get one at aistudio.google.com" } if api_key.blank? + return cached_result if use_cache? + + generate_scene + rescue StandardError => e + handle_error(e) + end + + private + + # NOTE: Overrides base class to skip screen pipeline when cached or missing API key. + # Base class checks merge variables; this checks cache state instead. + def skip_screen_generation? + !@generate_screen + end + + # --- Caching --- + + def use_cache? + !force_refresh? && prompt_unchanged? && cached_image_url.present? + end + + def cached_image_url + plugin_setting.persistence_data.dig('locals', 'image_url') + end + + def cached_prompt_hash + plugin_setting.persistence_data.dig('locals', 'prompt_hash') + end + + def prompt_unchanged? + cached_prompt_hash == current_prompt_hash + end + + def current_prompt_hash + Digest::SHA256.hexdigest(cache_key_inputs) + end + + def cache_key_inputs + [ + settings.slice( + 'style_prompt', 'style_custom', 'custom_text', + 'data_instructions', 'aspect_ratio', 'model', 'color_palette' + ).sort.to_json, + selected_plugin_ids.sort.to_s, + source_data_fingerprint + ].join(':') + end + + def source_data_fingerprint + return '' if selected_plugin_ids.empty? + + Digest::SHA256.hexdigest(selected_plugins_data.to_json) + end + + def cached_result + { image_url: cached_image_url, prompt_hash: cached_prompt_hash } + end + + # --- Plugin Data --- + + # CONTEXT: Settings store plugin references as "keyname_id" (e.g. "weather_42"). + # We extract the numeric ID to query plugin_settings by primary key. + def selected_plugin_ids + @_selected_plugin_ids ||= [settings['plugin_1'], settings['plugin_2'], settings['plugin_3']] + .compact + .reject(&:blank?) + .map { |s| s.split('_').last.to_i } + .select(&:positive?) + .uniq + end + + # NOTE: Delegates to User#merged_plugin_locals which handles webhook freshness + # (preferring api['merge_variables'] over stale locals for webhook plugins). + def selected_plugins_data + return {} if selected_plugin_ids.empty? + + @_selected_plugin_data ||= user.merged_plugin_locals(plugin_setting_ids: selected_plugin_ids) + end + + # --- Image Generation --- + + def generate_scene + { + image_url: call_gemini_api(build_prompt(selected_plugins_data)), + prompt_hash: current_prompt_hash + } + end + + def build_prompt(data) + <<~PROMPT + Generate a single image for an e-ink display in #{orientation} orientation where data is naturally integrated into the scene. + + The data is the reason this image exists — it must be prominent, legible, and accurate. But it should feel like it belongs in the world, not pasted on top. + + #{scene_section} + Available data — each key is a data source name: + #{data.to_json} + + Data instructions: + #{data_instructions_text} + #{custom_text_section} + How to integrate data: + - Data should appear as physical objects in the scene (signs, displays, labels, etc.) + - Use LARGE, bold text for key values — legibility is the top priority + - Pair graphical representations with their numeric values + - Most important data should be largest and most central + - Group related data together + + Requirements: + - #{palette_prompt_section} + - Optimized for e-ink display + - High contrast with sharp edges — no gradients or soft shadows + - All text, numbers, and data must be clearly legible even at small display sizes + - Compose for #{orientation} #{aspect_ratio} aspect ratio + PROMPT + end + + # --- Gemini API --- + + def call_gemini_api(prompt) + parsed = gemini_request(gemini_url, gemini_body(prompt)) + image_part = parsed.dig('candidates', 0, 'content', 'parts') + &.find { |p| p['inlineData'] } + raise StandardError, "No image data in response" unless image_part + + upload_to_storage(image_part.dig('inlineData', 'data'), image_part.dig('inlineData', 'mimeType')) + end + + # NOTE: Retries once on timeout using recursion to avoid mutable counter state. + def gemini_request(url, body, retried: false) + response = HTTParty.post(url, body: body, headers: gemini_headers, timeout: 60) + parsed = response.parsed_response + raise StandardError, "Empty response from Gemini API" if parsed.nil? + raise StandardError, parsed.dig('error', 'message') || 'Unknown API error' if parsed['error'] + + parsed + rescue Net::ReadTimeout, Net::OpenTimeout + raise if retried + + sleep 1 + gemini_request(url, body, retried: true) + end + + # NOTE: Reuses existing blob when possible to avoid orphaned blobs in storage. + # New attachments need filename/content_type; existing blobs just need the bytes replaced. + def upload_to_storage(base64_data, mime_type) + binary = Base64.decode64(base64_data) + + if plugin_setting.temp_image.attached? + plugin_setting.temp_image.blob.upload(StringIO.new(binary), identify: true) + plugin_setting.temp_image.blob.save! + else + plugin_setting.temp_image.attach( + io: StringIO.new(binary), + filename: "nano-banana-#{plugin_setting.id}.#{mime_type.split('/').last}", + content_type: mime_type + ) + end + + Rails.application.routes.url_helpers.url_for(plugin_setting.temp_image) + end + + def gemini_url + format(GEMINI_URL, gemini_model) + end + + def gemini_headers + { + "Content-Type" => "application/json", + "x-goog-api-key" => api_key + } + end + + def gemini_body(prompt) + { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + imageConfig: { aspectRatio: aspect_ratio } + } + }.to_json + end + + # --- Error Handling --- + + def handle_error(error) + logger.error { "NanoBananaDashboard error: #{error.message}" } + + if cached_image_url.present? + cached_result + else + { errors: user_friendly_error(error) } + end + end + + def user_friendly_error(error) + case error.message + when /API_KEY_INVALID/i, /invalid.*key/i then "Invalid API key. Check your Gemini settings." + when /RATE_LIMIT_EXCEEDED/i, /rate_limit/i then "API rate limit exceeded. Try again later." + when /SAFETY/i, /content_policy/i then "Could not generate due to content policy." + when /billing/i then "Gemini billing issue. Check your account." + when /No image data/i then "No image was generated. Try a different prompt." + else "Image generation failed. Please try again." + end + end + + # --- Settings --- + + def api_key = settings['api_key'] + + def gemini_model = GEMINI_MODELS[settings['model']] || GEMINI_MODELS['flash'] + + def scene_prompt_text + style = settings['style_prompt'] + return SCENE_PROMPTS.except('none').values.compact.sample if style == 'random' + + SCENE_PROMPTS[style] || SCENE_PROMPTS['none'] + end + + def scene_section + parts = [ + ("Scene: #{scene_prompt_text}" if scene_prompt_text), + ("Additional style: #{settings['style_custom']}" if settings['style_custom'].present?) + ].compact + + parts.any? ? "#{parts.join("\n")}\n" : '' + end + + def custom_text_section + text = settings['custom_text'] + return '' unless text.present? + + "\nCustom text to display prominently in the scene: \"#{text}\"\n" + end + + def data_instructions_text + return "Data usage instructions: #{settings['data_instructions']}" if settings['data_instructions'].present? + + 'Feature the most important data points prominently. Use your judgment on what to emphasize.' + end + + # --- Color Palette --- + + def palette_id = settings['color_palette'].presence || 'bw' + + # CONTEXT: Returns hex array for constrained palettes, nil for wide-spectrum + # (gray-256, color-12bit, color-24bit) where listing colors is meaningless. + def palette_hex_colors + palette = Screen::Palette.find(palette_id) + return palette.colors if palette.color? && palette.colors.present? + + grayscale_hex_values(palette.grays) if palette.grayscale? && palette.grays <= 16 + rescue ArgumentError + grayscale_hex_values(2) + end + + # NOTE: Matches ImageMagick posterize — evenly spaced gray levels. + def grayscale_hex_values(count) + # rubocop:disable Style/FormatStringToken + (0...count).map { |i| format('#%02X%02X%02X', *([(i * 255.0 / (count - 1)).round] * 3)) } + # rubocop:enable Style/FormatStringToken + end + + def palette_prompt_section + colors = palette_hex_colors + return "Use ONLY these exact colors (no others): #{colors.join(', ')}" if colors + return 'Full grayscale spectrum' if palette_id.start_with?('gray') + + 'Full color spectrum' + end + + # --- Aspect Ratio & Orientation --- + + def aspect_ratio + ratio = settings['aspect_ratio'] + VALID_ASPECT_RATIOS.include?(ratio) ? ratio : '16:9' + end + + def orientation + w, h = aspect_ratio.split(':').map(&:to_i) + return 'landscape' if w > h + return 'portrait' if h > w + + 'square' + end + end +end diff --git a/lib/nano_banana_dashboard/views/full.html.erb b/lib/nano_banana_dashboard/views/full.html.erb new file mode 100644 index 0000000..ec513e8 --- /dev/null +++ b/lib/nano_banana_dashboard/views/full.html.erb @@ -0,0 +1,9 @@ +<%= render 'plugins/image_display/full', + image_url: local_assigns[:image_url], + image_fit: 'contain', + error_config: local_assigns[:errors].present? ? { + icon: 'nano_banana_dashboard', + title: 'Nano Banana Dashboard', + error_title: 'Generation Failed', + error_message: local_assigns[:errors] + } : nil %> diff --git a/lib/nano_banana_dashboard/views/half_horizontal.html.erb b/lib/nano_banana_dashboard/views/half_horizontal.html.erb new file mode 100644 index 0000000..9f13e24 --- /dev/null +++ b/lib/nano_banana_dashboard/views/half_horizontal.html.erb @@ -0,0 +1,9 @@ +<%= render 'plugins/image_display/half_horizontal', + image_url: local_assigns[:image_url], + image_fit: 'contain', + error_config: local_assigns[:errors].present? ? { + icon: 'nano_banana_dashboard', + title: 'Nano Banana Dashboard', + error_title: 'Generation Failed', + error_message: local_assigns[:errors] + } : nil %> diff --git a/lib/nano_banana_dashboard/views/half_vertical.html.erb b/lib/nano_banana_dashboard/views/half_vertical.html.erb new file mode 100644 index 0000000..5e30857 --- /dev/null +++ b/lib/nano_banana_dashboard/views/half_vertical.html.erb @@ -0,0 +1,9 @@ +<%= render 'plugins/image_display/half_vertical', + image_url: local_assigns[:image_url], + image_fit: 'contain', + error_config: local_assigns[:errors].present? ? { + icon: 'nano_banana_dashboard', + title: 'Nano Banana Dashboard', + error_title: 'Generation Failed', + error_message: local_assigns[:errors] + } : nil %> diff --git a/lib/nano_banana_dashboard/views/image_display/_full.html.erb b/lib/nano_banana_dashboard/views/image_display/_full.html.erb new file mode 100644 index 0000000..78ec35d --- /dev/null +++ b/lib/nano_banana_dashboard/views/image_display/_full.html.erb @@ -0,0 +1,10 @@ +
+ <% if local_assigns[:image_url].present? || !local_assigns[:error_config].present? %> + <% fit_mode = local_assigns[:image_fit] || 'cover' %> +
+ <% else %> + <%= render 'plugins/errors/full', **error_config %> + <% end %> +
+ +<%= render 'plugins/image_display/common' %> diff --git a/lib/nano_banana_dashboard/views/image_display/_half_horizontal.html.erb b/lib/nano_banana_dashboard/views/image_display/_half_horizontal.html.erb new file mode 100644 index 0000000..507620f --- /dev/null +++ b/lib/nano_banana_dashboard/views/image_display/_half_horizontal.html.erb @@ -0,0 +1,10 @@ +
+ <% if local_assigns[:image_url].present? || !local_assigns[:error_config].present? %> + <% fit_mode = local_assigns[:image_fit] || 'cover' %> +
+ <% else %> + <%= render 'plugins/errors/half_horizontal', **error_config %> + <% end %> +
+ +<%= render 'plugins/image_display/common' %> diff --git a/lib/nano_banana_dashboard/views/image_display/_half_vertical.html.erb b/lib/nano_banana_dashboard/views/image_display/_half_vertical.html.erb new file mode 100644 index 0000000..503e478 --- /dev/null +++ b/lib/nano_banana_dashboard/views/image_display/_half_vertical.html.erb @@ -0,0 +1,10 @@ +
+ <% if local_assigns[:image_url].present? || !local_assigns[:error_config].present? %> + <% fit_mode = local_assigns[:image_fit] || 'cover' %> +
+ <% else %> + <%= render 'plugins/errors/half_vertical', **error_config %> + <% end %> +
+ +<%= render 'plugins/image_display/common' %> diff --git a/lib/nano_banana_dashboard/views/image_display/_quadrant.html.erb b/lib/nano_banana_dashboard/views/image_display/_quadrant.html.erb new file mode 100644 index 0000000..c4b43ff --- /dev/null +++ b/lib/nano_banana_dashboard/views/image_display/_quadrant.html.erb @@ -0,0 +1,10 @@ +
+ <% if local_assigns[:image_url].present? || !local_assigns[:error_config].present? %> + <% fit_mode = local_assigns[:image_fit] || 'cover' %> +
+ <% else %> + <%= render 'plugins/errors/quadrant', **error_config %> + <% end %> +
+ +<%= render 'plugins/image_display/common' %> diff --git a/lib/nano_banana_dashboard/views/quadrant.html.erb b/lib/nano_banana_dashboard/views/quadrant.html.erb new file mode 100644 index 0000000..9535880 --- /dev/null +++ b/lib/nano_banana_dashboard/views/quadrant.html.erb @@ -0,0 +1,9 @@ +<%= render 'plugins/image_display/quadrant', + image_url: local_assigns[:image_url], + image_fit: 'contain', + error_config: local_assigns[:errors].present? ? { + icon: 'nano_banana_dashboard', + title: 'Nano Banana Dashboard', + error_title: 'Generation Failed', + error_message: local_assigns[:errors] + } : nil %>