collavre 0.5.0 → 0.7.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -4,6 +4,7 @@ import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $i
4
4
  import { createInlineEditor } from './lexical_inline_editor'
5
5
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
6
6
  import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
7
+ import yaml from 'js-yaml'
7
8
  // Import Stimulus application from the global window (set by host app)
8
9
  const application = window.Stimulus
9
10
 
@@ -99,6 +100,11 @@ export function initializeCreativeRowEditor() {
99
100
  const afterInput = document.getElementById('inline-after-id');
100
101
  const childInput = document.getElementById('inline-child-id');
101
102
  const originIdInput = document.getElementById('inline-origin-id');
103
+ const metadataBtn = document.getElementById('inline-metadata-btn');
104
+ const metadataPopup = document.getElementById('metadata-popup');
105
+ const metadataEditor = document.getElementById('metadata-yaml-editor');
106
+ const metadataSaveBtn = document.getElementById('metadata-save-btn');
107
+ const metadataCloseBtn = document.getElementById('metadata-popup-close');
102
108
 
103
109
  let lexicalEditor = null;
104
110
  if (editorContainer) {
@@ -304,6 +310,16 @@ export function initializeCreativeRowEditor() {
304
310
  originalProgress = normalizedProgress;
305
311
  lexicalEditor.focus();
306
312
  updateActionButtonStates();
313
+ // Reload metadata if the popup is open
314
+ if (isMetadataPopupVisible()) {
315
+ if (data.data !== undefined) {
316
+ // Use data already fetched from API response — avoids extra request
317
+ const yamlStr = yaml.dump(data.data || {}, { lineWidth: -1 });
318
+ metadataEditor.value = yamlStr;
319
+ } else {
320
+ loadMetadataForCreative(creativeId);
321
+ }
322
+ }
307
323
  }
308
324
 
309
325
  function siblingTreeRow(row, direction) {
@@ -1925,5 +1941,77 @@ export function initializeCreativeRowEditor() {
1925
1941
  });
1926
1942
  });
1927
1943
  }
1944
+
1945
+ // Metadata editor handlers
1946
+ function loadMetadataForCreative(creativeId) {
1947
+ if (!creativeId || !metadataPopup || !metadataEditor) return;
1948
+ creativesApi.get(creativeId)
1949
+ .then(function (data) {
1950
+ const metadataObj = data.data || {};
1951
+ const yamlStr = yaml.dump(metadataObj, { lineWidth: -1 });
1952
+ metadataEditor.value = yamlStr;
1953
+ })
1954
+ .catch(function (error) {
1955
+ console.error('Failed to load metadata:', error);
1956
+ alert('Failed to load metadata');
1957
+ });
1958
+ }
1959
+
1960
+ function isMetadataPopupVisible() {
1961
+ return metadataPopup && metadataPopup.style.display !== 'none';
1962
+ }
1963
+
1964
+ if (metadataBtn && metadataPopup && metadataEditor) {
1965
+ metadataBtn.addEventListener('click', function () {
1966
+ const creativeId = form.dataset.creativeId;
1967
+ if (!creativeId) return;
1968
+
1969
+ if (isMetadataPopupVisible()) {
1970
+ metadataPopup.style.display = 'none';
1971
+ return;
1972
+ }
1973
+
1974
+ loadMetadataForCreative(creativeId);
1975
+ metadataPopup.style.display = 'block';
1976
+ });
1977
+
1978
+ if (metadataCloseBtn) {
1979
+ metadataCloseBtn.addEventListener('click', function () {
1980
+ metadataPopup.style.display = 'none';
1981
+ });
1982
+ }
1983
+
1984
+ if (metadataSaveBtn) {
1985
+ metadataSaveBtn.addEventListener('click', function () {
1986
+ const creativeId = form.dataset.creativeId;
1987
+ if (!creativeId) return;
1988
+
1989
+ try {
1990
+ // Parse YAML back to JSON
1991
+ const yamlStr = metadataEditor.value;
1992
+ const parsedData = yaml.load(yamlStr) || {};
1993
+
1994
+ // Send PATCH request
1995
+ creativesApi.updateMetadata(creativeId, parsedData)
1996
+ .then(function (response) {
1997
+ if (response.ok) {
1998
+ metadataPopup.style.display = 'none';
1999
+ } else {
2000
+ return response.json().then(function (data) {
2001
+ alert('Failed to save metadata: ' + (data.error || 'Unknown error'));
2002
+ });
2003
+ }
2004
+ })
2005
+ .catch(function (error) {
2006
+ console.error('Failed to save metadata:', error);
2007
+ alert('Failed to save metadata');
2008
+ });
2009
+ } catch (error) {
2010
+ console.error('YAML parse error:', error);
2011
+ alert('Invalid YAML format: ' + error.message);
2012
+ }
2013
+ });
2014
+ }
2015
+ }
1928
2016
  });
