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.
Files changed (410) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +221 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/stylesheets/collavre/actiontext.css +577 -0
  5. data/app/assets/stylesheets/collavre/activity_logs.css +99 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +692 -0
  7. data/app/assets/stylesheets/collavre/creatives.css +559 -0
  8. data/app/assets/stylesheets/collavre/dark_mode.css +118 -0
  9. data/app/assets/stylesheets/collavre/mention_menu.css +43 -0
  10. data/app/assets/stylesheets/collavre/popup.css +160 -0
  11. data/app/assets/stylesheets/collavre/print.css +37 -0
  12. data/app/assets/stylesheets/collavre/slide_view.css +79 -0
  13. data/app/assets/stylesheets/collavre/user_menu.css +34 -0
  14. data/app/channels/collavre/comments_presence_channel.rb +54 -0
  15. data/app/channels/collavre/slide_view_channel.rb +11 -0
  16. data/app/channels/collavre/topics_channel.rb +12 -0
  17. data/app/components/collavre/avatar_component.html.erb +15 -0
  18. data/app/components/collavre/avatar_component.rb +59 -0
  19. data/app/components/collavre/inbox/badge_component.html.erb +6 -0
  20. data/app/components/collavre/inbox/badge_component.rb +18 -0
  21. data/app/components/collavre/plans_timeline_component.html.erb +14 -0
  22. data/app/components/collavre/plans_timeline_component.rb +56 -0
  23. data/app/components/collavre/popup_menu_component.html.erb +6 -0
  24. data/app/components/collavre/popup_menu_component.rb +30 -0
  25. data/app/components/collavre/progress_filter_component.html.erb +5 -0
  26. data/app/components/collavre/progress_filter_component.rb +10 -0
  27. data/app/components/collavre/user_mention_menu_component.html.erb +3 -0
  28. data/app/components/collavre/user_mention_menu_component.rb +8 -0
  29. data/app/controllers/collavre/application_controller.rb +15 -0
  30. data/app/controllers/collavre/attachments_controller.rb +44 -0
  31. data/app/controllers/collavre/calendar_events_controller.rb +15 -0
  32. data/app/controllers/collavre/comment_read_pointers_controller.rb +80 -0
  33. data/app/controllers/collavre/comments/activity_logs_controller.rb +26 -0
  34. data/app/controllers/collavre/comments/reactions_controller.rb +82 -0
  35. data/app/controllers/collavre/comments_controller.rb +464 -0
  36. data/app/controllers/collavre/contacts_controller.rb +10 -0
  37. data/app/controllers/collavre/creative_expanded_states_controller.rb +27 -0
  38. data/app/controllers/collavre/creative_imports_controller.rb +24 -0
  39. data/app/controllers/collavre/creative_plans_controller.rb +69 -0
  40. data/app/controllers/collavre/creative_shares_controller.rb +79 -0
  41. data/app/controllers/collavre/creatives_controller.rb +535 -0
  42. data/app/controllers/collavre/devices_controller.rb +19 -0
  43. data/app/controllers/collavre/email_verifications_controller.rb +16 -0
  44. data/app/controllers/collavre/emails_controller.rb +11 -0
  45. data/app/controllers/collavre/github_auth_controller.rb +25 -0
  46. data/app/controllers/collavre/google_auth_controller.rb +43 -0
  47. data/app/controllers/collavre/inbox_items_controller.rb +64 -0
  48. data/app/controllers/collavre/invites_controller.rb +27 -0
  49. data/app/controllers/collavre/notion_auth_controller.rb +25 -0
  50. data/app/controllers/collavre/passwords_controller.rb +37 -0
  51. data/app/controllers/collavre/plans_controller.rb +110 -0
  52. data/app/controllers/collavre/sessions_controller.rb +57 -0
  53. data/app/controllers/collavre/topics_controller.rb +58 -0
  54. data/app/controllers/collavre/user_themes_controller.rb +58 -0
  55. data/app/controllers/collavre/users_controller.rb +390 -0
  56. data/app/helpers/collavre/application_helper.rb +4 -0
  57. data/app/helpers/collavre/comments_helper.rb +9 -0
  58. data/app/helpers/collavre/creatives_helper.rb +343 -0
  59. data/app/helpers/collavre/navigation_helper.rb +163 -0
  60. data/app/helpers/collavre/user_themes_helper.rb +4 -0
  61. data/app/javascript/collavre.js +26 -0
  62. data/app/javascript/components/InlineLexicalEditor.jsx +889 -0
  63. data/app/javascript/components/LinkPopup.jsx +112 -0
  64. data/app/javascript/components/creative_tree_row.js +503 -0
  65. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +95 -0
  66. data/app/javascript/components/plugins/image_upload_plugin.jsx +162 -0
  67. data/app/javascript/controllers/click_target_controller.js +13 -0
  68. data/app/javascript/controllers/comment_controller.js +162 -0
  69. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +68 -0
  70. data/app/javascript/controllers/comments/form_controller.js +530 -0
  71. data/app/javascript/controllers/comments/list_controller.js +715 -0
  72. data/app/javascript/controllers/comments/mention_menu_controller.js +41 -0
  73. data/app/javascript/controllers/comments/popup_controller.js +385 -0
  74. data/app/javascript/controllers/comments/presence_controller.js +311 -0
  75. data/app/javascript/controllers/comments/topics_controller.js +338 -0
  76. data/app/javascript/controllers/common_popup_controller.js +55 -0
  77. data/app/javascript/controllers/creatives/drag_drop_controller.js +45 -0
  78. data/app/javascript/controllers/creatives/expansion_controller.js +222 -0
  79. data/app/javascript/controllers/creatives/import_controller.js +116 -0
  80. data/app/javascript/controllers/creatives/row_editor_controller.js +8 -0
  81. data/app/javascript/controllers/creatives/select_mode_controller.js +231 -0
  82. data/app/javascript/controllers/creatives/set_plan_modal_controller.js +107 -0
  83. data/app/javascript/controllers/creatives/tree_controller.js +218 -0
  84. data/app/javascript/controllers/index.js +79 -0
  85. data/app/javascript/controllers/link_creative_controller.js +91 -0
  86. data/app/javascript/controllers/popup_menu_controller.js +82 -0
  87. data/app/javascript/controllers/progress_filter_controller.js +35 -0
  88. data/app/javascript/controllers/reaction_picker_controller.js +107 -0
  89. data/app/javascript/controllers/share_invite_controller.js +15 -0
  90. data/app/javascript/controllers/share_user_search_controller.js +121 -0
  91. data/app/javascript/controllers/tabs_controller.js +43 -0
  92. data/app/javascript/creatives/drag_drop/dom.js +170 -0
  93. data/app/javascript/creatives/drag_drop/event_handlers.js +846 -0
  94. data/app/javascript/creatives/drag_drop/indicator.js +35 -0
  95. data/app/javascript/creatives/drag_drop/operations.js +116 -0
  96. data/app/javascript/creatives/drag_drop/state.js +31 -0
  97. data/app/javascript/creatives/tree_renderer.js +248 -0
  98. data/app/javascript/lib/api/__tests__/queue_manager.test.js +153 -0
  99. data/app/javascript/lib/api/creatives.js +79 -0
  100. data/app/javascript/lib/api/csrf_fetch.js +22 -0
  101. data/app/javascript/lib/api/drag_drop.js +31 -0
  102. data/app/javascript/lib/api/queue_manager.js +423 -0
  103. data/app/javascript/lib/apply_lexical_styles.js +15 -0
  104. data/app/javascript/lib/common_popup.js +195 -0
  105. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +91 -0
  106. data/app/javascript/lib/lexical/__tests__/attachment_payload.test.js +194 -0
  107. data/app/javascript/lib/lexical/action_text_attachment_node.js +459 -0
  108. data/app/javascript/lib/lexical/attachment_node.jsx +170 -0
  109. data/app/javascript/lib/lexical/attachment_payload.js +293 -0
  110. data/app/javascript/lib/lexical/dom_attachment_utils.js +66 -0
  111. data/app/javascript/lib/lexical/image_node.jsx +159 -0
  112. data/app/javascript/lib/lexical/style_attributes.js +40 -0
  113. data/app/javascript/lib/responsive_images.js +54 -0
  114. data/app/javascript/lib/turbo_stream_actions.js +33 -0
  115. data/app/javascript/lib/utils/markdown.js +23 -0
  116. data/app/javascript/modules/creative_guide.js +53 -0
  117. data/app/javascript/modules/creative_row_editor.js +1841 -0
  118. data/app/javascript/modules/creative_row_swipe.js +43 -0
  119. data/app/javascript/modules/creatives.js +15 -0
  120. data/app/javascript/modules/export_to_markdown.js +34 -0
  121. data/app/javascript/modules/inbox_panel.js +226 -0
  122. data/app/javascript/modules/lexical_inline_editor.jsx +133 -0
  123. data/app/javascript/modules/mention_menu.js +77 -0
  124. data/app/javascript/modules/plans_menu.js +39 -0
  125. data/app/javascript/modules/plans_timeline.js +397 -0
  126. data/app/javascript/modules/share_modal.js +73 -0
  127. data/app/javascript/modules/share_user_popup.js +77 -0
  128. data/app/javascript/modules/slide_view.js +163 -0
  129. data/app/javascript/services/cable.js +32 -0
  130. data/app/javascript/slide_view.js +2 -0
  131. data/app/javascript/utils/caret_position.js +42 -0
  132. data/app/javascript/utils/clipboard.js +40 -0
  133. data/app/jobs/collavre/ai_agent_job.rb +27 -0
  134. data/app/jobs/collavre/inbox_summary_job.rb +24 -0
  135. data/app/jobs/collavre/notion_export_job.rb +30 -0
  136. data/app/jobs/collavre/notion_sync_job.rb +48 -0
  137. data/app/jobs/collavre/permission_cache_cleanup_job.rb +36 -0
  138. data/app/jobs/collavre/permission_cache_job.rb +71 -0
  139. data/app/jobs/collavre/push_notification_job.rb +86 -0
  140. data/app/mailers/collavre/application_mailer.rb +17 -0
  141. data/app/mailers/collavre/creative_mailer.rb +9 -0
  142. data/app/mailers/collavre/email_verification_mailer.rb +20 -0
  143. data/app/mailers/collavre/inbox_mailer.rb +19 -0
  144. data/app/mailers/collavre/invitation_mailer.rb +16 -0
  145. data/app/mailers/collavre/passwords_mailer.rb +10 -0
  146. data/app/models/collavre/activity_log.rb +13 -0
  147. data/app/models/collavre/application_record.rb +5 -0
  148. data/app/models/collavre/calendar_event.rb +20 -0
  149. data/app/models/collavre/comment.rb +307 -0
  150. data/app/models/collavre/comment_presence_store.rb +30 -0
  151. data/app/models/collavre/comment_reaction.rb +11 -0
  152. data/app/models/collavre/comment_read_pointer.rb +26 -0
  153. data/app/models/collavre/contact.rb +23 -0
  154. data/app/models/collavre/creative.rb +413 -0
  155. data/app/models/collavre/creative_expanded_state.rb +11 -0
  156. data/app/models/collavre/creative_share.rb +122 -0
  157. data/app/models/collavre/creative_shares_cache.rb +18 -0
  158. data/app/models/collavre/current.rb +14 -0
  159. data/app/models/collavre/device.rb +13 -0
  160. data/app/models/collavre/email.rb +14 -0
  161. data/app/models/collavre/github_account.rb +10 -0
  162. data/app/models/collavre/github_repository_link.rb +19 -0
  163. data/app/models/collavre/inbox_item.rb +95 -0
  164. data/app/models/collavre/invitation.rb +22 -0
  165. data/app/models/collavre/label.rb +47 -0
  166. data/app/models/collavre/mcp_tool.rb +30 -0
  167. data/app/models/collavre/notion_account.rb +17 -0
  168. data/app/models/collavre/notion_block_link.rb +10 -0
  169. data/app/models/collavre/notion_page_link.rb +19 -0
  170. data/app/models/collavre/plan.rb +20 -0
  171. data/app/models/collavre/session.rb +24 -0
  172. data/app/models/collavre/system_setting.rb +144 -0
  173. data/app/models/collavre/tag.rb +10 -0
  174. data/app/models/collavre/task.rb +10 -0
  175. data/app/models/collavre/task_action.rb +10 -0
  176. data/app/models/collavre/topic.rb +12 -0
  177. data/app/models/collavre/user.rb +174 -0
  178. data/app/models/collavre/user_theme.rb +10 -0
  179. data/app/models/collavre/variation.rb +5 -0
  180. data/app/models/collavre/webauthn_credential.rb +11 -0
  181. data/app/services/collavre/ai_agent_service.rb +193 -0
  182. data/app/services/collavre/ai_client.rb +183 -0
  183. data/app/services/collavre/ai_system_prompt_renderer.rb +38 -0
  184. data/app/services/collavre/auto_theme_generator.rb +198 -0
  185. data/app/services/collavre/comment_link_formatter.rb +60 -0
  186. data/app/services/collavre/comments/action_executor.rb +262 -0
  187. data/app/services/collavre/comments/action_validator.rb +58 -0
  188. data/app/services/collavre/comments/calendar_command.rb +97 -0
  189. data/app/services/collavre/comments/command_processor.rb +37 -0
  190. data/app/services/collavre/comments/mcp_command.rb +109 -0
  191. data/app/services/collavre/comments/mcp_command_builder.rb +32 -0
  192. data/app/services/collavre/creatives/filter_pipeline.rb +196 -0
  193. data/app/services/collavre/creatives/filters/assignee_filter.rb +30 -0
  194. data/app/services/collavre/creatives/filters/base_filter.rb +24 -0
  195. data/app/services/collavre/creatives/filters/comment_filter.rb +21 -0
  196. data/app/services/collavre/creatives/filters/date_filter.rb +58 -0
  197. data/app/services/collavre/creatives/filters/progress_filter.rb +25 -0
  198. data/app/services/collavre/creatives/filters/search_filter.rb +28 -0
  199. data/app/services/collavre/creatives/filters/tag_filter.rb +16 -0
  200. data/app/services/collavre/creatives/importer.rb +47 -0
  201. data/app/services/collavre/creatives/index_query.rb +191 -0
  202. data/app/services/collavre/creatives/path_exporter.rb +131 -0
  203. data/app/services/collavre/creatives/permission_cache_builder.rb +194 -0
  204. data/app/services/collavre/creatives/permission_checker.rb +42 -0
  205. data/app/services/collavre/creatives/plan_tagger.rb +53 -0
  206. data/app/services/collavre/creatives/progress_service.rb +89 -0
  207. data/app/services/collavre/creatives/reorderer.rb +187 -0
  208. data/app/services/collavre/creatives/tree_builder.rb +231 -0
  209. data/app/services/collavre/creatives/tree_formatter.rb +36 -0
  210. data/app/services/collavre/gemini_parent_recommender.rb +77 -0
  211. data/app/services/collavre/github/client.rb +112 -0
  212. data/app/services/collavre/github/pull_request_analyzer.rb +280 -0
  213. data/app/services/collavre/github/pull_request_processor.rb +181 -0
  214. data/app/services/collavre/github/webhook_provisioner.rb +130 -0
  215. data/app/services/collavre/google_calendar_service.rb +149 -0
  216. data/app/services/collavre/link_preview_fetcher.rb +230 -0
  217. data/app/services/collavre/markdown_importer.rb +202 -0
  218. data/app/services/collavre/mcp_service.rb +217 -0
  219. data/app/services/collavre/notion_client.rb +231 -0
  220. data/app/services/collavre/notion_creative_exporter.rb +296 -0
  221. data/app/services/collavre/notion_service.rb +249 -0
  222. data/app/services/collavre/ppt_importer.rb +76 -0
  223. data/app/services/collavre/ruby_llm_interaction_logger.rb +34 -0
  224. data/app/services/collavre/system_events/context_builder.rb +41 -0
  225. data/app/services/collavre/system_events/dispatcher.rb +19 -0
  226. data/app/services/collavre/system_events/router.rb +72 -0
  227. data/app/services/collavre/tools/creative_retrieval_service.rb +138 -0
  228. data/app/views/admin/shared/_tabs.html.erb +4 -0
  229. data/app/views/collavre/comments/_activity_log_details.html.erb +23 -0
  230. data/app/views/collavre/comments/_comment.html.erb +147 -0
  231. data/app/views/collavre/comments/_comments_popup.html.erb +46 -0
  232. data/app/views/collavre/comments/_list.html.erb +10 -0
  233. data/app/views/collavre/comments/_presence_avatars.html.erb +8 -0
  234. data/app/views/collavre/comments/_reaction_picker.html.erb +15 -0
  235. data/app/views/collavre/comments/_read_receipts.html.erb +19 -0
  236. data/app/views/collavre/creatives/_add_button.html.erb +20 -0
  237. data/app/views/collavre/creatives/_delete_button.html.erb +12 -0
  238. data/app/views/collavre/creatives/_github_integration_modal.html.erb +77 -0
  239. data/app/views/collavre/creatives/_import_upload_zone.html.erb +10 -0
  240. data/app/views/collavre/creatives/_inline_edit_form.html.erb +89 -0
  241. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +24 -0
  242. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +90 -0
  243. data/app/views/collavre/creatives/_set_plan_modal.html.erb +25 -0
  244. data/app/views/collavre/creatives/_share_button.html.erb +55 -0
  245. data/app/views/collavre/creatives/edit.html.erb +4 -0
  246. data/app/views/collavre/creatives/index.html.erb +192 -0
  247. data/app/views/collavre/creatives/new.html.erb +4 -0
  248. data/app/views/collavre/creatives/show.html.erb +29 -0
  249. data/app/views/collavre/creatives/slide_view.html.erb +20 -0
  250. data/app/views/collavre/email_verification_mailer/verify.html.erb +4 -0
  251. data/app/views/collavre/email_verification_mailer/verify.text.erb +2 -0
  252. data/app/views/collavre/emails/index.html.erb +19 -0
  253. data/app/views/collavre/emails/show.html.erb +5 -0
  254. data/app/views/collavre/inbox_items/_item.html.erb +18 -0
  255. data/app/views/collavre/inbox_items/_items.html.erb +3 -0
  256. data/app/views/collavre/inbox_items/_list.html.erb +7 -0
  257. data/app/views/collavre/inbox_items/index.html.erb +1 -0
  258. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +11 -0
  259. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +8 -0
  260. data/app/views/collavre/invitation_mailer/invite.html.erb +5 -0
  261. data/app/views/collavre/invitation_mailer/invite.text.erb +3 -0
  262. data/app/views/collavre/invites/show.html.erb +8 -0
  263. data/app/views/collavre/passwords/edit.html.erb +12 -0
  264. data/app/views/collavre/passwords/new.html.erb +17 -0
  265. data/app/views/collavre/passwords_mailer/reset.html.erb +4 -0
  266. data/app/views/collavre/passwords_mailer/reset.text.erb +2 -0
  267. data/app/views/collavre/sessions/new.html.erb +27 -0
  268. data/app/views/collavre/sessions/providers/_google.html.erb +7 -0
  269. data/app/views/collavre/sessions/providers/_passkey.html.erb +3 -0
  270. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -0
  271. data/app/views/collavre/shared/_navigation.html.erb +37 -0
  272. data/app/views/collavre/shared/navigation/_help_button.html.erb +1 -0
  273. data/app/views/collavre/shared/navigation/_inbox_button.html.erb +5 -0
  274. data/app/views/collavre/shared/navigation/_mobile_inbox_button.html.erb +3 -0
  275. data/app/views/collavre/shared/navigation/_mobile_plans_button.html.erb +1 -0
  276. data/app/views/collavre/shared/navigation/_panels.html.erb +18 -0
  277. data/app/views/collavre/shared/navigation/_plans_button.html.erb +3 -0
  278. data/app/views/collavre/shared/navigation/_search_form.html.erb +6 -0
  279. data/app/views/collavre/user_themes/index.html.erb +75 -0
  280. data/app/views/collavre/users/_app_header.html.erb +5 -0
  281. data/app/views/collavre/users/_contact_management.html.erb +77 -0
  282. data/app/views/collavre/users/_id_pwd_fields.html.erb +27 -0
  283. data/app/views/collavre/users/edit_ai.html.erb +79 -0
  284. data/app/views/collavre/users/edit_password.html.erb +27 -0
  285. data/app/views/collavre/users/index.html.erb +80 -0
  286. data/app/views/collavre/users/new.html.erb +33 -0
  287. data/app/views/collavre/users/new_ai.html.erb +87 -0
  288. data/app/views/collavre/users/passkeys.html.erb +43 -0
  289. data/app/views/collavre/users/show.html.erb +143 -0
  290. data/app/views/inbox/badge_component/_count.html.erb +2 -0
  291. data/app/views/layouts/collavre/slide.html.erb +29 -0
  292. data/config/locales/comments.en.yml +114 -0
  293. data/config/locales/comments.ko.yml +110 -0
  294. data/config/locales/contacts.en.yml +16 -0
  295. data/config/locales/contacts.ko.yml +16 -0
  296. data/config/locales/creatives.en.yml +183 -0
  297. data/config/locales/creatives.ko.yml +164 -0
  298. data/config/locales/invites.en.yml +19 -0
  299. data/config/locales/invites.ko.yml +17 -0
  300. data/config/locales/notifications.en.yml +8 -0
  301. data/config/locales/notifications.ko.yml +8 -0
  302. data/config/locales/plans.en.yml +12 -0
  303. data/config/locales/plans.ko.yml +19 -0
  304. data/config/locales/themes.en.yml +29 -0
  305. data/config/locales/themes.ko.yml +27 -0
  306. data/config/locales/users.en.yml +151 -0
  307. data/config/locales/users.ko.yml +146 -0
  308. data/config/routes.rb +92 -0
  309. data/db/migrate/20241201000000_create_notion_integrations.rb +29 -0
  310. data/db/migrate/20250128110017_create_creatives.rb +11 -0
  311. data/db/migrate/20250128120122_create_users.rb +11 -0
  312. data/db/migrate/20250128120123_create_sessions.rb +11 -0
  313. data/db/migrate/20250128123633_create_subscribers.rb +10 -0
  314. data/db/migrate/20250312000000_create_notion_block_links.rb +16 -0
  315. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +5 -0
  316. data/db/migrate/20250522115048_remove_name_from_creatives.rb +5 -0
  317. data/db/migrate/20250522190651_add_parent_id_to_creatives.rb +5 -0
  318. data/db/migrate/20250523133100_rename_inventory_count_to_progress.rb +13 -0
  319. data/db/migrate/20250523133101_add_sequence_to_creatives.rb +5 -0
  320. data/db/migrate/20250525205100_add_user_id_to_creatives.rb +10 -0
  321. data/db/migrate/20250527014217_create_creative_shares.rb +12 -0
  322. data/db/migrate/20250528142349_add_origin_id_to_creatives.rb +5 -0
  323. data/db/migrate/20250530060200_create_tags.rb +9 -0
  324. data/db/migrate/20250531105150_add_value_to_tags.rb +5 -0
  325. data/db/migrate/20250531140142_create_labels.rb +12 -0
  326. data/db/migrate/20250531140145_change_tag_to_label_reference.rb +6 -0
  327. data/db/migrate/20250601000000_create_comments.rb +10 -0
  328. data/db/migrate/20250601061830_drop_plans_and_variations.rb +6 -0
  329. data/db/migrate/20250604122600_add_owner_to_labels.rb +5 -0
  330. data/db/migrate/20250606000000_rename_email_address_to_email_in_users.rb +7 -0
  331. data/db/migrate/20250606150329_create_creative_hierarchies.rb +16 -0
  332. data/db/migrate/20250610142000_create_creative_expanded_states.rb +11 -0
  333. data/db/migrate/20250611030138_create_invitations.rb +15 -0
  334. data/db/migrate/20250611105524_create_inbox_items.rb +14 -0
  335. data/db/migrate/20250612150000_update_permissions.rb +43 -0
  336. data/db/migrate/20250612232913_add_email_verified_at_to_users.rb +5 -0
  337. data/db/migrate/20250616065905_add_avatar_to_users.rb +5 -0
  338. data/db/migrate/20250617092111_add_display_level_to_users.rb +5 -0
  339. data/db/migrate/20250620004558_create_emails.rb +13 -0
  340. data/db/migrate/20250621000000_create_comment_read_pointers.rb +11 -0
  341. data/db/migrate/20250622000000_add_completion_mark_to_users.rb +5 -0
  342. data/db/migrate/20250623000000_add_theme_to_users.rb +5 -0
  343. data/db/migrate/20250624000000_add_name_to_users.rb +18 -0
  344. data/db/migrate/20250714190000_create_devices.rb +17 -0
  345. data/db/migrate/20250715120000_add_notifications_enabled_to_users.rb +5 -0
  346. data/db/migrate/20250823000000_add_no_access_permission.rb +25 -0
  347. data/db/migrate/20250826000000_add_calendar_id_to_users.rb +5 -0
  348. data/db/migrate/20250827000000_add_google_oauth_tokens_to_users.rb +8 -0
  349. data/db/migrate/20250827061238_add_timezone_to_users.rb +5 -0
  350. data/db/migrate/20250828000000_create_calendar_events.rb +16 -0
  351. data/db/migrate/20250828060000_add_creative_to_calendar_events.rb +5 -0
  352. data/db/migrate/20250830141052_add_locale_to_users.rb +5 -0
  353. data/db/migrate/20250830141101_add_message_key_to_inbox_items.rb +6 -0
  354. data/db/migrate/20250902025423_remove_description_and_featured_image_from_creatives.rb +6 -0
  355. data/db/migrate/20250910000000_add_private_to_comments.rb +5 -0
  356. data/db/migrate/20250910105640_migrate_write_permissions_to_admin.rb +17 -0
  357. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +27 -0
  358. data/db/migrate/20250923002959_deduplicate_device_fcm_tokens.rb +25 -0
  359. data/db/migrate/20250924000000_add_system_admin_to_users.rb +6 -0
  360. data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
  361. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
  362. data/db/migrate/20250928000000_add_action_and_approver_to_comments.rb +6 -0
  363. data/db/migrate/20250928010000_add_action_execution_tracking_to_comments.rb +6 -0
  364. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
  365. data/db/migrate/20250929000000_add_comment_and_creative_refs_to_inbox_items.rb +6 -0
  366. data/db/migrate/20251001000001_create_contacts.rb +71 -0
  367. data/db/migrate/20251002000000_add_shared_by_to_creative_shares.rb +14 -0
  368. data/db/migrate/20251124120902_add_ai_fields_to_users.rb +7 -0
  369. data/db/migrate/20251124122218_add_created_by_id_to_users.rb +5 -0
  370. data/db/migrate/20251124124521_add_llm_api_key_to_users.rb +5 -0
  371. data/db/migrate/20251124130000_add_searchable_to_users.rb +6 -0
  372. data/db/migrate/20251125072705_migrate_linked_creative_children_to_origin.rb +28 -0
  373. data/db/migrate/20251126040752_add_description_to_creatives.rb +75 -0
  374. data/db/migrate/20251127000000_move_comments_from_linked_creatives_to_origins.rb +18 -0
  375. data/db/migrate/20251201073823_add_tools_to_users.rb +5 -0
  376. data/db/migrate/20251202062715_create_mcp_tools.rb +16 -0
  377. data/db/migrate/20251204125754_create_tasks_and_task_actions.rb +23 -0
  378. data/db/migrate/20251204125756_add_routing_expression_to_users.rb +5 -0
  379. data/db/migrate/20251204161133_set_default_routing_expression_for_ai_agents.rb +13 -0
  380. data/db/migrate/20251211033025_nullify_self_referencing_origins.rb +9 -0
  381. data/db/migrate/20251211080040_create_topics_and_add_topic_to_comments.rb +15 -0
  382. data/db/migrate/20251215143500_create_activity_logs.rb +16 -0
  383. data/db/migrate/20251222125727_create_webauthn_credentials.rb +14 -0
  384. data/db/migrate/20251222125839_add_webauthn_id_to_users.rb +5 -0
  385. data/db/migrate/20251223022315_create_comment_reactions.rb +13 -0
  386. data/db/migrate/20251223072625_create_user_themes.rb +11 -0
  387. data/db/migrate/20251230074456_add_creative_id_to_labels.rb +5 -0
  388. data/db/migrate/20251230113607_refactor_labels.rb +38 -0
  389. data/db/migrate/20251231010012_backfill_tags_for_labels.rb +15 -0
  390. data/db/migrate/20251231013234_drop_subscribers.rb +9 -0
  391. data/db/migrate/20260106090544_allow_null_user_id_in_creative_shares.rb +6 -0
  392. data/db/migrate/20260106160643_create_system_settings.rb +11 -0
  393. data/db/migrate/20260116000000_create_creative_shares_cache.rb +15 -0
  394. data/db/migrate/20260116000001_populate_creative_shares_cache.rb +23 -0
  395. data/db/migrate/20260119022933_make_source_share_id_nullable_in_creative_shares_caches.rb +7 -0
  396. data/db/migrate/20260119023446_populate_owner_cache_entries.rb +25 -0
  397. data/db/migrate/20260119100000_add_account_lockout_to_users.rb +6 -0
  398. data/db/migrate/20260119110000_add_last_active_at_to_sessions.rb +6 -0
  399. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +60 -0
  400. data/db/migrate/20260120162259_remove_fk_from_creative_shares_caches.rb +7 -0
  401. data/db/migrate/20260120163856_remove_timestamps_from_creative_shares_caches.rb +6 -0
  402. data/lib/collavre/configuration.rb +14 -0
  403. data/lib/collavre/engine.rb +77 -0
  404. data/lib/collavre/user_extensions.rb +29 -0
  405. data/lib/collavre/version.rb +3 -0
  406. data/lib/collavre.rb +26 -0
  407. data/lib/generators/collavre/install/install_generator.rb +105 -0
  408. data/lib/generators/collavre/install/templates/build.cjs.tt +100 -0
  409. data/lib/tasks/collavre_assets.rake +15 -0
  410. 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