collavre 0.8.2 → 0.9.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/activity_logs.css +1 -1
  3. data/app/assets/stylesheets/collavre/comment_versions.css +10 -6
  4. data/app/assets/stylesheets/collavre/comments_popup.css +60 -2
  5. data/app/assets/stylesheets/collavre/creatives.css +7 -4
  6. data/app/assets/stylesheets/collavre/dark_mode.css +25 -5
  7. data/app/controllers/collavre/comments_controller.rb +2 -1
  8. data/app/controllers/collavre/topics_controller.rb +80 -3
  9. data/app/controllers/concerns/collavre/comments/approval_actions.rb +5 -0
  10. data/app/controllers/concerns/collavre/comments/batch_operations.rb +32 -0
  11. data/app/javascript/controllers/comment_version_controller.js +5 -1
  12. data/app/javascript/controllers/comments/contexts_controller.js +118 -3
  13. data/app/javascript/controllers/comments/list_controller.js +67 -1
  14. data/app/javascript/controllers/comments/popup_controller.js +143 -10
  15. data/app/javascript/controllers/comments/presence_controller.js +22 -0
  16. data/app/javascript/controllers/comments/topics_controller.js +105 -6
  17. data/app/javascript/controllers/creatives/expansion_controller.js +8 -0
  18. data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
  19. data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
  20. data/app/javascript/modules/creative_row_editor.js +13 -0
  21. data/app/jobs/collavre/compress_job.rb +24 -25
  22. data/app/jobs/collavre/merge_comments_job.rb +79 -0
  23. data/app/models/collavre/topic.rb +29 -0
  24. data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
  25. data/app/services/collavre/comments/topic_command.rb +27 -13
  26. data/app/views/collavre/comments/_comments_popup.html.erb +5 -1
  27. data/config/locales/comments.en.yml +11 -1
  28. data/config/locales/comments.ko.yml +11 -1
  29. data/config/routes.rb +2 -0
  30. data/lib/collavre/version.rb +1 -1
  31. metadata +3 -1
@@ -1,5 +1,7 @@
1
1
  module Collavre
2
2
  class CompressJob < ApplicationJob
3
+ include AiAgentResolvable
4
+
3
5
  queue_as :default
4
6
 
5
7
  def perform(creative_id, topic_id, user_id, extra_prompt = nil)
@@ -31,23 +33,37 @@ module Collavre
31
33
  system_prompt += "\n\nAdditional instruction from the user: #{extra_prompt}"
32
34
  end
33
35
 
34
- # Find an AI agent on this creative, or use default config
35
- agent = find_ai_agent(creative)
36
+ # Find an AI agent on this creative (no fallback agent is required)
37
+ agent = resolve_ai_agent(creative, topic_id)
38
+
39
+ unless agent
40
+ error_msg = I18n.t("collavre.comments.compress_command.no_agent")
41
+ creative.comments.create!(user: user, topic_id: topic_id, content: "⚠️ #{error_msg}")
42
+ Rails.logger.error("[CompressJob] No AI agent found for creative #{creative_id}, topic #{topic_id}")
43
+ return
44
+ end
36
45
 
37
46
  client = AiClient.new(
38
- vendor: agent&.llm_vendor || default_vendor,
39
- model: agent&.llm_model || default_model,
47
+ vendor: agent.llm_vendor,
48
+ model: agent.llm_model,
40
49
  system_prompt: system_prompt,
41
- llm_api_key: agent&.llm_api_key || agent&.creator&.llm_api_key
50
+ llm_api_key: agent.llm_api_key || agent.creator&.llm_api_key,
51
+ context: {
52
+ creative: creative,
53
+ user: agent,
54
+ topic_id: topic_id
55
+ }
42
56
  )
43
57
 
44
58
  summary = String.new
45
- client.chat([ { role: "user", text: conversation } ]) do |delta|
59
+ result = client.chat([ { role: "user", text: conversation } ]) do |delta|
46
60
  summary << delta
47
61
  end
48
62
 
49
- if summary.blank?
50
- Rails.logger.error("[CompressJob] AI returned empty summary for topic #{topic_id}")
63
+ # AiClient returns nil on error (but still yields error text as delta).
64
+ # Check both: return value must be truthy AND content must be non-blank.
65
+ if result.nil? || summary.blank?
66
+ Rails.logger.error("[CompressJob] AI failed for topic #{topic_id}")
51
67
  return
