collavre 0.3.2 → 0.5.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +73 -71
  3. data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
  4. data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
  5. data/app/assets/stylesheets/collavre/creatives.css +101 -51
  6. data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
  7. data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
  8. data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
  9. data/app/assets/stylesheets/collavre/popup.css +57 -27
  10. data/app/assets/stylesheets/collavre/slide_view.css +6 -6
  11. data/app/assets/stylesheets/collavre/user_menu.css +4 -5
  12. data/app/components/collavre/plans_timeline_component.html.erb +2 -2
  13. data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
  14. data/app/controllers/collavre/admin/settings_controller.rb +199 -0
  15. data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
  16. data/app/controllers/collavre/comments_controller.rb +39 -162
  17. data/app/controllers/collavre/creatives_controller.rb +18 -58
  18. data/app/controllers/collavre/users_controller.rb +31 -3
  19. data/app/helpers/collavre/application_helper.rb +97 -0
  20. data/app/helpers/collavre/creatives_helper.rb +10 -202
  21. data/app/javascript/collavre.js +0 -1
  22. data/app/javascript/components/creative_tree_row.js +3 -2
  23. data/app/javascript/controllers/comment_controller.js +309 -4
  24. data/app/javascript/controllers/comments/form_controller.js +52 -0
  25. data/app/javascript/controllers/comments/presence_controller.js +13 -0
  26. data/app/javascript/controllers/creatives/tree_controller.js +2 -1
  27. data/app/javascript/controllers/link_creative_controller.js +29 -3
  28. data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
  29. data/app/javascript/lib/html_code_block_wrapper.js +168 -0
  30. data/app/javascript/lib/utils/markdown.js +2 -1
  31. data/app/javascript/modules/creative_row_editor.js +5 -1
  32. data/app/javascript/utils/emoji_parser.js +21 -0
  33. data/app/jobs/collavre/ai_agent_job.rb +6 -2
  34. data/app/jobs/collavre/cron_action_job.rb +18 -6
  35. data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
  36. data/app/models/collavre/comment/approvable.rb +50 -0
  37. data/app/models/collavre/comment/broadcastable.rb +119 -0
  38. data/app/models/collavre/comment/notifiable.rb +111 -0
  39. data/app/models/collavre/comment.rb +13 -258
  40. data/app/models/collavre/comment_reaction.rb +15 -0
  41. data/app/models/collavre/creative/describable.rb +86 -0
  42. data/app/models/collavre/creative/linkable.rb +77 -0
  43. data/app/models/collavre/creative/permissible.rb +103 -0
  44. data/app/models/collavre/creative.rb +3 -289
  45. data/app/models/collavre/orchestrator_policy.rb +1 -1
  46. data/app/models/collavre/system_setting.rb +27 -1
  47. data/app/models/collavre/user.rb +42 -0
  48. data/app/models/collavre/user_theme.rb +10 -0
  49. data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
  50. data/app/services/collavre/ai_agent/message_builder.rb +129 -0
  51. data/app/services/collavre/ai_agent/review_handler.rb +70 -0
  52. data/app/services/collavre/ai_agent_service.rb +93 -150
  53. data/app/services/collavre/ai_client.rb +23 -4
  54. data/app/services/collavre/auto_theme_generator.rb +168 -50
  55. data/app/services/collavre/command_menu_service.rb +70 -0
  56. data/app/services/collavre/comment_move_service.rb +94 -0
  57. data/app/services/collavre/comments/action_executor.rb +10 -0
  58. data/app/services/collavre/comments/mcp_command.rb +1 -2
  59. data/app/services/collavre/creatives/create_service.rb +86 -0
  60. data/app/services/collavre/creatives/destroy_service.rb +41 -0
  61. data/app/services/collavre/creatives/index_query.rb +3 -0
  62. data/app/services/collavre/markdown_converter.rb +240 -0
  63. data/app/services/collavre/mention_parser.rb +63 -0
  64. data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
  65. data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
  66. data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
  67. data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
  68. data/app/services/collavre/orchestration/scheduler.rb +4 -3
  69. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  70. data/app/services/collavre/system_events/context_builder.rb +1 -6
  71. data/app/services/collavre/tools/creative_batch_service.rb +107 -0
  72. data/app/services/collavre/tools/creative_update_service.rb +17 -12
  73. data/app/services/collavre/tools/cron_create_service.rb +17 -5
  74. data/app/views/admin/shared/_tabs.html.erb +2 -1
  75. data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
  76. data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
  77. data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
  78. data/app/views/collavre/admin/settings/index.html.erb +11 -0
  79. data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
  80. data/app/views/collavre/comments/_comment.html.erb +15 -5
  81. data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
  82. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
  83. data/app/views/collavre/creatives/_share_button.html.erb +0 -52
  84. data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
  85. data/app/views/collavre/creatives/index.html.erb +5 -8
  86. data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
  87. data/app/views/collavre/user_themes/index.html.erb +7 -9
  88. data/app/views/collavre/users/_contact_management.html.erb +2 -1
  89. data/app/views/collavre/users/edit_ai.html.erb +7 -0
  90. data/app/views/collavre/users/index.html.erb +16 -1
  91. data/app/views/collavre/users/new_ai.html.erb +18 -8
  92. data/app/views/collavre/users/passkeys.html.erb +1 -1
  93. data/app/views/collavre/users/show.html.erb +1 -1
  94. data/app/views/layouts/collavre/slide.html.erb +8 -1
  95. data/config/locales/admin.en.yml +88 -0
  96. data/config/locales/admin.ko.yml +88 -0
  97. data/config/locales/ai_agent.en.yml +5 -1
  98. data/config/locales/ai_agent.ko.yml +5 -1
  99. data/config/locales/comments.en.yml +5 -1
  100. data/config/locales/comments.ko.yml +5 -1
  101. data/config/locales/orchestration.en.yml +8 -0
  102. data/config/locales/orchestration.ko.yml +8 -0
  103. data/config/locales/users.en.yml +12 -0
  104. data/config/locales/users.ko.yml +12 -0
  105. data/config/routes.rb +7 -1
  106. data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
  107. data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
  108. data/lib/collavre/engine.rb +25 -0
  109. data/lib/collavre/version.rb +1 -1
  110. metadata +32 -1
