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
@@ -6,25 +6,35 @@ module Collavre
6
6
  # agent orchestration pipeline.
7
7
  #
8
8
  # Created dynamically via the cron_create MCP tool.
9
+ # topic_id can be nil for the main topic.
9
10
  class CronActionJob < ApplicationJob
10
11
  queue_as :default
11
12
 
12
13
  def perform(creative_id:, topic_id:, agent_id:, message:)
13
14
  creative = Creative.find_by(id: creative_id)
14
- topic = Topic.find_by(id: topic_id)
15
15
  agent = User.find_by(id: agent_id)
16
16
 
17
- unless creative && topic && agent
17
+ unless creative && agent
18
18
  Rails.logger.warn(
19
- "[CronActionJob] Skipping: creative=#{creative_id} topic=#{topic_id} agent=#{agent_id} - record not found"
19
+ "[CronActionJob] Skipping: creative=#{creative_id} agent=#{agent_id} - record not found"
20
20
  )
21
21
  return
22
22
  end
23
23
 
24
+ # topic_id nil means main topic; otherwise look up the topic
25
+ topic = nil
26
+ if topic_id.present?
27
+ topic = Topic.find_by(id: topic_id)
28
+ unless topic
29
+ Rails.logger.warn("[CronActionJob] Skipping: topic=#{topic_id} not found")
30
+ return
31
+ end
32
+ end
33
+
24
34
  comment = creative.comments.create!(
25
35
  content: message,
26
36
  user: agent,
27
- topic_id: topic.id,
37
+ topic_id: topic&.id,
28
38
  private: false
29
39
  )
30
40
 
@@ -40,14 +50,16 @@ module Collavre
40
50
  description: creative.description
41
51
  },
42
52
  topic: {
43
- id: topic.id
53
+ id: topic&.id
44
54
  },
45
55
  chat: {
46
56
  content: comment.content
47
57
  }
48
58
  })
49
59
 
50
- Rails.logger.info("[CronActionJob] Posted cron message to creative #{creative_id}, topic #{topic_id}")
60
+ Rails.logger.info(
61
+ "[CronActionJob] Posted cron message to creative #{creative_id}, topic #{topic_id || 'main'}"
62
+ )
51
63
  end
52
64
  end
