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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Collavre
|
|
2
|
-
require "
|
|
2
|
+
require "net/http"
|
|
3
3
|
require "nokogiri"
|
|
4
4
|
require "uri"
|
|
5
5
|
require "ipaddr"
|
|
@@ -29,18 +29,22 @@ module Collavre
|
|
|
29
29
|
IPAddr.new("ff00::/8")
|
|
30
30
|
].freeze
|
|
31
31
|
|
|
32
|
+
# Minimal HTTP result the fetcher reasons about. `body` is only populated for
|
|
33
|
+
# successful (2xx) responses; redirects carry a `location` instead.
|
|
34
|
+
Response = Struct.new(:code, :content_type, :body, :location, keyword_init: true)
|
|
35
|
+
|
|
32
36
|
def self.fetch(url)
|
|
33
37
|
new(url).fetch
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
def initialize(url,
|
|
40
|
+
def initialize(url, http_client: nil, logger: Rails.logger)
|
|
37
41
|
@url = url
|
|
38
|
-
@
|
|
42
|
+
@http_client = http_client || PinnedHttpClient.new
|
|
39
43
|
@logger = logger
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def fetch
|
|
43
|
-
uri =
|
|
47
|
+
uri = parse_http_uri(@url)
|
|
44
48
|
return {} unless uri
|
|
45
49
|
|
|
46
50
|
html, base_uri = read_html(uri)
|
|
@@ -56,47 +60,53 @@ module Collavre
|
|
|
56
60
|
private
|
|
57
61
|
|
|
58
62
|
def read_html(uri, redirect_limit = MAX_REDIRECTS)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
# Resolve+validate the host ONCE per hop and pin the request to that exact
|
|
64
|
+
# IP. This closes the DNS-rebinding TOCTOU gap: a hostname that validates
|
|
65
|
+
# as public here can no longer be re-resolved to a private/metadata IP at
|
|
66
|
+
# connect time, because the connection targets the pinned address (with the
|
|
67
|
+
# original hostname preserved for Host/SNI).
|
|
68
|
+
addresses = safe_addresses(uri)
|
|
69
|
+
return [ nil, nil ] if addresses.empty?
|
|
70
|
+
|
|
71
|
+
response = fetch_via_pinned_addresses(uri, addresses)
|
|
72
|
+
return [ nil, nil ] unless response
|
|
73
|
+
|
|
74
|
+
if redirect?(response.code)
|
|
75
|
+
return [ nil, nil ] if redirect_limit <= 0
|
|
76
|
+
|
|
77
|
+
redirected_uri = normalize_redirect_uri(uri, response.location)
|
|
78
|
+
return [ nil, nil ] unless redirected_uri
|
|
79
|
+
|
|
80
|
+
# Recurse so the new host is resolved+validated+pinned from scratch;
|
|
81
|
+
# never follow a hop to an internal address.
|
|
82
|
+
return read_html(redirected_uri, redirect_limit - 1)
|
|
69
83
|
end
|
|
70
|
-
[ html, base_uri ]
|
|
71
|
-
rescue OpenURI::HTTPRedirect => e
|
|
72
|
-
return [ nil, nil ] if redirect_limit <= 0
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
return [ nil, nil ] unless redirected_uri
|
|
85
|
+
return [ nil, nil ] unless success?(response.code)
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
content_type = response.content_type
|
|
88
|
+
if content_type && HTML_CONTENT_TYPES.none? { |type| content_type.include?(type) }
|
|
89
|
+
return [ nil, nil ]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[ response.body, uri ]
|
|
93
|
+
rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
94
|
+
OpenSSL::SSL::SSLError, URI::InvalidURIError => e
|
|
79
95
|
@logger&.info("Link preview fetch skipped for #{@url}: #{e.class} #{e.message}")
|
|
80
96
|
[ nil, nil ]
|
|
81
97
|
end
|
|
82
98
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
return unless uri
|
|
86
|
-
return unless allowed_destination?(uri)
|
|
87
|
-
|
|
88
|
-
uri
|
|
99
|
+
def redirect?(code)
|
|
100
|
+
code.to_i.between?(300, 399)
|
|
89
101
|
end
|
|
90
102
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
return unless new_uri
|
|
94
|
-
return unless allowed_destination?(new_uri)
|
|
95
|
-
|
|
96
|
-
new_uri
|
|
103
|
+
def success?(code)
|
|
104
|
+
code.to_i.between?(200, 299)
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
def normalize_redirect_uri(current_uri, redirected)
|
|
108
|
+
return if redirected.blank?
|
|
109
|
+
|
|
100
110
|
target_uri = redirected.is_a?(URI) ? redirected : URI.parse(redirected.to_s)
|
|
101
111
|
target_uri = current_uri.merge(target_uri) if target_uri.relative?
|
|
102
112
|
return unless %w[http https].include?(target_uri.scheme)
|
|
@@ -116,15 +126,45 @@ module Collavre
|
|
|
116
126
|
nil
|
|
117
127
|
end
|
|
118
128
|
|
|
119
|
-
|
|
129
|
+
# Resolve the host, reject if it maps to ANY unsafe address, and return every
|
|
130
|
+
# validated public IP to pin against. Rejecting when any resolved address is
|
|
131
|
+
# unsafe (not just the one we pick) keeps defense-in-depth against
|
|
132
|
+
# split-horizon DNS returning a mix of public and private records. Returning
|
|
133
|
+
# all safe addresses lets the caller fall back to the next one when the first
|
|
134
|
+
# is unreachable (e.g. an AAAA record with no IPv6 egress).
|
|
135
|
+
def safe_addresses(uri)
|
|
120
136
|
host = uri.hostname
|
|
121
|
-
return
|
|
137
|
+
return [] if host.nil? || host.empty?
|
|
122
138
|
|
|
123
139
|
addresses = resolve_addresses(host)
|
|
124
|
-
return
|
|
125
|
-
return
|
|
140
|
+
return [] if addresses.empty?
|
|
141
|
+
return [] if addresses.any? { |address| unsafe_ip?(address) }
|
|
126
142
|
|
|
127
|
-
|
|
143
|
+
addresses
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Try each pre-validated address in turn, pinning the connection to it, and
|
|
147
|
+
# return the first response we can actually open. Connection-level failures
|
|
148
|
+
# fall through to the next candidate; if none connect, the caller treats it
|
|
149
|
+
# as an empty preview. Every address here already passed safe_addresses, so
|
|
150
|
+
# falling back never targets an unsafe IP.
|
|
151
|
+
def fetch_via_pinned_addresses(uri, addresses)
|
|
152
|
+
last_error = nil
|
|
153
|
+
addresses.each do |address|
|
|
154
|
+
return @http_client.get(
|
|
155
|
+
uri,
|
|
156
|
+
ip: address,
|
|
157
|
+
headers: REQUEST_OPTIONS,
|
|
158
|
+
open_timeout: OPEN_TIMEOUT,
|
|
159
|
+
read_timeout: READ_TIMEOUT,
|
|
160
|
+
max_bytes: MAX_BYTES
|
|
161
|
+
)
|
|
162
|
+
rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
163
|
+
OpenSSL::SSL::SSLError => e
|
|
164
|
+
last_error = e
|
|
165
|
+
end
|
|
166
|
+
@logger&.info("Link preview fetch skipped for #{@url}: #{last_error.class} #{last_error.message}") if last_error
|
|
167
|
+
nil
|
|
128
168
|
end
|
|
129
169
|
|
|
130
170
|
def resolve_addresses(host)
|
|
@@ -226,5 +266,56 @@ module Collavre
|
|
|
226
266
|
|
|
227
267
|
text.to_s.gsub(/\s+/, " ").strip
|
|
228
268
|
end
|
|
269
|
+
|
|
270
|
+
# Performs a single (non-redirect-following) GET against a pre-resolved IP
|
|
271
|
+
# while keeping the original hostname for the Host header and TLS SNI/cert
|
|
272
|
+
# verification. Net::HTTP#ipaddr= pins the socket to `ip`, so no second DNS
|
|
273
|
+
# lookup happens between validation and connect. Enforces the caller's open/
|
|
274
|
+
# read timeouts and byte cap.
|
|
275
|
+
class PinnedHttpClient
|
|
276
|
+
def get(uri, ip:, headers:, open_timeout:, read_timeout:, max_bytes:)
|
|
277
|
+
# Pass an explicit nil proxy: Net::HTTP.new defaults p_addr to :ENV, so a
|
|
278
|
+
# set http_proxy/HTTP_PROXY would route through the proxy, which resolves
|
|
279
|
+
# the hostname itself and defeats `http.ipaddr = ip` pinning β reopening
|
|
280
|
+
# the DNS-rebinding/SSRF gap. nil forces a direct, pinned connection.
|
|
281
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
282
|
+
http.use_ssl = uri.scheme == "https"
|
|
283
|
+
http.ipaddr = ip
|
|
284
|
+
http.open_timeout = open_timeout
|
|
285
|
+
http.read_timeout = read_timeout
|
|
286
|
+
|
|
287
|
+
request = Net::HTTP::Get.new(uri, headers)
|
|
288
|
+
response_struct = nil
|
|
289
|
+
|
|
290
|
+
http.start do |conn|
|
|
291
|
+
conn.request(request) do |response|
|
|
292
|
+
body = read_capped_body(response, max_bytes)
|
|
293
|
+
response_struct = Response.new(
|
|
294
|
+
code: response.code.to_i,
|
|
295
|
+
content_type: response.content_type,
|
|
296
|
+
body: body,
|
|
297
|
+
location: response["location"]
|
|
298
|
+
)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
response_struct
|
|
303
|
+
ensure
|
|
304
|
+
http&.finish if http&.started?
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
def read_capped_body(response, max_bytes)
|
|
310
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
311
|
+
|
|
312
|
+
body = +""
|
|
313
|
+
response.read_body do |chunk|
|
|
314
|
+
body << chunk
|
|
315
|
+
break if body.bytesize >= max_bytes
|
|
316
|
+
end
|
|
317
|
+
body.byteslice(0, max_bytes)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
229
320
|
end
|
|
230
321
|
end
|
|
@@ -20,6 +20,12 @@ module Collavre
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
created = []
|
|
23
|
+
# Per-parent sequence counters. closure_tree orders siblings by the
|
|
24
|
+
# `sequence` column (default 0), but plain Creative.create never assigns
|
|
25
|
+
# it β so without this every imported sibling would share sequence 0 and
|
|
26
|
+
# fall back to a DB-defined tie-break (stable on SQLite, arbitrary on
|
|
27
|
+
# Postgres), corrupting document order. Assign contiguous values here.
|
|
28
|
+
seq = {}
|
|
23
29
|
root = parent
|
|
24
30
|
i = 0
|
|
25
31
|
if create_root
|
|
@@ -28,7 +34,7 @@ module Collavre
|
|
|
28
34
|
end
|
|
29
35
|
if i < lines.size && lines[i] !~ /^\s*#/ && lines[i] !~ /^\s*[-*+]/
|
|
30
36
|
page_title = lines[i].strip
|
|
31
|
-
root =
|
|
37
|
+
root = create_child(user: user, parent: parent, description: page_title, seq: seq)
|
|
32
38
|
created << root
|
|
33
39
|
i += 1
|
|
34
40
|
end
|
|
@@ -73,7 +79,7 @@ module Collavre
|
|
|
73
79
|
code_html = build_code_block_html(code_content, fence_info)
|
|
74
80
|
|
|
75
81
|
new_parent = stack.any? ? stack.last[1] : root
|
|
76
|
-
c =
|
|
82
|
+
c = create_child(user: user, parent: new_parent, description: code_html, seq: seq)
|
|
77
83
|
created << c
|
|
78
84
|
next
|
|
79
85
|
end
|
|
@@ -81,7 +87,7 @@ module Collavre
|
|
|
81
87
|
if (table_data = parse_markdown_table(lines, i))
|
|
82
88
|
table_html = build_table_html(table_data, image_refs)
|
|
83
89
|
new_parent = stack.any? ? stack.last[1] : root
|
|
84
|
-
c =
|
|
90
|
+
c = create_child(user: user, parent: new_parent, description: table_html, seq: seq)
|
|
85
91
|
created << c
|
|
86
92
|
i = table_data[:next_index]
|
|
87
93
|
elsif line =~ /^(#+)\s+(.*)$/
|
|
@@ -89,7 +95,7 @@ module Collavre
|
|
|
89
95
|
desc = ApplicationController.helpers.markdown_links_to_html($2.strip, image_refs)
|
|
90
96
|
stack.pop while stack.any? && stack.last[0] >= level
|
|
91
97
|
new_parent = stack.any? ? stack.last[1] : root
|
|
92
|
-
c =
|
|
98
|
+
c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
|
|
93
99
|
created << c
|
|
94
100
|
stack << [ level, c ]
|
|
95
101
|
i += 1
|
|
@@ -99,14 +105,14 @@ module Collavre
|
|
|
99
105
|
bullet_level = 10 + indent / 2
|
|
100
106
|
stack.pop while stack.any? && stack.last[0] >= bullet_level
|
|
101
107
|
new_parent = stack.any? ? stack.last[1] : root
|
|
102
|
-
c =
|
|
108
|
+
c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
|
|
103
109
|
created << c
|
|
104
110
|
stack << [ bullet_level, c ]
|
|
105
111
|
i += 1
|
|
106
112
|
elsif !line.strip.empty?
|
|
107
113
|
desc = ApplicationController.helpers.markdown_links_to_html(line.strip, image_refs)
|
|
108
114
|
new_parent = stack.any? ? stack.last[1] : root
|
|
109
|
-
c =
|
|
115
|
+
c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
|
|
110
116
|
created << c
|
|
111
117
|
i += 1
|
|
112
118
|
else
|
|
@@ -121,6 +127,24 @@ module Collavre
|
|
|
121
127
|
created
|
|
122
128
|
end
|
|
123
129
|
|
|
130
|
+
# Creates a child under `parent` with an explicit `sequence`, tracking the
|
|
131
|
+
# next value per parent in `seq`. The first time a parent is seen the
|
|
132
|
+
# counter is seeded past any pre-existing children so an import into an
|
|
133
|
+
# already-populated parent appends after them instead of colliding at 0.
|
|
134
|
+
# A nil parent means a root-level import (top-level page with no parent_id);
|
|
135
|
+
# closure_tree orders roots by `sequence` too, so seed from Creative.roots.
|
|
136
|
+
def self.create_child(user:, parent:, description:, seq:)
|
|
137
|
+
key = parent&.id
|
|
138
|
+
unless seq.key?(key)
|
|
139
|
+
siblings = parent ? parent.children : Creative.roots
|
|
140
|
+
max = siblings.maximum(:sequence)
|
|
141
|
+
seq[key] = max ? max + 1 : 0
|
|
142
|
+
end
|
|
143
|
+
creative = Creative.create(user: user, parent: parent, description: description, sequence: seq[key])
|
|
144
|
+
seq[key] += 1
|
|
145
|
+
creative
|
|
146
|
+
end
|
|
147
|
+
|
|
124
148
|
def self.build_code_block_html(code_content, language = nil)
|
|
125
149
|
escaped_content = ERB::Util.html_escape(code_content)
|
|
126
150
|
lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
|
|
@@ -126,24 +126,10 @@ module Collavre
|
|
|
126
126
|
"id" => @agent.id,
|
|
127
127
|
"name" => @agent.name,
|
|
128
128
|
"display_name" => @agent.display_name,
|
|
129
|
-
"type" =>
|
|
129
|
+
"type" => AgentTypeClassifier.classify(@agent)
|
|
130
130
|
}
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def extract_agent_type
|
|
134
|
-
# Extract agent type from system_prompt or default
|
|
135
|
-
prompt = @agent.system_prompt.to_s.downcase
|
|
136
|
-
case prompt
|
|
137
|
-
when /developer|κ°λ°/ then "developer"
|
|
138
|
-
when /pm|project.?manager|νλ‘μ νΈ/ then "pm"
|
|
139
|
-
when /qa|test|quality|ν
μ€νΈ|νμ§/ then "qa"
|
|
140
|
-
when /research|μ‘°μ¬|μ°κ΅¬/ then "researcher"
|
|
141
|
-
when /market|λ§μΌν
/ then "marketer"
|
|
142
|
-
when /plan|κΈ°ν/ then "planner"
|
|
143
|
-
else "agent"
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
133
|
def build_collaborators
|
|
148
134
|
return [] unless @creative
|
|
149
135
|
|
|
@@ -64,7 +64,7 @@ module Collavre
|
|
|
64
64
|
return unless creative_id && context&.key?("topic")
|
|
65
65
|
|
|
66
66
|
topic_id = context.dig("topic", "id")
|
|
67
|
-
scope = Comment.public_only
|
|
67
|
+
scope = Comment.public_only.without_approval_action
|
|
68
68
|
.where(creative_id: creative_id, topic_id: topic_id)
|
|
69
69
|
.where.not(user_id: [ task.agent_id, nil ])
|
|
70
70
|
.order(created_at: :desc)
|
|
@@ -34,23 +34,10 @@ module Collavre
|
|
|
34
34
|
"name" => user.name,
|
|
35
35
|
"display_name" => user.respond_to?(:display_name) ? user.display_name : user.name,
|
|
36
36
|
"is_ai" => user.ai_user?,
|
|
37
|
-
"type" => user.ai_user? ?
|
|
37
|
+
"type" => user.ai_user? ? AgentTypeClassifier.classify(user) : "human"
|
|
38
38
|
}
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def extract_agent_type(user)
|
|
42
|
-
prompt = user.system_prompt.to_s.downcase
|
|
43
|
-
case prompt
|
|
44
|
-
when /developer|κ°λ°/ then "developer"
|
|
45
|
-
when /pm|project.?manager|νλ‘μ νΈ/ then "pm"
|
|
46
|
-
when /qa|test|quality|ν
μ€νΈ|νμ§/ then "qa"
|
|
47
|
-
when /research|μ‘°μ¬|μ°κ΅¬/ then "researcher"
|
|
48
|
-
when /market|λ§μΌν
/ then "marketer"
|
|
49
|
-
when /plan|κΈ°ν/ then "planner"
|
|
50
|
-
else "agent"
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
41
|
def mentioned_user(chat_context)
|
|
55
42
|
content = chat_context["content"]
|
|
56
43
|
return nil unless content
|
|
@@ -113,15 +113,13 @@ module Tools
|
|
|
113
113
|
raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
116
|
+
# worktree_id is a real column denormalized from config (see
|
|
117
|
+
# Collavre::Channel#sync_indexed_config_columns), so the lookup is an
|
|
118
|
+
# indexed query β it also backs the (topic_id, worktree_id) unique index
|
|
119
|
+
# that makes attach idempotent.
|
|
120
120
|
sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
|
|
121
121
|
def lookup_channel(topic, worktree_id)
|
|
122
|
-
Collavre::PreviewChannel.
|
|
123
|
-
c.worktree_id.to_s == worktree_id.to_s
|
|
124
|
-
end
|
|
122
|
+
Collavre::PreviewChannel.find_by(topic_id: topic.id, worktree_id: worktree_id)
|
|
125
123
|
end
|
|
126
124
|
end
|
|
127
125
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% comment_topic = comment.topic %>
|
|
2
2
|
<% current_topic_id = local_assigns[:current_topic_id] %>
|
|
3
|
-
<% has_pending_action = comment.
|
|
3
|
+
<% has_pending_action = comment.approval_action? && comment.action_executed_at.blank? %>
|
|
4
4
|
<% approver_id = comment.approver_id %>
|
|
5
5
|
<div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>" data-ai-user="<%= comment.user&.ai_user? %>" data-streaming="<%= local_assigns.fetch(:streaming, false) %>">
|
|
6
6
|
<div class="comment-select">
|
|
@@ -90,8 +90,8 @@
|
|
|
90
90
|
</div>
|
|
91
91
|
<% end %>
|
|
92
92
|
<div class="comment-content" data-comment-target="content"><%= comment.content %></div>
|
|
93
|
-
<% if comment.
|
|
94
|
-
<% version_count = comment.
|
|
93
|
+
<% if comment.comment_versions_count > 0 %>
|
|
94
|
+
<% version_count = comment.comment_versions_count %>
|
|
95
95
|
<% selected_id = comment.selected_version_id %>
|
|
96
96
|
<% version_ids = comment.comment_versions.order(:version_number).pluck(:id) %>
|
|
97
97
|
<% selected_index = selected_id ? (version_ids.index(selected_id)&.+(1) || version_count) : version_count %>
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
<a href="#" class="comment-topic-switch" data-topic-id="<%= comment_topic.id %>">#<%= comment_topic.name %></a>
|
|
174
174
|
</div>
|
|
175
175
|
<% end %>
|
|
176
|
-
<% if comment.
|
|
176
|
+
<% if comment.approval_action? %>
|
|
177
177
|
<div class="comment-action-block" data-comment-id="<%= comment.id %>">
|
|
178
178
|
<details class="comment-action-details">
|
|
179
179
|
<summary class="comment-action-summary"><%= t("collavre.comments.action_summary") %></summary>
|
|
@@ -125,9 +125,17 @@
|
|
|
125
125
|
data-action="dragover->comments--contexts#handleExternalDragOver drop->comments--contexts#handleExternalDrop dragleave->comments--contexts#handleExternalDragLeave"></div>
|
|
126
126
|
<div data-share-modal-target="container"></div>
|
|
127
127
|
<div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
|
|
128
|
-
<div
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
<div class="comment-topics-bar">
|
|
129
|
+
<div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
|
|
130
|
+
data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
|
|
131
|
+
data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
|
|
132
|
+
<button type="button" class="topic-list-btn"
|
|
133
|
+
data-action="click->comments--topics#openTopicListPopup"
|
|
134
|
+
title="<%= t('collavre.comments.topic_list') %>"
|
|
135
|
+
aria-label="<%= t('collavre.comments.topic_list') %>">
|
|
136
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
131
139
|
<%= render Collavre::UserMentionMenuComponent.new(menu_id: 'mention-menu') %>
|
|
132
140
|
<%= render Collavre::CommandMenuComponent.new(menu_id: 'command-menu') %>
|
|
133
141
|
<div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
|
|
@@ -50,6 +50,15 @@
|
|
|
50
50
|
style="margin-left:0.5em;font-family:monospace;font-size:0.9em;">
|
|
51
51
|
<%= t('collavre.creatives.index.toggle_markdown') %>
|
|
52
52
|
</button>
|
|
53
|
+
<span id="inline-save-status"
|
|
54
|
+
class="inline-save-status"
|
|
55
|
+
role="status"
|
|
56
|
+
aria-live="polite"
|
|
57
|
+
data-label-pending="<%= t('collavre.creatives.index.save_status_pending') %>"
|
|
58
|
+
data-label-saving="<%= t('collavre.creatives.index.save_status_saving') %>"
|
|
59
|
+
data-label-saved="<%= t('collavre.creatives.index.save_status_saved') %>"
|
|
60
|
+
data-label-error="<%= t('collavre.creatives.index.save_status_error') %>"
|
|
61
|
+
style="margin-left:auto;font-size:0.85em;color:var(--text-secondary);"></span>
|
|
53
62
|
</div>
|
|
54
63
|
<div style="margin-top:0.5em;">
|
|
55
64
|
<button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<section id="problem" class="landing-section landing-problem">
|
|
20
20
|
<div class="landing-container">
|
|
21
21
|
<h2 class="landing-section-title"><%= t('collavre.landing.problem.title') %></h2>
|
|
22
|
+
<p class="landing-section-sub"><%= t('collavre.landing.problem.subtitle') %></p>
|
|
22
23
|
<div class="landing-problem-grid">
|
|
23
24
|
<div class="landing-problem-card">
|
|
24
25
|
<span class="landing-problem-icon">π</span>
|
|
@@ -52,40 +53,77 @@
|
|
|
52
53
|
<p><%= t('collavre.landing.features.tree.desc') %></p>
|
|
53
54
|
</div>
|
|
54
55
|
|
|
55
|
-
<div class="landing-feature-card">
|
|
56
|
-
<div class="landing-feature-icon">π€</div>
|
|
57
|
-
<h3><%= t('collavre.landing.features.ai.title') %></h3>
|
|
58
|
-
<p><%= t('collavre.landing.features.ai.desc') %></p>
|
|
59
|
-
</div>
|
|
60
|
-
|
|
61
56
|
<div class="landing-feature-card">
|
|
62
57
|
<div class="landing-feature-icon">π‘</div>
|
|
63
58
|
<h3><%= t('collavre.landing.features.context.title') %></h3>
|
|
64
59
|
<p><%= t('collavre.landing.features.context.desc') %></p>
|
|
65
60
|
</div>
|
|
66
61
|
|
|
62
|
+
<div class="landing-feature-card">
|
|
63
|
+
<div class="landing-feature-icon">π€</div>
|
|
64
|
+
<h3><%= t('collavre.landing.features.ai.title') %></h3>
|
|
65
|
+
<p><%= t('collavre.landing.features.ai.desc') %></p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
67
68
|
<div class="landing-feature-card">
|
|
68
69
|
<div class="landing-feature-icon">π¬</div>
|
|
69
70
|
<h3><%= t('collavre.landing.features.chat.title') %></h3>
|
|
70
71
|
<p><%= t('collavre.landing.features.chat.desc') %></p>
|
|
71
72
|
</div>
|
|
72
73
|
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
73
77
|
|
|
78
|
+
<%# βββββββββββββββββββββββββββββββββββββ THE INTERFACE ββββββββββββββββββββββββββββββββββββββ %>
|
|
79
|
+
<%# A still before the film: the reader decides in two seconds whether this is a surface they
|
|
80
|
+
would write in, and a video they have not pressed play on cannot answer that. %>
|
|
81
|
+
<section id="interface" class="landing-section landing-interface">
|
|
82
|
+
<div class="landing-container">
|
|
83
|
+
<h2 class="landing-section-title"><%= t('collavre.landing.interface.title') %></h2>
|
|
84
|
+
<p class="landing-section-sub"><%= t('collavre.landing.interface.subtitle') %></p>
|
|
85
|
+
|
|
86
|
+
<div class="landing-shot-frame landing-shot-light">
|
|
87
|
+
<%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}.png",
|
|
88
|
+
class: "landing-shot-img",
|
|
89
|
+
alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
|
|
74
90
|
</div>
|
|
91
|
+
<div class="landing-shot-frame landing-shot-dark">
|
|
92
|
+
<%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}-dark.png",
|
|
93
|
+
class: "landing-shot-img",
|
|
94
|
+
alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<ul class="landing-interface-points">
|
|
98
|
+
<li><%= t('collavre.landing.interface.markdown') %></li>
|
|
99
|
+
<li><%= t('collavre.landing.interface.rich') %></li>
|
|
100
|
+
<li><%= t('collavre.landing.interface.drag') %></li>
|
|
101
|
+
<li><%= t('collavre.landing.interface.presence') %></li>
|
|
102
|
+
</ul>
|
|
75
103
|
</div>
|
|
76
104
|
</section>
|
|
77
105
|
|
|
78
106
|
<%# ββββββββββββββββββββββββββββββββββββββββββ DEMO ββββββββββββββββββββββββββββββββββββββββββ %>
|
|
107
|
+
<%# Demo video/poster URLs are full absolute URLs stored per-locale in
|
|
108
|
+
config/locales/landing.*.yml. They point at the hosted assets directly, so
|
|
109
|
+
they resolve from any environment (preview included) with no DB lookup and
|
|
110
|
+
no env-bound blob ids. Render the section only when a video URL is set. %>
|
|
111
|
+
<% demo_video = t('collavre.landing.demo.video', default: nil) %>
|
|
112
|
+
<% demo_poster = t('collavre.landing.demo.poster', default: nil) %>
|
|
113
|
+
<% demo_video_dark = t('collavre.landing.demo.video_dark', default: nil) %>
|
|
114
|
+
<% demo_poster_dark = t('collavre.landing.demo.poster_dark', default: nil) %>
|
|
115
|
+
<% if demo_video.present? || demo_video_dark.present? %>
|
|
79
116
|
<section id="demo" class="landing-section landing-demo">
|
|
80
117
|
<div class="landing-container">
|
|
81
118
|
<h2 class="landing-section-title"><%= t('collavre.landing.demo.title') %></h2>
|
|
82
119
|
<%# Light mode video %>
|
|
120
|
+
<% if demo_video.present? %>
|
|
83
121
|
<div class="landing-demo-frame landing-demo-light" data-controller="landing-video">
|
|
84
122
|
<video class="landing-demo-video"
|
|
85
123
|
data-landing-video-target="video"
|
|
86
124
|
autoplay muted loop playsinline
|
|
87
|
-
poster="
|
|
88
|
-
<source src="
|
|
125
|
+
poster="<%= demo_poster %>">
|
|
126
|
+
<source src="<%= demo_video %>" type="video/mp4">
|
|
89
127
|
</video>
|
|
90
128
|
<div class="landing-demo-progress" data-landing-video-target="progressBar">
|
|
91
129
|
<div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
|
|
@@ -94,13 +132,15 @@
|
|
|
94
132
|
<span class="landing-demo-toggle-icon" data-landing-video-target="icon">ββ</span>
|
|
95
133
|
</button>
|
|
96
134
|
</div>
|
|
135
|
+
<% end %>
|
|
97
136
|
<%# Dark mode video %>
|
|
137
|
+
<% if demo_video_dark.present? %>
|
|
98
138
|
<div class="landing-demo-frame landing-demo-dark" data-controller="landing-video">
|
|
99
139
|
<video class="landing-demo-video"
|
|
100
140
|
data-landing-video-target="video"
|
|
101
141
|
autoplay muted loop playsinline
|
|
102
|
-
poster="
|
|
103
|
-
<source src="
|
|
142
|
+
poster="<%= demo_poster_dark %>">
|
|
143
|
+
<source src="<%= demo_video_dark %>" type="video/mp4">
|
|
104
144
|
</video>
|
|
105
145
|
<div class="landing-demo-progress" data-landing-video-target="progressBar">
|
|
106
146
|
<div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
|
|
@@ -109,8 +149,10 @@
|
|
|
109
149
|
<span class="landing-demo-toggle-icon" data-landing-video-target="icon">ββ</span>
|
|
110
150
|
</button>
|
|
111
151
|
</div>
|
|
152
|
+
<% end %>
|
|
112
153
|
</div>
|
|
113
154
|
</section>
|
|
155
|
+
<% end %>
|
|
114
156
|
|
|
115
157
|
<%# βββββββββββββββββββββββββββββββββββββββ FINAL CTA ββββββββββββββββββββββββββββββββββββββββ %>
|
|
116
158
|
<section class="landing-section landing-cta">
|
|
@@ -140,6 +140,7 @@ en:
|
|
|
140
140
|
batch_delete_not_found: Some selected messages could not be found.
|
|
141
141
|
topic_search_placeholder: Search topics...
|
|
142
142
|
topic_main: All Messages
|
|
143
|
+
topic_list: Topic list
|
|
143
144
|
fullscreen: Full screen
|
|
144
145
|
exit_fullscreen: Exit full screen
|
|
145
146
|
typo_keep_label: keep
|
|
@@ -137,6 +137,7 @@ ko:
|
|
|
137
137
|
batch_delete_not_found: μΌλΆ μ νν λ©μμ§λ₯Ό μ°Ύμ μ μμ΅λλ€.
|
|
138
138
|
topic_search_placeholder: ν ν½ κ²μ...
|
|
139
139
|
topic_main: μ 체 λ©μΈμ§
|
|
140
|
+
topic_list: ν ν½ λͺ©λ‘
|
|
140
141
|
fullscreen: μ 체 νλ©΄
|
|
141
142
|
exit_fullscreen: μ 체 νλ©΄ μ’
λ£
|
|
142
143
|
typo_keep_label: μ μ§
|
|
@@ -136,6 +136,10 @@ en:
|
|
|
136
136
|
metadata_save: Save
|
|
137
137
|
toggle_markdown: Markdown
|
|
138
138
|
toggle_richtext: Rich Text
|
|
139
|
+
save_status_pending: Save pending
|
|
140
|
+
save_status_saving: Savingβ¦
|
|
141
|
+
save_status_saved: Saved
|
|
142
|
+
save_status_error: Save failed
|
|
139
143
|
markdown_to_richtext_confirm: Switching to Rich Text will discard the Markdown
|
|
140
144
|
source. Continue?
|
|
141
145
|
richtext_to_markdown_confirm: Switching to Markdown will discard the current
|
|
@@ -156,6 +160,8 @@ en:
|
|
|
156
160
|
errors:
|
|
157
161
|
no_permission: You do not have permission to perform this action.
|
|
158
162
|
metadata_must_be_object: Metadata must be an object.
|
|
163
|
+
description_read_only_source: cannot be changed directly for read-only source
|
|
164
|
+
content
|
|
159
165
|
inline_editor:
|
|
160
166
|
placeholder: Describe the creativeβ¦
|
|
161
167
|
markdown_placeholder: Write in Markdownβ¦
|