collavre 0.23.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/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/comments_popup.css +40 -0
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- 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/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- 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/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/topics_controller.js +47 -0
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- 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/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -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/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- 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/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/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- 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 +12 -0
- data/app/models/collavre/user.rb +33 -9
- 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 +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- 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/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +1 -0
- data/config/locales/comments.ko.yml +1 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- 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/lib/collavre/version.rb +1 -1
- metadata +42 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9658b35d00bfa088037c3bc4ff2720913c69f43746e924545805f08482b091e9
|
|
4
|
+
data.tar.gz: e614bb81be4080199ee4dbf9ae248ce43ca1f88cbff11678514ecde1d66d69f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be8a6c28498c6878c9eb1ae6287bdd97adfb395452b1a0296e1a7cce5e159763b904f93926bfff8c1ad3b4db077abfd0b5c77f2aa1fada447e65d291cc0bab24
|
|
7
|
+
data.tar.gz: a6fde45bf58c0707916f3f02e0d2293244b2c8598de2f8913555183d77c88590df29ec67f00a2a3c093378a3f2c7b0995975aaf61fb5f9d95a6c345b0fc85568
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1716,6 +1716,46 @@ body.chat-fullscreen {
|
|
|
1716
1716
|
opacity: 0.5;
|
|
1717
1717
|
font-style: italic;
|
|
1718
1718
|
}
|
|
1719
|
+
|
|
1720
|
+
/* Topic list popup button (pinned right of the scrolling tab bar) */
|
|
1721
|
+
.comment-topics-bar {
|
|
1722
|
+
display: flex;
|
|
1723
|
+
align-items: center;
|
|
1724
|
+
gap: 0.5em;
|
|
1725
|
+
}
|
|
1726
|
+
.comment-topics-bar .comment-topics-list {
|
|
1727
|
+
flex: 1;
|
|
1728
|
+
min-width: 0;
|
|
1729
|
+
}
|
|
1730
|
+
.topic-list-btn {
|
|
1731
|
+
flex-shrink: 0;
|
|
1732
|
+
display: inline-flex;
|
|
1733
|
+
align-items: center;
|
|
1734
|
+
justify-content: center;
|
|
1735
|
+
width: 24px;
|
|
1736
|
+
height: 24px;
|
|
1737
|
+
border-radius: var(--radius-3);
|
|
1738
|
+
border: 1px solid var(--color-border);
|
|
1739
|
+
background: transparent;
|
|
1740
|
+
cursor: pointer;
|
|
1741
|
+
color: var(--color-muted);
|
|
1742
|
+
padding: 0;
|
|
1743
|
+
}
|
|
1744
|
+
.topic-list-btn:hover {
|
|
1745
|
+
background: var(--color-section-bg);
|
|
1746
|
+
color: var(--color-text);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/* Archived entries inside the topic list popup */
|
|
1750
|
+
.topic-list-item {
|
|
1751
|
+
display: inline-flex;
|
|
1752
|
+
align-items: center;
|
|
1753
|
+
gap: 0.3em;
|
|
1754
|
+
}
|
|
1755
|
+
.topic-list-item--archived {
|
|
1756
|
+
opacity: 0.5;
|
|
1757
|
+
font-style: italic;
|
|
1758
|
+
}
|
|
1719
1759
|
.topic-branch-icon {
|
|
1720
1760
|
font-size: 0.75em;
|
|
1721
1761
|
opacity: 0.6;
|
|
@@ -94,6 +94,18 @@
|
|
|
94
94
|
z-index: 0;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
.landing-hero-eyebrow { /* L4 — category line, above the claim */
|
|
98
|
+
font-size: var(--text-2);
|
|
99
|
+
font-weight: var(--weight-6);
|
|
100
|
+
letter-spacing: 0.08em;
|
|
101
|
+
text-transform: uppercase;
|
|
102
|
+
color: var(--color-active);
|
|
103
|
+
margin: 0 auto var(--space-4);
|
|
104
|
+
text-align: center;
|
|
105
|
+
position: relative;
|
|
106
|
+
z-index: 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
97
109
|
.landing-hero-title { /* L1 — main headline */
|
|
98
110
|
font-size: var(--text-7);
|
|
99
111
|
font-weight: var(--weight-8);
|
|
@@ -117,6 +129,17 @@
|
|
|
117
129
|
z-index: 1;
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
.landing-hero-tagline { /* L4 — the mechanism, one line */
|
|
133
|
+
font-size: var(--text-2);
|
|
134
|
+
color: var(--text-muted);
|
|
135
|
+
margin: 0 auto var(--space-6);
|
|
136
|
+
max-width: 720px;
|
|
137
|
+
text-align: center;
|
|
138
|
+
line-height: var(--leading-2);
|
|
139
|
+
position: relative;
|
|
140
|
+
z-index: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
.landing-hero-brand { /* L1 — "콜라브" in brand color */
|
|
121
144
|
font-size: var(--text-7);
|
|
122
145
|
font-weight: var(--weight-8);
|
|
@@ -238,6 +261,7 @@
|
|
|
238
261
|
background: var(--surface-section);
|
|
239
262
|
}
|
|
240
263
|
|
|
264
|
+
/* Three tools, three cards — one row on desktop. */
|
|
241
265
|
.landing-problem-grid {
|
|
242
266
|
display: grid;
|
|
243
267
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
@@ -258,6 +282,12 @@
|
|
|
258
282
|
border-color: var(--text-muted);
|
|
259
283
|
}
|
|
260
284
|
|
|
285
|
+
/* The fourth card is the punchline — the copy the AI era added. */
|
|
286
|
+
.landing-problem-card-accent {
|
|
287
|
+
border-color: var(--color-active);
|
|
288
|
+
background: color-mix(in srgb, var(--color-active) 6%, var(--surface-bg));
|
|
289
|
+
}
|
|
290
|
+
|
|
261
291
|
.landing-problem-icon {
|
|
262
292
|
font-size: var(--text-5); /* L2 — emoji decorative */
|
|
263
293
|
display: block;
|
|
@@ -432,6 +462,68 @@ body.dark-mode .landing-demo-dark { display: block; }
|
|
|
432
462
|
body:not(.light-mode) .landing-demo-dark { display: block; }
|
|
433
463
|
}
|
|
434
464
|
|
|
465
|
+
/* ─── Interface: the product still ─────────────────────────────────────────── */
|
|
466
|
+
.landing-shot-frame {
|
|
467
|
+
max-width: 980px;
|
|
468
|
+
margin: 0 auto;
|
|
469
|
+
border-radius: var(--radius-4);
|
|
470
|
+
border: var(--border-1) solid var(--border-color);
|
|
471
|
+
background: var(--surface-section);
|
|
472
|
+
overflow: hidden;
|
|
473
|
+
box-shadow: var(--shadow-3);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.landing-shot-img {
|
|
477
|
+
display: block;
|
|
478
|
+
width: 100%;
|
|
479
|
+
height: auto;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/* Same light/dark pairing the demo video uses: the screenshot is app chrome, so a
|
|
483
|
+
light shot on a dark page reads as a bug, not as a screenshot. */
|
|
484
|
+
.landing-shot-dark { display: none; }
|
|
485
|
+
|
|
486
|
+
body.dark-mode .landing-shot-light { display: none; }
|
|
487
|
+
body.dark-mode .landing-shot-dark { display: block; }
|
|
488
|
+
|
|
489
|
+
@media (prefers-color-scheme: dark) {
|
|
490
|
+
body:not(.light-mode) .landing-shot-light { display: none; }
|
|
491
|
+
body:not(.light-mode) .landing-shot-dark { display: block; }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.landing-interface-points {
|
|
495
|
+
list-style: none;
|
|
496
|
+
padding: 0;
|
|
497
|
+
margin: var(--space-5) auto 0;
|
|
498
|
+
max-width: 980px;
|
|
499
|
+
display: grid;
|
|
500
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
501
|
+
gap: var(--space-3) var(--space-5);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.landing-interface-points li {
|
|
505
|
+
position: relative;
|
|
506
|
+
padding-left: var(--space-4);
|
|
507
|
+
font-size: var(--text-2);
|
|
508
|
+
color: var(--text-muted);
|
|
509
|
+
line-height: var(--leading-2);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.landing-interface-points li::before {
|
|
513
|
+
content: "";
|
|
514
|
+
position: absolute;
|
|
515
|
+
left: 0;
|
|
516
|
+
top: 0.6em;
|
|
517
|
+
width: 6px;
|
|
518
|
+
height: 6px;
|
|
519
|
+
border-radius: 50%;
|
|
520
|
+
background: var(--color-accent, var(--text-muted));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
@media (max-width: 780px) {
|
|
524
|
+
.landing-interface-points { grid-template-columns: 1fr; }
|
|
525
|
+
}
|
|
526
|
+
|
|
435
527
|
/* ─── Responsive ────────────────────────────────────────────────────────────── */
|
|
436
528
|
@media (max-width: 640px) {
|
|
437
529
|
.landing-container {
|
|
@@ -81,14 +81,16 @@
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
#link-creative-modal,
|
|
84
|
-
#topic-search-modal
|
|
84
|
+
#topic-search-modal,
|
|
85
|
+
#topic-list-modal {
|
|
85
86
|
min-width: 320px;
|
|
86
87
|
z-index: calc(var(--layer-modal) + 10);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/* When chat is fullscreen, these modals must sit above it */
|
|
90
91
|
body.chat-fullscreen #link-creative-modal,
|
|
91
|
-
body.chat-fullscreen #topic-search-modal
|
|
92
|
+
body.chat-fullscreen #topic-search-modal,
|
|
93
|
+
body.chat-fullscreen #topic-list-modal {
|
|
92
94
|
z-index: calc(var(--layer-fullscreen) + 1);
|
|
93
95
|
}
|
|
94
96
|
|
|
@@ -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
|
|
@@ -64,9 +64,19 @@ module Collavre
|
|
|
64
64
|
signed_id = blob.signed_id
|
|
65
65
|
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(signed_id)}%"
|
|
66
66
|
|
|
67
|
-
Creative.where("description LIKE ?", pattern).
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
creatives = Creative.where("description LIKE ?", pattern).includes(:origin)
|
|
68
|
+
return false if creatives.empty?
|
|
69
|
+
|
|
70
|
+
# Permission is evaluated on the origin for linked creatives. Resolve every
|
|
71
|
+
# referencing creative to its permission base, then delegate the write-rank
|
|
72
|
+
# deny-invariant to the canonical PermissionFilter (owner wins; a
|
|
73
|
+
# user-specific entry — including an explicit no_access deny — takes
|
|
74
|
+
# precedence over the public share). readable_ids batch-loads the cache
|
|
75
|
+
# entries once, preserving the prior N+1 avoidance.
|
|
76
|
+
bases = creatives.map { |c| c.origin_id.nil? ? c : c.origin }.compact.uniq
|
|
77
|
+
Collavre::Creatives::PermissionFilter.new(user: Current.user)
|
|
78
|
+
.readable_ids(bases.map(&:id), min_permission: :write)
|
|
79
|
+
.any?
|
|
70
80
|
end
|
|
71
81
|
end
|
|
72
82
|
end
|