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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
- data/app/assets/stylesheets/collavre/creatives.css +73 -1
- data/app/assets/stylesheets/collavre/org_chart.css +319 -0
- data/app/assets/stylesheets/collavre/popup.css +68 -1
- data/app/controllers/collavre/application_controller.rb +13 -0
- data/app/controllers/collavre/comments/versions_controller.rb +82 -0
- data/app/controllers/collavre/comments_controller.rb +14 -153
- data/app/controllers/collavre/concerns/exportable.rb +30 -0
- data/app/controllers/collavre/concerns/shareable.rb +28 -0
- data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
- data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
- data/app/controllers/collavre/creative_imports_controller.rb +6 -0
- data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
- data/app/controllers/collavre/creative_plans_controller.rb +1 -1
- data/app/controllers/collavre/creative_shares_controller.rb +84 -14
- data/app/controllers/collavre/creatives_controller.rb +70 -194
- data/app/controllers/collavre/google_auth_controller.rb +3 -0
- data/app/controllers/collavre/invites_controller.rb +2 -1
- data/app/controllers/collavre/sessions_controller.rb +3 -0
- data/app/controllers/collavre/topics_controller.rb +39 -2
- data/app/controllers/collavre/users_controller.rb +5 -404
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
- data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
- data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
- data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
- data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +12 -9
- data/app/helpers/collavre/navigation_helper.rb +1 -1
- data/app/javascript/collavre.js +0 -1
- data/app/javascript/controllers/comment_controller.js +33 -70
- data/app/javascript/controllers/comment_version_controller.js +164 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
- data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
- data/app/javascript/controllers/comments/contexts_controller.js +363 -0
- data/app/javascript/controllers/comments/form_controller.js +304 -13
- data/app/javascript/controllers/comments/list_controller.js +151 -62
- data/app/javascript/controllers/comments/popup_controller.js +66 -38
- data/app/javascript/controllers/comments/presence_controller.js +2 -10
- data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
- data/app/javascript/controllers/comments/topics_controller.js +34 -10
- data/app/javascript/controllers/index.js +15 -1
- data/app/javascript/controllers/org_chart_controller.js +46 -0
- data/app/javascript/controllers/share_modal_controller.js +369 -0
- data/app/javascript/controllers/topic_search_controller.js +103 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
- data/app/javascript/lib/api/creatives.js +12 -0
- data/app/javascript/lib/api/csrf_fetch.js +35 -0
- data/app/javascript/lib/api/drag_drop.js +17 -0
- data/app/javascript/modules/command_menu.js +40 -0
- data/app/javascript/modules/creative_row_editor.js +88 -0
- data/app/javascript/modules/slide_view.js +2 -1
- data/app/jobs/collavre/ai_agent_job.rb +42 -30
- data/app/jobs/collavre/compress_job.rb +92 -0
- data/app/models/collavre/comment.rb +36 -1
- data/app/models/collavre/comment_version.rb +15 -0
- data/app/models/collavre/creative/describable.rb +1 -1
- data/app/models/collavre/creative.rb +51 -0
- data/app/models/collavre/task.rb +30 -2
- data/app/models/collavre/user.rb +20 -3
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
- data/app/services/collavre/ai_agent/message_builder.rb +85 -6
- data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
- data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
- data/app/services/collavre/ai_agent/review_handler.rb +18 -1
- data/app/services/collavre/ai_agent_service.rb +130 -183
- data/app/services/collavre/ai_client.rb +6 -0
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/command_menu_service.rb +19 -0
- data/app/services/collavre/comments/command_processor.rb +3 -1
- data/app/services/collavre/comments/compress_command.rb +75 -0
- data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
- data/app/services/collavre/comments/work_command.rb +161 -0
- data/app/services/collavre/comments/workflow_executor.rb +276 -0
- data/app/services/collavre/creatives/plan_tagger.rb +14 -3
- data/app/services/collavre/creatives/tree_formatter.rb +53 -13
- data/app/services/collavre/gemini_parent_recommender.rb +4 -4
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
- data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
- data/app/services/collavre/orchestration/scheduler.rb +3 -2
- data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
- data/app/services/collavre/system_events/dispatcher.rb +9 -0
- data/app/services/collavre/tools/creative_create_service.rb +1 -8
- data/app/services/collavre/tools/creative_import_service.rb +46 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
- data/app/services/collavre/tools/creative_update_service.rb +1 -8
- data/app/services/collavre/tools/cron_list_service.rb +1 -1
- data/app/services/collavre/tools/description_normalizable.rb +16 -0
- data/app/views/collavre/comments/_comment.html.erb +25 -8
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
- data/app/views/collavre/creatives/_share_button.html.erb +4 -1
- data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
- data/app/views/collavre/creatives/index.html.erb +5 -5
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
- data/app/views/collavre/users/_org_chart.html.erb +68 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
- data/app/views/collavre/users/new_ai.html.erb +9 -0
- data/app/views/collavre/users/show.html.erb +32 -8
- data/config/locales/comments.en.yml +57 -2
- data/config/locales/comments.ko.yml +57 -2
- data/config/locales/contacts.en.yml +31 -0
- data/config/locales/contacts.ko.yml +31 -0
- data/config/locales/contexts.en.yml +8 -0
- data/config/locales/contexts.ko.yml +8 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +6 -0
- data/config/locales/users.en.yml +1 -0
- data/config/locales/users.ko.yml +1 -0
- data/config/routes.rb +14 -1
- data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
- data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
- data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
- data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
- data/lib/collavre/version.rb +1 -1
- metadata +47 -10
- data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
- data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
- data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
- data/app/javascript/modules/share_modal.js +0 -76
- data/app/javascript/modules/share_user_popup.js +0 -77
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
- data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
- 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
|
-
#
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
data/app/models/collavre/task.rb
CHANGED
|
@@ -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
|
|
11
|
-
|
|
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
|
data/app/models/collavre/user.rb
CHANGED
|
@@ -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
|
-
#
|
|
43
|
-
has_many :creatives, class_name: "Collavre::Creative", dependent:
|
|
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-
|
|
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
|