1929
2017
  }
@@ -1,4 +1,5 @@
1
1
  import { createSubscription } from '../services/cable'
2
+ import DOMPurify from 'dompurify'
2
3
 
3
4
  document.addEventListener('DOMContentLoaded', function() {
4
5
  var container = document.getElementById('slide-view');
@@ -59,7 +60,7 @@ document.addEventListener('DOMContentLoaded', function() {
59
60
  }
60
61
  contentEl.innerHTML = '';
61
62
  var el = document.createElement(tag);
62
- el.innerHTML = data.description;
63
+ el.innerHTML = DOMPurify.sanitize(data.description);
63
64
  contentEl.appendChild(el);
64
65
  if (captionEl) {
65
66
  captionEl.textContent = data.prompt || '';
@@ -14,13 +14,25 @@ module Collavre
14
14
  else
15
15
  # Create new task
16
16
  agent = User.find(agent_id_or_task)
17
+
18
+ # Guard: skip if there's already a running task for the same agent + comment
19
+ comment_id = context&.dig("comment", "id")
20
+ if comment_id && Task.duplicate_running_for_comment?(agent.id, comment_id)
21
+ Rails.logger.warn(
22
+ "[AiAgentJob] Skipping duplicate: agent #{agent.id} already has a running task " \
23
+ "for comment #{comment_id} (event=#{event_name})"
24
+ )
25
+ return
26
+ end
27
+
17
28
  task = Task.create!(
18
29
  name: "Response to #{event_name}",
19
30
  status: "running",
20
31
  trigger_event_name: event_name,
21
32
  trigger_event_payload: context,
22
33
  agent: agent,
23
- topic_id: context&.dig("topic", "id")
34
+ topic_id: context&.dig("topic", "id"),
35
+ creative_id: context&.dig("creative", "id")
24
36
  )
25
37
 
26
38
  # Record task for loop breaker tracking (per-topic, skip user-initiated)
@@ -41,26 +53,33 @@ module Collavre
41
53
  begin
42
54
  response_content = AiAgentService.new(task).call
43
55
 
44
- # Evaluate self-reflection if enabled
45
- reflection_result = evaluate_self_reflection(task, response_content)
56
+ # Workflow subtasks with empty responses should retry, then fail
57
+ if task.parent_task_id.present? && response_content.blank?
58
+ max_retries = 2
59
+ current_retry = task.retry_count || 0
46
60
 
47
- case reflection_result.action
48
- when :retry
49
- # Schedule retry with delay - don't release resources yet
50
- schedule_self_reflection_retry(task, reflection_result, response_content)
51
- Rails.logger.info(
52
- "[AiAgentJob] Task #{task.id} scheduled for retry " \
53
- "(attempt #{task.retry_count + 1}, confidence: #{reflection_result.confidence})"
54
- )
55
- nil # Exit without releasing resources or dequeuing
56
- when :escalate
57
- # Escalate to admins
58
- Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content).escalate!(reflection_result)
59
- tracker.release!(job_id || task.id, tokens_used: 0)
60
- Rails.logger.info("[AiAgentJob] Task #{task.id} escalated after max retries")
61
- else # :done
61
+ if current_retry < max_retries
62
+ task.update!(retry_count: current_retry + 1, status: "pending")
63
+ tracker.release!(job_id || task.id, tokens_used: 0)
64
+ Rails.logger.warn(
65
+ "[AiAgentJob] Workflow subtask #{task.id} returned empty response, " \
66
+ "retrying (#{current_retry + 1}/#{max_retries})"
67
+ )
68
+ AiAgentJob.set(wait: 5.seconds).perform_later(task)
69
+ else
70
+ task.update!(status: "failed")
71
+ tracker.release!(job_id || task.id, tokens_used: 0)
72
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
73
+ task, error_message: "Agent returned empty response after #{max_retries} retries"
74
+ )
75
+ end
76
+ else
62
77
  task.update!(status: "done")
63
78
  tracker.release!(job_id || task.id, tokens_used: 0)
79
+ # Advance workflow after releasing resources to avoid deadlock
80
+ if task.parent_task_id.present?
81
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
82
+ end
64
83
  end
65
84
  rescue ApprovalPendingError
66
85
  # Task status already set to pending_approval by AiAgentService
@@ -73,24 +92,17 @@ module Collavre
73
92
  rescue StandardError => e
74
93
  task.update!(status: "failed")
75
94
  tracker.release!(job_id || task.id, tokens_used: 0)
95
+ # Fail workflow if this is a sub-task
96
+ if task.parent_task_id.present?
97
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(task, error_message: e.message)
98
+ end
76
99
  Rails.logger.error("AiAgentJob failed for task #{task.id}: #{e.message}")
77
100
  raise e
78
101
  ensure
79
102
  if task&.trigger_event_payload&.key?("topic") && %w[done failed cancelled escalated].include?(task.reload.status)
80
- Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id)
103
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
81
104
  end
82
105
  end
83
106
  end
84
-
85
- private
86
-
87
- def evaluate_self_reflection(task, response_content)
88
- Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content).evaluate
89
- end
90
-
91
- def schedule_self_reflection_retry(task, result, response_content)
92
- evaluator = Orchestration::SelfReflectionEvaluator.new(task, response_content: response_content)
93
- evaluator.schedule_retry!(result)
94
- end
95
107
  end
