collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Keeps denormalized real columns in lockstep with keys stored inside a JSON
5
+ # column, so database indexes can be defined over plain columns instead of
6
+ # JSON expressions.
7
+ #
8
+ # WHY: A JSON-expression index serializes per-adapter --
9
+ # `json_extract(config, '$.x')` on SQLite vs `config->>'x'` on PostgreSQL.
10
+ # Because schema.rb is dumped from the SQLite dev DB, it carries the
11
+ # `json_extract` form, which is not a PostgreSQL function, so
12
+ # `db:schema:load` crashes on the prod PostgreSQL database. Promote the JSON
13
+ # key to a real column (which indexes and dumps identically on both adapters)
14
+ # and keep it synced from the JSON column on every save. The JSON column
15
+ # stays the source of truth; the promoted columns are re-derived on each save.
16
+ #
17
+ # Usage:
18
+ # include Collavre::IndexedJsonColumns
19
+ #
20
+ # indexed_json_columns json: :config, columns: {
21
+ # repo_full_name: "repo_full_name",
22
+ # pr_number: "pr_number",
23
+ # }
24
+ #
25
+ # `json:` names the JSON attribute (source of truth). `columns:` maps each
26
+ # promoted column to the JSON key it mirrors. A before_save re-derives every
27
+ # promoted column from the JSON attribute so the plain-column indexes enforce
28
+ # the same guarantees the JSON-expression indexes did.
29
+ module IndexedJsonColumns
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ class_attribute :indexed_json_source, instance_writer: false, default: nil
34
+ class_attribute :indexed_json_column_map, instance_writer: false, default: {}.freeze
35
+ end
36
+
37
+ class_methods do
38
+ def indexed_json_columns(json:, columns:)
39
+ self.indexed_json_source = json.to_sym
40
+ self.indexed_json_column_map =
41
+ columns.each_with_object({}) { |(col, key), m| m[col.to_s] = key.to_s }.freeze
42
+ before_save :sync_indexed_json_columns
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Re-derive each promoted column from its JSON key. Reads the JSON attribute
49
+ # (source of truth) and writes through `[]=` so subtype reader overrides are
50
+ # bypassed and only genuinely changed values dirty the record.
51
+ def sync_indexed_json_columns
52
+ json = public_send(indexed_json_source) || {}
53
+ indexed_json_column_map.each do |column, json_key|
54
+ self[column] = json[json_key]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ module Collavre
2
+ # Classifies an AI user/agent into a coarse role type by scanning its
3
+ # system prompt. Shared by the orchestration and system-events context
4
+ # builders so the prompt-to-type mapping lives in exactly one place.
5
+ module AgentTypeClassifier
6
+ module_function
7
+
8
+ # Returns the agent type string ("developer", "pm", "qa", "researcher",
9
+ # "marketer", "planner") or "agent" as the default when nothing matches.
10
+ def classify(user)
11
+ prompt = user.system_prompt.to_s.downcase
12
+ case prompt
13
+ when /developer|개발/ then "developer"
14
+ when /pm|project.?manager|프로젝트/ then "pm"
15
+ when /qa|test|quality|테스트|품질/ then "qa"
16
+ when /research|조사|연구/ then "researcher"
17
+ when /market|마케팅/ then "marketer"
18
+ when /plan|기획/ then "planner"
19
+ else "agent"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -15,6 +15,10 @@ module Collavre
15
15
  # Dispatch A2A events if the response mentions any AI agents
16
16
  def dispatch
17
17
  return unless @reply_comment&.content.present?
18
+ # An approval-action message (approve button / approved) is a human decision
19
+ # surface and must never reach an AI agent — not even via an @mention in
20
+ # another agent's reply. Same invariant as Comment#dispatch_to_orchestration.
21
+ return if @reply_comment.approval_action?
18
22
 
19
23
  mentioned_agents = find_mentioned_agents
20
24
  return if mentioned_agents.empty?
@@ -146,7 +146,7 @@ module Collavre
146
146
  history_chars = 0
147
147
  count = 0
