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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
# the
|
|
178
|
-
|
|
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
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
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
|
|
196
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
333
|
+
extract_signed_ids_from(description)
|
|
334
|
+
end
|
|
270
335
|
|
|
271
|
-
|
|
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")
|
data/app/models/collavre/task.rb
CHANGED
|
@@ -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
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|