96
108
  end
@@ -0,0 +1,92 @@
1
+ module Collavre
2
+ class CompressJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform(creative_id, topic_id, user_id, extra_prompt = nil)
6
+ creative = Creative.find(creative_id)
7
+ topic = Topic.find(topic_id)
8
+ user = User.find(user_id)
9
+
10
+ # Collect all comments in topic (chronological), excluding /compress command
11
+ all_comments = creative.comments
12
+ .where(topic_id: topic_id)
13
+ .order(created_at: :asc)
14
+ .includes(:user)
15
+
16
+ # Separate: comments to summarize vs the compress command itself
17
+ compress_pattern = /\A\/compress\b/i
18
+ target_comments = all_comments.reject { |c| c.content.to_s.strip.match?(compress_pattern) }
19
+
20
+ return if target_comments.size < 2
21
+
22
+ # Build conversation text
23
+ conversation = target_comments.map do |c|
24
+ author = c.user&.name || I18n.t("collavre.comments.anonymous")
25
+ "#{author}: #{c.content}"
26
+ end.join("\n\n")
27
+
28
+ # Build AI prompt
29
+ system_prompt = Comments::CompressCommand::SYSTEM_PROMPT.dup
30
+ if extra_prompt.present?
31
+ system_prompt += "\n\nAdditional instruction from the user: #{extra_prompt}"
32
+ end
33
+
34
+ # Find an AI agent on this creative, or use default config
35
+ agent = find_ai_agent(creative)
36
+
37
+ client = AiClient.new(
38
+ vendor: agent&.llm_vendor || default_vendor,
39
+ model: agent&.llm_model || default_model,
40
+ system_prompt: system_prompt,
41
+ llm_api_key: agent&.llm_api_key || agent&.creator&.llm_api_key
42
+ )
43
+
44
+ summary = String.new
45
+ client.chat([ { role: "user", text: conversation } ]) do |delta|
46
+ summary << delta
47
+ end
48
+
49
+ if summary.blank?
50
+ Rails.logger.error("[CompressJob] AI returned empty summary for topic #{topic_id}")
51
+ return
52
+ end
53
+
54
+ # Create summary comment
55
+ topic_name = topic.name.presence || "Topic"
56
+ title = I18n.t("collavre.comments.compress_command.summary_title", topic: topic_name)
57
+ summary_content = "**#{title}**\n\n#{summary}"
58
+
59
+ # Store comment IDs to delete before creating the new one (all originals including /compress command)
60
+ comment_ids_to_delete = all_comments.pluck(:id)
61
+
62
+ # Create the summary comment in the same topic
63
+ summary_comment = creative.comments.create!(
64
+ user: user,
65
+ topic_id: topic_id,
66
+ content: summary_content
67
+ )
68
+
69
+ # Delete original comments (excluding the newly created summary)
70
+ creative.comments.where(id: comment_ids_to_delete).destroy_all
71
+ rescue ActiveRecord::RecordNotFound => e
72
+ Rails.logger.error("[CompressJob] Record not found: #{e.message}")
73
+ end
74
+
75
+ private
76
+
77
+ def find_ai_agent(creative)
78
+ # Look for an AI agent with access to this creative
79
+ creative.effective_origin.all_shared_users(:feedback)
80
+ .map(&:user)
81
+ .find(&:ai_user?)
82
+ end
83
+
84
+ def default_vendor
85
+ "google"
86
+ end
87
+
88
+ def default_model
89
+ "gemini-3-flash-preview"
90
+ end
91
+ end
92
+ end
@@ -15,8 +15,20 @@ module Collavre
15
15
  belongs_to :action_executed_by, class_name: Collavre.configuration.user_class_name, optional: true