52
68
  end
53
69
 
@@ -71,22 +87,5 @@ module Collavre
71
87
  rescue ActiveRecord::RecordNotFound => e
72
88
  Rails.logger.error("[CompressJob] Record not found: #{e.message}")
73
89
  end
74
-
75
- private
76
-
77
- def find_ai_agent(creative)
78
- # Look for an AI agent with access to this creative
79
- creative.effective_origin.all_shared_users(:feedback)
80
- .map(&:user)
81
- .find(&:ai_user?)
82
- end
83
-
84
- def default_vendor
85
- "google"
86
- end
87
-
88
- def default_model
89
- "gemini-3-flash-preview"
90
- end
91
90
  end
92
91
  end
@@ -0,0 +1,79 @@
1
+ module Collavre
2
+ class MergeCommentsJob < ApplicationJob
3
+ include AiAgentResolvable
4
+
5
+ queue_as :default
6
+
7
+ SYSTEM_PROMPT = <<~PROMPT.freeze
8
+ You are merging multiple chat messages into a single coherent message.
9
+ Synthesize the content from all messages, preserving all important information,
10
+ decisions, action items, and context.
11
+ Do not add commentary about the merge process itself.
12
+ Respond in the same language as the original messages.
13
+ Use markdown formatting for readability.
14
+ PROMPT
15
+
16
+ def perform(creative_id, comment_ids, user_id) # rubocop:disable Lint/UnusedMethodArgument -- user_id reserved for future audit/notification use
17
+ creative = Creative.find(creative_id)
18
+
19
+ # Fetch comments in chronological order
20
+ comments = creative.comments
21
+ .where(id: comment_ids)
22
+ .order(created_at: :asc)
23
+ .includes(:user)
24
+ .to_a
25
+
26
+ return if comments.size < 2
27
+
28
+ target_comment = comments.first
29
+ topic_id = target_comment.topic_id
30
+
31
+ # Build conversation text
32
+ conversation = comments.map do |c|
33
+ author = c.user&.name || I18n.t("collavre.comments.anonymous")
34
+ "#{author}: #{c.content}"
35
+ end.join("\n\n")
36
+
37
+ # Resolve AI agent (same as /compress — agent is required)
38
+ agent = resolve_ai_agent(creative, topic_id)
39
+
40
+ unless agent
41
+ Rails.logger.error("[MergeCommentsJob] No AI agent found for creative #{creative_id}")
42
+ return
43
+ end
44
+
45
+ client = AiClient.new(
46
+ vendor: agent.llm_vendor,
47
+ model: agent.llm_model,
48
+ system_prompt: SYSTEM_PROMPT,
49
+ llm_api_key: agent.llm_api_key || agent.creator&.llm_api_key,
50
+ context: {
51
+ creative: creative,
52
+ user: agent,
53
+ topic_id: topic_id
54
+ }
55
+ )
56
+
57
+ merged_content = String.new
58
+ result = client.chat([ { role: "user", text: conversation } ]) do |delta|
59
+ merged_content << delta
60
+ end
61
+
62
+ # AiClient returns nil on error (but still yields error text as delta).
63
+ # Check both: return value must be truthy AND content must be non-blank.
64
+ if result.nil? || merged_content.blank?
65
+ Rails.logger.error("[MergeCommentsJob] AI failed for comments #{comment_ids}")
66
+ return
67
+ end
68
+
69
+ # Update the first comment and delete the rest atomically
70
+ remaining_ids = comments[1..].map(&:id)
71
+ ActiveRecord::Base.transaction do
72
+ target_comment.update!(content: merged_content)
73
+ creative.comments.where(id: remaining_ids).destroy_all
74
+ end
75
+ rescue ActiveRecord::RecordNotFound => e
76
+ Rails.logger.error("[MergeCommentsJob] Record not found: #{e.message}")
77
+ end
78
+ end
79
+ end
@@ -17,6 +17,35 @@ module Collavre
17
17
 
18
18
  default_scope { order(:position) }
19
19
 
