collavre 0.15.0 → 0.20.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/code_highlight.css +13 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +65 -5
  4. data/app/assets/stylesheets/collavre/creatives.css +5 -2
  5. data/app/controllers/collavre/admin/settings_controller.rb +9 -0
  6. data/app/controllers/collavre/attachments_controller.rb +1 -1
  7. data/app/controllers/collavre/comment_read_pointers_controller.rb +2 -2
  8. data/app/controllers/collavre/comments/snapshots_controller.rb +2 -7
  9. data/app/controllers/collavre/comments_controller.rb +23 -25
  10. data/app/controllers/collavre/creatives_controller.rb +22 -11
  11. data/app/controllers/collavre/emails_controller.rb +2 -0
  12. data/app/controllers/collavre/google_auth_controller.rb +1 -1
  13. data/app/controllers/collavre/inbox_items_controller.rb +1 -1
  14. data/app/controllers/collavre/invites_controller.rb +3 -0
  15. data/app/controllers/collavre/tasks_controller.rb +1 -1
  16. data/app/controllers/collavre/topics_controller.rb +27 -85
  17. data/app/controllers/concerns/collavre/comments/comment_scoping.rb +3 -0
  18. data/app/controllers/concerns/collavre/creative_permission_guard.rb +32 -0
  19. data/app/controllers/concerns/collavre/integration_setup.rb +17 -0
  20. data/app/helpers/collavre/application_helper.rb +30 -2
  21. data/app/javascript/components/InlineLexicalEditor.jsx +7 -3
  22. data/app/javascript/components/creative_tree_row.js +21 -1
  23. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +34 -0
  24. data/app/javascript/controllers/comment_controller.js +19 -1
  25. data/app/javascript/controllers/comment_version_controller.js +2 -1
  26. data/app/javascript/controllers/comments/form_controller.js +7 -4
  27. data/app/javascript/controllers/comments/list_controller.js +43 -4
  28. data/app/javascript/controllers/comments/popup_controller.js +45 -12
  29. data/app/javascript/controllers/comments/presence_controller.js +8 -0
  30. data/app/javascript/controllers/comments/topics_controller.js +50 -31
  31. data/app/javascript/creatives/tree_renderer.js +1 -0
  32. data/app/javascript/lib/__tests__/chat_history.test.js +31 -0
  33. data/app/javascript/lib/chat_history.js +12 -2
  34. data/app/javascript/lib/utils/markdown.js +32 -1
  35. data/app/javascript/modules/command_args_form.js +8 -0
  36. data/app/javascript/modules/creative_row_editor.js +12 -17
  37. data/app/javascript/modules/integration_wizard.js +162 -0
  38. data/app/jobs/collavre/compress_job.rb +1 -0
  39. data/app/jobs/collavre/creative_broadcast_job.rb +4 -1
  40. data/app/jobs/collavre/merge_comments_job.rb +1 -0
  41. data/app/jobs/collavre/trigger_loop_check_job.rb +1 -0
  42. data/app/jobs/collavre/trigger_loop_verify_job.rb +1 -0
  43. data/app/models/collavre/calendar_event.rb +0 -4
  44. data/app/models/collavre/comment/broadcastable.rb +1 -1
  45. data/app/models/collavre/comment.rb +17 -2
  46. data/app/models/collavre/comment_snapshot.rb +0 -1
  47. data/app/models/collavre/creative/describable.rb +10 -1
  48. data/app/models/collavre/creative/realtime_broadcastable.rb +17 -5
  49. data/app/models/collavre/creative.rb +43 -1
  50. data/app/models/collavre/current.rb +1 -1
  51. data/app/models/collavre/inbox_item.rb +0 -4
  52. data/app/models/collavre/system_setting.rb +10 -1
  53. data/app/models/collavre/task.rb +17 -8
  54. data/app/models/collavre/user.rb +11 -1
  55. data/app/models/concerns/collavre/ai_agent_resolvable.rb +0 -8
  56. data/app/services/collavre/ai_agent/message_builder.rb +32 -15
  57. data/app/services/collavre/ai_agent/response_finalizer.rb +2 -1
  58. data/app/services/collavre/ai_agent/session_context_resolver.rb +50 -0
  59. data/app/services/collavre/ai_agent_service.rb +14 -2
  60. data/app/services/collavre/ai_client.rb +13 -0
  61. data/app/services/collavre/command_menu_service.rb +23 -3
  62. data/app/services/collavre/comments/command_processor.rb +1 -1
  63. data/app/services/collavre/comments/mcp_command.rb +27 -8
  64. data/app/services/collavre/comments/mcp_command_builder.rb +4 -3
  65. data/app/services/collavre/creatives/tree_builder.rb +3 -0
  66. data/app/services/collavre/google_calendar_service.rb +32 -6
  67. data/app/services/collavre/markdown_converter.rb +15 -20
  68. data/app/services/collavre/mcp_service.rb +4 -4
  69. data/app/services/collavre/orchestration/agent_orchestrator.rb +2 -2
  70. data/app/services/collavre/tools/creative_batch_service.rb +5 -1
  71. data/app/services/collavre/tools/cron_create_service.rb +9 -6
  72. data/app/services/collavre/topic_branch_service.rb +2 -2
  73. data/app/views/collavre/admin/settings/_system_tab.html.erb +11 -0
  74. data/app/views/collavre/comments/_comment.html.erb +7 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  76. data/config/locales/admin.en.yml +3 -0
  77. data/config/locales/admin.ko.yml +3 -0
  78. data/config/locales/comments.en.yml +6 -1
  79. data/config/locales/comments.ko.yml +6 -1
  80. data/db/migrate/20251126040752_add_description_to_creatives.rb +1 -1
  81. data/db/migrate/20260415000000_create_main_topics_for_existing_creatives.rb +69 -0
  82. data/lib/collavre/version.rb +1 -1
  83. metadata +21 -4
  84. data/app/jobs/collavre/permission_cache_cleanup_job.rb +0 -36
  85. data/app/models/collavre/variation.rb +0 -5
  86. data/app/services/collavre/creatives/path_exporter.rb +0 -131
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Integration Wizard — shared helpers for integration modal wizards.
3
+ *
4
+ * Provides common utilities used across Slack, Notion, and future
5
+ * integration wizard UIs: CSRF handling, error display, step management,
6
+ * OAuth popups, fetch wrappers, and modal close behavior.
7
+ *
8
+ * Usage:
9
+ * import { csrfToken, showError, ... } from 'collavre/modules/integration_wizard'
10
+ */
11
+
12
+ /**
13
+ * Read the Rails CSRF token from the meta tag.
14
+ * @returns {string|undefined}
15
+ */
16
+ export function csrfToken() {
17
+ return document.querySelector('meta[name="csrf-token"]')?.content;
18
+ }
19
+
20
+ /**
21
+ * Display an error message inside the given element.
22
+ * @param {HTMLElement} el - The error container element
23
+ * @param {string} message - Error text to display
24
+ */
25
+ export function showError(el, message) {
26
+ if (!message) return;
27
+ el.textContent = message;
28
+ el.style.display = 'block';
29
+ }
30
+
31
+ /**
32
+ * Hide and clear the error element.
33
+ * @param {HTMLElement} el - The error container element
34
+ */
35
+ export function clearError(el) {
36
+ el.style.display = 'none';
37
+ el.textContent = '';
38
+ }
39
+
40
+ /**
41
+ * Show/hide step panes by step name.
42
+ *
43
+ * @param {string} currentStep - The active step name
44
+ * @param {string[]} stepNames - All step names in order
45
+ * @param {string} prefix - DOM id prefix, e.g. 'slack-step' produces ids like 'slack-step-connect'
46
+ */
47
+ export function updateStepVisibility(currentStep, stepNames, prefix) {
48
+ stepNames.forEach(function (step) {
49
+ const el = document.getElementById(`${prefix}-${step}`);
50
+ if (!el) return;
51
+ el.style.display = (step === currentStep) ? 'block' : 'none';
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Open a centered OAuth popup window and poll for its closure.
57
+ *
58
+ * @param {string} windowName - Name for window.open (e.g. 'slack-auth-window')
59
+ * @param {Object} options
60
+ * @param {number} [options.width=600] - Popup width
61
+ * @param {number} [options.height=700] - Popup height
62
+ * @param {string} [options.url=''] - URL to open (pass '' if submitting a form to the window)
63
+ * @param {Function} [options.onClose] - Callback invoked when the popup is closed
64
+ * @returns {Window|null} The opened window reference
65
+ */
66
+ export function openOAuthPopup(windowName, options = {}) {
67
+ const width = options.width || 600;
68
+ const height = options.height || 700;
69
+ const left = (screen.width - width) / 2;
70
+ const top = (screen.height - height) / 2;
71
+ const url = options.url || '';
72
+
73
+ const authWindow = window.open(
74
+ url,
75
+ windowName,
76
+ `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
77
+ );
78
+
79
+ if (authWindow && options.onClose) {
80
+ const checkClosed = setInterval(() => {
81
+ if (authWindow.closed) {
82
+ clearInterval(checkClosed);
83
+ setTimeout(() => options.onClose(), 1000);
84
+ }
85
+ }, 1000);
86
+ }
87
+
88
+ return authWindow;
89
+ }
90
+
91
+ /**
92
+ * Perform a fetch request with CSRF token and JSON headers.
93
+ *
94
+ * @param {string} url - Request URL
95
+ * @param {Object} [options={}]
96
+ * @param {string} [options.method='GET'] - HTTP method
97
+ * @param {Object} [options.body] - Body payload (will be JSON-stringified)
98
+ * @param {Object} [options.headers] - Additional headers (merged with defaults)
99
+ * @returns {Promise<Response>}
100
+ */
101
+ export function fetchWithCsrf(url, options = {}) {
102
+ const method = options.method || 'GET';
103
+ const headers = {
104
+ 'X-CSRF-Token': csrfToken(),
105
+ 'Accept': 'application/json',
106
+ ...options.headers,
107
+ };
108
+
109
+ // Include Content-Type for requests with a body
110
+ if (options.body) {
111
+ headers['Content-Type'] = 'application/json';
112
+ }
113
+
114
+ const fetchOptions = { method, headers };
115
+ if (options.body) {
116
+ fetchOptions.body = JSON.stringify(options.body);
117
+ }
118
+
119
+ return fetch(url, fetchOptions);
120
+ }
121
+
122
+ /**
123
+ * Hide a list of DOM elements (set display to 'none') and optionally clear their content.
124
+ *
125
+ * @param {Array<{el: HTMLElement|null, clear?: boolean}>} items
126
+ * Each item has an `el` (may be null) and an optional `clear` flag.
127
+ * When `clear` is true, textContent or innerHTML is also reset.
128
+ */
129
+ export function resetElements(items) {
130
+ items.forEach(function ({ el, clear }) {
131
+ if (!el) return;
132
+ el.style.display = 'none';
133
+ if (clear) {
134
+ if ('innerHTML' in el && el.tagName !== 'INPUT') {
135
+ el.textContent = '';
136
+ }
137
+ }
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Set up common modal close behavior: close button, backdrop click, and Escape key.
143
+ *
144
+ * @param {HTMLElement} modal - The modal overlay element
145
+ * @param {HTMLElement} closeBtn - The close button element
146
+ * @param {Function} onClose - Callback to execute on close (e.g. resetWizard)
147
+ */
148
+ export function setupModalClose(modal, closeBtn, onClose) {
149
+ if (closeBtn) {
150
+ closeBtn.addEventListener('click', function () {
151
+ modal.style.display = 'none';
152
+ onClose();
153
+ });
154
+ }
155
+
156
+ modal.addEventListener('click', function (e) {
157
+ if (e.target === modal) {
158
+ modal.style.display = 'none';
159
+ onClose();
160
+ }
161
+ });
162
+ }
@@ -49,6 +49,7 @@ module Collavre
49
49
  model: agent.llm_model,
50
50
  system_prompt: system_prompt,
51
51
  llm_api_key: agent.llm_api_key || agent.creator&.llm_api_key,
52
+ gateway_url: agent.gateway_url.presence || agent.creator&.gateway_url,
52
53
  context: {
53
54
  creative: creative,
54
55
  user: agent,
@@ -233,8 +233,11 @@ module Collavre
233
233
 
234
234
  svg = raw.dup
235
235
  if options[:class]
236
- svg.sub!(/<svg\b/, %(<svg class="#{options[:class]}"))
236
+ escaped_class = ERB::Util.html_escape(options[:class])
237
+ svg.sub!(/<svg\b/, %(<svg class="#{escaped_class}"))
237
238
  end
239
+ # Safe: SVG source is a static asset file bundled with the app;
240
+ # user-supplied class attribute is escaped above via html_escape.
238
241
  svg.html_safe # rubocop:disable Rails/OutputSafety
239
242
  end
240
243
 
@@ -48,6 +48,7 @@ module Collavre
48
48
  model: agent.llm_model,
49
49
  system_prompt: SYSTEM_PROMPT,
50
50
  llm_api_key: agent.llm_api_key || agent.creator&.llm_api_key,
51
+ gateway_url: agent.gateway_url.presence || agent.creator&.gateway_url,
51
52
  context: {
52
53
  creative: creative,
53
54
  user: agent,
@@ -233,6 +233,7 @@ module Collavre
233
233
  model: verifier.llm_model,
234
234
  system_prompt: LLM_FALLBACK_SYSTEM_PROMPT,
235
235
  llm_api_key: verifier.llm_api_key || verifier.creator&.llm_api_key,
236
+ gateway_url: verifier.gateway_url.presence || verifier.creator&.gateway_url,
236
237
  context: {}
237
238
  )
238
239
 
@@ -151,6 +151,7 @@ module Collavre
151
151
  model: verifier.llm_model,
152
152
  system_prompt: VERIFY_SYSTEM_PROMPT,
153
153
  llm_api_key: verifier.llm_api_key || verifier.creator&.llm_api_key,
154
+ gateway_url: verifier.gateway_url.presence || verifier.creator&.gateway_url,
154
155
  context: {}
155
156
  )
156
157
 
@@ -9,10 +9,6 @@ module Collavre
9
9
 
10
10
  after_commit :delete_google_event, on: :destroy
11
11
 
12
- def synced_to_google?
13
- google_event_id.present?
14
- end
15
-
16
12
  private
17
13
 
18
14
  def delete_google_event
@@ -27,7 +27,7 @@ module Collavre
27
27
  pointers = CommentReadPointer.where(user_id: user_ids, creative: origin).index_by(&:user_id)
28
28
  present_user_ids = CommentPresenceStore.list(origin.id)
29
29
 
30
- public_count = origin.comments.where(private: false).count
30
+ public_count = origin.comments.public_only.count
31
31
  private_counts = origin.comments
32
32
  .where(private: true, user_id: user_ids)
33
33
  .group(:user_id)
@@ -13,9 +13,12 @@ module Collavre
13
13
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
14
14
  belongs_to :approver, class_name: Collavre.configuration.user_class_name, optional: true
15
15
  belongs_to :action_executed_by, class_name: Collavre.configuration.user_class_name, optional: true
16
+ belongs_to :task, class_name: "Collavre::Task", optional: true
16
17
  belongs_to :topic, class_name: "Collavre::Topic", optional: true
17
18
  belongs_to :quoted_comment, class_name: "Collavre::Comment", optional: true
18
19
 
20
+ scope :public_only, -> { where(private: false) }
21
+
19
22
  scope :visible_to, ->(user) {
20
23
  where(
21
24
  "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
@@ -49,6 +52,7 @@ module Collavre
49
52
 
50
53
  before_validation :use_origin_creative
51
54
  before_validation :assign_default_user, on: :create
55
+ before_validation :assign_main_topic, on: :create
52
56
  before_save :apply_link_previews, if: :should_apply_link_previews?
53
57
  after_create_commit :dispatch_to_orchestration
54
58
  after_create_commit :resume_trigger_loop_if_awaiting
@@ -104,8 +108,10 @@ module Collavre
104
108
  end
105
109
 
106
110
  def cancel_pending_tasks
107
- # Cancel tasks triggered by this comment
108
- Task.where(status: %w[pending running queued]).each do |task|
111
+ # Cancel tasks triggered by this comment (no creative_id scoping —
112
+ # CommentMoveService can change comment.creative_id without updating
113
+ # existing tasks, so scoping would miss moved-comment tasks)
114
+ Task.where(status: %w[pending running queued]).find_each do |task|
109
115
  if task.trigger_event_payload&.dig("comment", "id") == id
110
116
  task.update!(status: "cancelled")
111
117
  end
@@ -133,6 +139,7 @@ module Collavre
133
139
  return unless user_id # nil user = system message
134
140
  return if user&.ai_user? # AI replies use A2aDispatcher, not this callback
135
141
  return unless creative
142
+ return if creative.inbox?
136
143
 
137
144
  SystemEvents::Dispatcher.dispatch("comment_created", dispatch_payload)
138
145
  rescue StandardError => e
@@ -205,6 +212,14 @@ module Collavre
205
212
  )
206
213
  end
207
214
 
215
+ def assign_main_topic
216
+ return if topic_id.present?
217
+ return unless creative
218
+
219
+ fallback = user || Collavre.current_user || creative.user
220
+ self.topic = creative.main_topic(fallback_user: fallback)
221
+ end
222
+
208
223
  def use_origin_creative
209
224
  return unless creative
210
225
  self.creative = creative.effective_origin
@@ -13,7 +13,6 @@ module Collavre
13
13
  validates :comments_data, presence: true
14
14
 
15
15
  scope :restorable, -> { where(restored_at: nil) }
16
- scope :restored, -> { where.not(restored_at: nil) }
17
16
 
18
17
  def restorable?
19
18
  restored_at.nil?
@@ -6,6 +6,7 @@ module Collavre
6
6
  included do
7
7
  validates :description, presence: true, unless: -> { origin_id.present? }
8
8
  validate :description_cannot_change_if_has_origin, on: :update
9
+ validate :description_cannot_change_if_github_source, on: :update
9
10
 
10
11
  before_save :sanitize_description_html
11
12
  after_destroy_commit :purge_description_attachments
@@ -60,7 +61,7 @@ module Collavre
60
61
  blob.purge
61
62
  rescue ActiveRecord::RecordNotFound, ActiveSupport::MessageVerifier::InvalidSignature
62
63
  Rails.logger.warn("Creative##{id}: could not find blob for signed_id=#{signed_id}")
63
- rescue => e
64
+ rescue StandardError => e
64
65
  Rails.logger.error("Creative##{id}: failed to purge blob #{signed_id}: #{e.message}")
65
66
  end
66
67
  end
@@ -82,6 +83,14 @@ module Collavre
82
83
  errors.add(:description, "cannot be changed directly when linked to an origin")
83
84
  end
84
85
  end
86
+
87
+ def description_cannot_change_if_github_source
88
+ return unless will_save_change_to_description?
89
+ return unless github_markdown?
90
+ return if @skip_github_validation
91
+
92
+ errors.add(:description, "cannot be changed directly for GitHub synced content")
93
+ end
85
94
  end
86
95
  end
87
96
  end
@@ -27,7 +27,9 @@ module Collavre
27
27
  # Skip broadcast when only progress changed (cascade from update_parent_progress).
28
28
  # The original creative's broadcast already includes ancestor progress in its payload,
29
29
  # so receivers update parent rows without needing separate broadcasts per ancestor.
30
- return if progress_only_change?
30
+ # Exception: MCP requests must always broadcast because the browser has no HTTP
31
+ # response to update from — WebSocket is the only delivery channel.
32
+ return if progress_only_change? && !Collavre::Current.mcp_request
31
33
 
32
34
  enqueue_broadcast(:updated, broadcast_node_payload)
33
35
  rescue StandardError => e
@@ -58,11 +60,10 @@ module Collavre
58
60
  def broadcast_creative_destroyed
59
61
  return if @_destroy_broadcast_users.blank?
60
62
 
61
- current_user_id = Collavre.current_user&.id
62
63
  CreativeBroadcastJob.perform_later(
63
64
  id,
64
65
  "destroyed",
65
- current_user_id: current_user_id,
66
+ current_user_id: broadcast_excludable_user_id,
66
67
  payload: @_destroy_payload,
67
68
  options: {
68
69
  destroy_user_ids: @_destroy_broadcast_users.map(&:id),
@@ -134,11 +135,13 @@ module Collavre
134
135
 
135
136
  # Enqueue broadcast as a background job to avoid blocking the request cycle.
136
137
  # Payload is built synchronously (needs fresh DB state), then delivery is async.
138
+ # For MCP requests, current_user_id is nil so the job broadcasts to ALL users
139
+ # including the requester (whose browser relies solely on WebSocket updates).
137
140
  def enqueue_broadcast(action, payload)
138
141
  CreativeBroadcastJob.perform_later(
139
142
  id,
140
143
  action.to_s,
141
- current_user_id: Collavre.current_user&.id,
144
+ current_user_id: broadcast_excludable_user_id,
142
145
  payload: payload
143
146
  )
144
147
  end
@@ -153,7 +156,7 @@ module Collavre
153
156
  CreativeBroadcastJob.perform_later(
154
157
  creative_ids,
155
158
  "batch_created",
156
- current_user_id: Collavre.current_user&.id
159
+ current_user_id: broadcast_excludable_user_id
157
160
  )
158
161
  end
159
162
 
@@ -251,6 +254,15 @@ module Collavre
251
254
  Rails.logger.error "[CreativeBroadcast] Error finding users: #{e.message}"
252
255
  []
253
256
  end
257
+ # Returns nil for MCP requests so broadcast includes all users;
258
+ # returns current_user's ID for web requests so the requester is excluded.
259
+ def self.broadcast_excludable_user_id
260
+ Collavre::Current.mcp_request ? nil : Collavre.current_user&.id
261
+ end
262
+
263
+ def broadcast_excludable_user_id
264
+ RealtimeBroadcastable.broadcast_excludable_user_id
265
+ end
254
266
  end
255
267
  end
256
268
  end
@@ -13,7 +13,7 @@ module Collavre
13
13
  after_save :touch_subtree_on_move, if: :saved_change_to_parent_id?
14
14
  after_save :fire_drop_trigger_on_move, if: :saved_change_to_parent_id?
15
15
  after_create_commit :fire_drop_trigger_on_create, if: :parent_id?
16
-
16
+ after_create :create_main_topic
17
17
 
18
18
  include Linkable
19
19
  include Permissible
@@ -34,11 +34,19 @@ module Collavre
34
34
  scope :inboxes, -> { where("data->>'kind' = 'inbox'") }
35
35
 
36
36
  SYSTEM_TOPIC_NAME = "System"
37
+ MAIN_TOPIC_NAME = "Main"
38
+ CONTENT_TOPIC_NAME = "Content"
37
39
 
38
40
  def inbox?
39
41
  data&.dig("kind") == "inbox"
40
42
  end
41
43
 
44
+ attr_accessor :skip_github_validation
45
+
46
+ def github_markdown?
47
+ data.is_a?(Hash) && data.dig("source", "type") == "github_markdown"
48
+ end
49
+
42
50
  # Find or create the "System" topic for this inbox creative.
43
51
  # Re-creates it if the user deletes it.
44
52
  def system_topic(fallback_user: user)
@@ -47,6 +55,18 @@ module Collavre
47
55
  end
48
56
  end
49
57
 
58
+ def main_topic(fallback_user: user)
59
+ topics.find_or_create_by!(name: MAIN_TOPIC_NAME) do |topic|
60
+ topic.user = fallback_user
61
+ end
62
+ end
63
+
64
+ def content_topic(fallback_user: user)
65
+ topics.find_or_create_by!(name: CONTENT_TOPIC_NAME) do |topic|
66
+ topic.user = fallback_user
67
+ end
68
+ end
69
+
50
70
  # Find or create the inbox creative for a given user.
51
71
  # Places it as a root creative (no parent) owned by the user.
52
72
  def self.inbox_for(user)
@@ -124,6 +144,11 @@ module Collavre
124
144
  Array(data&.dig("context_ids"))
125
145
  end
126
146
 
147
+ # Returns the directly-configured disabled context IDs for this creative.
148
+ def disabled_context_ids
149
+ Array(data&.dig("disabled_context_ids"))
150
+ end
151
+
127
152
  # Returns the effective context IDs: own + inherited from ancestors (deduplicated).
128
153
  def effective_context_ids(visited_ids = Set.new)
129
154
  return [] if visited_ids.include?(id)
@@ -134,6 +159,16 @@ module Collavre
134
159
  (own + parent_ctx).uniq
135
160
  end
136
161
 
162
+ # Returns the effective disabled context IDs: own + inherited from ancestors (deduplicated).
163
+ def effective_disabled_context_ids(visited_ids = Set.new)
164
+ return [] if visited_ids.include?(id)
165
+
166
+ visited_ids.add(id)
167
+ own = disabled_context_ids
168
+ parent_disabled = parent&.effective_disabled_context_ids(visited_ids) || []
169
+ (own + parent_disabled).uniq
170
+ end
171
+
137
172
  # Returns context creatives (excludes self to avoid duplication).
138
173
  def context_creatives
139
174
  ids = effective_context_ids
@@ -287,5 +322,12 @@ module Collavre
287
322
 
288
323
  DropTriggerJob.perform_later(parent_id, id)
289
324
  end
325
+
326
+ def create_main_topic
327
+ effective_user = user || Collavre.current_user
328
+ return unless effective_user
329
+
330
+ topics.create!(name: MAIN_TOPIC_NAME, user: effective_user)
331
+ end
290
332
  end
291
333
  end
@@ -5,8 +5,8 @@ module Collavre
5
5
  attribute :session
6
6
  attribute :creative_share_cache
7
7
  attribute :mcp_tool_approval_required
8
+ attribute :mcp_request
8
9
  attribute :user
9
-
10
10
  def user
11
11
  super || session&.user
12
12
  end
@@ -22,10 +22,6 @@ module Collavre
22
22
 
23
23
  scope :new_items, -> { where(state: "new") }
24
24
 
25
- def read?
26
- state == "read"
27
- end
28
-
29
25
  def localized_message(locale: I18n.locale)
30
26
  msg =
31
27
  if message_key.present?
@@ -33,6 +33,9 @@ module Collavre
33
33
  DEFAULT_LIGHT_THEME_ID = nil
34
34
  DEFAULT_DARK_THEME_ID = nil
35
35
 
36
+ # Default LLM request timeout (seconds)
37
+ DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 1800
38
+
36
39
  # Default creative display settings (system-wide)
37
40
  DEFAULT_DISPLAY_LEVEL = 6
38
41
  DEFAULT_COMPLETION_MARK = ""
@@ -57,7 +60,7 @@ module Collavre
57
60
  password_reset_rate_limit password_reset_rate_period_minutes
58
61
  api_rate_limit api_rate_period_minutes auth_providers_disabled
59
62
  creatives_login_required home_page_path default_light_theme_id default_dark_theme_id
60
- display_level completion_mark
63
+ display_level completion_mark llm_request_timeout_seconds
61
64
  ].each { |k| Rails.cache.delete("system_setting:#{k}") }
62
65
  end
63
66
 
@@ -181,5 +184,11 @@ module Collavre
181
184
  value = cached_value("completion_mark")
182
185
  value.nil? ? DEFAULT_COMPLETION_MARK : value
183
186
  end
187
+
188
+ # LLM request timeout (seconds)
189
+ def self.llm_request_timeout_seconds
190
+ value = cached_value("llm_request_timeout_seconds")&.to_i
191
+ value.nil? || value < 30 ? DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS : value
192
+ end
184
193
  end
185
194
  end
@@ -7,10 +7,12 @@ module Collavre
7
7
  belongs_to :parent_task, class_name: "Collavre::Task", optional: true
8
8
  has_many :sub_tasks, class_name: "Collavre::Task", foreign_key: :parent_task_id, dependent: :destroy
9
9
  belongs_to :creative, class_name: "Collavre::Creative", optional: true
10
+ has_one :reply_comment, class_name: "Collavre::Comment", foreign_key: :task_id, dependent: :nullify
10
11
 
11
12
  validates :name, presence: true
12
13
 
13
14
  after_update_commit :check_trigger_loop_completion, if: :trigger_loop_candidate?
15
+ after_update_commit :broadcast_stop_button_removal, if: :became_terminal?
14
16
 
15
17
  scope :running_for_topic, ->(topic_id, creative_id = nil) {
16
18
  rel = where(topic_id: topic_id, status: "running")
@@ -32,14 +34,6 @@ module Collavre
32
34
  false
33
35
  end
34
36
 
35
- def workflow_parent?
36
- workflow_state.present? && parent_task_id.nil?
37
- end
38
-
39
- def all_sub_tasks_done?
40
- sub_tasks.where.not(status: %w[done cancelled]).empty?
41
- end
42
-
43
37
  private
44
38
 
45
39
  def trigger_loop_candidate?
@@ -56,6 +50,21 @@ module Collavre
56
50
  trigger_topic_id.nil? || trigger_topic_id == topic_id
57
51
  end
58
52
 
53
+ def became_terminal?
54
+ saved_change_to_attribute?("status") && status.in?(%w[done cancelled failed])
55
+ end
56
+
57
+ def broadcast_stop_button_removal
58
+ comment = reply_comment
59
+ return unless comment
60
+
61
+ comment.broadcast_replace_to(
62
+ [ comment.creative, :comments ],
63
+ partial: "collavre/comments/comment",
64
+ locals: { comment: comment, streaming: false }
65
+ )
66
+ end
67
+
59
68
  def check_trigger_loop_completion
60
69
  loop_config = creative.data&.dig("trigger", "loop")
61
70
  cooldown = (loop_config&.dig("cooldown_seconds") || 10).to_i
@@ -77,7 +77,8 @@ module Collavre
77
77
  "chat_history" => 50,
78
78
  "chat_history_size" => 100_000,
79
79
  "creative_children_level" => nil
80
- }
80
+ },
81
+ "session" => { "enabled" => nil }
81
82
  }.freeze
82
83
 
83
84
  # Default creative children level when agent_conf doesn't specify one
@@ -112,6 +113,15 @@ module Collavre
112
113
  level.nil? ? DEFAULT_CREATIVE_CHILDREN_LEVEL : level.to_i
113
114
  end
114
115
 
116
+ # Whether this agent uses stateful sessions (incremental messaging).
117
+ # nil in agent_conf = auto-detect by vendor (openclaw → true).
118
+ def supports_session?
119
+ explicit = parsed_agent_conf.dig("session", "enabled")
120
+ return ActiveModel::Type::Boolean.new.cast(explicit) unless explicit.nil?
121
+
122
+ llm_vendor&.downcase == "openclaw"
123
+ end
124
+
115
125
  encrypts :llm_api_key, deterministic: false
116
126
  encrypts :google_access_token, deterministic: false
117
127
  encrypts :google_refresh_token, deterministic: false
@@ -30,13 +30,5 @@ module Collavre
30
30
  context["topic"] = { "id" => topic_id } if topic_id.present?
31
31
  context
32
32
  end
33
-
34
- def default_vendor
35
- "google"
36
- end
37
-
38
- def default_model
39
- "gemini-3-flash-preview"
40
- end
41
33
  end
42
34
  end