16
16
  belongs_to :topic, class_name: "Collavre::Topic", optional: true
17
17
  belongs_to :quoted_comment, class_name: "Collavre::Comment", optional: true
18
+
19
+ # review_type: nil = normal chat, 0 = review, 1 = question
20
+ enum :review_type, { review: 0, question: 1 }, prefix: true
21
+
22
+ # Must run before dependent: :destroy on comment_versions to clear FK
23
+ before_destroy :nullify_selected_version
24
+
18
25
  has_many :activity_logs, class_name: "Collavre::ActivityLog", dependent: :destroy
19
26
  has_many :comment_reactions, class_name: "Collavre::CommentReaction", dependent: :destroy
27
+ has_many :comment_versions, class_name: "Collavre::CommentVersion", dependent: :destroy
28
+ has_many :review_versions, class_name: "Collavre::CommentVersion", foreign_key: :review_comment_id, dependent: :nullify
29
+ has_many :inbox_items, class_name: "Collavre::InboxItem", dependent: :nullify
30
+ has_many :quoting_comments, class_name: "Collavre::Comment", foreign_key: :quoted_comment_id, dependent: :destroy
31
+ belongs_to :selected_version, class_name: "Collavre::CommentVersion", optional: true
20
32
 
21
33
  has_many_attached :images, dependent: :purge_later
22
34
 
@@ -36,8 +48,12 @@ module Collavre
36
48
 
37
49
  after_destroy_commit :cancel_pending_tasks
38
50
 
51
+ def next_version_number
52
+ (comment_versions.maximum(:version_number) || 0) + 1
53
+ end
54
+
39
55
  def review_message?
40
- quoted_comment_id.present?
56
+ quoted_comment_id.present? && !review_type_question?
41
57
  end
42
58
 
43
59
  # public for db migration
@@ -47,12 +63,31 @@ module Collavre
47
63
 