148
148
 
149
- Comment.public_only.where(creative_id: creative_id)
149
+ Comment.public_only.without_approval_action.where(creative_id: creative_id)
150
150
  .where(topic_id: topic_id)
151
151
  .where.not(user_id: nil)
152
152
  .includes(:user)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Provisions the identities a Claude Code registration needs:
6
+ # - one shared ai_user per (human, agent_name) — the Agent identity
7
+ # - one Topic per (agent, session_id) — the Session identity
8
+ #
9
+ # Extracted from Api::V1::AgentsController#register so the action stays a
10
+ # thin orchestration of provisioning + inbox share. Behavior is identical to
11
+ # the inlined controller methods it replaces.
12
+ class SessionProvisioner
13
+ def initialize(user)
14
+ @user = user
15
+ end
16
+
17
+ # One ai_user per (user, agent_name) so a human's sessions share one Agent
18
+ # identity. Same agent_name re-registering reuses the existing row —
19
+ # idempotent retries (and every additional session) don't proliferate
20
+ # agents.
21
+ #
22
+ # Returns nil when a row with the deterministic email already exists but is
23
+ # owned by someone else or isn't a Claude Channel ai_user. The email format
24
+ # is human-derivable (user.id + slug + digest), so a foreign row could be
25
+ # planted by signup/import; silently reusing it would attach the caller's
26
+ # inbox feedback share to that foreign User and leave the plugin's
27
+ # AgentChannel subscription rejected on ownership mismatch. Caller renders
28
+ # 409 in that case.
29
+ def find_or_create_agent(agent_name)
30
+ email = session_agent_email(agent_name)
31
+
32
+ existing = User.find_by(email: email)
33
+ return verified_agent(existing) if existing
34
+
35
+ # routing_expression: nil so the new ai_user is not matchable until the
36
+ # client claims the per-agent stream via AgentChannel. See the comment on
37
+ # verified_agent.
38
+ User.create!(
39
+ email: email,
40
+ name: "Claude Channel (#{agent_name})",
41
+ password: SecureRandom.hex(32),
42
+ llm_vendor: "anthropic",
43
+ llm_model: "claude-code",
44
+ created_by_id: @user.id,
45
+ searchable: false,
46
+ routing_expression: nil
47
+ )
48
+ rescue ActiveRecord::RecordNotUnique
49
+ # A concurrent registration for the same (user, agent_name) won the
50
+ # users.email unique race. The desired row now exists — re-find and
51
+ # re-verify ownership instead of surfacing a 500 that aborts one of the
52
+ # two simultaneously launching plugin instances.
53
+ verified_agent(User.find_by(email: email))
54
+ end
55
+
56
+ # One Topic per (agent, session_id). On re-register (including --resume from
57
+ # the same cwd, which yields the same session_id) the existing topic is
58
+ # reused — even if archived — so the conversation persists instead of
59
+ # orphaning. A fresh session gets a new topic under the same shared agent,
60
+ # which is how one agent fans out to many sessions.
61
+ def find_or_create_topic(inbox, ai_user, session_id, session_label)
62
+ existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
63
+ return existing if existing
64
+
65
+ label = session_label.to_s.strip.presence || session_id
66
+ inbox.topics.create!(
67
+ name: unique_topic_name(inbox, "Claude #{label}"),
68
+ user: @user,
69
+ primary_agent_id: ai_user.id,
70
+ session_id: session_id
71
+ )
72
+ end
73
+
74
+ private
75
+
76
+ # Deterministic, collision-free email key for a (user, agent_name) agent.
77
+ # The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all squeeze
78
+ # to "qa-bot", and any all-symbol name collapses to "session" — so a short
79
+ # digest of the normalized raw name disambiguates distinct configured names
80
+ # that would otherwise alias onto ONE shared agent identity (ActionCable
81
+ # stream, routing state, tasks, creative shares). The slug stays for human
82
+ # readability; the digest decides identity. Same normalized name -> same
83
+ # key, so idempotent re-register/reuse is preserved. Normalization
84
+ # (strip+downcase) keeps case/whitespace-only differences folded, matching
85
+ # the prior behavior — only the lossy punctuation/spacing collapse is fixed.
86
+ def session_agent_email(agent_name)
87
+ normalized = agent_name.to_s.strip.downcase
88
+ slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
89
+ slug = "session" if slug.blank?
90
+ digest = Digest::SHA256.hexdigest(normalized)[0, 10]
91
+ "claude-channel-#{@user.id}-#{slug}-#{digest}@agent.collavre.local"
92
+ end
93
+
94
+ # Returns the agent only when it is the current user's own Claude Channel
95
+ # agent; nil otherwise (the caller renders :conflict). Routing activation
96
+ # stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
97
+ # becomes matchable only once a WebSocket subscriber exists for
98
+ # agent:user:<id> — otherwise comments matched between register returning
99
+ # and the client's subsequent cable subscribe would broadcast into an empty
100
+ # stream, stranding delegated tasks until stuck recovery.
101
+ def verified_agent(ai_user)
102
+ return nil unless ai_user &&
103
+ ai_user.created_by_id == @user.id &&
104
+ ai_user.ai_user? &&
105
+ ai_user.claude_channel_agent?
106
+
107
+ ai_user
108
+ end
109
+
110
+ # Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
111
+ # friendly labels collide (e.g. both rooted at a dir named "src") cannot
112
+ # share a name. Append the smallest numeric suffix that is free.
113
+ def unique_topic_name(inbox, desired)
114
+ return desired unless inbox.topics.exists?(name: desired)
115
+
116
+ n = 2
117
+ loop do
118
+ candidate = "#{desired} (#{n})"
119
+ return candidate unless inbox.topics.exists?(name: candidate)
120
+
121
+ n += 1
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Atomically claims and finalizes a delegated task on behalf of a Claude
6
+ # Channel /reply. Extracted from Api::V1::AgentsController#reply so the
7
+ # controller only sequences: claim -> save comment -> finalize. Behavior is
8
+ # identical to the inlined controller methods it replaces.
9
+ class TaskClaimService
10
+ # Atomically claim a delegated task for completion. Two-step:
11
+ # 1. Find a candidate task in delegated state, scoped to this agent +
12
+ # topic. With task_id supplied, exact match (required under topic
13
+ # concurrency > 1 where multiple delegated tasks coexist; the client
14
+ # echoes the dispatch's task_id). Without task_id (legacy clients),
15
+ # oldest-first.
16
+ # 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
17
+ # status == 'delegated' under the lock, then update! to 'done'.
18
+ # Concurrent claimers block on the lock; the loser sees the
19
+ # already-flipped status post-lock and returns nil so the caller can
20
+ # refuse the duplicate.
21
+ # update_all (NOT update!) is required to skip Task's after_update_commit
22
+ # callbacks at claim time. The callbacks fire check_trigger_loop_completion
23
+ # (which enqueues TriggerLoopCheckJob) and broadcast_stop_button_removal
24
+ # (which reads reply_comment). Both depend on the reply comment already
25
+ # existing — but reply() claims BEFORE comment.save to win the race against
26
+ # concurrent /reply calls. If update! fired the trigger-loop check here, the
27
+ # job could run (cooldown_seconds: 0) before comment.save commits, find no
28
+ # agent comment, and leave the loop stuck in "running". #finalize replays
29
+ # both callbacks after the comment is persisted via
30
+ # Task#fire_completion_callbacks_after_external_claim.
31
+ def claim(agent:, topic:, requested_task_id:)
32
+ scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
33
+ candidate =
34
+ if requested_task_id.present?
35
+ scope.find_by(id: requested_task_id)
36
+ else
37
+ scope.order(:created_at).first
38
+ end
39
+ return nil unless candidate
40
+
41
+ claimed = nil
42
+ Task.transaction do
43
+ locked = Task.lock.find_by(id: candidate.id)
44
+ next unless locked && locked.status == "delegated"
45
+
46
+ Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
47
+ claimed = locked.reload
48
+ end
49
+ claimed
50
+ end
51
+
52
+ # Post-claim side effects, run only after the reply comment is saved. Links
53
+ # the comment to the claimed task, releases the ResourceTracker slot the
54
+ # AiAgentJob held under task.id, advances the parent workflow (if any), and
55
+ # drains the topic queue — mirroring AiAgentJob#perform's success path for
56
+ # non-delegated runs.
57
+ def finalize(agent:, task:, comment:)
58
+ comment.update_column(:task_id, task.id)
59
+
60
+ Orchestration::ResourceTracker.for(agent).release!(task.id)
61
+
62
+ if task.parent_task_id.present?
63
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
64
+ end
65
+
66
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
67
+
68
+ # Replay the after_update_commit callbacks that were bypassed by
69
+ # update_all in #claim — now that the reply comment is linked,
70
+ # TriggerLoopCheckJob can read it and decide whether to advance/await/
71
+ # complete the drop-trigger loop, and the stop-button broadcast has a
72
+ # comment to render.
73
+ task.fire_completion_callbacks_after_external_claim
74
+
75
+ # Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
76
+ # would also stop on its next beat (task no longer "delegated"), but that
77
+ # is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
78
+ # drops the moment Claude's reply lands.
79
+ broadcast_claude_idle(agent, task, comment)
80
+ end
81
+
82
+ private
83
+
84
+ # Clear the chat typing indicator via the canonical status broadcaster (the
85
+ # same one AiAgentService uses for every other agent), so the Claude path
86
+ # emits an identical "idle" agent_status payload.
87
+ def broadcast_claude_idle(agent, task, comment)
88
+ creative = comment.creative&.effective_origin
89
+ return unless creative
90
+
91
+ AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
92
+ .broadcast_status("idle")
93
+ end
94
+ end
95
+ end
96
+ end
@@ -10,13 +10,61 @@ module Collavre
10
10
 
