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
|
@@ -15,6 +15,10 @@ module Collavre
|
|
|
15
15
|
# Dispatch A2A events if the response mentions any AI agents
|
|
16
16
|
def dispatch
|
|
17
17
|
return unless @reply_comment&.content.present?
|
|
18
|
+
# An approval-action message (approve button / approved) is a human decision
|
|
19
|
+
# surface and must never reach an AI agent — not even via an @mention in
|
|
20
|
+
# another agent's reply. Same invariant as Comment#dispatch_to_orchestration.
|
|
21
|
+
return if @reply_comment.approval_action?
|
|
18
22
|
|
|
19
23
|
mentioned_agents = find_mentioned_agents
|
|
20
24
|
return if mentioned_agents.empty?
|
|
@@ -146,7 +146,7 @@ module Collavre
|
|
|
146
146
|
history_chars = 0
|
|
147
147
|
count = 0
|
|
148
148
|
|
|
149
|
-
Comment.public_only.where(creative_id: creative_id)
|
|
149
|
+
Comment.public_only.without_approval_action.where(creative_id: creative_id)
|
|
150
150
|
.where(topic_id: topic_id)
|
|
151
151
|
.where.not(user_id: nil)
|
|
152
152
|
.includes(:user)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module AiAgent
|
|
5
|
+
# Provisions the identities a Claude Code registration needs:
|
|
6
|
+
# - one shared ai_user per (human, agent_name) — the Agent identity
|
|
7
|
+
# - one Topic per (agent, session_id) — the Session identity
|
|
8
|
+
#
|
|
9
|
+
# Extracted from Api::V1::AgentsController#register so the action stays a
|
|
10
|
+
# thin orchestration of provisioning + inbox share. Behavior is identical to
|
|
11
|
+
# the inlined controller methods it replaces.
|
|
12
|
+
class SessionProvisioner
|
|
13
|
+
def initialize(user)
|
|
14
|
+
@user = user
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# One ai_user per (user, agent_name) so a human's sessions share one Agent
|
|
18
|
+
# identity. Same agent_name re-registering reuses the existing row —
|
|
19
|
+
# idempotent retries (and every additional session) don't proliferate
|
|
20
|
+
# agents.
|
|
21
|
+
#
|
|
22
|
+
# Returns nil when a row with the deterministic email already exists but is
|
|
23
|
+
# owned by someone else or isn't a Claude Channel ai_user. The email format
|
|
24
|
+
# is human-derivable (user.id + slug + digest), so a foreign row could be
|
|
25
|
+
# planted by signup/import; silently reusing it would attach the caller's
|
|
26
|
+
# inbox feedback share to that foreign User and leave the plugin's
|
|
27
|
+
# AgentChannel subscription rejected on ownership mismatch. Caller renders
|
|
28
|
+
# 409 in that case.
|
|
29
|
+
def find_or_create_agent(agent_name)
|
|
30
|
+
email = session_agent_email(agent_name)
|
|
31
|
+
|
|
32
|
+
existing = User.find_by(email: email)
|
|
33
|
+
return verified_agent(existing) if existing
|
|
34
|
+
|
|
35
|
+
# routing_expression: nil so the new ai_user is not matchable until the
|
|
36
|
+
# client claims the per-agent stream via AgentChannel. See the comment on
|
|
37
|
+
# verified_agent.
|
|
38
|
+
User.create!(
|
|
39
|
+
email: email,
|
|
40
|
+
name: "Claude Channel (#{agent_name})",
|
|
41
|
+
password: SecureRandom.hex(32),
|
|
42
|
+
llm_vendor: "anthropic",
|
|
43
|
+
llm_model: "claude-code",
|
|
44
|
+
created_by_id: @user.id,
|
|
45
|
+
searchable: false,
|
|
46
|
+
routing_expression: nil
|
|
47
|
+
)
|
|
48
|
+
rescue ActiveRecord::RecordNotUnique
|
|
49
|
+
# A concurrent registration for the same (user, agent_name) won the
|
|
50
|
+
# users.email unique race. The desired row now exists — re-find and
|
|
51
|
+
# re-verify ownership instead of surfacing a 500 that aborts one of the
|
|
52
|
+
# two simultaneously launching plugin instances.
|
|
53
|
+
verified_agent(User.find_by(email: email))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One Topic per (agent, session_id). On re-register (including --resume from
|
|
57
|
+
# the same cwd, which yields the same session_id) the existing topic is
|
|
58
|
+
# reused — even if archived — so the conversation persists instead of
|
|
59
|
+
# orphaning. A fresh session gets a new topic under the same shared agent,
|
|
60
|
+
# which is how one agent fans out to many sessions.
|
|
61
|
+
def find_or_create_topic(inbox, ai_user, session_id, session_label)
|
|
62
|
+
existing = inbox.topics.find_by(primary_agent_id: ai_user.id, session_id: session_id)
|
|
63
|
+
return existing if existing
|
|
64
|
+
|
|
65
|
+
label = session_label.to_s.strip.presence || session_id
|
|
66
|
+
inbox.topics.create!(
|
|
67
|
+
name: unique_topic_name(inbox, "Claude #{label}"),
|
|
68
|
+
user: @user,
|
|
69
|
+
primary_agent_id: ai_user.id,
|
|
70
|
+
session_id: session_id
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Deterministic, collision-free email key for a (user, agent_name) agent.
|
|
77
|
+
# The readable slug is lossy — "qa/bot", "qa bot" and "qa--bot" all squeeze
|
|
78
|
+
# to "qa-bot", and any all-symbol name collapses to "session" — so a short
|
|
79
|
+
# digest of the normalized raw name disambiguates distinct configured names
|
|
80
|
+
# that would otherwise alias onto ONE shared agent identity (ActionCable
|
|
81
|
+
# stream, routing state, tasks, creative shares). The slug stays for human
|
|
82
|
+
# readability; the digest decides identity. Same normalized name -> same
|
|
83
|
+
# key, so idempotent re-register/reuse is preserved. Normalization
|
|
84
|
+
# (strip+downcase) keeps case/whitespace-only differences folded, matching
|
|
85
|
+
# the prior behavior — only the lossy punctuation/spacing collapse is fixed.
|
|
86
|
+
def session_agent_email(agent_name)
|
|
87
|
+
normalized = agent_name.to_s.strip.downcase
|
|
88
|
+
slug = normalized.gsub(/[^a-z0-9-]+/, "-").squeeze("-").gsub(/\A-|-\z/, "")
|
|
89
|
+
slug = "session" if slug.blank?
|
|
90
|
+
digest = Digest::SHA256.hexdigest(normalized)[0, 10]
|
|
91
|
+
"claude-channel-#{@user.id}-#{slug}-#{digest}@agent.collavre.local"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns the agent only when it is the current user's own Claude Channel
|
|
95
|
+
# agent; nil otherwise (the caller renders :conflict). Routing activation
|
|
96
|
+
# stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
|
|
97
|
+
# becomes matchable only once a WebSocket subscriber exists for
|
|
98
|
+
# agent:user:<id> — otherwise comments matched between register returning
|
|
99
|
+
# and the client's subsequent cable subscribe would broadcast into an empty
|
|
100
|
+
# stream, stranding delegated tasks until stuck recovery.
|
|
101
|
+
def verified_agent(ai_user)
|
|
102
|
+
return nil unless ai_user &&
|
|
103
|
+
ai_user.created_by_id == @user.id &&
|
|
104
|
+
ai_user.ai_user? &&
|
|
105
|
+
ai_user.claude_channel_agent?
|
|
106
|
+
|
|
107
|
+
ai_user
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Topics carry a UNIQUE (creative_id, name) index, so two sessions whose
|
|
111
|
+
# friendly labels collide (e.g. both rooted at a dir named "src") cannot
|
|
112
|
+
# share a name. Append the smallest numeric suffix that is free.
|
|
113
|
+
def unique_topic_name(inbox, desired)
|
|
114
|
+
return desired unless inbox.topics.exists?(name: desired)
|
|
115
|
+
|
|
116
|
+
n = 2
|
|
117
|
+
loop do
|
|
118
|
+
candidate = "#{desired} (#{n})"
|
|
119
|
+
return candidate unless inbox.topics.exists?(name: candidate)
|
|
120
|
+
|
|
121
|
+
n += 1
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module AiAgent
|
|
5
|
+
# Atomically claims and finalizes a delegated task on behalf of a Claude
|
|
6
|
+
# Channel /reply. Extracted from Api::V1::AgentsController#reply so the
|
|
7
|
+
# controller only sequences: claim -> save comment -> finalize. Behavior is
|
|
8
|
+
# identical to the inlined controller methods it replaces.
|
|
9
|
+
class TaskClaimService
|
|
10
|
+
# Atomically claim a delegated task for completion. Two-step:
|
|
11
|
+
# 1. Find a candidate task in delegated state, scoped to this agent +
|
|
12
|
+
# topic. With task_id supplied, exact match (required under topic
|
|
13
|
+
# concurrency > 1 where multiple delegated tasks coexist; the client
|
|
14
|
+
# echoes the dispatch's task_id). Without task_id (legacy clients),
|
|
15
|
+
# oldest-first.
|
|
16
|
+
# 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
|
|
17
|
+
# status == 'delegated' under the lock, then update! to 'done'.
|
|
18
|
+
# Concurrent claimers block on the lock; the loser sees the
|
|
19
|
+
# already-flipped status post-lock and returns nil so the caller can
|
|
20
|
+
# refuse the duplicate.
|
|
21
|
+
# update_all (NOT update!) is required to skip Task's after_update_commit
|
|
22
|
+
# callbacks at claim time. The callbacks fire check_trigger_loop_completion
|
|
23
|
+
# (which enqueues TriggerLoopCheckJob) and broadcast_stop_button_removal
|
|
24
|
+
# (which reads reply_comment). Both depend on the reply comment already
|
|
25
|
+
# existing — but reply() claims BEFORE comment.save to win the race against
|
|
26
|
+
# concurrent /reply calls. If update! fired the trigger-loop check here, the
|
|
27
|
+
# job could run (cooldown_seconds: 0) before comment.save commits, find no
|
|
28
|
+
# agent comment, and leave the loop stuck in "running". #finalize replays
|
|
29
|
+
# both callbacks after the comment is persisted via
|
|
30
|
+
# Task#fire_completion_callbacks_after_external_claim.
|
|
31
|
+
def claim(agent:, topic:, requested_task_id:)
|
|
32
|
+
scope = Task.where(agent_id: agent.id, topic_id: topic.id, status: "delegated")
|
|
33
|
+
candidate =
|
|
34
|
+
if requested_task_id.present?
|
|
35
|
+
scope.find_by(id: requested_task_id)
|
|
36
|
+
else
|
|
37
|
+
scope.order(:created_at).first
|
|
38
|
+
end
|
|
39
|
+
return nil unless candidate
|
|
40
|
+
|
|
41
|
+
claimed = nil
|
|
42
|
+
Task.transaction do
|
|
43
|
+
locked = Task.lock.find_by(id: candidate.id)
|
|
44
|
+
next unless locked && locked.status == "delegated"
|
|
45
|
+
|
|
46
|
+
Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
|
|
47
|
+
claimed = locked.reload
|
|
48
|
+
end
|
|
49
|
+
claimed
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Post-claim side effects, run only after the reply comment is saved. Links
|
|
53
|
+
# the comment to the claimed task, releases the ResourceTracker slot the
|
|
54
|
+
# AiAgentJob held under task.id, advances the parent workflow (if any), and
|
|
55
|
+
# drains the topic queue — mirroring AiAgentJob#perform's success path for
|
|
56
|
+
# non-delegated runs.
|
|
57
|
+
def finalize(agent:, task:, comment:)
|
|
58
|
+
comment.update_column(:task_id, task.id)
|
|
59
|
+
|
|
60
|
+
Orchestration::ResourceTracker.for(agent).release!(task.id)
|
|
61
|
+
|
|
62
|
+
if task.parent_task_id.present?
|
|
63
|
+
Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
|
|
67
|
+
|
|
68
|
+
# Replay the after_update_commit callbacks that were bypassed by
|
|
69
|
+
# update_all in #claim — now that the reply comment is linked,
|
|
70
|
+
# TriggerLoopCheckJob can read it and decide whether to advance/await/
|
|
71
|
+
# complete the drop-trigger loop, and the stop-button broadcast has a
|
|
72
|
+
# comment to render.
|
|
73
|
+
task.fire_completion_callbacks_after_external_claim
|
|
74
|
+
|
|
75
|
+
# Clear the typing indicator immediately on reply. ClaudeChannelPresenceJob
|
|
76
|
+
# would also stop on its next beat (task no longer "delegated"), but that
|
|
77
|
+
# is up to HEARTBEAT_SECONDS away — broadcast idle now so the indicator
|
|
78
|
+
# drops the moment Claude's reply lands.
|
|
79
|
+
broadcast_claude_idle(agent, task, comment)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Clear the chat typing indicator via the canonical status broadcaster (the
|
|
85
|
+
# same one AiAgentService uses for every other agent), so the Claude path
|
|
86
|
+
# emits an identical "idle" agent_status payload.
|
|
87
|
+
def broadcast_claude_idle(agent, task, comment)
|
|
88
|
+
creative = comment.creative&.effective_origin
|
|
89
|
+
return unless creative
|
|
90
|
+
|
|
91
|
+
AiAgent::AgentLifecycleManager.new(task: task, agent: agent, creative: creative)
|
|
92
|
+
.broadcast_status("idle")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -10,6 +10,49 @@ module Collavre
|
|
|
10
10
|
|
|
11
11
|
attr_reader :last_input_tokens, :last_output_tokens
|
|
12
12
|
|
|
13
|
+
# Vendor <select> options for AI-agent config. Core ships only its built-in
|
|
14
|
+
# (stateless) providers; vendor engines append their own through
|
|
15
|
+
# register_vendor_option, so core never names a vendor engine.
|
|
16
|
+
BASE_VENDOR_OPTIONS = [
|
|
17
|
+
[ "Google (Gemini)", "google" ],
|
|
18
|
+
[ "OpenAI", "openai" ],
|
|
19
|
+
[ "Anthropic", "anthropic" ]
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def registered_vendor_options
|
|
24
|
+
@registered_vendor_options ||= []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Append a vendor <select> option ([label, value]). Idempotent by value.
|
|
28
|
+
def register_vendor_option(label, value)
|
|
29
|
+
value = value.to_s
|
|
30
|
+
return if BASE_VENDOR_OPTIONS.any? { |_l, v| v == value }
|
|
31
|
+
return if registered_vendor_options.any? { |_l, v| v == value }
|
|
32
|
+
|
|
33
|
+
registered_vendor_options << [ label, value ]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def vendor_options
|
|
37
|
+
BASE_VENDOR_OPTIONS + registered_vendor_options
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Vendors that use stateful/incremental sessions. Base providers are
|
|
41
|
+
# stateless; vendor engines that add session support register here so core
|
|
42
|
+
# never string-matches a vendor name to decide session behavior.
|
|
43
|
+
def session_vendors
|
|
44
|
+
@session_vendors ||= Set.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def register_session_vendor(vendor)
|
|
48
|
+
session_vendors << vendor.to_s.downcase
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def vendor_supports_session?(vendor)
|
|
52
|
+
session_vendors.include?(vendor.to_s.downcase)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
13
56
|
# log_interactions: persist each call to ActivityLog. Default true. Pass false
|
|
14
57
|
# for ephemeral, high-frequency calls on text the user has not submitted (e.g.
|
|
15
58
|
# inline typo correction on debounced typing) so private drafts are never
|
|
@@ -41,8 +84,16 @@ module Collavre
|
|
|
41
84
|
add_messages(@conversation, contents)
|
|
42
85
|
|
|
43
86
|
response = @conversation.complete do |chunk|
|
|
44
|
-
delta = extract_chunk_content(chunk)
|
|
45
|
-
|
|
87
|
+
delta = extract_chunk_content(chunk).to_s
|
|
88
|
+
# Deliberately NOT `blank?`. A delta of exactly "\n\n" — the paragraph
|
|
89
|
+
# break, which providers routinely emit as a token of its own — is
|
|
90
|
+
# blank? == true, so skipping blanks deletes it. And the deleted delta is
|
|
91
|
+
# never yielded, so it is gone from the caller's stream too: AiAgentService
|
|
92
|
+
# persists ResponseStreamer#content, which is built only from the yielded
|
|
93
|
+
# deltas, and the reply lands in the database with its paragraphs glued
|
|
94
|
+
# together. Only truly empty deltas (role-only / tool-call chunks carry no
|
|
95
|
+
# content) are skippable.
|
|
96
|
+
next if delta.empty?
|
|
46
97
|
|
|
47
98
|
response_content << delta
|
|
48
99
|
yield delta if block_given?
|
|
@@ -57,7 +57,20 @@ module Collavre
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def generate(prompt)
|
|
60
|
-
|
|
60
|
+
parse_response(request_theme(prompt))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def request_theme(prompt)
|
|
66
|
+
@client.chat([
|
|
67
|
+
{ role: :system, parts: [ { text: system_prompt } ] },
|
|
68
|
+
{ role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
|
|
69
|
+
])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def system_prompt
|
|
73
|
+
<<~PROMPT
|
|
61
74
|
You are a color theme designer for a workspace app.
|
|
62
75
|
Generate a JSON with ONLY these keys: #{REQUIRED_VARIABLES.join(', ')}.
|
|
63
76
|
|
|
@@ -181,17 +194,8 @@ module Collavre
|
|
|
181
194
|
For dark/accent themes, h1 can use a subtle darker surface. Must not clash with text.
|
|
182
195
|
- For playful themes, vary heading colors. For minimal themes, keep sizes uniform.
|
|
183
196
|
PROMPT
|
|
184
|
-
|
|
185
|
-
response = @client.chat([
|
|
186
|
-
{ role: :system, parts: [ { text: system_prompt } ] },
|
|
187
|
-
{ role: :user, parts: [ { text: "Create a theme description: #{prompt}" } ] }
|
|
188
|
-
])
|
|
189
|
-
|
|
190
|
-
parse_response(response)
|
|
191
197
|
end
|
|
192
198
|
|
|
193
|
-
private
|
|
194
|
-
|
|
195
199
|
def default_client
|
|
196
200
|
AiClient.new(
|
|
197
201
|
vendor: "google",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Per-level batch answer to "which children does this node show?" for the
|
|
4
|
+
# browse tree, replacing a per-node `children_with_permission` call.
|
|
5
|
+
#
|
|
6
|
+
# Presence (`has_children?`) is resolved for every node in a level with one
|
|
7
|
+
# plucked scan plus one batched PermissionFilter; the full child rows are
|
|
8
|
+
# materialized only for the nodes that actually render them (expanded, or a
|
|
9
|
+
# filter forcing the subtree open). Presence and content are derived from the
|
|
10
|
+
# same candidate set, so an expand toggle can never open an empty branch or
|
|
11
|
+
# hide a reachable subtree — a drift that would also leak the existence of
|
|
12
|
+
# children the user cannot see.
|
|
13
|
+
#
|
|
14
|
+
# Children of a linked shell live under its effective origin, so every lookup
|
|
15
|
+
# resolves the shell to its origin first. Preload `:origin` on the level before
|
|
16
|
+
# indexing it, or that resolution costs a query per shell.
|
|
17
|
+
class ChildrenIndex
|
|
18
|
+
def initialize(user:, show_archived:, allowed_creative_ids: nil)
|
|
19
|
+
@user = user
|
|
20
|
+
@show_archived = show_archived
|
|
21
|
+
@allowed_creative_ids = allowed_creative_ids
|
|
22
|
+
@child_ids_by_creative = {}
|
|
23
|
+
@rows_by_child_id = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Scans a level. Only creatives not seen before cost anything, so the
|
|
27
|
+
# recursive descent pays two queries per level, not per node.
|
|
28
|
+
def index(creatives)
|
|
29
|
+
pending = creatives.reject { |c| @child_ids_by_creative.key?(c.id) }
|
|
30
|
+
return if pending.empty?
|
|
31
|
+
|
|
32
|
+
origin_id_by_id = pending.to_h { |c| [ c.id, c.effective_origin.id ] }
|
|
33
|
+
|
|
34
|
+
candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
|
|
35
|
+
candidates = candidates.where(archived_at: nil) unless show_archived
|
|
36
|
+
rows = candidates.order(:sequence).pluck(:id, :parent_id)
|
|
37
|
+
|
|
38
|
+
visible_by_origin = visible_child_ids_by_origin(rows)
|
|
39
|
+
pending.each do |creative|
|
|
40
|
+
@child_ids_by_creative[creative.id] = visible_by_origin[origin_id_by_id[creative.id]] || []
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def has_children?(creative)
|
|
45
|
+
child_ids(creative).any?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Materializes the child rows of `creatives` in a single query. Called only
|
|
49
|
+
# for the nodes whose children actually render.
|
|
50
|
+
def load(creatives)
|
|
51
|
+
wanted = creatives.flat_map { |c| child_ids(c) }.uniq - @rows_by_child_id.keys
|
|
52
|
+
return if wanted.empty?
|
|
53
|
+
|
|
54
|
+
Creative.where(id: wanted).each { |child| @rows_by_child_id[child.id] = child }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sequence-ordered, permission- and archive-filtered children. Empty unless
|
|
58
|
+
# `load` has materialized them.
|
|
59
|
+
def children_for(creative)
|
|
60
|
+
child_ids(creative).filter_map { |id| @rows_by_child_id[id] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :user, :show_archived, :allowed_creative_ids
|
|
66
|
+
|
|
67
|
+
# `rows` arrive in sequence order, so the per-origin lists inherit it.
|
|
68
|
+
def visible_child_ids_by_origin(rows)
|
|
69
|
+
return {} if rows.empty?
|
|
70
|
+
|
|
71
|
+
readable = PermissionFilter.new(user: user).readable_ids(rows.map(&:first)).to_set
|
|
72
|
+
|
|
73
|
+
rows.each_with_object({}) do |(child_id, origin_id), acc|
|
|
74
|
+
next unless readable.include?(child_id)
|
|
75
|
+
next if allowed_creative_ids && !allowed_creative_ids.include?(child_id.to_s)
|
|
76
|
+
|
|
77
|
+
(acc[origin_id] ||= []) << child_id
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def child_ids(creative)
|
|
82
|
+
@child_ids_by_creative.fetch(creative.id, [])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Batches the browse tree's comment badge. Per node this was two queries — a
|
|
4
|
+
# read-pointer lookup and an unread COUNT — which is what made the badge cost
|
|
5
|
+
# scale with the size of the rendered tree.
|
|
6
|
+
#
|
|
7
|
+
# Keyed by *origin*: a linked shell shows its origin's comments.
|
|
8
|
+
class CommentBadgeIndex
|
|
9
|
+
def initialize(user:)
|
|
10
|
+
@user = user
|
|
11
|
+
@unread_by_origin_id = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def index(origins)
|
|
15
|
+
pending = origins.uniq(&:id).reject { |o| @unread_by_origin_id.key?(o.id) }
|
|
16
|
+
return if pending.empty?
|
|
17
|
+
|
|
18
|
+
watermarks = read_watermarks(pending.map(&:id))
|
|
19
|
+
|
|
20
|
+
# No read pointer means nothing has been read yet, so every comment counts
|
|
21
|
+
# as unread — including private ones, matching the counter cache.
|
|
22
|
+
pending.each do |origin|
|
|
23
|
+
@unread_by_origin_id[origin.id] = origin.comments_count unless watermarks.key?(origin.id)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
counts = unread_counts(watermarks)
|
|
27
|
+
watermarks.each_key { |origin_id| @unread_by_origin_id[origin_id] = counts.fetch(origin_id, 0) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# nil when the origin was never indexed, which tells the caller to fall back
|
|
31
|
+
# to the single-creative path rather than silently render a zero badge.
|
|
32
|
+
def unread_count_for(origin)
|
|
33
|
+
@unread_by_origin_id[origin.id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :user
|
|
39
|
+
|
|
40
|
+
# `user_id: nil` for an anonymous visitor is the lookup the un-batched path
|
|
41
|
+
# made too, so the two agree on which pointer (if any) applies.
|
|
42
|
+
def read_watermarks(origin_ids)
|
|
43
|
+
return {} if origin_ids.empty?
|
|
44
|
+
|
|
45
|
+
CommentReadPointer
|
|
46
|
+
.where(user_id: user&.id, creative_id: origin_ids)
|
|
47
|
+
.where.not(last_read_comment_id: nil)
|
|
48
|
+
.pluck(:creative_id, :last_read_comment_id)
|
|
49
|
+
.to_h
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# One grouped COUNT for the whole level. Each origin carries its own read
|
|
53
|
+
# watermark, so the thresholds are OR-ed together rather than shared. Built
|
|
54
|
+
# from relations rather than a SQL fragment: a hand-built string here would
|
|
55
|
+
# be safe (literal template, bound values) but unprovably so to a scanner.
|
|
56
|
+
def unread_counts(watermarks)
|
|
57
|
+
return {} if watermarks.empty?
|
|
58
|
+
|
|
59
|
+
newer_than_watermark = watermarks
|
|
60
|
+
.map { |origin_id, last_read_id| unread_scope(origin_id, last_read_id) }
|
|
61
|
+
.reduce { |combined, scope| combined.or(scope) }
|
|
62
|
+
|
|
63
|
+
Comment.where(private: false).merge(newer_than_watermark).group(:creative_id).count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unread_scope(origin_id, last_read_id)
|
|
67
|
+
Comment
|
|
68
|
+
.where(creative_id: origin_id)
|
|
69
|
+
.where(Comment.arel_table[:id].gt(last_read_id))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Serializes a flat collection of creatives into the lightweight JSON payload
|
|
4
|
+
# consumed by the picker popup (simple mode) and the plain browse list.
|
|
5
|
+
#
|
|
6
|
+
# Extracted from CreativesController#index so the controller action stays thin.
|
|
7
|
+
# Depends only on the acting user and the request params (read-only), never on
|
|
8
|
+
# view_context, so the whole responsibility lives outside the controller.
|
|
9
|
+
class CreativeTreeSerializer
|
|
10
|
+
def initialize(user:, params:)
|
|
11
|
+
@user = user
|
|
12
|
+
@params = params
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize(collection)
|
|
16
|
+
if params[:simple].present?
|
|
17
|
+
serialize_simple(collection)
|
|
18
|
+
else
|
|
19
|
+
collection.map { |c| { id: c.id, description: c.effective_description, progress: c.progress } }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :user, :params
|
|
26
|
+
|
|
27
|
+
def serialize_simple(collection)
|
|
28
|
+
# Preload origins so effective_origin (used per linked-shell row in both
|
|
29
|
+
# children_presence_set and the origin_id mapping below) resolves from
|
|
30
|
+
# memory instead of firing a query per shell. Only browse can hold shells;
|
|
31
|
+
# search is scoped to origin_id: nil, so this is a no-op there.
|
|
32
|
+
ActiveRecord::Associations::Preloader.new(records: collection.to_a, associations: :origin).call
|
|
33
|
+
children_ids = children_presence_set(collection)
|
|
34
|
+
searching = params[:search].present?
|
|
35
|
+
breadcrumbs = searching ? BreadcrumbResolver.new(collection.map(&:id), user: user, include_archived: params[:show_archived].present?).call : {}
|
|
36
|
+
# For hits routed through a linked shell, the path to expand in the
|
|
37
|
+
# user's own tree (local folders -> shell) so a breadcrumb jump can
|
|
38
|
+
# reach a shell nested under a collapsed folder.
|
|
39
|
+
reveal_paths = searching ? RevealPathResolver.new(collection.map(&:id), user: user, include_archived: params[:show_archived].present?).call : {}
|
|
40
|
+
collection.map do |c|
|
|
41
|
+
item = {
|
|
42
|
+
id: c.id,
|
|
43
|
+
description: c.effective_description(nil, false),
|
|
44
|
+
progress: c.progress,
|
|
45
|
+
has_children: children_ids.include?(c.id)
|
|
46
|
+
}
|
|
47
|
+
reveal = reveal_paths[c.id]
|
|
48
|
+
path = breadcrumbs[c.id]
|
|
49
|
+
if path.present?
|
|
50
|
+
item[:path] = mask_unreachable_crumbs(path, reveal)
|
|
51
|
+
end
|
|
52
|
+
item[:reveal_path] = reveal if reveal.present?
|
|
53
|
+
# For linked shells, expose the effective origin id so the picker can
|
|
54
|
+
# map a search breadcrumb (origin ids) back to the rendered shell node.
|
|
55
|
+
item[:origin_id] = c.effective_origin.id if c.origin_id
|
|
56
|
+
item
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A breadcrumb jump expands the tree from a rendered root (or, for a shared
|
|
61
|
+
# subtree, a linked shell) down to the clicked crumb. If an ancestor above a
|
|
62
|
+
# crumb is itself unrenderable (unreadable, or archived while archived rows
|
|
63
|
+
# aren't shown) and no linked shell re-roots the path at/below it, the
|
|
64
|
+
# descendant can't be expanded either — so mask it too. BreadcrumbResolver
|
|
65
|
+
# masks the unrenderable ancestor itself; this masks everything downstream of
|
|
66
|
+
# it on the plain origin chain, matching exactly what the tree can render.
|
|
67
|
+
#
|
|
68
|
+
# `reveal` is RevealPathResolver's per-origin map: a crumb whose origin id is
|
|
69
|
+
# a key re-roots navigation through its own shell (the client anchors at the
|
|
70
|
+
# nearest reveal entry at/above the clicked crumb), so it clears the block for
|
|
71
|
+
# itself and its descendants regardless of an archived/unreadable origin
|
|
72
|
+
# above it.
|
|
73
|
+
def mask_unreachable_crumbs(path, reveal)
|
|
74
|
+
blocked = false
|
|
75
|
+
path.map do |crumb|
|
|
76
|
+
if reveal&.key?(crumb[:id])
|
|
77
|
+
blocked = false
|
|
78
|
+
crumb
|
|
79
|
+
elsif crumb[:restricted]
|
|
80
|
+
blocked = true
|
|
81
|
+
crumb
|
|
82
|
+
elsif blocked
|
|
83
|
+
crumb.merge(restricted: true, description: nil)
|
|
84
|
+
else
|
|
85
|
+
crumb
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Batched "does this node have a child the user can actually browse to?"
|
|
91
|
+
# lookup so the picker tree renders expand toggles without an N+1.
|
|
92
|
+
#
|
|
93
|
+
# Must match exactly what expanding the node shows (IndexQuery#handle_id_query
|
|
94
|
+
# -> children_with_permission, minus archived unless show_archived), or the
|
|
95
|
+
# toggle either hides a reachable subtree or opens to an empty branch (and
|
|
96
|
+
# leaks that hidden children exist). Two alignments are needed:
|
|
97
|
+
# 1. Linked shells (origin_id set) store children under the effective
|
|
98
|
+
# origin (redirect_parent_to_origin + children->origin migration), so
|
|
99
|
+
# resolve each row to its effective origin before the lookup.
|
|
100
|
+
# 2. Apply the same archived + read-permission filters as the browse path.
|
|
101
|
+
def children_presence_set(collection)
|
|
102
|
+
return Set.new if collection.empty?
|
|
103
|
+
|
|
104
|
+
origin_id_by_id = collection.to_h { |c| [ c.id, c.effective_origin.id ] }
|
|
105
|
+
|
|
106
|
+
candidates = Creative.where(parent_id: origin_id_by_id.values.uniq)
|
|
107
|
+
candidates = candidates.where(archived_at: nil) unless params[:show_archived]
|
|
108
|
+
child_rows = candidates.pluck(:id, :parent_id) # [child_id, origin_id]
|
|
109
|
+
return Set.new if child_rows.empty?
|
|
110
|
+
|
|
111
|
+
readable = PermissionFilter
|
|
112
|
+
.new(user: user).readable_ids(child_rows.map(&:first)).to_set
|
|
113
|
+
origins_with_visible_children = child_rows
|
|
114
|
+
.each_with_object(Set.new) { |(child_id, origin_id), set| set << origin_id if readable.include?(child_id) }
|
|
115
|
+
|
|
116
|
+
collection.each_with_object(Set.new) do |c, set|
|
|
117
|
+
set << c.id if origins_with_visible_children.include?(origin_id_by_id[c.id])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Single source of truth for "which creative's cache/ownership actually
|
|
4
|
+
# governs permission for this creative?".
|
|
5
|
+
#
|
|
6
|
+
# A linked creative (origin_id present) borrows its origin's permission: it
|
|
7
|
+
# has no cache entries of its own and its ownership is the origin's ownership.
|
|
8
|
+
# PermissionChecker (single-item) and PermissionFilter (batch) MUST resolve
|
|
9
|
+
# the effective creative through this module so a single check and a batch
|
|
10
|
+
# filter can never diverge for the same user + linked creative.
|
|
11
|
+
module EffectiveCreativeResolution
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# For a single loaded creative: returns the creative whose cache/ownership
|
|
15
|
+
# governs permission. Non-linked creatives resolve to themselves.
|
|
16
|
+
def effective_creative(creative)
|
|
17
|
+
creative.origin_id.nil? ? creative : creative.origin
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# For a batch of creative ids: returns { original_id => effective_id }.
|
|
21
|
+
# Linked creatives map to their origin_id; every other id (including ids
|
|
22
|
+
# not found in the table) maps to itself. This mirrors
|
|
23
|
+
# +effective_creative+ so the batch reader resolves origin identically.
|
|
24
|
+
def effective_creative_ids(ids)
|
|
25
|
+
# Coerce to Integer: origin_by_id is keyed by integer ids from pluck, so a
|
|
26
|
+
# string id (e.g. sourced from request params) would miss origin lookup
|
|
27
|
+
# and later miss the integer-keyed readable Set. Non-numeric ids can't
|
|
28
|
+
# identify a Creative, so drop them.
|
|
29
|
+
ids = ids.to_a.filter_map { |id| Integer(id, exception: false) }.uniq
|
|
30
|
+
return {} if ids.empty?
|
|
31
|
+
|
|
32
|
+
origin_by_id = Creative.where(id: ids).pluck(:id, :origin_id).to_h
|
|
33
|
+
ids.index_with { |id| origin_by_id[id] || id }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|