collavre 0.22.0 → 0.23.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -118,7 +118,7 @@ module Collavre
118
118
  def notify_write_users
119
119
  return if private? || !user
120
120
  return if streaming_placeholder?
121
- return if creative&.inbox? # Don't notify about inbox comments
121
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
122
122
  notification_recipients.each do |recipient|
123
123
  create_inbox_comment(
124
124
  recipient,
@@ -131,7 +131,7 @@ module Collavre
131
131
  def notify_mentions
132
132
  return if private?
133
133
  return if streaming_placeholder?
134
- return if creative&.inbox? # Don't notify about inbox comments
134
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
135
135
  notify_mentioned_users
136
136
  end
137
137
 
@@ -146,7 +146,7 @@ module Collavre
146
146
  end
147
147
 
148
148
  def notify_ai_write_users
149
- return if creative&.inbox? # Don't notify about inbox comments
149
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
150
150
  notification_recipients.each do |recipient|
151
151
  create_inbox_comment(
152
152
  recipient,
@@ -157,10 +157,20 @@ module Collavre
157
157
  end
158
158
 
159
159
  def notify_ai_mentions
160
- return if creative&.inbox? # Don't notify about inbox comments
160
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
161
161
  notify_mentioned_users
162
162
  end
163
163
 
164
+ # #1301 made every inbox topic EXCEPT System dispatch like a normal topic;
165
+ # the alarm stream follows suit. Suppress notifications ONLY for the System
166
+ # topic itself (its system-authored notices must not cascade into more
167
+ # notices — a loop). Inbox#Main and other inbox topics notify normally, so
168
+ # an agent reply there reaches the absent owner. Mirrors the dispatch gate
169
+ # in Comment#dispatch_to_orchestration.
170
+ def suppress_inbox_notification?
171
+ creative&.inbox? && inbox_system_topic?
172
+ end
173
+
164
174
  def notify_approver
165
175
  return unless approver.present? && action.present?
166
176
  return if approver == user
@@ -3,12 +3,54 @@ module Collavre
3
3
  self.table_name = "comments"
4
4
 
5
5
  STREAMING_PLACEHOLDER_CONTENT = "..."
6
+ # Authorless "⏳" waiting-notice system messages posted when an agent is
7
+ # deferred for topic concurrency. AgentOrchestrator.cleanup_waiting_notices!
8
+ # matches the same prefix to remove them once the waiter is dequeued.
9
+ WAITING_NOTICE_PREFIX = "⏳"
6
10
 
7
11
  # Use non-namespaced partial path for backward compatibility
8
12
  def to_partial_path
9
13
  "comments/comment"
10
14
  end
11
15
 
16
+ # A system "⏳" waiting notice (no author) telling a user their agent is
17
+ # deferred because another task holds the topic's running slot.
18
+ def waiting_notice?
19
+ user_id.nil? && content.to_s.start_with?(WAITING_NOTICE_PREFIX)
20
+ end
21
+
22
+ # The task holding this topic's concurrency slot — the blocker this waiting
23
+ # notice is about. Lets the notice render a stop button that cancels the
24
+ # blocker (freeing the topic so the deferred waiter proceeds) instead of
25
+ # being an anonymous dead end. Resolved at render time rather than stored on
26
+ # task_id, which Task#reply_comment keys on (a shared task_id would make the
27
+ # blocker's reply_comment ambiguous).
28
+ #
29
+ # Two gates keep the button honest:
30
+ # 1. Only THIS notice's own topic-concurrency defer qualifies. The same "⏳"
31
+ # notice is also posted for :delayed decisions (busy / rate_limited),
32
+ # which schedule a delayed job WITHOUT queuing a topic waiter —
33
+ # cancelling some unrelated running task would not unblock them.
34
+ # topic_concurrency_defer is set only on the :deferred path, so a
35
+ # :delayed notice never shows the button even when an unrelated queued
36
+ # waiter happens to share the topic. The queued_for_topic check then
37
+ # confirms a waiter is still actually pending on the slot.
38
+ # 2. Resolve the blocker over occupying_topic_slot, not just running/
39
+ # delegated: a holder paused on pending_approval still occupies the slot
40
+ # and is cancellable, so the button must stay visible for it.
41
+ # Returns nil once no slot holder remains — at which point the notice itself
42
+ # is cleaned up.
43
+ def topic_blocking_task
44
+ return @topic_blocking_task if defined?(@topic_blocking_task)
45
+
46
+ @topic_blocking_task =
47
+ if topic_concurrency_defer? && topic_id &&
48
+ Collavre::Task.queued_for_topic(topic_id, creative_id).exists?
49
+ Collavre::Task.occupying_topic_slot(topic_id, creative_id)
50
+ .includes(:agent).order(:created_at).first
51
+ end
52
+ end
53
+
12
54
  belongs_to :creative, class_name: "Collavre::Creative"
13
55
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
14
56
  belongs_to :approver, class_name: Collavre.configuration.user_class_name, optional: true
@@ -50,6 +92,10 @@ module Collavre
50
92
 
51
93
  attribute :skip_default_user, :boolean, default: false
52
94
  attribute :skip_dispatch, :boolean, default: false
95
+ # Set by AgentOrchestrator.cleanup_waiting_notices! so destroying a notice as
96
+ # part of *promoting* a waiter does not run the user-delete cancel cascade
97
+ # (which would cancel other still-queued waiters in the same topic).
98
+ attribute :suppress_waiter_cancellation, :boolean, default: false
53
99
 
54
100
  before_validation :use_origin_creative
55
101
  before_validation :assign_default_user, on: :create
@@ -142,12 +188,18 @@ module Collavre
142
188
  Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
143
189
  end
144
190
 
145
- # Cancel queued tasks when their waiting notice (system comment) is deleted
146
- cancel_queued_tasks_for_waiting_notice if waiting_notice?
147
- end
148
-
149
- def waiting_notice?
150
- user_id.nil? && content&.start_with?("⏳")
191
+ # Cancel queued tasks when a user DELETES their waiting notice. Gated to
192
+ # topic_concurrency_defer notices: only the :deferred path queues a topic
193
+ # waiter, so a :delayed (busy / rate_limited) "⏳" notice — which shares the
194
+ # prefix but has no waiter of its own — must not cancel an unrelated queued
195
+ # waiter that happens to share the topic. Also skipped when the notice is
196
+ # removed by promotion cleanup (suppress_waiter_cancellation): there the
197
+ # waiter is being advanced, not abandoned, and cancelling other still-queued
198
+ # waiters would drop their work (multi-slot orphan recovery must not cancel
199
+ # the rest).
200
+ if waiting_notice? && topic_concurrency_defer? && !suppress_waiter_cancellation
201
+ cancel_queued_tasks_for_waiting_notice
202
+ end
151
203
  end
152
204
 
153
205
  def cancel_queued_tasks_for_waiting_notice
@@ -164,18 +216,21 @@ module Collavre
164
216
  return unless user_id # nil user = system message
165
217
  return if user&.ai_user? # AI replies use A2aDispatcher, not this callback
166
218
  return unless creative
167
- # Inbox creatives hold the user's notifications/DMs and normally must not
168
- # trigger AI orchestration. Exception: a Claude Channel agent session
169
- # registers its topic *inside* the inbox (Creative.inbox_for) and depends
170
- # on the orchestration pipeline (Matcher Arbiter AiAgentService) to
171
- # deliver comments to the running session. Scope the exception to actual
172
- # Claude Channel session topics (primary_agent is a claude_channel_agent?).
173
- # An inbox topic can be given any ai_user as primary_agent via
174
- # TopicsController#set_primary_agent; gating on mere primary_agent presence
175
- # would leak ordinary inbox DMs to the live Claude session, which holds
176
- # inbox-wide :feedback + routing_expression="true" and would be selected by
177
- # the Matcher for any dispatched inbox comment.
178
- return if creative.inbox? && !claude_channel_session_topic?
219
+ # Inbox creatives hold the user's notifications AND ordinary conversations.
220
+ # Only the System topic is special: it carries alarms/notifications (stuck
221
+ # recovery, share notices, …) and must never trigger AI. Every OTHER inbox
222
+ # topic Main, Content, user threads, Claude Channel session topics — is an
223
+ # ordinary conversation surface and dispatches exactly like a normal
224
+ # (non-inbox) topic.
225
+ #
226
+ # A live Claude Channel session holds inbox-wide :feedback +
227
+ # routing_expression="true", so it would otherwise be selected by the
228
+ # Matcher for *every* dispatched inbox comment (leaking ordinary inbox
229
+ # threads into the live session). That confinement now lives in
230
+ # Orchestration::Matcher, which scopes a Claude session agent to its own
231
+ # registered session topic — keeping non-System topics truly identical to a
232
+ # normal topic whether or not a session is live.
233
+ return if creative.inbox? && inbox_system_topic?
179
234
 
180
235
  # A Claude Channel session suspended on a native tool-permission prompt
181
236
  # parks its in-flight dispatch as a `delegated` task carrying a
@@ -200,19 +255,12 @@ module Collavre
200
255
  raise # re-raise so calling jobs (e.g. DropTriggerJob) can retry
201
256
  end
202
257
 
203
- # True only when this comment's topic is an actual Claude Channel session
204
- # topic it carries the registration marker (session_id) AND its
205
- # primary_agent is a claude_channel_agent? (llm_model "claude-code"). Used to
206
- # scope the inbox dispatch exception so ordinary inbox threads stay local.
207
- #
208
- # session_id is required, not just the Claude primary_agent: a Claude
209
- # channel ai_user can be assigned as primary_agent on an ordinary inbox
210
- # topic via TopicsController#set_primary_agent without ever registering a
211
- # session. Gating on the agent alone would dispatch that ordinary thread and
212
- # leak it to the live Claude session. session_id is exactly what
213
- # ClaudeChannelAdapter#session_topic? keys on, so the two stay consistent.
214
- def claude_channel_session_topic?
215
- topic&.session_id.present? && topic&.primary_agent&.claude_channel_agent?
258
+ # The inbox System topic is the alarm/notification stream and must never
259
+ # trigger AI orchestration. Matched by name (Creative::SYSTEM_TOPIC_NAME),
260
+ # the same topic Creative#system_topic finds/creates and that stuck-recovery
261
+ # and share notices post into.
262
+ def inbox_system_topic?
263
+ topic&.name == Creative::SYSTEM_TOPIC_NAME
216
264
  end
217
265
 
218
266
  def assign_default_user
@@ -5,6 +5,11 @@ module Collavre
5
5
 
6
6
  included do
7
7
  attr_accessor :content_type_input
8
+ # Which editing surface authored this Markdown: "rich" (Lexical) reopens
9
+ # in the rich editor, "source" (or absent/legacy) reopens in the advanced
10
+ # textarea. Stored in data["editor"]; decoupled from the storage format
11
+ # (content_type), which is now Markdown for both surfaces.
12
+ attr_accessor :markdown_editor
8
13
  attr_reader :markdown_source
9
14
 
10
15
  def markdown_source=(value)
@@ -128,6 +133,9 @@ module Collavre
128
133
  prev_source = data["markdown_source"].to_s
129
134
  prev_type = data["content_type"]
130
135
  self.data["content_type"] = "markdown"
136
+ # Remember which surface authored this. Absent param (MCP/tool writes)
137
+ # keeps the prior choice, defaulting to the advanced textarea.
138
+ self.data["editor"] = markdown_editor.presence || data["editor"] || "source"
131
139
  if new_source != prev_source || prev_type != "markdown"
132
140
  # Rewrite inline data-URI images to freshly-uploaded blob paths
133
141
  # FIRST, then persist the rewritten source. Subsequent edits
@@ -149,6 +157,7 @@ module Collavre
149
157
  self.data ||= {}
150
158
  self.data.delete("content_type")
151
159
  self.data.delete("markdown_source")
160
+ self.data.delete("editor")
152
161
  elsif !new_record? && description_changed? && data&.dig("content_type") == "markdown"
153
162
  # Description was rewritten through a non-markdown path (tool/MCP
154
163
  # update, direct base.update(description: ...), etc.) on a creative
@@ -158,6 +167,7 @@ module Collavre
158
167
  # Demote to HTML mode so the persisted source matches description.
159
168
  self.data.delete("content_type")
160
169
  self.data.delete("markdown_source")
170
+ self.data.delete("editor")
161
171
  end
162
172
  end
163
173
 
@@ -181,21 +191,67 @@ module Collavre
181
191
  end
182
192
  end
183
193
 
194
+ # The Lexical editor stores text color / background as inline <span
195
+ # style="...">. Tighten every style attribute to ONLY validated color /
196
+ # background-color declarations BEFORE sanitization, so allowing `style`
197
+ # in the safelist can't smuggle layout/position/url() payloads.
198
+ scrub_inline_color_styles(scrubbed)
199
+
184
200
  self.description = ActionController::Base.helpers.sanitize(
185
201
  scrubbed.to_html,
186
202
  tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags + media_tags + %w[input],
187
- attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical]
203
+ attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical style]
188
204
  )
