collavre 0.5.0 → 0.7.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -1,11 +1,13 @@
1
1
  module Collavre
2
+ # Orchestrates AI agent execution for a task.
3
+ # Delegates specific concerns to specialized service objects:
4
+ # - AgentLifecycleManager: agent status, cancellation, heartbeats
5
+ # - ResponseStreamer: streaming content updates
6
+ # - ResponseFinalizer: comment finalization, review workflow
7
+ # - A2aDispatcher: agent-to-agent event dispatch
2
8
  class AiAgentService
3
- # Minimum interval (in seconds) between streaming updates to avoid excessive DB writes
4
- STREAM_THROTTLE_INTERVAL = 0.1
5
- # Minimum interval (in seconds) between cancellation checks to avoid excessive DB queries
6
- CANCEL_CHECK_INTERVAL = 1.0
7
- # Interval (in seconds) between agent_status heartbeats during streaming
8
- AGENT_STATUS_HEARTBEAT_INTERVAL = 3.0
9
+ # Compatibility alias for constants moved to AgentLifecycleManager
10
+ CANCEL_CHECK_INTERVAL = AiAgent::AgentLifecycleManager::CANCEL_CHECK_INTERVAL
9
11
 
10
12
  def initialize(task)
11
13
  @task = task
@@ -17,94 +19,47 @@ module Collavre
17
19
  Current.set(user: @agent) do
18
20
  log_action("start", { message: "Starting agent execution" })
19
21
 
20
- target_comment_id = @context.dig("comment", "id")
21
- @original_comment = target_comment_id ? Comment.find_by(id: target_comment_id) : nil
22
+ # Build context and messages
23
+ @original_comment = find_original_comment
24
+ messages = build_messages
25
+ log_action("prompt_generated", { messages: messages })
22
26
 
23
- messages = AiAgent::MessageBuilder.new(
24
- agent: @agent, context: @context, original_comment: @original_comment
25
- ).build
27
+ # Prepare rendering context and prompts
28
+ @creative = find_creative
29
+ rendering_context = prepare_rendering_context
30
+ system_prompt = render_system_prompt(rendering_context)
26
31
 
27
- log_action("prompt_generated", { messages: messages })
32
+ # Create placeholder comment if needed
33
+ @reply_comment = create_reply_comment_if_needed
28
34
 
