collavre 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -10,6 +10,11 @@ module Creatives
|
|
|
10
10
|
# than its entire match set.
|
|
11
11
|
PERMISSION_FILTER_BATCH = 200
|
|
12
12
|
|
|
13
|
+
# Page size for the top-nav "Chats" feed (the comment filter). The list is
|
|
14
|
+
# unbounded by nature (it grows with every commented creative), so it is
|
|
15
|
+
# paginated instead of materialized whole.
|
|
16
|
+
COMMENT_CHATS_PER_PAGE = 30
|
|
17
|
+
|
|
13
18
|
Result = Struct.new(
|
|
14
19
|
:creatives,
|
|
15
20
|
:parent_creative,
|
|
@@ -18,6 +23,7 @@ module Creatives
|
|
|
18
23
|
:overall_progress,
|
|
19
24
|
:allowed_creative_ids,
|
|
20
25
|
:progress_map,
|
|
26
|
+
:pagination,
|
|
21
27
|
keyword_init: true
|
|
22
28
|
)
|
|
23
29
|
|
|
@@ -38,7 +44,8 @@ module Creatives
|
|
|
38
44
|
shared_list: shared_list,
|
|
39
45
|
overall_progress: result[:overall_progress] || 0,
|
|
40
46
|
allowed_creative_ids: result[:allowed_ids],
|
|
41
|
-
progress_map: result[:progress_map]
|
|
47
|
+
progress_map: result[:progress_map],
|
|
48
|
+
pagination: result[:pagination]
|
|
42
49
|
)
|
|
43
50
|
end
|
|
44
51
|
|
|
@@ -83,29 +90,46 @@ module Creatives
|
|
|
83
90
|
|
|
84
91
|
result = pipeline.call
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
# The comment ("Chats") path still returns a pagination object on an empty
|
|
94
|
+
# match so the client gets consistent metadata; other filters short-circuit.
|
|
95
|
+
return empty_result if result.matched_ids.empty? && params[:comment] != "true"
|
|
87
96
|
|
|
88
97
|
# For search/comment filters, return matched items directly (flat results sorted by relevance)
|
|
89
98
|
# Unless search_mode=tree is specified, which returns tree structure instead
|
|
90
99
|
# For other filters (tags, progress), return tree start nodes
|
|
91
100
|
if (params[:search].present? || params[:comment] == "true") && params[:search_mode] != "tree"
|
|
92
|
-
|
|
93
|
-
.order(:sequence)
|
|
94
|
-
.select { |c| readable?(c) }
|
|
101
|
+
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
95
102
|
|
|
96
|
-
# Sort by comment updated_at for comment filter
|
|
97
103
|
if params[:comment] == "true"
|
|
98
|
-
|
|
104
|
+
# Top-nav "Chats" list: one page of commented creatives, newest chat
|
|
105
|
+
# first. The matched set is permission-filtered in a single batch and
|
|
106
|
+
# the recency ordering is computed by the DB (LIMIT/OFFSET over a
|
|
107
|
+
# grouped MAX(comments.updated_at)), so response cost is a fixed page
|
|
108
|
+
# regardless of how many chats exist. This replaces an unbounded load
|
|
109
|
+
# that ran a per-row readable? query and a per-row MAX(updated_at)
|
|
110
|
+
# aggregate (two N+1s that grew with the chat count).
|
|
111
|
+
page = comment_chats_page(result.matched_ids)
|
|
112
|
+
{
|
|
113
|
+
creatives: page[:creatives],
|
|
114
|
+
parent: parent,
|
|
115
|
+
allowed_ids: result.allowed_ids,
|
|
116
|
+
overall_progress: result.overall_progress,
|
|
117
|
+
progress_map: result.progress_map,
|
|
118
|
+
pagination: page[:pagination]
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
matched_creatives = Creative.where(id: result.matched_ids.to_a)
|
|
122
|
+
.order(:sequence)
|
|
123
|
+
.select { |c| readable?(c) }
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
creatives: matched_creatives,
|
|
127
|
+
parent: parent,
|
|
128
|
+
allowed_ids: result.allowed_ids,
|
|
129
|
+
overall_progress: result.overall_progress,
|
|
130
|
+
progress_map: result.progress_map
|
|
131
|
+
}
|
|
99
132
|
end
|
|
100
|
-
|
|
101
|
-
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
102
|
-
{
|
|
103
|
-
creatives: matched_creatives,
|
|
104
|
-
parent: parent,
|
|
105
|
-
allowed_ids: result.allowed_ids,
|
|
106
|
-
overall_progress: result.overall_progress,
|
|
107
|
-
progress_map: result.progress_map
|
|
108
|
-
}
|
|
109
133
|
else
|
|
110
134
|
start_nodes = determine_start_nodes(result.allowed_ids)
|
|
111
135
|
parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
|
|
@@ -119,6 +143,51 @@ module Creatives
|
|
|
119
143
|
end
|
|
120
144
|
end
|
|
121
145
|
|
|
146
|
+
# One page of the "Chats" feed from the comment-matched id set, ordered by
|
|
147
|
+
# most-recent comment. Permission filtering is batched (PermissionFilter does
|
|
148
|
+
# a handful of IN queries, not one per row) and the recency sort + windowing
|
|
149
|
+
# happen in SQL, so neither cost scales with the total number of chats.
|
|
150
|
+
def comment_chats_page(matched_ids)
|
|
151
|
+
per_page = comment_chats_per_page
|
|
152
|
+
page = comment_chats_page_number
|
|
153
|
+
offset = (page - 1) * per_page
|
|
154
|
+
|
|
155
|
+
readable_ids = PermissionFilter.new(user: user).readable_ids(matched_ids.to_a)
|
|
156
|
+
return { creatives: [], pagination: pagination_meta(page, false) } if readable_ids.empty?
|
|
157
|
+
|
|
158
|
+
# Fetch one extra row to detect whether a further page exists. id is the
|
|
159
|
+
# tiebreaker so the order is total and stable across page boundaries.
|
|
160
|
+
page_ids = Creative.where(id: readable_ids)
|
|
161
|
+
.joins(:comments)
|
|
162
|
+
.group("creatives.id")
|
|
163
|
+
.order(Arel.sql("MAX(comments.updated_at) DESC, creatives.id DESC"))
|
|
164
|
+
.offset(offset)
|
|
165
|
+
.limit(per_page + 1)
|
|
166
|
+
.pluck("creatives.id")
|
|
167
|
+
|
|
168
|
+
has_more = page_ids.size > per_page
|
|
169
|
+
page_ids = page_ids.first(per_page)
|
|
170
|
+
|
|
171
|
+
by_id = Creative.where(id: page_ids).index_by(&:id)
|
|
172
|
+
creatives = page_ids.filter_map { |id| by_id[id] }
|
|
173
|
+
|
|
174
|
+
{ creatives: creatives, pagination: pagination_meta(page, has_more) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def comment_chats_per_page
|
|
178
|
+
per = params[:per_page].presence&.to_i || COMMENT_CHATS_PER_PAGE
|
|
179
|
+
per <= 0 || per > 100 ? COMMENT_CHATS_PER_PAGE : per
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def comment_chats_page_number
|
|
183
|
+
page = params[:page].presence&.to_i || 1
|
|
184
|
+
page < 1 ? 1 : page
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def pagination_meta(page, has_more)
|
|
188
|
+
{ page: page, next_page: has_more ? page + 1 : nil, has_more: has_more }
|
|
189
|
+
end
|
|
190
|
+
|
|
122
191
|
def simple_search?
|
|
123
192
|
params[:search].present? && params[:simple].present? &&
|
|
124
193
|
params[:search_mode] != "tree" && params[:comment] != "true"
|
|
@@ -195,7 +195,8 @@ module Creatives
|
|
|
195
195
|
progress: creative.progress,
|
|
196
196
|
origin_id: creative.origin_id,
|
|
197
197
|
content_type: effective.data&.dig("content_type"),
|
|
198
|
-
markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil
|
|
198
|
+
markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil,
|
|
199
|
+
markdown_editor: effective.data&.dig("editor")
|
|
199
200
|
}
|
|
200
201
|
end
|
|
201
202
|
|
|
@@ -64,6 +64,11 @@ module Collavre
|
|
|
64
64
|
content: @comment.content,
|
|
65
65
|
user: @comment.user,
|
|
66
66
|
quoted_comment: original,
|
|
67
|
+
# quoted_comment is set only for linkage to the message being answered.
|
|
68
|
+
# Mark it as a "question" so review_message? stays false — otherwise the
|
|
69
|
+
# agent's response would update the quoted comment in place (the review
|
|
70
|
+
# flow) instead of posting a new reply. See Comment#review_message?.
|
|
71
|
+
review_type: :question,
|
|
67
72
|
private: false
|
|
68
73
|
)
|
|
69
74
|
|
|
@@ -48,12 +48,22 @@ module Collavre
|
|
|
48
48
|
source
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
# Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
|
|
51
|
+
# Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter).
|
|
52
|
+
# hardbreaks: a single newline becomes <br>, mirroring the JS renderer (marked breaks:true)
|
|
53
|
+
# and the canonical markdown_source, which stores consecutive rich-editor lines one-per-line
|
|
54
|
+
# rather than separated by a blank line.
|
|
55
|
+
#
|
|
56
|
+
# syntax_highlighter: nil disables comrak's built-in syntect highlighter, which otherwise
|
|
57
|
+
# bakes a fixed base16-ocean.dark theme into the stored HTML as inline `style=` attributes
|
|
58
|
+
# (e.g. `<pre style="background-color:#2b303b"><span style="color:#bf616a">`). Those inline
|
|
59
|
+
# styles override our theme-aware code_highlight.css palette and are blind to light/dark mode.
|
|
60
|
+
# Disabling it emits a plain `<pre lang="ruby"><code>…</code></pre>`; the client re-tokenizes
|
|
61
|
+
# with highlight.js so the rendered creative matches the editor (and respects the theme).
|
|
52
62
|
html = Commonmarker.to_html(input, options: {
|
|
53
63
|
parse: { smart: true },
|
|
54
|
-
render: { unsafe: true },
|
|
64
|
+
render: { unsafe: true, hardbreaks: true },
|
|
55
65
|
extension: { table: true, strikethrough: true, autolink: true, tasklist: true, tagfilter: true }
|
|
56
|
-
})
|
|
66
|
+
}, plugins: { syntax_highlighter: nil })
|
|
57
67
|
|
|
58
68
|
html.strip!
|
|
59
69
|
html
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Mobile
|
|
5
|
+
# Turns an approval/permission comment or an agent reply into a SHORT,
|
|
6
|
+
# decision-oriented line of speech. Deterministic (no LLM) so the hot path
|
|
7
|
+
# stays fast: it extracts the tool name / human description from the
|
|
8
|
+
# permission action payload and renders a localized, spoken-friendly summary.
|
|
9
|
+
class EventSummarizer
|
|
10
|
+
def initialize(locale:)
|
|
11
|
+
@locale = (locale.presence || I18n.default_locale).to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def approval_summary(comment:, label:)
|
|
15
|
+
payload = parse_action(comment)
|
|
16
|
+
I18n.with_locale(@locale) do
|
|
17
|
+
detail =
|
|
18
|
+
payload&.dig("description").presence ||
|
|
19
|
+
payload&.dig("tool_name").presence ||
|
|
20
|
+
I18n.t("collavre.mobile.summary.a_tool")
|
|
21
|
+
I18n.t("collavre.mobile.summary.approval", label: label, detail: flatten(detail))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def flatten(text)
|
|
28
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_action(comment)
|
|
32
|
+
return nil if comment.action.blank?
|
|
33
|
+
|
|
34
|
+
JSON.parse(comment.action)
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -45,7 +45,12 @@ module Collavre
|
|
|
45
45
|
|
|
46
46
|
Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
|
|
47
47
|
.where("content LIKE ?", "⏳%")
|
|
48
|
-
.
|
|
48
|
+
.find_each do |notice|
|
|
49
|
+
# System promotion, not user abandonment: do not let the destroy
|
|
50
|
+
# callback cancel other still-queued waiters in this topic.
|
|
51
|
+
notice.suppress_waiter_cancellation = true
|
|
52
|
+
notice.destroy
|
|
53
|
+
end
|
|
49
54
|
end
|
|
50
55
|
private_class_method :cleanup_waiting_notices!
|
|
51
56
|
|
|
@@ -184,17 +189,38 @@ module Collavre
|
|
|
184
189
|
creative = Creative.find_by(id: creative_id)
|
|
185
190
|
return unless creative
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
reason_text = I18n.t(
|
|
189
|
-
"collavre.orchestration.waiting_reasons.#{reason_key}",
|
|
190
|
-
default: reason_key.to_s.humanize
|
|
191
|
-
)
|
|
192
|
+
reason_text = waiting_reason_text(decision[:reason] || :unknown, topic_id, creative_id)
|
|
192
193
|
|
|
193
194
|
creative.comments.create!(
|
|
194
195
|
content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
|
|
195
196
|
topic_id: topic_id,
|
|
196
197
|
private: false,
|
|
197
|
-
skip_default_user: true
|
|
198
|
+
skip_default_user: true,
|
|
199
|
+
# Only :deferred queues a topic waiter; mark it so its stop button can
|
|
200
|
+
# target the blocker. :delayed (busy / rate_limited) notices stay false.
|
|
201
|
+
topic_concurrency_defer: decision[:timing] == :deferred
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Human-readable reason for the "⏳" waiting notice. For topic-concurrency
|
|
206
|
+
# deferrals, name the agent(s) actually holding the topic's running slot so
|
|
207
|
+
# a waiting user can see *who* is blocking them (and reach that task's stop
|
|
208
|
+
# button) rather than an anonymous "another task is running" dead end.
|
|
209
|
+
def waiting_reason_text(reason_key, topic_id, creative_id)
|
|
210
|
+
if reason_key == :topic_concurrency && topic_id
|
|
211
|
+
names = Task.running_for_topic(topic_id, creative_id)
|
|
212
|
+
.includes(:agent).filter_map { |t| t.agent&.name }.uniq
|
|
213
|
+
if names.any?
|
|
214
|
+
return I18n.t(
|
|
215
|
+
"collavre.orchestration.waiting_reasons.topic_concurrency_with_agent",
|
|
216
|
+
agent: names.join(", ")
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
I18n.t(
|
|
222
|
+
"collavre.orchestration.waiting_reasons.#{reason_key}",
|
|
223
|
+
default: reason_key.to_s.humanize
|
|
198
224
|
)
|
|
199
225
|
end
|
|
200
226
|
end
|
|
@@ -25,6 +25,13 @@ module Collavre
|
|
|
25
25
|
def select(candidates)
|
|
26
26
|
return [] if candidates.empty?
|
|
27
27
|
|
|
28
|
+
# Review feedback is forced routing: the Matcher already restricts candidates
|
|
29
|
+
# to the quoted comment's author, the sole agent ReviewHandler will accept.
|
|
30
|
+
# Floor-control arbitration (bid scoring, round_robin) among a forced single
|
|
31
|
+
# recipient is meaningless, and a low bid score must not drop it (e.g. bid
|
|
32
|
+
# strategy with bid_fallback_enabled: false) or the Review button no-ops.
|
|
33
|
+
return candidates if review_message?
|
|
34
|
+
|
|
28
35
|
strategy = @policy_resolver.arbitration_strategy
|
|
29
36
|
selected = apply_strategy(strategy, candidates)
|
|
30
37
|
|
|
@@ -37,6 +44,15 @@ module Collavre
|
|
|
37
44
|
|
|
38
45
|
private
|
|
39
46
|
|
|
47
|
+
# True when the triggering comment is review feedback (mirrors Matcher's
|
|
48
|
+
# review-author routing), so arbitration can be bypassed for forced routing.
|
|
49
|
+
def review_message?
|
|
50
|
+
comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
|
|
51
|
+
return false unless comment_id
|
|
52
|
+
|
|
53
|
+
Comment.find_by(id: comment_id)&.review_message? || false
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
def apply_strategy(strategy, candidates)
|
|
41
57
|
case strategy
|
|
42
58
|
when "all"
|
|
@@ -21,6 +21,10 @@ module Collavre
|
|
|
21
21
|
|
|
22
22
|
# Returns Array of User (AI agents) that are qualified to respond
|
|
23
23
|
def match
|
|
24
|
+
# Priority 0: Review routing (exclusive)
|
|
25
|
+
review_result = match_by_review_author
|
|
26
|
+
return review_result unless review_result.nil?
|
|
27
|
+
|
|
24
28
|
# Priority 1: Mention-based routing (exclusive)
|
|
25
29
|
mentioned_result = match_by_mention
|
|
26
30
|
return mentioned_result unless mentioned_result.nil?
|
|
@@ -31,6 +35,39 @@ module Collavre
|
|
|
31
35
|
|
|
32
36
|
private
|
|
33
37
|
|
|
38
|
+
# Returns [author] for a routable review message, [] for an unroutable one,
|
|
39
|
+
# nil when this is not a review message.
|
|
40
|
+
#
|
|
41
|
+
# A review message can ONLY be handled by the author of the quoted comment:
|
|
42
|
+
# ReviewHandler#eligible? requires quoted_comment.user_id == agent.id, so any
|
|
43
|
+
# other agent would post a stray reply instead of the in-place review update.
|
|
44
|
+
# Route exclusively to that author — independent of mention or
|
|
45
|
+
# routing_expression — so the Review button is reliable even when the author
|
|
46
|
+
# has none (e.g. /compress summaries authored by a primary agent resolved via
|
|
47
|
+
# primary_agent_id). Without this the button renders but the feedback no-ops.
|
|
48
|
+
#
|
|
49
|
+
# Once this IS a review message, the only safe outcomes are exclusive-route or
|
|
50
|
+
# block ([]) — never nil. ResponseFinalizer keys on review_message? regardless
|
|
51
|
+
# of which agent the matcher picks, so a fall-through to mention/expression
|
|
52
|
+
# routing would schedule an agent that ReviewHandler#handle then bails on,
|
|
53
|
+
# producing the very stray reply this routing exists to prevent.
|
|
54
|
+
def match_by_review_author
|
|
55
|
+
return nil unless matched_comment&.review_message?
|
|
56
|
+
|
|
57
|
+
author = matched_comment.quoted_comment&.user
|
|
58
|
+
return [] unless author&.ai_user?
|
|
59
|
+
|
|
60
|
+
# Mirror ReviewHandler#eligible?: if the handler would reject this quote
|
|
61
|
+
# (private, or from another topic/creative), no agent can handle the review.
|
|
62
|
+
# Block rather than fall through, so it can't become a stray normal reply.
|
|
63
|
+
return [] unless Collavre::AiAgent::ReviewHandler.eligible?(matched_comment, author)
|
|
64
|
+
|
|
65
|
+
return [] unless has_creative_permission?(author)
|
|
66
|
+
return [] unless eligible_in_inbox?(author)
|
|
67
|
+
|
|
68
|
+
[ author ]
|
|
69
|
+
end
|
|
70
|
+
|
|
34
71
|
# Returns Array of agents if mention found, nil if no mention
|
|
35
72
|
# When mention IS found, this is exclusive routing
|
|
36
73
|
def match_by_mention
|
|
@@ -47,6 +84,11 @@ module Collavre
|
|
|
47
84
|
# Permission check for mentioned AI agent
|
|
48
85
|
return [] unless has_creative_permission?(mentioned_user)
|
|
49
86
|
|
|
87
|
+
# Inbox confinement applies to mentions too: a live Claude Channel
|
|
88
|
+
# session agent must not be pulled into an ordinary inbox topic, even by
|
|
89
|
+
# an explicit @mention (see #eligible_in_inbox?).
|
|
90
|
+
return [] unless eligible_in_inbox?(mentioned_user)
|
|
91
|
+
|
|
50
92
|
[ mentioned_user ]
|
|
51
93
|
end
|
|
52
94
|
|
|
@@ -57,11 +99,26 @@ module Collavre
|
|
|
57
99
|
|
|
58
100
|
agents.select do |agent|
|
|
59
101
|
next false unless has_creative_permission?(agent)
|
|
102
|
+
next false unless eligible_in_inbox?(agent)
|
|
60
103
|
|
|
61
104
|
evaluate_routing_expression(agent)
|
|
62
105
|
end
|
|
63
106
|
end
|
|
64
107
|
|
|
108
|
+
# A Claude Channel session agent holds inbox-wide :feedback +
|
|
109
|
+
# routing_expression="true", so within the user's Inbox it would otherwise
|
|
110
|
+
# match EVERY topic. Confine it to its own registered session topic (the
|
|
111
|
+
# topic it is primary_agent on, carrying a session_id) so ordinary inbox
|
|
112
|
+
# topics — Main, Content, user threads — stay identical to a normal topic
|
|
113
|
+
# and are never absorbed by a live session. Only the inbox is affected: on
|
|
114
|
+
# work/project creatives the agent still matches via routing_expression.
|
|
115
|
+
def eligible_in_inbox?(agent)
|
|
116
|
+
return true unless matched_creative&.inbox?
|
|
117
|
+
return true unless agent.claude_channel_agent?
|
|
118
|
+
|
|
119
|
+
matched_topic&.session_id.present? && matched_topic.primary_agent_id == agent.id
|
|
120
|
+
end
|
|
121
|
+
|
|
65
122
|
def evaluate_routing_expression(agent)
|
|
66
123
|
expression = agent.routing_expression.strip
|
|
67
124
|
|
|
@@ -85,14 +142,32 @@ module Collavre
|
|
|
85
142
|
def has_creative_permission?(agent)
|
|
86
143
|
# All agents need feedback permission on the creative to respond
|
|
87
144
|
# searchable only affects discoverability, not response permission
|
|
88
|
-
|
|
89
|
-
return false unless creative_id
|
|
90
|
-
|
|
91
|
-
creative = Creative.find_by(id: creative_id)
|
|
145
|
+
creative = matched_creative
|
|
92
146
|
return false unless creative
|
|
93
147
|
|
|
94
148
|
creative.has_permission?(agent, :feedback)
|
|
95
149
|
end
|
|
150
|
+
|
|
151
|
+
def matched_creative
|
|
152
|
+
return @matched_creative if defined?(@matched_creative)
|
|
153
|
+
|
|
154
|
+
creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
|
|
155
|
+
@matched_creative = creative_id && Creative.find_by(id: creative_id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def matched_topic
|
|
159
|
+
return @matched_topic if defined?(@matched_topic)
|
|
160
|
+
|
|
161
|
+
topic_id = @context.dig("topic", "id") || @context.dig(:topic, :id)
|
|
162
|
+
@matched_topic = topic_id && Topic.find_by(id: topic_id)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def matched_comment
|
|
166
|
+
return @matched_comment if defined?(@matched_comment)
|
|
167
|
+
|
|
168
|
+
comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
|
|
169
|
+
@matched_comment = comment_id && Comment.find_by(id: comment_id)
|
|
170
|
+
end
|
|
96
171
|
end
|
|
97
172
|
end
|
|
98
173
|
end
|
|
@@ -39,9 +39,10 @@ module Collavre
|
|
|
39
39
|
},
|
|
40
40
|
"stuck_detection" => {
|
|
41
41
|
"enabled" => false,
|
|
42
|
-
"task_stuck_threshold_minutes" => 30,
|
|
43
|
-
"creative_stall_threshold_minutes" => 120,
|
|
44
|
-
"
|
|
42
|
+
"task_stuck_threshold_minutes" => 30, # Task running for > N minutes
|
|
43
|
+
"creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
|
|
44
|
+
"queued_orphan_threshold_minutes" => 5, # Queued waiter with no live blocker for > N minutes
|
|
45
|
+
"create_system_comment" => true # Create system comment on escalation
|
|
45
46
|
},
|
|
46
47
|
"collaboration" => {
|
|
47
48
|
"a2a_focus_instruction" => nil, # nil = locale default
|