11
11
  attr_reader :last_input_tokens, :last_output_tokens
12
12
 
13
- def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {})
13
+ # Vendor <select> options for AI-agent config. Core ships only its built-in
14
+ # (stateless) providers; vendor engines append their own through
15
+ # register_vendor_option, so core never names a vendor engine.
16
+ BASE_VENDOR_OPTIONS = [
17
+ [ "Google (Gemini)", "google" ],
18
+ [ "OpenAI", "openai" ],
19
+ [ "Anthropic", "anthropic" ]
20
+ ].freeze
21
+
22
+ class << self
23
+ def registered_vendor_options
24
+ @registered_vendor_options ||= []
25
+ end
26
+
27
+ # Append a vendor <select> option ([label, value]). Idempotent by value.
28
+ def register_vendor_option(label, value)
29
+ value = value.to_s
30
+ return if BASE_VENDOR_OPTIONS.any? { |_l, v| v == value }
31
+ return if registered_vendor_options.any? { |_l, v| v == value }
32
+
33
+ registered_vendor_options << [ label, value ]
34
+ end
35
+
36
+ def vendor_options
37
+ BASE_VENDOR_OPTIONS + registered_vendor_options
38
+ end
39
+
40
+ # Vendors that use stateful/incremental sessions. Base providers are
41
+ # stateless; vendor engines that add session support register here so core
42
+ # never string-matches a vendor name to decide session behavior.
43
+ def session_vendors
44
+ @session_vendors ||= Set.new
45
+ end
46
+
47
+ def register_session_vendor(vendor)
48
+ session_vendors << vendor.to_s.downcase
49
+ end
50
+
51
+ def vendor_supports_session?(vendor)
52
+ session_vendors.include?(vendor.to_s.downcase)
53
+ end
54
+ end
55
+
56
+ # log_interactions: persist each call to ActivityLog. Default true. Pass false
57
+ # for ephemeral, high-frequency calls on text the user has not submitted (e.g.
58
+ # inline typo correction on debounced typing) so private drafts are never
59
+ # written to server-side activity logs.
60
+ def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true)
14
61
  @vendor = vendor