20
+ # Returns the primary agent User for this topic (from orchestration policy)
21
+ def primary_agent
22
+ policy = OrchestratorPolicy.find_by(
23
+ policy_type: "arbitration",
24
+ scope_type: "Topic",
25
+ scope_id: id
26
+ )
27
+ return nil unless policy&.config&.dig("primary_agent_id")
28
+
29
+ User.find_by(id: policy.config["primary_agent_id"])
30
+ end
31
+
32
+ # Sets or replaces the primary agent for this topic
33
+ def set_primary_agent!(agent)
34
+ policy = OrchestratorPolicy.find_or_initialize_by(
35
+ policy_type: "arbitration",
36
+ scope_type: "Topic",
37
+ scope_id: id
38
+ )
39
+ policy.update!(
40
+ config: {
41
+ "strategy" => "primary_first",
42
+ "primary_agent_id" => agent.id
43
+ },
44
+ priority: 10,
45
+ enabled: true
46
+ )
47
+ end
48
+
20
49
  def archived?
21
50
  archived_at.present?
22
51
  end
@@ -0,0 +1,42 @@
1
+ module Collavre
2
+ module AiAgentResolvable
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
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
10
+ def resolve_ai_agent(creative, topic_id)
11
+ if topic_id.present?
12
+ context = build_ai_agent_policy_context(creative, topic_id)
13
+ resolver = Orchestration::PolicyResolver.new(context)
14
+ primary_id = resolver.primary_agent_id
15
+
16
+ if primary_id.present?
17
+ agent = User.find_by(id: primary_id)
18
+ return agent if agent&.ai_user?
19
+ end
20
+ end
21
+
22
+ creative.effective_origin.all_shared_users(:feedback)
23
+ .map(&:user)
24
+ .find(&:ai_user?)
25
+ end
26
+
27
+ def build_ai_agent_policy_context(creative, topic_id)
28
+ context = {}
29
+ context["creative"] = { "id" => creative.id }
30
+ context["topic"] = { "id" => topic_id } if topic_id.present?
31
+ context
32
+ end
33
+
34
+ def default_vendor
35
+ "google"
36
+ end
37
+
38
+ def default_model
39
+ "gemini-3-flash-preview"
40
+ end
41
+ end
42
+ end
@@ -72,29 +72,43 @@ module Collavre
72
72
 
73
73
  if primary_agent
74
74
  set_primary_agent(topic, primary_agent)
75
+ broadcast_topic_created(topic, primary_agent)
75
76
  I18n.t("collavre.comments.topic_command.created_with_agent",
76
77
  name: topic.name,
77
78
  agent: primary_agent.name)
78
79
  else
80
+ broadcast_topic_created(topic)
79
81
  I18n.t("collavre.comments.topic_command.created", name: topic.name)
80
82
  end
81
83
  end
82
84
  end
83
85
 
86
+ def broadcast_topic_created(topic, agent = nil)
87
+ data = { action: "created", topic: topic.slice(:id, :name), user_id: user.id }
88
+ if agent
89
+ data[:topic][:primary_agent] = {
90
+ id: agent.id,
91
+ name: agent.display_name,
92
+ avatar_url: resolve_avatar_url(agent)
93
+ }
94
+ end
95
+ TopicsChannel.broadcast_to(creative, data)
96
+ end
97
+
98
+ def resolve_avatar_url(agent)
99
+ if agent.avatar.attached?
100
+ Rails.application.routes.url_helpers.rails_blob_url(
101
+ agent.avatar, only_path: true
102
+ )
103
+ elsif agent.avatar_url.present?
104
+ agent.avatar_url
105
+ else
106
+ ActionController::Base.helpers.asset_path("default_avatar.svg")
107
+ end
108
+ end
109
+
84
110
  def set_primary_agent(topic, agent)
85
- policy = OrchestratorPolicy.find_or_initialize_by(
86
- policy_type: "arbitration",
87
- scope_type: "Topic",
88
- scope_id: topic.id
89
- )
90
- policy.update!(
91
- config: {
92
- "strategy" => "primary_first",
93
- "primary_agent_id" => agent.id
94
- },
95
- priority: 10,
96
- enabled: true
97
- )
111
+ topic.set_primary_agent!(agent)
98
112
  end
99
113
  end
100
114
  end
@@ -29,12 +29,15 @@
29
29
  data-voice-stop-text="<%= t('collavre.comments.voice_stop') %>"
30
30
  data-move-no-selection-text="<%= t('collavre.comments.move_no_selection') %>"