48
64
  private
49
65
 
66
+ def nullify_selected_version
67
+ update_column(:selected_version_id, nil) if selected_version_id.present?
68
+ end
69
+
50
70
  def cancel_pending_tasks
71
+ # Cancel tasks triggered by this comment
51
72
  Task.where(status: %w[pending running queued]).each do |task|
52
73
  if task.trigger_event_payload&.dig("comment", "id") == id
53
74
  task.update!(status: "cancelled")
54
75
  end
55
76
  end
77
+
78
+ # Cancel queued tasks when their waiting notice (system comment) is deleted
79
+ cancel_queued_tasks_for_waiting_notice if waiting_notice?
80
+ end
81
+
82
+ def waiting_notice?
83
+ user_id.nil? && content&.start_with?("⏳")
84
+ end
85
+
86
+ def cancel_queued_tasks_for_waiting_notice
87
+ scope = Task.where(status: "queued", creative_id: creative_id)
88
+ scope = topic_id ? scope.where(topic_id: topic_id) : scope.where(topic_id: nil)
89
+ task = scope.order(created_at: :desc).first
90
+ task&.update!(status: "cancelled")
56
91
  end
57
92
 
58
93
  def assign_default_user
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CommentVersion < ApplicationRecord
5
+ self.table_name = "comment_versions"
6
+
7
+ belongs_to :comment, class_name: "Collavre::Comment"
8
+ belongs_to :review_comment, class_name: "Collavre::Comment", optional: true
9
+
10
+ validates :content, presence: true
11
+ validates :version_number, presence: true,
12
+ uniqueness: { scope: :comment_id },
13
+ numericality: { only_integer: true, greater_than: 0 }
14
+ end
15
+ end
@@ -26,7 +26,7 @@ module Collavre
26
26
  end
27
27
 
28
28
  def creative_snippet
29
- ActionController::Base.helpers.strip_tags(effective_origin.description || "").truncate(24, omission: "...")
29
+ CGI.unescapeHTML(ActionController::Base.helpers.strip_tags(effective_origin.description || "")).truncate(24, omission: "...")
30
30
  end
31
31
 
32
32
  private
@@ -36,6 +36,34 @@ module Collavre
36
36
  has_many :calendar_events, class_name: "Collavre::CalendarEvent", dependent: :destroy
37
37
  has_many :labels, class_name: "Collavre::Label", dependent: :destroy
38
38
 
39
+ # Returns IDs of creatives that have at least one active share and are
40
+ # accessible by the given user (owned or shared with/by them).
41
+ # Includes linked creatives whose origin has shares.
42
+ def self.shared_accessible_ids(user)
43
+ own_ids = where(user_id: user.id).pluck(:id)
44
+ shared_ids = CreativeShare
45
+ .where.not(permission: :no_access)
46
+ .where("user_id = :uid OR shared_by_id = :uid", uid: user.id)
47
+ .pluck(:creative_id)
48
+
49
+ accessible_ids = (own_ids | shared_ids).uniq
50
+
51
+ # Direct shares on accessible creatives
52
+ directly_shared = CreativeShare
53
+ .where(creative_id: accessible_ids)
54
+ .where.not(permission: :no_access)
55
+ .pluck(:creative_id)
56
+
57
+ # Linked creatives whose origin has shares
58
+ origin_shared = where(id: accessible_ids)
59
+ .where.not(origin_id: nil)
60
+ .joins("INNER JOIN creative_shares ON creative_shares.creative_id = creatives.origin_id")
61
+ .where.not(creative_shares: { permission: :no_access })
62
+ .pluck(:id)
63
+
64
+ (directly_shared | origin_shared).uniq
65
+ end
66
+
39
67
  validates :progress, numericality: { greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0 }, unless: -> { origin_id.present? }
40
68
 
41
69
  validate :progress_cannot_change_if_has_origin, on: :update
@@ -46,6 +74,29 @@ module Collavre
46
74
  after_destroy :update_parent_progress