15
62
  @model = model
16
63
  @system_prompt = system_prompt
17
64
  @llm_api_key = llm_api_key
18
65
  @gateway_url = gateway_url
19
66
  @context = context
67
+ @log_interactions = log_interactions
20
68
  @last_input_tokens = 0
21
69
  @last_output_tokens = 0
22
70
  end
@@ -36,8 +84,16 @@ module Collavre
36
84
  add_messages(@conversation, contents)
37
85
 
38
86
  response = @conversation.complete do |chunk|
39
- delta = extract_chunk_content(chunk)
40
- next if delta.blank?
87
+ delta = extract_chunk_content(chunk).to_s
88
+ # Deliberately NOT `blank?`. A delta of exactly "\n\n" — the paragraph
89
+ # break, which providers routinely emit as a token of its own — is
90
+ # blank? == true, so skipping blanks deletes it. And the deleted delta is
91
+ # never yielded, so it is gone from the caller's stream too: AiAgentService
92
+ # persists ResponseStreamer#content, which is built only from the yielded
93
+ # deltas, and the reply lands in the database with its paragraphs glued
94
+ # together. Only truly empty deltas (role-only / tool-call chunks carry no
95
+ # content) are skippable.
96
+ next if delta.empty?
41
97
 
42
98
  response_content << delta
