collavre 0.20.2 → 0.21.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 +83 -0
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/creatives_controller.rb +50 -6
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/passwords_controller.rb +1 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/topics_controller.rb +21 -30
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/controllers/comment_controller.js +9 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +10 -7
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +83 -1
- 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/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- 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/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/creative/describable.rb +65 -3
- data/app/models/collavre/creative.rb +2 -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/topic.rb +3 -25
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- 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/tools/creative_attach_files_service.rb +96 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
- 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/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 +54 -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 +6 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
- 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/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 +11 -0
- data/config/locales/channels.ko.yml +11 -0
- data/config/locales/comments.en.yml +2 -0
- data/config/locales/comments.ko.yml +2 -0
- data/config/locales/creatives.en.yml +9 -0
- data/config/locales/creatives.ko.yml +8 -0
- data/config/locales/integrations.en.yml +44 -0
- data/config/locales/integrations.ko.yml +44 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +18 -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/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +51 -0
- data/lib/collavre/integration_settings/key_definition.rb +29 -0
- data/lib/collavre/integration_settings/registry.rb +55 -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 +52 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
class Channel < ApplicationRecord
|
|
3
|
+
BOT_EMAIL = "channel@collavre.local"
|
|
4
|
+
BOT_NAME = "Channel"
|
|
5
|
+
|
|
6
|
+
self.table_name = "channels"
|
|
7
|
+
|
|
8
|
+
belongs_to :topic, class_name: "Collavre::Topic"
|
|
9
|
+
|
|
10
|
+
enum :state, { active: 0, detached: 1 }, default: :active
|
|
11
|
+
|
|
12
|
+
scope :not_dismissed, -> { where(dismissed_at: nil) }
|
|
13
|
+
|
|
14
|
+
def handle(event:, payload:)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #handle"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Chip fallbacks rendered before any webhook event populates latest_label /
|
|
19
|
+
# latest_link. Base class returns nil; subclasses override when they can
|
|
20
|
+
# derive a stable label/link from their own config (e.g. PR #N + URL).
|
|
21
|
+
def default_label
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_link
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Badge interface for the typing-indicator chip. Subclasses override
|
|
30
|
+
# `badge_state` to drive the chip badge color (the value becomes a CSS
|
|
31
|
+
# modifier: channel-chip-badge--<state>), and `badge_title` to provide a
|
|
32
|
+
# localized title / aria-label string. Returning nil from `badge_state`
|
|
33
|
+
# hides the badge entirely.
|
|
34
|
+
def badge_state
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def badge_title
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def detach!
|
|
43
|
+
update!(state: :detached)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dismissed?
|
|
47
|
+
dismissed_at.present?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Hide the chip from the typing-indicator row. Performs detach! as a
|
|
51
|
+
# side-effect when the channel is still active so dismissal is a single
|
|
52
|
+
# user-facing action — clicking the X always removes the chip regardless
|
|
53
|
+
# of prior state.
|
|
54
|
+
def dismiss!
|
|
55
|
+
transaction do
|
|
56
|
+
detach! if active?
|
|
57
|
+
update!(dismissed_at: Time.current) if dismissed_at.nil?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def record_event!(label:, link:)
|
|
62
|
+
update!(latest_label: label, latest_link: link, last_event_at: Time.current)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def inject_into_topic!(injected_message)
|
|
66
|
+
transaction do
|
|
67
|
+
comment = topic.creative.comments.create!(
|
|
68
|
+
user: injected_message.speaker,
|
|
69
|
+
topic_id: topic.id,
|
|
70
|
+
content: injected_message.message,
|
|
71
|
+
private: false
|
|
72
|
+
)
|
|
73
|
+
record_event!(label: injected_message.label, link: injected_message.link)
|
|
74
|
+
comment
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
after_create_commit :broadcast_chips_changed
|
|
79
|
+
after_update_commit :broadcast_chips_changed
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def broadcast_chips_changed
|
|
84
|
+
Collavre::CommentsPresenceChannel.broadcast_channel_chips_changed(topic.creative_id, topic_id: topic_id)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -4,10 +4,18 @@ module Collavre
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
+
attr_accessor :content_type_input
|
|
8
|
+
attr_reader :markdown_source
|
|
9
|
+
|
|
10
|
+
def markdown_source=(value)
|
|
11
|
+
@markdown_source = value.is_a?(String) ? value.gsub(/\r\n?/, "\n") : value
|
|
12
|
+
end
|
|
13
|
+
|
|
7
14
|
validates :description, presence: true, unless: -> { origin_id.present? }
|
|
8
15
|
validate :description_cannot_change_if_has_origin, on: :update
|
|
9
16
|
validate :description_cannot_change_if_github_source, on: :update
|
|
10
17
|
|
|
18
|
+
before_validation :convert_markdown_to_html
|
|
11
19
|
before_save :sanitize_description_html
|
|
12
20
|
after_destroy_commit :purge_description_attachments
|
|
13
21
|
end
|
|
@@ -32,14 +40,68 @@ module Collavre
|
|
|
32
40
|
|
|
33
41
|
private
|
|
34
42
|
|
|
43
|
+
def convert_markdown_to_html
|
|
44
|
+
if content_type_input == "markdown"
|
|
45
|
+
self.data ||= {}
|
|
46
|
+
new_source = markdown_source.to_s
|
|
47
|
+
prev_source = data["markdown_source"].to_s
|
|
48
|
+
prev_type = data["content_type"]
|
|
49
|
+
self.data["content_type"] = "markdown"
|
|
50
|
+
if new_source != prev_source || prev_type != "markdown"
|
|
51
|
+
# Rewrite inline data-URI images to freshly-uploaded blob paths
|
|
52
|
+
# FIRST, then persist the rewritten source. Subsequent edits
|
|
53
|
+
# around the same image carry the blob path instead of the
|
|
54
|
+
# data URI, so re-renders no longer create duplicate blobs.
|
|
55
|
+
rewritten_source = Collavre::MarkdownConverter.rewrite_data_uri_images(new_source)
|
|
56
|
+
self.data["markdown_source"] = rewritten_source
|
|
57
|
+
self.description = Collavre::MarkdownConverter.markdown_to_html(rewritten_source)
|
|
58
|
+
else
|
|
59
|
+
self.data["markdown_source"] = new_source
|
|
60
|
+
# Source unchanged: description must stay derived from markdown_source.
|
|
61
|
+
# Restore the persisted value rather than trusting params[:description],
|
|
62
|
+
# which would let a stale/crafted request diverge from markdown_source.
|
|
63
|
+
# Skipping the re-render also avoids re-importing data-URI images as
|
|
64
|
+
# fresh Active Storage blobs on every autosave/progress/move.
|
|
65
|
+
self.description = description_was if description_changed?
|
|
66
|
+
end
|
|
67
|
+
elsif content_type_input == "html"
|
|
68
|
+
self.data ||= {}
|
|
69
|
+
self.data.delete("content_type")
|
|
70
|
+
self.data.delete("markdown_source")
|
|
71
|
+
elsif !new_record? && description_changed? && data&.dig("content_type") == "markdown"
|
|
72
|
+
# Description was rewritten through a non-markdown path (tool/MCP
|
|
73
|
+
# update, direct base.update(description: ...), etc.) on a creative
|
|
74
|
+
# that was previously in markdown mode. The new HTML no longer
|
|
75
|
+
# matches data["markdown_source"], so the next inline-markdown open
|
|
76
|
+
# would load the stale source and silently overwrite this update.
|
|
77
|
+
# Demote to HTML mode so the persisted source matches description.
|
|
78
|
+
self.data.delete("content_type")
|
|
79
|
+
self.data.delete("markdown_source")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
35
83
|
def sanitize_description_html
|
|
36
84
|
table_tags = %w[table thead tbody tfoot tr th td]
|
|
37
85
|
table_attrs = %w[colspan rowspan]
|
|
38
86
|
attachment_attrs = %w[download data-filesize]
|
|
87
|
+
task_list_attrs = %w[type disabled checked]
|
|
88
|
+
|
|
89
|
+
# GFM task list checkboxes (`- [ ]` / `- [x]`) render as
|
|
90
|
+
# <input type="checkbox" disabled> via Commonmarker's tasklist
|
|
91
|
+
# extension. Strip any other <input> variant before sanitization
|
|
92
|
+
# so allowing the `input` tag in the safelist can't smuggle in
|
|
93
|
+
# text/image/submit inputs.
|
|
94
|
+
scrubbed = Loofah.fragment(description.to_s)
|
|
95
|
+
scrubbed.css("input").each do |node|
|
|
96
|
+
unless node["type"] == "checkbox" && node.has_attribute?("disabled")
|
|
97
|
+
node.remove
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
39
101
|
self.description = ActionController::Base.helpers.sanitize(
|
|
40
|
-
|
|
41
|
-
tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags,
|
|
42
|
-
attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + %w[data-lexical]
|
|
102
|
+
scrubbed.to_html,
|
|
103
|
+
tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags + %w[input],
|
|
104
|
+
attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + %w[data-lexical]
|
|
43
105
|
)
|
|
44
106
|
end
|
|
45
107
|
|
|
@@ -24,6 +24,8 @@ module Collavre
|
|
|
24
24
|
has_many :comment_read_pointers, class_name: "Collavre::CommentReadPointer", dependent: :delete_all
|
|
25
25
|
has_many :comment_snapshots, class_name: "Collavre::CommentSnapshot", dependent: :destroy
|
|
26
26
|
|
|
27
|
+
has_many_attached :files, dependent: :purge_later
|
|
28
|
+
|
|
27
29
|
has_closure_tree order: :sequence, name_column: :description, hierarchy_table_name: "creative_hierarchies"
|
|
28
30
|
|
|
29
31
|
# --- Archive scopes ---
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Stores externally-configurable integration secrets (Slack, Google OAuth,
|
|
5
|
+
# AWS S3/SES, FCM, etc.) on a per-key basis with `value` encrypted at rest.
|
|
6
|
+
#
|
|
7
|
+
# Distinct from {Collavre::SystemSetting}, which stores unencrypted app-behavior
|
|
8
|
+
# toggles (rate limits, themes, etc.). Pair with
|
|
9
|
+
# {Collavre::IntegrationSettings::Registry} (key definitions) and
|
|
10
|
+
# {Collavre::IntegrationSettings::Resolver} (DB > ENV > default precedence).
|
|
11
|
+
class IntegrationSetting < ApplicationRecord
|
|
12
|
+
self.table_name = "integration_settings"
|
|
13
|
+
|
|
14
|
+
encrypts :value, deterministic: false
|
|
15
|
+
|
|
16
|
+
validates :key, presence: true, uniqueness: true
|
|
17
|
+
validates :category, presence: true
|
|
18
|
+
|
|
19
|
+
after_commit :clear_cache
|
|
20
|
+
|
|
21
|
+
def self.cache_key_for(key)
|
|
22
|
+
"collavre/integration_setting/#{key}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def clear_cache
|
|
28
|
+
Rails.cache.delete(self.class.cache_key_for(key))
|
|
29
|
+
if saved_change_to_key?
|
|
30
|
+
old_key = saved_change_to_key.first
|
|
31
|
+
Rails.cache.delete(self.class.cache_key_for(old_key)) if old_key.present?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
# Topic chip for a running development preview server. Unlike
|
|
3
|
+
# GithubPrChannel there is no external webhook source — the chip is
|
|
4
|
+
# populated by AI Agents (or humans) calling preview_attach/preview_detach
|
|
5
|
+
# MCP tools, one for each ./bin/dev they spin up alongside a worktree.
|
|
6
|
+
class PreviewChannel < Collavre::Channel
|
|
7
|
+
self.table_name = "channels"
|
|
8
|
+
|
|
9
|
+
PREVIEW_STATES = %w[running stopped].freeze
|
|
10
|
+
|
|
11
|
+
def preview_url
|
|
12
|
+
config["preview_url"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def worktree_id
|
|
16
|
+
config["worktree_id"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def custom_label
|
|
20
|
+
config["label"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Chip fallbacks rendered immediately on attach, before the user has
|
|
24
|
+
# interacted with the chip. The label prefers the caller-supplied
|
|
25
|
+
# "Preview #42" style override and falls back to a localized default;
|
|
26
|
+
# the link is always the preview URL.
|
|
27
|
+
def default_label
|
|
28
|
+
custom_label.presence || I18n.t("collavre.channel.preview.label_default")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def default_link
|
|
32
|
+
preview_url
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def badge_state
|
|
36
|
+
preview_state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def badge_title
|
|
40
|
+
I18n.t("collavre.channel.preview.badge.#{preview_state}", default: preview_state.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def preview_state
|
|
44
|
+
state = config["preview_state"].to_s
|
|
45
|
+
PREVIEW_STATES.include?(state) ? state : "running"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def preview_state=(value)
|
|
49
|
+
value = value.to_s
|
|
50
|
+
raise ArgumentError, "Invalid preview_state: #{value.inspect}" unless PREVIEW_STATES.include?(value)
|
|
51
|
+
self.config = config.merge("preview_state" => value)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def attached_message
|
|
55
|
+
Collavre::Channel::InjectedMessage.new(
|
|
56
|
+
speaker: channel_bot_user,
|
|
57
|
+
message: I18n.t("collavre.channel.preview.attached_message",
|
|
58
|
+
label: default_label, url: preview_url),
|
|
59
|
+
label: default_label,
|
|
60
|
+
link: preview_url
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# PreviewChannel has no external event source — preview_attach/detach
|
|
65
|
+
# mutate the channel directly. The base `handle` would raise
|
|
66
|
+
# NotImplementedError, so override with an explicit no-op.
|
|
67
|
+
def handle(event:, payload:)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Same bot user as GithubPrChannel so all channel-injected comments come
|
|
74
|
+
# from a single "Channel" speaker in the topic timeline.
|
|
75
|
+
def channel_bot_user
|
|
76
|
+
@channel_bot_user ||=
|
|
77
|
+
Collavre::User.find_by(email: Collavre::Channel::BOT_EMAIL) ||
|
|
78
|
+
ensure_channel_bot_user!
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_channel_bot_user!
|
|
82
|
+
email = Collavre::Channel::BOT_EMAIL
|
|
83
|
+
user = Collavre::User.find_or_initialize_by(email: email)
|
|
84
|
+
user.name = Collavre::Channel::BOT_NAME
|
|
85
|
+
user.password = SecureRandom.hex(32) if user.new_record?
|
|
86
|
+
user.email_verified_at ||= Time.current
|
|
87
|
+
user.searchable = false if user.respond_to?(:searchable=)
|
|
88
|
+
user.llm_vendor = nil
|
|
89
|
+
user.save!
|
|
90
|
+
user
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -25,9 +25,15 @@ module Collavre
|
|
|
25
25
|
# By default, public access is allowed (false)
|
|
26
26
|
DEFAULT_CREATIVES_LOGIN_REQUIRED = false
|
|
27
27
|
|
|
28
|
-
# Default home page path (nil means use root_path "/")
|
|
28
|
+
# Default home page path for unauthenticated visitors (nil means use root_path "/")
|
|
29
29
|
DEFAULT_HOME_PAGE_PATH = nil
|
|
30
30
|
|
|
31
|
+
# Default home page path for authenticated users.
|
|
32
|
+
# Signed-in visitors hitting "/" are redirected to this path when the
|
|
33
|
+
# admin has not configured a value. Set to "/" via the admin UI to
|
|
34
|
+
# disable the redirect and fall back to the unauthenticated rewrite.
|
|
35
|
+
DEFAULT_HOME_PAGE_PATH_AUTHENTICATED = "/creatives"
|
|
36
|
+
|
|
31
37
|
# Default theme IDs (nil means use built-in light/dark)
|
|
32
38
|
# These reference UserTheme IDs for admin-configured custom themes
|
|
33
39
|
DEFAULT_LIGHT_THEME_ID = nil
|
|
@@ -59,7 +65,7 @@ module Collavre
|
|
|
59
65
|
lockout_duration_minutes session_timeout_minutes password_min_length
|
|
60
66
|
password_reset_rate_limit password_reset_rate_period_minutes
|
|
61
67
|
api_rate_limit api_rate_period_minutes auth_providers_disabled
|
|
62
|
-
creatives_login_required home_page_path default_light_theme_id default_dark_theme_id
|
|
68
|
+
creatives_login_required home_page_path home_page_path_authenticated default_light_theme_id default_dark_theme_id
|
|
63
69
|
display_level completion_mark llm_request_timeout_seconds
|
|
64
70
|
].each { |k| Rails.cache.delete("system_setting:#{k}") }
|
|
65
71
|
end
|
|
@@ -88,6 +94,11 @@ module Collavre
|
|
|
88
94
|
value.presence
|
|
89
95
|
end
|
|
90
96
|
|
|
97
|
+
def self.home_page_path_authenticated
|
|
98
|
+
value = cached_value("home_page_path_authenticated")
|
|
99
|
+
value.presence || DEFAULT_HOME_PAGE_PATH_AUTHENTICATED
|
|
100
|
+
end
|
|
101
|
+
|
|
91
102
|
def self.mcp_tool_approval_required?
|
|
92
103
|
if Current.mcp_tool_approval_required.nil?
|
|
93
104
|
Current.mcp_tool_approval_required = cached_value("mcp_tool_approval_required") == "true"
|
|
@@ -5,8 +5,10 @@ module Collavre
|
|
|
5
5
|
belongs_to :creative, class_name: "Collavre::Creative"
|
|
6
6
|
belongs_to :user, class_name: Collavre.configuration.user_class_name
|
|
7
7
|
belongs_to :source_topic, class_name: "Collavre::Topic", optional: true
|
|
8
|
+
belongs_to :primary_agent, class_name: Collavre.configuration.user_class_name, optional: true
|
|
8
9
|
|
|
9
10
|
has_many :comments, class_name: "Collavre::Comment", dependent: :destroy
|
|
11
|
+
has_many :channels, class_name: "Collavre::Channel", dependent: :destroy
|
|
10
12
|
has_many :branches, class_name: "Collavre::Topic", foreign_key: :source_topic_id, dependent: :nullify
|
|
11
13
|
has_many :user_creative_preferences_as_last_topic, class_name: "Collavre::UserCreativePreference",
|
|
12
14
|
foreign_key: :last_topic_id, dependent: :nullify, inverse_of: :last_topic
|
|
@@ -21,33 +23,9 @@ module Collavre
|
|
|
21
23
|
|
|
22
24
|
default_scope { order(:position) }
|
|
23
25
|
|
|
24
|
-
# Returns the primary agent User for this topic (from orchestration policy)
|
|
25
|
-
def primary_agent
|
|
26
|
-
policy = OrchestratorPolicy.find_by(
|
|
27
|
-
policy_type: "arbitration",
|
|
28
|
-
scope_type: "Topic",
|
|
29
|
-
scope_id: id
|
|
30
|
-
)
|
|
31
|
-
return nil unless policy&.config&.dig("primary_agent_id")
|
|
32
|
-
|
|
33
|
-
User.find_by(id: policy.config["primary_agent_id"])
|
|
34
|
-
end
|
|
35
|
-
|
|
36
26
|
# Sets or replaces the primary agent for this topic
|
|
37
27
|
def set_primary_agent!(agent)
|
|
38
|
-
|
|
39
|
-
policy_type: "arbitration",
|
|
40
|
-
scope_type: "Topic",
|
|
41
|
-
scope_id: id
|
|
42
|
-
)
|
|
43
|
-
policy.update!(
|
|
44
|
-
config: {
|
|
45
|
-
"strategy" => "primary_first",
|
|
46
|
-
"primary_agent_id" => agent.id
|
|
47
|
-
},
|
|
48
|
-
priority: 10,
|
|
49
|
-
enabled: true
|
|
50
|
-
)
|
|
28
|
+
update!(primary_agent: agent)
|
|
51
29
|
end
|
|
52
30
|
|
|
53
31
|
def archived?
|
|
@@ -5,13 +5,22 @@ module Collavre
|
|
|
5
5
|
private
|
|
6
6
|
|
|
7
7
|
# Resolve AI agent using orchestration rules:
|
|
8
|
-
# 1. Topic's primary agent (
|
|
9
|
-
# 2. Fallback:
|
|
8
|
+
# 1. Topic's primary agent (stored on topics table)
|
|
9
|
+
# 2. Fallback: policy-level primary_agent_id (creative/global scope)
|
|
10
|
+
# 3. Fallback: any AI agent with feedback permission on the creative
|
|
10
11
|
def resolve_ai_agent(creative, topic_id)
|
|
11
12
|
if topic_id.present?
|
|
13
|
+
# Check topic's direct primary_agent_id first
|
|
14
|
+
topic = Topic.find_by(id: topic_id)
|
|
15
|
+
if topic&.primary_agent_id.present?
|
|
16
|
+
agent = User.find_by(id: topic.primary_agent_id)
|
|
17
|
+
return agent if agent&.ai_user?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fall back to policy-level config (creative/global scope)
|
|
12
21
|
context = build_ai_agent_policy_context(creative, topic_id)
|
|
13
22
|
resolver = Orchestration::PolicyResolver.new(context)
|
|
14
|
-
primary_id = resolver.primary_agent_id
|
|
23
|
+
primary_id = resolver.arbitration_config["primary_agent_id"]
|
|
15
24
|
|
|
16
25
|
if primary_id.present?
|
|
17
26
|
agent = User.find_by(id: primary_id)
|
|
@@ -113,17 +113,17 @@ module Collavre
|
|
|
113
113
|
|
|
114
114
|
context_block = case normalized_vendor
|
|
115
115
|
when "openai"
|
|
116
|
-
api_key = @llm_api_key.presence ||
|
|
116
|
+
api_key = @llm_api_key.presence || IntegrationSettings.fetch(:openai_api_key)
|
|
117
117
|
base_url = @gateway_url.presence
|
|
118
118
|
proc do |config|
|
|
119
119
|
config.openai_api_key = api_key
|
|
120
120
|
config.openai_api_base = base_url if base_url
|
|
121
121
|
end
|
|
122
122
|
when "anthropic"
|
|
123
|
-
api_key = @llm_api_key.presence ||
|
|
123
|
+
api_key = @llm_api_key.presence || IntegrationSettings.fetch(:anthropic_api_key)
|
|
124
124
|
proc { |config| config.anthropic_api_key = api_key }
|
|
125
125
|
else
|
|
126
|
-
api_key = @llm_api_key.presence ||
|
|
126
|
+
api_key = @llm_api_key.presence || IntegrationSettings.fetch(:gemini_api_key)
|
|
127
127
|
proc { |config| config.gemini_api_key = api_key }
|
|
128
128
|
end
|
|
129
129
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Shared idempotent attach/reactivate lifecycle for topic channels.
|
|
5
|
+
# Every channel subtype reaches the same end-state regardless of which tool
|
|
6
|
+
# invoked it: a row that is active, not dismissed, and visible under the
|
|
7
|
+
# `not_dismissed` render scope.
|
|
8
|
+
#
|
|
9
|
+
# Subtype-specific resets (e.g. PR `pr_state` back to "open" so a previously
|
|
10
|
+
# merged-then-reattached channel renders the green badge again) run via the
|
|
11
|
+
# `on_reactivate` proc.
|
|
12
|
+
class ChannelAttacher
|
|
13
|
+
# Returns [channel, status] where status ∈ :created / :reactivated / :noop.
|
|
14
|
+
#
|
|
15
|
+
# `lookup` is called twice in the unique-violation race path, so it must
|
|
16
|
+
# be a fresh query each call (not a captured AR record).
|
|
17
|
+
#
|
|
18
|
+
# `on_noop` runs when the existing channel is still active+visible and we
|
|
19
|
+
# would otherwise short-circuit. Preview channels use it to silently refresh
|
|
20
|
+
# config (e.g. new port after a restart) so the chip link never points at a
|
|
21
|
+
# dead server while reporting success.
|
|
22
|
+
def self.call(channel_class:, lookup:, create_attrs:, on_reactivate: nil, on_noop: nil)
|
|
23
|
+
existing = lookup.call
|
|
24
|
+
if existing
|
|
25
|
+
return noop(existing, on_noop) if existing.active? && existing.dismissed_at.nil?
|
|
26
|
+
reactivate(existing, on_reactivate)
|
|
27
|
+
return [ existing, :reactivated ]
|
|
28
|
+
end
|
|
29
|
+
[ channel_class.create!(create_attrs), :created ]
|
|
30
|
+
rescue ActiveRecord::RecordNotUnique
|
|
31
|
+
existing = lookup.call
|
|
32
|
+
raise unless existing
|
|
33
|
+
if existing.active? && existing.dismissed_at.nil?
|
|
34
|
+
noop(existing, on_noop)
|
|
35
|
+
else
|
|
36
|
+
reactivate(existing, on_reactivate)
|
|
37
|
+
[ existing, :reactivated ]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.reactivate(channel, on_reactivate)
|
|
42
|
+
channel.state = :active unless channel.active?
|
|
43
|
+
channel.dismissed_at = nil unless channel.dismissed_at.nil?
|
|
44
|
+
on_reactivate&.call(channel)
|
|
45
|
+
channel.save!
|
|
46
|
+
end
|
|
47
|
+
private_class_method :reactivate
|
|
48
|
+
|
|
49
|
+
def self.noop(channel, on_noop)
|
|
50
|
+
if on_noop
|
|
51
|
+
on_noop.call(channel)
|
|
52
|
+
channel.save! if channel.changed?
|
|
53
|
+
end
|
|
54
|
+
[ channel, :noop ]
|
|
55
|
+
end
|
|
56
|
+
private_class_method :noop
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -98,6 +98,18 @@ module Collavre
|
|
|
98
98
|
return I18n.t("collavre.comments.mcp_command.error_running", tool_name: tool_name, error: response[:error]) if response[:error].present?
|
|
99
99
|
|
|
100
100
|
result = response[:result]
|
|
101
|
+
escaped_tool = ERB::Util.html_escape(tool_name)
|
|
102
|
+
|
|
103
|
+
markdown_body = extract_markdown(result)
|
|
104
|
+
if markdown_body
|
|
105
|
+
return <<~HTML
|
|
106
|
+
<details open><summary>#{escaped_tool} response</summary>
|
|
107
|
+
|
|
108
|
+
#{markdown_body}
|
|
109
|
+
</details>
|
|
110
|
+
HTML
|
|
111
|
+
end
|
|
112
|
+
|
|
101
113
|
content = case result
|
|
102
114
|
when Hash, Array
|
|
103
115
|
JSON.pretty_generate(result)
|
|
@@ -105,7 +117,6 @@ module Collavre
|
|
|
105
117
|
result.to_s
|
|
106
118
|
end
|
|
107
119
|
|
|
108
|
-
escaped_tool = ERB::Util.html_escape(tool_name)
|
|
109
120
|
escaped_content = ERB::Util.html_escape(content)
|
|
110
121
|
|
|
111
122
|
<<~HTML
|
|
@@ -115,6 +126,25 @@ module Collavre
|
|
|
115
126
|
HTML
|
|
116
127
|
end
|
|
117
128
|
|
|
129
|
+
def extract_markdown(result)
|
|
130
|
+
candidate = nil
|
|
131
|
+
|
|
132
|
+
if result.is_a?(Hash)
|
|
133
|
+
candidate = result["markdown"] || result[:markdown]
|
|
134
|
+
elsif result.is_a?(String)
|
|
135
|
+
parsed = begin
|
|
136
|
+
JSON.parse(result)
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
candidate = parsed["markdown"] if parsed.is_a?(Hash)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return nil unless candidate.is_a?(String) && candidate.strip.length.positive?
|
|
144
|
+
|
|
145
|
+
candidate
|
|
146
|
+
end
|
|
147
|
+
|
|
118
148
|
def tool_name
|
|
119
149
|
tool[:name]
|
|
120
150
|
end
|
|
@@ -128,7 +128,7 @@ module Creatives
|
|
|
128
128
|
sequence: creative.sequence,
|
|
129
129
|
link_url: view_context.collavre.creative_path(creative),
|
|
130
130
|
templates: template_payload_for(creative, has_children: filtered_children.any?),
|
|
131
|
-
inline_editor_payload: inline_editor_payload_for(creative),
|
|
131
|
+
inline_editor_payload: inline_editor_payload_for(creative, can_write: cached_can_write?(creative)),
|
|
132
132
|
children_container: children_container_payload(
|
|
133
133
|
creative,
|
|
134
134
|
filtered_children,
|
|
@@ -187,11 +187,15 @@ module Creatives
|
|
|
187
187
|
}
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
def inline_editor_payload_for(creative)
|
|
190
|
+
def inline_editor_payload_for(creative, can_write:)
|
|
191
|
+
effective = creative.effective_origin(Set.new)
|
|
192
|
+
origin_writable = effective.id == creative.id ? can_write : creative.has_permission?(user, :write)
|
|
191
193
|
{
|
|
192
194
|
description_raw_html: creative.effective_description(nil, true),
|
|
193
195
|
progress: creative.progress,
|
|
194
|
-
origin_id: creative.origin_id
|
|
196
|
+
origin_id: creative.origin_id,
|
|
197
|
+
content_type: effective.data&.dig("content_type"),
|
|
198
|
+
markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil
|
|
195
199
|
}
|
|
196
200
|
end
|
|
197
201
|
|
|
@@ -110,8 +110,10 @@ module Collavre
|
|
|
110
110
|
raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.not_connected") if token.blank?
|
|
111
111
|
|
|
112
112
|
Google::Auth::UserRefreshCredentials.new(
|
|
113
|
-
client_id:
|
|
114
|
-
|
|
113
|
+
client_id: Collavre::IntegrationSettings::Resolver.get(:google_client_id).presence ||
|
|
114
|
+
Rails.application.credentials.dig(:google, :client_id),
|
|
115
|
+
client_secret: Collavre::IntegrationSettings::Resolver.get(:google_client_secret).presence ||
|
|
116
|
+
Rails.application.credentials.dig(:google, :client_secret),
|
|
115
117
|
scope: [ Google::Apis::CalendarV3::AUTH_CALENDAR_APP_CREATED ],
|
|
116
118
|
refresh_token: token
|
|
117
119
|
).tap(&:fetch_access_token!)
|