collavre 0.20.3 → 0.22.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 +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
class BaseController < ActionController::API
|
|
7
|
+
before_action :authenticate!
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def authenticate!
|
|
12
|
+
token = extract_bearer_token
|
|
13
|
+
if token.blank?
|
|
14
|
+
render json: { error: "Missing authentication token" }, status: :unauthorized
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
access_token = Doorkeeper::AccessToken.by_token(token)
|
|
19
|
+
unless access_token&.accessible?
|
|
20
|
+
render json: { error: "Invalid authentication token" }, status: :unauthorized
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
user = Collavre::User.find_by(id: access_token.resource_owner_id)
|
|
25
|
+
unless user
|
|
26
|
+
render json: { error: "User not found" }, status: :unauthorized
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Collavre::Current.user = user
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def extract_bearer_token
|
|
34
|
+
auth_header = request.headers["Authorization"]
|
|
35
|
+
return nil unless auth_header&.start_with?("Bearer ")
|
|
36
|
+
|
|
37
|
+
auth_header.sub("Bearer ", "")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_user
|
|
41
|
+
Collavre::Current.user
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -9,8 +9,35 @@ module Collavre
|
|
|
9
9
|
# of sync with the session cookie and POSTs fail with 422.
|
|
10
10
|
after_action :set_csrf_token_header
|
|
11
11
|
|
|
12
|
+
before_action :redirect_authenticated_root_to_home
|
|
13
|
+
|
|
12
14
|
private
|
|
13
15
|
|
|
16
|
+
# If the original request was for "/" and the user is signed in,
|
|
17
|
+
# honor SystemSetting.home_page_path_authenticated by redirecting.
|
|
18
|
+
# The middleware preserves "/" as a stable URL for unauthenticated
|
|
19
|
+
# visitors; authenticated users get a real URL change so the address
|
|
20
|
+
# bar reflects state.
|
|
21
|
+
#
|
|
22
|
+
# Loop safety: the env flag is only set when PATH_INFO == "/" hits
|
|
23
|
+
# the middleware. Direct visits to the redirect target never trip it,
|
|
24
|
+
# so comparing request.path to target would be wrong here — when both
|
|
25
|
+
# home_page_path and home_page_path_authenticated resolve to the same
|
|
26
|
+
# path, the middleware has already rewritten request.path to that
|
|
27
|
+
# target while the browser URL is still "/", and we must still
|
|
28
|
+
# redirect so the address bar reflects state.
|
|
29
|
+
def redirect_authenticated_root_to_home
|
|
30
|
+
return unless request.get? || request.head?
|
|
31
|
+
return unless request.env["collavre.root_request"]
|
|
32
|
+
return unless authenticated?
|
|
33
|
+
|
|
34
|
+
target = SystemSetting.home_page_path_authenticated
|
|
35
|
+
return if target.blank?
|
|
36
|
+
return if target == "/"
|
|
37
|
+
|
|
38
|
+
redirect_to target
|
|
39
|
+
end
|
|
40
|
+
|
|
14
41
|
def set_csrf_token_header
|
|
15
42
|
return unless protect_against_forgery?
|
|
16
43
|
|
|
@@ -8,7 +8,7 @@ module Collavre
|
|
|
8
8
|
return head :forbidden
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
blob
|
|
11
|
+
purge_unless_referenced(blob)
|
|
12
12
|
head :no_content
|
|
13
13
|
rescue ActiveRecord::RecordNotFound
|
|
14
14
|
head :not_found
|
|
@@ -19,10 +19,38 @@ module Collavre
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
+
# A blob can be shared across creatives (description HTML copied between
|
|
23
|
+
# them). The editor fires this DELETE for removed nodes after its PATCH, so
|
|
24
|
+
# purging unconditionally could delete a blob another creative still
|
|
25
|
+
# references, 404-ing its description. Only purge a true orphan.
|
|
26
|
+
def purge_unless_referenced(blob)
|
|
27
|
+
signed_id = blob.signed_id
|
|
28
|
+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%"
|
|
29
|
+
|
|
30
|
+
return if Creative.where("description LIKE ?", pattern).exists?
|
|
31
|
+
return if ActiveStorage::Attachment.where(blob_id: blob.id).exists?
|
|
32
|
+
|
|
33
|
+
blob.purge
|
|
34
|
+
end
|
|
35
|
+
|
|
22
36
|
def authorized_to_purge?(blob)
|
|
23
37
|
return false unless Current.user
|
|
24
38
|
|
|
25
|
-
attachment_owned_by_current_user?(blob) ||
|
|
39
|
+
attachment_owned_by_current_user?(blob) ||
|
|
40
|
+
editable_creative_reference?(blob) ||
|
|
41
|
+
orphan_blob?(blob)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Authorize purging a true orphan (attached to nothing, in no description).
|
|
45
|
+
# Covers a node removed before its blob was ever attached (removed-then-save,
|
|
46
|
+
# so reconcile never sees it), which can prove neither ownership nor a
|
|
47
|
+
# writable reference and would otherwise 403, stranding the blob. Safe: any
|
|
48
|
+
# in-use blob still fails this check. Mirrors purge_unless_referenced.
|
|
49
|
+
def orphan_blob?(blob)
|
|
50
|
+
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(blob.signed_id)}%"
|
|
51
|
+
|
|
52
|
+
!ActiveStorage::Attachment.where(blob_id: blob.id).exists? &&
|
|
53
|
+
!Creative.where("description LIKE ?", pattern).exists?
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
def attachment_owned_by_current_user?(blob)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class ChannelsController < ApplicationController
|
|
3
|
+
include Collavre::CreativePermissionGuard
|
|
4
|
+
|
|
5
|
+
before_action :set_channel
|
|
6
|
+
before_action :require_creative_write!
|
|
7
|
+
|
|
8
|
+
def destroy
|
|
9
|
+
@channel.dismiss!
|
|
10
|
+
head :no_content
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def set_channel
|
|
16
|
+
# Look up among not-yet-dismissed channels (active OR detached). The X
|
|
17
|
+
# button must work on detached chips too — those linger so the user can
|
|
18
|
+
# still see the final merge/close badge until they choose to clear it.
|
|
19
|
+
@channel = Channel.not_dismissed.find(params[:id])
|
|
20
|
+
@creative = @channel.topic.creative.effective_origin
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -6,7 +6,7 @@ module Collavre
|
|
|
6
6
|
include Collavre::Comments::BatchOperations
|
|
7
7
|
|
|
8
8
|
before_action :set_creative
|
|
9
|
-
before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :update_action, :download_images, :remove_image ]
|
|
9
|
+
before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :deny, :update_action, :download_images, :remove_image ]
|
|
10
10
|
|
|
11
11
|
def fullscreen
|
|
12
12
|
# Render the creative index page with comments popup auto-opened in fullscreen.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Bearer-only multipart upload endpoint for agents/CLI. Creates an
|
|
4
|
+
# ActiveStorage blob from the uploaded bytes, embeds the matching node into
|
|
5
|
+
# the Creative's description, and lets after_save reconcile attach it to
|
|
6
|
+
# creative.files. No server-local paths, no session/CSRF.
|
|
7
|
+
class AttachmentsController < Collavre::ApplicationController
|
|
8
|
+
include Collavre::PublicAssetsHelper
|
|
9
|
+
|
|
10
|
+
allow_unauthenticated_access
|
|
11
|
+
skip_forgery_protection
|
|
12
|
+
before_action :authenticate_bearer!
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
creative = Collavre::Creative.find_by(id: params[:creative_id])
|
|
16
|
+
return render(json: { error: "Creative not found" }, status: :not_found) unless creative
|
|
17
|
+
|
|
18
|
+
unless creative.has_permission?(Current.user, :write)
|
|
19
|
+
return render(json: { error: "No write permission" }, status: :forbidden)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless creative.attachments_embeddable?
|
|
23
|
+
return render(json: { error: "Cannot attach files to GitHub-synced content" }, status: :unprocessable_entity)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
file = params[:file]
|
|
27
|
+
return render(json: { error: "No file provided" }, status: :unprocessable_entity) unless file.respond_to?(:read)
|
|
28
|
+
|
|
29
|
+
io = file.to_io
|
|
30
|
+
content_type = resolved_content_type(file, io)
|
|
31
|
+
io.rewind
|
|
32
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
33
|
+
io: io,
|
|
34
|
+
filename: file.original_filename,
|
|
35
|
+
content_type: content_type
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
creative.embed_attachment_blob!(blob)
|
|
39
|
+
|
|
40
|
+
render json: {
|
|
41
|
+
signed_id: blob.signed_id,
|
|
42
|
+
filename: blob.filename.to_s,
|
|
43
|
+
content_type: blob.content_type,
|
|
44
|
+
byte_size: blob.byte_size,
|
|
45
|
+
url: public_asset_url(blob)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# The bundled `collavre` CLI sends application/octet-stream for every part,
|
|
52
|
+
# so treat octet-stream (and blank) as "unknown" and sniff via Marcel —
|
|
53
|
+
# otherwise png/mp4 render as generic download links, not inline media.
|
|
54
|
+
def resolved_content_type(file, io)
|
|
55
|
+
declared = file.content_type.presence
|
|
56
|
+
return declared if declared && declared != "application/octet-stream"
|
|
57
|
+
|
|
58
|
+
Marcel::MimeType.for(io, name: file.original_filename).presence ||
|
|
59
|
+
"application/octet-stream"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolve a Doorkeeper bearer token to Current.user. The MCP middleware
|
|
63
|
+
# only does this for /mcp paths, so this endpoint authenticates itself.
|
|
64
|
+
def authenticate_bearer!
|
|
65
|
+
token_string = Doorkeeper::OAuth::Token.from_request(
|
|
66
|
+
request, *Doorkeeper.configuration.access_token_methods
|
|
67
|
+
)
|
|
68
|
+
token = Doorkeeper::AccessToken.by_token(token_string) if token_string.present?
|
|
69
|
+
user = Collavre::User.find_by(id: token.resource_owner_id) if token&.accessible?
|
|
70
|
+
|
|
71
|
+
if user
|
|
72
|
+
Current.user = user
|
|
73
|
+
else
|
|
74
|
+
render json: { error: "Unauthorized" }, status: :unauthorized
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -118,6 +118,7 @@ module Collavre
|
|
|
118
118
|
|
|
119
119
|
trigger_loop_data = @creative.data&.dig("trigger", "loop")
|
|
120
120
|
parent_trigger_enabled = @creative.parent&.drop_trigger_enabled? || false
|
|
121
|
+
can_edit = @creative.has_permission?(Current.user, :write)
|
|
121
122
|
|
|
122
123
|
etag = [
|
|
123
124
|
"creative",
|
|
@@ -132,7 +133,9 @@ module Collavre
|
|
|
132
133
|
"trigger_v3",
|
|
133
134
|
trigger_loop_data&.dig("state"),
|
|
134
135
|
trigger_loop_data&.dig("current_iteration"),
|
|
135
|
-
parent_trigger_enabled
|
|
136
|
+
parent_trigger_enabled,
|
|
137
|
+
"can_edit",
|
|
138
|
+
can_edit
|
|
136
139
|
].join(":")
|
|
137
140
|
|
|
138
141
|
if stale?(etag: etag, last_modified: last_modified, public: false)
|
|
@@ -142,6 +145,13 @@ module Collavre
|
|
|
142
145
|
else
|
|
143
146
|
@creative.ancestors.count + 1
|
|
144
147
|
end
|
|
148
|
+
sanitized_data = @creative.effective_origin(Set.new).data
|
|
149
|
+
# markdown_source is exposed via the top-level `markdown_source:` field for writers;
|
|
150
|
+
# exclude it from the editable `data` payload so the metadata YAML editor can't
|
|
151
|
+
# round-trip a stale copy back into data["markdown_source"] on update_metadata.
|
|
152
|
+
if sanitized_data.is_a?(Hash) && sanitized_data.key?("markdown_source")
|
|
153
|
+
sanitized_data = sanitized_data.except("markdown_source")
|
|
154
|
+
end
|
|
145
155
|
render json: {
|
|
146
156
|
id: @creative.id,
|
|
147
157
|
description: @creative.effective_description,
|
|
@@ -153,10 +163,12 @@ module Collavre
|
|
|
153
163
|
depth: depth,
|
|
154
164
|
prompt: @creative.prompt_for(Current.user),
|
|
155
165
|
has_children: children_count > 0,
|
|
156
|
-
data:
|
|
166
|
+
data: sanitized_data,
|
|
167
|
+
content_type: effective.data&.dig("content_type"),
|
|
168
|
+
markdown_source: can_edit ? effective.data&.dig("markdown_source") : nil,
|
|
157
169
|
trigger_loop: trigger_loop_data,
|
|
158
170
|
is_trigger_task: parent_trigger_enabled,
|
|
159
|
-
can_edit:
|
|
171
|
+
can_edit: can_edit
|
|
160
172
|
}
|
|
161
173
|
end
|
|
162
174
|
end
|
|
@@ -190,7 +202,16 @@ module Collavre
|
|
|
190
202
|
@creative = result.creative
|
|
191
203
|
|
|
192
204
|
if result.success?
|
|
193
|
-
|
|
205
|
+
# Expose the post-rewrite markdown source so the client can sync its
|
|
206
|
+
# textarea after the server replaces inline data: URIs with blob paths,
|
|
207
|
+
# matching the update endpoint contract. Without this, a freshly created
|
|
208
|
+
# markdown creative with a pasted data: URI would re-import the blob on
|
|
209
|
+
# the next keystroke save.
|
|
210
|
+
render json: {
|
|
211
|
+
id: @creative.id,
|
|
212
|
+
content_type: @creative.data&.dig("content_type"),
|
|
213
|
+
markdown_source: @creative.data&.dig("markdown_source")
|
|
214
|
+
}
|
|
194
215
|
else
|
|
195
216
|
render json: { errors: result.errors }, status: :unprocessable_entity
|
|
196
217
|
end
|
|
@@ -259,8 +280,17 @@ module Collavre
|
|
|
259
280
|
id: base.id,
|
|
260
281
|
progress: base.progress,
|
|
261
282
|
progress_html: view_context.render_creative_progress(base),
|
|
262
|
-
has_children: base.children.exists
|
|
283
|
+
has_children: base.children.exists?,
|
|
284
|
+
content_type: base.data&.dig("content_type")
|
|
263
285
|
}
|
|
286
|
+
# Expose the post-rewrite markdown source so the client can sync its
|
|
287
|
+
# textarea after the server replaces inline data: URIs with blob paths.
|
|
288
|
+
# Gated on write permission so a read-only share recipient moving a
|
|
289
|
+
# linked creative (parent_id-only PATCH bypasses the origin_changes
|
|
290
|
+
# write check) cannot read the origin's raw Markdown source.
|
|
291
|
+
if @creative.has_permission?(Current.user, :write)
|
|
292
|
+
response_data[:markdown_source] = base.data&.dig("markdown_source")
|
|
293
|
+
end
|
|
264
294
|
# Build ancestor chain for progress updates (closure_tree: 1 SELECT via hierarchy table)
|
|
265
295
|
ancestor_records = base.ancestors.order(:id)
|
|
266
296
|
if ancestor_records.any?
|
|
@@ -359,6 +389,20 @@ module Collavre
|
|
|
359
389
|
render json: { error: "Invalid JSON: #{e.message}" }, status: :unprocessable_entity
|
|
360
390
|
return
|
|
361
391
|
end
|
|
392
|
+
unless new_data.is_a?(Hash)
|
|
393
|
+
render json: { error: t("collavre.creatives.errors.metadata_must_be_object") }, status: :unprocessable_entity
|
|
394
|
+
return
|
|
395
|
+
end
|
|
396
|
+
# Reserved markdown fields are not editable via metadata; preserve current values so a stale
|
|
397
|
+
# YAML payload from the metadata popup can't overwrite a concurrent markdown edit.
|
|
398
|
+
current_data = creative.data || {}
|
|
399
|
+
%w[markdown_source content_type].each do |key|
|
|
400
|
+
if current_data.key?(key)
|
|
401
|
+
new_data[key] = current_data[key]
|
|
402
|
+
else
|
|
403
|
+
new_data.delete(key)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
362
406
|
previous_enabled = creative.drop_trigger_enabled?
|
|
363
407
|
|
|
364
408
|
if creative.update(data: new_data)
|
|
@@ -499,7 +543,7 @@ module Collavre
|
|
|
499
543
|
end
|
|
500
544
|
|
|
501
545
|
def creative_params
|
|
502
|
-
params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id)
|
|
546
|
+
params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input)
|
|
503
547
|
end
|
|
504
548
|
|
|
505
549
|
def any_filter_active?
|
|
@@ -519,12 +563,102 @@ module Collavre
|
|
|
519
563
|
|
|
520
564
|
def serialize_creatives(collection)
|
|
521
565
|
if params[:simple].present?
|
|
522
|
-
|
|
566
|
+
# Preload origins so effective_origin (used per linked-shell row in both
|
|
567
|
+
# children_presence_set and the origin_id mapping below) resolves from
|
|
568
|
+
# memory instead of firing a query per shell. Only browse can hold shells;
|
|
569
|
+
# search is scoped to origin_id: nil, so this is a no-op there.
|
|
570
|
+
ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
|
|
571
|
+
children_ids = children_presence_set(collection)
|
|
572
|
+
searching = params[:search].present?
|
|
573
|
+
breadcrumbs = searching ? ::Creatives::BreadcrumbResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
|
|
574
|
+
# For hits routed through a linked shell, the path to expand in the
|
|
575
|
+
# user's own tree (local folders -> shell) so a breadcrumb jump can
|
|
576
|
+
# reach a shell nested under a collapsed folder.
|
|
577
|
+
reveal_paths = searching ? ::Creatives::RevealPathResolver.new(collection.map(&:id), user: Current.user, include_archived: params[:show_archived].present?).call : {}
|
|
578
|
+
collection.map do |c|
|
|
579
|
+
item = {
|
|
580
|
+
id: c.id,
|
|
581
|
+
description: c.effective_description(nil, false),
|
|
582
|
+
progress: c.progress,
|
|
583
|
+
has_children: children_ids.include?(c.id)
|
|
584
|
+
}
|
|
585
|
+
reveal = reveal_paths[c.id]
|
|
586
|
+
path = breadcrumbs[c.id]
|
|
587
|
+
if path.present?
|
|
588
|
+
item[:path] = mask_unreachable_crumbs(path, reveal)
|
|
589
|
+
end
|
|
590
|
+
item[:reveal_path] = reveal if reveal.present?
|
|
591
|
+
# For linked shells, expose the effective origin id so the picker can
|
|
592
|
+
# map a search breadcrumb (origin ids) back to the rendered shell node.
|
|
593
|
+
item[:origin_id] = c.effective_origin.id if c.origin_id
|
|
594
|
+
item
|
|
595
|
+
end
|
|
523
596
|
else
|
|
524
597
|
collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
|
|
525
598
|
end
|
|
526
599
|
end
|
|
527
600
|
|
|
601
|
+
# A breadcrumb jump expands the tree from a rendered root (or, for a shared
|
|
602
|
+
# subtree, a linked shell) down to the clicked crumb. If an ancestor above a
|
|
603
|
+
# crumb is itself unrenderable (unreadable, or archived while archived rows
|
|
604
|
+
# aren't shown) and no linked shell re-roots the path at/below it, the
|
|
605
|
+
# descendant can't be expanded either — so mask it too. BreadcrumbResolver
|
|
606
|
+
# masks the unrenderable ancestor itself; this masks everything downstream of
|
|
607
|
+
# it on the plain origin chain, matching exactly what the tree can render.
|
|
608
|
+
#
|
|
609
|
+
# `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
|
|
610
|
+
# a key re-roots navigation through its own shell (the client anchors at the
|
|
611
|
+
# nearest reveal entry at/above the clicked crumb), so it clears the block for
|
|
612
|
+
# itself and its descendants regardless of an archived/unreadable origin
|
|
613
|
+
# above it.
|
|
614
|
+
def mask_unreachable_crumbs(path, reveal)
|
|
615
|
+
blocked = false
|
|
616
|
+
path.map do |crumb|
|
|
617
|
+
if reveal&.key?(crumb[:id])
|
|
618
|
+
blocked = false
|
|
619
|
+
crumb
|
|
620
|
+
elsif crumb[:restricted]
|
|
621
|
+
blocked = true
|
|
622
|
+
crumb
|
|
623
|
+
elsif blocked
|
|
624
|
+
crumb.merge(restricted: true, description: nil)
|
|
625
|
+
else
|
|
626
|
+
crumb
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Batched "does this node have a child the user can actually browse to?"
|
|
632
|
+
# lookup so the picker tree renders expand toggles without an N+1.
|
|
633
|
+
#
|
|
634
|
+
# Must match exactly what expanding the node shows (IndexQuery#handle_id_query
|
|
635
|
+
# -> children_with_permission, minus archived unless show_archived), or the
|
|
636
|
+
# toggle either hides a reachable subtree or opens to an empty branch (and
|
|
637
|
+
# leaks that hidden children exist). Two alignments are needed:
|
|
638
|
+
# 1. Linked shells (origin_id set) store children under the effective
|
|
639
|
+
# origin (redirect_parent_to_origin + children->origin migration), so
|
|
640
|
+
# resolve each row to its effective origin before the lookup.
|
|
641
|
+
# 2. Apply the same archived + read-permission filters as the browse path.
|
|
642
|
+
def children_presence_set(collection)
|
|
643
|
+
return Set.new if collection.empty?
|
|
644
|
+
|
|
645
|
+
origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
|
|
646
|
+
|
|
647
|
+
candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
|
|
648
|
+
candidates = candidates.where(archived_at: nil) unless params[:show_archived]
|
|
649
|
+
child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
|
|
650
|
+
return Set.new if child_rows.empty?
|
|
651
|
+
|
|
652
|
+
readable = ::Creatives::PermissionFilter
|
|
653
|
+
.new(user: Current.user).readable_ids(child_rows.map(&:first)).to_set
|
|
654
|
+
origins_with_visible_children = child_rows
|
|
655
|
+
.each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
|
|
656
|
+
|
|
657
|
+
collection.each_with_object(Set.new) do |c, set|
|
|
658
|
+
set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
528
662
|
def reorderer
|
|
529
663
|
@reorderer ||= ::Creatives::Reorderer.new(user: Current.user)
|
|
530
664
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Serves ActiveStorage blobs through a CDN-friendly path:
|
|
5
|
+
# /public-assets/blobs/:signed_id/*filename
|
|
6
|
+
#
|
|
7
|
+
# The signed_id is the only capability — no auth required. This makes the URL
|
|
8
|
+
# safe to embed in landing pages and other publicly-rendered HTML. CloudFront
|
|
9
|
+
# caches by full URL, so rotating signed_id (re-attach) invalidates effectively.
|
|
10
|
+
class PublicAssetsController < ActionController::Base
|
|
11
|
+
include ActiveStorage::SetCurrent
|
|
12
|
+
include ActiveStorage::Streaming
|
|
13
|
+
|
|
14
|
+
PUBLIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
blob = ActiveStorage::Blob.find_signed!(params[:signed_id])
|
|
18
|
+
response.headers["Cache-Control"] = PUBLIC_CACHE_CONTROL
|
|
19
|
+
send_blob_stream blob, disposition: "inline"
|
|
20
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
|
21
|
+
head :not_found
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -10,21 +10,29 @@ module Collavre
|
|
|
10
10
|
return head :forbidden
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
unless %w[running pending queued pending_approval].include?(task.status)
|
|
13
|
+
unless %w[running pending queued pending_approval delegated].include?(task.status)
|
|
14
14
|
return head :unprocessable_entity
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
was_delegated = task.status == "delegated"
|
|
17
18
|
task.update!(status: "cancelled")
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
# Delegated tasks have no active worker to run the ensure-block release,
|
|
21
|
+
# so free the agent slot and drain the topic queue here.
|
|
22
|
+
if was_delegated && task.agent
|
|
23
|
+
Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
|
|
24
|
+
Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
abort_agent_session(task)
|
|
20
28
|
|
|
21
29
|
head :ok
|
|
22
30
|
end
|
|
23
31
|
|
|
24
32
|
private
|
|
25
33
|
|
|
26
|
-
def
|
|
27
|
-
Collavre::
|
|
34
|
+
def abort_agent_session(task)
|
|
35
|
+
Collavre::AgentSessionAbort.call(agent: task.agent, task: task)
|
|
28
36
|
end
|
|
29
37
|
end
|
|
30
38
|
end
|
|
@@ -3,7 +3,7 @@ module Collavre
|
|
|
3
3
|
include Collavre::CreativePermissionGuard
|
|
4
4
|
|
|
5
5
|
before_action :set_creative
|
|
6
|
-
before_action :require_creative_read!, only: %i[next_name]
|
|
6
|
+
before_action :require_creative_read!, only: %i[next_name channel_chips]
|
|
7
7
|
before_action :require_creative_admin!, only: %i[update destroy move reorder]
|
|
8
8
|
before_action :require_creative_write!, only: %i[create archive unarchive set_primary_agent]
|
|
9
9
|
|
|
@@ -12,8 +12,15 @@ module Collavre
|
|
|
12
12
|
can_manage = @creative.has_permission?(Current.user, :admin) || is_owner
|
|
13
13
|
can_create_topic = can_manage || @creative.has_permission?(Current.user, :write)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# Eagerly ensure Main (and System for inboxes) exist BEFORE loading
|
|
16
|
+
# active_topics. Otherwise the first inbox visit sees no System topic in
|
|
17
|
+
# the sidebar, and when a later notification creates it via
|
|
18
|
+
# find_or_create_by!, the unread badge appears but the user has no way to
|
|
19
|
+
# open the topic.
|
|
20
|
+
main_topic = @creative.main_topic(fallback_user: Current.user)
|
|
21
|
+
system_topic = @creative.inbox? ? @creative.system_topic(fallback_user: Current.user) : nil
|
|
22
|
+
|
|
23
|
+
active_topics = @creative.topics.active.includes(primary_agent: { avatar_attachment: :blob }).order(:created_at).to_a
|
|
17
24
|
archived_topics = @creative.topics.archived.order(:created_at)
|
|
18
25
|
|
|
19
26
|
last_topic_id = if Current.user
|
|
@@ -22,8 +29,8 @@ module Collavre
|
|
|
22
29
|
.pick(:last_topic_id)
|
|
23
30
|
end
|
|
24
31
|
|
|
25
|
-
system_topic_id =
|
|
26
|
-
main_topic_id =
|
|
32
|
+
system_topic_id = system_topic&.id
|
|
33
|
+
main_topic_id = main_topic.id
|
|
27
34
|
|
|
28
35
|
render json: {
|
|
29
36
|
topics: active_topics.map { |t| topic_json(t) },
|
|
@@ -33,7 +40,8 @@ module Collavre
|
|
|
33
40
|
last_topic_id: last_topic_id,
|
|
34
41
|
is_inbox: @creative.inbox?,
|
|
35
42
|
system_topic_id: system_topic_id,
|
|
36
|
-
main_topic_id: main_topic_id
|
|
43
|
+
main_topic_id: main_topic_id,
|
|
44
|
+
effective_creative_id: @creative.id
|
|
37
45
|
}
|
|
38
46
|
end
|
|
39
47
|
|
|
@@ -73,6 +81,11 @@ module Collavre
|
|
|
73
81
|
render json: { name: generate_next_topic_name }
|
|
74
82
|
end
|
|
75
83
|
|
|
84
|
+
def channel_chips
|
|
85
|
+
topic = @creative.topics.find(params[:id])
|
|
86
|
+
render partial: "collavre/comments/channel_chips", locals: { topic: topic }
|
|
87
|
+
end
|
|
88
|
+
|
|
76
89
|
def update
|
|
77
90
|
topic = @creative.topics.find(params[:id])
|
|
78
91
|
|
|
@@ -92,8 +105,23 @@ module Collavre
|
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
topic_id = topic.id
|
|
108
|
+
topic_name = topic.name
|
|
95
109
|
topic.destroy
|
|
96
110
|
|
|
111
|
+
# Best-effort orphaned-cron notice. Must never break the core deletion:
|
|
112
|
+
# if it raises, swallow + log so broadcast/head still run.
|
|
113
|
+
begin
|
|
114
|
+
Collavre::Topics::OrphanedCronNotifier.new(
|
|
115
|
+
topic_id: topic_id,
|
|
116
|
+
topic_name: topic_name
|
|
117
|
+
).call
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Rails.logger.error(
|
|
120
|
+
"[TopicsController#destroy] OrphanedCronNotifier failed for topic " \
|
|
121
|
+
"#{topic_id}: #{e.class} #{e.message}"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
97
125
|
broadcast_topic_event("deleted", topic_id: topic_id)
|
|
98
126
|
head :no_content
|
|
99
127
|
end
|
|
@@ -194,32 +222,10 @@ module Collavre
|
|
|
194
222
|
"#{prefix}#{next_number}"
|
|
195
223
|
end
|
|
196
224
|
|
|
197
|
-
# Batch-load primary agents for all topics to avoid N+1 queries
|
|
198
|
-
def preload_primary_agents(topics)
|
|
199
|
-
topic_ids = topics.map(&:id)
|
|
200
|
-
return if topic_ids.empty?
|
|
201
|
-
|
|
202
|
-
policies = OrchestratorPolicy.where(
|
|
203
|
-
policy_type: "arbitration",
|
|
204
|
-
scope_type: "Topic",
|
|
205
|
-
scope_id: topic_ids
|
|
206
|
-
).index_by { |p| p.scope_id.to_i }
|
|
207
|
-
|
|
208
|
-
agent_ids = policies.values.filter_map { |p| p.config&.dig("primary_agent_id") }
|
|
209
|
-
agents = agent_ids.present? ? User.where(id: agent_ids).includes(avatar_attachment: :blob).index_by(&:id) : {}
|
|
210
|
-
|
|
211
|
-
topics.each do |topic|
|
|
212
|
-
policy = policies[topic.id]
|
|
213
|
-
agent_id = policy&.config&.dig("primary_agent_id")
|
|
214
|
-
topic.instance_variable_set(:@_primary_agent, agents&.dig(agent_id))
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
225
|
def topic_json(topic)
|
|
219
226
|
data = topic.slice(:id, :name, :source_topic_id)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
data[:primary_agent] = agent_json(agent)
|
|
227
|
+
if topic.primary_agent
|
|
228
|
+
data[:primary_agent] = agent_json(topic.primary_agent)
|
|
223
229
|
end
|
|
224
230
|
data
|
|
225
231
|
end
|