31
31
  data-move-error-text="<%= t('collavre.comments.move_error') %>"
32
+ data-selection-select-all-text="<%= t('collavre.comments.select_all') %>"
32
33
  data-selection-count-text="<%= t('collavre.comments.selection_count') %>"
33
34
  data-selection-delete-text="<%= t('collavre.comments.selection_delete') %>"
34
35
  data-selection-move-text="<%= t('collavre.comments.selection_move') %>"
35
36
  data-selection-topic-move-text="<%= t('collavre.comments.selection_topic_move') %>"
36
37
  data-selection-close-text="<%= t('collavre.comments.selection_close') %>"
37
38
  data-selection-drag-hint-text="<%= t('collavre.comments.selection_drag_hint') %>"
39
+ data-selection-merge-text="<%= t('collavre.comments.selection_merge') %>"
40
+ data-merge-confirm-text="<%= t('collavre.comments.merge_confirm') %>"
38
41
  data-batch-delete-confirm-text="<%= t('collavre.comments.batch_delete_confirm') %>"
39
42
  data-topic-search-placeholder-text="<%= t('collavre.comments.topic_search_placeholder') %>"
40
43
  data-topic-main-text="<%= t('collavre.comments.topic_main') %>"
@@ -79,7 +82,8 @@
79
82
  <div id="comment-contexts" data-comments--contexts-target="list" class="comment-contexts-list" style="display:none;"
80
83
  data-inherited-label="<%= t('collavre.contexts.inherited_label', default: 'Inherited from parent') %>"
81
84
  data-self-context-label="<%= t('collavre.contexts.self_context_label', default: 'Current creative context') %>"
82
- data-navigate-label="<%= t('collavre.contexts.navigate_label', default: 'Go to creative') %>"></div>
85
+ data-navigate-label="<%= t('collavre.contexts.navigate_label', default: 'Go to creative') %>"
86
+ data-action="dragover->comments--contexts#handleExternalDragOver drop->comments--contexts#handleExternalDrop dragleave->comments--contexts#handleExternalDragLeave"></div>
83
87
  <div data-share-modal-target="container"></div>
84
88
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
85
89
  <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
@@ -8,6 +8,7 @@ en:
8
8
  archive: Archive
9
9
  unarchive: Restore
10
10
  archived_topics: "Archived topics (%{count})"
11
+ not_ai_agent: Only AI agents can be set as primary agent.
11
12
  move:
12
13
  no_target_permission: You don't have write permission on the target creative.
13
14
  duplicate_name: A topic named '%{name}' already exists in the target creative.
@@ -85,12 +86,20 @@ en:
85
86
  move_no_selection: Select at least one message to move.
86
87
  move_error: Unable to move messages.
87
88
  add_participant: Add user
88
- selection_count: "{count} selected"
89
+ select_all: All
90
+ selection_count: "{count}/{total} selected"
89
91
  selection_delete: Delete
90
92
  selection_move: Move
91
93
  selection_topic_move: Move to topic
92
94
  selection_close: Cancel
93
95
  selection_drag_hint: "You can also drag & drop to move to a topic"
96
+ selection_merge: Merge
97
+ merge_confirm: "Merge the selected messages into one? The first message will be updated and the rest will be deleted."
98
+ merge:
99
+ started: "⏳ Merging messages..."
100
+ minimum_required: "Select at least 2 messages to merge."
101
+ not_authorized: "You don't have permission to merge messages."
102
+ own_messages_only: "You can only merge your own messages or messages from your AI agents."
94
103
  batch_delete_confirm: Are you sure you want to delete the selected messages?
95
104
  batch_delete_no_selection: Select at least one message to delete.
96
105
  batch_delete_not_found: Some selected messages could not be found.
@@ -124,6 +133,7 @@ en:
124
133
  not_authorized: "You need write permission to compress a topic."
125
134
  topic_required: "The /compress command can only be used within a topic."
126
135
  nothing_to_compress: "No messages to compress in this topic."
136
+ no_agent: "No AI agent is available for this topic. Please assign an AI agent first."
127
137
  started: "⏳ Compressing topic messages..."
128
138
  summary_title: "Topic Summary — %{topic}"
129
139
  failed: "Compress failed"
@@ -8,6 +8,7 @@ ko:
8
8
  archive: 아카이브
9
9
  unarchive: 복원
