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
|
@@ -2,6 +2,8 @@ module Collavre
|
|
|
2
2
|
require "google/apis/calendar_v3"
|
|
3
3
|
require "googleauth"
|
|
4
4
|
|
|
5
|
+
class GoogleCalendarError < StandardError; end
|
|
6
|
+
|
|
5
7
|
class GoogleCalendarService
|
|
6
8
|
def initialize(user:)
|
|
7
9
|
@user = user
|
|
@@ -13,7 +15,7 @@ module Collavre
|
|
|
13
15
|
# Creates a Google Calendar event.
|
|
14
16
|
# Optional params supported: location, recurrence (array), attendees (array of emails or attendee hashes),
|
|
15
17
|
# reminders (hash: { use_default: true/false, overrides: [{ method: 'email'|'popup', minutes: Integer }, ...] })
|
|
16
|
-
def
|
|
18
|
+
def create_google_event(
|
|
17
19
|
calendar_id: "primary",
|
|
18
20
|
start_time:,
|
|
19
21
|
end_time:,
|
|
@@ -24,8 +26,7 @@ module Collavre
|
|
|
24
26
|
recurrence: nil,
|
|
25
27
|
attendees: nil,
|
|
26
28
|
reminders: nil,
|
|
27
|
-
all_day: false
|
|
28
|
-
creative: nil
|
|
29
|
+
all_day: false
|
|
29
30
|
)
|
|
30
31
|
timezone ||= @user.timezone || Time.zone.tzinfo.name
|
|
31
32
|
event_args = { summary: summary, description: description }
|
|
@@ -74,17 +75,7 @@ module Collavre
|
|
|
74
75
|
@user.calendar_id ||= create_app_calendar
|
|
75
76
|
calendar_id = @user.calendar_id
|
|
76
77
|
end
|
|
77
|
-
|
|
78
|
-
CalendarEvent.create!(
|
|
79
|
-
user: @user,
|
|
80
|
-
creative: creative,
|
|
81
|
-
google_event_id: result.id,
|
|
82
|
-
summary: result.summary,
|
|
83
|
-
start_time: result.start.date_time || result.start.date,
|
|
84
|
-
end_time: result.end.date_time || result.end.date,
|
|
85
|
-
html_link: result.html_link
|
|
86
|
-
)
|
|
87
|
-
result
|
|
78
|
+
@service.insert_event(calendar_id, event)
|
|
88
79
|
rescue Google::Apis::ClientError => e
|
|
89
80
|
# Surface helpful error info to aid debugging 400 errors
|
|
90
81
|
Rails.logger.error("Google Calendar insert_event 4xx: #{e.class} #{e.status_code} - #{e.message} body=#{e.body}")
|
|
@@ -109,14 +100,46 @@ module Collavre
|
|
|
109
100
|
private
|
|
110
101
|
|
|
111
102
|
def user_credentials
|
|
103
|
+
token = refresh_token
|
|
104
|
+
raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.not_connected") if token.blank?
|
|
105
|
+
|
|
112
106
|
Google::Auth::UserRefreshCredentials.new(
|
|
113
107
|
client_id: ENV["GOOGLE_CLIENT_ID"] || Rails.application.credentials.dig(:google, :client_id),
|
|
114
108
|
client_secret: ENV["GOOGLE_CLIENT_SECRET"] || Rails.application.credentials.dig(:google, :client_secret),
|
|
115
109
|
scope: [ Google::Apis::CalendarV3::AUTH_CALENDAR_APP_CREATED ],
|
|
116
|
-
refresh_token:
|
|
110
|
+
refresh_token: token
|
|
117
111
|
).tap(&:fetch_access_token!)
|
|
118
112
|
end
|
|
119
113
|
|
|
114
|
+
def refresh_token
|
|
115
|
+
raw_value = raw_google_refresh_token
|
|
116
|
+
return nil if raw_value.blank?
|
|
117
|
+
|
|
118
|
+
# If already encrypted (JSON format with "p":), decrypt normally
|
|
119
|
+
if encrypted_value?(raw_value)
|
|
120
|
+
@user.google_refresh_token
|
|
121
|
+
else
|
|
122
|
+
# Legacy unencrypted token - re-encrypt and return
|
|
123
|
+
@user.update!(google_refresh_token: raw_value)
|
|
124
|
+
raw_value
|
|
125
|
+
end
|
|
126
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveRecord::Encryption::Errors::Decryption
|
|
127
|
+
# Decryption failed - token may be corrupted or encrypted with different key
|
|
128
|
+
Rails.logger.error("Failed to decrypt google_refresh_token for user #{@user.id}")
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def raw_google_refresh_token
|
|
133
|
+
result = ActiveRecord::Base.connection.select_value(
|
|
134
|
+
"SELECT google_refresh_token FROM users WHERE id = #{@user.id}"
|
|
135
|
+
)
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def encrypted_value?(value)
|
|
140
|
+
value.is_a?(String) && value.start_with?("{") && value.include?('"p":')
|
|
141
|
+
end
|
|
142
|
+
|
|
120
143
|
def create_app_calendar
|
|
121
144
|
calendar = @service.insert_calendar(Google::Apis::CalendarV3::Calendar.new(summary: @service.client_options.application_name))
|
|
122
145
|
# save calendar id to user profile
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
class MarkdownImporter
|
|
3
|
+
# Matches horizontal rules: ---, ***, ___ (with optional spaces)
|
|
4
|
+
HORIZONTAL_RULE_REGEX = /^\s*([-*_])\s*\1\s*\1[\s\1]*$/
|
|
5
|
+
|
|
3
6
|
def self.import(content, parent:, user:, create_root: false)
|
|
4
7
|
lines = content.to_s.lines
|
|
5
8
|
image_refs = {}
|
|
@@ -26,21 +29,51 @@ module Collavre
|
|
|
26
29
|
end
|
|
27
30
|
end
|
|
28
31
|
stack = [ [ 0, root ] ]
|
|
29
|
-
current_fence = nil
|
|
30
32
|
while i < lines.size
|
|
31
33
|
line = lines[i]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
|
|
35
|
+
# Skip horizontal rules (---, ***, ___)
|
|
36
|
+
if line =~ HORIZONTAL_RULE_REGEX
|
|
37
|
+
i += 1
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Handle fenced code blocks (``` or ~~~)
|
|
42
|
+
if (fence_match = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/))
|
|
43
|
+
fence_indent = fence_match[1]
|
|
44
|
+
fence_marker = fence_match[2]
|
|
34
45
|
fence_char = fence_marker[0]
|
|
35
46
|
fence_length = fence_marker.length
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
fence_info = fence_match[3].strip # language info (e.g., "ruby", "javascript")
|
|
48
|
+
|
|
49
|
+
# Collect all lines until closing fence
|
|
50
|
+
code_lines = []
|
|
51
|
+
i += 1
|
|
52
|
+
while i < lines.size
|
|
53
|
+
close_line = lines[i]
|
|
54
|
+
# Check for closing fence (same char, at least same length)
|
|
55
|
+
if (close_match = close_line.match(/^\s*(`{3,}|~{3+})\s*$/))
|
|
56
|
+
close_marker = close_match[1]
|
|
57
|
+
if close_marker[0] == fence_char && close_marker.length >= fence_length
|
|
58
|
+
i += 1
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
code_lines << close_line
|
|
63
|
+
i += 1
|
|
40
64
|
end
|
|
65
|
+
|
|
66
|
+
# Build code block HTML
|
|
67
|
+
code_content = code_lines.join
|
|
68
|
+
code_html = build_code_block_html(code_content, fence_info)
|
|
69
|
+
|
|
70
|
+
new_parent = stack.any? ? stack.last[1] : root
|
|
71
|
+
c = Creative.create(user: user, parent: new_parent, description: code_html)
|
|
72
|
+
created << c
|
|
73
|
+
next
|
|
41
74
|
end
|
|
42
75
|
|
|
43
|
-
if
|
|
76
|
+
if (table_data = parse_markdown_table(lines, i))
|
|
44
77
|
table_html = build_table_html(table_data, image_refs)
|
|
45
78
|
new_parent = stack.any? ? stack.last[1] : root
|
|
46
79
|
c = Creative.create(user: user, parent: new_parent, description: table_html)
|
|
@@ -78,6 +111,12 @@ module Collavre
|
|
|
78
111
|
created
|
|
79
112
|
end
|
|
80
113
|
|
|
114
|
+
def self.build_code_block_html(code_content, language = nil)
|
|
115
|
+
escaped_content = ERB::Util.html_escape(code_content)
|
|
116
|
+
lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
|
|
117
|
+
"<pre><code#{lang_class}>#{escaped_content}</code></pre>"
|
|
118
|
+
end
|
|
119
|
+
|
|
81
120
|
def self.parse_markdown_table(lines, index)
|
|
82
121
|
return nil if index >= lines.length
|
|
83
122
|
header_line = lines[index]
|
|
@@ -74,16 +74,15 @@ module Collavre
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# Check strict loading? No, simple where is fine.
|
|
77
|
-
dynamic_tools = McpTool.where(name: registered_names)
|
|
77
|
+
dynamic_tools = McpTool.where(name: registered_names).includes(:creative)
|
|
78
78
|
dynamic_tool_names = dynamic_tools.pluck(:name).to_set
|
|
79
79
|
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
.to_set
|
|
80
|
+
# Build set of tool names the user has permission to run
|
|
81
|
+
# User needs write permission on the creative to run its tools
|
|
82
|
+
accessible_tool_names = if user
|
|
83
|
+
dynamic_tools.select do |mcp_tool|
|
|
84
|
+
mcp_tool.creative&.has_permission?(user, :write)
|
|
85
|
+
end.map(&:name).to_set
|
|
87
86
|
else
|
|
88
87
|
Set.new
|
|
89
88
|
end
|
|
@@ -97,8 +96,8 @@ module Collavre
|
|
|
97
96
|
nil
|
|
98
97
|
end
|
|
99
98
|
if dynamic_tool_names.include?(name)
|
|
100
|
-
# It is a dynamic tool; user must
|
|
101
|
-
|
|
99
|
+
# It is a dynamic tool; user must have write permission on its creative.
|
|
100
|
+
accessible_tool_names.include?(name)
|
|
102
101
|
else
|
|
103
102
|
# It is a system tool (not in McpTool database); allow it.
|
|
104
103
|
true
|
|
@@ -112,6 +111,20 @@ module Collavre
|
|
|
112
111
|
end
|
|
113
112
|
end
|
|
114
113
|
|
|
114
|
+
# Fetch and filter available tools for the given user.
|
|
115
|
+
# Returns an array of tool hashes with :name, :description, :params keys.
|
|
116
|
+
def self.available_tools(user)
|
|
117
|
+
return [] unless defined?(RailsMcpEngine)
|
|
118
|
+
|
|
119
|
+
RailsMcpEngine::Engine.build_tools!
|
|
120
|
+
result = ::Tools::MetaToolService.new.call(action: "list", tool_name: nil, query: nil, arguments: nil)
|
|
121
|
+
tool_list = Array(result[:tools])
|
|
122
|
+
filter_tools(tool_list, user)
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
Rails.logger.error("Failed to load available tools: #{e.message}")
|
|
125
|
+
[]
|
|
126
|
+
end
|
|
127
|
+
|
|
115
128
|
def self.delete_tool(tool_name)
|
|
116
129
|
result = ::Tools::MetaToolWriteService.new.delete_tool(tool_name)
|
|
117
130
|
|
|
@@ -6,6 +6,41 @@ module Collavre
|
|
|
6
6
|
liquid_context = Collavre::SystemEvents::ContextBuilder.new(context).build
|
|
7
7
|
liquid_context["event_name"] = event_name
|
|
8
8
|
|
|
9
|
+
# Priority 1: Mention-based routing (exclusive)
|
|
10
|
+
# If any user is mentioned, mention routing takes over exclusively.
|
|
11
|
+
# - Mentioned AI agent → route only to that agent
|
|
12
|
+
# - Mentioned non-AI user → route to nobody (no AI agents)
|
|
13
|
+
mentioned_result = find_mentioned_routing(liquid_context, context)
|
|
14
|
+
return mentioned_result unless mentioned_result.nil?
|
|
15
|
+
|
|
16
|
+
# Priority 2: Liquid expression routing (fallback, only when no mention)
|
|
17
|
+
route_by_liquid_expression(liquid_context, context)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Returns Array of agents to route to, or nil if no mention was found.
|
|
23
|
+
# When a mention IS found, this is exclusive:
|
|
24
|
+
# - AI agent mentioned → [agent] (if permitted)
|
|
25
|
+
# - Non-AI user mentioned → [] (no AI agents should receive it)
|
|
26
|
+
def find_mentioned_routing(liquid_context, context)
|
|
27
|
+
mentioned_user_data = liquid_context.dig("chat", "mentioned_user")
|
|
28
|
+
return nil unless mentioned_user_data && mentioned_user_data["id"]
|
|
29
|
+
|
|
30
|
+
mentioned_user = User.find_by(id: mentioned_user_data["id"])
|
|
31
|
+
return nil unless mentioned_user
|
|
32
|
+
|
|
33
|
+
# Mention found — this is exclusive routing.
|
|
34
|
+
# If the mentioned user is not an AI agent, no AI agents should receive the message.
|
|
35
|
+
return [] unless mentioned_user.ai_user?
|
|
36
|
+
|
|
37
|
+
# Permission check for mentioned AI agent
|
|
38
|
+
return [] unless has_creative_permission?(mentioned_user, context)
|
|
39
|
+
|
|
40
|
+
[ mentioned_user ]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def route_by_liquid_expression(liquid_context, context)
|
|
9
44
|
# Find all AI agents
|
|
10
45
|
agents = User.where.not(llm_vendor: nil)
|
|
11
46
|
|
|
@@ -15,32 +50,7 @@ module Collavre
|
|
|
15
50
|
next if agent.routing_expression.blank?
|
|
16
51
|
|
|
17
52
|
# Permission Check
|
|
18
|
-
|
|
19
|
-
unless agent.searchable?
|
|
20
|
-
creative_id = context.dig("creative", "id") || context.dig(:creative, :id)
|
|
21
|
-
if creative_id
|
|
22
|
-
creative = Creative.find_by(id: creative_id)
|
|
23
|
-
if creative
|
|
24
|
-
# Check for feedback permission (which implies read access)
|
|
25
|
-
# has_permission? checks for the specific permission or higher
|
|
26
|
-
unless creative.has_permission?(agent, :feedback)
|
|
27
|
-
# Rails.logger.info "Agent #{agent.id} skipped: No feedback permission on Creative #{creative.id}"
|
|
28
|
-
next
|
|
29
|
-
end
|
|
30
|
-
else
|
|
31
|
-
# If creative ID is present but not found, skip for safety
|
|
32
|
-
next
|
|
33
|
-
end
|
|
34
|
-
else
|
|
35
|
-
# If no creative context, we might skip or allow depending on policy.
|
|
36
|
-
# Assuming 'chat.creative' implies creative context is required for this check.
|
|
37
|
-
# If it's a global event without creative, maybe searchable check isn't needed?
|
|
38
|
-
# But the user said "must have feedback permission on the chat.creative".
|
|
39
|
-
# If there is no creative, we can't check permission, so we should probably skip to be safe
|
|
40
|
-
# unless it's a purely global event. But for now, let's skip.
|
|
41
|
-
next
|
|
42
|
-
end
|
|
43
|
-
end
|
|
53
|
+
next unless has_creative_permission?(agent, context)
|
|
44
54
|
|
|
45
55
|
begin
|
|
46
56
|
# Add 'agent' to context so they can refer to themselves
|
|
@@ -67,6 +77,20 @@ module Collavre
|
|
|
67
77
|
|
|
68
78
|
matched_agents
|
|
69
79
|
end
|
|
80
|
+
|
|
81
|
+
def has_creative_permission?(agent, context)
|
|
82
|
+
# Searchable agents can receive any routed message
|
|
83
|
+
return true if agent.searchable?
|
|
84
|
+
|
|
85
|
+
# Non-searchable agents must have feedback permission on the creative
|
|
86
|
+
creative_id = context.dig("creative", "id") || context.dig(:creative, :id)
|
|
87
|
+
return false unless creative_id
|
|
88
|
+
|
|
89
|
+
creative = Creative.find_by(id: creative_id)
|
|
90
|
+
return false unless creative
|
|
91
|
+
|
|
92
|
+
creative.has_permission?(agent, :feedback)
|
|
93
|
+
end
|
|
70
94
|
end
|
|
71
95
|
end
|
|
72
96
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeCreateService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "creative_create_service"
|
|
10
|
+
tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field accepts HTML format for rich text content."
|
|
11
|
+
|
|
12
|
+
tool_param :description, description: "The content/title of the Creative. Accepts HTML format (e.g., '<p>Task title</p>'). Plain text will be wrapped in <p> tags automatically.", required: true
|
|
13
|
+
tool_param :parent_id, description: "ID of the parent Creative. Required to create under a specific parent. If omitted, creates a root Creative.", required: false
|
|
14
|
+
tool_param :progress, description: "Initial progress value (0.0 to 1.0). Default is 0.", required: false
|
|
15
|
+
tool_param :after_id, description: "ID of a sibling Creative to insert after. Used for ordering.", required: false
|
|
16
|
+
tool_param :before_id, description: "ID of a sibling Creative to insert before. Used for ordering.", required: false
|
|
17
|
+
|
|
18
|
+
sig { params(description: String, parent_id: T.nilable(Integer), progress: T.nilable(Numeric), after_id: T.nilable(Integer), before_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
|
|
19
|
+
def call(description:, parent_id: nil, progress: nil, after_id: nil, before_id: nil)
|
|
20
|
+
raise "Current.user is required" unless Current.user
|
|
21
|
+
|
|
22
|
+
# Validate parent permission if specified
|
|
23
|
+
parent = nil
|
|
24
|
+
if parent_id.present?
|
|
25
|
+
parent = Creative.find_by(id: parent_id)
|
|
26
|
+
unless parent
|
|
27
|
+
return { error: "Parent Creative not found", id: parent_id }
|
|
28
|
+
end
|
|
29
|
+
unless parent.has_permission?(Current.user, :write)
|
|
30
|
+
return { error: "No write permission on parent Creative", id: parent_id }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Normalize description - wrap plain text in <p> tags if needed
|
|
35
|
+
normalized_description = normalize_description(description)
|
|
36
|
+
|
|
37
|
+
# Build the creative
|
|
38
|
+
creative = Creative.new(
|
|
39
|
+
description: normalized_description,
|
|
40
|
+
parent: parent,
|
|
41
|
+
progress: progress || 0
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Set user based on parent or current user
|
|
45
|
+
creative.user = parent ? parent.user : Current.user
|
|
46
|
+
|
|
47
|
+
unless creative.save
|
|
48
|
+
return { error: "Failed to create Creative", details: creative.errors.full_messages }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Handle ordering
|
|
52
|
+
handle_ordering(creative, before_id: before_id, after_id: after_id)
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
success: true,
|
|
56
|
+
id: creative.id,
|
|
57
|
+
description: creative.description,
|
|
58
|
+
parent_id: creative.parent_id,
|
|
59
|
+
progress: creative.progress
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalize_description(desc)
|
|
66
|
+
return desc if desc.blank?
|
|
67
|
+
# If it already looks like HTML, return as-is
|
|
68
|
+
return desc if desc.strip.start_with?("<")
|
|
69
|
+
# Otherwise wrap in <p> tags
|
|
70
|
+
"<p>#{ERB::Util.html_escape(desc)}</p>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle_ordering(creative, before_id:, after_id:)
|
|
74
|
+
return unless before_id.present? || after_id.present?
|
|
75
|
+
|
|
76
|
+
siblings = creative.parent ? creative.parent.children.order(:sequence).to_a : Creative.roots.order(:sequence).to_a
|
|
77
|
+
siblings.reject! { |s| s.id == creative.id }
|
|
78
|
+
|
|
79
|
+
if before_id.present?
|
|
80
|
+
before_creative = Creative.find_by(id: before_id)
|
|
81
|
+
if before_creative && before_creative.parent_id == creative.parent_id
|
|
82
|
+
index = siblings.index { |s| s.id == before_creative.id } || 0
|
|
83
|
+
siblings.insert(index, creative)
|
|
84
|
+
end
|
|
85
|
+
elsif after_id.present?
|
|
86
|
+
after_creative = Creative.find_by(id: after_id)
|
|
87
|
+
if after_creative && after_creative.parent_id == creative.parent_id
|
|
88
|
+
index = siblings.index { |s| s.id == after_creative.id } || -1
|
|
89
|
+
siblings.insert(index + 1, creative)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
siblings.each_with_index { |c, idx| c.update_column(:sequence, idx) }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
require "sorbet-runtime"
|
|
3
|
+
require "rails_mcp_engine"
|
|
4
|
+
module Tools
|
|
5
|
+
class CreativeUpdateService
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend ToolMeta
|
|
8
|
+
|
|
9
|
+
tool_name "creative_update_service"
|
|
10
|
+
tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Update progress (0.0 to 1.0, where 1.0 = complete)\n- Move a Creative to a different parent\n\nNote: Setting progress to 1.0 on a parent will also complete all descendants."
|
|
11
|
+
|
|
12
|
+
tool_param :id, description: "The ID of the Creative to update.", required: true
|
|
13
|
+
tool_param :description, description: "New content/title for the Creative. Accepts HTML format. If omitted, description remains unchanged.", required: false
|
|
14
|
+
tool_param :progress, description: "New progress value (0.0 to 1.0). Setting to 1.0 marks as complete.", required: false
|
|
15
|
+
tool_param :parent_id, description: "New parent Creative ID to move this Creative under. Use null/0 to make it a root Creative.", required: false
|
|
16
|
+
|
|
17
|
+
sig { params(id: Integer, description: T.nilable(String), progress: T.nilable(Float), parent_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
|
|
18
|
+
def call(id:, description: nil, progress: nil, parent_id: nil)
|
|
19
|
+
raise "Current.user is required" unless Current.user
|
|
20
|
+
|
|
21
|
+
creative = Creative.find_by(id: id)
|
|
22
|
+
unless creative
|
|
23
|
+
return { error: "Creative not found", id: id }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
unless creative.has_permission?(Current.user, :write)
|
|
27
|
+
return { error: "No write permission on this Creative", id: id }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the effective origin for updating content
|
|
31
|
+
base = creative.effective_origin
|
|
32
|
+
previous_progress = base.progress
|
|
33
|
+
|
|
34
|
+
updates = {}
|
|
35
|
+
parent_updates = {}
|
|
36
|
+
|
|
37
|
+
# Handle description update
|
|
38
|
+
if description.present?
|
|
39
|
+
updates[:description] = normalize_description(description)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Handle progress update
|
|
43
|
+
if progress.present?
|
|
44
|
+
updates[:progress] = progress.to_f.clamp(0.0, 1.0)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Handle parent change (on the creative itself, not base)
|
|
48
|
+
if parent_id.present? || parent_id == 0
|
|
49
|
+
new_parent_id = parent_id == 0 ? nil : parent_id
|
|
50
|
+
|
|
51
|
+
if new_parent_id.present?
|
|
52
|
+
new_parent = Creative.find_by(id: new_parent_id)
|
|
53
|
+
unless new_parent
|
|
54
|
+
return { error: "New parent Creative not found", parent_id: new_parent_id }
|
|
55
|
+
end
|
|
56
|
+
unless new_parent.has_permission?(Current.user, :write)
|
|
57
|
+
return { error: "No write permission on new parent Creative", parent_id: new_parent_id }
|
|
58
|
+
end
|
|
59
|
+
# Prevent circular reference
|
|
60
|
+
if new_parent.self_and_ancestors.include?(creative)
|
|
61
|
+
return { error: "Cannot move Creative under its own descendant", parent_id: new_parent_id }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
parent_updates[:parent_id] = new_parent_id
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Apply updates
|
|
69
|
+
success = true
|
|
70
|
+
|
|
71
|
+
# Update parent on the creative (for linked creatives, parent is on the link, not origin)
|
|
72
|
+
if parent_updates.present?
|
|
73
|
+
success &&= creative.update(parent_updates)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Update content on the base/origin
|
|
77
|
+
if updates.present?
|
|
78
|
+
success &&= base.update(updates)
|
|
79
|
+
|
|
80
|
+
# If progress set to 1.0 and has children, complete all descendants
|
|
81
|
+
requested_progress = updates[:progress]
|
|
82
|
+
if success && requested_progress.present? && requested_progress >= 1 && previous_progress.to_f < 1
|
|
83
|
+
if base.children.exists?
|
|
84
|
+
base.self_and_descendants.where(origin_id: nil)
|
|
85
|
+
.update_all(progress: 1.0, updated_at: Time.current)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if success
|
|
91
|
+
creative.reload
|
|
92
|
+
base.reload
|
|
93
|
+
{
|
|
94
|
+
success: true,
|
|
95
|
+
id: creative.id,
|
|
96
|
+
description: base.description,
|
|
97
|
+
parent_id: creative.parent_id,
|
|
98
|
+
progress: base.progress
|
|
99
|
+
}
|
|
100
|
+
else
|
|
101
|
+
{ error: "Failed to update Creative", details: creative.errors.full_messages + base.errors.full_messages }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def normalize_description(desc)
|
|
108
|
+
return desc if desc.blank?
|
|
109
|
+
# If it already looks like HTML, return as-is
|
|
110
|
+
return desc if desc.strip.start_with?("<")
|
|
111
|
+
# Otherwise wrap in <p> tags
|
|
112
|
+
"<p>#{ERB::Util.html_escape(desc)}</p>"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
<%= render "collavre/comments/read_receipts", read_by_users: users_read, present_user_ids: present_user_ids unless comment.private? %>
|
|
35
35
|
</span>
|
|
36
36
|
<% if comment.private? %>
|
|
37
|
-
<span class="private-label"
|
|
37
|
+
<span class="comment-status-label private-label">🔒 <%= t('collavre.comments.private') %></span>
|
|
38
38
|
<% end %>
|
|
39
39
|
<% if comment.action_executed_at.present? %>
|
|
40
|
-
<span class="comment-status-label approved-label"
|
|
40
|
+
<span class="comment-status-label approved-label">✅ <%= t('collavre.comments.approved_label') %></span>
|
|
41
41
|
<% end %>
|
|
42
42
|
<% can_convert_comment = comment.user == Current.user || comment.creative.has_permission?(Current.user, :admin) %>
|
|
43
43
|
</div>
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
|
|
2
|
+
<% fullscreen = local_assigns.fetch(:fullscreen, false) %>
|
|
3
|
+
<% creative = local_assigns[:creative] %>
|
|
2
4
|
<div id="comments-popup" data-controller="comments--popup comments--list comments--form comments--presence comments--mention-menu comments--topics" class="popup-box"
|
|
5
|
+
data-fullscreen="<%= fullscreen %>"
|
|
6
|
+
data-fullscreen-url-template="<%= collavre.fullscreen_creative_comments_path(creative_id: "__CREATIVE_ID__") %>"
|
|
7
|
+
<% if fullscreen && creative.present? %>
|
|
8
|
+
data-creative-id="<%= creative.id %>"
|
|
9
|
+
data-creative-snippet="<%= creative.creative_snippet %>"
|
|
10
|
+
data-can-comment="<%= creative.has_permission?(Current.user, :feedback) %>"
|
|
11
|
+
<% end %>
|
|
3
12
|
data-loading-text="<%= t('app.loading') %>"
|
|
4
13
|
data-delete-confirm-text="<%= t("collavre.comments.delete_confirm") %>"
|
|
5
14
|
data-update-comment-text="<%= t('collavre.comments.update_comment') %>"
|
|
@@ -15,23 +24,48 @@
|
|
|
15
24
|
data-voice-start-text="<%= t('collavre.comments.voice_button') %>"
|
|
16
25
|
data-voice-stop-text="<%= t('collavre.comments.voice_stop') %>"
|
|
17
26
|
data-move-no-selection-text="<%= t('collavre.comments.move_no_selection') %>"
|
|
18
|
-
data-move-error-text="<%= t('collavre.comments.move_error') %>"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
data-move-error-text="<%= t('collavre.comments.move_error') %>"
|
|
28
|
+
data-hint-drag-topic-text="<%= t('collavre.comments.hint_drag_topic') %>"
|
|
29
|
+
data-hint-move-button-text="<%= t('collavre.comments.hint_move_button') %>"
|
|
30
|
+
data-add-participant-text="<%= t('collavre.comments.add_participant') %>">
|
|
31
|
+
<% unless fullscreen %>
|
|
32
|
+
<div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
|
|
33
|
+
<div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
|
|
34
|
+
<% end %>
|
|
35
|
+
<div class="comments-popup-header">
|
|
36
|
+
<h3 id="comments-popup-title" data-comments--popup-target="title"><%= fullscreen && creative.present? ? creative.creative_snippet : t('collavre.comments.comments') %></h3>
|
|
37
|
+
<span data-integration-badges class="integration-badges"></span>
|
|
38
|
+
<div class="comments-popup-actions">
|
|
39
|
+
<% if fullscreen && creative.present? %>
|
|
40
|
+
<%= link_to collavre.creative_path(creative, open_comments: true),
|
|
41
|
+
class: "comments-popup-action comments-popup-fullscreen",
|
|
42
|
+
aria: { label: t("collavre.comments.exit_fullscreen", default: "Exit full screen") } do %>
|
|
43
|
+
<%= svg_tag "exit-fullscreen.svg", class: "comments-popup-action-icon" %>
|
|
44
|
+
<% end %>
|
|
45
|
+
<% else %>
|
|
46
|
+
<%= link_to "#",
|
|
47
|
+
class: "comments-popup-action comments-popup-fullscreen",
|
|
48
|
+
data: { "comments--popup-target": "fullscreenLink" },
|
|
49
|
+
aria: { label: t("collavre.comments.fullscreen", default: "Full screen") } do %>
|
|
50
|
+
<%= svg_tag "fullscreen.svg", class: "comments-popup-action-icon" %>
|
|
51
|
+
<% end %>
|
|
52
|
+
<button id="close-comments-btn" data-comments--popup-target="closeButton" class="comments-popup-action comments-popup-close" type="button">×</button>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
23
56
|
<div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
|
|
24
57
|
<div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
|
|
25
58
|
data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
|
|
26
59
|
data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
|
|
27
60
|
<%= render UserMentionMenuComponent.new(menu_id: 'mention-menu') %>
|
|
61
|
+
<%= render CommandMenuComponent.new(menu_id: 'command-menu') %>
|
|
28
62
|
<div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
|
|
29
63
|
<div id="typing-indicator" data-comments--presence-target="typingIndicator"></div>
|
|
30
64
|
<form id="new-comment-form" data-comments--popup-target="form" data-comments--form-target="form" style="display:none;">
|
|
31
65
|
<textarea class="shared-input-surface" name="comment[content]" data-comments--form-target="textarea" data-comments--presence-target="textarea" data-comments--mention-menu-target="textarea" rows="2" enterkeyhint="send"></textarea>
|
|
32
66
|
<div class="comment-bottom">
|
|
33
67
|
<input type="file" id="comment-images" name="comment[images][]" accept="image/*" multiple data-comments--form-target="imageInput" style="display:none;" />
|
|
34
|
-
<label style="height: 22px"><input type="checkbox" id="comment-private" data-comments--form-target="privateCheckbox" data-comments--presence-target="privateCheckbox" name="comment[private]" /> <%= t('collavre.comments.private') %></label>
|
|
68
|
+
<label style="height: 22px"><input type="hidden" name="comment[private]" value="0" /><input type="checkbox" id="comment-private" data-comments--form-target="privateCheckbox" data-comments--presence-target="privateCheckbox" name="comment[private]" value="1" /> <%= t('collavre.comments.private') %></label>
|
|
35
69
|
<div class="comment-actions">
|
|
36
70
|
<button class="creative-action-btn" id="attach-image-btn" data-comments--form-target="imageButton" type="button"><%= t('collavre.comments.image_button') %></button>
|
|
37
71
|
<button class="creative-action-btn" id="voice-comments-btn" data-comments--form-target="voiceButton" type="button" data-voice-state="idle"><%= t('collavre.comments.voice_button') %></button>
|
|
@@ -16,8 +16,17 @@
|
|
|
16
16
|
data-placeholder="<%= t('collavre.creatives.inline_editor.placeholder') %>"></div>
|
|
17
17
|
<div id="actions-inline-editor" style="padding: 0px 8px;">
|
|
18
18
|
<div style="margin-top:0.5em;display:flex;align-items:center;gap:0.5em;">
|
|
19
|
-
<input type="
|
|
20
|
-
<
|
|
19
|
+
<input type="hidden" name="creative[progress]" value="0">
|
|
20
|
+
<label for="inline-creative-progress" style="display:flex;align-items:center;gap:0.4em;">
|
|
21
|
+
<input type="checkbox"
|
|
22
|
+
id="inline-creative-progress"
|
|
23
|
+
name="creative[progress]"
|
|
24
|
+
value="1"
|
|
25
|
+
data-complete-label="<%= t('collavre.creatives.index.progress_complete') %>"
|
|
26
|
+
data-incomplete-label="<%= t('collavre.creatives.index.progress_incomplete') %>"
|
|
27
|
+
data-children-alert-message="<%= t('collavre.creatives.index.progress_complete_children_alert') %>">
|
|
28
|
+
<span id="inline-progress-value"><%= t('collavre.creatives.index.progress_incomplete') %></span>
|
|
29
|
+
</label>
|
|
21
30
|
</div>
|
|
22
31
|
<div style="margin-top:0.5em;">
|
|
23
32
|
<button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
|
|
@@ -86,4 +95,3 @@
|
|
|
86
95
|
</form>
|
|
87
96
|
</div>
|
|
88
97
|
|
|
89
|
-
|