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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +83 -0
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  7. data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
  8. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  9. data/app/controllers/collavre/application_controller.rb +27 -0
  10. data/app/controllers/collavre/channels_controller.rb +23 -0
  11. data/app/controllers/collavre/creatives_controller.rb +50 -6
  12. data/app/controllers/collavre/landing_controller.rb +8 -0
  13. data/app/controllers/collavre/passwords_controller.rb +1 -0
  14. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  15. data/app/controllers/collavre/topics_controller.rb +21 -30
  16. data/app/helpers/collavre/comments_helper.rb +7 -0
  17. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  18. data/app/javascript/controllers/comment_controller.js +9 -0
  19. data/app/javascript/controllers/comments/form_controller.js +4 -0
  20. data/app/javascript/controllers/comments/list_controller.js +10 -7
  21. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  22. data/app/javascript/controllers/comments/presence_controller.js +83 -1
  23. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  24. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  25. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  26. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  27. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  28. data/app/javascript/controllers/index.js +4 -1
  29. data/app/javascript/controllers/landing_video_controller.js +53 -0
  30. data/app/javascript/creatives/tree_renderer.js +6 -0
  31. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  32. data/app/javascript/lib/api/queue_manager.js +17 -5
  33. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  34. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  35. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  36. data/app/javascript/modules/command_args_form.js +22 -4
  37. data/app/javascript/modules/command_menu.js +27 -0
  38. data/app/javascript/modules/creative_row_editor.js +227 -17
  39. data/app/javascript/modules/html_content_empty.js +12 -0
  40. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  41. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  42. data/app/mailers/collavre/application_mailer.rb +1 -1
  43. data/app/models/collavre/channel/injected_message.rb +5 -0
  44. data/app/models/collavre/channel.rb +87 -0
  45. data/app/models/collavre/creative/describable.rb +65 -3
  46. data/app/models/collavre/creative.rb +2 -0
  47. data/app/models/collavre/integration_setting.rb +35 -0
  48. data/app/models/collavre/preview_channel.rb +93 -0
  49. data/app/models/collavre/system_setting.rb +13 -2
  50. data/app/models/collavre/topic.rb +3 -25
  51. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  52. data/app/services/collavre/ai_client.rb +3 -3
  53. data/app/services/collavre/channel_attacher.rb +58 -0
  54. data/app/services/collavre/comments/mcp_command.rb +31 -1
  55. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  56. data/app/services/collavre/google_calendar_service.rb +4 -2
  57. data/app/services/collavre/markdown_converter.rb +130 -15
  58. data/app/services/collavre/markdown_importer.rb +7 -2
  59. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  60. data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
  61. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  62. data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
  63. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  64. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  65. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  66. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  67. data/app/services/collavre/topic_branch_service.rb +34 -26
  68. data/app/views/admin/shared/_tabs.html.erb +1 -0
  69. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  70. data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
  71. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  72. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  73. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  74. data/app/views/collavre/comments/_comment.html.erb +6 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
  76. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  77. data/app/views/collavre/creatives/index.html.erb +10 -2
  78. data/app/views/collavre/landing/show.html.erb +130 -0
  79. data/app/views/layouts/collavre/landing.html.erb +33 -0
  80. data/config/locales/admin.en.yml +4 -2
  81. data/config/locales/admin.ko.yml +4 -2
  82. data/config/locales/channels.en.yml +11 -0
  83. data/config/locales/channels.ko.yml +11 -0
  84. data/config/locales/comments.en.yml +2 -0
  85. data/config/locales/comments.ko.yml +2 -0
  86. data/config/locales/creatives.en.yml +9 -0
  87. data/config/locales/creatives.ko.yml +8 -0
  88. data/config/locales/integrations.en.yml +44 -0
  89. data/config/locales/integrations.ko.yml +44 -0
  90. data/config/locales/landing.en.yml +51 -0
  91. data/config/locales/landing.ko.yml +51 -0
  92. data/config/routes.rb +18 -0
  93. data/db/migrate/20260526000000_create_channels.rb +42 -0
  94. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  95. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  96. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  97. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  98. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  99. data/db/seeds.rb +19 -0
  100. data/lib/collavre/aws_credentials.rb +75 -0
  101. data/lib/collavre/engine.rb +51 -0
  102. data/lib/collavre/integration_settings/key_definition.rb +29 -0
  103. data/lib/collavre/integration_settings/registry.rb +55 -0
  104. data/lib/collavre/integration_settings/resolver.rb +71 -0
  105. data/lib/collavre/integration_settings.rb +46 -0
  106. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  107. data/lib/collavre/version.rb +1 -1
  108. data/lib/collavre.rb +3 -0
  109. 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
- description,
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
- policy = OrchestratorPolicy.find_or_initialize_by(
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 (from OrchestratorPolicy)
9
- # 2. Fallback: any AI agent with feedback permission on the creative
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 || ENV["OPENAI_API_KEY"]
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 || ENV["ANTHROPIC_API_KEY"]
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 || ENV["GEMINI_API_KEY"]
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: ENV["GOOGLE_CLIENT_ID"] || Rails.application.credentials.dig(:google, :client_id),
114
- client_secret: ENV["GOOGLE_CLIENT_SECRET"] || Rails.application.credentials.dig(:google, :client_secret),
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!)