collavre 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +221 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/collavre/actiontext.css +577 -0
- data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
- data/app/assets/stylesheets/collavre/creatives.css +559 -0
- data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
- data/app/assets/stylesheets/collavre/popup.css +160 -0
- data/app/assets/stylesheets/collavre/print.css +37 -0
- data/app/assets/stylesheets/collavre/slide_view.css +79 -0
- data/app/assets/stylesheets/collavre/user_menu.css +34 -0
- data/app/channels/collavre/comments_presence_channel.rb +54 -0
- data/app/channels/collavre/slide_view_channel.rb +11 -0
- data/app/channels/collavre/topics_channel.rb +12 -0
- data/app/components/collavre/avatar_component.html.erb +15 -0
- data/app/components/collavre/avatar_component.rb +59 -0
- data/app/components/collavre/inbox/badge_component.html.erb +6 -0
- data/app/components/collavre/inbox/badge_component.rb +18 -0
- data/app/components/collavre/plans_timeline_component.html.erb +14 -0
- data/app/components/collavre/plans_timeline_component.rb +56 -0
- data/app/components/collavre/popup_menu_component.html.erb +6 -0
- data/app/components/collavre/popup_menu_component.rb +30 -0
- data/app/components/collavre/progress_filter_component.html.erb +5 -0
- data/app/components/collavre/progress_filter_component.rb +10 -0
- data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
- data/app/components/collavre/user_mention_menu_component.rb +8 -0
- data/app/controllers/collavre/application_controller.rb +15 -0
- data/app/controllers/collavre/attachments_controller.rb +44 -0
- data/app/controllers/collavre/calendar_events_controller.rb +15 -0
- data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
- data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +464 -0
- data/app/controllers/collavre/contacts_controller.rb +10 -0
- data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
- data/app/controllers/collavre/creative_imports_controller.rb +24 -0
- data/app/controllers/collavre/creative_plans_controller.rb +69 -0
- data/app/controllers/collavre/creative_shares_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +535 -0
- data/app/controllers/collavre/devices_controller.rb +19 -0
- data/app/controllers/collavre/email_verifications_controller.rb +16 -0
- data/app/controllers/collavre/emails_controller.rb +11 -0
- data/app/controllers/collavre/github_auth_controller.rb +25 -0
- data/app/controllers/collavre/google_auth_controller.rb +43 -0
- data/app/controllers/collavre/inbox_items_controller.rb +64 -0
- data/app/controllers/collavre/invites_controller.rb +27 -0
- data/app/controllers/collavre/notion_auth_controller.rb +25 -0
- data/app/controllers/collavre/passwords_controller.rb +37 -0
- data/app/controllers/collavre/plans_controller.rb +110 -0
- data/app/controllers/collavre/sessions_controller.rb +57 -0
- data/app/controllers/collavre/topics_controller.rb +58 -0
- data/app/controllers/collavre/user_themes_controller.rb +58 -0
- data/app/controllers/collavre/users_controller.rb +390 -0
- data/app/helpers/collavre/application_helper.rb +4 -0
- data/app/helpers/collavre/comments_helper.rb +9 -0
- data/app/helpers/collavre/creatives_helper.rb +343 -0
- data/app/helpers/collavre/navigation_helper.rb +163 -0
- data/app/helpers/collavre/user_themes_helper.rb +4 -0
- data/app/javascript/collavre.js +26 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
- data/app/javascript/components/LinkPopup.jsx +112 -0
- data/app/javascript/components/creative_tree_row.js +503 -0
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
- data/app/javascript/controllers/click_target_controller.js +13 -0
- data/app/javascript/controllers/comment_controller.js +162 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
- data/app/javascript/controllers/comments/form_controller.js +530 -0
- data/app/javascript/controllers/comments/list_controller.js +715 -0
- data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
- data/app/javascript/controllers/comments/popup_controller.js +385 -0
- data/app/javascript/controllers/comments/presence_controller.js +311 -0
- data/app/javascript/controllers/comments/topics_controller.js +338 -0
- data/app/javascript/controllers/common_popup_controller.js +55 -0
- data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
- data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
- data/app/javascript/controllers/creatives/import_controller.js +116 -0
- data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
- data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
- data/app/javascript/controllers/creatives/tree_controller.js +218 -0
- data/app/javascript/controllers/index.js +79 -0
- data/app/javascript/controllers/link_creative_controller.js +91 -0
- data/app/javascript/controllers/popup_menu_controller.js +82 -0
- data/app/javascript/controllers/progress_filter_controller.js +35 -0
- data/app/javascript/controllers/reaction_picker_controller.js +107 -0
- data/app/javascript/controllers/share_invite_controller.js +15 -0
- data/app/javascript/controllers/share_user_search_controller.js +121 -0
- data/app/javascript/controllers/tabs_controller.js +43 -0
- data/app/javascript/creatives/drag_drop/dom.js +170 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
- data/app/javascript/creatives/drag_drop/indicator.js +35 -0
- data/app/javascript/creatives/drag_drop/operations.js +116 -0
- data/app/javascript/creatives/drag_drop/state.js +31 -0
- data/app/javascript/creatives/tree_renderer.js +248 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
- data/app/javascript/lib/api/creatives.js +79 -0
- data/app/javascript/lib/api/csrf_fetch.js +22 -0
- data/app/javascript/lib/api/drag_drop.js +31 -0
- data/app/javascript/lib/api/queue_manager.js +423 -0
- data/app/javascript/lib/apply_lexical_styles.js +15 -0
- data/app/javascript/lib/common_popup.js +195 -0
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
- data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
- data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
- data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
- data/app/javascript/lib/lexical/attachment_payload.js +293 -0
- data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
- data/app/javascript/lib/lexical/image_node.jsx +159 -0
- data/app/javascript/lib/lexical/style_attributes.js +40 -0
- data/app/javascript/lib/responsive_images.js +54 -0
- data/app/javascript/lib/turbo_stream_actions.js +33 -0
- data/app/javascript/lib/utils/markdown.js +23 -0
- data/app/javascript/modules/creative_guide.js +53 -0
- data/app/javascript/modules/creative_row_editor.js +1841 -0
- data/app/javascript/modules/creative_row_swipe.js +43 -0
- data/app/javascript/modules/creatives.js +15 -0
- data/app/javascript/modules/export_to_markdown.js +34 -0
- data/app/javascript/modules/inbox_panel.js +226 -0
- data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
- data/app/javascript/modules/mention_menu.js +77 -0
- data/app/javascript/modules/plans_menu.js +39 -0
- data/app/javascript/modules/plans_timeline.js +397 -0
- data/app/javascript/modules/share_modal.js +73 -0
- data/app/javascript/modules/share_user_popup.js +77 -0
- data/app/javascript/modules/slide_view.js +163 -0
- data/app/javascript/services/cable.js +32 -0
- data/app/javascript/slide_view.js +2 -0
- data/app/javascript/utils/caret_position.js +42 -0
- data/app/javascript/utils/clipboard.js +40 -0
- data/app/jobs/collavre/ai_agent_job.rb +27 -0
- data/app/jobs/collavre/inbox_summary_job.rb +24 -0
- data/app/jobs/collavre/notion_export_job.rb +30 -0
- data/app/jobs/collavre/notion_sync_job.rb +48 -0
- data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
- data/app/jobs/collavre/permission_cache_job.rb +71 -0
- data/app/jobs/collavre/push_notification_job.rb +86 -0
- data/app/mailers/collavre/application_mailer.rb +17 -0
- data/app/mailers/collavre/creative_mailer.rb +9 -0
- data/app/mailers/collavre/email_verification_mailer.rb +20 -0
- data/app/mailers/collavre/inbox_mailer.rb +19 -0
- data/app/mailers/collavre/invitation_mailer.rb +16 -0
- data/app/mailers/collavre/passwords_mailer.rb +10 -0
- data/app/models/collavre/activity_log.rb +13 -0
- data/app/models/collavre/application_record.rb +5 -0
- data/app/models/collavre/calendar_event.rb +20 -0
- data/app/models/collavre/comment.rb +307 -0
- data/app/models/collavre/comment_presence_store.rb +30 -0
- data/app/models/collavre/comment_reaction.rb +11 -0
- data/app/models/collavre/comment_read_pointer.rb +26 -0
- data/app/models/collavre/contact.rb +23 -0
- data/app/models/collavre/creative.rb +413 -0
- data/app/models/collavre/creative_expanded_state.rb +11 -0
- data/app/models/collavre/creative_share.rb +122 -0
- data/app/models/collavre/creative_shares_cache.rb +18 -0
- data/app/models/collavre/current.rb +14 -0
- data/app/models/collavre/device.rb +13 -0
- data/app/models/collavre/email.rb +14 -0
- data/app/models/collavre/github_account.rb +10 -0
- data/app/models/collavre/github_repository_link.rb +19 -0
- data/app/models/collavre/inbox_item.rb +95 -0
- data/app/models/collavre/invitation.rb +22 -0
- data/app/models/collavre/label.rb +47 -0
- data/app/models/collavre/mcp_tool.rb +30 -0
- data/app/models/collavre/notion_account.rb +17 -0
- data/app/models/collavre/notion_block_link.rb +10 -0
- data/app/models/collavre/notion_page_link.rb +19 -0
- data/app/models/collavre/plan.rb +20 -0
- data/app/models/collavre/session.rb +24 -0
- data/app/models/collavre/system_setting.rb +144 -0
- data/app/models/collavre/tag.rb +10 -0
- data/app/models/collavre/task.rb +10 -0
- data/app/models/collavre/task_action.rb +10 -0
- data/app/models/collavre/topic.rb +12 -0
- data/app/models/collavre/user.rb +174 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/models/collavre/variation.rb +5 -0
- data/app/models/collavre/webauthn_credential.rb +11 -0
- data/app/services/collavre/ai_agent_service.rb +193 -0
- data/app/services/collavre/ai_client.rb +183 -0
- data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
- data/app/services/collavre/auto_theme_generator.rb +198 -0
- data/app/services/collavre/comment_link_formatter.rb +60 -0
- data/app/services/collavre/comments/action_executor.rb +262 -0
- data/app/services/collavre/comments/action_validator.rb +58 -0
- data/app/services/collavre/comments/calendar_command.rb +97 -0
- data/app/services/collavre/comments/command_processor.rb +37 -0
- data/app/services/collavre/comments/mcp_command.rb +109 -0
- data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
- data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
- data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
- data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
- data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
- data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
- data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
- data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
- data/app/services/collavre/creatives/importer.rb +47 -0
- data/app/services/collavre/creatives/index_query.rb +191 -0
- data/app/services/collavre/creatives/path_exporter.rb +131 -0
- data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
- data/app/services/collavre/creatives/permission_checker.rb +42 -0
- data/app/services/collavre/creatives/plan_tagger.rb +53 -0
- data/app/services/collavre/creatives/progress_service.rb +89 -0
- data/app/services/collavre/creatives/reorderer.rb +187 -0
- data/app/services/collavre/creatives/tree_builder.rb +231 -0
- data/app/services/collavre/creatives/tree_formatter.rb +36 -0
- data/app/services/collavre/gemini_parent_recommender.rb +77 -0
- data/app/services/collavre/github/client.rb +112 -0
- data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
- data/app/services/collavre/github/pull_request_processor.rb +181 -0
- data/app/services/collavre/github/webhook_provisioner.rb +130 -0
- data/app/services/collavre/google_calendar_service.rb +149 -0
- data/app/services/collavre/link_preview_fetcher.rb +230 -0
- data/app/services/collavre/markdown_importer.rb +202 -0
- data/app/services/collavre/mcp_service.rb +217 -0
- data/app/services/collavre/notion_client.rb +231 -0
- data/app/services/collavre/notion_creative_exporter.rb +296 -0
- data/app/services/collavre/notion_service.rb +249 -0
- data/app/services/collavre/ppt_importer.rb +76 -0
- data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
- data/app/services/collavre/system_events/context_builder.rb +41 -0
- data/app/services/collavre/system_events/dispatcher.rb +19 -0
- data/app/services/collavre/system_events/router.rb +72 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
- data/app/views/admin/shared/_tabs.html.erb +4 -0
- data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
- data/app/views/collavre/comments/_comment.html.erb +147 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
- data/app/views/collavre/comments/_list.html.erb +10 -0
- data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
- data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
- data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
- data/app/views/collavre/creatives/_add_button.html.erb +20 -0
- data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
- data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
- data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
- data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
- data/app/views/collavre/creatives/_share_button.html.erb +55 -0
- data/app/views/collavre/creatives/edit.html.erb +4 -0
- data/app/views/collavre/creatives/index.html.erb +192 -0
- data/app/views/collavre/creatives/new.html.erb +4 -0
- data/app/views/collavre/creatives/show.html.erb +29 -0
- data/app/views/collavre/creatives/slide_view.html.erb +20 -0
- data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
- data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
- data/app/views/collavre/emails/index.html.erb +19 -0
- data/app/views/collavre/emails/show.html.erb +5 -0
- data/app/views/collavre/inbox_items/_item.html.erb +18 -0
- data/app/views/collavre/inbox_items/_items.html.erb +3 -0
- data/app/views/collavre/inbox_items/_list.html.erb +7 -0
- data/app/views/collavre/inbox_items/index.html.erb +1 -0
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
- data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
- data/app/views/collavre/invites/show.html.erb +8 -0
- data/app/views/collavre/passwords/edit.html.erb +12 -0
- data/app/views/collavre/passwords/new.html.erb +17 -0
- data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
- data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
- data/app/views/collavre/sessions/new.html.erb +27 -0
- data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
- data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
- data/app/views/collavre/shared/_navigation.html.erb +37 -0
- data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
- data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
- data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
- data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
- data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
- data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
- data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
- data/app/views/collavre/user_themes/index.html.erb +75 -0
- data/app/views/collavre/users/_app_header.html.erb +5 -0
- data/app/views/collavre/users/_contact_management.html.erb +77 -0
- data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
- data/app/views/collavre/users/edit_ai.html.erb +79 -0
- data/app/views/collavre/users/edit_password.html.erb +27 -0
- data/app/views/collavre/users/index.html.erb +80 -0
- data/app/views/collavre/users/new.html.erb +33 -0
- data/app/views/collavre/users/new_ai.html.erb +87 -0
- data/app/views/collavre/users/passkeys.html.erb +43 -0
- data/app/views/collavre/users/show.html.erb +143 -0
- data/app/views/inbox/badge_component/_count.html.erb +2 -0
- data/app/views/layouts/collavre/slide.html.erb +29 -0
- data/config/locales/comments.en.yml +114 -0
- data/config/locales/comments.ko.yml +110 -0
- data/config/locales/contacts.en.yml +16 -0
- data/config/locales/contacts.ko.yml +16 -0
- data/config/locales/creatives.en.yml +183 -0
- data/config/locales/creatives.ko.yml +164 -0
- data/config/locales/invites.en.yml +19 -0
- data/config/locales/invites.ko.yml +17 -0
- data/config/locales/notifications.en.yml +8 -0
- data/config/locales/notifications.ko.yml +8 -0
- data/config/locales/plans.en.yml +12 -0
- data/config/locales/plans.ko.yml +19 -0
- data/config/locales/themes.en.yml +29 -0
- data/config/locales/themes.ko.yml +27 -0
- data/config/locales/users.en.yml +151 -0
- data/config/locales/users.ko.yml +146 -0
- data/config/routes.rb +92 -0
- data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
- data/db/migrate/20250128110017_create_creatives.rb +11 -0
- data/db/migrate/20250128120122_create_users.rb +11 -0
- data/db/migrate/20250128120123_create_sessions.rb +11 -0
- data/db/migrate/20250128123633_create_subscribers.rb +10 -0
- data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
- data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
- data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
- data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
- data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
- data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
- data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
- data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
- data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
- data/db/migrate/20250530060200_create_tags.rb +9 -0
- data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
- data/db/migrate/20250531140142_create_labels.rb +12 -0
- data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
- data/db/migrate/20250601000000_create_comments.rb +10 -0
- data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
- data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
- data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
- data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
- data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
- data/db/migrate/20250611030138_create_invitations.rb +15 -0
- data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
- data/db/migrate/20250612150000_update_permissions.rb +43 -0
- data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
- data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
- data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
- data/db/migrate/20250620004558_create_emails.rb +13 -0
- data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
- data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
- data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
- data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
- data/db/migrate/20250714190000_create_devices.rb +17 -0
- data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
- data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
- data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
- data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
- data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
- data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
- data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
- data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
- data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
- data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
- data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
- data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
- data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
- data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
- data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
- data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
- data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
- data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
- data/db/migrate/20251001000001_create_contacts.rb +71 -0
- data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
- data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
- data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
- data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
- data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
- data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
- data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
- data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
- data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
- data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
- data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
- data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
- data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
- data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
- data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
- data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
- data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
- data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
- data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
- data/db/migrate/20251223072625_create_user_themes.rb +11 -0
- data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
- data/db/migrate/20251230113607_refactor_labels.rb +38 -0
- data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
- data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
- data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
- data/db/migrate/20260106160643_create_system_settings.rb +11 -0
- data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
- data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
- data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
- data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
- data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
- data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
- data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
- data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
- data/lib/collavre/configuration.rb +14 -0
- data/lib/collavre/engine.rb +77 -0
- data/lib/collavre/user_extensions.rb +29 -0
- data/lib/collavre/version.rb +3 -0
- data/lib/collavre.rb +26 -0
- data/lib/generators/collavre/install/install_generator.rb +105 -0
- data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
- data/lib/tasks/collavre_assets.rake +15 -0
- metadata +591 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class WebauthnCredential < ApplicationRecord
|
|
3
|
+
self.table_name = "webauthn_credentials"
|
|
4
|
+
|
|
5
|
+
belongs_to :user, class_name: "Collavre::User"
|
|
6
|
+
|
|
7
|
+
validates :webauthn_id, presence: true, uniqueness: true
|
|
8
|
+
validates :public_key, presence: true
|
|
9
|
+
validates :sign_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class AiAgentService
|
|
3
|
+
def initialize(task)
|
|
4
|
+
@task = task
|
|
5
|
+
@agent = task.agent
|
|
6
|
+
@context = task.trigger_event_payload
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
Current.set(user: @agent) do
|
|
11
|
+
# Log start action
|
|
12
|
+
log_action("start", { message: "Starting agent execution" })
|
|
13
|
+
|
|
14
|
+
# Prepare messages for AI
|
|
15
|
+
messages = build_messages
|
|
16
|
+
|
|
17
|
+
# Log prompt generation
|
|
18
|
+
log_action("prompt_generated", { messages: messages })
|
|
19
|
+
|
|
20
|
+
# Call AI Client
|
|
21
|
+
response_content = ""
|
|
22
|
+
|
|
23
|
+
# Enrich context for rendering
|
|
24
|
+
rendering_context = @context.dup
|
|
25
|
+
if @context.dig("creative", "id")
|
|
26
|
+
creative = Creative.find_by(id: @context["creative"]["id"])
|
|
27
|
+
rendering_context["creative"] = creative.as_json if creative
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
rendered_system_prompt = AiSystemPromptRenderer.new(
|
|
31
|
+
template: @agent.system_prompt,
|
|
32
|
+
context: rendering_context
|
|
33
|
+
).render
|
|
34
|
+
|
|
35
|
+
# Create a placeholder comment to stream into
|
|
36
|
+
target_comment_id = @context.dig("comment", "id")
|
|
37
|
+
reply_comment = nil
|
|
38
|
+
|
|
39
|
+
if target_comment_id
|
|
40
|
+
original_comment = Comment.find_by(id: target_comment_id)
|
|
41
|
+
if original_comment
|
|
42
|
+
reply_comment = original_comment.creative.comments.create!(
|
|
43
|
+
content: "...", # Placeholder
|
|
44
|
+
user: @agent,
|
|
45
|
+
topic_id: original_comment.topic_id
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# we may pass event payload also to the AI client for more context if needed - TODO
|
|
51
|
+
client = AiClient.new(
|
|
52
|
+
vendor: @agent.llm_vendor,
|
|
53
|
+
model: @agent.llm_model,
|
|
54
|
+
system_prompt: rendered_system_prompt,
|
|
55
|
+
llm_api_key: @agent.llm_api_key,
|
|
56
|
+
context: {
|
|
57
|
+
creative: @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil,
|
|
58
|
+
user: @agent,
|
|
59
|
+
comment: reply_comment || (@context.dig("comment", "id") ? Comment.find_by(id: @context["comment"]["id"]) : nil)
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
client.chat(messages, tools: @agent.tools || []) do |delta|
|
|
64
|
+
response_content += delta
|
|
65
|
+
|
|
66
|
+
# Stream updates to the comment
|
|
67
|
+
if reply_comment
|
|
68
|
+
# We use update_column to avoid triggering full model callbacks/validations on every chunk
|
|
69
|
+
# but we *do* want to broadcast the update.
|
|
70
|
+
# However, calling 'update' trigger callbacks which might be heavy.
|
|
71
|
+
# Let's try direct broadcast or a lighter update.
|
|
72
|
+
# For now, let's just update the content.
|
|
73
|
+
# To avoid being too chatty we could throttle, but let's try direct updates first.
|
|
74
|
+
|
|
75
|
+
reply_comment.update_column(:content, response_content)
|
|
76
|
+
|
|
77
|
+
# Manually trigger broadcast for the content update
|
|
78
|
+
# We use broadcast_update_to to immediately stream the update
|
|
79
|
+
reply_comment.broadcast_update_to([ reply_comment.creative, :comments ], partial: "collavre/comments/comment")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Log completion
|
|
84
|
+
log_action("completion", { response: response_content })
|
|
85
|
+
|
|
86
|
+
# Final save to ensure everything is consistent and trigger final callbacks
|
|
87
|
+
if reply_comment
|
|
88
|
+
if response_content.present?
|
|
89
|
+
reply_comment.update!(content: response_content)
|
|
90
|
+
log_action("reply_created", { comment_id: reply_comment.id, content: response_content })
|
|
91
|
+
else
|
|
92
|
+
reply_comment.destroy!
|
|
93
|
+
end
|
|
94
|
+
elsif target_comment_id && response_content.present?
|
|
95
|
+
# Fallback if creation failed earlier or logic changed
|
|
96
|
+
reply_to_comment(target_comment_id, response_content)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def log_action(type, payload, result = nil)
|
|
104
|
+
@task.task_actions.create!(
|
|
105
|
+
action_type: type,
|
|
106
|
+
payload: payload,
|
|
107
|
+
result: result,
|
|
108
|
+
status: "done"
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_messages
|
|
113
|
+
# This logic mimics the old AiResponder but adapts to the new context structure
|
|
114
|
+
# We might need to fetch the creative and history based on context
|
|
115
|
+
|
|
116
|
+
messages = []
|
|
117
|
+
|
|
118
|
+
# Add context-specific messages
|
|
119
|
+
# For comment_created, we want the creative context and chat history
|
|
120
|
+
|
|
121
|
+
if @context["creative"]
|
|
122
|
+
# We might need to re-fetch creative to get the full markdown if it's not in context
|
|
123
|
+
# But for efficiency, let's assume we fetch it if ID is present
|
|
124
|
+
creative_id = @context.dig("creative", "id")
|
|
125
|
+
if creative_id
|
|
126
|
+
creative = Creative.find_by(id: creative_id)
|
|
127
|
+
if creative
|
|
128
|
+
markdown = ApplicationController.helpers.render_creative_tree_markdown([ creative ], 1, true)
|
|
129
|
+
messages << { role: "user", parts: [ { text: "Creative:\n#{markdown}" } ] }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Add chat history
|
|
135
|
+
if @context.dig("creative", "id")
|
|
136
|
+
creative_id = @context["creative"]["id"]
|
|
137
|
+
# Fetch comments for context, excluding private ones unless owned by the user
|
|
138
|
+
# We need to be careful about which comments to include.
|
|
139
|
+
# For now, let's include non-private comments.
|
|
140
|
+
|
|
141
|
+
# We need to know who the "user" is to determine roles.
|
|
142
|
+
# In the new system, the agent is @agent.
|
|
143
|
+
|
|
144
|
+
# We need to filter by topic_id to maintain conversation context
|
|
145
|
+
trigger_comment_id = @context.dig("comment", "id")
|
|
146
|
+
trigger_comment = Comment.find_by(id: trigger_comment_id)
|
|
147
|
+
topic_id = trigger_comment&.topic_id
|
|
148
|
+
|
|
149
|
+
Comment.where(creative_id: creative_id, private: false)
|
|
150
|
+
.where(topic_id: topic_id)
|
|
151
|
+
.order(created_at: :desc)
|
|
152
|
+
.limit(50) # Limit history to avoid context window issues
|
|
153
|
+
.reverse # Re-order to chronological for the AI
|
|
154
|
+
.each do |c|
|
|
155
|
+
next if c.id == @context.dig("comment", "id") # Skip the current trigger comment if it's in the list (it shouldn't be usually if we query right, but good to be safe)
|
|
156
|
+
|
|
157
|
+
role = (c.user_id == @agent.id) ? "model" : "user"
|
|
158
|
+
content = c.content
|
|
159
|
+
|
|
160
|
+
# Strip mentions of the agent from user messages to clean up context
|
|
161
|
+
if role == "user"
|
|
162
|
+
if content.match?(/\A@#{Regexp.escape(@agent.name)}:/i)
|
|
163
|
+
content = content.sub(/\A@#{Regexp.escape(@agent.name)}:\s*/i, "")
|
|
164
|
+
elsif content.match?(/\A@#{Regexp.escape(@agent.name)}\s+/i)
|
|
165
|
+
content = content.sub(/\A@#{Regexp.escape(@agent.name)}\s+/i, "")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
messages << { role: role, parts: [ { text: content } ] }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Add the trigger payload
|
|
174
|
+
payload_text = @context.dig("comment", "content") || @context.to_json
|
|
175
|
+
messages << { role: "user", parts: [ { text: payload_text } ] }
|
|
176
|
+
|
|
177
|
+
messages
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def reply_to_comment(comment_id, content)
|
|
181
|
+
original_comment = Comment.find_by(id: comment_id)
|
|
182
|
+
return unless original_comment
|
|
183
|
+
|
|
184
|
+
reply = original_comment.creative.comments.create!(
|
|
185
|
+
content: content,
|
|
186
|
+
user: @agent,
|
|
187
|
+
topic_id: original_comment.topic_id
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
log_action("reply_created", { comment_id: reply.id, content: content })
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class AiClient
|
|
3
|
+
SYSTEM_INSTRUCTIONS = <<~PROMPT.freeze
|
|
4
|
+
You are a senior expert teammate. Respond:
|
|
5
|
+
- Be concise and focus on the essentials (avoid unnecessary verbosity).
|
|
6
|
+
- Use short bullet points only when helpful.
|
|
7
|
+
- State only what you're confident about; briefly note any uncertainty.
|
|
8
|
+
- Respond in the asker's language (prefer the latest user message). Keep code and error messages in their original form.
|
|
9
|
+
PROMPT
|
|
10
|
+
|
|
11
|
+
def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, context: {})
|
|
12
|
+
@vendor = vendor
|
|
13
|
+
@model = model
|
|
14
|
+
@system_prompt = system_prompt
|
|
15
|
+
@llm_api_key = llm_api_key
|
|
16
|
+
@context = context
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def chat(contents, tools: [], &block)
|
|
20
|
+
response_content = +""
|
|
21
|
+
error_message = nil
|
|
22
|
+
input_tokens = nil
|
|
23
|
+
output_tokens = nil
|
|
24
|
+
|
|
25
|
+
# For now, we assume the API key is in the environment variable GEMINI_API_KEY
|
|
26
|
+
# In a real generic implementation, we might need to fetch keys based on vendor.
|
|
27
|
+
# Since the user request mentioned "ruby_llm", we try to use it.
|
|
28
|
+
# However, RubyLLM configuration in this project seems to be static in initializer.
|
|
29
|
+
# We might need to adjust RubyLLM usage to be dynamic if possible, or just support Gemini for now via RubyLLM
|
|
30
|
+
# but allowing model configuration.
|
|
31
|
+
|
|
32
|
+
# Current RubyLLM initializer:
|
|
33
|
+
# RubyLLM.configure do |config|
|
|
34
|
+
# config.gemini_api_key = ENV["GEMINI_API_KEY"]
|
|
35
|
+
# end
|
|
36
|
+
|
|
37
|
+
# We can use RubyLLM.context to override config per request if needed,
|
|
38
|
+
# but for now we'll stick to the pattern in GeminiChatClient but make it slightly more generic
|
|
39
|
+
# if RubyLLM supports other vendors.
|
|
40
|
+
|
|
41
|
+
# NOTE: The current requirement implies we should support what RubyLLM supports.
|
|
42
|
+
# If the user enters vendor='google', we use Gemini.
|
|
43
|
+
|
|
44
|
+
# For now, we assume the API key is in the environment variable GEMINI_API_KEY
|
|
45
|
+
# In a real generic implementation, we might need to fetch keys based on vendor.
|
|
46
|
+
# Since the user request mentioned "ruby_llm", we try to use it.
|
|
47
|
+
# Previously the method returned early unless vendor was "google" which caused AI responses
|
|
48
|
+
# to be omitted for agents with a different or nil vendor. We now proceed for any vendor
|
|
49
|
+
# and log a warning if the vendor is unsupported.
|
|
50
|
+
|
|
51
|
+
normalized_vendor = vendor.to_s.downcase
|
|
52
|
+
unless %w[google gemini].include?(normalized_vendor)
|
|
53
|
+
Rails.logger.warn "Unsupported LLM vendor '#{@vendor}'. Attempting to use default (google)."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
conversation = build_conversation(tools)
|
|
57
|
+
add_messages(conversation, contents)
|
|
58
|
+
|
|
59
|
+
response = conversation.complete do |chunk|
|
|
60
|
+
delta = extract_chunk_content(chunk)
|
|
61
|
+
next if delta.blank?
|
|
62
|
+
|
|
63
|
+
response_content << delta
|
|
64
|
+
yield delta if block_given?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if response
|
|
68
|
+
response_content = response.content.to_s if response.content.present?
|
|
69
|
+
|
|
70
|
+
# Extract token usage directly from response object (RubyLLM style)
|
|
71
|
+
if response.respond_to?(:input_tokens)
|
|
72
|
+
input_tokens = response.input_tokens
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if response.respond_to?(:output_tokens)
|
|
76
|
+
output_tokens = response.output_tokens
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
response_content.presence
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
error_message = e.message
|
|
83
|
+
Rails.logger.error "AI Client error: #{e.message}"
|
|
84
|
+
Rails.logger.debug e.backtrace.join("\n")
|
|
85
|
+
yield "AI Error: #{e.message}" if block_given?
|
|
86
|
+
nil
|
|
87
|
+
ensure
|
|
88
|
+
log_interaction(
|
|
89
|
+
messages: conversation.messages.to_a || Array(contents),
|
|
90
|
+
tools: conversation.tools.to_a,
|
|
91
|
+
response_content: response_content.presence,
|
|
92
|
+
error_message: error_message,
|
|
93
|
+
input_tokens: input_tokens,
|
|
94
|
+
output_tokens: output_tokens
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
attr_reader :vendor, :model, :system_prompt, :llm_api_key, :context
|
|
101
|
+
|
|
102
|
+
def build_conversation(tools = [])
|
|
103
|
+
# Using RubyLLM.context to ensure we can potentially switch keys if we had them.
|
|
104
|
+
# We explicitly set the key from ENV for now, as RubyLLM might not pick it up from global config in context?
|
|
105
|
+
# Or maybe the global config was not loaded in the runner context properly?
|
|
106
|
+
# Regardless, setting it here ensures it works like GeminiChatClient.
|
|
107
|
+
|
|
108
|
+
api_key = @llm_api_key.presence || ENV["GEMINI_API_KEY"]
|
|
109
|
+
RubyLLM.context { |config| config.gemini_api_key = api_key }
|
|
110
|
+
.chat(model: model).tap do |chat|
|
|
111
|
+
chat.with_instructions(system_prompt) if system_prompt.present?
|
|
112
|
+
chat.on_tool_call do |tool_call|
|
|
113
|
+
# You can do on_tool_call, on_tool_result hook by ruby llm provides
|
|
114
|
+
# Rails.logger.info("Tool call: #{JSON.pretty_generate(tool_call.to_h)}")
|
|
115
|
+
end
|
|
116
|
+
if tools.any?
|
|
117
|
+
# Resolve tool names to classes using the gem's helper
|
|
118
|
+
tool_classes = ::Tools::MetaToolService.ruby_llm_tools(tools)
|
|
119
|
+
chat.with_tools(*tool_classes, replace: true)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def add_messages(conversation, contents)
|
|
125
|
+
Array(contents).each do |message|
|
|
126
|
+
next if message.nil?
|
|
127
|
+
|
|
128
|
+
role = normalize_role(message)
|
|
129
|
+
next unless role
|
|
130
|
+
|
|
131
|
+
text = extract_message_text(message)
|
|
132
|
+
next if text.blank?
|
|
133
|
+
|
|
134
|
+
conversation.add_message(role:, content: text)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def normalize_role(message)
|
|
139
|
+
value = message[:role] || message["role"]
|
|
140
|
+
case value.to_s
|
|
141
|
+
when "user" then :user
|
|
142
|
+
when "model", "assistant" then :assistant
|
|
143
|
+
when "system" then :system
|
|
144
|
+
when "function", "tool" then :tool
|
|
145
|
+
else
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_message_text(message)
|
|
151
|
+
parts = message[:parts] || message["parts"]
|
|
152
|
+
return message[:text] || message["text"] if parts.nil?
|
|
153
|
+
|
|
154
|
+
Array(parts).map { |part| part[:text] || part["text"] }.compact.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def extract_chunk_content(chunk)
|
|
158
|
+
return if chunk.nil?
|
|
159
|
+
|
|
160
|
+
if chunk.respond_to?(:content)
|
|
161
|
+
chunk.content
|
|
162
|
+
else
|
|
163
|
+
chunk.to_s
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def log_interaction(messages:, tools:, response_content:, error_message: nil, input_tokens: nil, output_tokens: nil)
|
|
168
|
+
RubyLlmInteractionLogger.log(
|
|
169
|
+
vendor: @vendor,
|
|
170
|
+
model: @model,
|
|
171
|
+
messages: messages,
|
|
172
|
+
tools: tools,
|
|
173
|
+
response_content: response_content,
|
|
174
|
+
error_message: error_message,
|
|
175
|
+
creative: context&.dig(:creative),
|
|
176
|
+
user: context&.dig(:user),
|
|
177
|
+
comment: context&.dig(:comment),
|
|
178
|
+
input_tokens: input_tokens,
|
|
179
|
+
output_tokens: output_tokens
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class AiSystemPromptRenderer
|
|
3
|
+
def self.render(template:, context: {})
|
|
4
|
+
new(template:, context:).render
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def initialize(template:, context: {})
|
|
8
|
+
@template = template.presence || AiClient::SYSTEM_INSTRUCTIONS
|
|
9
|
+
@context = context
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render
|
|
13
|
+
parsed_template.render(stringified_context, render_options)
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
Rails.logger.warn("AI system prompt rendering failed: #{e.message}")
|
|
16
|
+
template
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :template, :context
|
|
22
|
+
|
|
23
|
+
def parsed_template
|
|
24
|
+
Liquid::Template.parse(template, error_mode: :warn)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stringified_context
|
|
28
|
+
context.deep_stringify_keys
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_options
|
|
32
|
+
{
|
|
33
|
+
strict_variables: false,
|
|
34
|
+
strict_filters: false
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class AutoThemeGenerator
|
|
3
|
+
REQUIRED_VARIABLES = %w[
|
|
4
|
+
--color-bg
|
|
5
|
+
--color-text
|
|
6
|
+
--color-link
|
|
7
|
+
--color-nav-bg
|
|
8
|
+
--color-section-bg
|
|
9
|
+
--color-btn-bg
|
|
10
|
+
--color-btn-text
|
|
11
|
+
--color-border
|
|
12
|
+
--color-muted
|
|
13
|
+
--color-complete
|
|
14
|
+
--color-chip-bg
|
|
15
|
+
--color-drag-over
|
|
16
|
+
--color-drag-over-edge
|
|
17
|
+
--hover-brightness
|
|
18
|
+
--color-badge-bg
|
|
19
|
+
--color-badge-text
|
|
20
|
+
--color-secondary-active
|
|
21
|
+
--color-secondary-background
|
|
22
|
+
--color-nav-btn-text
|
|
23
|
+
--color-chat-btn-text
|
|
24
|
+
--color-input-bg
|
|
25
|
+
--color-input-text
|
|
26
|
+
--color-nav-text
|
|
27
|
+
--creative-loading-emojis
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(client: default_client)
|
|
31
|
+
@client = client
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate(prompt)
|
|
35
|
+
system_prompt = <<~PROMPT
|
|
36
|
+
You are an expert UI/UX designer specialized in creating color themes for web applications.
|
|
37
|
+
Your task is to generate a JSON object containing CSS variables.
|
|
38
|
+
Generate a CSS theme as a JSON object based on the prompt: "#{prompt}".
|
|
39
|
+
The JSON must strictly contain ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
|
|
40
|
+
|
|
41
|
+
CRITICAL DESIGN RULES:
|
|
42
|
+
1. Use **only 'oklch()' color format** for all colors. Do not use hex, rgb, or hsl.
|
|
43
|
+
2. Ensure "--color-nav-btn-text" has High Contrast (WCAG AA/AAA) against "--color-bg" (which is used as the button background in the nav).
|
|
44
|
+
3. Ensure "--color-chat-btn-text" has High Contrast against "--color-section-bg" (where chat messages reside).
|
|
45
|
+
4. Ensure "--color-nav-text" has High Contrast against "--color-nav-bg".
|
|
46
|
+
5. Names of these text colors should be visually distinct from their background colors to ensure readability.
|
|
47
|
+
6. For "--creative-loading-emojis", provide a comma-separated string of exactly 6 emojis that match the theme mood (e.g., "🌵,🏜️,☀️,🦎,🌾,🐪").
|
|
48
|
+
7. Do not include any other keys or newlines.
|
|
49
|
+
8. Return valid JSON only.
|
|
50
|
+
|
|
51
|
+
The JSON object must strictly follow this structure:
|
|
52
|
+
{
|
|
53
|
+
"--color-bg": "oklch(95% 0.01 200)",
|
|
54
|
+
"--color-text": "oklch(20% 0.02 200)",
|
|
55
|
+
...
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
REQUIRED VARIABLES:
|
|
59
|
+
#{REQUIRED_VARIABLES.join("\n")}
|
|
60
|
+
|
|
61
|
+
GUIDELINES:
|
|
62
|
+
- Ensure high contrast between text and background.
|
|
63
|
+
- Maintain a consistent aesthetic suitable for the description.
|
|
64
|
+
- Return ONLY the JSON object. No markdown formatting, no explanations.
|
|
65
|
+
PROMPT
|
|
66
|
+
|
|
67
|
+
response = @client.chat([
|
|
68
|
+
{ role: :system, parts: [ { text: system_prompt } ] },
|
|
69
|
+
{ role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
parse_response(response)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def default_client
|
|
78
|
+
AiClient.new(
|
|
79
|
+
vendor: "google",
|
|
80
|
+
model: "gemini-2.5-flash",
|
|
81
|
+
system_prompt: nil
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_response(content)
|
|
86
|
+
return {} if content.blank?
|
|
87
|
+
|
|
88
|
+
# Remove markdown code blocks if present
|
|
89
|
+
cleaned = content.gsub(/^```json\s*/, "").gsub(/\s*```$/, "")
|
|
90
|
+
|
|
91
|
+
parsed = begin
|
|
92
|
+
JSON.parse(cleaned)
|
|
93
|
+
rescue JSON::ParserError => e
|
|
94
|
+
Rails.logger.error("AutoThemeGenerator JSON Error: #{e.message}. Content: #{content}")
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return {} unless parsed.is_a?(Hash)
|
|
99
|
+
|
|
100
|
+
process_variables(parsed)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def process_variables(variables)
|
|
104
|
+
variables.transform_values do |value|
|
|
105
|
+
if value.is_a?(String) && value.start_with?("oklch(")
|
|
106
|
+
convert_oklch_to_hex(value)
|
|
107
|
+
else
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def convert_oklch_to_hex(oklch_str)
|
|
114
|
+
# Parse oklch string: oklch(L C H [/ A])
|
|
115
|
+
# Supports %, deg, and alpha channel
|
|
116
|
+
# Example: oklch(60% 0.15 240deg / 0.5)
|
|
117
|
+
# Regex matches:
|
|
118
|
+
# 1. Lightness (number + optional %)
|
|
119
|
+
# 2. Chroma (number + optional %)
|
|
120
|
+
# 3. Hue (number + optional deg/rad/turn)
|
|
121
|
+
# 4. Optional alpha (number + optional %)
|
|
122
|
+
match = oklch_str.match(/oklch\(\s*([0-9.]+)%?\s+([0-9.]+)%?\s+([0-9.]+)(?:deg|rad|turn)?(?:\s*\/\s*([0-9.]+)%?)?\s*\)/)
|
|
123
|
+
return oklch_str unless match
|
|
124
|
+
|
|
125
|
+
l_val = match[1].to_f
|
|
126
|
+
l_val /= 100.0 if oklch_str.include?("#{match[1]}%") && !match[1].include?(".") # Simple heuristic, or trust regex groups if I separated units.
|
|
127
|
+
# Better to just handle the % if it was captured. My regex captures the number part separate from %.
|
|
128
|
+
# Actually, the regex above `([0-9.]+)%?` captures ONLY the number in group 1.
|
|
129
|
+
# So I need to check if the original string had % for that match.
|
|
130
|
+
# Let's refine parsing.
|
|
131
|
+
|
|
132
|
+
# Re-parsing carefully
|
|
133
|
+
l_raw = match[1]
|
|
134
|
+
l_val = l_raw.to_f
|
|
135
|
+
l_val /= 100.0 if oklch_str =~ /#{Regexp.escape(l_raw)}%/
|
|
136
|
+
|
|
137
|
+
c_val = match[2].to_f
|
|
138
|
+
# Chroma usually doesn't have %, but if it does (rare), handle it? Standard is number.
|
|
139
|
+
# Let's assume number.
|
|
140
|
+
|
|
141
|
+
h_val = match[3].to_f
|
|
142
|
+
# Hue is usually degrees if unitless or deg.
|
|
143
|
+
# If rad/turn, conversion needed? Standard oklch is degrees-like?
|
|
144
|
+
# CSS spec says oklch hue is angle. Deg is default.
|
|
145
|
+
|
|
146
|
+
# Alpha: match[4]
|
|
147
|
+
# We are currently ignoring alpha for 6-digit hex output.
|
|
148
|
+
|
|
149
|
+
# 1. OKLCH to OKLab
|
|
150
|
+
# h is in degrees, convert to radians
|
|
151
|
+
h_rad = h_val * Math::PI / 180.0
|
|
152
|
+
a_val = c_val * Math.cos(h_rad)
|
|
153
|
+
b_val = c_val * Math.sin(h_rad)
|
|
154
|
+
|
|
155
|
+
# 2. OKLab to Linear sRGB
|
|
156
|
+
# Matrix values from standard implementation
|
|
157
|
+
# Step 1: Lab to LMS (non-linear)
|
|
158
|
+
l_non_linear = l_val + 0.3963377774 * a_val + 0.2158037573 * b_val
|
|
159
|
+
m_non_linear = l_val - 0.1055613458 * a_val - 0.0638541728 * b_val
|
|
160
|
+
s_non_linear = l_val - 0.0894841775 * a_val - 1.2914855480 * b_val
|
|
161
|
+
|
|
162
|
+
# Step 2: Cube to get Linear LMS
|
|
163
|
+
l = l_non_linear ** 3
|
|
164
|
+
m = m_non_linear ** 3
|
|
165
|
+
s = s_non_linear ** 3
|
|
166
|
+
|
|
167
|
+
# Step 3: LMS to Linear sRGB
|
|
168
|
+
r_linear = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
|
|
169
|
+
g_linear = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
|
|
170
|
+
b_linear = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
|
|
171
|
+
|
|
172
|
+
# 3. Linear sRGB to sRGB (Gamma correction)
|
|
173
|
+
r = linear_srgb_to_srgb(r_linear)
|
|
174
|
+
g = linear_srgb_to_srgb(g_linear)
|
|
175
|
+
b = linear_srgb_to_srgb(b_linear)
|
|
176
|
+
|
|
177
|
+
# 4. To Hex
|
|
178
|
+
to_hex(r, g, b)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def linear_srgb_to_srgb(c)
|
|
182
|
+
val = if c <= 0.0031308
|
|
183
|
+
12.92 * c
|
|
184
|
+
else
|
|
185
|
+
1.055 * (c ** (1.0 / 2.4)) - 0.055
|
|
186
|
+
end
|
|
187
|
+
# Clamp between 0 and 1
|
|
188
|
+
[ [ val, 0.0 ].max, 1.0 ].min
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def to_hex(r, g, b)
|
|
192
|
+
r_int = (r * 255).round
|
|
193
|
+
g_int = (g * 255).round
|
|
194
|
+
b_int = (b * 255).round
|
|
195
|
+
sprintf("#%02x%02x%02x", r_int, g_int, b_int)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
class CommentLinkFormatter
|
|
5
|
+
URL_REGEX = URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
|
6
|
+
TRAILING_PUNCTUATION = ".,!?;:".freeze
|
|
7
|
+
|
|
8
|
+
def initialize(content, metadata_fetcher: nil, logger: Rails.logger)
|
|
9
|
+
@content = content.to_s
|
|
10
|
+
@metadata_fetcher = metadata_fetcher || method(:default_fetch_metadata)
|
|
11
|
+
@logger = logger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format
|
|
15
|
+
return @content if @content.blank?
|
|
16
|
+
|
|
17
|
+
@content.gsub(URL_REGEX) do |match|
|
|
18
|
+
match_data = Regexp.last_match
|
|
19
|
+
url, trailing = strip_trailing_punctuation(match)
|
|
20
|
+
next match if markdown_link?(match_data.pre_match)
|
|
21
|
+
|
|
22
|
+
metadata = fetch_metadata(url)
|
|
23
|
+
title = metadata[:title].presence
|
|
24
|
+
next match if title.blank?
|
|
25
|
+
|
|
26
|
+
"[#{title}](#{url})#{trailing}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def strip_trailing_punctuation(url)
|
|
33
|
+
trailing = ""
|
|
34
|
+
while url.length.positive? && TRAILING_PUNCTUATION.include?(url[-1])
|
|
35
|
+
trailing = url[-1] + trailing
|
|
36
|
+
url = url[0...-1]
|
|
37
|
+
end
|
|
38
|
+
[ url, trailing ]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def markdown_link?(pre_match)
|
|
42
|
+
return false unless pre_match
|
|
43
|
+
pre_match =~ /\[[^\]]*\]\([^)]*$/
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch_metadata(url)
|
|
47
|
+
@metadata_cache ||= {}
|
|
48
|
+
return @metadata_cache[url] if @metadata_cache.key?(url)
|
|
49
|
+
|
|
50
|
+
@metadata_cache[url] = @metadata_fetcher.call(url)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
@logger&.warn("Failed to fetch link metadata for #{url}: #{e.class} #{e.message}")
|
|
53
|
+
@metadata_cache[url] = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def default_fetch_metadata(url)
|
|
57
|
+
LinkPreviewFetcher.fetch(url)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|