@@ -12,36 +12,10 @@ module Collavre
12
12
 
13
13
  after_save :touch_subtree_on_move, if: :saved_change_to_parent_id?
14
14
 
15
- unless const_defined?(:DEFAULT_GITHUB_GEMINI_PROMPT)
16
- DEFAULT_GITHUB_GEMINI_PROMPT = <<~PROMPT.freeze
17
- You are reviewing a GitHub pull request and mapping it to Creative tasks.
18
- Pull request title: \#{pr_title}
19
- Pull request body:
20
- \#{pr_body}
21
15
 
22
- Pull request commit messages:
23
- \#{commit_messages}
24
-
25
- Pull request diff:
26
- \#{diff}
27
-
28
- Creative tree structure. Each line represents a creative node with indentation indicating depth (4 spaces per level).
29
- Format: - {"id": <ID>, "progress": <0.0-1.0>, "desc": "<Description>"}
30
- \#{creative_tree}
31
-
32
- \#{language_instructions}
33
-
34
- When describing creatives, write from an end-user perspective similar to a user manual. Avoid unnecessary technical detail, and keep sentences concise.
35
-
36
- Return a JSON object with two keys:
37
- - "completed": array of objects representing tasks finished by this PR. Each object must include "creative_id" (from the IDs above). Use only creatives marked [LEAF] in the list above. Optionally include "progress" (0.0 to 1.0), "note", or "path" for context.
38
- - "additional": array of objects for new creatives that are not already represented in the tree above. Each object must include "parent_id" (from the IDs above) and "description" (the new creative text). Do not use this list for follow-up tasks on existing creatives—only describe brand new creatives. Optionally include "progress" (0.0 to 1.0), "note", or "path".
39
-
40
- Do not add tasks to "completed" if they already show 100% progress in the tree above unless this PR clearly made new changes that justify marking them complete.
41
-
42
- Use only IDs present in the tree. Respond with valid JSON only.
43
- PROMPT
44
- end
16
+ include Linkable
17
+ include Permissible
18
+ include Describable
45
19
 
46
20
  has_many :comments, class_name: "Collavre::Comment", dependent: :destroy
47
21
  has_many :comment_read_pointers, class_name: "Collavre::CommentReadPointer", dependent: :delete_all
@@ -50,12 +24,8 @@ module Collavre
50
24
 
51
25
  attr_accessor :filtered_progress
52
26
 