29
- @response_content = ""
30
-
31
- rendering_context = @context.dup
32
- if @context.dig("creative", "id")
33
- creative = Creative.find_by(id: @context["creative"]["id"])
34
- rendering_context["creative"] = creative.as_json if creative
35
- end
36
-
37
- agent_context = build_agent_context(creative)
38
- rendering_context.merge!(agent_context)
39
-
40
- rendered_system_prompt = AiSystemPromptRenderer.new(
41
- template: @agent.system_prompt,
42
- context: rendering_context
43
- ).render
44
-
45
- collaboration_prompt = build_collaboration_prompt(creative)
46
- rendered_system_prompt = "#{rendered_system_prompt}\n\n#{collaboration_prompt}" if collaboration_prompt.present?
47
-
48
- @reply_comment = nil
49
-
50
- if @original_comment
51
- @reply_comment = @original_comment.creative.comments.create!(
52
- content: Comment::STREAMING_PLACEHOLDER_CONTENT,
53
- user: @agent,
54
- topic_id: @original_comment.topic_id
55
- )
56
- end
57
-
58
- @creative = @context.dig("creative", "id") ? Creative.find_by(id: @context["creative"]["id"]) : nil
59
-
60
- broadcast_agent_status("thinking")
61
-
62
- client = AiClient.new(
63
- vendor: @agent.llm_vendor,
64
- model: @agent.llm_model,
65
- system_prompt: rendered_system_prompt,
66
- llm_api_key: @agent.llm_api_key,
67
- context: {
68
- creative: @creative,
69
- user: @agent,
70
- task: @task,
71
- comment: @reply_comment || @original_comment
72
- }
35
+ # Initialize lifecycle manager
36
+ @lifecycle_manager = AiAgent::AgentLifecycleManager.new(
37
+ task: @task,
38
+ agent: @agent,
39
+ creative: @creative
73
40
  )
74
41
 
75
- last_broadcast_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
76
- last_heartbeat_at = last_broadcast_at
77
- @last_cancel_check_at = last_broadcast_at
78
-
79
- client.chat(messages, tools: @agent.tools || []) do |delta|
80
- check_cancelled!
81
- @response_content += delta
42
+ # Initialize response streamer
43
+ @streamer = AiAgent::ResponseStreamer.new(
44
+ reply_comment: @reply_comment,
45
+ creative: @creative
46
+ )
82
47
 
83
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
- if @reply_comment && (now - last_broadcast_at) >= STREAM_THROTTLE_INTERVAL
85
- @reply_comment.update_column(:content, @response_content)
86
- @reply_comment.broadcast_update_to(
87
- [ @reply_comment.creative, :comments ],
88
- partial: "collavre/comments/comment",
89
- locals: { comment: @reply_comment, streaming: true }
90
- )
91
- last_broadcast_at = now
92
- end
48
+ @lifecycle_manager.broadcast_status("thinking")
93
49
 
94
- # Periodic agent_status heartbeat so clients know we're still streaming
95
- if (now - last_heartbeat_at) >= AGENT_STATUS_HEARTBEAT_INTERVAL
96
- broadcast_agent_status("streaming")
97
- last_heartbeat_at = now
98
- end
99
- end
50
+ # Execute AI chat with streaming
51
+ client = build_ai_client(system_prompt)
52
+ stream_response(client, messages)
100
53
 
101
- log_action("completion", { response: @response_content })
54
+ log_action("completion", { response: @streamer.content })
102
55
 
103
- finalize_response
56
+ # Finalize and dispatch (skip A2A for review-flow updates)
57
+ finalized_comment = finalize_response
58
+ dispatch_a2a(finalized_comment) unless @finalizer&.review_flow
104
59
 
105
- broadcast_agent_status("idle")
60
+ @lifecycle_manager.broadcast_status("idle")
106
61
 
107
- @response_content
62
+ @streamer.content
108
63
  end
109
64
  rescue ApprovalPendingError => e
110
65
  AiAgent::ApprovalHandler.new(
@@ -119,132 +74,124 @@ module Collavre
119
74
 
120
75
  private
121
76
 
122
- def check_cancelled!
123
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
- return if (now - @last_cancel_check_at) < CANCEL_CHECK_INTERVAL
77
+ def find_original_comment
78
+ target_comment_id = @context.dig("comment", "id")
79
+ target_comment_id ? Comment.find_by(id: target_comment_id) : nil
80
+ end
81
+
82
+ def build_messages
83
+ AiAgent::MessageBuilder.new(
84
+ agent: @agent,
85
+ context: @context,
86
+ original_comment: @original_comment
87
+ ).build
88
+ end
125
89
 
126
- @last_cancel_check_at = now
127
- raise CancelledError if @task.reload.status == "cancelled"
90
+ def find_creative
91
+ creative_id = @context.dig("creative", "id")
92
+ creative_id ? Creative.find_by(id: creative_id) : nil
128
93
  end
129
94
 
130
- def handle_cancelled
131
- if @reply_comment
132
- if @response_content.present?
133
- @reply_comment.content_will_change!
134
- @reply_comment.update!(content: @response_content)
135
- @reply_comment.broadcast_update_to(
136
- [ @reply_comment.creative, :comments ],
137
- partial: "collavre/comments/comment",
138
- locals: { comment: @reply_comment, streaming: false }
139
- )
140
- log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content, partial: true })
141
- reassociate_activity_logs(@original_comment, @reply_comment)
142
- else
143
- @reply_comment.destroy!
144
- end
95
+ def prepare_rendering_context
96
+ rendering_context = @context.dup
97
+ if @creative
98
+ rendering_context["creative"] = @creative.as_json
145
99
  end
146
100
 
147
- broadcast_agent_status("idle")
148
- log_action("cancelled", { message: "Task cancelled by user" })
101
+ agent_context = build_agent_context(@creative)
102
+ rendering_context.merge!(agent_context)
103
+ rendering_context
149
104
  end
150
105
 