47
75
  after_save :update_mcp_tools
48
76
 
77
+ # --- Context IDs ---
78
+ # Returns the directly-configured context creative IDs for this creative.
79
+ def context_ids
80
+ Array(data&.dig("context_ids"))
81
+ end
82
+
83
+ # Returns the effective context IDs: own + inherited from ancestors (deduplicated).
84
+ def effective_context_ids(visited_ids = Set.new)
85
+ return [] if visited_ids.include?(id)
86
+
87
+ visited_ids.add(id)
88
+ own = context_ids
89
+ parent_ctx = parent&.effective_context_ids(visited_ids) || []
90
+ (own + parent_ctx).uniq
91
+ end
92
+
93
+ # Returns context creatives (excludes self to avoid duplication).
94
+ def context_creatives
95
+ ids = effective_context_ids
96
+ ids -= [ id ]
97
+ Creative.where(id: ids)
98
+ end
99
+
49
100
  # Compatibility helper: ancestry gem exposes `subtree_ids`, while
50
101
  # closure_tree typically uses `self_and_descendants`.
51
102
  def subtree_ids
@@ -4,10 +4,38 @@ module Collavre
4
4
 
5
5
  belongs_to :agent, class_name: "Collavre::User"
6
6
  has_many :task_actions, class_name: "Collavre::TaskAction", dependent: :destroy
7
+ belongs_to :parent_task, class_name: "Collavre::Task", optional: true
8
+ has_many :sub_tasks, class_name: "Collavre::Task", foreign_key: :parent_task_id, dependent: :destroy
9
+ belongs_to :creative, class_name: "Collavre::Creative", optional: true
7
10
 
8
11
  validates :name, presence: true
9
12
 
10
- scope :running_for_topic, ->(topic_id) { where(topic_id: topic_id, status: "running") }
11
- scope :queued_for_topic, ->(topic_id) { where(topic_id: topic_id, status: "queued").order(:created_at) }
13
+ scope :running_for_topic, ->(topic_id, creative_id = nil) {
14
+ rel = where(topic_id: topic_id, status: "running")
15
+ rel = rel.where(creative_id: creative_id) if creative_id
16
+ rel
17
+ }
18
+ scope :queued_for_topic, ->(topic_id, creative_id = nil) {
19
+ rel = where(topic_id: topic_id, status: "queued")
20
+ rel = rel.where(creative_id: creative_id) if creative_id
21
+ rel.order(:created_at)
22
+ }
23
+
24
+ # Check if agent already has a running task triggered by the same comment
25
+ def self.duplicate_running_for_comment?(agent_id, comment_id)
26
+ where(agent_id: agent_id, status: "running", trigger_event_name: "comment_created")
27
+ .find_each do |task|
28
+ return true if task.trigger_event_payload&.dig("comment", "id").to_s == comment_id.to_s
29
+ end
30
+ false
31
+ end
32
+
33
+ def workflow_parent?
34
+ workflow_state.present? && parent_task_id.nil?
35
+ end
36
+
37
+ def all_sub_tasks_done?
38
+ sub_tasks.where.not(status: %w[done cancelled]).empty?
39
+ end
12
40
  end
13
41
  end
@@ -39,8 +39,9 @@ module Collavre
39
39
  dependent: :nullify, inverse_of: :approver
40
40
  has_many :comment_reactions, class_name: "Collavre::CommentReaction", dependent: :destroy
41
41
 
42
- # Creatives must be destroyed AFTER all associations that reference them
43
- has_many :creatives, class_name: "Collavre::Creative", dependent: :destroy
42
+ # Leaf-first destroy to avoid closure_tree find(parent_id) errors
43
+ has_many :creatives, class_name: "Collavre::Creative", dependent: nil
44
+ before_destroy :destroy_creatives_leaf_first
44
45
 
45
46
  belongs_to :creator, class_name: "Collavre::User", foreign_key: "created_by_id", optional: true
