openclacky 0.9.23 → 0.9.25
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/CHANGELOG.md +41 -1
- data/lib/clacky/agent/message_compressor_helper.rb +24 -4
- data/lib/clacky/agent/session_serializer.rb +197 -4
- data/lib/clacky/agent.rb +20 -7
- data/lib/clacky/default_skills/channel-setup/SKILL.md +24 -8
- data/lib/clacky/default_skills/product-help/SKILL.md +14 -1
- data/lib/clacky/message_format/anthropic.rb +14 -2
- data/lib/clacky/message_format/bedrock.rb +14 -2
- data/lib/clacky/providers.rb +9 -0
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +47 -14
- data/lib/clacky/server/http_server.rb +99 -40
- data/lib/clacky/server/server_master.rb +1 -0
- data/lib/clacky/server/session_registry.rb +35 -14
- data/lib/clacky/tools/safe_shell.rb +21 -0
- data/lib/clacky/tools/shell.rb +66 -28
- data/lib/clacky/tools/web_search.rb +21 -67
- data/lib/clacky/utils/file_processor.rb +35 -3
- data/lib/clacky/utils/scripts_manager.rb +2 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +281 -34
- data/lib/clacky/web/app.js +134 -22
- data/lib/clacky/web/channels.js +0 -1
- data/lib/clacky/web/i18n.js +26 -8
- data/lib/clacky/web/index.html +47 -23
- data/lib/clacky/web/onboard.js +0 -1
- data/lib/clacky/web/sessions.js +214 -104
- data/lib/clacky/web/settings.js +0 -1
- data/lib/clacky/web/skills.js +3 -6
- data/lib/clacky/web/tasks.js +2 -4
- data/lib/clacky/web/weixin-qr.html +108 -3
- data/scripts/install.ps1 +1 -1
- data/scripts/install_browser.sh +48 -0
- data/scripts/install_system_deps.sh +254 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 931a504ff578fcee85cf5d361feeca8b772a0aafe3438c4f27cc7da52573453a
|
|
4
|
+
data.tar.gz: f1cef39e28c531fef2b2b0fb3cf435ffb05729fdeaae12dadb4e338df073ec1e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b8bc2a7dd702beb4570e40264b63b75f170c26dea56a354b20c9afdd1d97fb0ef1e2a8010457170d23823ed6bc82353bbfd390c9ef8f6137ad9bbf73e15ee17
|
|
7
|
+
data.tar.gz: dc13201eda667f31435309a3664503fd53f1973983e0494f2b29b2ff5c0fca336b9801c265aa18830e1878e0a970237de16ab41833992119d8ea24f2eea66b62
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.25] - 2026-04-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **CSV file upload support**: you can now upload `.csv` files in the Web UI — agent can read and analyse tabular data directly
|
|
14
|
+
- **Browser install tips**: when a browser-dependent command fails, the agent now shows a clear install tip with instructions to set up Chrome/Edge, rather than a cryptic error
|
|
15
|
+
- **Auto-focus on file upload dialog**: the file input field is now auto-focused when the upload dialog opens, improving keyboard UX
|
|
16
|
+
- **Session ID search in Web UI**: you can now search sessions by session ID in addition to session name
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **WeChat (Weixin) file upload**: fixed a bug where file attachments sent via WeChat were not correctly forwarded to the agent
|
|
20
|
+
- **WeChat without browser**: WeChat channel now works even when no browser tool is configured — falls back gracefully
|
|
21
|
+
- **API message timeout**: fixed a race condition in message compression / session serialisation that could cause API requests to time out mid-conversation
|
|
22
|
+
- **Session chunk replay**: fixed a bug where streaming (chunk-based) messages were incorrectly replayed when restoring a session
|
|
23
|
+
|
|
24
|
+
### Improved
|
|
25
|
+
- **Shell tool robustness**: `pkill` commands are now scope-limited to prevent accidental process kills; server process cleans up properly when the terminal is closed
|
|
26
|
+
- **Broken pipe handling**: improved error handling in the HTTP server and shell tool to avoid noisy broken-pipe errors on abrupt connection close
|
|
27
|
+
|
|
28
|
+
### More
|
|
29
|
+
- Updated product-help skill with new session search and CSV upload documentation
|
|
30
|
+
- Updated channel-setup skill with improved WeChat non-browser setup guide
|
|
31
|
+
|
|
32
|
+
## [0.9.24] - 2026-04-02
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **New session list & search in Web UI**: sidebar now shows full session history with real-time search — find any past conversation instantly
|
|
36
|
+
- **Session type indicators**: sessions are labeled by type (chat / agent) so you can see at a glance what kind of interaction it was
|
|
37
|
+
- **Image lightbox**: click any image in the chat to expand it full-screen with a clean overlay viewer
|
|
38
|
+
- **Session history replay for streaming messages**: chunk-based (streaming) messages are now fully replayed when revisiting a past session
|
|
39
|
+
- **Xiaomi AI provider**: added Xiaomi as a supported AI provider
|
|
40
|
+
- **Chinese Bing web search**: web search now uses cn.bing.com for users in China, improving search relevance and reliability
|
|
41
|
+
- **Auto-install system dependencies script**: agent can now automatically install missing system packages (Node, Python, etc.) via a bundled `install_system_deps.sh` script
|
|
42
|
+
- **User message timestamps**: each user message now displays the time it was sent
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- **Bedrock file attachments & partial cost tracking**: fixed file handling and cost accumulation for AWS Bedrock sessions
|
|
46
|
+
- **Session name timestamp**: fixed incorrect timestamp display on session names
|
|
47
|
+
- **New session scroll**: new sessions now correctly scroll to the latest message
|
|
48
|
+
- **Feishu WebSocket client crash**: fixed a nil-reference error that caused the Feishu WS client to crash on reconnect
|
|
49
|
+
|
|
10
50
|
## [0.9.23] - 2026-04-01
|
|
11
51
|
|
|
12
52
|
### Improved
|
|
@@ -17,7 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
57
|
- **CLI -c option model initialization**: fixed a bug where the CLI command with -c option was not passing the model name to the client, causing routing failures for certain providers
|
|
18
58
|
|
|
19
59
|
### More
|
|
20
|
-
-
|
|
60
|
+
- ClackyAI provider updated to use the latest model name format (abs- prefix)
|
|
21
61
|
|
|
22
62
|
## [0.9.22] - 2026-03-31
|
|
23
63
|
|
|
@@ -128,7 +128,11 @@ module Clacky
|
|
|
128
128
|
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
129
129
|
|
|
130
130
|
# Archive compressed messages to a chunk MD file before discarding them
|
|
131
|
-
|
|
131
|
+
# Count existing compressed_summary messages in history to determine the next chunk index.
|
|
132
|
+
# Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
|
|
133
|
+
# chunk files, creating circular chunk references. Counting from history is always accurate.
|
|
134
|
+
existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
|
|
135
|
+
chunk_index = existing_chunk_count + 1
|
|
132
136
|
chunk_path = save_compressed_chunk(
|
|
133
137
|
original_messages,
|
|
134
138
|
compression_context[:recent_messages],
|
|
@@ -305,9 +309,11 @@ module Clacky
|
|
|
305
309
|
return nil unless @session_id && @created_at
|
|
306
310
|
|
|
307
311
|
# Messages being compressed = original minus system message minus recent messages
|
|
312
|
+
# Also exclude system-injected scaffolding (session context, memory prompts, etc.)
|
|
313
|
+
# — these are internal CLI metadata and must not appear in chunk MD or WebUI history.
|
|
308
314
|
recent_set = recent_messages.to_a
|
|
309
315
|
messages_to_archive = original_messages.reject do |m|
|
|
310
|
-
m[:role] == "system" || recent_set.include?(m)
|
|
316
|
+
m[:role] == "system" || m[:system_injected] || recent_set.include?(m)
|
|
311
317
|
end
|
|
312
318
|
|
|
313
319
|
return nil if messages_to_archive.empty?
|
|
@@ -374,9 +380,23 @@ module Clacky
|
|
|
374
380
|
end
|
|
375
381
|
lines << ""
|
|
376
382
|
# Include tool calls summary if present
|
|
383
|
+
# Format: "_Tool calls: name | {args_json}_" so replay can restore args for WebUI display.
|
|
377
384
|
if msg[:tool_calls]&.any?
|
|
378
|
-
|
|
379
|
-
|
|
385
|
+
tc_parts = msg[:tool_calls].map do |tc|
|
|
386
|
+
name = tc.dig(:function, :name) || tc[:name] || ""
|
|
387
|
+
next nil if name.empty?
|
|
388
|
+
|
|
389
|
+
args_raw = tc.dig(:function, :arguments) || tc[:arguments] || {}
|
|
390
|
+
args = args_raw.is_a?(String) ? (JSON.parse(args_raw) rescue nil) : args_raw
|
|
391
|
+
if args.is_a?(Hash) && !args.empty?
|
|
392
|
+
# Truncate large string values to keep chunk MD readable
|
|
393
|
+
compact = args.transform_values { |v| v.is_a?(String) && v.length > 200 ? v[0..197] + "..." : v }
|
|
394
|
+
"#{name} | #{compact.to_json}"
|
|
395
|
+
else
|
|
396
|
+
name
|
|
397
|
+
end
|
|
398
|
+
end.compact
|
|
399
|
+
lines << "_Tool calls: #{tc_parts.join("; ")}_"
|
|
380
400
|
lines << ""
|
|
381
401
|
end
|
|
382
402
|
lines << format_message_content(content) if content
|
|
@@ -114,6 +114,7 @@ module Clacky
|
|
|
114
114
|
# Replay conversation history by calling ui.show_* methods for each message.
|
|
115
115
|
# Supports cursor-based pagination using created_at timestamps on user messages.
|
|
116
116
|
# Each "round" starts at a user message and includes all subsequent assistant/tool messages.
|
|
117
|
+
# Compressed chunks (chunk_path on assistant messages) are transparently expanded.
|
|
117
118
|
#
|
|
118
119
|
# @param ui [Object] UI interface that responds to show_user_message, show_assistant_message, etc.
|
|
119
120
|
# @param limit [Integer] Maximum number of rounds (user turns) to replay
|
|
@@ -147,9 +148,29 @@ module Clacky
|
|
|
147
148
|
rounds << current_round
|
|
148
149
|
elsif current_round
|
|
149
150
|
current_round[:events] << msg
|
|
151
|
+
elsif msg[:compressed_summary] && msg[:chunk_path]
|
|
152
|
+
# Compressed summary sitting before any user rounds — expand it from chunk md
|
|
153
|
+
chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
|
|
154
|
+
rounds.concat(chunk_rounds)
|
|
155
|
+
# After expanding, treat the last chunk round as the current round so that
|
|
156
|
+
# any orphaned assistant/tool messages that follow in session.json (belonging
|
|
157
|
+
# to the same task whose user message was compressed into the chunk) get
|
|
158
|
+
# appended here instead of being silently discarded.
|
|
159
|
+
current_round = rounds.last
|
|
160
|
+
elsif rounds.last
|
|
161
|
+
# Orphaned non-user message with no current_round yet (e.g. recent_messages
|
|
162
|
+
# after compression started mid-task with no leading user message).
|
|
163
|
+
# Attach to the last known round rather than drop silently.
|
|
164
|
+
rounds.last[:events] << msg
|
|
150
165
|
end
|
|
151
166
|
end
|
|
152
167
|
|
|
168
|
+
# Expand any compressed_summary assistant messages sitting inside a round's events.
|
|
169
|
+
# These occur when compression happened mid-round (rare) — expand them in-place.
|
|
170
|
+
rounds.each do |round|
|
|
171
|
+
round[:events].select! { |ev| !ev[:compressed_summary] }
|
|
172
|
+
end
|
|
173
|
+
|
|
153
174
|
# Apply before-cursor filter: only rounds whose user message created_at < before
|
|
154
175
|
if before
|
|
155
176
|
rounds = rounds.select { |r| r[:user_msg][:created_at] && r[:user_msg][:created_at] < before }
|
|
@@ -193,6 +214,176 @@ module Clacky
|
|
|
193
214
|
{ has_more: has_more }
|
|
194
215
|
end
|
|
195
216
|
|
|
217
|
+
# Parse a chunk MD file into an array of rounds compatible with replay_history.
|
|
218
|
+
# Each round is { user_msg: Hash, events: Array<Hash> }.
|
|
219
|
+
# Timestamps are synthesised from the chunk's archived_at, spread backwards.
|
|
220
|
+
# Recursively expands nested chunk references (compressed summary inside a chunk).
|
|
221
|
+
#
|
|
222
|
+
# @param chunk_path [String] Path to the chunk md file
|
|
223
|
+
# @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
|
|
224
|
+
private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new)
|
|
225
|
+
return [] unless chunk_path
|
|
226
|
+
|
|
227
|
+
# 1. Try the stored absolute path first (same machine, normal case).
|
|
228
|
+
# 2. If not found, fall back to basename + SESSIONS_DIR (cross-user / cross-machine).
|
|
229
|
+
resolved = chunk_path.to_s
|
|
230
|
+
unless File.exist?(resolved)
|
|
231
|
+
resolved = File.join(Clacky::SessionManager::SESSIONS_DIR, File.basename(resolved))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
return [] unless File.exist?(resolved)
|
|
235
|
+
|
|
236
|
+
# Guard against circular chunk references (e.g. chunk-3 → chunk-2 → chunk-1 → chunk-9 → … → chunk-3)
|
|
237
|
+
canonical = File.expand_path(resolved)
|
|
238
|
+
if visited.include?(canonical)
|
|
239
|
+
Clacky::Logger.warn("parse_chunk_md_to_rounds: circular reference detected, skipping #{canonical}")
|
|
240
|
+
return []
|
|
241
|
+
end
|
|
242
|
+
visited = visited.dup.add(canonical)
|
|
243
|
+
|
|
244
|
+
raw = File.read(resolved)
|
|
245
|
+
|
|
246
|
+
# Parse YAML front matter to get archived_at for synthetic timestamps
|
|
247
|
+
archived_at = nil
|
|
248
|
+
if raw.start_with?("---")
|
|
249
|
+
fm_end = raw.index("\n---\n", 4)
|
|
250
|
+
if fm_end
|
|
251
|
+
fm_text = raw[4...fm_end]
|
|
252
|
+
fm_text.each_line do |line|
|
|
253
|
+
if line.start_with?("archived_at:")
|
|
254
|
+
archived_at = Time.parse(line.split(":", 2).last.strip) rescue nil
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
base_time = (archived_at || Time.now).to_f
|
|
260
|
+
chunk_dir = File.dirname(chunk_path.to_s)
|
|
261
|
+
|
|
262
|
+
# Split into sections by ## headings
|
|
263
|
+
sections = []
|
|
264
|
+
current_role = nil
|
|
265
|
+
current_lines = []
|
|
266
|
+
current_nested_chunk = nil # chunk reference from a Compressed Summary heading
|
|
267
|
+
|
|
268
|
+
raw.each_line do |line|
|
|
269
|
+
stripped = line.chomp
|
|
270
|
+
if (m = stripped.match(/\A## Assistant \[Compressed Summary — original conversation at: (.+)\]/))
|
|
271
|
+
# Nested chunk reference — record it, treat as assistant section
|
|
272
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
273
|
+
current_role = "assistant"
|
|
274
|
+
current_lines = []
|
|
275
|
+
current_nested_chunk = File.join(chunk_dir, m[1])
|
|
276
|
+
elsif stripped.match?(/\A## (User|Assistant)/)
|
|
277
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
278
|
+
current_role = stripped.match(/\A## (User|Assistant)/)[1].downcase
|
|
279
|
+
current_lines = []
|
|
280
|
+
current_nested_chunk = nil
|
|
281
|
+
elsif stripped.match?(/\A### Tool Result:/)
|
|
282
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
283
|
+
current_role = "tool"
|
|
284
|
+
current_lines = []
|
|
285
|
+
current_nested_chunk = nil
|
|
286
|
+
else
|
|
287
|
+
current_lines << line
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
sections << { role: current_role, lines: current_lines.dup, nested_chunk: current_nested_chunk } if current_role
|
|
291
|
+
|
|
292
|
+
# Remove front-matter / header noise sections (nil role or non-user/assistant/tool)
|
|
293
|
+
sections.select! { |s| %w[user assistant tool].include?(s[:role]) }
|
|
294
|
+
|
|
295
|
+
# Group into rounds: each user section starts a new round
|
|
296
|
+
rounds = []
|
|
297
|
+
current_round = nil
|
|
298
|
+
round_index = 0
|
|
299
|
+
|
|
300
|
+
sections.each do |sec|
|
|
301
|
+
text = sec[:lines].join.strip
|
|
302
|
+
|
|
303
|
+
# Nested chunk: expand it recursively, prepend before current rounds
|
|
304
|
+
if sec[:nested_chunk]
|
|
305
|
+
nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
|
|
306
|
+
rounds = nested + rounds unless nested.empty?
|
|
307
|
+
# Also render its summary text as an assistant event in current round if any
|
|
308
|
+
if current_round && !text.empty?
|
|
309
|
+
current_round[:events] << { role: "assistant", content: text }
|
|
310
|
+
end
|
|
311
|
+
next
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
next if text.empty?
|
|
315
|
+
|
|
316
|
+
if sec[:role] == "user"
|
|
317
|
+
round_index += 1
|
|
318
|
+
# Synthetic timestamp: spread rounds backwards from archived_at
|
|
319
|
+
synthetic_ts = base_time - (sections.size - round_index) * 1.0
|
|
320
|
+
current_round = {
|
|
321
|
+
user_msg: {
|
|
322
|
+
role: "user",
|
|
323
|
+
content: text,
|
|
324
|
+
created_at: synthetic_ts,
|
|
325
|
+
_from_chunk: true
|
|
326
|
+
},
|
|
327
|
+
events: []
|
|
328
|
+
}
|
|
329
|
+
rounds << current_round
|
|
330
|
+
elsif current_round
|
|
331
|
+
if sec[:role] == "assistant"
|
|
332
|
+
# Detect "_Tool calls: ..._" lines — convert to tool_calls events
|
|
333
|
+
# so _replay_single_message renders them as tool group UI (same as live).
|
|
334
|
+
#
|
|
335
|
+
# Formats supported:
|
|
336
|
+
# New: "_Tool calls: name | {"arg":"val"}; name2 | {"k":"v"}_"
|
|
337
|
+
# Old: "_Tool calls: name1, name2_" (backward compat)
|
|
338
|
+
remaining_lines = []
|
|
339
|
+
pending_tool_entries = [] # [{name:, args:}]
|
|
340
|
+
|
|
341
|
+
text.each_line do |line|
|
|
342
|
+
stripped = line.strip
|
|
343
|
+
if (m = stripped.match(/\A_Tool calls?:\s*(.+?)_?\z/i))
|
|
344
|
+
raw = m[1]
|
|
345
|
+
# New format uses ";" as separator between tools (each entry: "name | {json}")
|
|
346
|
+
# Old format uses "," with no JSON part.
|
|
347
|
+
entries = raw.include?(" | ") ? raw.split(/;\s*/) : raw.split(/,\s*/)
|
|
348
|
+
entries.each do |entry|
|
|
349
|
+
entry = entry.strip
|
|
350
|
+
if (parts = entry.match(/\A(.+?)\s*\|\s*(\{.+\})\z/))
|
|
351
|
+
tool_name = parts[1].strip
|
|
352
|
+
args = JSON.parse(parts[2]) rescue {}
|
|
353
|
+
pending_tool_entries << { name: tool_name, args: args }
|
|
354
|
+
else
|
|
355
|
+
pending_tool_entries << { name: entry, args: {} }
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
else
|
|
359
|
+
remaining_lines << line
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Flush any plain text
|
|
364
|
+
plain_text = remaining_lines.join.strip
|
|
365
|
+
current_round[:events] << { role: "assistant", content: plain_text } unless plain_text.empty?
|
|
366
|
+
|
|
367
|
+
# Emit one synthetic tool_calls message per detected tool
|
|
368
|
+
pending_tool_entries.each do |entry|
|
|
369
|
+
current_round[:events] << {
|
|
370
|
+
role: "assistant",
|
|
371
|
+
content: "",
|
|
372
|
+
tool_calls: [{ name: entry[:name], arguments: entry[:args] }]
|
|
373
|
+
}
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
current_round[:events] << { role: "tool", content: text }
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
rounds
|
|
382
|
+
rescue => e
|
|
383
|
+
Clacky::Logger.warn("parse_chunk_md_to_rounds failed for #{chunk_path}: #{e.message}")
|
|
384
|
+
[]
|
|
385
|
+
end
|
|
386
|
+
|
|
196
387
|
|
|
197
388
|
# Render a single non-user message into the UI.
|
|
198
389
|
# Used by both the normal round-based replay and the compressed-session fallback.
|
|
@@ -328,12 +519,14 @@ module Clacky
|
|
|
328
519
|
next unless block[:type] == "image_url"
|
|
329
520
|
|
|
330
521
|
url = block.dig(:image_url, :url)
|
|
331
|
-
|
|
522
|
+
# image_path is stored at send-time so replay can reconstruct the image from tmp
|
|
523
|
+
path = block[:image_path]
|
|
524
|
+
|
|
525
|
+
next unless url&.start_with?("data:") || path
|
|
332
526
|
|
|
333
|
-
|
|
334
|
-
mime_type = url[/\Adata:([^;]+);/, 1] || "image/jpeg"
|
|
527
|
+
mime_type = (url || "")[/\Adata:([^;]+);/, 1] || "image/jpeg"
|
|
335
528
|
ext = mime_type.split("/").last
|
|
336
|
-
{ name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url }
|
|
529
|
+
{ name: "image_#{idx + 1}.#{ext}", mime_type: mime_type, data_url: url, path: path }
|
|
337
530
|
end
|
|
338
531
|
end
|
|
339
532
|
end
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -218,7 +218,9 @@ module Clacky
|
|
|
218
218
|
all_disk_files = disk_files + downgraded
|
|
219
219
|
|
|
220
220
|
# Format user message — text + inline vision images
|
|
221
|
-
|
|
221
|
+
# Store the tmp path alongside the data_url so the history replay can
|
|
222
|
+
# reconstruct the image if the base64 was stripped (e.g. after compression).
|
|
223
|
+
user_content = format_user_content(user_input, vision_images.map { |v| { url: v[:url], path: v[:path] } })
|
|
222
224
|
|
|
223
225
|
# Parse disk files — agent's responsibility, not the upload layer.
|
|
224
226
|
# process_path runs the parser script and returns a FileRef with preview_path or parse_error.
|
|
@@ -1111,15 +1113,25 @@ module Clacky
|
|
|
1111
1113
|
|
|
1112
1114
|
# Build user message content for LLM.
|
|
1113
1115
|
# Returns plain String when no vision images; Array of content parts otherwise.
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
+
# Build user message content for LLM.
|
|
1117
|
+
# vision_images: Array of String (plain url) OR Hash { url:, path: }
|
|
1118
|
+
# path is stored in the block so history replay can reconstruct the image
|
|
1119
|
+
# from the tmp file when the base64 data_url is no longer available.
|
|
1120
|
+
private def format_user_content(text, vision_images)
|
|
1121
|
+
vision_images ||= []
|
|
1116
1122
|
|
|
1117
|
-
return text if
|
|
1123
|
+
return text if vision_images.empty?
|
|
1118
1124
|
|
|
1119
1125
|
content = []
|
|
1120
1126
|
content << { type: "text", text: text } unless text.nil? || text.empty?
|
|
1121
|
-
|
|
1122
|
-
|
|
1127
|
+
vision_images.each do |img|
|
|
1128
|
+
if img.is_a?(Hash)
|
|
1129
|
+
block = { type: "image_url", image_url: { url: img[:url] } }
|
|
1130
|
+
block[:image_path] = img[:path] if img[:path]
|
|
1131
|
+
content << block
|
|
1132
|
+
else
|
|
1133
|
+
content << { type: "image_url", image_url: { url: img } }
|
|
1134
|
+
end
|
|
1123
1135
|
end
|
|
1124
1136
|
content
|
|
1125
1137
|
end
|
|
@@ -1156,7 +1168,8 @@ module Clacky
|
|
|
1156
1168
|
"Today is #{Time.now.strftime('%Y-%m-%d, %A')}",
|
|
1157
1169
|
"Current model: #{current_model}",
|
|
1158
1170
|
os != :unknown ? "OS: #{Clacky::Utils::EnvironmentDetector.os_label}" : nil,
|
|
1159
|
-
desktop ? "Desktop: #{desktop}" : nil
|
|
1171
|
+
desktop ? "Desktop: #{desktop}" : nil,
|
|
1172
|
+
"Working directory: #{@working_dir}"
|
|
1160
1173
|
].compact.join(". ")
|
|
1161
1174
|
|
|
1162
1175
|
content = "[Session context: #{parts}]"
|
|
@@ -206,7 +206,7 @@ On success: "✅ WeCom channel configured. WeCom client → Contacts → Smart B
|
|
|
206
206
|
|
|
207
207
|
Weixin uses a QR code login — no app_id/app_secret needed. The token from the QR scan is saved directly in `channels.yml`.
|
|
208
208
|
|
|
209
|
-
#### Step 1 — Fetch QR code
|
|
209
|
+
#### Step 1 — Fetch QR code
|
|
210
210
|
|
|
211
211
|
Run the script in `--fetch-qr` mode to get the QR URL without blocking:
|
|
212
212
|
|
|
@@ -221,17 +221,29 @@ Parse the JSON output:
|
|
|
221
221
|
|
|
222
222
|
If the output contains `"error"`, show it and stop.
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
> Opening the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
|
|
224
|
+
#### Step 2 — Show QR code to user (browser or manual fallback)
|
|
226
225
|
|
|
227
|
-
|
|
226
|
+
Build the local QR page URL (include current Unix timestamp as `since` to detect new logins only):
|
|
227
|
+
```
|
|
228
|
+
http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>&since=<current_unix_timestamp>
|
|
229
|
+
```
|
|
228
230
|
|
|
231
|
+
**Try browser first** — attempt to open the QR page using the browser tool:
|
|
229
232
|
```
|
|
230
|
-
|
|
233
|
+
browser(action="navigate", url="<qr_page_url>")
|
|
231
234
|
```
|
|
232
235
|
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
**If browser succeeds:** Tell the user:
|
|
237
|
+
> I've opened the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
|
|
238
|
+
|
|
239
|
+
**If browser fails (not configured or unavailable):** Fall back to manual — tell the user:
|
|
240
|
+
> Please open the following link in your browser to scan the WeChat QR code:
|
|
241
|
+
>
|
|
242
|
+
> `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
|
|
243
|
+
>
|
|
244
|
+
> Scan the QR code with WeChat, confirm in the app, then reply "done".
|
|
245
|
+
|
|
246
|
+
The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
|
|
235
247
|
|
|
236
248
|
#### Step 3 — Wait for scan and save credentials
|
|
237
249
|
|
|
@@ -243,7 +255,11 @@ ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
|
|
|
243
255
|
|
|
244
256
|
Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
|
|
245
257
|
|
|
246
|
-
|
|
258
|
+
Run this command with `timeout: 60`. If it doesn't succeed, **retry up to 3 times with the same `$QRCODE_ID`** — the QR code stays valid for 5 minutes. Only stop retrying if:
|
|
259
|
+
- Exit code is 0 → success
|
|
260
|
+
- Output contains "expired" → QR expired, offer to restart from Step 1
|
|
261
|
+
- Output contains "timed out" → offer to restart from Step 1
|
|
262
|
+
- 3 retries exhausted → show error and offer to restart from Step 1
|
|
247
263
|
|
|
248
264
|
Tell the user while waiting:
|
|
249
265
|
> Waiting for you to scan the QR code and confirm in WeChat... (this may take a moment)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: product-help
|
|
3
|
-
description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, or
|
|
3
|
+
description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, troubleshooting, or restarting the server. Do NOT trigger for general coding tasks unrelated to me.'
|
|
4
4
|
fork_agent: true
|
|
5
5
|
user-invocable: false
|
|
6
6
|
auto_summarize: true
|
|
@@ -90,3 +90,16 @@ web_fetch(url: "<URL>", max_length: 5000)
|
|
|
90
90
|
- If the fetched page doesn't answer the question, try the next most relevant URL (max 2 fetches)
|
|
91
91
|
- If still no answer, tell the user: "请访问 https://www.openclacky.com/docs 查看完整文档"
|
|
92
92
|
- Keep answers concise — extract what's relevant, don't paste the whole page
|
|
93
|
+
|
|
94
|
+
## Restarting the server
|
|
95
|
+
|
|
96
|
+
If the user asks to restart the clacky/openclacky server (e.g. "重启", "restart", "请重启openclacky"):
|
|
97
|
+
|
|
98
|
+
**Do NOT fetch any docs.** Just return this answer directly:
|
|
99
|
+
|
|
100
|
+
> To restart the server gracefully (hot restart, zero downtime):
|
|
101
|
+
> ```
|
|
102
|
+
> kill -USR1 $CLACKY_MASTER_PID
|
|
103
|
+
> ```
|
|
104
|
+
> This sends USR1 to the Master process, which spawns a new Worker and gracefully stops the old one.
|
|
105
|
+
> The `$CLACKY_MASTER_PID` environment variable is already set in the current session.
|
|
@@ -168,6 +168,8 @@ module Clacky
|
|
|
168
168
|
# user message as a cache breakpoint, which invalidates the intended cache boundary
|
|
169
169
|
# and results in cache misses (cache_read=0) every turn.
|
|
170
170
|
blocks = content_to_blocks(content)
|
|
171
|
+
# Anthropic rejects messages with an empty content array — use a placeholder text block.
|
|
172
|
+
blocks = [{ type: "text", text: "..." }] if blocks.empty?
|
|
171
173
|
{ role: role, content: blocks }
|
|
172
174
|
end
|
|
173
175
|
|
|
@@ -176,11 +178,17 @@ module Clacky
|
|
|
176
178
|
private_class_method def self.content_to_blocks(content)
|
|
177
179
|
case content
|
|
178
180
|
when String
|
|
181
|
+
# Anthropic rejects blank text blocks — skip empty strings
|
|
182
|
+
return [] if content.empty?
|
|
183
|
+
|
|
179
184
|
[{ type: "text", text: content }]
|
|
180
185
|
when Array
|
|
181
186
|
content.map { |b| normalize_block(b) }.compact
|
|
182
187
|
else
|
|
183
|
-
|
|
188
|
+
str = content.to_s
|
|
189
|
+
return [] if str.empty?
|
|
190
|
+
|
|
191
|
+
[{ type: "text", text: str }]
|
|
184
192
|
end
|
|
185
193
|
end
|
|
186
194
|
|
|
@@ -190,8 +198,12 @@ module Clacky
|
|
|
190
198
|
|
|
191
199
|
case block[:type]
|
|
192
200
|
when "text"
|
|
201
|
+
# Anthropic rejects blank text blocks — drop them instead of sending { type:"text", text:"" }
|
|
202
|
+
text = block[:text]
|
|
203
|
+
return nil if text.nil? || text.empty?
|
|
204
|
+
|
|
193
205
|
# Preserve cache_control if present (placed by Client#apply_message_caching)
|
|
194
|
-
result = { type: "text", text:
|
|
206
|
+
result = { type: "text", text: text }
|
|
195
207
|
result[:cache_control] = block[:cache_control] if block[:cache_control]
|
|
196
208
|
result
|
|
197
209
|
when "image_url"
|
|
@@ -180,6 +180,8 @@ module Clacky
|
|
|
180
180
|
|
|
181
181
|
# regular user/assistant message
|
|
182
182
|
blocks = content_to_blocks(content)
|
|
183
|
+
# Bedrock rejects messages with an empty content array — use a placeholder text block.
|
|
184
|
+
blocks = [{ text: "..." }] if blocks.empty?
|
|
183
185
|
{ role: role, content: blocks }
|
|
184
186
|
end
|
|
185
187
|
|
|
@@ -187,11 +189,17 @@ module Clacky
|
|
|
187
189
|
private_class_method def self.content_to_blocks(content)
|
|
188
190
|
case content
|
|
189
191
|
when String
|
|
192
|
+
# Bedrock rejects blank text blocks — skip empty strings
|
|
193
|
+
return [] if content.empty?
|
|
194
|
+
|
|
190
195
|
[{ text: content }]
|
|
191
196
|
when Array
|
|
192
197
|
content.map { |b| normalize_block(b) }.compact
|
|
193
198
|
else
|
|
194
|
-
|
|
199
|
+
str = content.to_s
|
|
200
|
+
return [] if str.empty?
|
|
201
|
+
|
|
202
|
+
[{ text: str }]
|
|
195
203
|
end
|
|
196
204
|
end
|
|
197
205
|
|
|
@@ -201,7 +209,11 @@ module Clacky
|
|
|
201
209
|
|
|
202
210
|
case block[:type]
|
|
203
211
|
when "text"
|
|
204
|
-
|
|
212
|
+
# Bedrock rejects blank text blocks — drop them
|
|
213
|
+
text = block[:text].to_s
|
|
214
|
+
return nil if text.empty?
|
|
215
|
+
|
|
216
|
+
{ text: text }
|
|
205
217
|
when "image_url"
|
|
206
218
|
# Bedrock image format — base64 only
|
|
207
219
|
url = block.dig(:image_url, :url) || block[:url]
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -59,6 +59,15 @@ module Clacky
|
|
|
59
59
|
"abs-claude-haiku-4-5"
|
|
60
60
|
],
|
|
61
61
|
"website_url" => "https://clacky.ai"
|
|
62
|
+
}.freeze,
|
|
63
|
+
|
|
64
|
+
"mimo" => {
|
|
65
|
+
"name" => "MiMo (Xiaomi)",
|
|
66
|
+
"base_url" => "https://api.xiaomimimo.com/v1",
|
|
67
|
+
"api" => "openai-completions",
|
|
68
|
+
"default_model" => "mimo-v2-pro",
|
|
69
|
+
"models" => ["mimo-v2-pro", "mimo-v2-omni"],
|
|
70
|
+
"website_url" => "https://platform.xiaomimimo.com/"
|
|
62
71
|
}.freeze
|
|
63
72
|
|
|
64
73
|
}.freeze
|
|
@@ -160,6 +160,12 @@ module Clacky
|
|
|
160
160
|
@ping_interval = (client_config["PingInterval"] || 90).to_i
|
|
161
161
|
|
|
162
162
|
url = data.dig("data", "URL")
|
|
163
|
+
if url.nil? || url.strip.empty?
|
|
164
|
+
Clacky::Logger.error("[feishu-ws] WebSocket endpoint URL is missing from response. " \
|
|
165
|
+
"Please verify your Feishu App ID and App Secret are correct.")
|
|
166
|
+
raise "Failed to get WebSocket endpoint: URL is missing (check your Feishu App ID / App Secret)"
|
|
167
|
+
end
|
|
168
|
+
|
|
163
169
|
if url =~ /service_id=(\d+)/
|
|
164
170
|
@service_id = $1.to_i
|
|
165
171
|
end
|