151
- def finalize_response
152
- review_handler = AiAgent::ReviewHandler.new(@original_comment, @agent)
153
-
154
- if @reply_comment
155
- if @response_content.present?
156
- if @original_comment&.review_message? && review_handler.handle(@response_content, task: @task)
157
- review_handler.add_completion_reaction
158
- reassociate_activity_logs(@reply_comment, @original_comment.quoted_comment)
159
- @reply_comment.destroy!
160
- else
161
- @reply_comment.content_will_change!
162
- @reply_comment.update!(content: @response_content)
163
- @reply_comment.broadcast_update_to(
164
- [ @reply_comment.creative, :comments ],
165
- partial: "collavre/comments/comment",
166
- locals: { comment: @reply_comment, streaming: false }
167
- )
168
- log_action("reply_created", { comment_id: @reply_comment.id, content: @response_content })
169
- reassociate_activity_logs(@original_comment, @reply_comment)
170
- dispatch_a2a_if_needed
171
- end
172
- else
173
- @reply_comment.destroy!
174
- end
175
- elsif @context.dig("comment", "id") && @response_content.present?
176
- reply_to_comment(@context["comment"]["id"], @response_content)
106
+ def render_system_prompt(rendering_context)
107
+ rendered = AiSystemPromptRenderer.new(
108
+ template: @agent.system_prompt,
109
+ context: rendering_context
110
+ ).render
111
+
112
+ collaboration_prompt = build_collaboration_prompt(@creative)
113
+ if collaboration_prompt.present?
114
+ "#{rendered}\n\n#{collaboration_prompt}"
115
+ else
116
+ rendered
177
117
  end
178
118
  end
179
119
 
