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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module AiAgent
|
|
5
|
+
# Builds the message array for AI agent conversations.
|
|
6
|
+
#
|
|
7
|
+
# Extracts creative context, chat history, and the trigger comment
|
|
8
|
+
# into the format expected by AiClient.
|
|
9
|
+
class MessageBuilder
|
|
10
|
+
def initialize(agent:, context:, original_comment: nil)
|
|
11
|
+
@agent = agent
|
|
12
|
+
@context = context
|
|
13
|
+
@original_comment = original_comment
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build
|
|
17
|
+
messages = []
|
|
18
|
+
|
|
19
|
+
append_creative_context(messages)
|
|
20
|
+
append_chat_history(messages)
|
|
21
|
+
append_trigger_message(messages)
|
|
22
|
+
|
|
23
|
+
messages
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def append_creative_context(messages)
|
|
29
|
+
creative_id = @context.dig("creative", "id")
|
|
30
|
+
return unless creative_id
|
|
31
|
+
|
|
32
|
+
creative = Creative.find_by(id: creative_id)
|
|
33
|
+
return unless creative
|
|
34
|
+
|
|
35
|
+
children_level = @agent.creative_children_level
|
|
36
|
+
max_depth = 1 + children_level
|
|
37
|
+
markdown = ApplicationController.helpers.render_creative_tree_markdown(
|
|
38
|
+
[ creative ], 1, true, max_depth: max_depth
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
topic = current_topic
|
|
42
|
+
topic_info = topic ? "\nTopic: #{topic.name} (id: #{topic.id})" : ""
|
|
43
|
+
|
|
44
|
+
messages << { role: "user", parts: [ { text: "Creative (id: #{creative.id}):#{topic_info}\n#{markdown}" } ] }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def append_chat_history(messages)
|
|
48
|
+
creative_id = @context.dig("creative", "id")
|
|
49
|
+
return unless creative_id
|
|
50
|
+
|
|
51
|
+
topic_id = trigger_comment&.topic_id
|
|
52
|
+
|
|
53
|
+
history_limit = @agent.chat_history_limit
|
|
54
|
+
history_size_limit = @agent.chat_history_size_limit
|
|
55
|
+
history_chars = 0
|
|
56
|
+
|
|
57
|
+
Comment.where(creative_id: creative_id, private: false)
|
|
58
|
+
.where(topic_id: topic_id)
|
|
59
|
+
.where.not(user_id: nil)
|
|
60
|
+
.includes(:user)
|
|
61
|
+
.order(created_at: :desc)
|
|
62
|
+
.limit(history_limit)
|
|
63
|
+
.reverse
|
|
64
|
+
.each do |c|
|
|
65
|
+
next if c.id == trigger_comment&.id
|
|
66
|
+
|
|
67
|
+
role = (c.user_id == @agent.id) ? "model" : "user"
|
|
68
|
+
content = c.content.to_s
|
|
69
|
+
|
|
70
|
+
if role == "user"
|
|
71
|
+
content = MentionParser.strip_self_mention(content, @agent.name)
|
|
72
|
+
speaker = c.user&.name || "unknown"
|
|
73
|
+
content = "[#{speaker}]: #{content}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
history_chars += content.length
|
|
77
|
+
break if history_chars > history_size_limit
|
|
78
|
+
|
|
79
|
+
messages << { role: role, parts: [ { text: content } ] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def append_trigger_message(messages)
|
|
84
|
+
payload_text = @context.dig("comment", "content") || @context.to_json
|
|
85
|
+
|
|
86
|
+
if review_eligible?
|
|
87
|
+
quoted_body = @original_comment.quoted_comment&.content
|
|
88
|
+
review_context = I18n.t("collavre.ai_agent.review.context")
|
|
89
|
+
review_parts = [ review_context ]
|
|
90
|
+
review_parts << "---\nOriginal message:\n#{quoted_body}\n---" if quoted_body.present?
|
|
91
|
+
payload_text = "#{review_parts.join("\n\n")}\n\n#{payload_text}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
sender_name = @context.dig("sender", "name")
|
|
95
|
+
if sender_name
|
|
96
|
+
payload_text = MentionParser.strip_self_mention(payload_text, @agent.name)
|
|
97
|
+
payload_text = "[#{sender_name}]: #{payload_text}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
trigger_parts = [ { text: payload_text } ]
|
|
101
|
+
|
|
102
|
+
if @original_comment&.images&.attached?
|
|
103
|
+
@original_comment.images.each do |image|
|
|
104
|
+
trigger_parts << { image: image.blob }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
messages << { role: "user", parts: trigger_parts }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def trigger_comment
|
|
112
|
+
@trigger_comment ||= begin
|
|
113
|
+
id = @context.dig("comment", "id")
|
|
114
|
+
Comment.find_by(id: id) if id
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def current_topic
|
|
119
|
+
return unless trigger_comment&.topic_id
|
|
120
|
+
|
|
121
|
+
Topic.find_by(id: trigger_comment.topic_id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def review_eligible?
|
|
125
|
+
ReviewHandler.eligible?(@original_comment, @agent)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module AiAgent
|
|
5
|
+
# Handles AI agent review workflows: eligibility checks,
|
|
6
|
+
# in-place content updates, and completion reactions.
|
|
7
|
+
class ReviewHandler
|
|
8
|
+
def initialize(original_comment, agent)
|
|
9
|
+
@original_comment = original_comment
|
|
10
|
+
@agent = agent
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Class-level convenience for eligibility checks.
|
|
14
|
+
def self.eligible?(original_comment, agent)
|
|
15
|
+
new(original_comment, agent).eligible?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def eligible?
|
|
19
|
+
return false unless @original_comment&.review_message?
|
|
20
|
+
|
|
21
|
+
quoted_comment = @original_comment.quoted_comment
|
|
22
|
+
return false unless quoted_comment
|
|
23
|
+
return false unless quoted_comment.user_id == @agent.id
|
|
24
|
+
return false unless quoted_comment.creative_id == @original_comment.creative_id
|
|
25
|
+
return false if quoted_comment.private?
|
|
26
|
+
return false unless quoted_comment.topic_id == @original_comment.topic_id
|
|
27
|
+
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Update the quoted comment with the AI response content.
|
|
32
|
+
# Returns true if the review was handled, false otherwise.
|
|
33
|
+
def handle(response_content, task:)
|
|
34
|
+
return false unless eligible?
|
|
35
|
+
|
|
36
|
+
quoted_comment = @original_comment.quoted_comment
|
|
37
|
+
quoted_comment.update!(content: response_content)
|
|
38
|
+
|
|
39
|
+
task.task_actions.create!(
|
|
40
|
+
action_type: "review_updated",
|
|
41
|
+
payload: {
|
|
42
|
+
quoted_comment_id: quoted_comment.id,
|
|
43
|
+
original_comment_id: @original_comment.id,
|
|
44
|
+
content: response_content
|
|
45
|
+
},
|
|
46
|
+
status: "done"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Add a completion reaction emoji to the review comment.
|
|
53
|
+
def add_completion_reaction
|
|
54
|
+
reaction = begin
|
|
55
|
+
CommentReaction.find_or_create_by!(
|
|
56
|
+
comment: @original_comment,
|
|
57
|
+
user: @agent,
|
|
58
|
+
emoji: "✅"
|
|
59
|
+
)
|
|
60
|
+
rescue ActiveRecord::RecordNotUnique
|
|
61
|
+
CommentReaction.find_by(comment: @original_comment, user: @agent, emoji: "✅")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
CommentReaction.broadcast_reaction_update(@original_comment) if reaction
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
Rails.logger.warn("[AiAgent::ReviewHandler] Failed to add review reaction: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -4,6 +4,8 @@ module Collavre
|
|
|
4
4
|
STREAM_THROTTLE_INTERVAL = 0.1
|
|
5
5
|
# Minimum interval (in seconds) between cancellation checks to avoid excessive DB queries
|
|
6
6
|
CANCEL_CHECK_INTERVAL = 1.0
|
|
7
|
+
# Interval (in seconds) between agent_status heartbeats during streaming
|
|
8
|
+
AGENT_STATUS_HEARTBEAT_INTERVAL = 3.0
|
|
7
9
|
|
|
8
10
|
def initialize(task)
|
|
9
11
|
@task = task
|
|
@@ -13,26 +15,25 @@ module Collavre
|
|
|
13
15
|
|
|
14
16
|
def call
|
|
15
17
|
Current.set(user: @agent) do
|
|
16
|
-
# Log start action
|
|
17
18
|
log_action("start", { message: "Starting agent execution" })
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
target_comment_id = @context.dig("comment", "id")
|
|
21
|
+
@original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
|
|
22
|
+
|
|
23
|
+
messages = AiAgent::MessageBuilder.new(
|
|
24
|
+
agent: @agent, context: @context, original_comment: @original_comment
|
|
25
|
+
).build
|
|
21
26
|
|
|
22
|
-
# Log prompt generation
|
|
23
27
|
log_action("prompt_generated", { messages: messages })
|
|
24
28
|
|
|
25
|
-
# Call AI Client
|
|
26
29
|
@response_content = ""
|
|
27
30
|
|
|
28
|
-
# Enrich context for rendering
|
|
29
31
|
rendering_context = @context.dup
|
|
30
32
|
if @context.dig("creative", "id")
|
|
31
33
|
creative = Creative.find_by(id: @context["creative"]["id"])
|
|
32
34
|
rendering_context["creative"] = creative.as_json if creative
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
# Add agent collaboration context
|
|
36
37
|
agent_context = build_agent_context(creative)
|
|
37
38
|
rendering_context.merge!(agent_context)
|
|
38
39
|
|
|
@@ -41,18 +42,14 @@ module Collavre
|
|
|
41
42
|
context: rendering_context
|
|
42
43
|
).render
|
|
43
44
|
|
|
44
|
-
# Append collaboration guide to system prompt
|
|
45
45
|
collaboration_prompt = build_collaboration_prompt(creative)
|
|
46
46
|
rendered_system_prompt = "#{rendered_system_prompt}\n\n#{collaboration_prompt}" if collaboration_prompt.present?
|
|
47
47
|
|
|
48
|
-
# Create a placeholder comment to stream into
|
|
49
|
-
target_comment_id = @context.dig("comment", "id")
|
|
50
|
-
@original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
|
|
51
48
|
@reply_comment = nil
|
|
52
49
|
|
|
53
50
|
if @original_comment
|
|
54
51
|
@reply_comment = @original_comment.creative.comments.create!(
|
|
55
|
-
content: Comment::STREAMING_PLACEHOLDER_CONTENT,
|
|
52
|
+
content: Comment::STREAMING_PLACEHOLDER_CONTENT,
|
|
56
53
|
user: @agent,
|
|
57
54
|
topic_id: @original_comment.topic_id
|
|
58
55
|
)
|
|
@@ -60,7 +57,6 @@ module Collavre
|
|
|
60
57
|
|
|
61
58
|
@creative = @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil
|
|
62
59
|
|
|
63
|
-
# Broadcast "thinking" status via presence channel
|
|
64
60
|
broadcast_agent_status("thinking")
|
|
65
61
|
|
|
66
62
|
client = AiClient.new(
|
|
@@ -77,52 +73,45 @@ module Collavre
|
|
|
77
73
|
)
|
|
78
74
|
|
|
79
75
|
last_broadcast_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
last_heartbeat_at = last_broadcast_at
|
|
80
77
|
@last_cancel_check_at = last_broadcast_at
|
|
81
78
|
|
|
82
79
|
client.chat(messages, tools: @agent.tools || []) do |delta|
|
|
83
80
|
check_cancelled!
|
|
84
81
|
@response_content += delta
|
|
85
82
|
|
|
86
|
-
# Stream updates to placeholder comment (throttled)
|
|
87
83
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
84
|
if @reply_comment && (now - last_broadcast_at) >= STREAM_THROTTLE_INTERVAL
|
|
89
85
|
@reply_comment.update_column(:content, @response_content)
|
|
90
86
|
@reply_comment.broadcast_update_to(
|
|
91
87
|
[ @reply_comment.creative, :comments ],
|
|
92
|
-
partial: "collavre/comments/comment"
|
|
88
|
+
partial: "collavre/comments/comment",
|
|
89
|
+
locals: { comment: @reply_comment, streaming: true }
|
|
93
90
|
)
|
|
94
91
|
last_broadcast_at = now
|
|
95
92
|
end
|
|
93
|
+
|
|
94
|
+
# Periodic agent_status heartbeat so clients know we're still streaming
|
|
95
|
+
if (now - last_heartbeat_at) >= AGENT_STATUS_HEARTBEAT_INTERVAL
|
|
96
|
+
broadcast_agent_status("streaming")
|
|
97
|
+
last_heartbeat_at = now
|
|
98
|
+
end
|
|
96
99
|
end
|
|
97
100
|
|
|
98
|
-
# Log completion
|
|
99
101
|
log_action("completion", { response: @response_content })
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
if @reply_comment
|
|
103
|
-
if @response_content.present?
|
|
104
|
-
# Force dirty tracking since update_column bypassed it during streaming
|
|
105
|
-
@reply_comment.content_will_change!
|
|
106
|
-
@reply_comment.update!(content: @response_content)
|
|
107
|
-
log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
|
|
103
|
+
finalize_response
|
|
108
104
|
|
|
109
|
-
# Re-associate activity logs from the trigger comment to the reply comment
|
|
110
|
-
reassociate_activity_logs(@original_comment, @reply_comment)
|
|
111
|
-
else
|
|
112
|
-
@reply_comment.destroy!
|
|
113
|
-
end
|
|
114
|
-
elsif target_comment_id && @response_content.present?
|
|
115
|
-
reply_to_comment(target_comment_id, @response_content)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Broadcast "idle" status
|
|
119
105
|
broadcast_agent_status("idle")
|
|
120
106
|
|
|
121
107
|
@response_content
|
|
122
108
|
end
|
|
123
109
|
rescue ApprovalPendingError => e
|
|
124
|
-
|
|
125
|
-
|
|
110
|
+
AiAgent::ApprovalHandler.new(
|
|
111
|
+
task: @task, agent: @agent, context: @context,
|
|
112
|
+
creative: @creative, reply_comment: @reply_comment
|
|
113
|
+
).handle(e)
|
|
114
|
+
raise
|
|
126
115
|
rescue CancelledError
|
|
127
116
|
handle_cancelled
|
|
128
117
|
raise
|
|
@@ -139,11 +128,15 @@ module Collavre
|
|
|
139
128
|
end
|
|
140
129
|
|
|
141
130
|
def handle_cancelled
|
|
142
|
-
# Save accumulated content to the placeholder comment before stopping
|
|
143
131
|
if @reply_comment
|
|
144
132
|
if @response_content.present?
|
|
145
133
|
@reply_comment.content_will_change!
|
|
146
134
|
@reply_comment.update!(content: @response_content)
|
|
135
|
+
@reply_comment.broadcast_update_to(
|
|
136
|
+
[ @reply_comment.creative, :comments ],
|
|
137
|
+
partial: "collavre/comments/comment",
|
|
138
|
+
locals: { comment: @reply_comment, streaming: false }
|
|
139
|
+
)
|
|
147
140
|
log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content, partial: true })
|
|
148
141
|
reassociate_activity_logs(@original_comment, @reply_comment)
|
|
149
142
|
else
|
|
@@ -155,6 +148,35 @@ module Collavre
|
|
|
155
148
|
log_action("cancelled", { message: "Task cancelled by user" })
|
|
156
149
|
end
|
|
157
150
|
|
|
151
|
+
def finalize_response
|
|
152
|
+
review_handler = AiAgent::ReviewHandler.new(@original_comment, @agent)
|
|
153
|
+
|
|
154
|
+
if @reply_comment
|
|
155
|
+
if @response_content.present?
|
|
156
|
+
if @original_comment&.review_message? && review_handler.handle(@response_content, task: @task)
|
|
157
|
+
review_handler.add_completion_reaction
|
|
158
|
+
reassociate_activity_logs(@reply_comment, @original_comment.quoted_comment)
|
|
159
|
+
@reply_comment.destroy!
|
|
160
|
+
else
|
|
161
|
+
@reply_comment.content_will_change!
|
|
162
|
+
@reply_comment.update!(content: @response_content)
|
|
163
|
+
@reply_comment.broadcast_update_to(
|
|
164
|
+
[ @reply_comment.creative, :comments ],
|
|
165
|
+
partial: "collavre/comments/comment",
|
|
166
|
+
locals: { comment: @reply_comment, streaming: false }
|
|
167
|
+
)
|
|
168
|
+
log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
|
|
169
|
+
reassociate_activity_logs(@original_comment, @reply_comment)
|
|
170
|
+
dispatch_a2a_if_needed
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
@reply_comment.destroy!
|
|
174
|
+
end
|
|
175
|
+
elsif @context.dig("comment", "id") && @response_content.present?
|
|
176
|
+
reply_to_comment(@context["comment"]["id"], @response_content)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
158
180
|
def broadcast_agent_status(status, content: nil)
|
|
159
181
|
return unless @creative
|
|
160
182
|
|
|
@@ -178,68 +200,46 @@ module Collavre
|
|
|
178
200
|
)
|
|
179
201
|
end
|
|
180
202
|
|
|
181
|
-
def build_messages
|
|
182
|
-
messages = []
|
|
183
|
-
|
|
184
|
-
if @context["creative"]
|
|
185
|
-
creative_id = @context.dig("creative", "id")
|
|
186
|
-
if creative_id
|
|
187
|
-
creative = Creative.find_by(id: creative_id)
|
|
188
|
-
if creative
|
|
189
|
-
markdown = ApplicationController.helpers.render_creative_tree_markdown([ creative ], 1, true)
|
|
190
|
-
messages << { role: "user", parts: [ { text: "Creative:\n#{markdown}" } ] }
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
if @context.dig("creative", "id")
|
|
196
|
-
creative_id = @context["creative"]["id"]
|
|
197
|
-
|
|
198
|
-
trigger_comment_id = @context.dig("comment", "id")
|
|
199
|
-
trigger_comment = Comment.find_by(id: trigger_comment_id)
|
|
200
|
-
topic_id = trigger_comment&.topic_id
|
|
201
|
-
|
|
202
|
-
Comment.where(creative_id: creative_id, private: false)
|
|
203
|
-
.where(topic_id: topic_id)
|
|
204
|
-
.order(created_at: :desc)
|
|
205
|
-
.limit(50)
|
|
206
|
-
.reverse
|
|
207
|
-
.each do |c|
|
|
208
|
-
next if c.id == @context.dig("comment", "id")
|
|
209
|
-
|
|
210
|
-
role = (c.user_id == @agent.id) ? "model" : "user"
|
|
211
|
-
content = c.content
|
|
212
|
-
|
|
213
|
-
if role == "user"
|
|
214
|
-
if content.match?(/\A@#{Regexp.escape(@agent.name)}:/i)
|
|
215
|
-
content = content.sub(/\A@#{Regexp.escape(@agent.name)}:\s*/i, "")
|
|
216
|
-
elsif content.match?(/\A@#{Regexp.escape(@agent.name)}\s+/i)
|
|
217
|
-
content = content.sub(/\A@#{Regexp.escape(@agent.name)}\s+/i, "")
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
messages << { role: role, parts: [ { text: content } ] }
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
payload_text = @context.dig("comment", "content") || @context.to_json
|
|
226
|
-
messages << { role: "user", parts: [ { text: payload_text } ] }
|
|
227
|
-
|
|
228
|
-
messages
|
|
229
|
-
end
|
|
230
|
-
|
|
231
203
|
def reply_to_comment(comment_id, content)
|
|
232
204
|
original_comment = Comment.find_by(id: comment_id)
|
|
233
205
|
return unless original_comment
|
|
234
206
|
|
|
235
|
-
|
|
207
|
+
@reply_comment = original_comment.creative.comments.create!(
|
|
236
208
|
content: content,
|
|
237
209
|
user: @agent,
|
|
238
210
|
topic_id: original_comment.topic_id
|
|
239
211
|
)
|
|
240
212
|
|
|
241
|
-
log_action("reply_created", { comment_id:
|
|
242
|
-
reassociate_activity_logs(original_comment,
|
|
213
|
+
log_action("reply_created", { comment_id: @reply_comment.id, content: content })
|
|
214
|
+
reassociate_activity_logs(original_comment, @reply_comment)
|
|
215
|
+
dispatch_a2a_if_needed
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def dispatch_a2a_if_needed
|
|
219
|
+
return unless @reply_comment&.content.present?
|
|
220
|
+
|
|
221
|
+
# Find all mentioned AI agents anywhere in the response (not just at the start)
|
|
222
|
+
mentioned_agents = MentionParser.resolve_all_users(@reply_comment.content).select(&:ai_user?)
|
|
223
|
+
return if mentioned_agents.empty?
|
|
224
|
+
|
|
225
|
+
creative = @reply_comment.creative
|
|
226
|
+
|
|
227
|
+
mentioned_agents.each do |mentioned_user|
|
|
228
|
+
if creative
|
|
229
|
+
context = { "creative" => { "id" => creative.id }, "topic" => { "id" => @reply_comment.topic_id } }
|
|
230
|
+
Orchestration::LoopBreaker.new(context).record_interaction(@agent.id, mentioned_user.id, creative.id)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Dispatch event — downstream Matcher will route to the correct agent(s)
|
|
235
|
+
SystemEvents::Dispatcher.dispatch("comment_created", {
|
|
236
|
+
comment: { id: @reply_comment.id, content: @reply_comment.content, user_id: @reply_comment.user_id },
|
|
237
|
+
creative: { id: creative&.id, description: creative&.description },
|
|
238
|
+
topic: { id: @reply_comment.topic_id },
|
|
239
|
+
chat: { content: @reply_comment.content }
|
|
240
|
+
})
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
Rails.logger.error("[AiAgentService] A2A dispatch failed: #{e.message}")
|
|
243
243
|
end
|
|
244
244
|
|
|
245
245
|
def reassociate_activity_logs(from_comment, to_comment)
|
|
@@ -263,70 +263,13 @@ module Collavre
|
|
|
263
263
|
Orchestration::AgentContextBuilder.new(
|
|
264
264
|
agent: @agent,
|
|
265
265
|
creative: creative,
|
|
266
|
-
sender: @context["sender"]
|
|
266
|
+
sender: @context["sender"],
|
|
267
|
+
policy_resolver: build_policy_resolver
|
|
267
268
|
).to_collaboration_prompt
|
|
268
269
|
end
|
|
269
270
|
|
|
270
|
-
def
|
|
271
|
-
|
|
272
|
-
@reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
|
|
273
|
-
|
|
274
|
-
broadcast_agent_status("idle")
|
|
275
|
-
|
|
276
|
-
@task.update!(
|
|
277
|
-
status: "pending_approval",
|
|
278
|
-
pending_tool_call: {
|
|
279
|
-
tool_name: error.tool_name,
|
|
280
|
-
tool_call_id: error.tool_call_id,
|
|
281
|
-
arguments: error.tool_arguments,
|
|
282
|
-
requested_at: Time.current.iso8601
|
|
283
|
-
}
|
|
284
|
-
)
|
|
285
|
-
|
|
286
|
-
log_action("pending_approval", error.to_h)
|
|
287
|
-
create_approval_comment(error)
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def create_approval_comment(error)
|
|
291
|
-
return unless @creative
|
|
292
|
-
|
|
293
|
-
approver = @creative.user || User.find_by(id: @context.dig("comment", "user_id"))
|
|
294
|
-
return unless approver
|
|
295
|
-
|
|
296
|
-
action_payload = {
|
|
297
|
-
action: "execute_tool",
|
|
298
|
-
tool_name: error.tool_name,
|
|
299
|
-
arguments: error.tool_arguments,
|
|
300
|
-
resume: {
|
|
301
|
-
task_id: @task.id,
|
|
302
|
-
tool_call_id: error.tool_call_id
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
args_display = if error.tool_arguments.present?
|
|
307
|
-
JSON.pretty_generate(error.tool_arguments)
|
|
308
|
-
else
|
|
309
|
-
I18n.t("collavre.ai_agent.approval.no_arguments")
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
content = I18n.t(
|
|
313
|
-
"collavre.ai_agent.approval.message",
|
|
314
|
-
tool_name: error.tool_name,
|
|
315
|
-
arguments: args_display
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
original_comment = Comment.find_by(id: @context.dig("comment", "id"))
|
|
319
|
-
topic_id = original_comment&.topic_id
|
|
320
|
-
|
|
321
|
-
Comment.create!(
|
|
322
|
-
creative: @creative,
|
|
323
|
-
content: content,
|
|
324
|
-
user: @agent,
|
|
325
|
-
approver: approver,
|
|
326
|
-
action: JSON.pretty_generate(action_payload),
|
|
327
|
-
topic_id: topic_id,
|
|
328
|
-
private: false
|
|
329
|
-
)
|
|
271
|
+
def build_policy_resolver
|
|
272
|
+
Orchestration::PolicyResolver.new(@context)
|
|
330
273
|
end
|
|
331
274
|
end
|
|
332
275
|
end
|
|
@@ -129,9 +129,12 @@ module Collavre
|
|
|
129
129
|
tool_name = tool_call.name
|
|
130
130
|
task = context&.dig(:task)
|
|
131
131
|
|
|
132
|
-
# Check if this tool requires approval
|
|
132
|
+
# Check if this tool requires approval (dynamic McpTool or system tool)
|
|
133
133
|
mcp_tool = McpTool.find_by(name: tool_name)
|
|
134
|
-
|
|
134
|
+
system_tool_class = ToolMeta.registry.find { |klass| klass.tool_metadata[:name] == tool_name }
|
|
135
|
+
requires = mcp_tool&.requires_approval? ||
|
|
136
|
+
(system_tool_class.respond_to?(:requires_approval?) && system_tool_class.requires_approval?)
|
|
137
|
+
return unless requires
|
|
135
138
|
|
|
136
139
|
# Check if we already have approval for this specific call (resume scenario)
|
|
137
140
|
if task&.pending_tool_call.present?
|
|
@@ -159,9 +162,18 @@ module Collavre
|
|
|
159
162
|
next unless role
|
|
160
163
|
|
|
161
164
|
text = extract_message_text(message)
|
|
162
|
-
|
|
165
|
+
image_sources = extract_image_sources(message)
|
|
163
166
|
|
|
164
|
-
|
|
167
|
+
next if text.blank? && image_sources.empty?
|
|
168
|
+
|
|
169
|
+
if image_sources.any?
|
|
170
|
+
content = RubyLLM::Content.new(text.presence, image_sources)
|
|
171
|
+
conversation.add_message(role:, content: content)
|
|
172
|
+
else
|
|
173
|
+
next if text.blank?
|
|
174
|
+
|
|
175
|
+
conversation.add_message(role:, content: text)
|
|
176
|
+
end
|
|
165
177
|
end
|
|
166
178
|
end
|
|
167
179
|
|
|
@@ -177,6 +189,13 @@ module Collavre
|
|
|
177
189
|
end
|
|
178
190
|
end
|
|
179
191
|
|
|
192
|
+
def extract_image_sources(message)
|
|
193
|
+
parts = message[:parts] || message["parts"]
|
|
194
|
+
return [] if parts.nil?
|
|
195
|
+
|
|
196
|
+
Array(parts).filter_map { |part| part[:image] || part["image"] }
|
|
197
|
+
end
|
|
198
|
+
|
|
180
199
|
def extract_message_text(message)
|
|
181
200
|
parts = message[:parts] || message["parts"]
|
|
182
201
|
return message[:text] || message["text"] if parts.nil?
|