collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -1,224 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Collavre
4
- module Api
5
- module V1
6
- module Mobile
7
- # The heart of the loop. GET surfaces pending approvals (+ recent agent
8
- # replies) with a stable spoken ref number and a decision-ready summary.
9
- # POST :id/respond branches on the referenced event KIND:
10
- # (A) approval/permission comment → the spoken response is a BUTTON
11
- # press (allow|deny) → decide_claude_channel_permission! + relay.
12
- # (B) ordinary agent message → the spoken response is FREE TEXT passed
13
- # verbatim back to the agent as a reply comment (no server parsing).
14
- class AgentEventsController < BaseController
15
- # Spoken decision verbs. The app no longer addresses approvals by
16
- # ordinal, so a reply to an approval event is just an allow/deny verb;
17
- # anything else asks for clarification rather than firing a decision.
18
- APPROVE = /(승인|허용|적용|approve|allow|confirm|네|좋아|오케이|\bok\b|\byes\b)/i
19
- DENY = /(거절|거부|반려|deny|reject|취소|아니|\bno\b)/i
20
-
21
- def index
22
- approvals = pending_approvals
23
- notices = system_inbox_messages
24
-
25
- # Every undecided approval is emitted on every poll — the server keeps
26
- # no per-client cursor. Re-speaking is prevented on the CLIENT (an
27
- # in-memory "already spoken" set), the same at-least-once shape as
28
- # notices: a pending approval keeps surfacing until it is decided
29
- # (which clears it from pending_approvals) or the app restarts (when a
30
- # still-pending approval SHOULD be re-surfaced). The old `since` cursor
31
- # filtered here, but a batch high-water-mark could burn past an approval
32
- # whose created_at trailed a newer notice, losing it forever.
33
- events = approvals.map do |c|
34
- {
35
- id: c.id, type: "approval_requested",
36
- title: title_for_topic(c.topic_id),
37
- summary: summarizer.approval_summary(comment: c, label: label_for_topic(c.topic_id)),
38
- speak: true, requires_response: true, topic_id: c.topic_id,
39
- created_at: c.created_at.iso8601(6)
40
- }
41
- end
42
-
43
- events += notices.map do |c|
44
- # The notice stands in for the origin comment it quotes; the app
45
- # lists/reads it against the ORIGIN thread and replies route there.
46
- origin_topic_id = c.quoted_comment&.topic_id || c.topic_id
47
- {
48
- id: c.id, type: "agent_reply",
49
- title: title_for_topic(origin_topic_id),
50
- summary: speakable(c.content),
51
- speak: true, requires_response: c.quoted_comment_id.present?,
52
- topic_id: origin_topic_id, created_at: c.created_at.iso8601(6)
53
- }
54
- end
55
-
56
- render json: events.sort_by { |e| e[:created_at] }
57
- end
58
-
59
- def respond
60
- comment = Collavre::Comment.find_by(id: params[:id])
61
- unless comment && authorized_comment?(comment)
62
- return render json: { error: "Event not found" }, status: :not_found
63
- end
64
-
65
- # Acting on a notice means the user has heard it — clear it from the
66
- # unread inbox set so it isn't read again on the next poll.
67
- mark_inbox_read(comment)
68
-
69
- if comment.claude_channel_permission?
70
- decide_on(comment)
71
- else
72
- relay_free_text(comment)
73
- end
74
- end
75
-
76
- # The app calls this when it finishes reading a message ALOUD (whether or
77
- # not the user then replies). Reading = hearing it, so the inbox read
78
- # pointer advances and the notice stops surfacing — the SAME read-state
79
- # the inbox badge uses. A crash before this call leaves the notice unread,
80
- # so the next poll re-reads it (at-least-once, not the lossy old cursor).
81
- def read
82
- comment = Collavre::Comment.find_by(id: params[:id])
83
- unless comment && authorized_comment?(comment)
84
- return render json: { error: "Event not found" }, status: :not_found
85
- end
86
-
87
- mark_inbox_read(comment)
88
- head :ok
89
- end
90
-
91
- private
92
-
93
- # The user's Inbox → System topic is the per-user alarm stream: mentions,
94
- # agent replies, share notices, … land here as system-authored
95
- # (user_id: nil) comments that QUOTE the origin comment. This is the
96
- # canonical, agent-ownership-independent feed the voice loop reads aloud.
97
- #
98
- # Emission is gated by the inbox's OWN read-state — the CommentReadPointer
99
- # (comments with id > last_read_comment_id are unread) that also drives the
100
- # inbox badge — NOT by the client's `since` cursor. A `since` cursor is a
101
- # fetch-time high-water-mark: any notice fetched-but-never-spoken (crash,
102
- # background, busy queue, restart) gets burned past and is lost forever,
103
- # even though it stays unread in the inbox. Driving off the read pointer
104
- # makes "unread in the inbox" and "read aloud by the app" the same set; the
105
- # app marks a notice read (POST :id/read) only after actually speaking it.
106
- def system_inbox_messages
107
- inbox = current_user_inbox or return []
108
- last_read = inbox_read_pointer&.last_read_comment_id || 0
109
-
110
- scope = Collavre::Comment.where(creative_id: inbox.id, topic_id: inbox.system_topic.id)
111
- .where("comments.id > ?", last_read)
112
- .order(:id)
113
- .limit(50)
114
- # Approval prompts are surfaced (actionably) by pending_approvals; their
115
- # System-topic FYI duplicate must not be read a second time.
116
- scope.reject { |c| c.quoted_comment&.claude_channel_permission? }
117
- end
118
-
119
- def inbox_read_pointer
120
- inbox = current_user_inbox or return nil
121
-
122
- Collavre::CommentReadPointer.find_by(user: current_user, creative: inbox)
123
- end
124
-
125
- # Advance the inbox read pointer to (at least) this notice — forward-only,
126
- # so a later message is never un-read. Only inbox-creative comments move the
127
- # inbox pointer; calling this for an approval (which lives in the origin
128
- # creative and is owned by pending_approvals) is a harmless no-op.
129
- def mark_inbox_read(comment)
130
- inbox = current_user_inbox or return
131
- return unless comment.creative_id == inbox.id
132
-
133
- pointer = Collavre::CommentReadPointer.find_or_initialize_by(user: current_user, creative: inbox)
134
- new_id = [ pointer.last_read_comment_id || 0, comment.id ].max
135
- pointer.update(last_read_comment_id: new_id)
136
- end
137
-
138
- def current_user_inbox
139
- return @current_user_inbox if defined?(@current_user_inbox)
140
-
141
- @current_user_inbox = Collavre::Creative.inbox_for(current_user)
142
- end
143
-
144
- # Read the whole message aloud, but spoken: strip markdown link syntax to
145
- # its text and collapse whitespace so TTS doesn't read raw URLs.
146
- def speakable(text)
147
- text.to_s.gsub(/\[([^\]]+)\]\([^)]*\)/, '\1').gsub(/\s+/, " ").strip
148
- end
149
-
150
- def authorized_comment?(comment)
151
- return true if comment.approver_id == current_user.id
152
- return true if own_inbox_notice?(comment)
153
-
154
- author = comment.user
155
- author.respond_to?(:created_by_id) && agent_ids.include?(author.id)
156
- end
157
-
158
- # A system-authored notice in the caller's own Inbox#System topic. Owning
159
- # the inbox is the authorization proof (the notice has no approver/author).
160
- def own_inbox_notice?(comment)
161
- inbox = current_user_inbox or return false
162
-
163
- comment.user_id.nil? &&
164
- comment.creative_id == inbox.id &&
165
- comment.topic_id == inbox.system_topic.id
166
- end
167
-
168
- # (A) bounded decision: spoken response IS the approve/deny button.
169
- def decide_on(comment)
170
- behavior = behavior_from(params[:response])
171
-
172
- unless %w[allow deny].include?(behavior)
173
- return render_speak(:clarify_decision, action: { type: "needs_clarification", comment_id: comment.id })
174
- end
175
-
176
- case decide_permission(comment, behavior)
177
- when :unauthorized
178
- render_speak(:not_authorized, action: { type: "not_authorized", comment_id: comment.id }, status: :forbidden)
179
- when :already_decided
180
- render_speak(:already_decided, action: { type: "already_decided", comment_id: comment.id })
181
- else
182
- render_speak(
183
- behavior == "allow" ? :approved : :denied,
184
- action: { type: behavior == "allow" ? "approved" : "denied", comment_id: comment.id }
185
- )
186
- end
187
- end
188
-
189
- # (B) free-form: relay the utterance verbatim as a human reply.
190
- def relay_free_text(comment)
191
- text = params[:response].to_s.strip
192
- return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
193
-
194
- # A System-inbox notice is a stand-in for the origin comment it quotes;
195
- # the reply must land on that origin thread (the System topic itself
196
- # never dispatches AI), not on the notice.
197
- target = comment.quoted_comment || comment
198
- topic = target.topic
199
- creative = topic&.creative&.effective_origin || target.creative
200
- # quoted_comment threads the reply to the message being answered, but
201
- # review_type: :question keeps review_message? false — otherwise the
202
- # agent's response would update the quoted comment IN PLACE (review flow)
203
- # instead of posting a new reply, so no new comment is created and the
204
- # Inbox#System alarm never fires. Same guard as InboxReplyService (#1301).
205
- reply = creative.comments.create!(
206
- content: text, user: current_user, topic: topic,
207
- quoted_comment: target, review_type: :question
208
- )
209
-
210
- render_speak(:relayed, action: { type: "relayed", comment_id: reply.id, topic_id: topic&.id })
211
- end
212
-
213
- def behavior_from(response)
214
- text = response.to_s.strip
215
- return "allow" if text.match?(APPROVE)
216
- return "deny" if text.match?(DENY)
217
-
218
- nil
219
- end
220
- end
221
- end
222
- end
223
- end
224
- end
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Collavre
4
- module Api
5
- module V1
6
- module Mobile
7
- # Shared base for the voice-companion mobile API. Inherits Doorkeeper
8
- # bearer auth from Api::V1::BaseController.
9
- #
10
- # ★ The API base controller authenticates the token but does NOT run the
11
- # host app's locale around_action, so I18n would otherwise serve the
12
- # process-default locale. Every persisted/spoken string here must match
13
- # the speaker, so we wrap every action in I18n.with_locale (app-supplied
14
- # `locale` param wins, else the user's stored locale).
15
- class BaseController < Collavre::Api::V1::BaseController
16
- around_action :with_request_locale
17
-
18
- private
19
-
20
- def with_request_locale(&block)
21
- I18n.with_locale(requested_locale, &block)
22
- end
23
-
24
- def requested_locale
25
- normalize_locale(params[:locale].presence || current_user&.locale)
26
- end
27
-
28
- def normalize_locale(value)
29
- return I18n.default_locale if value.blank?
30
-
31
- base = value.to_s.tr("-", "_").split("_").first.to_sym
32
- I18n.available_locales.include?(base) ? base : I18n.default_locale
33
- end
34
-
35
- def summarizer
36
- @summarizer ||= Collavre::Mobile::EventSummarizer.new(locale: I18n.locale)
37
- end
38
-
39
- # User-owned AI agents (Claude Channel sessions, etc.).
40
- def agent_ids
41
- @agent_ids ||= Collavre::User.ai_agents.where(created_by_id: current_user.id).pluck(:id)
42
- end
43
-
44
- # Undecided permission prompts the token holder is the approver for.
45
- def pending_approvals
46
- Collavre::Comment.where(approver_id: current_user.id, action_executed_at: nil)
47
- .where.not(action: nil)
48
- .order(:created_at)
49
- .limit(50)
50
- .select(&:claude_channel_permission?)
51
- end
52
-
53
- def reply(key)
54
- I18n.t("collavre.mobile.reply.#{key}")
55
- end
56
-
57
- def label_for_topic(topic_id)
58
- topic = topic_id && Collavre::Topic.find_by(id: topic_id)
59
- topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
60
- end
61
-
62
- # List/display title for an event: "Creative#Topic" so the app can show
63
- # which thread a message belongs to (spec: 제목을 크리에이티브#토픽 형태로).
64
- def title_for_topic(topic_id)
65
- topic = topic_id && Collavre::Topic.find_by(id: topic_id)
66
- name = topic&.name.presence || I18n.t("collavre.mobile.default_task_label")
67
- creative = topic&.creative&.effective_origin || topic&.creative
68
- creative&.description.present? ? "#{creative.description}##{name}" : name
69
- end
70
-
71
- def render_speak(reply_key_or_text, action: {}, speak: true, status: :ok)
72
- text = reply_key_or_text.is_a?(Symbol) ? reply(reply_key_or_text) : reply_key_or_text
73
- render json: { reply: text, speak: speak, action: action }, status: status
74
- end
75
-
76
- # Gate + decide + broadcast a Claude Channel permission. Routes the
77
- # voice path through the same approval_status gate the web path uses
78
- # (Comments::ApprovalActions). Without this the caller could decide a
79
- # prompt they merely own the authoring agent for but are not the
80
- # designated approver of (authorized_comment? lets agent-owned events
81
- # through). Returns a symbol: :ok | :unauthorized | :already_decided.
82
- def decide_permission(comment, behavior)
83
- return :unauthorized unless comment.can_be_approved_by?(current_user)
84
-
85
- comment.decide_claude_channel_permission!(behavior, by: current_user)
86
- comment.broadcast_claude_channel_permission_decision(behavior)
87
- :ok
88
- rescue Collavre::Comment::ClaudeChannelPermission::AlreadyDecided
89
- :already_decided
90
- end
91
- end
92
- end
93
- end
94
- end
95
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Collavre
4
- module Api
5
- module V1
6
- module Mobile
7
- # FCM registration for the Android companion. Push is a follow-up (MVP
8
- # uses foreground polling), but registering the device now keeps the
9
- # token current so push can light up without a client change.
10
- class DevicesController < BaseController
11
- def create
12
- device = Collavre::Device.find_by(fcm_token: device_params[:fcm_token]) ||
13
- current_user.devices.find_or_initialize_by(client_id: device_params[:client_id])
14
-
15
- device.assign_attributes(device_params)
16
- device.user = current_user
17
- device.device_type = :android if device.device_type.blank?
18
- device.save!
19
- head :no_content
20
- end
21
-
22
- private
23
-
24
- def device_params
25
- params.require(:device).permit(:client_id, :device_type, :app_id, :app_version, :fcm_token)
26
- end
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Collavre
4
- module Api
5
- module V1
6
- module Mobile
7
- # A plain mic press (no message selected) routes here: the utterance
8
- # starts a new piece of work in the user's Inbox#Main, exactly like
9
- # typing in the inbox chat — dispatch then matches an agent via
10
- # routing_expression. Replies to a SPECIFIC message go through
11
- # agent_events#respond instead (they carry the target event id).
12
- class VoiceCommandsController < BaseController
13
- def create
14
- text = params[:text].to_s.strip
15
- return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
16
-
17
- inbox = Collavre::Creative.inbox_for(current_user)
18
- comment = inbox.comments.create!(content: text, user: current_user)
19
- render_speak(:sent, action: { type: "message_sent", comment_id: comment.id })
20
- end
21
- end
22
- end
23
- end
24
- end
25
- end
@@ -1,70 +0,0 @@
1
- module Collavre
2
- class InboxItemsController < ApplicationController
3
- before_action :set_inbox_item, only: [ :update, :destroy ]
4
-
5
- PER_PAGE = 20
6
-
7
- def index
8
- scope = InboxItem.where(owner: Current.user).order(created_at: :desc)
9
- scope = scope.new_items unless params[:show] == "all"
10
-
11
- per_page = params[:per_page].presence&.to_i || PER_PAGE
12
- per_page = PER_PAGE if per_page <= 0 || per_page > 100
13
- page = params[:page].presence&.to_i || 1
14
- page = 1 if page < 1
15
- offset = (page - 1) * per_page
16
-
17
- items = scope.offset(offset).limit(per_page + 1).to_a
18
- @inbox_items = items.first(per_page)
19
- @next_page = items.length > per_page ? page + 1 : nil
20
-
21
- respond_to do |format|
22
- format.html do
23
- render partial: "collavre/inbox_items/list", locals: { items: @inbox_items, next_page: @next_page }
24
- end
25
- format.json do
26
- render json: {
27
- items_html: render_to_string(partial: "collavre/inbox_items/items", formats: [ :html ], locals: { items: @inbox_items }),
28
- next_page: @next_page,
29
- empty: @inbox_items.empty?
30
- }
31
- end
32
- end
33
- end
34
-
35
- def count
36
- c = InboxItem.where(owner: Current.user, state: "new").count
37
- render json: { count: c }
38
- end
39
-
40
- def update
41
- unless @inbox_item.owner == Current.user
42
- head :forbidden
43
- return
44
- end
45
-
46
- unless InboxItem.states.key?(params[:state].to_s)
47
- head :unprocessable_entity
48
- return
49
- end
50
-
51
- @inbox_item.update(state: params[:state])
52
- head :no_content
53
- end
54
-
55
- def destroy
56
- if @inbox_item.owner == Current.user
57
- @inbox_item.destroy
58
- head :no_content
59
- else
60
- head :forbidden
61
- end
62
- end
63
-
64
- private
65
-
66
- def set_inbox_item
67
- @inbox_item = InboxItem.where(owner: Current.user).find(params[:id])
68
- end
69
- end
70
- end
@@ -1,39 +0,0 @@
1
- module Collavre
2
- # Session-authenticated endpoint backing the inline typo-correction UI. The
3
- # frontend also gates by device/location, but we re-check here (fail closed)
4
- # before spending an LLM call, and echo the user's auto-apply threshold so the
5
- # client never has to hardcode it.
6
- class TypoCorrectionsController < ApplicationController
7
- # The client debounce is only a hint — a scripted/compromised page can loop
8
- # this endpoint with arbitrary text, and every accepted call spends an LLM
9
- # request. Bound both the call rate and the per-call size server-side. The
10
- # chat composer is short text; anything larger is not a typo-correction case.
11
- MAX_TEXT_LENGTH = 4_000
12
- RATE_LIMIT = 120 # requests
13
- RATE_PERIOD = 1.minute # ...per user per minute
14
-
15
- def create
16
- user = Current.user
17
- device = params[:device].to_s
18
- location = params[:location].presence || "chat"
19
- text = params[:text].to_s
20
-
21
- unless user.typo_correction_active_for?(device: device, location: location)
22
- return render json: { edits: [], threshold: user.typo_correction_threshold }
23
- end
24
-
25
- # Oversized input: skip the LLM entirely (return no edits, not an error —
26
- # this is a background affordance, not a user-facing failure).
27
- if text.length > MAX_TEXT_LENGTH
28
- return render json: { edits: [], threshold: user.typo_correction_threshold }
29
- end
30
-
31
- # Raises RateLimitExceeded -> 429 (DynamicRateLimiting), which the client
32
- # treats as "no suggestions this round".
33
- check_rate_limit!(key: "typo_correction:#{user.id}", limit: RATE_LIMIT, period: RATE_PERIOD)
34
-
35
- edits = TypoCorrector.new.correct(text)
36
- render json: { edits: edits, threshold: user.typo_correction_threshold }
37
- end
38
- end
39
- end
@@ -1,192 +0,0 @@
1
- import {
2
- detectDevice,
3
- shouldRun,
4
- buildCandidateList,
5
- anchorEdits,
6
- applyEditAt,
7
- shiftEditsAfter,
8
- partitionByThreshold,
9
- DEVICE_VOICE,
10
- DEVICE_SOFT_KEYBOARD,
11
- DEVICE_PHYSICAL_KEYBOARD,
12
- } from '../typo_correction'
13
-
14
- const settings = ({
15
- enabled = true,
16
- onVoice = true,
17
- onSoftKeyboard = true,
18
- onPhysicalKeyboard = false,
19
- inChat = true,
20
- inEditor = false,
21
- } = {}) => ({ enabled, onVoice, onSoftKeyboard, onPhysicalKeyboard, inChat, inEditor })
22
-
23
- describe('detectDevice', () => {
24
- test('voice recognition wins regardless of keyboard signals', () => {
25
- expect(detectDevice({ voiceActive: true, lastPrintableKeydownAt: 100, inputAt: 110 })).toBe(DEVICE_VOICE)
26
- })
27
-
28
- test('printable keydown right before input => physical keyboard', () => {
29
- expect(detectDevice({ lastPrintableKeydownAt: 1000, inputAt: 1005 })).toBe(DEVICE_PHYSICAL_KEYBOARD)
30
- })
31
-
32
- test('input with no preceding printable keydown => soft keyboard', () => {
33
- expect(detectDevice({ lastPrintableKeydownAt: null, inputAt: 1000 })).toBe(DEVICE_SOFT_KEYBOARD)
34
- })
35
-
36
- test('stale keydown (too far before input) => soft keyboard', () => {
37
- expect(detectDevice({ lastPrintableKeydownAt: 1000, inputAt: 2000 })).toBe(DEVICE_SOFT_KEYBOARD)
38
- })
39
- })
40
-
41
- describe('shouldRun (2D gating)', () => {
42
- test('runs when master + device + location all on', () => {
43
- expect(shouldRun(settings(), { device: DEVICE_SOFT_KEYBOARD, location: 'chat' })).toBe(true)
44
- })
45
-
46
- test('blocked when master off', () => {
47
- expect(shouldRun(settings({ enabled: false }), { device: DEVICE_SOFT_KEYBOARD, location: 'chat' })).toBe(false)
48
- })
49
-
50
- test('physical keyboard off by default blocks even in chat', () => {
51
- expect(shouldRun(settings(), { device: DEVICE_PHYSICAL_KEYBOARD, location: 'chat' })).toBe(false)
52
- })
53
-
54
- test('editor location off by default blocks even with soft keyboard', () => {
55
- expect(shouldRun(settings(), { device: DEVICE_SOFT_KEYBOARD, location: 'editor' })).toBe(false)
56
- })
57
-
58
- test('voice in chat runs', () => {
59
- expect(shouldRun(settings(), { device: DEVICE_VOICE, location: 'chat' })).toBe(true)
60
- })
61
-
62
- test('null settings never runs', () => {
63
- expect(shouldRun(null, { device: DEVICE_VOICE, location: 'chat' })).toBe(false)
64
- })
65
- })
66
-
67
- describe('buildCandidateList', () => {
68
- test('candidate state: original pre-filled, suggestions by confidence', () => {
69
- const list = buildCandidateList({
70
- currentValue: '안녀하세요',
71
- originalWord: '안녀하세요',
72
- suggestions: [
73
- { suggestion: '안녕하세요', confidence: 0.95 },
74
- { suggestion: '안녕하셔요', confidence: 0.4 },
75
- ],
76
- })
77
- expect(list.map((i) => i.value)).toEqual(['안녀하세요', '안녕하세요', '안녕하셔요'])
78
- expect(list[0].isCurrent).toBe(true)
79
- expect(list[0].role).toBe('original')
80
- })
81
-
82
- test('auto-applied state: corrected word current, original offered as undo', () => {
83
- const list = buildCandidateList({
84
- currentValue: '있습니다',
85
- originalWord: '잇습니다',
86
- suggestions: [{ suggestion: '있습니다', confidence: 0.99 }],
87
- })
88
- // current (applied) first, then original (undo); suggestion dupes current and is dropped.
89
- expect(list.map((i) => i.value)).toEqual(['있습니다', '잇습니다'])
90
- expect(list[0].role).toBe('applied')
91
- expect(list[0].isCurrent).toBe(true)
92
- expect(list[1].role).toBe('original')
93
- })
94
-
95
- test('de-duplicates and skips empties', () => {
96
- const list = buildCandidateList({
97
- currentValue: 'a',
98
- originalWord: 'a',
99
- suggestions: [{ suggestion: 'a' }, { suggestion: '' }, { suggestion: 'b', confidence: 0.5 }],
100
- })
101
- expect(list.map((i) => i.value)).toEqual(['a', 'b'])
102
- })
103
- })
104
-
105
- describe('anchorEdits', () => {
106
- test('binds offsets to substrings', () => {
107
- const text = '안녀하세요 저는 콜라브를 만들고 잇습니다'
108
- const edits = anchorEdits(text, [
109
- { original: '안녀하세요', suggestion: '안녕하세요' },
110
- { original: '잇습니다', suggestion: '있습니다' },
111
- ])
112
- expect(text.slice(edits[0].start, edits[0].end)).toBe('안녀하세요')
113
- expect(text.slice(edits[1].start, edits[1].end)).toBe('잇습니다')
114
- })
115
-
116
- test('repeated words anchor to distinct occurrences, not all to the first', () => {
117
- const text = 'teh cat and teh dog'
118
- const edits = anchorEdits(text, [
119
- { original: 'teh', suggestion: 'the' },
120
- { original: 'teh', suggestion: 'the' },
121
- ])
122
- expect(edits[0].start).toBe(0)
123
- expect(edits[1].start).toBe(12)
124
- })
125
-
126
- test('drops edits whose original is gone', () => {
127
- const edits = anchorEdits('hello world', [{ original: 'xyz', suggestion: 'abc' }])
128
- expect(edits).toEqual([])
129
- })
130
-
131
- test('honors the server offset so a duplicate inside a protected span is skipped', () => {
132
- // "teh" appears first inside backticks (a region the server excludes) and
133
- // again in plain text at offset 15. A plain search would bind the protected
134
- // one; the offset pins us to the valid occurrence.
135
- const text = 'use `teh` then teh'
136
- const edits = anchorEdits(text, [{ original: 'teh', suggestion: 'the', offset: 15 }])
137
- expect(edits[0].start).toBe(15)
138
- expect(text.slice(edits[0].start, edits[0].end)).toBe('teh')
139
- })
140
-
141
- test('falls back to search when the offset no longer matches the text', () => {
142
- const text = 'hello teh'
143
- const edits = anchorEdits(text, [{ original: 'teh', suggestion: 'the', offset: 99 }])
144
- expect(edits[0].start).toBe(6)
145
- })
146
- })
147
-
148
- describe('applyEditAt + shiftEditsAfter (re-anchoring)', () => {
149
- test('applying one edit shifts the offsets of later edits', () => {
150
- let text = 'teh cat saw teh dog'
151
- const edits = anchorEdits(text, [
152
- { original: 'teh', suggestion: 'the' },
153
- { original: 'teh', suggestion: 'the' },
154
- ])
155
- const first = edits[0]
156
- const { text: t2, delta } = applyEditAt(text, { start: first.start, end: first.end, replacement: 'the' })
157
- text = t2
158
- expect(text).toBe('the cat saw teh dog')
159
- const remaining = shiftEditsAfter(edits.slice(1), first.start, delta)
160
- // 'the' and 'teh' are the same length here, delta 0; offset unchanged & still valid.
161
- expect(text.slice(remaining[0].start, remaining[0].end)).toBe('teh')
162
- })
163
-
164
- test('length-changing replacement re-anchors later edits correctly', () => {
165
- let text = 'a wrng word and anothr'
166
- const edits = anchorEdits(text, [
167
- { original: 'wrng', suggestion: 'wrong' },
168
- { original: 'anothr', suggestion: 'another' },
169
- ])
170
- const first = edits[0]
171
- const { text: t2, delta } = applyEditAt(text, { start: first.start, end: first.end, replacement: 'wrong' })
172
- text = t2
173
- expect(text).toBe('a wrong word and anothr')
174
- const remaining = shiftEditsAfter(edits.slice(1), first.start, delta)
175
- expect(text.slice(remaining[0].start, remaining[0].end)).toBe('anothr')
176
- })
177
- })
178
-
179
- describe('partitionByThreshold', () => {
180
- test('splits at/above vs below threshold', () => {
181
- const { autoApplied, candidates } = partitionByThreshold(
182
- [
183
- { original: 'a', confidence: 0.9 },
184
- { original: 'b', confidence: 0.8 },
185
- { original: 'c', confidence: 0.5 },
186
- ],
187
- 80,
188
- )
189
- expect(autoApplied.map((e) => e.original)).toEqual(['a', 'b'])
190
- expect(candidates.map((e) => e.original)).toEqual(['c'])
191
- })
192
- })