189
205
  end
190
206
 
191
- # Sync creative.files to exactly the blobs referenced in the description
192
- # HTML (attach new, detach removed). Must never raise during save —
193
- # malformed HTML yields [] and a no-op.
207
+ # Only these CSS color values may appear in a stored inline style — mirrors
208
+ # the JS markdown serializer's allowlist (markdown_serialize.js). Blocks
209
+ # url(), expression(), javascript:, and angle brackets.
210
+ SAFE_COLOR_VALUE = /\A(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|var\(--[a-zA-Z0-9\-_]+\)|[a-zA-Z]+)\z/
211
+
212
+ def scrub_inline_color_styles(fragment)
213
+ fragment.css("[style]").each do |node|
214
+ declarations = node["style"].to_s.split(";").filter_map do |decl|
215
+ key, value = decl.split(":", 2)
216
+ next if key.nil? || value.nil?
217
+
218
+ key = key.strip.downcase
219
+ value = value.strip
220
+ next unless %w[color background-color].include?(key)
221
+ next if value =~ /url\(|expression|javascript:|@import|[<>]/i
222
+ next unless value =~ SAFE_COLOR_VALUE
223
+
224
+ "#{key}: #{value}"
225
+ end
226
+
227
+ if declarations.any?
228
+ node["style"] = declarations.join("; ")
229
+ else
230
+ node.remove_attribute("style")
231
+ end
232
+ end
233
+ end
234
+
235
+ # Sync creative.files to the blobs referenced in the description HTML
236
+ # (attach new, detach removed). Must never raise during save — malformed
237
+ # HTML yields [] and a no-op.
238
+ #
239
+ # The rich (Lexical) editor is now Markdown-canonical and serializes
240
+ # inserted images/videos/files as raw blob-URL tags inside markdown_source,
241
+ # which markdown_to_html renders into `description`. So reconcile must run
242
+ # for markdown mode too, otherwise rich-editor uploads never reach
243
+ # creative.files (and the attachment list/remove services miss them).
194
244
  #
195
- # Markdown-mode creatives manage their own blobs via MarkdownConverter;
196
- # the embed paths demote markdown -> html first, so uploads still reconcile.
245
+ # Markdown detach is narrowed (not skipped): markdown creatives may carry
246
+ # legacy blobs attached directly but never embedded in the derived
247
+ # description (AttachmentBackfill skips markdown, so reconcile is their only
248
+ # touch point) — those must survive. But media the user actually removed
249
+ # in-editor WAS referenced in the prior description and no longer is; that
250
+ # must detach, or creative.files keeps listing a deleted file (the editor's
251
+ # purge DELETE can't compensate while the attachment still exists). So for
252
+ # markdown we detach only blobs that were previously referenced. HTML keeps
253
+ # full detach (backfill embeds its orphans, so none should linger).
197
254
  def reconcile_description_attachments
198
- return if data&.dig("content_type") == "markdown"
199
255
  # Linked creatives don't own their description (it lives on the origin)
200
256
  # and their own column is blank, so reconcile would treat every legacy
201
257
  # attachment as an orphan and purge it on the next save (e.g. a
@@ -212,8 +268,16 @@ module Collavre
212
268
  current = files.includes(:blob).to_a
213
269
  current_blob_ids = current.map(&:blob_id).to_set
214
270
 
271
+ markdown = data&.dig("content_type") == "markdown"
215
272
  to_attach = referenced.reject { |b| current_blob_ids.include?(b.id) }
216
- to_detach = current.reject { |a| referenced_ids.include?(a.blob_id) }
273
+ unreferenced = current.reject { |a| referenced_ids.include?(a.blob_id) }
274
+ to_detach =
275
+ if markdown
276
+ prev_referenced_ids = blob_ids_referenced_in(description_before_last_save)
277
+ unreferenced.select { |a| prev_referenced_ids.include?(a.blob_id) }
278
+ else
279
+ unreferenced
280
+ end
217
281
  return if to_attach.empty? && to_detach.empty?
218
282
 
219
283
  to_attach.each { |blob| files.attach(blob) }
@@ -266,9 +330,13 @@ module Collavre
266
330
  end
267
331
 
268
332
  def extract_signed_ids_from_description
269
- return [] if description.blank?
333
+ extract_signed_ids_from(description)
334
+ end
270
335
 
271
- html = description.to_s
336
+ def extract_signed_ids_from(html)
337
+ return [] if html.blank?
338
+
339
+ html = html.to_s
272
340
 
273
341
  ids = html.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/?#]+)}).flatten
274
342
  ids += html.scan(%r{/rails/active_storage/blobs/([^/?#]+)}).flatten
@@ -277,6 +345,17 @@ module Collavre
277
345
  ids.uniq
278
346
  end
279
347
 
348
+ # Blob ids that `html` references, resolving each signed id to its blob.
349
+ # Used to tell user-removed media (was referenced, now gone) apart from
350
+ # legacy orphans (never referenced) during markdown reconcile.
351
+ def blob_ids_referenced_in(html)
352
+ extract_signed_ids_from(html).filter_map do |sid|
353
+ ActiveStorage::Blob.find_signed(sid)&.id
354
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
355
+ nil
356
+ end.to_set
357
+ end
358
+
280
359
  def description_cannot_change_if_has_origin
281
360
  if origin_id.present? && will_save_change_to_description?
282
361
  errors.add(:description, "cannot be changed directly when linked to an origin")
@@ -24,6 +24,21 @@ module Collavre
24
24
  rel = rel.where(creative_id: creative_id) if creative_id
25
25
  rel.order(:created_at)
26
26
  }
27
+ # Tasks that hold a topic concurrency slot: running/delegated (executing)
28
+ # plus pending — a task that has been claimed (dequeue_next_for_topic moves a
29
+ # waiter queued -> pending, a retry re-queues to pending, initial dispatch
30
+ # creates pending) but whose AiAgentJob has not started yet — plus
31
+ # pending_approval — a task paused awaiting tool approval that intentionally
32
+ # keeps its resource (AiAgentJob sets should_release = false) and does NOT
33
+ # drain the topic queue (dequeue_next_for_topic only fires on terminal
34
+ # statuses done/failed/cancelled/escalated). Orphan detection must count all
35
+ # of these, otherwise a claimed-but-not-started or approval-paused slot looks
36
+ # free and a second waiter gets promoted into the same slot.
37
+ scope :occupying_topic_slot, ->(topic_id, creative_id = nil) {
38
+ rel = where(topic_id: topic_id, status: %w[running delegated pending pending_approval])
39
+ rel = rel.where(creative_id: creative_id) if creative_id
40
+ rel
41
+ }
27
42
 
28
43
  # Check if agent already has an in-flight task triggered by the same comment.
29
44
  # Treats "delegated" as in-flight: a Claude Channel task that is waiting on
@@ -44,6 +44,15 @@ module Collavre
44
44
  has_many :creatives, class_name: "Collavre::Creative", dependent: nil
45
45
  before_destroy :destroy_creatives_leaf_first
46
46
 
47
+ # /compress and /merge summaries are durable recovery artifacts: they replace
48
+ # the deleted original conversation and anchor the restore control (rendered
49
+ # from the surviving result comment via CommentSnapshot). Detach them (nullify
50
+ # author) BEFORE the comments dependent: :destroy cascade — prepend ensures we
51
+ # run first — so deleting the authoring agent/user doesn't destroy the summary
52
+ # and orphan the snapshot, which would erase the only path to restore the
53
+ # compressed conversation.
54
+ before_destroy :preserve_durable_summary_comments, prepend: true
55
+
47
56
  belongs_to :creator, class_name: "Collavre::User", foreign_key: "created_by_id", optional: true
48
57
  has_many :created_ai_users, class_name: "Collavre::User", foreign_key: "created_by_id", dependent: :destroy
49
58
 
@@ -58,6 +67,15 @@ module Collavre
58
67
  attribute :system_admin, :boolean, default: false
59
68
  attribute :searchable, :boolean, default: false
60
69
 
70
+ # Typo correction (2D gating: typing-device AND input-location must both be on).
71
+ attribute :typo_correction_enabled, :boolean, default: true
72
+ attribute :typo_correction_threshold, :integer, default: 80
73
+ attribute :typo_correction_on_soft_keyboard, :boolean, default: true
74
+ attribute :typo_correction_on_voice, :boolean, default: true
75
+ attribute :typo_correction_on_physical_keyboard, :boolean, default: false
76
+ attribute :typo_correction_in_chat, :boolean, default: true
77
+ attribute :typo_correction_in_editor, :boolean, default: false
78
+
61
79
  attribute :google_uid, :string
62
80
  attribute :google_access_token, :string
63
81
  attribute :google_refresh_token, :string
@@ -140,6 +158,31 @@ module Collavre
140
158
  end
141
159
  end
142
160
 
161
+ TYPO_CORRECTION_DEVICES = %w[voice soft_keyboard physical_keyboard].freeze
162
+ TYPO_CORRECTION_LOCATIONS = %w[chat editor].freeze
163
+
164
+ # 2D gating: typo correction runs only when the master switch is on AND the
165
+ # originating typing device AND the input location are both enabled. Unknown
166
+ # device/location values are treated as disabled (fail closed).
167
+ def typo_correction_active_for?(device:, location:)
168
+ return false unless typo_correction_enabled
169
+
170
+ device_on = case device.to_s
171
+ when "voice" then typo_correction_on_voice
172
+ when "soft_keyboard" then typo_correction_on_soft_keyboard
173
+ when "physical_keyboard" then typo_correction_on_physical_keyboard
174
+ else false
175
+ end
176
+
177
+ location_on = case location.to_s
178
+ when "chat" then typo_correction_in_chat
179
+ when "editor" then typo_correction_in_editor
180
+ else false
181
+ end
182
+
183
+ device_on && location_on
184
+ end
185
+
143
186
  LLM_VENDOR_OPTIONS = [
144
187
  [ "Google (Gemini)", "google" ],
145
188
  [ "OpenAI", "openai" ],
@@ -148,7 +191,7 @@ module Collavre
148
191
  ].freeze
149
192
 
150
193
  SUPPORTED_LLM_MODELS = [
151
- "gemini-3-flash-preview",
194
+ "gemini-3.1-flash-lite",
152
195
  "gemini-1.5-flash",
153
196
  "gemini-1.5-pro"
154
197
  ].freeze
@@ -194,6 +237,11 @@ module Collavre
194
237
  validates :timezone,
195
238
  inclusion: { in: ActiveSupport::TimeZone.all.map { |z| z.tzinfo.identifier } },
196
239
  allow_nil: true
240
+ # Column is NOT NULL; clearing the profile field (or a crafted PATCH) casts to
241
+ # nil and would raise a DB error on save. Validate so the form re-renders.
242
+ validates :typo_correction_threshold,
243
+ presence: true,
244
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
197
245
 
198
246
  generates_token_for :email_verification, expires_in: 1.day do
199
247
  email
@@ -255,6 +303,14 @@ module Collavre
255
303
  end
256
304
  end
257
305
 
306
+ # Keep durable compress/merge summaries (snapshot result comments) alive when
307
+ # their author is deleted: nullify authorship instead of cascading destroy.
308
+ def preserve_durable_summary_comments
309
+ Collavre::Comment
310
+ .where(id: Collavre::CommentSnapshot.where(result_comment_id: comments.select(:id)).select(:result_comment_id))
311
+ .update_all(user_id: nil)
312
+ end
313
+
258
314
  # Destroy creatives deepest-first so closure_tree always finds its parent
259
315
  def destroy_creatives_leaf_first
260
316
  all_creatives = creatives.flat_map { |c| c.self_and_descendants.to_a }.uniq
@@ -10,13 +10,18 @@ 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
+ # log_interactions: persist each call to ActivityLog. Default true. Pass false
14
+ # for ephemeral, high-frequency calls on text the user has not submitted (e.g.
15
+ # inline typo correction on debounced typing) so private drafts are never
16
+ # written to server-side activity logs.
17
+ def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true)
14
18
  @vendor = vendor
15
19
  @model = model
16
20
  @system_prompt = system_prompt
17
21
  @llm_api_key = llm_api_key
18
22
  @gateway_url = gateway_url
19
23
  @context = context
24
+ @log_interactions = log_interactions
20
25
  @last_input_tokens = 0
21
26
  @last_output_tokens = 0
22
27
  end
@@ -64,7 +69,13 @@ module Collavre
64
69
  raise # Re-raise cancellation errors without catching them
65
70
  rescue StandardError => e
66
71
  error_message = "[#{e.class.name}] #{e.message}"
67
- Rails.logger.error "AI Client error: #{error_message}"
72
+ # When log_interactions is false (inline typo correction runs on the user's
73
+ # *unsubmitted* draft), the LLM error message can echo the request text. Log
74
+ # only the error class to app logs so private drafts never leak — matching the
75
+ # no-log guarantee already enforced on the parse path (TypoCorrector) and the
76
+ # ActivityLog gate below. error_message stays intact for the gated ensure log
77
+ # and the streamed yield (which goes back to the same user).
78
+ Rails.logger.error "AI Client error: #{@log_interactions ? error_message : "[#{e.class.name}]"}"
68
79
  Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present?
69
80
  Rails.logger.debug e.backtrace.join("\n")
70
81
  yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
@@ -72,14 +83,16 @@ module Collavre
72
83
  ensure
73
84
  @last_input_tokens = input_tokens || 0
74
85
  @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
- )
86
+ if @log_interactions
87
+ log_interaction(
88
+ messages: @conversation&.messages&.to_a || Array(contents),
89
+ tools: @conversation&.tools&.to_a || [],
90
+ response_content: response_content.presence,
91
+ error_message: error_message,
92
+ input_tokens: input_tokens,
93
+ output_tokens: output_tokens
94
+ )
95
+ end
83
96
  end
84
97
 
85
98
  # Ask a follow-up question using the existing conversation context.
@@ -115,6 +128,11 @@ module Collavre
115
128
  when "openai"
116
129
  api_key = @llm_api_key.presence || IntegrationSettings.fetch(:openai_api_key)
117
130
  base_url = @gateway_url.presence
131
+ # A custom OpenAI-compatible gateway (local Ollama / LM Studio, etc.) needs
132
+ # no real OpenAI key, but RubyLLM raises ConfigurationError before sending
133
+ # if openai_api_key is blank. Supply a placeholder so keyless local gateways
134
+ # work; hosted OpenAI (no gateway) still requires a real key.
135
+ api_key = "local-gateway" if api_key.blank? && base_url
118
136
  proc do |config|
119
137
  config.openai_api_key = api_key
120
138
  config.openai_api_base = base_url if base_url
@@ -195,7 +195,7 @@ module Collavre
195
195
  def default_client
196
196
  AiClient.new(
197
197
  vendor: "google",
198
- model: "gemini-3-flash-preview",
198
+ model: "gemini-3.1-flash-lite",
199
199
  system_prompt: nil
200
200
  )
201
201
  end