collavre 0.1.1 → 0.2.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/comments_popup.css +293 -8
- data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
- data/app/assets/stylesheets/collavre/popup.css +7 -0
- data/app/assets/stylesheets/collavre/print.css +18 -0
- data/app/channels/collavre/comments_presence_channel.rb +33 -0
- data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
- data/app/components/collavre/autocomplete_popup_component.rb +18 -0
- data/app/components/collavre/command_menu_component.rb +7 -0
- data/app/components/collavre/plans_timeline_component.html.erb +1 -1
- data/app/components/collavre/plans_timeline_component.rb +29 -32
- data/app/components/collavre/user_mention_menu_component.rb +4 -5
- data/app/controllers/collavre/comments_controller.rb +111 -10
- data/app/controllers/collavre/creatives_controller.rb +8 -0
- data/app/controllers/collavre/google_auth_controller.rb +5 -1
- data/app/controllers/collavre/plans_controller.rb +65 -9
- data/app/controllers/collavre/topics_controller.rb +42 -0
- data/app/controllers/collavre/users_controller.rb +4 -14
- data/app/errors/collavre/approval_pending_error.rb +54 -0
- data/app/errors/collavre/cancelled_error.rb +9 -0
- data/app/helpers/collavre/navigation_helper.rb +3 -1
- data/app/javascript/collavre.js +1 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
- data/app/javascript/controllers/comments/form_controller.js +2 -1
- data/app/javascript/controllers/comments/list_controller.js +185 -2
- data/app/javascript/controllers/comments/popup_controller.js +95 -20
- data/app/javascript/controllers/comments/presence_controller.js +30 -1
- data/app/javascript/controllers/comments/topics_controller.js +314 -4
- data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
- data/app/javascript/modules/command_menu.js +116 -0
- data/app/javascript/modules/creative_progress.js +14 -0
- data/app/javascript/modules/creative_row_editor.js +104 -20
- data/app/javascript/modules/plans_timeline.js +15 -4
- data/app/javascript/modules/share_modal.js +3 -0
- data/app/jobs/collavre/ai_agent_job.rb +35 -21
- data/app/models/collavre/calendar_event.rb +7 -1
- data/app/models/collavre/comment.rb +35 -2
- data/app/models/collavre/creative.rb +1 -3
- data/app/models/collavre/mcp_tool.rb +4 -0
- data/app/models/collavre/plan.rb +23 -0
- data/app/models/collavre/topic.rb +12 -0
- data/app/models/collavre/user.rb +15 -1
- data/app/services/collavre/ai_agent_service.rb +174 -66
- data/app/services/collavre/ai_client.rb +31 -2
- data/app/services/collavre/comments/action_executor.rb +47 -1
- data/app/services/collavre/comments/calendar_command.rb +117 -18
- data/app/services/collavre/google_calendar_service.rb +38 -15
- data/app/services/collavre/markdown_importer.rb +47 -8
- data/app/services/collavre/mcp_service.rb +23 -10
- data/app/services/collavre/system_events/router.rb +50 -26
- data/app/services/collavre/tools/creative_create_service.rb +97 -0
- data/app/services/collavre/tools/creative_update_service.rb +116 -0
- data/app/views/collavre/comments/_comment.html.erb +2 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
- data/app/views/collavre/comments/fullscreen.html.erb +5 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
- data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
- data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
- data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
- data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
- data/app/views/collavre/creatives/_share_button.html.erb +1 -1
- data/app/views/collavre/creatives/index.html.erb +22 -4
- data/app/views/collavre/users/edit_ai.html.erb +15 -0
- data/app/views/collavre/users/new_ai.html.erb +15 -0
- data/app/views/layouts/collavre/chat.html.erb +46 -0
- data/config/locales/ai_agent.en.yml +15 -0
- data/config/locales/ai_agent.ko.yml +15 -0
- data/config/locales/comments.en.yml +15 -3
- data/config/locales/comments.ko.yml +15 -3
- data/config/locales/creatives.en.yml +3 -31
- data/config/locales/creatives.ko.yml +3 -27
- data/config/locales/plans.en.yml +4 -0
- data/config/locales/plans.ko.yml +4 -0
- data/config/locales/users.en.yml +3 -0
- data/config/locales/users.ko.yml +3 -0
- data/config/routes.rb +8 -3
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
- data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
- data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
- data/lib/collavre/engine.rb +171 -6
- data/lib/collavre/integration_registry.rb +129 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +2 -0
- data/lib/navigation/registry.rb +130 -0
- metadata +22 -15
- data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
- data/app/controllers/collavre/notion_auth_controller.rb +0 -25
- data/app/jobs/collavre/notion_export_job.rb +0 -30
- data/app/jobs/collavre/notion_sync_job.rb +0 -48
- data/app/models/collavre/notion_account.rb +0 -17
- data/app/models/collavre/notion_block_link.rb +0 -10
- data/app/models/collavre/notion_page_link.rb +0 -19
- data/app/services/collavre/notion_client.rb +0 -231
- data/app/services/collavre/notion_creative_exporter.rb +0 -296
- data/app/services/collavre/notion_service.rb +0 -249
- data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
- data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
- data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
- data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
class AiAgentService
|
|
3
|
+
# Minimum interval (in seconds) between streaming updates to avoid excessive DB writes
|
|
4
|
+
STREAM_THROTTLE_INTERVAL = 0.1
|
|
5
|
+
# Minimum interval (in seconds) between cancellation checks to avoid excessive DB queries
|
|
6
|
+
CANCEL_CHECK_INTERVAL = 1.0
|
|
7
|
+
|
|
3
8
|
def initialize(task)
|
|
4
9
|
@task = task
|
|
5
10
|
@agent = task.agent
|
|
@@ -18,7 +23,7 @@ module Collavre
|
|
|
18
23
|
log_action("prompt_generated", { messages: messages })
|
|
19
24
|
|
|
20
25
|
# Call AI Client
|
|
21
|
-
response_content = ""
|
|
26
|
+
@response_content = ""
|
|
22
27
|
|
|
23
28
|
# Enrich context for rendering
|
|
24
29
|
rendering_context = @context.dup
|
|
@@ -34,72 +39,125 @@ module Collavre
|
|
|
34
39
|
|
|
35
40
|
# Create a placeholder comment to stream into
|
|
36
41
|
target_comment_id = @context.dig("comment", "id")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
47
|
-
end
|
|
42
|
+
@original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
|
|
43
|
+
@reply_comment = nil
|
|
44
|
+
|
|
45
|
+
if @original_comment
|
|
46
|
+
@reply_comment = @original_comment.creative.comments.create!(
|
|
47
|
+
content: Comment::STREAMING_PLACEHOLDER_CONTENT, # Placeholder — replaced during streaming
|
|
48
|
+
user: @agent,
|
|
49
|
+
topic_id: @original_comment.topic_id
|
|
50
|
+
)
|
|
48
51
|
end
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
@creative = @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil
|
|
54
|
+
|
|
55
|
+
# Broadcast "thinking" status via presence channel
|
|
56
|
+
broadcast_agent_status("thinking")
|
|
57
|
+
|
|
51
58
|
client = AiClient.new(
|
|
52
59
|
vendor: @agent.llm_vendor,
|
|
53
60
|
model: @agent.llm_model,
|
|
54
61
|
system_prompt: rendered_system_prompt,
|
|
55
62
|
llm_api_key: @agent.llm_api_key,
|
|
56
63
|
context: {
|
|
57
|
-
creative: @
|
|
64
|
+
creative: @creative,
|
|
58
65
|
user: @agent,
|
|
59
|
-
|
|
66
|
+
task: @task,
|
|
67
|
+
comment: @reply_comment || @original_comment
|
|
60
68
|
}
|
|
61
69
|
)
|
|
62
70
|
|
|
71
|
+
last_broadcast_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
@last_cancel_check_at = last_broadcast_at
|
|
73
|
+
|
|
63
74
|
client.chat(messages, tools: @agent.tools || []) do |delta|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# Manually trigger broadcast for the content update
|
|
78
|
-
# We use broadcast_update_to to immediately stream the update
|
|
79
|
-
reply_comment.broadcast_update_to([ reply_comment.creative, :comments ], partial: "collavre/comments/comment")
|
|
75
|
+
check_cancelled!
|
|
76
|
+
@response_content += delta
|
|
77
|
+
|
|
78
|
+
# Stream updates to placeholder comment (throttled)
|
|
79
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
80
|
+
if @reply_comment && (now - last_broadcast_at) >= STREAM_THROTTLE_INTERVAL
|
|
81
|
+
@reply_comment.update_column(:content, @response_content)
|
|
82
|
+
@reply_comment.broadcast_update_to(
|
|
83
|
+
[ @reply_comment.creative, :comments ],
|
|
84
|
+
partial: "collavre/comments/comment"
|
|
85
|
+
)
|
|
86
|
+
last_broadcast_at = now
|
|
80
87
|
end
|
|
81
88
|
end
|
|
82
89
|
|
|
83
90
|
# Log completion
|
|
84
|
-
log_action("completion", { response: response_content })
|
|
91
|
+
log_action("completion", { response: @response_content })
|
|
85
92
|
|
|
86
93
|
# Final save to ensure everything is consistent and trigger final callbacks
|
|
87
|
-
if reply_comment
|
|
88
|
-
if response_content.present?
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
if @reply_comment
|
|
95
|
+
if @response_content.present?
|
|
96
|
+
# Force dirty tracking since update_column bypassed it during streaming
|
|
97
|
+
@reply_comment.content_will_change!
|
|
98
|
+
@reply_comment.update!(content: @response_content)
|
|
99
|
+
log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
|
|
100
|
+
|
|
101
|
+
# Re-associate activity logs from the trigger comment to the reply comment
|
|
102
|
+
reassociate_activity_logs(@original_comment, @reply_comment)
|
|
91
103
|
else
|
|
92
|
-
reply_comment.destroy!
|
|
104
|
+
@reply_comment.destroy!
|
|
93
105
|
end
|
|
94
|
-
elsif target_comment_id && response_content.present?
|
|
95
|
-
|
|
96
|
-
reply_to_comment(target_comment_id, response_content)
|
|
106
|
+
elsif target_comment_id && @response_content.present?
|
|
107
|
+
reply_to_comment(target_comment_id, @response_content)
|
|
97
108
|
end
|
|
109
|
+
|
|
110
|
+
# Broadcast "idle" status
|
|
111
|
+
broadcast_agent_status("idle")
|
|
98
112
|
end
|
|
113
|
+
rescue ApprovalPendingError => e
|
|
114
|
+
handle_approval_pending(e)
|
|
115
|
+
raise # Re-raise to signal the job to handle status
|
|
116
|
+
rescue CancelledError
|
|
117
|
+
handle_cancelled
|
|
118
|
+
raise
|
|
99
119
|
end
|
|
100
120
|
|
|
101
121
|
private
|
|
102
122
|
|
|
123
|
+
def check_cancelled!
|
|
124
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
125
|
+
return if (now - @last_cancel_check_at) < CANCEL_CHECK_INTERVAL
|
|
126
|
+
|
|
127
|
+
@last_cancel_check_at = now
|
|
128
|
+
raise CancelledError if @task.reload.status == "cancelled"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle_cancelled
|
|
132
|
+
# Save accumulated content to the placeholder comment before stopping
|
|
133
|
+
if @reply_comment
|
|
134
|
+
if @response_content.present?
|
|
135
|
+
@reply_comment.content_will_change!
|
|
136
|
+
@reply_comment.update!(content: @response_content)
|
|
137
|
+
log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content, partial: true })
|
|
138
|
+
reassociate_activity_logs(@original_comment, @reply_comment)
|
|
139
|
+
else
|
|
140
|
+
@reply_comment.destroy!
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
broadcast_agent_status("idle")
|
|
145
|
+
log_action("cancelled", { message: "Task cancelled by user" })
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def broadcast_agent_status(status, content: nil)
|
|
149
|
+
return unless @creative
|
|
150
|
+
|
|
151
|
+
CommentsPresenceChannel.broadcast_agent_status(
|
|
152
|
+
@creative.effective_origin.id,
|
|
153
|
+
status: status,
|
|
154
|
+
agent_id: @agent.id,
|
|
155
|
+
agent_name: @agent.display_name,
|
|
156
|
+
task_id: @task.id,
|
|
157
|
+
content: content
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
103
161
|
def log_action(type, payload, result = nil)
|
|
104
162
|
@task.task_actions.create!(
|
|
105
163
|
action_type: type,
|
|
@@ -110,17 +168,9 @@ module Collavre
|
|
|
110
168
|
end
|
|
111
169
|
|
|
112
170
|
def build_messages
|
|
113
|
-
# This logic mimics the old AiResponder but adapts to the new context structure
|
|
114
|
-
# We might need to fetch the creative and history based on context
|
|
115
|
-
|
|
116
171
|
messages = []
|
|
117
172
|
|
|
118
|
-
# Add context-specific messages
|
|
119
|
-
# For comment_created, we want the creative context and chat history
|
|
120
|
-
|
|
121
173
|
if @context["creative"]
|
|
122
|
-
# We might need to re-fetch creative to get the full markdown if it's not in context
|
|
123
|
-
# But for efficiency, let's assume we fetch it if ID is present
|
|
124
174
|
creative_id = @context.dig("creative", "id")
|
|
125
175
|
if creative_id
|
|
126
176
|
creative = Creative.find_by(id: creative_id)
|
|
@@ -131,17 +181,9 @@ module Collavre
|
|
|
131
181
|
end
|
|
132
182
|
end
|
|
133
183
|
|
|
134
|
-
# Add chat history
|
|
135
184
|
if @context.dig("creative", "id")
|
|
136
185
|
creative_id = @context["creative"]["id"]
|
|
137
|
-
# Fetch comments for context, excluding private ones unless owned by the user
|
|
138
|
-
# We need to be careful about which comments to include.
|
|
139
|
-
# For now, let's include non-private comments.
|
|
140
|
-
|
|
141
|
-
# We need to know who the "user" is to determine roles.
|
|
142
|
-
# In the new system, the agent is @agent.
|
|
143
186
|
|
|
144
|
-
# We need to filter by topic_id to maintain conversation context
|
|
145
187
|
trigger_comment_id = @context.dig("comment", "id")
|
|
146
188
|
trigger_comment = Comment.find_by(id: trigger_comment_id)
|
|
147
189
|
topic_id = trigger_comment&.topic_id
|
|
@@ -149,28 +191,26 @@ module Collavre
|
|
|
149
191
|
Comment.where(creative_id: creative_id, private: false)
|
|
150
192
|
.where(topic_id: topic_id)
|
|
151
193
|
.order(created_at: :desc)
|
|
152
|
-
.limit(50)
|
|
153
|
-
.reverse
|
|
194
|
+
.limit(50)
|
|
195
|
+
.reverse
|
|
154
196
|
.each do |c|
|
|
155
|
-
next if c.id == @context.dig("comment", "id")
|
|
197
|
+
next if c.id == @context.dig("comment", "id")
|
|
156
198
|
|
|
157
199
|
role = (c.user_id == @agent.id) ? "model" : "user"
|
|
158
200
|
content = c.content
|
|
159
201
|
|
|
160
|
-
# Strip mentions of the agent from user messages to clean up context
|
|
161
202
|
if role == "user"
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
203
|
+
if content.match?(/\A@#{Regexp.escape(@agent.name)}:/i)
|
|
204
|
+
content = content.sub(/\A@#{Regexp.escape(@agent.name)}:\s*/i, "")
|
|
205
|
+
elsif content.match?(/\A@#{Regexp.escape(@agent.name)}\s+/i)
|
|
206
|
+
content = content.sub(/\A@#{Regexp.escape(@agent.name)}\s+/i, "")
|
|
207
|
+
end
|
|
167
208
|
end
|
|
168
209
|
|
|
169
210
|
messages << { role: role, parts: [ { text: content } ] }
|
|
170
211
|
end
|
|
171
212
|
end
|
|
172
213
|
|
|
173
|
-
# Add the trigger payload
|
|
174
214
|
payload_text = @context.dig("comment", "content") || @context.to_json
|
|
175
215
|
messages << { role: "user", parts: [ { text: payload_text } ] }
|
|
176
216
|
|
|
@@ -188,6 +228,74 @@ module Collavre
|
|
|
188
228
|
)
|
|
189
229
|
|
|
190
230
|
log_action("reply_created", { comment_id: reply.id, content: content })
|
|
231
|
+
reassociate_activity_logs(original_comment, reply)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def reassociate_activity_logs(from_comment, to_comment)
|
|
235
|
+
ActivityLog.where(comment: from_comment, user: @agent)
|
|
236
|
+
.update_all(comment_id: to_comment.id)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def handle_approval_pending(error)
|
|
240
|
+
# Clean up placeholder comment if exists
|
|
241
|
+
@reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
|
|
242
|
+
|
|
243
|
+
broadcast_agent_status("idle")
|
|
244
|
+
|
|
245
|
+
@task.update!(
|
|
246
|
+
status: "pending_approval",
|
|
247
|
+
pending_tool_call: {
|
|
248
|
+
tool_name: error.tool_name,
|
|
249
|
+
tool_call_id: error.tool_call_id,
|
|
250
|
+
arguments: error.tool_arguments,
|
|
251
|
+
requested_at: Time.current.iso8601
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
log_action("pending_approval", error.to_h)
|
|
256
|
+
create_approval_comment(error)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def create_approval_comment(error)
|
|
260
|
+
return unless @creative
|
|
261
|
+
|
|
262
|
+
approver = @creative.user || User.find_by(id: @context.dig("comment", "user_id"))
|
|
263
|
+
return unless approver
|
|
264
|
+
|
|
265
|
+
action_payload = {
|
|
266
|
+
action: "execute_tool",
|
|
267
|
+
tool_name: error.tool_name,
|
|
268
|
+
arguments: error.tool_arguments,
|
|
269
|
+
resume: {
|
|
270
|
+
task_id: @task.id,
|
|
271
|
+
tool_call_id: error.tool_call_id
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
args_display = if error.tool_arguments.present?
|
|
276
|
+
JSON.pretty_generate(error.tool_arguments)
|
|
277
|
+
else
|
|
278
|
+
I18n.t("collavre.ai_agent.approval.no_arguments")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
content = I18n.t(
|
|
282
|
+
"collavre.ai_agent.approval.message",
|
|
283
|
+
tool_name: error.tool_name,
|
|
284
|
+
arguments: args_display
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
original_comment = Comment.find_by(id: @context.dig("comment", "id"))
|
|
288
|
+
topic_id = original_comment&.topic_id
|
|
289
|
+
|
|
290
|
+
Comment.create!(
|
|
291
|
+
creative: @creative,
|
|
292
|
+
content: content,
|
|
293
|
+
user: @agent,
|
|
294
|
+
approver: approver,
|
|
295
|
+
action: JSON.pretty_generate(action_payload),
|
|
296
|
+
topic_id: topic_id,
|
|
297
|
+
private: false
|
|
298
|
+
)
|
|
191
299
|
end
|
|
192
300
|
end
|
|
193
301
|
end
|
|
@@ -78,6 +78,10 @@ module Collavre
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
response_content.presence
|
|
81
|
+
rescue ApprovalPendingError
|
|
82
|
+
raise # Re-raise approval errors without catching them
|
|
83
|
+
rescue CancelledError
|
|
84
|
+
raise # Re-raise cancellation errors without catching them
|
|
81
85
|
rescue StandardError => e
|
|
82
86
|
error_message = e.message
|
|
83
87
|
Rails.logger.error "AI Client error: #{e.message}"
|
|
@@ -110,8 +114,7 @@ module Collavre
|
|
|
110
114
|
.chat(model: model).tap do |chat|
|
|
111
115
|
chat.with_instructions(system_prompt) if system_prompt.present?
|
|
112
116
|
chat.on_tool_call do |tool_call|
|
|
113
|
-
|
|
114
|
-
# Rails.logger.info("Tool call: #{JSON.pretty_generate(tool_call.to_h)}")
|
|
117
|
+
check_tool_approval!(tool_call)
|
|
115
118
|
end
|
|
116
119
|
if tools.any?
|
|
117
120
|
# Resolve tool names to classes using the gem's helper
|
|
@@ -121,6 +124,32 @@ module Collavre
|
|
|
121
124
|
end
|
|
122
125
|
end
|
|
123
126
|
|
|
127
|
+
def check_tool_approval!(tool_call)
|
|
128
|
+
tool_name = tool_call.name
|
|
129
|
+
task = context&.dig(:task)
|
|
130
|
+
|
|
131
|
+
# Check if this tool requires approval
|
|
132
|
+
mcp_tool = McpTool.find_by(name: tool_name)
|
|
133
|
+
return unless mcp_tool&.requires_approval?
|
|
134
|
+
|
|
135
|
+
# Check if we already have approval for this specific call (resume scenario)
|
|
136
|
+
if task&.pending_tool_call.present?
|
|
137
|
+
pending = task.pending_tool_call
|
|
138
|
+
if pending["tool_name"] == tool_name && pending["approved"]
|
|
139
|
+
# Already approved, clear the pending state and proceed
|
|
140
|
+
task.update!(pending_tool_call: nil)
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Requires approval - raise error to halt execution
|
|
146
|
+
raise ApprovalPendingError.new(
|
|
147
|
+
"Tool '#{tool_name}' requires approval before execution",
|
|
148
|
+
tool_call: tool_call,
|
|
149
|
+
task: task
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
124
153
|
def add_messages(conversation, contents)
|
|
125
154
|
Array(contents).each do |message|
|
|
126
155
|
next if message.nil?
|
|
@@ -73,7 +73,8 @@ module Collavre
|
|
|
73
73
|
SUPPORTED_ACTIONS = {
|
|
74
74
|
"create_creative" => :create_creative,
|
|
75
75
|
"update_creative" => :update_creative,
|
|
76
|
-
"approve_tool" => :approve_tool
|
|
76
|
+
"approve_tool" => :approve_tool,
|
|
77
|
+
"execute_tool" => :execute_tool
|
|
77
78
|
}.freeze
|
|
78
79
|
|
|
79
80
|
CREATIVE_ATTRIBUTES = %w[description progress].freeze
|
|
@@ -159,6 +160,51 @@ module Collavre
|
|
|
159
160
|
tool.approve!
|
|
160
161
|
end
|
|
161
162
|
|
|
163
|
+
def execute_tool(payload)
|
|
164
|
+
tool_name = payload["tool_name"]
|
|
165
|
+
arguments = payload["arguments"] || {}
|
|
166
|
+
resume_info = payload["resume"] || {}
|
|
167
|
+
task_id = resume_info["task_id"]
|
|
168
|
+
tool_call_id = resume_info["tool_call_id"]
|
|
169
|
+
|
|
170
|
+
raise InvalidActionError, "Tool name is required" if tool_name.blank?
|
|
171
|
+
|
|
172
|
+
# Execute the tool via MetaToolService
|
|
173
|
+
result = begin
|
|
174
|
+
::Tools::MetaToolService.new.call(
|
|
175
|
+
action: "call",
|
|
176
|
+
tool_name: tool_name,
|
|
177
|
+
arguments: arguments
|
|
178
|
+
)
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
Rails.logger.error("Tool execution failed: #{e.message}")
|
|
181
|
+
{ error: e.message }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# If there's a task to resume, update it and re-queue the job
|
|
185
|
+
if task_id.present?
|
|
186
|
+
task = Task.find_by(id: task_id)
|
|
187
|
+
if task
|
|
188
|
+
# Mark the tool call as approved with its result
|
|
189
|
+
task.update!(
|
|
190
|
+
pending_tool_call: {
|
|
191
|
+
tool_name: tool_name,
|
|
192
|
+
tool_call_id: tool_call_id,
|
|
193
|
+
arguments: arguments,
|
|
194
|
+
approved: true,
|
|
195
|
+
result: result,
|
|
196
|
+
approved_at: Time.current.iso8601
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Resume the AI agent job
|
|
201
|
+
AiAgentJob.perform_later(task)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
result
|
|
206
|
+
end
|
|
207
|
+
|
|
162
208
|
def process_action(payload)
|
|
163
209
|
unless payload.is_a?(Hash)
|
|
164
210
|
raise InvalidActionError, I18n.t("collavre.comments.approve_invalid_format")
|
|
@@ -30,15 +30,18 @@ module Collavre
|
|
|
30
30
|
def parsed_args
|
|
31
31
|
@parsed_args ||= begin
|
|
32
32
|
args = command_body
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return unless
|
|
33
|
+
return if args.blank?
|
|
34
|
+
|
|
35
|
+
date_token, time_token, memo = parse_command_parts(args)
|
|
36
|
+
return unless date_token || time_token
|
|
37
|
+
|
|
38
|
+
parsed_date = parse_date_token(date_token)
|
|
39
|
+
return if date_token.present? && parsed_date.blank?
|
|
37
40
|
|
|
38
41
|
{
|
|
39
|
-
date:
|
|
40
|
-
time:
|
|
41
|
-
memo:
|
|
42
|
+
date: parsed_date&.to_s,
|
|
43
|
+
time: time_token,
|
|
44
|
+
memo: memo
|
|
42
45
|
}
|
|
43
46
|
end
|
|
44
47
|
end
|
|
@@ -47,6 +50,79 @@ module Collavre
|
|
|
47
50
|
comment.content.to_s.strip.sub(/\A\S+/, "").strip
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
def parse_command_parts(args)
|
|
54
|
+
if args.start_with?("@")
|
|
55
|
+
match = args.match(/\A@(\d{2}:\d{2})(?:\s+(.*))?\z/)
|
|
56
|
+
return unless match
|
|
57
|
+
|
|
58
|
+
return [ nil, match[1], match[2] ]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Use [^\s@]+ to stop before @ so "today@14:00" splits correctly
|
|
62
|
+
match = args.match(/\A([^\s@]+)?(?:@(\d{2}:\d{2}))?(?:\s+(.*))?\z/)
|
|
63
|
+
return unless match
|
|
64
|
+
|
|
65
|
+
[ match[1], match[2], match[3] ]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_date_token(token)
|
|
69
|
+
return if token.blank?
|
|
70
|
+
|
|
71
|
+
today = Time.zone.today
|
|
72
|
+
token = token.strip
|
|
73
|
+
|
|
74
|
+
return today if token.match?(/\Atoday\z/i)
|
|
75
|
+
return today + 1.day if token.match?(/\Atomorrow\z/i)
|
|
76
|
+
|
|
77
|
+
interval_match = token.match(/\A\+(\d+)(days?|weeks?|months?|years?)\z/i)
|
|
78
|
+
if interval_match
|
|
79
|
+
amount = interval_match[1].to_i
|
|
80
|
+
unit = interval_match[2].downcase
|
|
81
|
+
return today + amount.days if unit.start_with?("day")
|
|
82
|
+
return today + amount.weeks if unit.start_with?("week")
|
|
83
|
+
return today.advance(months: amount) if unit.start_with?("month")
|
|
84
|
+
return today.advance(years: amount) if unit.start_with?("year")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
weekday_match = token.match(/\A\+(\d+)?(mon|tue|wed|thu|fri|sat|sun|[월화수목금토일])\z/i)
|
|
88
|
+
if weekday_match
|
|
89
|
+
count = weekday_match[1].present? ? weekday_match[1].to_i : 1
|
|
90
|
+
return next_weekday(today, weekday_index(weekday_match[2]), count)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# YYYY-MM-DD
|
|
94
|
+
return Date.iso8601(token) if token.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
95
|
+
|
|
96
|
+
# MM-DD (assume current year)
|
|
97
|
+
if token.match?(/\A\d{2}-\d{2}\z/)
|
|
98
|
+
return Date.parse("#{today.year}-#{token}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def weekday_index(token)
|
|
105
|
+
case token.to_s.downcase
|
|
106
|
+
when "mon", "월" then 1
|
|
107
|
+
when "tue", "화" then 2
|
|
108
|
+
when "wed", "수" then 3
|
|
109
|
+
when "thu", "목" then 4
|
|
110
|
+
when "fri", "금" then 5
|
|
111
|
+
when "sat", "토" then 6
|
|
112
|
+
when "sun", "일" then 0
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def next_weekday(base_date, target_wday, count)
|
|
117
|
+
days_until = (target_wday - base_date.wday) % 7
|
|
118
|
+
days_until = 7 if days_until.zero?
|
|
119
|
+
base_date + days_until + (count - 1) * 7
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def google_login?
|
|
123
|
+
user&.google_refresh_token.present?
|
|
124
|
+
end
|
|
125
|
+
|
|
50
126
|
def create_event
|
|
51
127
|
data = parsed_args
|
|
52
128
|
return unless data
|
|
@@ -54,20 +130,46 @@ module Collavre
|
|
|
54
130
|
timezone = Time.zone
|
|
55
131
|
start_time, end_time = calculate_times(timezone, data[:date], data[:time])
|
|
56
132
|
summary = build_summary(data[:memo])
|
|
133
|
+
|
|
134
|
+
# Always create local CalendarEvent first
|
|
135
|
+
calendar_event = CalendarEvent.create!(
|
|
136
|
+
user: user,
|
|
137
|
+
creative: creative,
|
|
138
|
+
summary: summary,
|
|
139
|
+
start_time: start_time,
|
|
140
|
+
end_time: end_time
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Sync to Google Calendar if connected
|
|
144
|
+
if google_login?
|
|
145
|
+
sync_to_google(calendar_event, timezone, data[:time].nil?)
|
|
146
|
+
else
|
|
147
|
+
I18n.t("collavre.comments.calendar_command.event_created_local")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sync_to_google(calendar_event, timezone, all_day)
|
|
57
152
|
calendar_id = comment.user&.calendar_id.presence || "primary"
|
|
58
153
|
|
|
59
|
-
|
|
154
|
+
google_event = GoogleCalendarService.new(user: user).create_google_event(
|
|
60
155
|
calendar_id: calendar_id,
|
|
61
|
-
start_time: start_time,
|
|
62
|
-
end_time: end_time,
|
|
63
|
-
summary: summary,
|
|
156
|
+
start_time: calendar_event.start_time,
|
|
157
|
+
end_time: calendar_event.end_time,
|
|
158
|
+
summary: calendar_event.summary,
|
|
64
159
|
description: event_description,
|
|
65
160
|
timezone: timezone.tzinfo.name,
|
|
66
|
-
all_day:
|
|
67
|
-
|
|
161
|
+
all_day: all_day
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
calendar_event.update!(
|
|
165
|
+
google_event_id: google_event.id,
|
|
166
|
+
html_link: google_event.html_link
|
|
68
167
|
)
|
|
69
168
|
|
|
70
|
-
I18n.t("collavre.comments.calendar_command.event_created", url:
|
|
169
|
+
I18n.t("collavre.comments.calendar_command.event_created", url: google_event.html_link)
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
Rails.logger.error("Google Calendar sync failed: #{e.message}")
|
|
172
|
+
I18n.t("collavre.comments.calendar_command.event_created_sync_failed")
|
|
71
173
|
end
|
|
72
174
|
|
|
73
175
|
def calculate_times(timezone, date_str, time_str)
|
|
@@ -82,10 +184,7 @@ module Collavre
|
|
|
82
184
|
end
|
|
83
185
|
|
|
84
186
|
def build_summary(memo)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
base_summary = creative.effective_description(false, false)
|
|
88
|
-
base_summary
|
|
187
|
+
memo.presence || creative.effective_description(false, false)
|
|
89
188
|
end
|
|
90
189
|
|
|
91
190
|
def event_description
|