53
- belongs_to :origin, class_name: "Collavre::Creative", optional: true
54
- has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :destroy
55
27
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
56
28
 
57
- has_many :creative_shares, class_name: "Collavre::CreativeShare", dependent: :destroy
58
- has_many :creative_shares_caches, class_name: "Collavre::CreativeSharesCache", dependent: :delete_all
59
29
  has_many :tags, class_name: "Collavre::Tag", dependent: :destroy
60
30
  has_many :creative_expanded_states, class_name: "Collavre::CreativeExpandedState", dependent: :delete_all
61
31
  has_many :invitations, class_name: "Collavre::Invitation", dependent: :delete_all
@@ -67,155 +37,26 @@ module Collavre
67
37
  has_many :labels, class_name: "Collavre::Label", dependent: :destroy
68
38
 
69
39
  validates :progress, numericality: { greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0 }, unless: -> { origin_id.present? }
70
- validates :description, presence: true, unless: -> { origin_id.present? }
71
40
 
72
41
  validate :progress_cannot_change_if_has_origin, on: :update
73
- validate :description_cannot_change_if_has_origin, on: :update
74
- validate :origin_cannot_be_self
75
42
 
76
43
  before_validation :assign_default_user, on: :create
77
- before_validation :redirect_parent_to_origin
78
- before_save :sanitize_description_html
79
44
 
80
45
  after_save :update_parent_progress
81
46
  after_destroy :update_parent_progress
82
- after_destroy_commit :purge_description_attachments
83
47
  after_save :update_mcp_tools
84
48
 
85
- after_commit :rebuild_permission_cache, if: :saved_change_to_parent_id?, unless: :destroyed?
86
- after_commit :cache_owner_permission, on: :create
87
- after_commit :update_owner_cache, if: :saved_change_to_user_id?, unless: :destroyed?
88
-
89
-
90
-
91
-
92
- def has_permission?(user, required_permission = :read)
93
- Collavre::Creatives::PermissionChecker.new(self, user).allowed?(required_permission)
94
- end
95
-
96
- # Returns only children for which the user has at least the given permission (default: :read)
97
- def children_with_permission(user = nil, min_permission = :read)
98
- user ||= Collavre.current_user
99
- children_scope = effective_origin(Set.new).children
100
- children_ids = children_scope.pluck(:id)
101
- return [] if children_ids.empty?
102
-
103
- min_rank = CreativeShare.permissions[min_permission.to_s]
104
- accessible_ids = Set.new
105
-
106
- if user
107
- # 사용자별 엔트리 확인 (user-specific entry가 있으면 public share보다 우선)
108
- user_entries = CreativeSharesCache
109
- .where(creative_id: children_ids, user_id: user.id)
110
- .pluck(:creative_id, :permission)
111
-
112
- user_has_entry = Set.new # user-specific entry가 있는 children
113
- user_entries.each do |cid, perm|
114
- user_has_entry << cid
115
- # perm is string from enum, convert to integer for comparison
116
- perm_rank = CreativeSharesCache.permissions[perm]
117
- # User-specific entry with sufficient permission grants access
118
- if perm_rank && perm_rank >= min_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
119
- accessible_ids << cid
120
- end
121
- # If insufficient or no_access, don't add to accessible - will be excluded from public fallback
122
- end
123
-
124
- # Public share 확인 (user-specific entry가 있는 건 제외 - user entry가 우선)
125
- public_accessible = CreativeSharesCache
126
- .where(creative_id: children_ids, user_id: nil)
127
- .where("permission >= ?", min_rank)
128
- .where.not(permission: :no_access)
129
- .pluck(:creative_id)
130
- accessible_ids.merge(public_accessible - user_has_entry.to_a)
131
-
132
- # Fallback: include owned children (for fixtures and missing cache entries)
133
- owned_ids = children_scope.where(user_id: user.id).pluck(:id)
134
- accessible_ids.merge(owned_ids)
135
- else
136
- # 비로그인: public share만
137
- accessible_ids = CreativeSharesCache
138
- .where(creative_id: children_ids, user_id: nil)
139
- .where("permission >= ?", min_rank)
140
- .where.not(permission: :no_access)
141
- .pluck(:creative_id)
142
- .to_set
143
- end
144
-
145
- children_scope.where(id: accessible_ids.to_a).order(:sequence).to_a
146
- end
147
-
148
- # Returns the effective attribute for linked creatives
149
- def effective_attribute(attr, visited_ids = Set.new)
150
- return self[attr] if origin_id.nil? || attr.to_s == "parent_id"
151
- return self[attr] if visited_ids.include?(id)
152
-
153
- visited_ids.add(id)
154
- origin.effective_attribute(attr, visited_ids)
155
- end
156
-
157
- def effective_origin(visited_ids = Set.new)
158
- return self if origin_id.nil?
159
- return self if visited_ids.include?(id)
160
-
161
- visited_ids.add(id)
162
- origin.effective_origin(visited_ids)
163
- end
164
-
165
49
  # Compatibility helper: ancestry gem exposes `subtree_ids`, while