43
99
  yield delta if block_given?
@@ -64,7 +120,13 @@ module Collavre
64
120
  raise # Re-raise cancellation errors without catching them
65
121
  rescue StandardError => e
66
122
  error_message = "[#{e.class.name}] #{e.message}"
67
- Rails.logger.error "AI Client error: #{error_message}"
123
+ # When log_interactions is false (inline typo correction runs on the user's
124
+ # *unsubmitted* draft), the LLM error message can echo the request text. Log
125
+ # only the error class to app logs so private drafts never leak — matching the
126
+ # no-log guarantee already enforced on the parse path (TypoCorrector) and the
127
+ # ActivityLog gate below. error_message stays intact for the gated ensure log
128
+ # and the streamed yield (which goes back to the same user).
129
+ Rails.logger.error "AI Client error: #{@log_interactions ? error_message : "[#{e.class.name}]"}"
68
130
  Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present?
69
131
  Rails.logger.debug e.backtrace.join("\n")
70
132
  yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
@@ -72,14 +134,16 @@ module Collavre
72
134
  ensure
73
135
  @last_input_tokens = input_tokens || 0
74
136
  @last_output_tokens = output_tokens || 0
75
- log_interaction(
76
- messages: @conversation&.messages&.to_a || Array(contents),
77
- tools: @conversation&.tools&.to_a || [],
78
- response_content: response_content.presence,
79
- error_message: error_message,
80
- input_tokens: input_tokens,
81
- output_tokens: output_tokens
82
- )
137
+ if @log_interactions
138
+ log_interaction(
139
+ messages: @conversation&.messages&.to_a || Array(contents),
140
+ tools: @conversation&.tools&.to_a || [],
141
+ response_content: response_content.presence,
142
+ error_message: error_message,
143
+ input_tokens: input_tokens,
144
+ output_tokens: output_tokens
145
+ )
146
+ end
83
147
  end
84
148
 
85
149
  # Ask a follow-up question using the existing conversation context.
@@ -115,6 +179,11 @@ module Collavre
115
179
  when "openai"
116
180
  api_key = @llm_api_key.presence || IntegrationSettings.fetch(:openai_api_key)
117
181
  base_url = @gateway_url.presence
182
+ # A custom OpenAI-compatible gateway (local Ollama / LM Studio, etc.) needs
183
+ # no real OpenAI key, but RubyLLM raises ConfigurationError before sending
184
+ # if openai_api_key is blank. Supply a placeholder so keyless local gateways
185
+ # work; hosted OpenAI (no gateway) still requires a real key.
186
+ api_key = "local-gateway" if api_key.blank? && base_url
118
187
  proc do |config|
119
188
  config.openai_api_key = api_key
120
189
  config.openai_api_base = base_url if base_url
@@ -57,7 +57,20 @@ module Collavre
57
57
  end
58
58
 
59
59
  def generate(prompt)
60
- system_prompt = <<~PROMPT
60
+ parse_response(request_theme(prompt))
61
+ end
62
+
63
+ private
64
+
65
+ def request_theme(prompt)
66
+ @client.chat([
67
+ { role: :system, parts: [ { text: system_prompt } ] },
68
+ { role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
69
+ ])
70
+ end
71
+
72
+ def system_prompt
73
+ <<~PROMPT
61
74
  You are a color theme designer for a workspace app.
62
75
  Generate a JSON with ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
63
76
 
@@ -181,21 +194,12 @@ module Collavre
181
194
  For dark/accent themes, h1 can use a subtle darker surface. Must not clash with text.
182
195
  - For playful themes, vary heading colors. For minimal themes, keep sizes uniform.
183
196
  PROMPT
184
-
185
- response = @client.chat([
186
- { role: :system, parts: [ { text: system_prompt } ] },
187
- { role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
188
- ])
189
-
190
- parse_response(response)
191
197
  end
192
198
 
193
- private
194
-
195
199
  def default_client
196
200
  AiClient.new(
197
201
  vendor: "google",
198
- model: "gemini-3-flash-preview",
202
+ model: "gemini-3.1-flash-lite",
199
203
  system_prompt: nil
200
204
  )
201
205
  end
@@ -0,0 +1,86 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Per-level batch answer to "which children does this node show?" for the
4
+ # browse tree, replacing a per-node `children_with_permission` call.
5
+ #
6
+ # Presence (`has_children?`) is resolved for every node in a level with one
7
+ # plucked scan plus one batched PermissionFilter; the full child rows are
8
+ # materialized only for the nodes that actually render them (expanded, or a
9
+ # filter forcing the subtree open). Presence and content are derived from the
10
+ # same candidate set, so an expand toggle can never open an empty branch or
11
+ # hide a reachable subtree — a drift that would also leak the existence of
12
+ # children the user cannot see.
13
+ #
14
+ # Children of a linked shell live under its effective origin, so every lookup
15
+ # resolves the shell to its origin first. Preload `:origin` on the level before
16
+ # indexing it, or that resolution costs a query per shell.
17
+ class ChildrenIndex
18
+ def initialize(user:, show_archived:, allowed_creative_ids: nil)
19
+ @user = user
20
+ @show_archived = show_archived
21
+ @allowed_creative_ids = allowed_creative_ids
22
+ @child_ids_by_creative = {}
23
+ @rows_by_child_id = {}
24
+ end
25
+
26
+ # Scans a level. Only creatives not seen before cost anything, so the
27
+ # recursive descent pays two queries per level, not per node.
28
+ def index(creatives)
29
+ pending = creatives.reject { |c| @child_ids_by_creative.key?(c.id) }
30
+ return if pending.empty?
31
+
32
+ origin_id_by_id = pending.to_h { |c| [ c.id, c.effective_origin.id ] }
33
+
34
+ candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
35
+ candidates = candidates.where(archived_at: nil) unless show_archived
36
+ rows = candidates.order(:sequence).pluck(:id, :parent_id)
37
+
38
+ visible_by_origin = visible_child_ids_by_origin(rows)
39
+ pending.each do |creative|
40
+ @child_ids_by_creative[creative.id] = visible_by_origin[origin_id_by_id[creative.id]] || []
41
+ end
42
+ end
43
+
44
+ def has_children?(creative)
45
+ child_ids(creative).any?
46
+ end
47
+
48
+ # Materializes the child rows of `creatives` in a single query. Called only
49
+ # for the nodes whose children actually render.
50
+ def load(creatives)
51
+ wanted = creatives.flat_map { |c| child_ids(c) }.uniq - @rows_by_child_id.keys
52
+ return if wanted.empty?
53
+
54
+ Creative.where(id: wanted).each { |child| @rows_by_child_id[child.id] = child }
55
+ end
56
+
57
+ # Sequence-ordered, permission- and archive-filtered children. Empty unless
58
+ # `load` has materialized them.
59
+ def children_for(creative)
60
+ child_ids(creative).filter_map { |id| @rows_by_child_id[id] }
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :user, :show_archived, :allowed_creative_ids
66
+
67
+ # `rows` arrive in sequence order, so the per-origin lists inherit it.
68
+ def visible_child_ids_by_origin(rows)
69
+ return {} if rows.empty?
70
+
71
+ readable = PermissionFilter.new(user: user).readable_ids(rows.map(&:first)).to_set
72
+
73
+ rows.each_with_object({}) do |(child_id, origin_id), acc|
74
+ next unless readable.include?(child_id)
75
+ next if allowed_creative_ids && !allowed_creative_ids.include?(child_id.to_s)
76
+
77
+ (acc[origin_id] ||= []) << child_id
78
+ end
79
+ end
80
+
81
+ def child_ids(creative)
82
+ @child_ids_by_creative.fetch(creative.id, [])
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,73 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Batches the browse tree's comment badge. Per node this was two queries — a
4
+ # read-pointer lookup and an unread COUNT — which is what made the badge cost
5
+ # scale with the size of the rendered tree.
6
+ #
7
+ # Keyed by *origin*: a linked shell shows its origin's comments.
8
+ class CommentBadgeIndex
9
+ def initialize(user:)
10
+ @user = user
11
+ @unread_by_origin_id = {}
12
+ end
13
+
14
+ def index(origins)
15
+ pending = origins.uniq(&:id).reject { |o| @unread_by_origin_id.key?(o.id) }
16
+ return if pending.empty?
17
+
18
+ watermarks = read_watermarks(pending.map(&:id))
19
+
20
+ # No read pointer means nothing has been read yet, so every comment counts
21
+ # as unread — including private ones, matching the counter cache.
22
+ pending.each do |origin|
23
+ @unread_by_origin_id[origin.id] = origin.comments_count unless watermarks.key?(origin.id)
24
+ end
25
+
26
+ counts = unread_counts(watermarks)
27
+ watermarks.each_key { |origin_id| @unread_by_origin_id[origin_id] = counts.fetch(origin_id, 0) }
28
+ end
29
+
30
+ # nil when the origin was never indexed, which tells the caller to fall back
31
+ # to the single-creative path rather than silently render a zero badge.
32
+ def unread_count_for(origin)
33
+ @unread_by_origin_id[origin.id]
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :user
39
+
40
+ # `user_id: nil` for an anonymous visitor is the lookup the un-batched path
41
+ # made too, so the two agree on which pointer (if any) applies.
42
+ def read_watermarks(origin_ids)
43
+ return {} if origin_ids.empty?
44
+
45
+ CommentReadPointer
46
+ .where(user_id: user&.id, creative_id: origin_ids)
47
+ .where.not(last_read_comment_id: nil)
48
+ .pluck(:creative_id, :last_read_comment_id)
49
+ .to_h
50
+ end
51
+
52
+ # One grouped COUNT for the whole level. Each origin carries its own read
53
+ # watermark, so the thresholds are OR-ed together rather than shared. Built
54
+ # from relations rather than a SQL fragment: a hand-built string here would
55
+ # be safe (literal template, bound values) but unprovably so to a scanner.
56
+ def unread_counts(watermarks)
57
+ return {} if watermarks.empty?
58
+
59
+ newer_than_watermark = watermarks
60
+ .map { |origin_id, last_read_id| unread_scope(origin_id, last_read_id) }
61
+ .reduce { |combined, scope| combined.or(scope) }
62
+
63
+ Comment.where(private: false).merge(newer_than_watermark).group(:creative_id).count
64
+ end
65
+
66
+ def unread_scope(origin_id, last_read_id)
67
+ Comment
68
+ .where(creative_id: origin_id)
69
+ .where(Comment.arel_table[:id].gt(last_read_id))
70
+ end
71
+ end
72
+ end
73
+ end