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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +73 -71
- data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
- data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
- data/app/assets/stylesheets/collavre/creatives.css +101 -51
- data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
- data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
- data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
- data/app/assets/stylesheets/collavre/popup.css +57 -27
- data/app/assets/stylesheets/collavre/slide_view.css +6 -6
- data/app/assets/stylesheets/collavre/user_menu.css +4 -5
- data/app/components/collavre/plans_timeline_component.html.erb +2 -2
- data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
- data/app/controllers/collavre/admin/settings_controller.rb +199 -0
- data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
- data/app/controllers/collavre/comments_controller.rb +39 -162
- data/app/controllers/collavre/creatives_controller.rb +18 -58
- data/app/controllers/collavre/users_controller.rb +31 -3
- data/app/helpers/collavre/application_helper.rb +97 -0
- data/app/helpers/collavre/creatives_helper.rb +10 -202
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/components/creative_tree_row.js +3 -2
- data/app/javascript/controllers/comment_controller.js +309 -4
- data/app/javascript/controllers/comments/form_controller.js +52 -0
- data/app/javascript/controllers/comments/presence_controller.js +13 -0
- data/app/javascript/controllers/creatives/tree_controller.js +2 -1
- data/app/javascript/controllers/link_creative_controller.js +29 -3
- data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
- data/app/javascript/lib/html_code_block_wrapper.js +168 -0
- data/app/javascript/lib/utils/markdown.js +2 -1
- data/app/javascript/modules/creative_row_editor.js +5 -1
- data/app/javascript/utils/emoji_parser.js +21 -0
- data/app/jobs/collavre/ai_agent_job.rb +6 -2
- data/app/jobs/collavre/cron_action_job.rb +18 -6
- data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
- data/app/models/collavre/comment/approvable.rb +50 -0
- data/app/models/collavre/comment/broadcastable.rb +119 -0
- data/app/models/collavre/comment/notifiable.rb +111 -0
- data/app/models/collavre/comment.rb +13 -258
- data/app/models/collavre/comment_reaction.rb +15 -0
- data/app/models/collavre/creative/describable.rb +86 -0
- data/app/models/collavre/creative/linkable.rb +77 -0
- data/app/models/collavre/creative/permissible.rb +103 -0
- data/app/models/collavre/creative.rb +3 -289
- data/app/models/collavre/orchestrator_policy.rb +1 -1
- data/app/models/collavre/system_setting.rb +27 -1
- data/app/models/collavre/user.rb +42 -0
- data/app/models/collavre/user_theme.rb +10 -0
- data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
- data/app/services/collavre/ai_agent/message_builder.rb +129 -0
- data/app/services/collavre/ai_agent/review_handler.rb +70 -0
- data/app/services/collavre/ai_agent_service.rb +93 -150
- data/app/services/collavre/ai_client.rb +23 -4
- data/app/services/collavre/auto_theme_generator.rb +168 -50
- data/app/services/collavre/command_menu_service.rb +70 -0
- data/app/services/collavre/comment_move_service.rb +94 -0
- data/app/services/collavre/comments/action_executor.rb +10 -0
- data/app/services/collavre/comments/mcp_command.rb +1 -2
- data/app/services/collavre/creatives/create_service.rb +86 -0
- data/app/services/collavre/creatives/destroy_service.rb +41 -0
- data/app/services/collavre/creatives/index_query.rb +3 -0
- data/app/services/collavre/markdown_converter.rb +240 -0
- data/app/services/collavre/mention_parser.rb +63 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
- data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
- data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
- data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
- data/app/services/collavre/orchestration/scheduler.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -6
- data/app/services/collavre/tools/creative_batch_service.rb +107 -0
- data/app/services/collavre/tools/creative_update_service.rb +17 -12
- data/app/services/collavre/tools/cron_create_service.rb +17 -5
- data/app/views/admin/shared/_tabs.html.erb +2 -1
- data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
- data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
- data/app/views/collavre/admin/settings/index.html.erb +11 -0
- data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
- data/app/views/collavre/comments/_comment.html.erb +15 -5
- data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
- data/app/views/collavre/creatives/_share_button.html.erb +0 -52
- data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
- data/app/views/collavre/creatives/index.html.erb +5 -8
- data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
- data/app/views/collavre/user_themes/index.html.erb +7 -9
- data/app/views/collavre/users/_contact_management.html.erb +2 -1
- data/app/views/collavre/users/edit_ai.html.erb +7 -0
- data/app/views/collavre/users/index.html.erb +16 -1
- data/app/views/collavre/users/new_ai.html.erb +18 -8
- data/app/views/collavre/users/passkeys.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +1 -1
- data/app/views/layouts/collavre/slide.html.erb +8 -1
- data/config/locales/admin.en.yml +88 -0
- data/config/locales/admin.ko.yml +88 -0
- data/config/locales/ai_agent.en.yml +5 -1
- data/config/locales/ai_agent.ko.yml +5 -1
- data/config/locales/comments.en.yml +5 -1
- data/config/locales/comments.ko.yml +5 -1
- data/config/locales/orchestration.en.yml +8 -0
- data/config/locales/orchestration.ko.yml +8 -0
- data/config/locales/users.en.yml +12 -0
- data/config/locales/users.ko.yml +12 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
- data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
- data/lib/collavre/engine.rb +25 -0
- data/lib/collavre/version.rb +1 -1
- 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
|
-
|
|
23
|
-
|
|
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
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -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
|