166
50
  # closure_tree typically uses `self_and_descendants`.
167
- # Provide `subtree_ids` so call sites (e.g., controller search) work.
168
51
  def subtree_ids
169
52
  self_and_descendants.pluck(:id)
170
53
  end
171
54
 
172
- # Linked Creative의 description을 안전하게 반환
173
- # variation_id가 주어지면 해당 Variation의 Tag value를 반환, 없으면 기존 description 반환
174
- def effective_description(variation_id = nil, html = true)
175
- if variation_id.present?
176
- variation_tag = tags.find_by(label_id: variation_id)
177
- return variation_tag.value if variation_tag&.value.present?
178
- end
179
- if origin_id.nil?
180
- description_val = description
181
- else
182
- description_val = origin.description
183
- end
184
- if html
185
- description_val&.to_s || ""
186
- else
187
- # For plain text, we might need to strip tags if description is HTML
188
- # But the original code used rich_text_description&.body which returns ActionText::Content
189
- # which has to_s (HTML) and to_plain_text.
190
- # Since we now store raw HTML, we should strip tags for plain text.
191
- ActionController::Base.helpers.strip_tags(description_val&.to_s || "")
192
- end
193
- end
194
-
195
- def creative_snippet
196
- ActionController::Base.helpers.strip_tags(effective_origin.description || "").truncate(24, omission: "...")
197
- end
198
-
199
- def progress
200
- effective_attribute(:progress, Set.new)
201
- end
202
-
203
- def user
204
- target = effective_origin(Set.new)
205
- return super if target == self
206
-
207
- target.user
208
- end
209
-
210
55
  def children
211
56
  # better not override this method, use children_with_permission instead or linked_children
212
57
  super
213
58
  end
214
59
 
215
- def linked_children
216
- origin_id.nil? ? children_with_permission(Collavre.current_user, :read) : origin&.children_with_permission(Collavre.current_user, :read) || []
217
- end
218
-
219
60
  def owning_parent
220
61
  if parent.present?
221
62
  Creative.find_by(origin_id: parent.id, user: Collavre.current_user) || parent
@@ -236,29 +77,10 @@ module Collavre
236
77
  progress_service.progress_for_tags(tag_ids, user)
237
78
  end
238
79
 
239
- # Calculate progress for the subtree ignoring permission checks.
240
- # `tagged_ids` should be a Set of creative IDs that are tagged with the plan.
241
80
  def progress_for_plan(tagged_ids)
242
81
  progress_service.progress_for_plan(tagged_ids)
243
82
  end
244
83
 
245
- # 공유 대상 사용자를 위해 Linked Creative를 생성합니다.
246
- # 이미 존재하거나 원본 작성자에게는 생성하지 않습니다.
247
- def create_linked_creative_for_user(user)
248
- original = effective_origin(Set.new)
249
- return if original.user_id == user.id
250
- ancestor_ids = original.ancestors.pluck(:id)
251
- has_ancestor_share = CreativeShare.where(creative_id: ancestor_ids, user_id: user.id)
252
- .where.not(permission: :no_access)
253
- .exists?
254
- has_owning_ancestors = Creative.where(id: ancestor_ids, user_id: user.id)
255
- .exists?
256
- return if has_ancestor_share or has_owning_ancestors
257
- Creative.find_or_create_by!(origin_id: original.id, user_id: user.id) do |c|
258
- c.parent_id = nil
259
- end
260
- end
261
-
262
84
  def update_parent_progress
263
85
  progress_service.update_parent_progress!
264
86
 
@@ -270,77 +92,9 @@ module Collavre
270
92
  end
271
93
  end
272
94
 
