collavre_openclaw 0.4.0 → 0.6.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/controllers/collavre_openclaw/callbacks_controller.rb +1 -0
- data/app/controllers/collavre_openclaw/health_controller.rb +18 -1
- data/app/jobs/collavre_openclaw/callback_processor_job.rb +13 -1
- data/app/services/collavre_openclaw/ai_client_extension.rb +20 -5
- data/app/services/collavre_openclaw/connection_manager.rb +51 -4
- data/app/services/collavre_openclaw/openclaw_adapter.rb +166 -94
- data/app/services/collavre_openclaw/websocket_client.rb +139 -65
- data/lib/collavre_openclaw/configuration.rb +1 -1
- data/lib/collavre_openclaw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff18cc7843b54de3c5e86de861f69b9225aa47dd7e57aae94cd612d7698d1d55
|
|
4
|
+
data.tar.gz: 8b83ffc51769d3946de8660b0583ccc672079f08d80db9b93178c12c1c374279
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54bf09a092ed87b269621100c55edadd8fd0591f41f25270672f27cf6837d3767dff33a3cff6bb6fe4315c90cbb5d596805b6ce6d2ec055eb5d0cd0f73e7d5d6
|
|
7
|
+
data.tar.gz: b967fc6beb11b41889b0574b937b94532910acba29e9b349e2ec40289fcb57bd9096ea4cf06756a5db05acdc5f9a13a51c1c3474fb8fe85709e1c68fafe703dc
|
|
@@ -66,6 +66,7 @@ module CollavreOpenclaw
|
|
|
66
66
|
payload[:context][:creative_id] ||= pending.creative_id
|
|
67
67
|
payload[:context][:comment_id] ||= pending.comment_id
|
|
68
68
|
payload[:context][:thread_id] ||= pending.thread_id
|
|
69
|
+
payload[:context][:topic_id] ||= pending.thread_id
|
|
69
70
|
|
|
70
71
|
# Merge any extra context stored in pending callback
|
|
71
72
|
if pending.context.present?
|
|
@@ -3,11 +3,28 @@ module CollavreOpenclaw
|
|
|
3
3
|
allow_unauthenticated_access only: :show
|
|
4
4
|
|
|
5
5
|
def show
|
|
6
|
-
|
|
6
|
+
payload = {
|
|
7
7
|
status: "ok",
|
|
8
8
|
engine: "collavre_openclaw",
|
|
9
9
|
version: CollavreOpenclaw::VERSION
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
# WebSocket details only for authenticated requests
|
|
13
|
+
if authenticated?
|
|
14
|
+
payload[:transport] = CollavreOpenclaw.config.transport
|
|
15
|
+
payload[:websocket] = ConnectionManager.status_summary
|
|
16
|
+
payload[:reactor] = { running: EmReactor.running? }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
render json: payload
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def authenticated?
|
|
25
|
+
respond_to?(:current_user, true) && current_user.present?
|
|
26
|
+
rescue
|
|
27
|
+
false
|
|
11
28
|
end
|
|
12
29
|
end
|
|
13
30
|
end
|
|
@@ -39,6 +39,8 @@ module CollavreOpenclaw
|
|
|
39
39
|
comment_id = context[:comment_id]
|
|
40
40
|
content = payload[:content] || payload[:message]
|
|
41
41
|
|
|
42
|
+
normalize_topic_context!(context)
|
|
43
|
+
|
|
42
44
|
if comment_id.present?
|
|
43
45
|
# Update existing comment (streaming completion)
|
|
44
46
|
comment = Collavre::Comment.find_by(id: comment_id)
|
|
@@ -56,7 +58,7 @@ module CollavreOpenclaw
|
|
|
56
58
|
def handle_proactive(payload)
|
|
57
59
|
creative_id = payload[:creative_id] || payload.dig(:context, :creative_id)
|
|
58
60
|
content = payload[:content] || payload[:message]
|
|
59
|
-
thread_id = payload[:thread_id] || payload.dig(:context, :thread_id)
|
|
61
|
+
thread_id = payload[:thread_id] || payload[:topic_id] || payload.dig(:context, :thread_id) || payload.dig(:context, :topic_id)
|
|
60
62
|
parent_comment_id = payload[:parent_comment_id] || payload.dig(:context, :parent_comment_id)
|
|
61
63
|
|
|
62
64
|
unless creative_id.present?
|
|
@@ -94,6 +96,8 @@ module CollavreOpenclaw
|
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
def create_ai_comment(creative_id, content, context = {})
|
|
99
|
+
normalize_topic_context!(context)
|
|
100
|
+
|
|
97
101
|
creative = Collavre::Creative.find_by(id: creative_id)
|
|
98
102
|
unless creative
|
|
99
103
|
Rails.logger.error("[CollavreOpenclaw] Creative not found: #{creative_id}")
|
|
@@ -135,5 +139,13 @@ module CollavreOpenclaw
|
|
|
135
139
|
Rails.logger.error("[CollavreOpenclaw] Failed to create comment: #{e.message}")
|
|
136
140
|
nil
|
|
137
141
|
end
|
|
142
|
+
|
|
143
|
+
def normalize_topic_context!(context)
|
|
144
|
+
return unless context.is_a?(Hash)
|
|
145
|
+
return if context[:thread_id].present?
|
|
146
|
+
|
|
147
|
+
topic_id = context[:topic_id]
|
|
148
|
+
context[:thread_id] = topic_id if topic_id.present?
|
|
149
|
+
end
|
|
138
150
|
end
|
|
139
151
|
end
|
|
@@ -12,8 +12,11 @@ module CollavreOpenclaw
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# @param messages_input [Hash, Array] Hash { messages:, first_message:, context_changed: }
|
|
16
|
+
# from MessageBuilder, or a plain Array from standalone callers (e.g., CompressJob).
|
|
17
|
+
def chat(messages_input, tools: [], &block)
|
|
16
18
|
normalized_vendor = vendor.to_s.downcase
|
|
19
|
+
messages_data = normalize_messages_input(messages_input)
|
|
17
20
|
|
|
18
21
|
# Check if we have a custom adapter for this vendor
|
|
19
22
|
adapter_class = self.class.adapter_registry[normalized_vendor]
|
|
@@ -31,13 +34,13 @@ module CollavreOpenclaw
|
|
|
31
34
|
error_message = nil
|
|
32
35
|
|
|
33
36
|
begin
|
|
34
|
-
response_content = adapter.chat(
|
|
37
|
+
response_content = adapter.chat(messages_data, &block)
|
|
35
38
|
rescue StandardError => e
|
|
36
39
|
error_message = e.message
|
|
37
40
|
raise
|
|
38
41
|
ensure
|
|
39
42
|
log_interaction(
|
|
40
|
-
messages:
|
|
43
|
+
messages: messages_data[:messages],
|
|
41
44
|
tools: [],
|
|
42
45
|
response_content: response_content,
|
|
43
46
|
error_message: error_message,
|
|
@@ -49,12 +52,24 @@ module CollavreOpenclaw
|
|
|
49
52
|
return response_content
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
# Fall back to original implementation
|
|
53
|
-
super
|
|
55
|
+
# Fall back to original RubyLLM implementation (expects Array)
|
|
56
|
+
super(messages_data[:messages], tools: tools, &block)
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
private
|
|
57
60
|
|
|
58
61
|
attr_reader :vendor, :system_prompt, :context
|
|
62
|
+
|
|
63
|
+
# Wrap plain Array input (from standalone callers like CompressJob)
|
|
64
|
+
# into the Hash format expected by the adapter.
|
|
65
|
+
def normalize_messages_input(input)
|
|
66
|
+
return input if input.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
messages: Array(input).map { |m| m.merge(kind: :trigger) },
|
|
70
|
+
first_message: true,
|
|
71
|
+
context_changed: false
|
|
72
|
+
}
|
|
73
|
+
end
|
|
59
74
|
end
|
|
60
75
|
end
|
|
@@ -43,10 +43,17 @@ module CollavreOpenclaw
|
|
|
43
43
|
client = @connections[gateway_url]
|
|
44
44
|
|
|
45
45
|
if client.nil?
|
|
46
|
-
client =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
client = create_client(user, gateway_url)
|
|
47
|
+
elsif client.user.llm_api_key != user.llm_api_key
|
|
48
|
+
# Same gateway_url but different API key. This is a configuration
|
|
49
|
+
# error: one Gateway = one API key. Log a warning so the admin
|
|
50
|
+
# notices, rather than silently ignoring the second user's key.
|
|
51
|
+
# In HTTP mode this would surface as a 401 on each request.
|
|
52
|
+
Rails.logger.warn(
|
|
53
|
+
"[CollavreOpenclaw::ConnectionManager] API key mismatch for gateway #{gateway_url}: " \
|
|
54
|
+
"user #{user.id} has a different key than connection owner #{client.user.id}. " \
|
|
55
|
+
"The connection uses the owner's key. Verify AI agent settings."
|
|
56
|
+
)
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
# Track this user as using this gateway
|
|
@@ -109,6 +116,17 @@ module CollavreOpenclaw
|
|
|
109
116
|
end
|
|
110
117
|
end
|
|
111
118
|
|
|
119
|
+
# Safe accessor: returns status without triggering singleton initialization.
|
|
120
|
+
# Use this from controllers/monitoring instead of instance_variable_get.
|
|
121
|
+
def self.status_summary
|
|
122
|
+
if instance_variable_defined?(:@singleton__instance__) && @singleton__instance__
|
|
123
|
+
instance.status
|
|
124
|
+
else
|
|
125
|
+
{ total_connections: 0, total_users: 0,
|
|
126
|
+
connected: 0, connecting: 0, reconnecting: 0, disconnected: 0 }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
112
130
|
# Register a proactive message handler for all connections.
|
|
113
131
|
# New connections will also get this handler.
|
|
114
132
|
def on_proactive_message(&handler)
|
|
@@ -138,6 +156,35 @@ module CollavreOpenclaw
|
|
|
138
156
|
|
|
139
157
|
private
|
|
140
158
|
|
|
159
|
+
# Create a new WebsocketClient and wire up handlers.
|
|
160
|
+
def create_client(user, gateway_url)
|
|
161
|
+
client = WebsocketClient.new(user: user)
|
|
162
|
+
client.on_proactive_message(&@proactive_handler) if @proactive_handler
|
|
163
|
+
client.on_fatal_close do |dead_client|
|
|
164
|
+
handle_fatal_close(gateway_url, dead_client)
|
|
165
|
+
end
|
|
166
|
+
@connections[gateway_url] = client
|
|
167
|
+
@gateway_users[gateway_url] ||= Set.new
|
|
168
|
+
client
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Called when a client receives a fatal close code (auth failure, etc.).
|
|
172
|
+
# Removes the dead client so the next connection_for call creates a fresh one.
|
|
173
|
+
#
|
|
174
|
+
# Guard: only deletes if the current mapping still points to dead_client.
|
|
175
|
+
# Without this, a late-arriving callback from an old client could wipe a
|
|
176
|
+
# new live connection that was already registered for the same gateway_url.
|
|
177
|
+
def handle_fatal_close(gateway_url, dead_client)
|
|
178
|
+
@mutex.synchronize do
|
|
179
|
+
return unless @connections[gateway_url].equal?(dead_client)
|
|
180
|
+
|
|
181
|
+
Rails.logger.warn("[CollavreOpenclaw::ConnectionManager] Fatal close for gateway #{gateway_url}, removing connection")
|
|
182
|
+
@connections.delete(gateway_url)
|
|
183
|
+
user_ids = @gateway_users.delete(gateway_url) || Set.new
|
|
184
|
+
user_ids.each { |uid| @user_gateways.delete(uid) }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
141
188
|
# Set up the default proactive message handler that dispatches
|
|
142
189
|
# unsolicited chat events to CallbackProcessorJob.
|
|
143
190
|
def setup_default_proactive_handler!
|
|
@@ -20,7 +20,10 @@ module CollavreOpenclaw
|
|
|
20
20
|
@context = context
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# @param messages_data [Hash] { messages:, first_message:, context_changed: }
|
|
24
|
+
def chat(messages_data, &block)
|
|
25
|
+
parse_messages_data!(messages_data)
|
|
26
|
+
|
|
24
27
|
unless @user&.gateway_url.present?
|
|
25
28
|
Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
|
|
26
29
|
yield "Error: OpenClaw Gateway URL not configured" if block_given?
|
|
@@ -37,12 +40,12 @@ module CollavreOpenclaw
|
|
|
37
40
|
# Try WebSocket first, fall back to HTTP
|
|
38
41
|
# Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
|
|
39
42
|
if CollavreOpenclaw.config.transport == "http"
|
|
40
|
-
Rails.logger.info("[CollavreOpenclaw]
|
|
41
|
-
chat_via_http(
|
|
43
|
+
Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
|
|
44
|
+
chat_via_http(&block)
|
|
42
45
|
elsif websocket_available?
|
|
43
|
-
chat_via_websocket(
|
|
46
|
+
chat_via_websocket(&block)
|
|
44
47
|
else
|
|
45
|
-
chat_via_http(
|
|
48
|
+
chat_via_http(&block)
|
|
46
49
|
end
|
|
47
50
|
end
|
|
48
51
|
|
|
@@ -69,6 +72,27 @@ module CollavreOpenclaw
|
|
|
69
72
|
|
|
70
73
|
private
|
|
71
74
|
|
|
75
|
+
CONTEXT_KINDS = %i[creative_context context_creative referenced_creative].freeze
|
|
76
|
+
|
|
77
|
+
def parse_messages_data!(data)
|
|
78
|
+
@all_messages = data[:messages] || []
|
|
79
|
+
@first_message = data[:first_message]
|
|
80
|
+
@context_changed = data[:context_changed]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def context_messages
|
|
84
|
+
@all_messages.select { |m| CONTEXT_KINDS.include?(m[:kind]) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def trigger_message
|
|
88
|
+
@all_messages.find { |m| m[:kind] == :trigger }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Send system prompt and context on first message or when they changed.
|
|
92
|
+
def include_full_context?
|
|
93
|
+
@first_message || @context_changed
|
|
94
|
+
end
|
|
95
|
+
|
|
72
96
|
# ─────────────────────────────────────────────
|
|
73
97
|
# WebSocket transport
|
|
74
98
|
# ─────────────────────────────────────────────
|
|
@@ -83,18 +107,19 @@ module CollavreOpenclaw
|
|
|
83
107
|
false
|
|
84
108
|
end
|
|
85
109
|
|
|
86
|
-
def chat_via_websocket(
|
|
110
|
+
def chat_via_websocket(&block)
|
|
87
111
|
response_content = +""
|
|
88
112
|
|
|
89
113
|
begin
|
|
90
114
|
client = ConnectionManager.instance.connection_for(@user)
|
|
91
|
-
|
|
115
|
+
payload = build_ws_chat_payload
|
|
92
116
|
|
|
93
|
-
Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
|
|
117
|
+
Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
|
|
94
118
|
|
|
95
119
|
client.chat_send(
|
|
96
120
|
session_key: session_key,
|
|
97
|
-
message:
|
|
121
|
+
message: payload[:message],
|
|
122
|
+
attachments: payload[:attachments]
|
|
98
123
|
) do |event|
|
|
99
124
|
case event[:state]
|
|
100
125
|
when "delta"
|
|
@@ -119,8 +144,8 @@ module CollavreOpenclaw
|
|
|
119
144
|
response_content.presence
|
|
120
145
|
rescue CollavreOpenclaw::ConnectionError,
|
|
121
146
|
CollavreOpenclaw::TimeoutError => e
|
|
122
|
-
Rails.logger.warn("[CollavreOpenclaw]
|
|
123
|
-
chat_via_http(
|
|
147
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
148
|
+
chat_via_http(&block)
|
|
124
149
|
rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
|
|
125
150
|
Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
|
|
126
151
|
error_msg = "OpenClaw Error: #{e.message}"
|
|
@@ -129,64 +154,48 @@ module CollavreOpenclaw
|
|
|
129
154
|
rescue StandardError => e
|
|
130
155
|
Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
|
|
131
156
|
"#{e.backtrace.first(5).join("\n")}")
|
|
132
|
-
Rails.logger.info("[CollavreOpenclaw]
|
|
133
|
-
chat_via_http(
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Format messages for WebSocket chat.send (single message string).
|
|
138
|
-
# Gateway manages session history, so we only send the latest user message
|
|
139
|
-
# with optional context prefix on the FIRST message only.
|
|
140
|
-
def format_message_for_ws(messages)
|
|
141
|
-
formatted = Array(messages)
|
|
142
|
-
|
|
143
|
-
# Extract the last user message
|
|
144
|
-
last_user = formatted.reverse.find do |m|
|
|
145
|
-
role = m[:role] || m["role"]
|
|
146
|
-
role.to_s == "user"
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
return "" unless last_user
|
|
150
|
-
|
|
151
|
-
text = extract_message_text(last_user)
|
|
152
|
-
|
|
153
|
-
# Only prepend creative context on the first message in a session.
|
|
154
|
-
# If there are prior assistant replies, the Gateway already has context.
|
|
155
|
-
if first_message_in_session?(formatted)
|
|
156
|
-
context_prefix = build_context_prefix(formatted)
|
|
157
|
-
if context_prefix.present?
|
|
158
|
-
return "#{context_prefix}\n\n#{text}"
|
|
159
|
-
end
|
|
157
|
+
Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
158
|
+
chat_via_http(&block)
|
|
160
159
|
end
|
|
161
|
-
|
|
162
|
-
text.to_s
|
|
163
160
|
end
|
|
164
161
|
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
162
|
+
# Build WebSocket chat.send payload.
|
|
163
|
+
#
|
|
164
|
+
# Token optimization: only includes system prompt and creative context on
|
|
165
|
+
# the first message or when context has changed. The Gateway maintains its
|
|
166
|
+
# own session history, so chat history is never sent — only the trigger.
|
|
167
|
+
def build_ws_chat_payload
|
|
168
|
+
trigger = trigger_message
|
|
169
|
+
{
|
|
170
|
+
message: format_message_for_ws,
|
|
171
|
+
attachments: trigger ? extract_ws_attachments([ trigger ]).presence : nil
|
|
172
|
+
}
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
175
|
+
# Format messages for WebSocket chat.send text payload.
|
|
176
|
+
#
|
|
177
|
+
# On first message (or context change):
|
|
178
|
+
# [system prompt] + [creative context] + [trigger]
|
|
179
|
+
# On subsequent messages:
|
|
180
|
+
# [trigger only]
|
|
181
|
+
#
|
|
182
|
+
# Chat history is NOT included — the Gateway's SessionManager tracks
|
|
183
|
+
# conversation turns automatically.
|
|
184
|
+
def format_message_for_ws
|
|
185
|
+
parts = []
|
|
186
|
+
|
|
187
|
+
if include_full_context?
|
|
188
|
+
parts << @system_prompt if @system_prompt.present?
|
|
189
|
+
context_messages.each do |m|
|
|
190
|
+
text = extract_message_text(m)
|
|
191
|
+
parts << text if text.present?
|
|
192
|
+
end
|
|
185
193
|
end
|
|
186
194
|
|
|
187
|
-
|
|
195
|
+
trigger = trigger_message
|
|
196
|
+
parts << extract_message_text(trigger) if trigger
|
|
188
197
|
|
|
189
|
-
|
|
198
|
+
parts.join("\n\n")
|
|
190
199
|
end
|
|
191
200
|
|
|
192
201
|
def extract_message_text(message)
|
|
@@ -205,6 +214,12 @@ module CollavreOpenclaw
|
|
|
205
214
|
Array(parts).filter_map { |part| part[:image] || part["image"] }
|
|
206
215
|
end
|
|
207
216
|
|
|
217
|
+
def extract_ws_attachments(messages)
|
|
218
|
+
Array(messages).flat_map do |message|
|
|
219
|
+
extract_image_sources(message).filter_map { |source| encode_image_source_for_ws(source) }
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
208
223
|
def encode_image_source(source)
|
|
209
224
|
if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
|
|
210
225
|
data = Base64.strict_encode64(source.download)
|
|
@@ -239,17 +254,54 @@ module CollavreOpenclaw
|
|
|
239
254
|
nil
|
|
240
255
|
end
|
|
241
256
|
|
|
257
|
+
def encode_image_source_for_ws(source)
|
|
258
|
+
if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
|
|
259
|
+
{
|
|
260
|
+
type: "image",
|
|
261
|
+
mimeType: source.content_type,
|
|
262
|
+
fileName: source.filename.to_s,
|
|
263
|
+
content: Base64.strict_encode64(source.download)
|
|
264
|
+
}
|
|
265
|
+
elsif source.respond_to?(:download)
|
|
266
|
+
blob = source.respond_to?(:blob) ? source.blob : source
|
|
267
|
+
return nil unless blob
|
|
268
|
+
|
|
269
|
+
{
|
|
270
|
+
type: "image",
|
|
271
|
+
mimeType: blob.content_type,
|
|
272
|
+
fileName: blob.filename.to_s,
|
|
273
|
+
content: Base64.strict_encode64(blob.download)
|
|
274
|
+
}
|
|
275
|
+
elsif source.is_a?(String) && source.match?(%r{^https?://})
|
|
276
|
+
nil
|
|
277
|
+
elsif source.is_a?(String)
|
|
278
|
+
return nil unless File.exist?(source)
|
|
279
|
+
|
|
280
|
+
{
|
|
281
|
+
type: "image",
|
|
282
|
+
mimeType: Marcel::MimeType.for(Pathname.new(source)),
|
|
283
|
+
fileName: File.basename(source),
|
|
284
|
+
content: Base64.strict_encode64(File.binread(source))
|
|
285
|
+
}
|
|
286
|
+
else
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
Rails.logger.warn("[CollavreOpenclaw] Failed to encode WS image attachment: #{e.message}")
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
242
294
|
# ─────────────────────────────────────────────
|
|
243
295
|
# HTTP transport (fallback)
|
|
244
296
|
# ─────────────────────────────────────────────
|
|
245
297
|
|
|
246
|
-
def chat_via_http(
|
|
298
|
+
def chat_via_http(&block)
|
|
247
299
|
response_content = +""
|
|
248
300
|
|
|
249
301
|
begin
|
|
250
|
-
payload = build_payload
|
|
302
|
+
payload = build_payload
|
|
251
303
|
|
|
252
|
-
Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key})")
|
|
304
|
+
Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
|
|
253
305
|
|
|
254
306
|
stream_response(payload) do |chunk|
|
|
255
307
|
response_content << chunk
|
|
@@ -281,7 +333,7 @@ module CollavreOpenclaw
|
|
|
281
333
|
# Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
|
|
282
334
|
def build_session_key
|
|
283
335
|
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
284
|
-
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
336
|
+
topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
|
|
285
337
|
agent_id = extract_agent_id_from_email || "main"
|
|
286
338
|
|
|
287
339
|
parts = [ "agent", agent_id, "collavre", @user.id ]
|
|
@@ -295,22 +347,35 @@ module CollavreOpenclaw
|
|
|
295
347
|
# HTTP payload building
|
|
296
348
|
# ─────────────────────────────────────────────
|
|
297
349
|
|
|
298
|
-
|
|
350
|
+
# Build HTTP payload with token optimization.
|
|
351
|
+
#
|
|
352
|
+
# On first message (or context change):
|
|
353
|
+
# system prompt + creative context + trigger
|
|
354
|
+
# On subsequent messages:
|
|
355
|
+
# trigger only
|
|
356
|
+
#
|
|
357
|
+
# Chat history is NOT included — the Gateway's SessionManager tracks
|
|
358
|
+
# conversation turns via the stable session key.
|
|
359
|
+
def build_payload
|
|
299
360
|
agent_id = extract_agent_id_from_email
|
|
300
361
|
model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
|
|
301
362
|
|
|
302
|
-
|
|
303
|
-
model: model_value,
|
|
304
|
-
messages: format_messages(messages),
|
|
305
|
-
stream: true
|
|
306
|
-
}
|
|
363
|
+
formatted = []
|
|
307
364
|
|
|
308
|
-
if
|
|
309
|
-
|
|
365
|
+
if include_full_context?
|
|
366
|
+
formatted << { role: "system", content: @system_prompt } if @system_prompt.present?
|
|
367
|
+
context_messages.each { |m| formatted << format_single_message(m) }
|
|
310
368
|
end
|
|
311
369
|
|
|
312
|
-
|
|
313
|
-
|
|
370
|
+
trigger = trigger_message
|
|
371
|
+
formatted << format_single_message(trigger) if trigger
|
|
372
|
+
|
|
373
|
+
{
|
|
374
|
+
model: model_value,
|
|
375
|
+
messages: formatted,
|
|
376
|
+
stream: true,
|
|
377
|
+
user: build_user_context
|
|
378
|
+
}
|
|
314
379
|
end
|
|
315
380
|
|
|
316
381
|
def build_user_context
|
|
@@ -318,7 +383,7 @@ module CollavreOpenclaw
|
|
|
318
383
|
|
|
319
384
|
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
320
385
|
comment_id = extract_id(@context, :comment) || @context[:comment_id]
|
|
321
|
-
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
386
|
+
topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
|
|
322
387
|
|
|
323
388
|
callback = callback_url
|
|
324
389
|
if callback.present? && creative_id.present?
|
|
@@ -346,28 +411,26 @@ module CollavreOpenclaw
|
|
|
346
411
|
end
|
|
347
412
|
end
|
|
348
413
|
|
|
349
|
-
def
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
text = extract_message_text(msg)
|
|
414
|
+
def format_single_message(msg)
|
|
415
|
+
role = msg[:role] || msg["role"]
|
|
416
|
+
text = extract_message_text(msg)
|
|
353
417
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
418
|
+
sender_name = msg[:sender_name] || msg["sender_name"]
|
|
419
|
+
if sender_name.present? && normalize_role(role) == "user"
|
|
420
|
+
text = "[#{sender_name}]: #{text}"
|
|
421
|
+
end
|
|
358
422
|
|
|
359
|
-
|
|
423
|
+
image_sources = extract_image_sources(msg)
|
|
360
424
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
end
|
|
367
|
-
{ role: normalize_role(role), content: content_parts }
|
|
368
|
-
else
|
|
369
|
-
{ role: normalize_role(role), content: text.to_s }
|
|
425
|
+
if image_sources.any?
|
|
426
|
+
content_parts = [ { type: "text", text: text.to_s } ]
|
|
427
|
+
image_sources.each do |source|
|
|
428
|
+
image_data = encode_image_source(source)
|
|
429
|
+
content_parts << image_data if image_data
|
|
370
430
|
end
|
|
431
|
+
{ role: normalize_role(role), content: content_parts }
|
|
432
|
+
else
|
|
433
|
+
{ role: normalize_role(role), content: text.to_s }
|
|
371
434
|
end
|
|
372
435
|
end
|
|
373
436
|
|
|
@@ -539,6 +602,15 @@ module CollavreOpenclaw
|
|
|
539
602
|
@user.email.split("@").first
|
|
540
603
|
end
|
|
541
604
|
|
|
605
|
+
# Infer topic_id from the comment object in context when not explicitly provided.
|
|
606
|
+
# AiAgentService passes :comment (the reply or original comment) which carries topic_id.
|
|
607
|
+
def infer_topic_id
|
|
608
|
+
comment = @context[:comment]
|
|
609
|
+
return comment.topic_id if comment.respond_to?(:topic_id) && comment.topic_id.present?
|
|
610
|
+
|
|
611
|
+
nil
|
|
612
|
+
end
|
|
613
|
+
|
|
542
614
|
def default_url_options
|
|
543
615
|
options = Rails.application.config.action_mailer.default_url_options || {}
|
|
544
616
|
|
|
@@ -22,6 +22,20 @@ module CollavreOpenclaw
|
|
|
22
22
|
COMPLETED_RUN_COOLDOWN = 5 # seconds to suppress late-arriving events for completed runs
|
|
23
23
|
SEEN_EVENT_TTL = 30 # seconds to remember (runId, seq) pairs for dedup
|
|
24
24
|
|
|
25
|
+
# WebSocket close codes → reconnection policy
|
|
26
|
+
# :reconnect — schedule reconnect with exponential backoff
|
|
27
|
+
# :fatal — permanent failure (auth, forbidden), don't retry, propagate error
|
|
28
|
+
# :normal — clean shutdown, no reconnect, no error
|
|
29
|
+
CLOSE_POLICIES = {
|
|
30
|
+
1000 => :normal, # Normal closure
|
|
31
|
+
1001 => :reconnect, # Going away (server shutting down)
|
|
32
|
+
1006 => :reconnect, # Abnormal closure (network issue)
|
|
33
|
+
1008 => :fatal, # Policy violation (likely auth)
|
|
34
|
+
1011 => :reconnect, # Internal server error
|
|
35
|
+
4001 => :fatal, # Auth failure (OpenClaw)
|
|
36
|
+
4003 => :fatal # Forbidden (OpenClaw)
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
25
39
|
attr_reader :user, :state
|
|
26
40
|
|
|
27
41
|
# States: :disconnected, :connecting, :connected, :reconnecting
|
|
@@ -39,8 +53,7 @@ module CollavreOpenclaw
|
|
|
39
53
|
@proactive_handler = nil
|
|
40
54
|
@reconnect_attempts = 0
|
|
41
55
|
@last_activity_at = nil
|
|
42
|
-
@
|
|
43
|
-
@tick_timer = nil
|
|
56
|
+
@rpc_run_registrations = {} # RPC request_id → run_queue (for EM-thread runId registration)
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
def connected?
|
|
@@ -52,7 +65,6 @@ module CollavreOpenclaw
|
|
|
52
65
|
def connect!
|
|
53
66
|
return if connected?
|
|
54
67
|
|
|
55
|
-
initiator = false
|
|
56
68
|
waiter_queue = nil
|
|
57
69
|
|
|
58
70
|
@connect_mutex.synchronize do
|
|
@@ -64,7 +76,6 @@ module CollavreOpenclaw
|
|
|
64
76
|
@connect_waiters << waiter_queue
|
|
65
77
|
else
|
|
66
78
|
@state = :connecting
|
|
67
|
-
initiator = true
|
|
68
79
|
end
|
|
69
80
|
end
|
|
70
81
|
|
|
@@ -126,7 +137,6 @@ module CollavreOpenclaw
|
|
|
126
137
|
def disconnect!
|
|
127
138
|
@state = :disconnected
|
|
128
139
|
EmReactor.next_tick do
|
|
129
|
-
cancel_tick_timer!
|
|
130
140
|
@ws&.close
|
|
131
141
|
@ws = nil
|
|
132
142
|
end
|
|
@@ -135,16 +145,18 @@ module CollavreOpenclaw
|
|
|
135
145
|
@pending_requests.clear
|
|
136
146
|
@pending_runs.each_value { |q| q.push({ done: true }) }
|
|
137
147
|
@pending_runs.clear
|
|
148
|
+
@rpc_run_registrations.clear
|
|
138
149
|
end
|
|
139
150
|
|
|
140
151
|
# Send a chat message. Blocks and yields streaming events.
|
|
141
152
|
#
|
|
142
153
|
# @param session_key [String]
|
|
143
154
|
# @param message [String]
|
|
155
|
+
# @param attachments [Array<Hash>, nil]
|
|
144
156
|
# @param idempotency_key [String]
|
|
145
157
|
# @yield [Hash] chat events with :state, :text, :message keys
|
|
146
158
|
# @return [String, nil] final response text
|
|
147
|
-
def chat_send(session_key:, message:, idempotency_key: nil, &block)
|
|
159
|
+
def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, &block)
|
|
148
160
|
ensure_connected!
|
|
149
161
|
touch_activity!
|
|
150
162
|
|
|
@@ -156,19 +168,33 @@ module CollavreOpenclaw
|
|
|
156
168
|
# Pre-register with idempotency_key to catch early events
|
|
157
169
|
@mutex.synchronize { @pending_runs[idempotency_key] = run_queue }
|
|
158
170
|
|
|
159
|
-
# Send the RPC request to get the real runId
|
|
160
|
-
|
|
171
|
+
# Send the RPC request to get the real runId.
|
|
172
|
+
# IMPORTANT: We pass the run_queue via @rpc_run_registrations so that
|
|
173
|
+
# handle_response can register @pending_runs[actual_run_id] on the EM
|
|
174
|
+
# thread BEFORE any subsequent chat events arrive. This prevents a race
|
|
175
|
+
# condition where fast Gateway responses send events before the Rails
|
|
176
|
+
# thread can re-register with the actual runId.
|
|
177
|
+
rpc_request_id = SecureRandom.uuid
|
|
178
|
+
@mutex.synchronize { @rpc_run_registrations[rpc_request_id] = run_queue }
|
|
179
|
+
|
|
180
|
+
rpc_params = {
|
|
161
181
|
sessionKey: session_key,
|
|
162
182
|
message: message,
|
|
163
183
|
idempotencyKey: idempotency_key
|
|
164
|
-
}
|
|
184
|
+
}
|
|
185
|
+
rpc_params[:attachments] = attachments if attachments.present?
|
|
186
|
+
|
|
187
|
+
response = send_rpc("chat.send", rpc_params, request_id: rpc_request_id)
|
|
165
188
|
|
|
166
|
-
#
|
|
189
|
+
# The EM thread already registered @pending_runs[actual_run_id] in
|
|
190
|
+
# handle_response. Clean up the idempotency_key entry if a different
|
|
191
|
+
# runId was assigned.
|
|
167
192
|
actual_run_id = response&.dig(:runId) || idempotency_key
|
|
168
193
|
if actual_run_id != idempotency_key
|
|
169
194
|
@mutex.synchronize do
|
|
170
195
|
@pending_runs.delete(idempotency_key)
|
|
171
|
-
|
|
196
|
+
# Ensure runId is registered (may already be from handle_response)
|
|
197
|
+
@pending_runs[actual_run_id] ||= run_queue
|
|
172
198
|
end
|
|
173
199
|
end
|
|
174
200
|
|
|
@@ -185,14 +211,19 @@ module CollavreOpenclaw
|
|
|
185
211
|
end
|
|
186
212
|
|
|
187
213
|
# Gateway may broadcast + nodeSend the same event, causing
|
|
188
|
-
# duplicates on the same WebSocket.
|
|
214
|
+
# duplicates on the same WebSocket. Skip already-seen seqs for
|
|
215
|
+
# deltas only. Terminal events (final, error, aborted) must NEVER
|
|
216
|
+
# be skipped — they break the loop and unblock the caller.
|
|
189
217
|
seq = event[:seq]
|
|
190
|
-
|
|
218
|
+
event_state = event[:state]
|
|
219
|
+
is_terminal = event_state == "final" || event_state == "error" || event_state == "aborted"
|
|
220
|
+
|
|
221
|
+
if !is_terminal && seq && last_seq && seq <= last_seq
|
|
191
222
|
next
|
|
192
223
|
end
|
|
193
224
|
last_seq = seq if seq
|
|
194
225
|
|
|
195
|
-
case
|
|
226
|
+
case event_state
|
|
196
227
|
when "delta"
|
|
197
228
|
text = extract_event_text(event)
|
|
198
229
|
if text.present?
|
|
@@ -234,6 +265,21 @@ module CollavreOpenclaw
|
|
|
234
265
|
end
|
|
235
266
|
end
|
|
236
267
|
|
|
268
|
+
# Inject an assistant message into a session transcript.
|
|
269
|
+
# Useful for pre-populating context without triggering an agent run.
|
|
270
|
+
#
|
|
271
|
+
# @param session_key [String]
|
|
272
|
+
# @param message [String] content to inject
|
|
273
|
+
# @param label [String, nil] optional label for the injected message
|
|
274
|
+
def chat_inject(session_key:, message:, label: nil)
|
|
275
|
+
ensure_connected!
|
|
276
|
+
touch_activity!
|
|
277
|
+
|
|
278
|
+
params = { sessionKey: session_key, message: message }
|
|
279
|
+
params[:label] = label if label
|
|
280
|
+
send_rpc("chat.inject", params)
|
|
281
|
+
end
|
|
282
|
+
|
|
237
283
|
# Fetch chat history for a session
|
|
238
284
|
def chat_history(session_key:, limit: nil)
|
|
239
285
|
ensure_connected!
|
|
@@ -260,6 +306,12 @@ module CollavreOpenclaw
|
|
|
260
306
|
@proactive_handler = handler
|
|
261
307
|
end
|
|
262
308
|
|
|
309
|
+
# Register a callback invoked when the connection dies with a fatal close code
|
|
310
|
+
# (auth failure, forbidden, etc.). ConnectionManager uses this to remove dead clients.
|
|
311
|
+
def on_fatal_close(&handler)
|
|
312
|
+
@on_fatal_close = handler
|
|
313
|
+
end
|
|
314
|
+
|
|
263
315
|
# Time since last activity (for idle timeout)
|
|
264
316
|
def idle_seconds
|
|
265
317
|
return Float::INFINITY unless @last_activity_at
|
|
@@ -272,6 +324,23 @@ module CollavreOpenclaw
|
|
|
272
324
|
CollavreOpenclaw.config
|
|
273
325
|
end
|
|
274
326
|
|
|
327
|
+
# Determine reconnection policy for a WebSocket close code.
|
|
328
|
+
# Returns :reconnect, :fatal, or :normal.
|
|
329
|
+
def close_policy(code)
|
|
330
|
+
CLOSE_POLICIES[code] || (code.to_i >= 4000 ? :fatal : :reconnect)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Drain all pending requests and streaming runs with an error message.
|
|
334
|
+
# Called on fatal close to unblock waiting Rails threads.
|
|
335
|
+
def drain_pending_with_error!(message)
|
|
336
|
+
@mutex.synchronize do
|
|
337
|
+
@pending_requests.each_value { |pr| pr[:queue]&.push({ error: message }) }
|
|
338
|
+
@pending_requests.clear
|
|
339
|
+
@pending_runs.each_value { |q| q.push({ error: message }) }
|
|
340
|
+
@pending_runs.clear
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
275
344
|
def gateway_ws_url
|
|
276
345
|
url = @user.gateway_url.to_s.strip
|
|
277
346
|
return nil if url.blank?
|
|
@@ -301,8 +370,7 @@ module CollavreOpenclaw
|
|
|
301
370
|
@handshake_done = false
|
|
302
371
|
|
|
303
372
|
@ws.on :open do |_event|
|
|
304
|
-
Rails.logger.info("[CollavreOpenclaw::WS]
|
|
305
|
-
# Wait for connect.challenge from gateway
|
|
373
|
+
Rails.logger.info("[CollavreOpenclaw::WS] CONNECT gateway=#{url} state=open")
|
|
306
374
|
end
|
|
307
375
|
|
|
308
376
|
@ws.on :message do |event|
|
|
@@ -312,18 +380,26 @@ module CollavreOpenclaw
|
|
|
312
380
|
@ws.on :close do |event|
|
|
313
381
|
code = event.code
|
|
314
382
|
reason = event.reason
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
cancel_tick_timer!
|
|
383
|
+
policy = close_policy(code)
|
|
384
|
+
Rails.logger.info("[CollavreOpenclaw::WS] DISCONNECT gateway=#{url} code=#{code} reason=#{reason} policy=#{policy}")
|
|
318
385
|
|
|
319
386
|
unless @handshake_done
|
|
320
387
|
@handshake_done = true
|
|
321
388
|
@handshake_queue&.push({ error: "Connection closed during handshake (code=#{code})" })
|
|
322
389
|
end
|
|
323
390
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
391
|
+
case policy
|
|
392
|
+
when :reconnect
|
|
393
|
+
if @state == :connected || @state == :connecting
|
|
394
|
+
@state = :reconnecting
|
|
395
|
+
schedule_reconnect!
|
|
396
|
+
end
|
|
397
|
+
when :fatal
|
|
398
|
+
@state = :disconnected
|
|
399
|
+
drain_pending_with_error!("Connection closed with fatal code #{code}: #{reason}")
|
|
400
|
+
@on_fatal_close&.call(self)
|
|
401
|
+
when :normal
|
|
402
|
+
@state = :disconnected
|
|
327
403
|
end
|
|
328
404
|
end
|
|
329
405
|
end
|
|
@@ -395,10 +471,8 @@ module CollavreOpenclaw
|
|
|
395
471
|
if id == @connect_request_id && !@handshake_done
|
|
396
472
|
@handshake_done = true
|
|
397
473
|
if ok
|
|
398
|
-
@tick_interval_ms = payload&.dig(:policy, :tickIntervalMs) || 15_000
|
|
399
474
|
@state = :connected
|
|
400
475
|
@reconnect_attempts = 0
|
|
401
|
-
start_tick_timer!
|
|
402
476
|
@handshake_queue&.push({ ok: true, payload: payload })
|
|
403
477
|
else
|
|
404
478
|
error_msg = error&.dig(:message) || error.to_s || "handshake failed"
|
|
@@ -411,6 +485,19 @@ module CollavreOpenclaw
|
|
|
411
485
|
# Regular RPC response
|
|
412
486
|
pending = @mutex.synchronize { @pending_requests.delete(id) }
|
|
413
487
|
if pending
|
|
488
|
+
# If this RPC response contains a runId (chat.send response), register
|
|
489
|
+
# the run_queue under that runId NOW, on the EM thread, before any
|
|
490
|
+
# subsequent chat events arrive. This eliminates the race condition
|
|
491
|
+
# where fast events arrive before the Rails thread can re-register.
|
|
492
|
+
if ok && payload.is_a?(Hash) && payload[:runId]
|
|
493
|
+
run_queue = @mutex.synchronize { @rpc_run_registrations.delete(id) }
|
|
494
|
+
if run_queue
|
|
495
|
+
@mutex.synchronize { @pending_runs[payload[:runId]] = run_queue }
|
|
496
|
+
end
|
|
497
|
+
else
|
|
498
|
+
@mutex.synchronize { @rpc_run_registrations.delete(id) }
|
|
499
|
+
end
|
|
500
|
+
|
|
414
501
|
if ok
|
|
415
502
|
pending[:queue].push({ ok: true, payload: payload })
|
|
416
503
|
else
|
|
@@ -425,9 +512,11 @@ module CollavreOpenclaw
|
|
|
425
512
|
seq = payload[:seq]
|
|
426
513
|
|
|
427
514
|
# Dedup: Gateway sends identical events via broadcast() + nodeSendToSession().
|
|
428
|
-
#
|
|
429
|
-
|
|
430
|
-
|
|
515
|
+
# Key includes state so that a delta and final with the same seq are NOT
|
|
516
|
+
# treated as duplicates (they are different events that share a seq number).
|
|
517
|
+
state = payload[:state]
|
|
518
|
+
if run_id && seq && duplicate_chat_event?(run_id, seq, state)
|
|
519
|
+
Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} seq=#{seq} state=dedup_skipped")
|
|
431
520
|
return
|
|
432
521
|
end
|
|
433
522
|
|
|
@@ -439,21 +528,22 @@ module CollavreOpenclaw
|
|
|
439
528
|
run_queue.push(payload)
|
|
440
529
|
elsif recently_completed_run?(run_id)
|
|
441
530
|
# Late-arriving event for a run we already finished — suppress it
|
|
442
|
-
Rails.logger.info("[CollavreOpenclaw::WS]
|
|
531
|
+
Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=late_suppressed")
|
|
443
532
|
elsif @proactive_handler
|
|
444
533
|
# Unknown run — proactive message from Gateway (cron/heartbeat)
|
|
445
|
-
Rails.logger.info("[CollavreOpenclaw::WS]
|
|
534
|
+
Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=proactive")
|
|
446
535
|
@proactive_handler.call(@user, payload)
|
|
447
536
|
else
|
|
448
|
-
Rails.logger.debug("[CollavreOpenclaw::WS]
|
|
537
|
+
Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=ignored_no_handler")
|
|
449
538
|
end
|
|
450
539
|
end
|
|
451
540
|
|
|
452
|
-
# Check if this (runId, seq)
|
|
541
|
+
# Check if this (runId, seq, state) triple was already seen. Records it if new.
|
|
453
542
|
# Gateway emits each event via broadcast() AND nodeSendToSession(),
|
|
454
|
-
# delivering the identical payload
|
|
455
|
-
|
|
456
|
-
|
|
543
|
+
# delivering the identical payload twice. Including state in the key
|
|
544
|
+
# ensures a delta and final with the same seq are treated as distinct events.
|
|
545
|
+
def duplicate_chat_event?(run_id, seq, state = nil)
|
|
546
|
+
key = "#{run_id}:#{seq}:#{state}"
|
|
457
547
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
458
548
|
|
|
459
549
|
@mutex.synchronize do
|
|
@@ -485,38 +575,21 @@ module CollavreOpenclaw
|
|
|
485
575
|
end
|
|
486
576
|
|
|
487
577
|
def handle_tick(_payload)
|
|
488
|
-
#
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
id: SecureRandom.uuid,
|
|
492
|
-
method: "poll",
|
|
493
|
-
params: {}
|
|
494
|
-
})
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
def start_tick_timer!
|
|
498
|
-
cancel_tick_timer!
|
|
499
|
-
interval = @tick_interval_ms / 1000.0
|
|
500
|
-
@tick_timer = EM.add_periodic_timer(interval) do
|
|
501
|
-
# Send keepalive poll if the server hasn't sent a tick
|
|
502
|
-
send_frame({
|
|
503
|
-
type: "req",
|
|
504
|
-
id: SecureRandom.uuid,
|
|
505
|
-
method: "poll",
|
|
506
|
-
params: {}
|
|
507
|
-
})
|
|
508
|
-
end
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
def cancel_tick_timer!
|
|
512
|
-
@tick_timer&.cancel
|
|
513
|
-
@tick_timer = nil
|
|
578
|
+
# The tick event from Gateway is a keepalive heartbeat.
|
|
579
|
+
# Receiving it already refreshes our activity timer (via touch_activity!
|
|
580
|
+
# in handle_raw_message). No response is needed.
|
|
514
581
|
end
|
|
515
582
|
|
|
516
583
|
# Send an RPC request and block until the response.
|
|
517
584
|
# Returns the response payload.
|
|
518
|
-
|
|
519
|
-
|
|
585
|
+
#
|
|
586
|
+
# @param method [String] RPC method name
|
|
587
|
+
# @param params [Hash] RPC parameters
|
|
588
|
+
# @param request_id [String, nil] Pre-generated request ID. Used by chat_send
|
|
589
|
+
# to correlate the RPC response with the run_queue for EM-thread runId
|
|
590
|
+
# registration (see handle_response). If nil, a random UUID is generated.
|
|
591
|
+
def send_rpc(method, params, request_id: nil)
|
|
592
|
+
request_id ||= SecureRandom.uuid
|
|
520
593
|
queue = Queue.new
|
|
521
594
|
|
|
522
595
|
@mutex.synchronize do
|
|
@@ -566,7 +639,8 @@ module CollavreOpenclaw
|
|
|
566
639
|
delay = config.ws_reconnect_base_delay * (2**(@reconnect_attempts - 1))
|
|
567
640
|
delay = [ delay, 60 ].min # Cap at 60 seconds
|
|
568
641
|
|
|
569
|
-
|
|
642
|
+
url = gateway_ws_url
|
|
643
|
+
Rails.logger.info("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} attempt=#{@reconnect_attempts}/#{max} delay=#{delay}s")
|
|
570
644
|
|
|
571
645
|
EM.add_timer(delay) do
|
|
572
646
|
next if @state == :disconnected # User explicitly disconnected
|
|
@@ -580,13 +654,13 @@ module CollavreOpenclaw
|
|
|
580
654
|
EM.add_timer(config.ws_connect_timeout) do
|
|
581
655
|
unless @handshake_done
|
|
582
656
|
@handshake_done = true
|
|
583
|
-
Rails.logger.warn("[CollavreOpenclaw::WS]
|
|
657
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=handshake_timeout")
|
|
584
658
|
@ws&.close
|
|
585
659
|
schedule_reconnect!
|
|
586
660
|
end
|
|
587
661
|
end
|
|
588
662
|
rescue => e
|
|
589
|
-
Rails.logger.error("[CollavreOpenclaw::WS]
|
|
663
|
+
Rails.logger.error("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=fail reason=#{e.message}")
|
|
590
664
|
schedule_reconnect!
|
|
591
665
|
end
|
|
592
666
|
end
|
|
@@ -33,7 +33,7 @@ module CollavreOpenclaw
|
|
|
33
33
|
@ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
|
|
34
34
|
@ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
|
|
35
35
|
@ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
|
|
36
|
-
@transport = ENV.fetch("OPENCLAW_TRANSPORT", "
|
|
36
|
+
@transport = ENV.fetch("OPENCLAW_TRANSPORT", "auto")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Legacy accessor for backward compatibility
|