180
- def broadcast_agent_status(status, content: nil)
181
- return unless @creative
182
-
183
- CommentsPresenceChannel.broadcast_agent_status(
184
- @creative.effective_origin.id,
185
- status: status,
186
- agent_id: @agent.id,
187
- agent_name: @agent.display_name,
188
- task_id: @task.id,
189
- content: content,
190
- source_creative_id: @creative.id
120
+ def create_reply_comment_if_needed
121
+ return nil unless @original_comment
122
+
123
+ @original_comment.creative.comments.create!(
124
+ content: Comment::STREAMING_PLACEHOLDER_CONTENT,
125
+ user: @agent,
126
+ topic_id: @original_comment.topic_id
191
127
  )
192
128
  end
193
129
 
194
- def log_action(type, payload, result = nil)
195
- @task.task_actions.create!(
196
- action_type: type,
197
- payload: payload,
198
- result: result,
199
- status: "done"
130
+ def build_ai_client(system_prompt)
131
+ AiClient.new(
132
+ vendor: @agent.llm_vendor,
133
+ model: @agent.llm_model,
134
+ system_prompt: system_prompt,
135
+ llm_api_key: @agent.llm_api_key,
136
+ context: {
137
+ creative: @creative,
138
+ user: @agent,
139
+ task: @task,
140
+ comment: @reply_comment || @original_comment
141
+ }
200
142
  )
201
143
  end
202
144
 
203
- def reply_to_comment(comment_id, content)
204
- original_comment = Comment.find_by(id: comment_id)
205
- return unless original_comment
145
+ def stream_response(client, messages)
146
+ client.chat(messages, tools: @agent.tools || []) do |delta|
147
+ @lifecycle_manager.check_cancelled!
148
+ @streamer.append(delta)
149
+ @lifecycle_manager.heartbeat_if_needed
150
+ end
151
+ end
206
152
 
207
- @reply_comment = original_comment.creative.comments.create!(
208
- content: content,
209
- user: @agent,
210
- topic_id: original_comment.topic_id
153
+ def finalize_response
154
+ @finalizer = AiAgent::ResponseFinalizer.new(
155
+ task: @task,
156
+ agent: @agent,
157
+ original_comment: @original_comment,
158
+ reply_comment: @reply_comment,
159
+ response_content: @streamer.content
211
160
  )
212
-
213
- log_action("reply_created", { comment_id: @reply_comment.id, content: content })
214
- reassociate_activity_logs(original_comment, @reply_comment)
215
- dispatch_a2a_if_needed
161
+ @finalizer.finalize
216
162
  end
217
163
 
218
- def dispatch_a2a_if_needed
219
- return unless @reply_comment&.content.present?
164
+ def dispatch_a2a(finalized_comment)
165
+ return unless finalized_comment
220
166
 
221
- # Find all mentioned AI agents anywhere in the response (not just at the start)
222
- mentioned_agents = MentionParser.resolve_all_users(@reply_comment.content).select(&:ai_user?)
223
- return if mentioned_agents.empty?
167
+ dispatcher = AiAgent::A2aDispatcher.new(
168
+ agent: @agent,
169
+ reply_comment: finalized_comment,
170
+ context: @context
171
+ )
172
+ dispatcher.dispatch
173
+ end
224
174
 
225
- creative = @reply_comment.creative
175
+ def handle_cancelled
176
+ @lifecycle_manager.handle_cancelled(
177
+ reply_comment: @reply_comment,
178
+ response_content: @streamer.content
179
+ )
226
180
 
227
- mentioned_agents.each do |mentioned_user|
228
- if creative
229
- context = { "creative" => { "id" => creative.id }, "topic" => { "id" => @reply_comment.topic_id } }
230
- Orchestration::LoopBreaker.new(context).record_interaction(@agent.id, mentioned_user.id, creative.id)
231
- end
181
+ # Reassociate activity logs if there was partial content
182
+ if @reply_comment && @streamer.content_present?
183
+ ActivityLog.where(comment: @original_comment, user: @agent)
184
+ .update_all(comment_id: @reply_comment.id)
232
185
  end
233
-
234
- # Dispatch event — downstream Matcher will route to the correct agent(s)
235
- SystemEvents::Dispatcher.dispatch("comment_created", {
236
- comment: { id: @reply_comment.id, content: @reply_comment.content, user_id: @reply_comment.user_id },
237
- creative: { id: creative&.id, description: creative&.description },
238
- topic: { id: @reply_comment.topic_id },
239
- chat: { content: @reply_comment.content }
240
- })
241
- rescue StandardError => e
242
- Rails.logger.error("[AiAgentService] A2A dispatch failed: #{e.message}")
243
186
  end
244
187
 
245
- def reassociate_activity_logs(from_comment, to_comment)
246
- ActivityLog.where(comment: from_comment, user: @agent)
247
- .update_all(comment_id: to_comment.id)
188
+ def log_action(type, payload, result = nil)
189
+ @task.task_actions.create!(
190
+ action_type: type,
191
+ payload: payload,
192
+ result: result,
193
+ status: "done"
194
+ )
248
195
  end
249
196
 
250
197
  def build_agent_context(creative)
@@ -8,12 +8,16 @@ module Collavre
8
8
  - Respond in the asker's language (prefer the latest user message). Keep code and error messages in their original form.
9
9
  PROMPT
10
10
 
11
+ attr_reader :last_input_tokens, :last_output_tokens
12
+
11
13
  def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, context: {})
12
14
  @vendor = vendor
13
15
  @model = model
14
16
  @system_prompt = system_prompt
15
17
  @llm_api_key = llm_api_key
16
18
  @context = context
19
+ @last_input_tokens = 0
20
+ @last_output_tokens = 0
17
21
  end
18
22
 
19
23
  def chat(contents, tools: [], &block)
@@ -90,6 +94,8 @@ module Collavre
90
94
  yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
91
95
  nil
92
96
  ensure
97
+ @last_input_tokens = input_tokens || 0
98
+ @last_output_tokens = output_tokens || 0
93
99
  log_interaction(
94
100
  messages: conversation.messages.to_a || Array(contents),
95
101
  tools: conversation.tools.to_a,
@@ -195,7 +195,7 @@ module Collavre
195
195
  def default_client
196
196
  AiClient.new(
197
197
  vendor: "google",
198
- model: "gemini-2.5-flash",
198
+ model: "gemini-3-flash-preview",
199
199
  system_prompt: nil
200
200
  )
201
201
  end
@@ -26,6 +26,25 @@ module Collavre
26
26
  label: "/topic",
27
27
  description: I18n.t("collavre.comments.command_menu.topic_description"),
28
28
  args: I18n.t("collavre.comments.command_menu.topic_args")
29
+ },
30
+ {
31
+ name: "work",
32
+ label: "/work",
33
+ description: I18n.t("collavre.comments.command_menu.work_description"),
34
+ args: I18n.t("collavre.comments.command_menu.work_args")
35
+ },
36
+ {
37
+ name: "compress",
38
+ label: "/compress",
39
+ description: I18n.t("collavre.comments.command_menu.compress_description"),
40
+ args: I18n.t("collavre.comments.command_menu.compress_args")
41
+ },
42
+ {
43
+ name: "creative",
44
+ label: "/creative",
45
+ type: "popup",
46
+ popup_type: "creative_picker",
47
+ description: I18n.t("collavre.comments.command_menu.creative_description")
29
48
  }
30
49
  ]
31
50
  end
@@ -28,7 +28,9 @@ module Collavre
28
28
  def static_commands
29
29
  [
30
30
  Collavre::Comments::CalendarCommand.new(comment: comment, user: user),
31
- Collavre::Comments::TopicCommand.new(comment: comment, user: user)
31
+ Collavre::Comments::TopicCommand.new(comment: comment, user: user),
32
+ Collavre::Comments::WorkCommand.new(comment: comment, user: user),
33
+ Collavre::Comments::CompressCommand.new(comment: comment, user: user)
32
34
  ]
33
35
  end
34
36
 
@@ -0,0 +1,75 @@
1
+ module Collavre
2
+ module Comments
3
+ class CompressCommand
4
+ COMMAND_PATTERN = /\A\/compress\b/i.freeze
5
+
6
+ SYSTEM_PROMPT = <<~PROMPT.freeze
7
+ You are summarizing a conversation thread in a project management tool.
8
+ Preserve key decisions, action items, important context, and any conclusions.
9
+ Be concise but thorough. Respond in the same language as the conversation.
10
+ Use markdown formatting for readability.
11
+ PROMPT
12
+
13
+ def initialize(comment:, user:)
14
+ @comment = comment
15
+ @user = user
16
+ @creative = comment.creative
17
+ end
18
+
19
+ def call
20
+ return unless compress_command?
21
+
22
+ validate!
23
+ authorize!
24
+ enqueue_compress_job
25
+ rescue StandardError => e
26
+ Rails.logger.error("Compress command failed: #{e.message}")
27
+ e.message
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :comment, :user, :creative
33
+
34
+ def compress_command?
35
+ comment.content.to_s.strip.match?(COMMAND_PATTERN)
36
+ end
37
+
38
+ def extra_prompt
39
+ content = comment.content.to_s.strip
40
+ rest = content.sub(COMMAND_PATTERN, "").strip
41
+ rest.presence
42
+ end
43
+
44
+ def validate!
45
+ unless comment.topic_id.present?
46
+ raise I18n.t("collavre.comments.compress_command.topic_required")
47
+ end
48
+
49
+ # The /compress command comment may not be saved yet, so count existing non-compress comments
50
+ compress_pattern = /\A\/compress\b/i
51
+ existing_count = creative.comments.where(topic_id: comment.topic_id)
52
+ .reject { |c| c.content.to_s.strip.match?(compress_pattern) }.size
53
+ if existing_count < 2
54
+ raise I18n.t("collavre.comments.compress_command.nothing_to_compress")
55
+ end
56
+ end
57
+
58
+ def authorize!
59
+ unless creative.has_permission?(user, :write)
60
+ raise I18n.t("collavre.comments.compress_command.not_authorized")
61
+ end
62
+ end
63
+
64
+ def enqueue_compress_job
65
+ CompressJob.perform_later(
66
+ creative.id,
67
+ comment.topic_id,
68
+ user.id,
69
+ extra_prompt
70
+ )
71
+ I18n.t("collavre.comments.compress_command.started")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,115 @@
1
+ module Collavre
2
+ module Comments
3
+ module Concerns
4
+ module WorkflowSupport
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ # Filter creatives that already have active tasks or are completed
10
+ # Returns array of creative IDs to skip
11
+ def filter_active_or_completed(creative_ids)
12
+ return [] if creative_ids.empty?
13
+
14
+ # Skip creatives with active tasks (done/failed/cancelled allow re-work)
15
+ active = Task.where(creative_id: creative_ids)
16
+ .where(status: %w[running queued pending pending_approval])
17
+ .pluck(:creative_id)
18
+
19
+ # Skip creatives already completed (progress >= 1.0)
20
+ completed = Creative.where(id: creative_ids)
21
+ .where("progress >= 1.0")
22
+ .pluck(:id)
23
+
24
+ (active + completed).uniq
25
+ end
26
+
27
+ # DFS traversal of creative tree
28
+ def dfs_traverse(node, &block)
29
+ yield node
30
+ node.children.order(:sequence).each { |child| dfs_traverse(child, &block) }
31
+ end
32
+
33
+ # Find the original user who triggered the workflow
34
+ # Looks in: comment author → stored user_id → creative owner → agent
35
+ def find_original_user_from_task(task)
36
+ comment_id = task.trigger_event_payload&.dig("comment", "id")
37
+ comment = Comment.find_by(id: comment_id) if comment_id
38
+
39
+ # Try: comment author → stored user_id → creative owner → agent (last resort)
40
+ user = comment&.user
41
+ user ||= User.find_by(id: task.trigger_event_payload&.dig("comment", "user_id"))
42
+ user ||= task.creative&.user
43
+ user ||= task.agent
44
+
45
+ Rails.logger.warn("[WorkflowSupport] Using fallback user (#{user&.class}:#{user&.id}) for task ##{task.id}") unless comment&.user
46
+
47
+ user
48
+ end
49
+
50
+ # Post a notice comment to the original creative thread
51
+ def post_workflow_notice(task, content)
52
+ comment_id = task.trigger_event_payload&.dig("comment", "id")
53
+ unless comment_id
54
+ Rails.logger.warn("[WorkflowSupport] post_workflow_notice: no comment_id in trigger_event_payload: #{task.trigger_event_payload.inspect}")
55
+ return
56
+ end
57
+
58
+ original_comment = Comment.find_by(id: comment_id)
59
+ unless original_comment
60
+ Rails.logger.warn("[WorkflowSupport] post_workflow_notice: comment #{comment_id} not found")
61
+ return
62
+ end
63
+
64
+ Rails.logger.info("[WorkflowSupport] post_workflow_notice: posting to creative #{original_comment.creative_id}")
65
+ original_comment.creative.comments.create!(
66
+ content: content,
67
+ user: task.agent,
68
+ topic_id: original_comment.topic_id
69
+ )
70
+ end
71
+
72
+ # Resolve workflow context: if it's a creative ID, render that creative's markdown
73
+ def resolve_workflow_context_from_task(task, state)
74
+ # Use cached rendered context if available (avoids re-rendering in job context)
75
+ cached = state["rendered_workflow_context"]
76
+ return cached if cached.present?
77
+
78
+ context_text = task.workflow_context.to_s.strip
79
+ creative_id = context_text[/\A\d+\z/]
80
+
81
+ resolved = if creative_id
82
+ context_creative = Creative.find_by(id: creative_id)
83
+ if context_creative
84
+ markdown = render_creative_markdown_for_task(task, context_creative)
85
+ markdown.presence || context_text
86
+ else
87
+ context_text
88
+ end
89
+ else
90
+ context_text
91
+ end
92
+
93
+ # Cache for subsequent sub-tasks
94
+ state["rendered_workflow_context"] = resolved
95
+ task.update!(workflow_state: state)
96
+
97
+ resolved
98
+ end
99
+
100
+ # Render creative tree as markdown with agent's depth settings
101
+ def render_creative_markdown_for_task(task, creative)
102
+ max_depth = task.agent.creative_children_level + 1
103
+ result = ApplicationController.helpers.render_creative_tree_markdown(
104
+ [ creative ], 1, true, max_depth: max_depth
105
+ )
106
+ Rails.logger.info("[WorkflowSupport] render_creative_markdown: creative=#{creative.id} result_length=#{result&.length}")
107
+ result
108
+ rescue StandardError => e
109
+ Rails.logger.error("[WorkflowSupport] render_creative_markdown FAILED: creative=#{creative.id} error=#{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
110
+ nil
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end