10
10
  archived_topics: "아카이브된 토픽 (%{count}개)"
11
+ not_ai_agent: AI 에이전트만 Primary Agent로 설정할 수 있습니다.
11
12
  move:
12
13
  no_target_permission: 대상 크리에이티브에 대한 쓰기 권한이 없습니다.
13
14
  duplicate_name: "'%{name}' 토픽이 대상 크리에이티브에 이미 존재합니다."
@@ -82,12 +83,20 @@ ko:
82
83
  move_no_selection: 이동할 메시지를 선택해주세요.
83
84
  move_error: 메시지를 이동할 수 없습니다.
84
85
  add_participant: 사용자 추가
85
- selection_count: "{count}개 선택"
86
+ select_all: 전체
87
+ selection_count: "{count}/{total}개 선택"
86
88
  selection_delete: 삭제
87
89
  selection_move: 이동
88
90
  selection_topic_move: 토픽 이동
89
91
  selection_close: 취소
90
92
  selection_drag_hint: "드래그&드롭으로도 토픽 이동 가능"
93
+ selection_merge: 병합
94
+ merge_confirm: "선택한 메시지를 하나로 병합하시겠습니까? 첫 번째 메시지가 업데이트되고 나머지는 삭제됩니다."
95
+ merge:
96
+ started: "⏳ 메시지 병합 중..."
97
+ minimum_required: "병합하려면 2개 이상의 메시지를 선택하세요."
98
+ not_authorized: "메시지를 병합할 권한이 없습니다."
99
+ own_messages_only: "자신의 메시지 또는 자신의 AI 에이전트 메시지만 병합할 수 있습니다."
91
100
  batch_delete_confirm: 선택한 메시지를 삭제하시겠습니까?
92
101
  batch_delete_no_selection: 삭제할 메시지를 선택해주세요.
93
102
  batch_delete_not_found: 일부 선택한 메시지를 찾을 수 없습니다.
@@ -121,6 +130,7 @@ ko:
121
130
  not_authorized: "토픽을 요약하려면 쓰기 권한이 필요합니다."
122
131
  topic_required: "/compress 명령은 토픽 내에서만 사용할 수 있습니다."
123
132
  nothing_to_compress: "이 토픽에 요약할 메세지가 없습니다."
133
+ no_agent: "이 토픽에 사용할 수 있는 AI 에이전트가 없습니다. 먼저 AI 에이전트를 지정해 주세요."
124
134
  started: "⏳ 토픽 메세지를 요약하는 중..."
125
135
  summary_title: "토픽 요약 — %{topic}"
126
136
  failed: "요약 실패"
data/config/routes.rb CHANGED
@@ -59,6 +59,7 @@ Collavre::Engine.routes.draw do
59
59
  patch :move
60
60
  patch :archive
61
61
  patch :unarchive
62
+ patch :set_primary_agent
62
63
  end
63
64
  end
64
65
  resources :comments, only: [ :index, :create, :destroy, :show, :update ] do
@@ -82,6 +83,7 @@ Collavre::Engine.routes.draw do
82
83
  get :fullscreen
83
84
  post :move
84
85
  delete :batch_destroy
86
+ post :merge
85
87
  get :commands
86
88
  end
87
89
  end
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.8.2"
2
+ VERSION = "0.9.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -324,6 +324,7 @@ files:
324
324
  - app/jobs/collavre/cron_action_job.rb
325
325
  - app/jobs/collavre/cron_scheduler_job.rb
326
326
  - app/jobs/collavre/inbox_summary_job.rb
327
+ - app/jobs/collavre/merge_comments_job.rb
327
328
  - app/jobs/collavre/permission_cache_cleanup_job.rb
328
329
  - app/jobs/collavre/permission_cache_job.rb
329
330
  - app/jobs/collavre/push_notification_job.rb
@@ -372,6 +373,7 @@ files:
372
373
  - app/models/collavre/user_theme.rb
373
374
  - app/models/collavre/variation.rb
374
375
  - app/models/collavre/webauthn_credential.rb
376
+ - app/models/concerns/collavre/ai_agent_resolvable.rb
375
377
  - app/services/collavre/ai_agent/a2a_dispatcher.rb
376
378
  - app/services/collavre/ai_agent/agent_lifecycle_manager.rb
377
379
  - app/services/collavre/ai_agent/approval_handler.rb