collavre 0.22.0 → 0.24.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/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -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 +158 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- 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/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +47 -103
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +54 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -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/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +28 -5
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- 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/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -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/__tests__/topics_controller_topic_list.test.js +61 -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 +61 -8
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- 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 +7 -1
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- 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__/common_popup_bounds.test.js +58 -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 +42 -10
- 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__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -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/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -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__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +231 -471
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- 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/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +15 -5
- data/app/models/collavre/comment.rb +87 -32
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +104 -19
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +27 -0
- data/app/models/collavre/user.rb +90 -10
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +81 -12
- data/app/services/collavre/auto_theme_generator.rb +15 -11
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +107 -78
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
- 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/system_events/context_builder.rb +1 -14
- 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/tools/preview_attach_service.rb +5 -7
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +9 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -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/landing/show.html.erb +52 -10
- 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 +16 -0
- data/config/locales/comments.ko.yml +16 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- 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/20260120045354_encrypt_oauth_tokens.rb +13 -4
- 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/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -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 +96 -2
- data/app/services/collavre/tools/description_normalizable.rb +0 -16
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
# Keeps denormalized real columns in lockstep with keys stored inside a JSON
|
|
5
|
+
# column, so database indexes can be defined over plain columns instead of
|
|
6
|
+
# JSON expressions.
|
|
7
|
+
#
|
|
8
|
+
# WHY: A JSON-expression index serializes per-adapter --
|
|
9
|
+
# `json_extract(config, '$.x')` on SQLite vs `config->>'x'` on PostgreSQL.
|
|
10
|
+
# Because schema.rb is dumped from the SQLite dev DB, it carries the
|
|
11
|
+
# `json_extract` form, which is not a PostgreSQL function, so
|
|
12
|
+
# `db:schema:load` crashes on the prod PostgreSQL database. Promote the JSON
|
|
13
|
+
# key to a real column (which indexes and dumps identically on both adapters)
|
|
14
|
+
# and keep it synced from the JSON column on every save. The JSON column
|
|
15
|
+
# stays the source of truth; the promoted columns are re-derived on each save.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# include Collavre::IndexedJsonColumns
|
|
19
|
+
#
|
|
20
|
+
# indexed_json_columns json: :config, columns: {
|
|
21
|
+
# repo_full_name: "repo_full_name",
|
|
22
|
+
# pr_number: "pr_number",
|
|
23
|
+
# }
|
|
24
|
+
#
|
|
25
|
+
# `json:` names the JSON attribute (source of truth). `columns:` maps each
|
|
26
|
+
# promoted column to the JSON key it mirrors. A before_save re-derives every
|
|
27
|
+
# promoted column from the JSON attribute so the plain-column indexes enforce
|
|
28
|
+
# the same guarantees the JSON-expression indexes did.
|
|
29
|
+
module IndexedJsonColumns
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
included do
|
|
33
|
+
class_attribute :indexed_json_source, instance_writer: false, default: nil
|
|
34
|
+
class_attribute :indexed_json_column_map, instance_writer: false, default: {}.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
def indexed_json_columns(json:, columns:)
|
|
39
|
+
self.indexed_json_source = json.to_sym
|
|
40
|
+
self.indexed_json_column_map =
|
|
41
|
+
columns.each_with_object({}) { |(col, key), m| m[col.to_s] = key.to_s }.freeze
|
|
42
|
+
before_save :sync_indexed_json_columns
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Re-derive each promoted column from its JSON key. Reads the JSON attribute
|
|
49
|
+
# (source of truth) and writes through `[]=` so subtype reader overrides are
|
|
50
|
+
# bypassed and only genuinely changed values dirty the record.
|
|
51
|
+
def sync_indexed_json_columns
|
|
52
|
+
json = public_send(indexed_json_source) || {}
|
|
53
|
+
indexed_json_column_map.each do |column, json_key|
|
|
54
|
+
self[column] = json[json_key]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
# Classifies an AI user/agent into a coarse role type by scanning its
|
|
3
|
+
# system prompt. Shared by the orchestration and system-events context
|
|
4
|
+
# builders so the prompt-to-type mapping lives in exactly one place.
|
|
5
|
+
module AgentTypeClassifier
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Returns the agent type string ("developer", "pm", "qa", "researcher",
|
|
9
|
+
# "marketer", "planner") or "agent" as the default when nothing matches.
|
|
10
|
+
def classify(user)
|
|
11
|
+
prompt = user.system_prompt.to_s.downcase
|
|
12
|
+
case prompt
|
|
13
|
+
when /developer|개발/ then "developer"
|
|
14
|
+
when /pm|project.?manager|프로젝트/ then "pm"
|
|
15
|
+
when /qa|test|quality|테스트|품질/ then "qa"
|
|
16
|
+
when /research|조사|연구/ then "researcher"
|
|
17
|
+
when /market|마케팅/ then "marketer"
|
|
18
|
+
when /plan|기획/ then "planner"
|
|
19
|
+
else "agent"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -15,6 +15,10 @@ module Collavre
|
|
|
15
15
|
# Dispatch A2A events if the response mentions any AI agents
|
|
16
16
|
def dispatch
|
|
17
17
|
return unless @reply_comment&.content.present?
|
|
18
|
+
# An approval-action message (approve button / approved) is a human decision
|
|
19
|
+
# surface and must never reach an AI agent — not even via an @mention in
|
|
20
|
+
# another agent's reply. Same invariant as Comment#dispatch_to_orchestration.
|
|
21
|
+
return if @reply_comment.approval_action?
|
|
18
22
|
|
|
19
23
|
mentioned_agents = find_mentioned_agents
|
|
20
24
|
return if mentioned_agents.empty?
|
|
@@ -146,7 +146,7 @@ module Collavre
|
|
|
146
146
|
history_chars = 0
|
|
147
147
|
count = 0
|
|
148
148
|
|
|
149
|
-
Comment.public_only.where(creative_id: creative_id)
|
|
149
|
+
Comment.public_only.without_approval_action.where(creative_id: creative_id)
|
|
150
150
|
.where(topic_id: topic_id)
|
|
151
151
|
.where.not(user_id: nil)
|
|
152
152
|
.includes(:user)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module AiAgent
|
|
5
|
+
# Provisions the identities a Claude Code registration needs:
|
|
6
|
+
# - one shared ai_user per (human, agent_name) — the Agent identity
|
|
7
|
+
# - one Topic per (agent, session_id) — the Session identity
|
|
8
|
+
#
|
|
9
|
+
# Extracted from Api::V1::AgentsController#register so the action stays a
|
|
10
|
+
# thin orchestration of provisioning + inbox share. Behavior is identical to
|
|
11
|
+
# the inlined controller methods it replaces.
|
|
12
|
+
class SessionProvisioner
|
|
13
|
+
def initialize(user)
|
|
14
|
+
@user = user
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# One ai_user per (user, agent_name) so a human's sessions share one Agent
|
|
18
|
+
# identity. Same agent_name re-registering reuses the existing row —
|
|
19
|
+
# idempotent retries (and every additional session) don't proliferate
|
|
20
|
+
# agents.
|
|
21
|
+
#
|
|
22
|
+
# Returns nil when a row with the deterministic email already exists but is
|
|
23
|
+
# owned by someone else or isn't a Claude Channel ai_user. The email format
|
|
24
|
+
# is human-derivable (user.id + slug + digest), so a foreign row could be
|
|
25
|
+
# planted by signup/import; silently reusing it would attach the caller's
|
|
26
|
+
# inbox feedback share to that foreign User and leave the plugin's
|
|
27
|
+
# AgentChannel subscription rejected on ownership mismatch. Caller renders
|
|
28
|
+
# 409 in that case.
|
|
29
|
+
def find_or_create_agent(agent_name)
|
|
30
|
+
email = session_agent_email(agent_name)
|
|
31
|
+
|
|
32
|
+
existing = User.find_by(email: email)
|
|
33
|
+
return verified_agent(existing) if existing
|
|
34
|
+
|
|
35
|
+
# routing_expression: nil so the new ai_user is not matchable until the
|
|
36
|
+
# client claims the per-agent stream via AgentChannel. See the comment on
|
|
37
|
+
# verified_agent.
|
|
38
|
+
User.create!(
|
|
39
|
+
email: email,
|
|
40
|
+
name: "Claude Channel (#{agent_name})",
|
|
41
|
+
password: SecureRandom.hex(32),
|
|
42
|
+
llm_vendor: "anthropic",
|
|
43
|
+
llm_model: "claude-code",
|
|
44
|
+
created_by_id: @user.id,
|
|
45
|
+
searchable: false,
|
|
46
|
+
routing_expression: nil
|
|
47
|
+
)
|
|
48
|
+
rescue ActiveRecord::RecordNotUnique
|
|
49
|
+
# A concurrent registration for the same (user, agent_name) won the
|
|
50
|
+
# users.email unique race. The desired row now exists — re-find and
|
|
51
|
+
# re-verify ownership instead of surfacing a 500 that aborts one of the
|
|
52
|
+
# two simultaneously launching plugin instances.
|
|
53
|
+
verified_agent(User.find_by(email: email))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One Topic per (agent, session_id). On re-register (including --resume from
|
|
57
|
+
# the same cwd, which yields the same session_id) the existing topic is
|
|
58
|
+
# reused — even if archived — so the conversation persists instead of
|
|
59
|
+
# orphaning. A fresh session gets a new topic under the same shared agent,
|
|
60
|
+
# which is how one agent fans out to many sessions.
|
|
61
|
+
def find_or_create_topic(inbox, ai_user, session_id, session_label)
|
|
62
|
+
existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
|
|
63
|
+
return existing if existing
|
|
64
|
+
|
|
65
|
+
label = session_label.to_s.strip.presence || session_id
|
|
66
|
+
inbox.topics.create!(
|
|
67
|
+
name: unique_topic_name(inbox, "Claude #{label}"),
|
|
68
|
+
user: @user,
|
|
69
|
+
primary_agent_id: ai_user.id,
|
|
70
|
+
session_id: session_id
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Deterministic, collision-free email key for a (user, agent_name) agent.
|
|
77
|
+
# The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all squeeze
|
|
78
|
+
# to "qa-bot", and any all-symbol name collapses to "session" — so a short
|
|
79
|
+
# digest of the normalized raw name disambiguates distinct configured names
|
|
80
|
+
# that would otherwise alias onto ONE shared agent identity (ActionCable
|
|
81
|
+
# stream, routing state, tasks, creative shares). The slug stays for human
|
|
82
|
+
# readability; the digest decides identity. Same normalized name -> same
|
|
83
|
+
# key, so idempotent re-register/reuse is preserved. Normalization
|
|
84
|
+
# (strip+downcase) keeps case/whitespace-only differences folded, matching
|
|
85
|
+
# the prior behavior — only the lossy punctuation/spacing collapse is fixed.
|
|
86
|
+
def session_agent_email(agent_name)
|
|
87
|
+
normalized = agent_name.to_s.strip.downcase
|
|
88
|
+
slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
|
|
89
|
+
slug = "session" if slug.blank?
|
|
90
|
+
digest = Digest::SHA256.hexdigest(normalized)[0, 10]
|
|
91
|
+
"claude-channel-#{@user.id}-#{slug}-#{digest}@agent.collavre.local"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the agent only when it is the current user's own Claude Channel
|
|
95
|
+
# agent; nil otherwise (the caller renders :conflict). Routing activation
|
|
96
|
+
# stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
|
|
97
|
+
# becomes matchable only once a WebSocket subscriber exists for
|
|
98
|
+
# agent:user:<id> — otherwise comments matched between register returning
|
|
99
|
+
# and the client's subsequent cable subscribe would broadcast into an empty
|
|
100
|
+
# stream, stranding delegated tasks until stuck recovery.
|
|
101
|
+
def verified_agent(ai_user)
|
|
102
|
+
return nil unless ai_user &&
|
|
103
|
+
ai_user.created_by_id == @user.id &&
|
|
104
|
+
ai_user.ai_user? &&
|
|
105
|
+
ai_user.claude_channel_agent?
|
|
106
|
+
|
|
107
|
+
ai_user
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
|
|
111
|
+
# friendly labels collide (e.g. both rooted at a dir named "src") cannot
|
|
112
|
+
# share a name. Append the smallest numeric suffix that is free.
|
|
113
|
+
def unique_topic_name(inbox, desired)
|
|
114
|
+
return desired unless inbox.topics.exists?(name: desired)
|
|
115
|
+
|
|
116
|
+
n = 2
|
|
117
|
+
loop do
|
|
118
|
+
candidate = "#{desired} (#{n})"
|
|
119
|
+
return candidate unless inbox.topics.exists?(name: candidate)
|
|
120
|
+
|
|
121
|
+
n += 1
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module AiAgent
|
|
5
|
+
# Atomically claims and finalizes a delegated task on behalf of a Claude
|
|
6
|
+
# Channel /reply. Extracted from Api::V1::AgentsController#reply so the
|
|
7
|
+
# controller only sequences: claim -> save comment -> finalize. Behavior is
|
|
8
|
+
# identical to the inlined controller methods it replaces.
|
|
9
|
+
class TaskClaimService
|
|
10
|
+
# Atomically claim a delegated task for completion. Two-step:
|
|
11
|
+
# 1. Find a candidate task in delegated state, scoped to this agent +
|
|
12
|
+
# topic. With task_id supplied, exact match (required under topic
|
|
13
|
+
# concurrency > 1 where multiple delegated tasks coexist; the client
|
|
14
|
+
# echoes the dispatch's task_id). Without task_id (legacy clients),
|
|
15
|
+
# oldest-first.
|
|
16
|
+
# 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
|
|
17
|
+
# status == 'delegated' under the lock, then update! to 'done'.
|
|
18
|
+
# Concurrent claimers block on the lock; the loser sees the
|
|
19
|
+
# already-flipped status post-lock and returns nil so the caller can
|
|
20
|
+
# refuse the duplicate.
|
|
21
|
+
# update_all (NOT update!) is required to skip Task's after_update_commit
|
|
22
|
+
# callbacks at claim time. The callbacks fire check_trigger_loop_completion
|
|
23
|
+
# (which enqueues TriggerLoopCheckJob) and broadcast_stop_button_removal
|
|
24
|
+
# (which reads reply_comment). Both depend on the reply comment already
|
|
25
|
+
# existing — but reply() claims BEFORE comment.save to win the race against
|
|
26
|
+
# concurrent /reply calls. If update! fired the trigger-loop check here, the
|
|
27
|
+
# job could run (cooldown_seconds: 0) before comment.save commits, find no
|
|
28
|
+
# agent comment, and leave the loop stuck in "running". #finalize replays
|
|
29
|
+
# both callbacks after the comment is persisted via
|
|
30
|
+
# Task#fire_completion_callbacks_after_external_claim.
|
|
31
|
+
def claim(agent:, topic:, requested_task_id:)
|
|
32
|
+
scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
|
|
33
|
+
candidate =
|
|
34
|
+
if requested_task_id.present?
|
|
35
|
+
scope.find_by(id: requested_task_id)
|
|
36
|
+
else
|
|
37
|
+
scope.order(:created_at).first
|
|
38
|
+
end
|
|
39
|
+
return nil unless candidate
|
|
40
|
+
|
|
41
|
+
claimed = nil
|
|
42
|
+
Task.transaction do
|
|
43
|
+
locked = Task.lock.find_by(id: candidate.id)
|
|
44
|
+
next unless locked && locked.status == "delegated"
|
|
45
|
+
|
|
46
|
+
Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
|
|
47
|
+
claimed = locked.reload
|
|
48
|
+
end
|
|
49
|
+
claimed
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Post-claim side effects, run only after the reply comment is saved. Links
|
|
53
|
+
# the comment to the claimed task, releases the ResourceTracker slot the
|
|
54
|
+
# AiAgentJob held under task.id, advances the parent workflow (if any), and
|
|
55
|
+
# drains the topic queue — mirroring AiAgentJob#perform's success path for
|
|
56
|
+
# non-delegated runs.
|
|
57
|
+
def finalize(agent:, task:, comment:)
|
|
58
|
+
comment.update_column(:task_id, task.id)
|
|
59
|
+
|
|
60
|
+
Orchestration::ResourceTracker.for(agent).release!(task.id)
|
|
61
|
+
|
|
62
|
+
if task.parent_task_id.present?
|
|
63
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
67
|
+
|
|
68
|
+
# Replay the after_update_commit callbacks that were bypassed by
|
|
69
|
+
# update_all in #claim — now that the reply comment is linked,
|
|
70
|
+
# TriggerLoopCheckJob can read it and decide whether to advance/await/
|
|
71
|
+
# complete the drop-trigger loop, and the stop-button broadcast has a
|
|
72
|
+
# comment to render.
|
|
73
|
+
task.fire_completion_callbacks_after_external_claim
|
|
74
|
+
|
|
75
|
+
# Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
|
|
76
|
+
# would also stop on its next beat (task no longer "delegated"), but that
|
|
77
|
+
# is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
|
|
78
|
+
# drops the moment Claude's reply lands.
|
|
79
|
+
broadcast_claude_idle(agent, task, comment)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Clear the chat typing indicator via the canonical status broadcaster (the
|
|
85
|
+
# same one AiAgentService uses for every other agent), so the Claude path
|
|
86
|
+
# emits an identical "idle" agent_status payload.
|
|
87
|
+
def broadcast_claude_idle(agent, task, comment)
|
|
88
|
+
creative = comment.creative&.effective_origin
|
|
89
|
+
return unless creative
|
|
90
|
+
|
|
91
|
+
AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
|
|
92
|
+
.broadcast_status("idle")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -10,13 +10,61 @@ module Collavre
|
|
|
10
10
|
|
|
11
11
|
attr_reader :last_input_tokens, :last_output_tokens
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Vendor <select> options for AI-agent config. Core ships only its built-in
|
|
14
|
+
# (stateless) providers; vendor engines append their own through
|
|
15
|
+
# register_vendor_option, so core never names a vendor engine.
|
|
16
|
+
BASE_VENDOR_OPTIONS = [
|
|
17
|
+
[ "Google (Gemini)", "google" ],
|
|
18
|
+
[ "OpenAI", "openai" ],
|
|
19
|
+
[ "Anthropic", "anthropic" ]
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def registered_vendor_options
|
|
24
|
+
@registered_vendor_options ||= []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Append a vendor <select> option ([label, value]). Idempotent by value.
|
|
28
|
+
def register_vendor_option(label, value)
|
|
29
|
+
value = value.to_s
|
|
30
|
+
return if BASE_VENDOR_OPTIONS.any? { |_l, v| v == value }
|
|
31
|
+
return if registered_vendor_options.any? { |_l, v| v == value }
|
|
32
|
+
|
|
33
|
+
registered_vendor_options << [ label, value ]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def vendor_options
|
|
37
|
+
BASE_VENDOR_OPTIONS + registered_vendor_options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Vendors that use stateful/incremental sessions. Base providers are
|
|
41
|
+
# stateless; vendor engines that add session support register here so core
|
|
42
|
+
# never string-matches a vendor name to decide session behavior.
|
|
43
|
+
def session_vendors
|
|
44
|
+
@session_vendors ||= Set.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_session_vendor(vendor)
|
|
48
|
+
session_vendors << vendor.to_s.downcase
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def vendor_supports_session?(vendor)
|
|
52
|
+
session_vendors.include?(vendor.to_s.downcase)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# log_interactions: persist each call to ActivityLog. Default true. Pass false
|
|
57
|
+
# for ephemeral, high-frequency calls on text the user has not submitted (e.g.
|
|
58
|
+
# inline typo correction on debounced typing) so private drafts are never
|
|
59
|
+
# written to server-side activity logs.
|
|
60
|
+
def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true)
|
|
14
61
|
@vendor = vendor
|
|
15
62
|
@model = model
|
|
16
63
|
@system_prompt = system_prompt
|
|
17
64
|
@llm_api_key = llm_api_key
|
|
18
65
|
@gateway_url = gateway_url
|
|
19
66
|
@context = context
|
|
67
|
+
@log_interactions = log_interactions
|
|
20
68
|
@last_input_tokens = 0
|
|
21
69
|
@last_output_tokens = 0
|
|
22
70
|
end
|
|
@@ -36,8 +84,16 @@ module Collavre
|
|
|
36
84
|
add_messages(@conversation, contents)
|
|
37
85
|
|
|
38
86
|
response = @conversation.complete do |chunk|
|
|
39
|
-
delta = extract_chunk_content(chunk)
|
|
40
|
-
|
|
87
|
+
delta = extract_chunk_content(chunk).to_s
|
|
88
|
+
# Deliberately NOT `blank?`. A delta of exactly "\n\n" — the paragraph
|
|
89
|
+
# break, which providers routinely emit as a token of its own — is
|
|
90
|
+
# blank? == true, so skipping blanks deletes it. And the deleted delta is
|
|
91
|
+
# never yielded, so it is gone from the caller's stream too: AiAgentService
|
|
92
|
+
# persists ResponseStreamer#content, which is built only from the yielded
|
|
93
|
+
# deltas, and the reply lands in the database with its paragraphs glued
|
|
94
|
+
# together. Only truly empty deltas (role-only / tool-call chunks carry no
|
|
95
|
+
# content) are skippable.
|
|
96
|
+
next if delta.empty?
|
|
41
97
|
|
|
42
98
|
response_content << delta
|
|
43
99
|
yield delta if block_given?
|
|
@@ -64,7 +120,13 @@ module Collavre
|
|
|
64
120
|
raise # Re-raise cancellation errors without catching them
|
|
65
121
|
rescue StandardError => e
|
|
66
122
|
error_message = "[#{e.class.name}] #{e.message}"
|
|
67
|
-
|
|
123
|
+
# When log_interactions is false (inline typo correction runs on the user's
|
|
124
|
+
# *unsubmitted* draft), the LLM error message can echo the request text. Log
|
|
125
|
+
# only the error class to app logs so private drafts never leak — matching the
|
|
126
|
+
# no-log guarantee already enforced on the parse path (TypoCorrector) and the
|
|
127
|
+
# ActivityLog gate below. error_message stays intact for the gated ensure log
|
|
128
|
+
# and the streamed yield (which goes back to the same user).
|
|
129
|
+
Rails.logger.error "AI Client error: #{@log_interactions ? error_message : "[#{e.class.name}]"}"
|
|
68
130
|
Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present?
|
|
69
131
|
Rails.logger.debug e.backtrace.join("\n")
|
|
70
132
|
yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
|
|
@@ -72,14 +134,16 @@ module Collavre
|
|
|
72
134
|
ensure
|
|
73
135
|
@last_input_tokens = input_tokens || 0
|
|
74
136
|
@last_output_tokens = output_tokens || 0
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
137
|
+
if @log_interactions
|
|
138
|
+
log_interaction(
|
|
139
|
+
messages: @conversation&.messages&.to_a || Array(contents),
|
|
140
|
+
tools: @conversation&.tools&.to_a || [],
|
|
141
|
+
response_content: response_content.presence,
|
|
142
|
+
error_message: error_message,
|
|
143
|
+
input_tokens: input_tokens,
|
|
144
|
+
output_tokens: output_tokens
|
|
145
|
+
)
|
|
146
|
+
end
|
|
83
147
|
end
|
|
84
148
|
|
|
85
149
|
# Ask a follow-up question using the existing conversation context.
|
|
@@ -115,6 +179,11 @@ module Collavre
|
|
|
115
179
|
when "openai"
|
|
116
180
|
api_key = @llm_api_key.presence || IntegrationSettings.fetch(:openai_api_key)
|
|
117
181
|
base_url = @gateway_url.presence
|
|
182
|
+
# A custom OpenAI-compatible gateway (local Ollama / LM Studio, etc.) needs
|
|
183
|
+
# no real OpenAI key, but RubyLLM raises ConfigurationError before sending
|
|
184
|
+
# if openai_api_key is blank. Supply a placeholder so keyless local gateways
|
|
185
|
+
# work; hosted OpenAI (no gateway) still requires a real key.
|
|
186
|
+
api_key = "local-gateway" if api_key.blank? && base_url
|
|
118
187
|
proc do |config|
|
|
119
188
|
config.openai_api_key = api_key
|
|
120
189
|
config.openai_api_base = base_url if base_url
|
|
@@ -57,7 +57,20 @@ module Collavre
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def generate(prompt)
|
|
60
|
-
|
|
60
|
+
parse_response(request_theme(prompt))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def request_theme(prompt)
|
|
66
|
+
@client.chat([
|
|
67
|
+
{ role: :system, parts: [ { text: system_prompt } ] },
|
|
68
|
+
{ role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
|
|
69
|
+
])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def system_prompt
|
|
73
|
+
<<~PROMPT
|
|
61
74
|
You are a color theme designer for a workspace app.
|
|
62
75
|
Generate a JSON with ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
|
|
63
76
|
|
|
@@ -181,21 +194,12 @@ module Collavre
|
|
|
181
194
|
For dark/accent themes, h1 can use a subtle darker surface. Must not clash with text.
|
|
182
195
|
- For playful themes, vary heading colors. For minimal themes, keep sizes uniform.
|
|
183
196
|
PROMPT
|
|
184
|
-
|
|
185
|
-
response = @client.chat([
|
|
186
|
-
{ role: :system, parts: [ { text: system_prompt } ] },
|
|
187
|
-
{ role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
|
|
188
|
-
])
|
|
189
|
-
|
|
190
|
-
parse_response(response)
|
|
191
197
|
end
|
|
192
198
|
|
|
193
|
-
private
|
|
194
|
-
|
|
195
199
|
def default_client
|
|
196
200
|
AiClient.new(
|
|
197
201
|
vendor: "google",
|
|
198
|
-
model: "gemini-3-flash-
|
|
202
|
+
model: "gemini-3.1-flash-lite",
|
|
199
203
|
system_prompt: nil
|
|
200
204
|
)
|
|
201
205
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Per-level batch answer to "which children does this node show?" for the
|
|
4
|
+
# browse tree, replacing a per-node `children_with_permission` call.
|
|
5
|
+
#
|
|
6
|
+
# Presence (`has_children?`) is resolved for every node in a level with one
|
|
7
|
+
# plucked scan plus one batched PermissionFilter; the full child rows are
|
|
8
|
+
# materialized only for the nodes that actually render them (expanded, or a
|
|
9
|
+
# filter forcing the subtree open). Presence and content are derived from the
|
|
10
|
+
# same candidate set, so an expand toggle can never open an empty branch or
|
|
11
|
+
# hide a reachable subtree — a drift that would also leak the existence of
|
|
12
|
+
# children the user cannot see.
|
|
13
|
+
#
|
|
14
|
+
# Children of a linked shell live under its effective origin, so every lookup
|
|
15
|
+
# resolves the shell to its origin first. Preload `:origin` on the level before
|
|
16
|
+
# indexing it, or that resolution costs a query per shell.
|
|
17
|
+
class ChildrenIndex
|
|
18
|
+
def initialize(user:, show_archived:, allowed_creative_ids: nil)
|
|
19
|
+
@user = user
|
|
20
|
+
@show_archived = show_archived
|
|
21
|
+
@allowed_creative_ids = allowed_creative_ids
|
|
22
|
+
@child_ids_by_creative = {}
|
|
23
|
+
@rows_by_child_id = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Scans a level. Only creatives not seen before cost anything, so the
|
|
27
|
+
# recursive descent pays two queries per level, not per node.
|
|
28
|
+
def index(creatives)
|
|
29
|
+
pending = creatives.reject { |c| @child_ids_by_creative.key?(c.id) }
|
|
30
|
+
return if pending.empty?
|
|
31
|
+
|
|
32
|
+
origin_id_by_id = pending.to_h { |c| [ c.id, c.effective_origin.id ] }
|
|
33
|
+
|
|
34
|
+
candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
|
|
35
|
+
candidates = candidates.where(archived_at: nil) unless show_archived
|
|
36
|
+
rows = candidates.order(:sequence).pluck(:id, :parent_id)
|
|
37
|
+
|
|
38
|
+
visible_by_origin = visible_child_ids_by_origin(rows)
|
|
39
|
+
pending.each do |creative|
|
|
40
|
+
@child_ids_by_creative[creative.id] = visible_by_origin[origin_id_by_id[creative.id]] || []
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def has_children?(creative)
|
|
45
|
+
child_ids(creative).any?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Materializes the child rows of `creatives` in a single query. Called only
|
|
49
|
+
# for the nodes whose children actually render.
|
|
50
|
+
def load(creatives)
|
|
51
|
+
wanted = creatives.flat_map { |c| child_ids(c) }.uniq - @rows_by_child_id.keys
|
|
52
|
+
return if wanted.empty?
|
|
53
|
+
|
|
54
|
+
Creative.where(id: wanted).each { |child| @rows_by_child_id[child.id] = child }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sequence-ordered, permission- and archive-filtered children. Empty unless
|
|
58
|
+
# `load` has materialized them.
|
|
59
|
+
def children_for(creative)
|
|
60
|
+
child_ids(creative).filter_map { |id| @rows_by_child_id[id] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :user, :show_archived, :allowed_creative_ids
|
|
66
|
+
|
|
67
|
+
# `rows` arrive in sequence order, so the per-origin lists inherit it.
|
|
68
|
+
def visible_child_ids_by_origin(rows)
|
|
69
|
+
return {} if rows.empty?
|
|
70
|
+
|
|
71
|
+
readable = PermissionFilter.new(user: user).readable_ids(rows.map(&:first)).to_set
|
|
72
|
+
|
|
73
|
+
rows.each_with_object({}) do |(child_id, origin_id), acc|
|
|
74
|
+
next unless readable.include?(child_id)
|
|
75
|
+
next if allowed_creative_ids && !allowed_creative_ids.include?(child_id.to_s)
|
|
76
|
+
|
|
77
|
+
(acc[origin_id] ||= []) << child_id
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def child_ids(creative)
|
|
82
|
+
@child_ids_by_creative.fetch(creative.id, [])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Batches the browse tree's comment badge. Per node this was two queries — a
|
|
4
|
+
# read-pointer lookup and an unread COUNT — which is what made the badge cost
|
|
5
|
+
# scale with the size of the rendered tree.
|
|
6
|
+
#
|
|
7
|
+
# Keyed by *origin*: a linked shell shows its origin's comments.
|
|
8
|
+
class CommentBadgeIndex
|
|
9
|
+
def initialize(user:)
|
|
10
|
+
@user = user
|
|
11
|
+
@unread_by_origin_id = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def index(origins)
|
|
15
|
+
pending = origins.uniq(&:id).reject { |o| @unread_by_origin_id.key?(o.id) }
|
|
16
|
+
return if pending.empty?
|
|
17
|
+
|
|
18
|
+
watermarks = read_watermarks(pending.map(&:id))
|
|
19
|
+
|
|
20
|
+
# No read pointer means nothing has been read yet, so every comment counts
|
|
21
|
+
# as unread — including private ones, matching the counter cache.
|
|
22
|
+
pending.each do |origin|
|
|
23
|
+
@unread_by_origin_id[origin.id] = origin.comments_count unless watermarks.key?(origin.id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
counts = unread_counts(watermarks)
|
|
27
|
+
watermarks.each_key { |origin_id| @unread_by_origin_id[origin_id] = counts.fetch(origin_id, 0) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# nil when the origin was never indexed, which tells the caller to fall back
|
|
31
|
+
# to the single-creative path rather than silently render a zero badge.
|
|
32
|
+
def unread_count_for(origin)
|
|
33
|
+
@unread_by_origin_id[origin.id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :user
|
|
39
|
+
|
|
40
|
+
# `user_id: nil` for an anonymous visitor is the lookup the un-batched path
|
|
41
|
+
# made too, so the two agree on which pointer (if any) applies.
|
|
42
|
+
def read_watermarks(origin_ids)
|
|
43
|
+
return {} if origin_ids.empty?
|
|
44
|
+
|
|
45
|
+
CommentReadPointer
|
|
46
|
+
.where(user_id: user&.id, creative_id: origin_ids)
|
|
47
|
+
.where.not(last_read_comment_id: nil)
|
|
48
|
+
.pluck(:creative_id, :last_read_comment_id)
|
|
49
|
+
.to_h
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One grouped COUNT for the whole level. Each origin carries its own read
|
|
53
|
+
# watermark, so the thresholds are OR-ed together rather than shared. Built
|
|
54
|
+
# from relations rather than a SQL fragment: a hand-built string here would
|
|
55
|
+
# be safe (literal template, bound values) but unprovably so to a scanner.
|
|
56
|
+
def unread_counts(watermarks)
|
|
57
|
+
return {} if watermarks.empty?
|
|
58
|
+
|
|
59
|
+
newer_than_watermark = watermarks
|
|
60
|
+
.map { |origin_id, last_read_id| unread_scope(origin_id, last_read_id) }
|
|
61
|
+
.reduce { |combined, scope| combined.or(scope) }
|
|
62
|
+
|
|
63
|
+
Comment.where(private: false).merge(newer_than_watermark).group(:creative_id).count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unread_scope(origin_id, last_read_id)
|
|
67
|
+
Comment
|
|
68
|
+
.where(creative_id: origin_id)
|
|
69
|
+
.where(Comment.arel_table[:id].gt(last_read_id))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|