273
- def all_shared_users(required_permission = :no_access)
274
- base_creative = effective_origin(Set.new)
275
- ancestor_ids = [ base_creative.id ] + base_creative.ancestors.pluck(:id)
276
- required_permission_level = CreativeShare.permissions.fetch(required_permission.to_s)
277
-
278
- shares = CreativeShare.where(creative_id: ancestor_ids).includes(:user)
279
- shares_for_user_hash = shares.group_by(&:user_id)
280
-
281
- shares_for_user_hash.filter_map do |_user_id, user_shares|
282
- closest_share = CreativeShare.closest_parent_share(ancestor_ids, user_shares)
283
- next unless closest_share
284
-
285
- closest_permission_level = CreativeShare.permissions.fetch(closest_share.permission.to_s)
286
- next if closest_permission_level < required_permission_level
287
-
288
- closest_share
289
- end
290
- end
291
-
292
- def github_gemini_prompt_template
293
- github_gemini_prompt.presence || DEFAULT_GITHUB_GEMINI_PROMPT
294
- end
295
95
 
296
96
  private
297
97
 
298
- def purge_description_attachments
299
- return if description.blank?
300
-
301
- signed_ids = extract_signed_ids_from_description
302
- return if signed_ids.empty?
303
-
304
- signed_ids.each do |signed_id|
305
- begin
306
- blob = ActiveStorage::Blob.find_signed(signed_id)
307
- next unless blob
308
-
309
- # Skip purging if another creative still references the blob
310
- next if Creative.where.not(id: id)
311
- .where("description LIKE ?", "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%")
312
- .exists?
313
-
314
- blob.purge
315
- rescue ActiveRecord::RecordNotFound, ActiveSupport::MessageVerifier::InvalidSignature
316
- Rails.logger.warn("Creative##{id}: could not find blob for signed_id=#{signed_id}")
317
- rescue => e
318
- Rails.logger.error("Creative##{id}: failed to purge blob #{signed_id}: #{e.message}")
319
- end
320
- end
321
- end
322
-
323
- def sanitize_description_html
324
- table_tags = %w[table thead tbody tfoot tr th td]
325
- table_attrs = %w[colspan rowspan]
326
- self.description = ActionController::Base.helpers.sanitize(
327
- description,
328
- tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags,
329
- attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + %w[data-lexical]
330
- )
331
- end
332
-
333
- def extract_signed_ids_from_description
334
- return [] if description.blank?
335
-
336
- html = description.to_s
337
-
338
- ids = html.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/?#]+)}).flatten
339
- ids += html.scan(%r{/rails/active_storage/blobs/([^/?#]+)}).flatten
340
-
341
- ids.uniq
342
- end
343
-
344
98
  def assign_default_user
345
99
  return if user.present?
346
100
  if parent_id.present? && parent
@@ -354,12 +108,6 @@ module Collavre
354
108
  @progress_service ||= Collavre::Creatives::ProgressService.new(self)
355
109
  end
356
110
 
357
- def redirect_parent_to_origin
358
- if parent&.origin_id.present?
359
- self.parent = parent.origin
360
- end
361
- end
362
-
363
111
  def update_mcp_tools
364
112
  McpService.new.update_from_creative(self)
365
113
  end
@@ -370,41 +118,7 @@ module Collavre
370
118
  end
371
119
  end
372
120
 
373
- def description_cannot_change_if_has_origin
374
- if origin_id.present? && will_save_change_to_description?
375
- errors.add(:description, "cannot be changed directly when linked to an origin")
376
- end
377
- end
378
-
379
- def origin_cannot_be_self
380
- if origin_id.present? && origin_id == id
381
- errors.add(:origin_id, "cannot be the same as id")
382
- end
383
- end
384
-
385
- def rebuild_permission_cache
386
- PermissionCacheJob.perform_later(:rebuild_for_creative, creative_id: id)
387
- end
388
-
389
- def cache_owner_permission
390
- PermissionCacheJob.perform_later(:cache_owner, creative_id: id)
391
- end
392
-
393
- def update_owner_cache
394
- old_user_id, new_user_id = saved_change_to_user_id
395
- PermissionCacheJob.perform_later(:update_owner,
396
- creative_id: id,
397
- old_user_id: old_user_id,
398
- new_user_id: new_user_id
399
- )
400
- end
401
-
402
- private
403
-
404
121
  def touch_subtree_on_move
405
- # When moving a tree, all descendants might have new effective permissions
406
- # so we must touch them to invalidate cache.
407
- # self is already touched by save.
408
122
  descendants.update_all(updated_at: Time.current)
409
123
  end
410
124
  end
@@ -38,7 +38,7 @@ module Collavre
38
38
  SCOPE_TYPES = %w[Creative Topic User].freeze
39
39
 
40
40
  # Policy types
41
- POLICY_TYPES = %w[matching arbitration scheduling stuck_detection].freeze
41
+ POLICY_TYPES = %w[matching arbitration scheduling stuck_detection collaboration].freeze
42
42
 
43
43
  # Arbitration strategies
44
44
  ARBITRATION_STRATEGIES = %w[all primary_first round_robin bid].freeze
@@ -28,6 +28,11 @@ module Collavre
28
28
  # Default home page path (nil means use root_path "/")
29
29
  DEFAULT_HOME_PAGE_PATH = nil
30
30
 
31
+ # Default theme IDs (nil means use built-in light/dark)
32
+ # These reference UserTheme IDs for admin-configured custom themes
33
+ DEFAULT_LIGHT_THEME_ID = nil
34
+ DEFAULT_DARK_THEME_ID = nil
35
+
31
36
  validates :key, presence: true, uniqueness: true
32
37
 
33
38
  # Clear cache after save
@@ -47,7 +52,7 @@ module Collavre
47
52
  lockout_duration_minutes session_timeout_minutes password_min_length
48
53
  password_reset_rate_limit password_reset_rate_period_minutes
49
54
  api_rate_limit api_rate_period_minutes auth_providers_disabled
50
- creatives_login_required home_page_path
55
+ creatives_login_required home_page_path default_light_theme_id default_dark_theme_id
51
56
  ].each { |k| Rails.cache.delete("system_setting:#{k}") }
52
57
  end
53
58
 
@@ -140,5 +145,26 @@ module Collavre
140
145
  def self.api_rate_period
141
146
  api_rate_period_minutes.minutes
142
147
  end
148
+
149
+ # Default theme IDs for users without a personal theme preference
150
+ def self.default_light_theme_id
151
+ value = cached_value("default_light_theme_id")
152
+ value.present? && value.to_i.positive? ? value.to_i : nil
153
+ end
154
+
155
+ def self.default_dark_theme_id
156
+ value = cached_value("default_dark_theme_id")
157
+ value.present? && value.to_i.positive? ? value.to_i : nil
158
+ end
159
+
160
+ def self.default_light_theme
161
+ id = default_light_theme_id
162
+ id ? Collavre::UserTheme.find_by(id: id) : nil
163
+ end
164
+
165
+ def self.default_dark_theme
166
+ id = default_dark_theme_id
167
+ id ? Collavre::UserTheme.find_by(id: id) : nil
168
+ end
143
169
  end
144
170
  end
@@ -69,6 +69,48 @@ module Collavre
69
69
  attribute :llm_api_key, :string
70
70
  attribute :gateway_url, :string
71
71
  attribute :tools, :json, default: -> { [] }
72
+ attribute :agent_conf, :string
73
+
74
+ # Default context settings for AI agents
75
+ AGENT_CONF_DEFAULTS = {
76
+ "context" => {
77
+ "chat_history" => 50,
78
+ "chat_history_size" => 100_000,
79
+ "creative_children_level" => nil
80
+ }
81
+ }.freeze
82
+
83
+ # Default creative children level when agent_conf doesn't specify one
84
+ DEFAULT_CREATIVE_CHILDREN_LEVEL = 6
85
+
86
+ # Returns parsed agent_conf merged with defaults
87
+ def parsed_agent_conf
88
+ defaults = AGENT_CONF_DEFAULTS.deep_dup
89
+ return defaults if agent_conf.blank?
90
+
91
+ user_conf = YAML.safe_load(agent_conf, permitted_classes: [ Symbol ]) || {}
92
+ defaults.deep_merge(user_conf)
93
+ rescue Psych::SyntaxError => e
94
+ Rails.logger.warn("[User#parsed_agent_conf] Invalid YAML for user #{id}: #{e.message}")
95
+ AGENT_CONF_DEFAULTS.deep_dup
96
+ end
97
+
98
+ # Convenience accessors for context settings
99
+ def chat_history_limit
100
+ parsed_agent_conf.dig("context", "chat_history") || 50
101
+ end
102
+
103
+ def chat_history_size_limit
104
+ parsed_agent_conf.dig("context", "chat_history_size") || 100_000
105
+ end
106
+
107
+ # Returns the creative children depth level for AI context.
108
+ # nil in agent_conf means use the default (6).
109
+ # 0 = no children, 1 = direct children only, 2 = grandchildren, etc.
110
+ def creative_children_level
111
+ level = parsed_agent_conf.dig("context", "creative_children_level")
112
+ level.nil? ? DEFAULT_CREATIVE_CHILDREN_LEVEL : level.to_i
113
+ end
72
114
 
73
115
  encrypts :llm_api_key, deterministic: false
74
116
  encrypts :google_access_token, deterministic: false
@@ -6,5 +6,15 @@ module Collavre
6
6
 
7
7
  validates :name, presence: true
8
8
  validates :variables, presence: true
9
+
10
+ # Returns true if the theme has a dark background (lightness < 50%)
11
+ def dark?
12
+ bg = variables["--surface-bg"].to_s
13
+ return false unless bg.match?(/\A#[0-9a-fA-F]{6}\z/)
14
+
15
+ r, g, b = bg[1..2].to_i(16), bg[3..4].to_i(16), bg[5..6].to_i(16)
16
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
17
+ luminance < 0.5
18
+ end
9
19
  end
10
20
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Handles tool-execution approval flow when an AI agent
6
+ # invokes a tool that requires human approval.
7
+ class ApprovalHandler
8
+ def initialize(task:, agent:, context:, creative: nil, reply_comment: nil)
9
+ @task = task
10
+ @agent = agent
11
+ @context = context
12
+ @creative = creative
13
+ @reply_comment = reply_comment
14
+ end
15
+
16
+ def handle(error)
17
+ cleanup_placeholder
18
+
19
+ broadcast_idle
20
+
21
+ update_task(error)
22
+
23
+ log_action(error)
24
+
25
+ create_approval_comment(error)
26
+ end
27
+
28
+ private
29
+
30
+ def cleanup_placeholder
31
+ @reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
32
+ end
33
+
34
+ def broadcast_idle
35
+ return unless @creative
36
+
37
+ CommentsPresenceChannel.broadcast_agent_status(
38
+ @creative.effective_origin.id,
39
+ status: "idle",
40
+ agent_id: @agent.id,
41
+ agent_name: @agent.display_name,
42
+ task_id: @task.id,
43
+ source_creative_id: @creative.id
44
+ )
45
+ end
46
+
47
+ def update_task(error)
48
+ @task.update!(
49
+ status: "pending_approval",
50
+ pending_tool_call: {
51
+ tool_name: error.tool_name,
52
+ tool_call_id: error.tool_call_id,
53
+ arguments: error.tool_arguments,
54
+ requested_at: Time.current.iso8601
55
+ }
56
+ )
57
+ end
58
+
59
+ def log_action(error)
60
+ @task.task_actions.create!(
61
+ action_type: "pending_approval",
62
+ payload: error.to_h,
63
+ status: "done"
64
+ )
65
+ end
66
+
67
+ def create_approval_comment(error)
68
+ return unless @creative
69
+
70
+ approver = @creative.user || User.find_by(id: @context.dig("comment", "user_id"))
71
+ return unless approver
72
+
73
+ action_payload = {
74
+ action: "execute_tool",
75
+ tool_name: error.tool_name,
76
+ arguments: error.tool_arguments,
77
+ resume: {
78
+ task_id: @task.id,
79
+ tool_call_id: error.tool_call_id
80
+ }
81
+ }
82
+
83
+ args_display = if error.tool_arguments.present?
84
+ JSON.pretty_generate(error.tool_arguments)
85
+ else
86
+ I18n.t("collavre.ai_agent.approval.no_arguments")
87
+ end
88
+
89
+ content = I18n.t(
90
+ "collavre.ai_agent.approval.message",
91
+ tool_name: error.tool_name,
92
+ arguments: args_display
93
+ )
94
+
95
+ original_comment = Comment.find_by(id: @context.dig("comment", "id"))
96
+ topic_id = original_comment&.topic_id
97
+
98
+ Comment.create!(
99
+ creative: @creative,
100
+ content: content,
101
+ user: @agent,
102
+ approver: approver,
103
+ action: JSON.pretty_generate(action_payload),
104
+ topic_id: topic_id,
105
+ private: false
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end