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
|
@@ -79,76 +79,16 @@ module Collavre
|
|
|
79
79
|
|
|
80
80
|
def update
|
|
81
81
|
SystemSetting.transaction do
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
#
|
|
88
|
-
mcp_setting = SystemSetting.find_or_initialize_by(key: "mcp_tool_approval_required")
|
|
89
|
-
mcp_setting.value = params[:mcp_tool_approval] == "1" ? "true" : "false"
|
|
90
|
-
mcp_setting.save!
|
|
91
|
-
|
|
92
|
-
# Creatives Login Required
|
|
93
|
-
creatives_login_setting = SystemSetting.find_or_initialize_by(key: "creatives_login_required")
|
|
94
|
-
creatives_login_setting.value = params[:creatives_login_required] == "1" ? "true" : "false"
|
|
95
|
-
creatives_login_setting.save!
|
|
96
|
-
|
|
97
|
-
# Home Page Paths (unauthenticated default + authenticated override)
|
|
82
|
+
settings_from_params.each do |key, value|
|
|
83
|
+
SystemSetting.find_or_initialize_by(key: key).update!(value: value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Home Page Paths (unauthenticated default + authenticated override).
|
|
87
|
+
# Kept separate because they validate/normalize and may raise.
|
|
98
88
|
save_home_page_path("home_page_path", params[:home_page_path])
|
|
99
89
|
save_home_page_path("home_page_path_authenticated", params[:home_page_path_authenticated])
|
|
100
90
|
|
|
101
|
-
|
|
102
|
-
max_attempts = params[:max_login_attempts].to_i
|
|
103
|
-
max_attempts = SystemSetting::DEFAULT_MAX_LOGIN_ATTEMPTS if max_attempts < 1
|
|
104
|
-
SystemSetting.find_or_initialize_by(key: "max_login_attempts").tap { |s| s.value = max_attempts.to_s; s.save! }
|
|
105
|
-
|
|
106
|
-
lockout_duration = params[:lockout_duration_minutes].to_i
|
|
107
|
-
lockout_duration = SystemSetting::DEFAULT_LOCKOUT_DURATION_MINUTES if lockout_duration < 1
|
|
108
|
-
SystemSetting.find_or_initialize_by(key: "lockout_duration_minutes").tap { |s| s.value = lockout_duration.to_s; s.save! }
|
|
109
|
-
|
|
110
|
-
# Password Policy Settings
|
|
111
|
-
password_min_length = [ [ params[:password_min_length].to_i, SystemSetting::DEFAULT_PASSWORD_MIN_LENGTH ].max, 72 ].min
|
|
112
|
-
SystemSetting.find_or_initialize_by(key: "password_min_length").tap { |s| s.value = password_min_length.to_s; s.save! }
|
|
113
|
-
|
|
114
|
-
# Session Timeout Settings
|
|
115
|
-
session_timeout = [ params[:session_timeout_minutes].to_i, 0 ].max
|
|
116
|
-
SystemSetting.find_or_initialize_by(key: "session_timeout_minutes").tap { |s| s.value = session_timeout.to_s; s.save! }
|
|
117
|
-
|
|
118
|
-
# Rate Limiting - Password Reset
|
|
119
|
-
pw_reset_limit = params[:password_reset_rate_limit].to_i
|
|
120
|
-
pw_reset_limit = SystemSetting::DEFAULT_PASSWORD_RESET_RATE_LIMIT if pw_reset_limit < 1
|
|
121
|
-
SystemSetting.find_or_initialize_by(key: "password_reset_rate_limit").tap { |s| s.value = pw_reset_limit.to_s; s.save! }
|
|
122
|
-
|
|
123
|
-
pw_reset_period = params[:password_reset_rate_period_minutes].to_i
|
|
124
|
-
pw_reset_period = SystemSetting::DEFAULT_PASSWORD_RESET_RATE_PERIOD_MINUTES if pw_reset_period < 1
|
|
125
|
-
SystemSetting.find_or_initialize_by(key: "password_reset_rate_period_minutes").tap { |s| s.value = pw_reset_period.to_s; s.save! }
|
|
126
|
-
|
|
127
|
-
# Rate Limiting - API
|
|
128
|
-
api_limit = params[:api_rate_limit].to_i
|
|
129
|
-
api_limit = SystemSetting::DEFAULT_API_RATE_LIMIT if api_limit < 1
|
|
130
|
-
SystemSetting.find_or_initialize_by(key: "api_rate_limit").tap { |s| s.value = api_limit.to_s; s.save! }
|
|
131
|
-
|
|
132
|
-
api_period = params[:api_rate_period_minutes].to_i
|
|
133
|
-
api_period = SystemSetting::DEFAULT_API_RATE_PERIOD_MINUTES if api_period < 1
|
|
134
|
-
SystemSetting.find_or_initialize_by(key: "api_rate_period_minutes").tap { |s| s.value = api_period.to_s; s.save! }
|
|
135
|
-
|
|
136
|
-
# LLM Settings
|
|
137
|
-
llm_timeout = params[:llm_request_timeout_seconds].to_i
|
|
138
|
-
llm_timeout = SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS if llm_timeout < 30
|
|
139
|
-
SystemSetting.find_or_initialize_by(key: "llm_request_timeout_seconds").tap { |s| s.value = llm_timeout.to_s; s.save! }
|
|
140
|
-
|
|
141
|
-
# Auth Providers
|
|
142
|
-
auth_providers = Array(params[:auth_providers]).reject(&:blank?)
|
|
143
|
-
if auth_providers.empty?
|
|
144
|
-
auth_setting = SystemSetting.new(key: "auth_providers_enabled")
|
|
145
|
-
auth_setting.errors.add(:base, t("admin.settings.auth_provider_required"))
|
|
146
|
-
raise ActiveRecord::RecordInvalid, auth_setting
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
all_provider_keys = Rails.application.config.auth_providers.map { |p| p[:key].to_s }
|
|
150
|
-
disabled_providers = all_provider_keys - auth_providers
|
|
151
|
-
SystemSetting.find_or_initialize_by(key: "auth_providers_disabled").tap { |s| s.value = disabled_providers.join(","); s.save! }
|
|
91
|
+
save_auth_providers!
|
|
152
92
|
end
|
|
153
93
|
|
|
154
94
|
redirect_to collavre.admin_settings_path, notice: t("admin.settings.updated")
|
|
@@ -174,6 +114,51 @@ module Collavre
|
|
|
174
114
|
|
|
175
115
|
private
|
|
176
116
|
|
|
117
|
+
# Maps each SystemSetting key to its normalized string value derived from
|
|
118
|
+
# the request params. Collapses the previously repetitive per-setting
|
|
119
|
+
# blocks into a single data-driven loop in #update.
|
|
120
|
+
def settings_from_params
|
|
121
|
+
{
|
|
122
|
+
"help_menu_link" => params[:help_link].to_s.strip,
|
|
123
|
+
"mcp_tool_approval_required" => boolean_setting(:mcp_tool_approval),
|
|
124
|
+
"creatives_login_required" => boolean_setting(:creatives_login_required),
|
|
125
|
+
"max_login_attempts" => int_setting(:max_login_attempts, SystemSetting::DEFAULT_MAX_LOGIN_ATTEMPTS),
|
|
126
|
+
"lockout_duration_minutes" => int_setting(:lockout_duration_minutes, SystemSetting::DEFAULT_LOCKOUT_DURATION_MINUTES),
|
|
127
|
+
"password_min_length" => [ [ params[:password_min_length].to_i, SystemSetting::DEFAULT_PASSWORD_MIN_LENGTH ].max, 72 ].min.to_s,
|
|
128
|
+
"session_timeout_minutes" => [ params[:session_timeout_minutes].to_i, 0 ].max.to_s,
|
|
129
|
+
"password_reset_rate_limit" => int_setting(:password_reset_rate_limit, SystemSetting::DEFAULT_PASSWORD_RESET_RATE_LIMIT),
|
|
130
|
+
"password_reset_rate_period_minutes" => int_setting(:password_reset_rate_period_minutes, SystemSetting::DEFAULT_PASSWORD_RESET_RATE_PERIOD_MINUTES),
|
|
131
|
+
"api_rate_limit" => int_setting(:api_rate_limit, SystemSetting::DEFAULT_API_RATE_LIMIT),
|
|
132
|
+
"api_rate_period_minutes" => int_setting(:api_rate_period_minutes, SystemSetting::DEFAULT_API_RATE_PERIOD_MINUTES),
|
|
133
|
+
"llm_request_timeout_seconds" => int_setting(:llm_request_timeout_seconds, SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, min: 30)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Checkbox param ("1" when checked) -> "true"/"false" string value.
|
|
138
|
+
def boolean_setting(param_key)
|
|
139
|
+
params[param_key] == "1" ? "true" : "false"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Integer param that falls back to a default when below the minimum floor.
|
|
143
|
+
def int_setting(param_key, default, min: 1)
|
|
144
|
+
value = params[param_key].to_i
|
|
145
|
+
value = default if value < min
|
|
146
|
+
value.to_s
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def save_auth_providers!
|
|
150
|
+
auth_providers = Array(params[:auth_providers]).reject(&:blank?)
|
|
151
|
+
if auth_providers.empty?
|
|
152
|
+
auth_setting = SystemSetting.new(key: "auth_providers_enabled")
|
|
153
|
+
auth_setting.errors.add(:base, t("admin.settings.auth_provider_required"))
|
|
154
|
+
raise ActiveRecord::RecordInvalid, auth_setting
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
all_provider_keys = Rails.application.config.auth_providers.map { |p| p[:key].to_s }
|
|
158
|
+
disabled_providers = all_provider_keys - auth_providers
|
|
159
|
+
SystemSetting.find_or_initialize_by(key: "auth_providers_disabled").update!(value: disabled_providers.join(","))
|
|
160
|
+
end
|
|
161
|
+
|
|
177
162
|
def save_home_page_path(key, raw_value)
|
|
178
163
|
input = raw_value.to_s.strip
|
|
179
164
|
setting = SystemSetting.find_or_initialize_by(key: key)
|
|
@@ -29,14 +29,14 @@ module Collavre
|
|
|
29
29
|
return
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
ai_user =
|
|
32
|
+
ai_user = session_provisioner.find_or_create_agent(agent_name)
|
|
33
33
|
unless ai_user
|
|
34
34
|
render json: { error: "Session agent email is already in use by a different account" }, status: :conflict
|
|
35
35
|
return
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
inbox = Creative.inbox_for(current_user)
|
|
39
|
-
topic =
|
|
39
|
+
topic = session_provisioner.find_or_create_topic(inbox, ai_user, session_id, params[:session_label])
|
|
40
40
|
topic.unarchive! if topic.archived?
|
|
41
41
|
topic.set_primary_agent!(ai_user)
|
|
42
42
|
|
|
@@ -202,7 +202,7 @@ module Collavre
|
|
|
202
202
|
)
|
|
203
203
|
|
|
204
204
|
if comment.save
|
|
205
|
-
|
|
205
|
+
task_claim_service.finalize(agent: agent, task: claimed_task, comment: comment) if claimed_task
|
|
206
206
|
dispatch_a2a(agent, comment)
|
|
207
207
|
render json: { comment_id: comment.id }, status: :created
|
|
208
208
|
else
|
|
@@ -463,90 +463,6 @@ module Collavre
|
|
|
463
463
|
nil
|
|
464
464
|
end
|
|
465
465
|
|
|
466
|
-
# Atomically claim a delegated task for completion. Two-step:
|
|
467
|
-
# 1. Find a candidate task in delegated state, scoped to this
|
|
468
|
-
# agent + topic. With task_id supplied, exact match (required
|
|
469
|
-
# under topic concurrency > 1 where multiple delegated tasks
|
|
470
|
-
# coexist; the client echoes the dispatch's task_id). Without
|
|
471
|
-
# task_id (legacy clients), oldest-first.
|
|
472
|
-
# 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
|
|
473
|
-
# status == 'delegated' under the lock, then update! to 'done'.
|
|
474
|
-
# Concurrent claimers block on the lock; the loser sees the
|
|
475
|
-
# already-flipped status post-lock and returns nil so the
|
|
476
|
-
# caller can refuse the duplicate.
|
|
477
|
-
# update_all (NOT update!) is required to skip Task's
|
|
478
|
-
# after_update_commit callbacks at claim time. The callbacks fire
|
|
479
|
-
# check_trigger_loop_completion (which enqueues TriggerLoopCheckJob)
|
|
480
|
-
# and broadcast_stop_button_removal (which reads reply_comment).
|
|
481
|
-
# Both depend on the reply comment already existing — but reply()
|
|
482
|
-
# claims BEFORE comment.save to win the race against concurrent
|
|
483
|
-
# /reply calls. If update! fired the trigger-loop check here, the
|
|
484
|
-
# job could run (cooldown_seconds: 0) before comment.save commits,
|
|
485
|
-
# find no agent comment, and leave the loop stuck in "running".
|
|
486
|
-
# finalize_claimed_task replays both callbacks after the comment is
|
|
487
|
-
# persisted via Task#fire_completion_callbacks_after_external_claim.
|
|
488
|
-
def claim_delegated_task(agent, topic, requested_task_id)
|
|
489
|
-
scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
|
|
490
|
-
candidate =
|
|
491
|
-
if requested_task_id.present?
|
|
492
|
-
scope.find_by(id: requested_task_id)
|
|
493
|
-
else
|
|
494
|
-
scope.order(:created_at).first
|
|
495
|
-
end
|
|
496
|
-
return nil unless candidate
|
|
497
|
-
|
|
498
|
-
claimed = nil
|
|
499
|
-
Task.transaction do
|
|
500
|
-
locked = Task.lock.find_by(id: candidate.id)
|
|
501
|
-
next unless locked && locked.status == "delegated"
|
|
502
|
-
|
|
503
|
-
Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
|
|
504
|
-
claimed = locked.reload
|
|
505
|
-
end
|
|
506
|
-
claimed
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
# Post-claim side effects, run only after the reply comment is saved.
|
|
510
|
-
# Links the comment to the claimed task, releases the ResourceTracker
|
|
511
|
-
# slot the AiAgentJob held under task.id, advances the parent
|
|
512
|
-
# workflow (if any), and drains the topic queue — mirroring
|
|
513
|
-
# AiAgentJob#perform's success path for non-delegated runs.
|
|
514
|
-
def finalize_claimed_task(agent, task, comment)
|
|
515
|
-
comment.update_column(:task_id, task.id)
|
|
516
|
-
|
|
517
|
-
Orchestration::ResourceTracker.for(agent).release!(task.id)
|
|
518
|
-
|
|
519
|
-
if task.parent_task_id.present?
|
|
520
|
-
Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
524
|
-
|
|
525
|
-
# Replay the after_update_commit callbacks that were bypassed by
|
|
526
|
-
# update_all in claim_delegated_task — now that the reply comment
|
|
527
|
-
# is linked, TriggerLoopCheckJob can read it and decide whether to
|
|
528
|
-
# advance/await/complete the drop-trigger loop, and the stop-button
|
|
529
|
-
# broadcast has a comment to render.
|
|
530
|
-
task.fire_completion_callbacks_after_external_claim
|
|
531
|
-
|
|
532
|
-
# Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
|
|
533
|
-
# would also stop on its next beat (task no longer "delegated"), but that
|
|
534
|
-
# is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
|
|
535
|
-
# drops the moment Claude's reply lands.
|
|
536
|
-
broadcast_claude_idle(agent, task, comment)
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
# Clear the chat typing indicator via the canonical status broadcaster
|
|
540
|
-
# (the same one AiAgentService uses for every other agent), so the Claude
|
|
541
|
-
# path emits an identical "idle" agent_status payload.
|
|
542
|
-
def broadcast_claude_idle(agent, task, comment)
|
|
543
|
-
creative = comment.creative&.effective_origin
|
|
544
|
-
return unless creative
|
|
545
|
-
|
|
546
|
-
AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
|
|
547
|
-
.broadcast_status("idle")
|
|
548
|
-
end
|
|
549
|
-
|
|
550
466
|
# When an MCP session unregisters, any tasks still in delegated state
|
|
551
467
|
# are abandoned — the client that would have called /reply is gone.
|
|
552
468
|
# Mirror the cancel path so the agent's slot and each topic's queue
|
|
@@ -665,111 +581,24 @@ module Collavre
|
|
|
665
581
|
).dispatch
|
|
666
582
|
end
|
|
667
583
|
|
|
668
|
-
#
|
|
669
|
-
#
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
# configured names that would otherwise alias onto ONE shared agent
|
|
673
|
-
# identity (ActionCable stream, routing state, tasks, creative shares).
|
|
674
|
-
# The slug stays for human readability; the digest decides identity. Same
|
|
675
|
-
# normalized name -> same key, so idempotent re-register/reuse is
|
|
676
|
-
# preserved. Normalization (strip+downcase) keeps case/whitespace-only
|
|
677
|
-
# differences folded, matching the prior behavior — only the lossy
|
|
678
|
-
# punctuation/spacing collapse is fixed.
|
|
679
|
-
def session_agent_email(agent_name)
|
|
680
|
-
normalized = agent_name.to_s.strip.downcase
|
|
681
|
-
slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
|
|
682
|
-
slug = "session" if slug.blank?
|
|
683
|
-
digest = Digest::SHA256.hexdigest(normalized)[0, 10]
|
|
684
|
-
"claude-channel-#{current_user.id}-#{slug}-#{digest}@agent.collavre.local"
|
|
685
|
-
end
|
|
686
|
-
|
|
687
|
-
# One ai_user per (current_user, agent_name) so a human's sessions share
|
|
688
|
-
# one Agent identity. Same agent_name re-registering reuses the existing
|
|
689
|
-
# row — idempotent retries (and every additional session) don't
|
|
690
|
-
# proliferate agents.
|
|
691
|
-
#
|
|
692
|
-
# Returns nil when a row with the deterministic email already exists
|
|
693
|
-
# but is owned by someone else or isn't a Claude Channel ai_user. The
|
|
694
|
-
# email format is human-derivable (current_user.id + slug + digest), so a
|
|
695
|
-
# foreign row could be planted by signup/import; silently reusing it
|
|
696
|
-
# would attach the caller's inbox feedback share to that foreign User
|
|
697
|
-
# and leave the plugin's AgentChannel subscription rejected on
|
|
698
|
-
# ownership mismatch. Caller renders 409 in that case.
|
|
699
|
-
def find_or_create_session_agent(agent_name)
|
|
700
|
-
email = session_agent_email(agent_name)
|
|
701
|
-
|
|
702
|
-
existing = User.find_by(email: email)
|
|
703
|
-
return verified_session_agent(existing) if existing
|
|
704
|
-
|
|
705
|
-
# routing_expression: nil so the new ai_user is not matchable until
|
|
706
|
-
# the client claims the per-agent stream via AgentChannel. See the
|
|
707
|
-
# comment on verified_session_agent.
|
|
708
|
-
User.create!(
|
|
709
|
-
email: email,
|
|
710
|
-
name: "Claude Channel (#{agent_name})",
|
|
711
|
-
password: SecureRandom.hex(32),
|
|
712
|
-
llm_vendor: "anthropic",
|
|
713
|
-
llm_model: "claude-code",
|
|
714
|
-
created_by_id: current_user.id,
|
|
715
|
-
searchable: false,
|
|
716
|
-
routing_expression: nil
|
|
717
|
-
)
|
|
718
|
-
rescue ActiveRecord::RecordNotUnique
|
|
719
|
-
# A concurrent registration for the same (user, agent_name) won the
|
|
720
|
-
# users.email unique race. The desired row now exists — re-find and
|
|
721
|
-
# re-verify ownership instead of surfacing a 500 that aborts one of the
|
|
722
|
-
# two simultaneously launching plugin instances.
|
|
723
|
-
verified_session_agent(User.find_by(email: email))
|
|
584
|
+
# Provisions the (agent, topic) identities for a Claude Code
|
|
585
|
+
# registration. Memoized per request; behavior extracted verbatim.
|
|
586
|
+
def session_provisioner
|
|
587
|
+
@session_provisioner ||= AiAgent::SessionProvisioner.new(current_user)
|
|
724
588
|
end
|
|
725
589
|
|
|
726
|
-
#
|
|
727
|
-
#
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
# agent:user:<id> — otherwise comments matched between this POST returning
|
|
731
|
-
# and the client's subsequent cable subscribe would broadcast into an
|
|
732
|
-
# empty stream, stranding delegated tasks until stuck recovery.
|
|
733
|
-
def verified_session_agent(ai_user)
|
|
734
|
-
return nil unless ai_user &&
|
|
735
|
-
ai_user.created_by_id == current_user.id &&
|
|
736
|
-
ai_user.ai_user? &&
|
|
737
|
-
ai_user.claude_channel_agent?
|
|
738
|
-
|
|
739
|
-
ai_user
|
|
590
|
+
# Claims and finalizes delegated tasks on /reply. Stateless, so a single
|
|
591
|
+
# instance is reused for the request.
|
|
592
|
+
def task_claim_service
|
|
593
|
+
@task_claim_service ||= AiAgent::TaskClaimService.new
|
|
740
594
|
end
|
|
741
595
|
|
|
742
|
-
#
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
#
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
|
|
749
|
-
return existing if existing
|
|
750
|
-
|
|
751
|
-
label = session_label.to_s.strip.presence || session_id
|
|
752
|
-
inbox.topics.create!(
|
|
753
|
-
name: unique_topic_name(inbox, "Claude #{label}"),
|
|
754
|
-
user: current_user,
|
|
755
|
-
primary_agent_id: ai_user.id,
|
|
756
|
-
session_id: session_id
|
|
757
|
-
)
|
|
758
|
-
end
|
|
759
|
-
|
|
760
|
-
# Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
|
|
761
|
-
# friendly labels collide (e.g. both rooted at a dir named "src") cannot
|
|
762
|
-
# share a name. Append the smallest numeric suffix that is free.
|
|
763
|
-
def unique_topic_name(inbox, desired)
|
|
764
|
-
return desired unless inbox.topics.exists?(name: desired)
|
|
765
|
-
|
|
766
|
-
n = 2
|
|
767
|
-
loop do
|
|
768
|
-
candidate = "#{desired} (#{n})"
|
|
769
|
-
return candidate unless inbox.topics.exists?(name: candidate)
|
|
770
|
-
|
|
771
|
-
n += 1
|
|
772
|
-
end
|
|
596
|
+
# Thin delegator to TaskClaimService#claim. Kept as a controller method
|
|
597
|
+
# (rather than calling the service inline in #reply) so the concurrency
|
|
598
|
+
# test that patches AgentsController#claim_delegated_task to inject a race
|
|
599
|
+
# keeps exercising the same seam.
|
|
600
|
+
def claim_delegated_task(agent, topic, requested_task_id)
|
|
601
|
+
task_claim_service.claim(agent: agent, topic: topic, requested_task_id: requested_task_id)
|
|
773
602
|
end
|
|
774
603
|
end
|
|
775
604
|
end
|
|
@@ -4,41 +4,23 @@ module Collavre
|
|
|
4
4
|
module Api
|
|
5
5
|
module V1
|
|
6
6
|
class BaseController < ActionController::API
|
|
7
|
+
include Collavre::Api::Authenticatable
|
|
8
|
+
|
|
7
9
|
before_action :authenticate!
|
|
8
10
|
|
|
11
|
+
rescue_from Collavre::ApiError do |e|
|
|
12
|
+
render json: { error: e.message }, status: e.status
|
|
13
|
+
end
|
|
14
|
+
|
|
9
15
|
private
|
|
10
16
|
|
|
11
17
|
def authenticate!
|
|
12
18
|
token = extract_bearer_token
|
|
13
|
-
if token.blank?
|
|
14
|
-
render json: { error: "Missing authentication token" }, status: :unauthorized
|
|
15
|
-
return
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
access_token = Doorkeeper::AccessToken.by_token(token)
|
|
19
|
-
unless access_token&.accessible?
|
|
20
|
-
render json: { error: "Invalid authentication token" }, status: :unauthorized
|
|
21
|
-
return
|
|
22
|
-
end
|
|
19
|
+
raise Collavre::ApiError.new("Missing authentication token", status: :unauthorized) if token.blank?
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
render json: { error: "User not found" }, status: :unauthorized
|
|
27
|
-
return
|
|
21
|
+
unless find_user_by_bearer_token(token)
|
|
22
|
+
raise Collavre::ApiError.new("Invalid authentication token", status: :unauthorized)
|
|
28
23
|
end
|
|
29
|
-
|
|
30
|
-
Collavre::Current.user = user
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def extract_bearer_token
|
|
34
|
-
auth_header = request.headers["Authorization"]
|
|
35
|
-
return nil unless auth_header&.start_with?("Bearer ")
|
|
36
|
-
|
|
37
|
-
auth_header.sub("Bearer ", "")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def current_user
|
|
41
|
-
Collavre::Current.user
|
|
42
24
|
end
|
|
43
25
|
end
|
|
44
26
|
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Api
|
|
5
|
+
module V1
|
|
6
|
+
module Mobile
|
|
7
|
+
# The heart of the loop. GET surfaces pending approvals (+ recent agent
|
|
8
|
+
# replies) with a stable spoken ref number and a decision-ready summary.
|
|
9
|
+
# POST :id/respond branches on the referenced event KIND:
|
|
10
|
+
# (A) approval/permission comment → the spoken response is a BUTTON
|
|
11
|
+
# press (allow|deny) → decide_claude_channel_permission! + relay.
|
|
12
|
+
# (B) ordinary agent message → the spoken response is FREE TEXT passed
|
|
13
|
+
# verbatim back to the agent as a reply comment (no server parsing).
|
|
14
|
+
class AgentEventsController < BaseController
|
|
15
|
+
# Spoken decision verbs. The app no longer addresses approvals by
|
|
16
|
+
# ordinal, so a reply to an approval event is just an allow/deny verb;
|
|
17
|
+
# anything else asks for clarification rather than firing a decision.
|
|
18
|
+
APPROVE = /(승인|허용|적용|approve|allow|confirm|네|좋아|오케이|\bok\b|\byes\b)/i
|
|
19
|
+
DENY = /(거절|거부|반려|deny|reject|취소|아니|\bno\b)/i
|
|
20
|
+
|
|
21
|
+
def index
|
|
22
|
+
approvals = pending_approvals
|
|
23
|
+
notices = system_inbox_messages
|
|
24
|
+
|
|
25
|
+
# Every undecided approval is emitted on every poll — the server keeps
|
|
26
|
+
# no per-client cursor. Re-speaking is prevented on the CLIENT (an
|
|
27
|
+
# in-memory "already spoken" set), the same at-least-once shape as
|
|
28
|
+
# notices: a pending approval keeps surfacing until it is decided
|
|
29
|
+
# (which clears it from pending_approvals) or the app restarts (when a
|
|
30
|
+
# still-pending approval SHOULD be re-surfaced). The old `since` cursor
|
|
31
|
+
# filtered here, but a batch high-water-mark could burn past an approval
|
|
32
|
+
# whose created_at trailed a newer notice, losing it forever.
|
|
33
|
+
events = approvals.map do |c|
|
|
34
|
+
{
|
|
35
|
+
id: c.id, type: "approval_requested",
|
|
36
|
+
title: title_for_topic(c.topic_id),
|
|
37
|
+
summary: summarizer.approval_summary(comment: c, label: label_for_topic(c.topic_id)),
|
|
38
|
+
speak: true, requires_response: true, topic_id: c.topic_id,
|
|
39
|
+
created_at: c.created_at.iso8601(6)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
events += notices.map do |c|
|
|
44
|
+
# The notice stands in for the origin comment it quotes; the app
|
|
45
|
+
# lists/reads it against the ORIGIN thread and replies route there.
|
|
46
|
+
origin_topic_id = c.quoted_comment&.topic_id || c.topic_id
|
|
47
|
+
{
|
|
48
|
+
id: c.id, type: "agent_reply",
|
|
49
|
+
title: title_for_topic(origin_topic_id),
|
|
50
|
+
summary: speakable(c.content),
|
|
51
|
+
speak: true, requires_response: c.quoted_comment_id.present?,
|
|
52
|
+
topic_id: origin_topic_id, created_at: c.created_at.iso8601(6)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
render json: events.sort_by { |e| e[:created_at] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def respond
|
|
60
|
+
comment = Collavre::Comment.find_by(id: params[:id])
|
|
61
|
+
unless comment && authorized_comment?(comment)
|
|
62
|
+
return render json: { error: "Event not found" }, status: :not_found
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Acting on a notice means the user has heard it — clear it from the
|
|
66
|
+
# unread inbox set so it isn't read again on the next poll.
|
|
67
|
+
mark_inbox_read(comment)
|
|
68
|
+
|
|
69
|
+
if comment.claude_channel_permission?
|
|
70
|
+
decide_on(comment)
|
|
71
|
+
else
|
|
72
|
+
relay_free_text(comment)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The app calls this when it finishes reading a message ALOUD (whether or
|
|
77
|
+
# not the user then replies). Reading = hearing it, so the inbox read
|
|
78
|
+
# pointer advances and the notice stops surfacing — the SAME read-state
|
|
79
|
+
# the inbox badge uses. A crash before this call leaves the notice unread,
|
|
80
|
+
# so the next poll re-reads it (at-least-once, not the lossy old cursor).
|
|
81
|
+
def read
|
|
82
|
+
comment = Collavre::Comment.find_by(id: params[:id])
|
|
83
|
+
unless comment && authorized_comment?(comment)
|
|
84
|
+
return render json: { error: "Event not found" }, status: :not_found
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
mark_inbox_read(comment)
|
|
88
|
+
head :ok
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# The user's Inbox → System topic is the per-user alarm stream: mentions,
|
|
94
|
+
# agent replies, share notices, … land here as system-authored
|
|
95
|
+
# (user_id: nil) comments that QUOTE the origin comment. This is the
|
|
96
|
+
# canonical, agent-ownership-independent feed the voice loop reads aloud.
|
|
97
|
+
#
|
|
98
|
+
# Emission is gated by the inbox's OWN read-state — the CommentReadPointer
|
|
99
|
+
# (comments with id > last_read_comment_id are unread) that also drives the
|
|
100
|
+
# inbox badge — NOT by the client's `since` cursor. A `since` cursor is a
|
|
101
|
+
# fetch-time high-water-mark: any notice fetched-but-never-spoken (crash,
|
|
102
|
+
# background, busy queue, restart) gets burned past and is lost forever,
|
|
103
|
+
# even though it stays unread in the inbox. Driving off the read pointer
|
|
104
|
+
# makes "unread in the inbox" and "read aloud by the app" the same set; the
|
|
105
|
+
# app marks a notice read (POST :id/read) only after actually speaking it.
|
|
106
|
+
def system_inbox_messages
|
|
107
|
+
inbox = current_user_inbox or return []
|
|
108
|
+
last_read = inbox_read_pointer&.last_read_comment_id || 0
|
|
109
|
+
|
|
110
|
+
scope = Collavre::Comment.where(creative_id: inbox.id, topic_id: inbox.system_topic.id)
|
|
111
|
+
.where("comments.id > ?", last_read)
|
|
112
|
+
.order(:id)
|
|
113
|
+
.limit(50)
|
|
114
|
+
# Approval prompts are surfaced (actionably) by pending_approvals; their
|
|
115
|
+
# System-topic FYI duplicate must not be read a second time.
|
|
116
|
+
scope.reject { |c| c.quoted_comment&.claude_channel_permission? }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inbox_read_pointer
|
|
120
|
+
inbox = current_user_inbox or return nil
|
|
121
|
+
|
|
122
|
+
Collavre::CommentReadPointer.find_by(user: current_user, creative: inbox)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Advance the inbox read pointer to (at least) this notice — forward-only,
|
|
126
|
+
# so a later message is never un-read. Only inbox-creative comments move the
|
|
127
|
+
# inbox pointer; calling this for an approval (which lives in the origin
|
|
128
|
+
# creative and is owned by pending_approvals) is a harmless no-op.
|
|
129
|
+
def mark_inbox_read(comment)
|
|
130
|
+
inbox = current_user_inbox or return
|
|
131
|
+
return unless comment.creative_id == inbox.id
|
|
132
|
+
|
|
133
|
+
pointer = Collavre::CommentReadPointer.find_or_initialize_by(user: current_user, creative: inbox)
|
|
134
|
+
new_id = [ pointer.last_read_comment_id || 0, comment.id ].max
|
|
135
|
+
pointer.update(last_read_comment_id: new_id)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def current_user_inbox
|
|
139
|
+
return @current_user_inbox if defined?(@current_user_inbox)
|
|
140
|
+
|
|
141
|
+
@current_user_inbox = Collavre::Creative.inbox_for(current_user)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Read the whole message aloud, but spoken: strip markdown link syntax to
|
|
145
|
+
# its text and collapse whitespace so TTS doesn't read raw URLs.
|
|
146
|
+
def speakable(text)
|
|
147
|
+
text.to_s.gsub(/\[([^\]]+)\]\([^)]*\)/, '\1').gsub(/\s+/, " ").strip
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def authorized_comment?(comment)
|
|
151
|
+
return true if comment.approver_id == current_user.id
|
|
152
|
+
return true if own_inbox_notice?(comment)
|
|
153
|
+
|
|
154
|
+
author = comment.user
|
|
155
|
+
author.respond_to?(:created_by_id) && agent_ids.include?(author.id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A system-authored notice in the caller's own Inbox#System topic. Owning
|
|
159
|
+
# the inbox is the authorization proof (the notice has no approver/author).
|
|
160
|
+
def own_inbox_notice?(comment)
|
|
161
|
+
inbox = current_user_inbox or return false
|
|
162
|
+
|
|
163
|
+
comment.user_id.nil? &&
|
|
164
|
+
comment.creative_id == inbox.id &&
|
|
165
|
+
comment.topic_id == inbox.system_topic.id
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# (A) bounded decision: spoken response IS the approve/deny button.
|
|
169
|
+
def decide_on(comment)
|
|
170
|
+
behavior = behavior_from(params[:response])
|
|
171
|
+
|
|
172
|
+
unless %w[allow deny].include?(behavior)
|
|
173
|
+
return render_speak(:clarify_decision, action: { type: "needs_clarification", comment_id: comment.id })
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
case decide_permission(comment, behavior)
|
|
177
|
+
when :unauthorized
|
|
178
|
+
render_speak(:not_authorized, action: { type: "not_authorized", comment_id: comment.id }, status: :forbidden)
|
|
179
|
+
when :already_decided
|
|
180
|
+
render_speak(:already_decided, action: { type: "already_decided", comment_id: comment.id })
|
|
181
|
+
else
|
|
182
|
+
render_speak(
|
|
183
|
+
behavior == "allow" ? :approved : :denied,
|
|
184
|
+
action: { type: behavior == "allow" ? "approved" : "denied", comment_id: comment.id }
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# (B) free-form: relay the utterance verbatim as a human reply.
|
|
190
|
+
def relay_free_text(comment)
|
|
191
|
+
text = params[:response].to_s.strip
|
|
192
|
+
return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
|
|
193
|
+
|
|
194
|
+
# A System-inbox notice is a stand-in for the origin comment it quotes;
|
|
195
|
+
# the reply must land on that origin thread (the System topic itself
|
|
196
|
+
# never dispatches AI), not on the notice.
|
|
197
|
+
target = comment.quoted_comment || comment
|
|
198
|
+
topic = target.topic
|
|
199
|
+
creative = topic&.creative&.effective_origin || target.creative
|
|
200
|
+
# quoted_comment threads the reply to the message being answered, but
|
|
201
|
+
# review_type: :question keeps review_message? false — otherwise the
|
|
202
|
+
# agent's response would update the quoted comment IN PLACE (review flow)
|
|
203
|
+
# instead of posting a new reply, so no new comment is created and the
|
|
204
|
+
# Inbox#System alarm never fires. Same guard as InboxReplyService (#1301).
|
|
205
|
+
reply = creative.comments.create!(
|
|
206
|
+
content: text, user: current_user, topic: topic,
|
|
207
|
+
quoted_comment: target, review_type: :question
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
render_speak(:relayed, action: { type: "relayed", comment_id: reply.id, topic_id: topic&.id })
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def behavior_from(response)
|
|
214
|
+
text = response.to_s.strip
|
|
215
|
+
return "allow" if text.match?(APPROVE)
|
|
216
|
+
return "deny" if text.match?(DENY)
|
|
217
|
+
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|