46
47
  has_many :created_ai_users, class_name: "Collavre::User", foreign_key: "created_by_id", dependent: :destroy
@@ -131,7 +132,7 @@ module Collavre
131
132
  end
132
133
 
133
134
  SUPPORTED_LLM_MODELS = [
134
- "gemini-2.5-flash",
135
+ "gemini-3-flash-preview",
135
136
  "gemini-1.5-flash",
136
137
  "gemini-1.5-pro"
137
138
  ].freeze
@@ -140,6 +141,14 @@ module Collavre
140
141
  llm_vendor.present?
141
142
  end
142
143
 
144
+ scope :ai_agents, -> { where.not(llm_vendor: [ nil, "" ]) }
145
+
146
+ def self.accessible_ai_agents_for(user)
147
+ owned = ai_agents.where(created_by_id: user.id)
148
+ searchable = ai_agents.where(searchable: true)
149
+ owned.or(searchable).distinct.order(:name)
150
+ end
151
+
143
152
  def self.mentionable_for(creative)
144
153
  scope = where(searchable: true)
145
154
  return scope unless creative
@@ -226,5 +235,13 @@ module Collavre
226
235
  errors.add(:theme, "is invalid")
227
236
  end
228
237
  end
238
+
239
+ # Destroy creatives deepest-first so closure_tree always finds its parent
240
+ def destroy_creatives_leaf_first
241
+ all_creatives = creatives.flat_map { |c| c.self_and_descendants.to_a }.uniq
242
+ all_creatives.sort_by { |c| -c.self_and_ancestors.count }.each do |c|
243
+ c.reload.destroy! if Creative.exists?(c.id)
244
+ end
245
+ end
229
246
  end
230
247
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Handles agent-to-agent (A2A) orchestration dispatch.
6
+ # Detects mentioned agents, records interactions for loop prevention,
7
+ # and dispatches system events for downstream processing.
8
+ class A2aDispatcher
9
+ def initialize(agent:, reply_comment:, context:)
10
+ @agent = agent
11
+ @reply_comment = reply_comment
12
+ @context = context
13
+ end
14
+
15
+ # Dispatch A2A events if the response mentions any AI agents
16
+ def dispatch
17
+ return unless @reply_comment&.content.present?
18
+
19
+ mentioned_agents = find_mentioned_agents
20
+ return if mentioned_agents.empty?
21
+
22
+ creative = @reply_comment.creative
23
+ record_interactions(mentioned_agents, creative)
24
+ dispatch_event(creative)
25
+ rescue StandardError => e
26
+ Rails.logger.error("[AiAgent::A2aDispatcher] A2A dispatch failed: #{e.message}")
27
+ end
28
+
29
+ private
30
+
31
+ def find_mentioned_agents
32
+ MentionParser.resolve_all_users(@reply_comment.content).select(&:ai_user?)
33
+ end
34
+
35
+ def record_interactions(mentioned_agents, creative)
36
+ return unless creative
37
+
38
+ mentioned_agents.each do |mentioned_user|
39
+ context = {
40
+ "creative" => { "id" => creative.id },
41
+ "topic" => { "id" => @reply_comment.topic_id }
42
+ }
43
+ Orchestration::LoopBreaker.new(context).record_interaction(
44
+ @agent.id,
45
+ mentioned_user.id,
46
+ creative.id
47
+ )
48
+ end
49
+ end
50
+
51
+ def dispatch_event(creative)
52
+ SystemEvents::Dispatcher.dispatch("comment_created", {
53
+ comment: {
54
+ id: @reply_comment.id,
55
+ content: @reply_comment.content,
56
+ user_id: @reply_comment.user_id
57
+ },
58
+ creative: {
59
+ id: creative&.id,
60
+ description: creative&.description
61
+ },
62
+ topic: { id: @reply_comment.topic_id },
63
+ chat: { content: @reply_comment.content }
64
+ })
65
+ end
66
+ end
67
+ end
68
+ end