53
65
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # CronSchedulerJob is a self-scheduling job that runs every minute and
5
+ # picks up dynamically-created recurring tasks (static: false) whose
6
+ # cron schedule matches the current minute. For each match it enqueues
7
+ # the target job (e.g. CronActionJob).
8
+ #
9
+ # This exists because Solid Queue's built-in Scheduler only processes
10
+ # static (config-defined) recurring tasks — dynamic ones created via
11
+ # cron_create are ignored by the Scheduler.
12
+ #
13
+ # Self-scheduling: after each run, the job re-enqueues itself with
14
+ # `wait: 1.minute`. Boot-time scheduling is handled by the engine
15
+ # initializer (see collavre/engine.rb).
16
+ #
17
+ # Duplicate-execution guard: uses Rails.cache with a per-task, per-minute
18
+ # key so that even if this job runs twice in the same minute window,
19
+ # each task fires at most once. (This cache is process-local and acceptable
20
+ # since CronSchedulerJob runs in a single SQ worker process.)
21
+ #
22
+ # Reschedule guard: uses SolidQueue::Job table (DB-based) to prevent
23
+ # duplicate scheduler chains — works across all environments without
24
+ # shared cache infrastructure.
25
+ class CronSchedulerJob < ApplicationJob
26
+ queue_as :default
27
+
28
+ def perform
29
+ now = Time.current
30
+ dynamic_tasks = SolidQueue::RecurringTask.where(static: false)
31
+
32
+ dynamic_tasks.find_each do |task|
33
+ next unless schedule_matches?(task, now)
34
+ next if already_dispatched?(task, now)
35
+
36
+ enqueue_task(task)
37
+ mark_dispatched(task, now)
38
+ end
39
+ ensure
40
+ reschedule
41
+ end
42
+
43
+ private
44
+
45
+ def schedule_matches?(task, time)
46
+ cron = Fugit.parse(task.schedule)
47
+ return false unless cron.is_a?(Fugit::Cron)
48
+
49
+ # Truncate to the beginning of the current minute so that
50
+ # cron.match? works regardless of which second the job runs at.
51
+ minute_start = time.change(sec: 0)
52
+ cron.match?(minute_start)
53
+ end
54
+
55
+ def cache_key(task, time)
56
+ minute = time.strftime("%Y%m%d%H%M")
57
+ "cron_scheduler:#{task.key}:#{minute}"
58
+ end
59
+
60
+ def already_dispatched?(task, time)
61
+ Rails.cache.read(cache_key(task, time)).present?
62
+ end
63
+
64
+ def mark_dispatched(task, time)
65
+ Rails.cache.write(cache_key(task, time), true, expires_in: 2.minutes)
66
+ end
67
+
68
+ def enqueue_task(task)
69
+ job_class = task.class_name.safe_constantize
70
+ unless job_class
71
+ Rails.logger.warn("[CronScheduler] Unknown job class: #{task.class_name} for task #{task.key}")
72
+ return
73
+ end
74
+
75
+ args = task.arguments || []
76
+ if args.is_a?(Array) && args.first.is_a?(Hash)
77
+ job_class.perform_later(**args.first.symbolize_keys)
78
+ elsif args.is_a?(Array)
79
+ job_class.perform_later(*args)
80
+ else
81
+ job_class.perform_later
82
+ end
83
+
84
+ Rails.logger.info("[CronScheduler] Enqueued #{task.class_name} for task #{task.key}")
85
+ rescue StandardError => e
86
+ Rails.logger.error("[CronScheduler] Failed to enqueue #{task.key}: #{e.message}")
87
+ end
88
+
89
+ def reschedule
90
+ # DB-based guard: skip if a CronSchedulerJob is already pending
91
+ return if pending_scheduler_exists?
92
+
93
+ self.class.set(wait: 1.minute).perform_later
94
+ Rails.logger.info("[CronScheduler] Rescheduled for next minute")
95
+ end
96
+
97
+ def pending_scheduler_exists?
98
+ scope = SolidQueue::Job
99
+ .where(class_name: self.class.name)
100
+ .where(finished_at: nil)
101
+
102
+ # Exclude the currently running job to avoid blocking our own reschedule
103
+ scope = scope.where.not(id: provider_job_id) if provider_job_id.present?
104
+
105
+ # Exclude failed jobs — they won't run again without manual retry
106
+ failed_job_ids = SolidQueue::FailedExecution.pluck(:job_id)
107
+ scope = scope.where.not(id: failed_job_ids) if failed_job_ids.any?
108
+
109
+ scope.exists?
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,50 @@
1
+ module Collavre
2
+ class Comment < ApplicationRecord
3
+ module Approvable
4
+ extend ActiveSupport::Concern
5
+
6
+ def can_be_approved_by?(user)
7
+ approval_status(user) == :ok
8
+ end
9
+
10
+ def approval_status(user)
11
+ return :not_allowed unless user
12
+
13
+ if action.blank?
14
+ return :not_allowed unless approver_id == user&.id
15
+ return :missing_action
16
+ end
17
+
18
+ begin
19
+ payload = JSON.parse(action)
20
+ rescue JSON::ParserError
21
+ return :invalid_action_format
22
+ end
23
+ return :invalid_action_format unless payload.is_a?(Hash)
24
+
25
+ actions = Array(payload["actions"])
26
+ actions = [ payload ] if actions.empty?
27
+
28
+ requires_admin = actions.any? do |item|
29
+ next false unless item.is_a?(Hash)
30
+ action_type = item["action"] || item["type"]
31
+ action_type == "approve_tool"
32
+ end
33
+
34
+ if requires_admin && SystemSetting.mcp_tool_approval_required?
35
+ return user.system_admin? ? :ok : :admin_required
36
+ end
37
+
38
+ return :missing_approver if approver_id.blank?
39
+ return :not_allowed unless approver_id == user&.id
40
+
41
+ :ok
42
+ end
43
+
44
+ def parsed_action_tool_name
45
+ parsed = JSON.parse(action) rescue nil
46
+ parsed&.dig("tool_name") || "unknown"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,119 @@
1
+ module Collavre
2
+ class Comment < ApplicationRecord
3
+ module Broadcastable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ after_create_commit :broadcast_create, :broadcast_badges
8
+ after_update_commit :broadcast_update
9
+ after_destroy_commit :broadcast_destroy, :broadcast_badges
10
+ end
11
+
12
+ module ClassMethods
13
+ def broadcast_badges(creative)
14
+ origin = creative.effective_origin
15
+ users = [ origin.user ].compact + origin.all_shared_users(:feedback).map(&:user)
16
+ users.compact!
17
+ users.uniq!
18
+ return if users.empty?
19
+
20
+ user_ids = users.map(&:id)
21
+
22
+ pointers = CommentReadPointer.where(user_id: user_ids, creative: origin).index_by(&:user_id)
23
+ present_user_ids = CommentPresenceStore.list(origin.id)
24
+
25
+ public_count = origin.comments.where(private: false).count
26
+ private_counts = origin.comments
27
+ .where(private: true, user_id: user_ids)
28
+ .group(:user_id)
29
+ .count
30
+
31
+ last_read_ids = pointers.transform_values { |p| p.last_read_comment_id || 0 }
32
+
33
+ unread_public_by_threshold = {}
34
+ last_read_ids.values.uniq.each do |threshold|
35
+ unread_public_by_threshold[threshold] = origin.comments
36
+ .where(private: false)
37
+ .where("comments.id > ?", threshold)
38
+ .count
39
+ end
40
+
41
+ unread_private_counts = {}
42
+ user_ids.each do |uid|
43
+ threshold = last_read_ids[uid] || 0
44
+ unread_private_counts[uid] = origin.comments
45
+ .where(private: true, user_id: uid)
46
+ .where("comments.id > ?", threshold)
47
+ .count
48
+ end
49
+
50
+ users.each do |u|
51
+ user_private_count = private_counts[u.id] || 0
52
+ total_count = public_count + user_private_count
53
+
54
+ threshold = last_read_ids[u.id] || 0
55
+ unread_public = unread_public_by_threshold[threshold] || 0
56
+ unread_private = unread_private_counts[u.id] || 0
57
+ unread_count = unread_public + unread_private
58
+
59
+ unread_count = 0 if present_user_ids.include?(u.id)
60
+
61
+ Turbo::StreamsChannel.broadcast_replace_to(
62
+ [ u, origin, :comment_badge ],
63
+ target: "comment-badge-#{origin.id}",
64
+ partial: "inbox/badge_component/count",
65
+ locals: {
66
+ count: unread_count,
67
+ badge_id: "comment-badge-#{origin.id}",
68
+ show_zero: total_count.positive?
69
+ }
70
+ )
71
+ end
72
+ end
73
+
74
+ def broadcast_badge(creative, user)
75
+ origin = creative.effective_origin
76
+ visible_comments = origin.comments.where("comments.private = ? OR comments.user_id = ?", false, user.id)
77
+ comments_count = visible_comments.count
78
+ pointer = CommentReadPointer.find_by(user: user, creative: origin)
79
+ last_read_id = pointer&.last_read_comment_id
80
+ unread_scope = last_read_id ? visible_comments.where("comments.id > ?", last_read_id) : visible_comments
81
+ unread_count = unread_scope.count
82
+ unread_count = 0 if CommentPresenceStore.list(origin.id).include?(user.id)
83
+ Turbo::StreamsChannel.broadcast_replace_to(
84
+ [ user, origin, :comment_badge ],
85
+ target: "comment-badge-#{origin.id}",
86
+ partial: "inbox/badge_component/count",
87
+ locals: {
88
+ count: unread_count,
89
+ badge_id: "comment-badge-#{origin.id}",
90
+ show_zero: comments_count.positive?
91
+ }
92
+ )
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def broadcast_create
99
+ return if private?
100
+ broadcast_append_later_to([ creative, :comments ], target: "comments-list", partial: "collavre/comments/comment")
101
+ end
102
+
103
+ def broadcast_update
104
+ return if private?
105
+ broadcast_replace_later_to([ creative, :comments ], partial: "collavre/comments/comment")
106
+ end
107
+
108
+ def broadcast_destroy
109
+ return if private? || !creative
110
+ broadcast_remove_to([ creative, :comments ])
111
+ end
112
+
113
+ def broadcast_badges
114
+ return unless creative
115
+ self.class.broadcast_badges(creative)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,111 @@
1
+ module Collavre
2
+ class Comment < ApplicationRecord
3
+ module Notifiable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ after_create_commit :notify_write_users, :notify_mentions, :notify_approver
8
+ end
9
+
10
+ def mentioned_users
11
+ return Collavre.user_class.none unless user
12
+ emails = mentioned_emails - [ user.email.downcase ]
13
+ names = mentioned_names - [ user.name.downcase ]
14
+
15
+ origin = creative.effective_origin
16
+ mentionable_users = Collavre.user_class.mentionable_for(origin)
17
+
18
+ scope = Collavre.user_class.none
19
+ scope = scope.or(mentionable_users.where(email: emails)) if emails.any?
20
+ scope = scope.or(mentionable_users.where("LOWER(name) IN (?)", names)) if names.any?
21
+ scope
22
+ end
23
+
24
+ private
25
+
26
+ def create_inbox_item(owner, key, params = {})
27
+ origin = creative&.effective_origin
28
+ metadata = params.to_h.stringify_keys
29
+ metadata["comment_id"] = id
30
+ metadata["creative_id"] = origin&.id
31
+
32
+ InboxItem.create!(
33
+ owner: owner,
34
+ message_key: key,
35
+ message_params: metadata,
36
+ comment: self,
37
+ creative: origin,
38
+ link: Collavre::Engine.routes.url_helpers.creative_comment_url(
39
+ creative,
40
+ self,
41
+ Rails.application.config.action_mailer.default_url_options
42
+ )
43
+ )
44
+ end
45
+
46
+ def streaming_placeholder?
47
+ user&.ai_user? && content == STREAMING_PLACEHOLDER_CONTENT
48
+ end
49
+
50
+ def mentioned_emails
51
+ return [] unless content
52
+ content.scan(/@([\w.\-+]+@[a-zA-Z0-9\-.]+\.[a-zA-Z]{2,})/)
53
+ .flatten
54
+ .map(&:downcase)
55
+ .uniq
56
+ end
57
+
58
+ def mentioned_names
59
+ return [] unless content
60
+ content.scan(/@([^:]+):/)
61
+ .flatten
62
+ .map(&:downcase)
63
+ .uniq
64
+ end
65
+
66
+ def notify_write_users
67
+ return if private? || !user
68
+ return if streaming_placeholder?
69
+ base_creative = creative.effective_origin
70
+ present_ids = CommentPresenceStore.list(base_creative.id)
71
+ recipients = base_creative.all_shared_users(:write).map(&:user)
72
+ recipients << base_creative.user
73
+ recipients.compact!
74
+ recipients.uniq!
75
+ recipients.delete(user)
76
+ recipients -= mentioned_users.to_a
77
+ recipients.reject! { |u| present_ids.include?(u.id) }
78
+ recipients.each do |recipient|
79
+ create_inbox_item(
80
+ recipient,
81
+ "inbox.comment_added",
82
+ { user: user.display_name, comment: content, creative: creative_snippet }
83
+ )
84
+ end
85
+ end
86
+
87
+ def notify_mentions
88
+ return if private?
89
+ return if streaming_placeholder?
90
+ mentioned_users.each do |mentioned|
91
+ create_inbox_item(
92
+ mentioned,
93
+ "inbox.user_mentioned",
94
+ { user: user.display_name, comment: content, creative: creative_snippet }
95
+ )
96
+ end
97
+ end
98
+
99
+ def notify_approver
100
+ return unless approver.present? && action.present?
101
+ return if approver == user
102
+
103
+ create_inbox_item(
104
+ approver,
105
+ "inbox.approval_requested",
106
+ { user: user&.display_name, tool_name: parsed_action_tool_name, creative: creative_snippet }
107
+ )
108
+ end
109
+ end
110
+ end
111
+ end