openclacky 1.2.8 → 1.2.10
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 +35 -0
- data/lib/clacky/agent/llm_caller.rb +3 -0
- data/lib/clacky/agent/message_compressor_helper.rb +6 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +9 -0
- data/lib/clacky/agent_config.rb +111 -8
- data/lib/clacky/brand_config.rb +1 -0
- data/lib/clacky/cli.rb +49 -22
- data/lib/clacky/client.rb +6 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/idle_compression_timer.rb +38 -15
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/providers.rb +89 -2
- data/lib/clacky/rich_ui_controller.rb +1549 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
- data/lib/clacky/server/channel/channel_manager.rb +89 -2
- data/lib/clacky/server/http_server.rb +334 -29
- data/lib/clacky/session_manager.rb +9 -8
- data/lib/clacky/telemetry.rb +26 -6
- data/lib/clacky/ui2/layout_manager.rb +11 -7
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +75 -53
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +393 -14
- data/lib/clacky/web/billing.js +1 -1
- data/lib/clacky/web/i18n.js +86 -4
- data/lib/clacky/web/index.html +23 -3
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/sessions.js +443 -2
- data/lib/clacky/web/settings.js +372 -97
- data/lib/clacky/web/workspace.js +9 -1
- data/lib/clacky.rb +3 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.ps1 +16 -4
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +12 -3
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
|
@@ -300,12 +300,31 @@ module Clacky
|
|
|
300
300
|
return { message_id: nil }
|
|
301
301
|
end
|
|
302
302
|
|
|
303
|
+
text = sanitize_for_weixin(text)
|
|
303
304
|
return { message_id: nil } if text.strip.empty?
|
|
304
305
|
|
|
305
306
|
@send_queue.enqueue(chat_id, text, ctoken)
|
|
306
307
|
{ message_id: nil }
|
|
307
308
|
end
|
|
308
309
|
|
|
310
|
+
private def sanitize_for_weixin(text)
|
|
311
|
+
s = text.to_s
|
|
312
|
+
s = s.gsub(/^[ \t]{0,3}>[ \t]?/, "")
|
|
313
|
+
s = s.gsub(/\*\*([^\n*][^*]*?)\*\*/m) { Regexp.last_match(1) }
|
|
314
|
+
s = s.gsub(/`([^`\n]+)`/) { Regexp.last_match(1) }
|
|
315
|
+
s = s.gsub(/^[ \t]{0,3}\#{1,6}[ \t]+/, "")
|
|
316
|
+
s
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private def sanitize_file_name_for_weixin(name)
|
|
320
|
+
ext = File.extname(name.to_s)
|
|
321
|
+
stem = File.basename(name.to_s, ext)
|
|
322
|
+
stem = stem.tr("&<>\"'\/", "_")
|
|
323
|
+
stem = stem.gsub(/[\x00-\x1f]/, "_").strip
|
|
324
|
+
stem = "file" if stem.empty?
|
|
325
|
+
"#{stem}#{ext}"
|
|
326
|
+
end
|
|
327
|
+
|
|
309
328
|
# Force-flush pending text for a chat_id. Called before sending files or on task completion.
|
|
310
329
|
def flush_pending(chat_id)
|
|
311
330
|
@send_queue.flush(chat_id)
|
|
@@ -321,17 +340,20 @@ module Clacky
|
|
|
321
340
|
return { message_id: nil }
|
|
322
341
|
end
|
|
323
342
|
|
|
343
|
+
display_name = name || File.basename(file_path)
|
|
344
|
+
safe_name = sanitize_file_name_for_weixin(display_name)
|
|
345
|
+
|
|
324
346
|
@send_queue.flush(chat_id)
|
|
325
347
|
|
|
326
348
|
@api_client.send_file(
|
|
327
349
|
to_user_id: chat_id,
|
|
328
350
|
file_path: file_path,
|
|
329
|
-
file_name:
|
|
351
|
+
file_name: safe_name,
|
|
330
352
|
context_token: ctoken
|
|
331
353
|
)
|
|
332
354
|
{ message_id: nil }
|
|
333
355
|
rescue => e
|
|
334
|
-
Clacky::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.message}")
|
|
356
|
+
Clacky::Logger.error("[WeixinAdapter] send_file failed for #{chat_id}: #{e.class}: #{e.message}")
|
|
335
357
|
{ message_id: nil }
|
|
336
358
|
end
|
|
337
359
|
|
|
@@ -58,6 +58,9 @@ module Clacky
|
|
|
58
58
|
|
|
59
59
|
Clacky::Logger.info("[ChannelManager] Starting channels: #{enabled_platforms.join(", ")}")
|
|
60
60
|
@running = true
|
|
61
|
+
|
|
62
|
+
restore_channel_bindings
|
|
63
|
+
|
|
61
64
|
enabled_platforms.each { |platform| start_adapter(platform) }
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -240,7 +243,11 @@ module Clacky
|
|
|
240
243
|
end
|
|
241
244
|
|
|
242
245
|
session_id = resolve_session(event)
|
|
243
|
-
|
|
246
|
+
if session_id
|
|
247
|
+
bind_key_to_session(channel_key(event), session_id)
|
|
248
|
+
else
|
|
249
|
+
session_id = auto_create_session(adapter, event)
|
|
250
|
+
end
|
|
244
251
|
|
|
245
252
|
session = @registry.get(session_id)
|
|
246
253
|
unless session
|
|
@@ -263,8 +270,13 @@ module Clacky
|
|
|
263
270
|
agent = session[:agent]
|
|
264
271
|
web_ui = session[:ui]
|
|
265
272
|
|
|
273
|
+
# Set channel info on the agent so session context includes platform/sender.
|
|
274
|
+
agent.channel_info = extract_channel_info(event) if agent.respond_to?(:channel_info=)
|
|
275
|
+
|
|
276
|
+
# Re-attach channel UI if it was dropped (session was evicted from memory and rebuilt by ensure).
|
|
277
|
+
ensure_channel_ui_subscribed(session_id, event)
|
|
278
|
+
|
|
266
279
|
# Update reply context so responses thread under the current message.
|
|
267
|
-
# channel_ui is bound to the session for its full lifetime (created in auto_create_session).
|
|
268
280
|
channel_ui_for_session(session_id)&.update_message_context(event)
|
|
269
281
|
|
|
270
282
|
# Sync the inbound message to WebUI so it shows up in the browser session.
|
|
@@ -499,6 +511,16 @@ module Clacky
|
|
|
499
511
|
found = nil
|
|
500
512
|
@registry.with_session(summary[:id]) { |s| found = s[:channel_keys]&.include?(key) }
|
|
501
513
|
return summary[:id] if found
|
|
514
|
+
|
|
515
|
+
# Check evicted channel sessions via persisted channel_info
|
|
516
|
+
next unless summary[:source] == "channel"
|
|
517
|
+
next unless @registry.ensure(summary[:id])
|
|
518
|
+
agent = nil
|
|
519
|
+
@registry.with_session(summary[:id]) { |s| agent = s[:agent] }
|
|
520
|
+
next unless agent&.channel_info
|
|
521
|
+
next unless channel_key_from_info(agent.channel_info) == key
|
|
522
|
+
bind_key_to_session(key, summary[:id])
|
|
523
|
+
return summary[:id]
|
|
502
524
|
end
|
|
503
525
|
nil
|
|
504
526
|
rescue StandardError => e
|
|
@@ -534,6 +556,24 @@ module Clacky
|
|
|
534
556
|
result
|
|
535
557
|
end
|
|
536
558
|
|
|
559
|
+
# Make sure session has a ChannelUIController subscribed to its WebUIController.
|
|
560
|
+
# Needed both at startup (for restored sessions) and after a session is evicted
|
|
561
|
+
# from memory and rebuilt by SessionRegistry#ensure (which drops :ui/:channel_ui).
|
|
562
|
+
def ensure_channel_ui_subscribed(session_id, event)
|
|
563
|
+
needs_attach = false
|
|
564
|
+
@registry.with_session(session_id) do |s|
|
|
565
|
+
needs_attach = s[:ui] && s[:channel_ui].nil?
|
|
566
|
+
end
|
|
567
|
+
return unless needs_attach
|
|
568
|
+
|
|
569
|
+
channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
|
|
570
|
+
@registry.with_session(session_id) do |s|
|
|
571
|
+
next unless s[:ui] && s[:channel_ui].nil?
|
|
572
|
+
s[:ui].subscribe_channel(channel_ui)
|
|
573
|
+
s[:channel_ui] = channel_ui
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
537
577
|
def web_ui_for_session_diag(session_id)
|
|
538
578
|
result = nil
|
|
539
579
|
@registry.with_session(session_id) do |s|
|
|
@@ -581,6 +621,27 @@ module Clacky
|
|
|
581
621
|
end
|
|
582
622
|
end
|
|
583
623
|
|
|
624
|
+
def channel_key_from_info(channel_info)
|
|
625
|
+
platform = channel_info[:platform].to_s
|
|
626
|
+
chat_id = channel_info[:chat_id].to_s
|
|
627
|
+
user_id = channel_info[:user_id].to_s
|
|
628
|
+
case @binding_mode
|
|
629
|
+
when :chat then "#{platform}:chat:#{chat_id}"
|
|
630
|
+
when :user then "#{platform}:user:#{user_id}"
|
|
631
|
+
else # :chat_user (default)
|
|
632
|
+
"#{platform}:chat:#{chat_id}:user:#{user_id}"
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
private def extract_channel_info(event)
|
|
637
|
+
{
|
|
638
|
+
platform: event[:platform],
|
|
639
|
+
user_id: event[:user_id],
|
|
640
|
+
user_name: event[:user_name],
|
|
641
|
+
chat_id: event[:chat_id]
|
|
642
|
+
}
|
|
643
|
+
end
|
|
644
|
+
|
|
584
645
|
# Extract the chat_id from the remainder of a channel_key (after removing "platform:" prefix).
|
|
585
646
|
#
|
|
586
647
|
# Possible formats:
|
|
@@ -604,6 +665,32 @@ module Clacky
|
|
|
604
665
|
end
|
|
605
666
|
end
|
|
606
667
|
|
|
668
|
+
def restore_channel_bindings
|
|
669
|
+
bound_keys = Set.new
|
|
670
|
+
restored_count = 0
|
|
671
|
+
@registry.list(limit: nil).each do |summary|
|
|
672
|
+
@registry.ensure(summary[:id])
|
|
673
|
+
agent = nil
|
|
674
|
+
@registry.with_session(summary[:id]) { |s| agent = s[:agent] }
|
|
675
|
+
next unless agent&.channel_info
|
|
676
|
+
|
|
677
|
+
info = agent.channel_info
|
|
678
|
+
next unless info[:platform] && info[:user_id] && info[:chat_id]
|
|
679
|
+
|
|
680
|
+
key = channel_key_from_info(info)
|
|
681
|
+
|
|
682
|
+
event = { platform: info[:platform], chat_id: info[:chat_id] }
|
|
683
|
+
ensure_channel_ui_subscribed(summary[:id], event)
|
|
684
|
+
|
|
685
|
+
next unless bound_keys.add?(key)
|
|
686
|
+
bind_key_to_session(key, summary[:id])
|
|
687
|
+
|
|
688
|
+
Clacky::Logger.info("[ChannelManager] Restored channel binding #{key} -> session #{summary[:id][0, 8]}")
|
|
689
|
+
restored_count += 1
|
|
690
|
+
end
|
|
691
|
+
Clacky::Logger.info("[ChannelManager] Restored #{restored_count} channel binding(s)") if restored_count > 0
|
|
692
|
+
end
|
|
693
|
+
|
|
607
694
|
def safe_stop_adapter(adapter)
|
|
608
695
|
adapter.stop
|
|
609
696
|
rescue StandardError => e
|
|
@@ -4,6 +4,7 @@ require "webrick"
|
|
|
4
4
|
require "websocket"
|
|
5
5
|
require "socket"
|
|
6
6
|
require "json"
|
|
7
|
+
require "net/http"
|
|
7
8
|
require "thread"
|
|
8
9
|
require "fileutils"
|
|
9
10
|
require "tmpdir"
|
|
@@ -107,6 +108,8 @@ module Clacky
|
|
|
107
108
|
# GET /** → static files served from lib/clacky/web/ directory
|
|
108
109
|
class HttpServer
|
|
109
110
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
111
|
+
EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
|
|
112
|
+
EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
|
|
110
113
|
|
|
111
114
|
# Default SOUL.md written when the user skips the onboard conversation.
|
|
112
115
|
# A richer version is created by the Agent during the soul_setup phase.
|
|
@@ -368,8 +371,15 @@ module Clacky
|
|
|
368
371
|
90
|
|
369
372
|
elsif path == "/api/tool/browser"
|
|
370
373
|
30
|
|
374
|
+
elsif path == "/api/exchange-rate"
|
|
375
|
+
20
|
|
371
376
|
elsif path.end_with?("/benchmark")
|
|
372
377
|
20
|
|
378
|
+
elsif path == "/api/media/image"
|
|
379
|
+
# Image generation routes through OpenRouter (chat completions
|
|
380
|
+
# with modalities:["image"]); end-to-end latency is commonly
|
|
381
|
+
# 20-60s and can exceed 2 minutes for or-gpt-image-2 under load.
|
|
382
|
+
300
|
|
373
383
|
else
|
|
374
384
|
10
|
|
375
385
|
end
|
|
@@ -396,9 +406,11 @@ module Clacky
|
|
|
396
406
|
when ["GET", "/api/skills"] then api_list_skills(res)
|
|
397
407
|
when ["GET", "/api/config"] then api_get_config(res)
|
|
398
408
|
when ["GET", "/api/config/settings"] then api_get_settings(res)
|
|
409
|
+
when ["GET", "/api/exchange-rate"] then api_exchange_rate(req, res)
|
|
399
410
|
when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
|
|
400
411
|
when ["POST", "/api/config/models"] then api_add_model(req, res)
|
|
401
412
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
413
|
+
when ["GET", "/api/config/media"] then api_get_media_config(res)
|
|
402
414
|
when ["GET", "/api/providers"] then api_list_providers(res)
|
|
403
415
|
when ["GET", "/api/onboard/status"] then api_onboard_status(res)
|
|
404
416
|
when ["GET", "/api/browser/status"] then api_browser_status(res)
|
|
@@ -430,6 +442,8 @@ module Clacky
|
|
|
430
442
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
431
443
|
when ["POST", "/api/file-action"] then api_file_action(req, res)
|
|
432
444
|
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
445
|
+
when ["POST", "/api/media/image"] then api_media_image(req, res)
|
|
446
|
+
when ["GET", "/api/media/types"] then api_media_types(res)
|
|
433
447
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
434
448
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
435
449
|
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
@@ -523,6 +537,9 @@ module Clacky
|
|
|
523
537
|
elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
|
|
524
538
|
id = path.sub("/api/config/models/", "")
|
|
525
539
|
api_delete_model(id, res)
|
|
540
|
+
elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
|
|
541
|
+
kind = path.sub("/api/config/media/", "")
|
|
542
|
+
api_update_media_config(kind, req, res)
|
|
526
543
|
elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
|
|
527
544
|
name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
|
|
528
545
|
api_run_cron_task(name, res)
|
|
@@ -645,6 +662,103 @@ module Clacky
|
|
|
645
662
|
end
|
|
646
663
|
end
|
|
647
664
|
|
|
665
|
+
# GET /api/exchange-rate?from=USD&to=CNY
|
|
666
|
+
# Fetches the latest exchange rate on demand. The browser still owns the
|
|
667
|
+
# saved preference in localStorage; this API is only a lightweight proxy
|
|
668
|
+
# that avoids CORS issues and normalizes provider responses.
|
|
669
|
+
def api_exchange_rate(req, res)
|
|
670
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
671
|
+
from = normalize_currency_code(query["from"], fallback: "USD")
|
|
672
|
+
to = normalize_currency_code(query["to"], fallback: "CNY")
|
|
673
|
+
|
|
674
|
+
unless from && to
|
|
675
|
+
return json_response(res, 400, { error: "from and to must be 3-letter currency codes" })
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
data = fetch_exchange_rate(from, to)
|
|
679
|
+
json_response(res, 200, data)
|
|
680
|
+
rescue StandardError => e
|
|
681
|
+
Clacky::Logger.warn("[ExchangeRate] failed: #{e.class}: #{e.message}")
|
|
682
|
+
json_response(res, 502, { error: "Failed to fetch exchange rate" })
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def normalize_currency_code(value, fallback:)
|
|
686
|
+
code = value.to_s.strip.upcase
|
|
687
|
+
code = fallback if code.empty?
|
|
688
|
+
code.match?(/\A[A-Z]{3}\z/) ? code : nil
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def fetch_exchange_rate(from, to)
|
|
692
|
+
fetch_open_exchange_rate(from, to)
|
|
693
|
+
rescue StandardError => primary_error
|
|
694
|
+
Clacky::Logger.warn("[ExchangeRate] primary source failed: #{primary_error.message}")
|
|
695
|
+
fetch_frankfurter_exchange_rate(from, to)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def fetch_open_exchange_rate(from, to)
|
|
699
|
+
data = fetch_exchange_rate_json("#{EXCHANGE_RATE_PRIMARY_BASE_URL}/#{URI.encode_www_form_component(from)}")
|
|
700
|
+
raise "open.er-api.com returned #{data["result"] || "unknown"}" unless data["result"] == "success"
|
|
701
|
+
|
|
702
|
+
rate = positive_float(data.dig("rates", to))
|
|
703
|
+
raise "open.er-api.com missing #{to} rate" unless rate
|
|
704
|
+
|
|
705
|
+
updated_at = data["time_last_update_utc"].to_s
|
|
706
|
+
{
|
|
707
|
+
from: from,
|
|
708
|
+
to: to,
|
|
709
|
+
rate: rate,
|
|
710
|
+
date: parse_exchange_rate_date(updated_at),
|
|
711
|
+
updated_at: updated_at,
|
|
712
|
+
source: "open.er-api.com"
|
|
713
|
+
}
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def fetch_frankfurter_exchange_rate(from, to)
|
|
717
|
+
query = URI.encode_www_form("from" => from, "to" => to)
|
|
718
|
+
data = fetch_exchange_rate_json("#{EXCHANGE_RATE_FALLBACK_URL}?#{query}")
|
|
719
|
+
|
|
720
|
+
rate = positive_float(data.dig("rates", to))
|
|
721
|
+
raise "frankfurter.app missing #{to} rate" unless rate
|
|
722
|
+
|
|
723
|
+
{
|
|
724
|
+
from: from,
|
|
725
|
+
to: to,
|
|
726
|
+
rate: rate,
|
|
727
|
+
date: data["date"].to_s,
|
|
728
|
+
updated_at: data["date"].to_s,
|
|
729
|
+
source: "frankfurter.app"
|
|
730
|
+
}
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def fetch_exchange_rate_json(url)
|
|
734
|
+
uri = URI(url)
|
|
735
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
736
|
+
http.use_ssl = uri.scheme == "https"
|
|
737
|
+
http.open_timeout = 5
|
|
738
|
+
http.read_timeout = 8
|
|
739
|
+
|
|
740
|
+
req = Net::HTTP::Get.new(uri.request_uri, "Accept" => "application/json")
|
|
741
|
+
response = http.request(req)
|
|
742
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
743
|
+
|
|
744
|
+
JSON.parse(response.body.to_s)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def positive_float(value)
|
|
748
|
+
rate = Float(value)
|
|
749
|
+
rate.positive? ? rate : nil
|
|
750
|
+
rescue ArgumentError, TypeError
|
|
751
|
+
nil
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def parse_exchange_rate_date(value)
|
|
755
|
+
return "" if value.to_s.strip.empty?
|
|
756
|
+
|
|
757
|
+
Date.parse(value.to_s).iso8601
|
|
758
|
+
rescue ArgumentError
|
|
759
|
+
""
|
|
760
|
+
end
|
|
761
|
+
|
|
648
762
|
# ── Onboard API ───────────────────────────────────────────────────────────
|
|
649
763
|
|
|
650
764
|
# GET /api/onboard/status
|
|
@@ -697,6 +811,158 @@ module Clacky
|
|
|
697
811
|
json_response(res, 500, { ok: false, error: e.message })
|
|
698
812
|
end
|
|
699
813
|
|
|
814
|
+
# POST /api/media/image
|
|
815
|
+
# Body: { "prompt": "...", "aspect_ratio": "landscape|square|portrait",
|
|
816
|
+
# "output_dir": "<absolute path, optional>" }
|
|
817
|
+
# Routes to the model configured with type=image in agent_config.
|
|
818
|
+
def api_media_image(req, res)
|
|
819
|
+
body = parse_json_body(req)
|
|
820
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
821
|
+
|
|
822
|
+
prompt = body["prompt"].to_s
|
|
823
|
+
if prompt.strip.empty?
|
|
824
|
+
return json_response(res, 422, { error: "prompt is required" })
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
aspect_ratio = body["aspect_ratio"].to_s
|
|
828
|
+
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
829
|
+
output_dir = body["output_dir"].to_s
|
|
830
|
+
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
831
|
+
|
|
832
|
+
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
833
|
+
prompt: prompt,
|
|
834
|
+
aspect_ratio: aspect_ratio,
|
|
835
|
+
output_dir: output_dir
|
|
836
|
+
)
|
|
837
|
+
if result["success"]
|
|
838
|
+
log_media_usage(result, prompt: prompt)
|
|
839
|
+
end
|
|
840
|
+
status = result["success"] ? 200 : 422
|
|
841
|
+
json_response(res, status, result)
|
|
842
|
+
rescue StandardError => e
|
|
843
|
+
json_response(res, 500, { error: e.message })
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
private def log_media_usage(result, prompt:)
|
|
847
|
+
usage = result["usage"]
|
|
848
|
+
cost = result["cost_usd"]
|
|
849
|
+
return if usage.nil? && cost.nil?
|
|
850
|
+
|
|
851
|
+
parts = []
|
|
852
|
+
parts << "model=#{result["model"]}"
|
|
853
|
+
parts << "provider=#{result["provider"]}"
|
|
854
|
+
if usage.is_a?(Hash)
|
|
855
|
+
parts << "prompt_tokens=#{usage["prompt_tokens"]}"
|
|
856
|
+
parts << "completion_tokens=#{usage["completion_tokens"]}"
|
|
857
|
+
parts << "cache_read=#{usage["cache_read_tokens"]}" if usage["cache_read_tokens"].to_i > 0
|
|
858
|
+
parts << "cache_write=#{usage["cache_write_tokens"]}" if usage["cache_write_tokens"].to_i > 0
|
|
859
|
+
end
|
|
860
|
+
parts << format("cost_usd=%.6f", cost.to_f) if cost
|
|
861
|
+
parts << "prompt=#{prompt[0, 60].inspect}"
|
|
862
|
+
Clacky::Logger.info("[Media] image generated #{parts.join(" ")}")
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# GET /api/media/types
|
|
866
|
+
# Returns which media types are configured in agent_config.models.
|
|
867
|
+
# Used by the media-gen skill to decide whether to surface generation
|
|
868
|
+
# capabilities to the user.
|
|
869
|
+
def api_media_types(res)
|
|
870
|
+
out = {}
|
|
871
|
+
Clacky::Providers::MEDIA_KINDS.each do |t|
|
|
872
|
+
state = @agent_config.media_state(t)
|
|
873
|
+
out[t] =
|
|
874
|
+
if state["configured"]
|
|
875
|
+
{
|
|
876
|
+
configured: true,
|
|
877
|
+
model: state["model"],
|
|
878
|
+
base_url: state["base_url"],
|
|
879
|
+
source: state["source"]
|
|
880
|
+
}
|
|
881
|
+
else
|
|
882
|
+
{ configured: false, source: "off" }
|
|
883
|
+
end
|
|
884
|
+
end
|
|
885
|
+
json_response(res, 200, out)
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# GET /api/config/media
|
|
889
|
+
# Used by the Settings UI to render the tri-state media controls.
|
|
890
|
+
# Per-kind payload mirrors AgentConfig#media_state.
|
|
891
|
+
def api_get_media_config(res)
|
|
892
|
+
out = {}
|
|
893
|
+
Clacky::Providers::MEDIA_KINDS.each do |t|
|
|
894
|
+
state = @agent_config.media_state(t)
|
|
895
|
+
entry = @agent_config.find_model_by_type(t)
|
|
896
|
+
out[t] = {
|
|
897
|
+
source: state["source"],
|
|
898
|
+
model: state["model"],
|
|
899
|
+
base_url: state["base_url"],
|
|
900
|
+
api_key_masked: entry ? mask_api_key(entry["api_key"]) : nil,
|
|
901
|
+
provider: state["provider"],
|
|
902
|
+
available: state["available"],
|
|
903
|
+
configured: state["configured"]
|
|
904
|
+
}
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# Surface what the current default model can offer, even when the
|
|
908
|
+
# user is currently in "off" — the UI uses this to render the
|
|
909
|
+
# auto-mode preview ("Auto would use X").
|
|
910
|
+
default = @agent_config.find_model_by_type("default")
|
|
911
|
+
provider_id = default && Clacky::Providers.resolve_provider(
|
|
912
|
+
base_url: default["base_url"],
|
|
913
|
+
api_key: default["api_key"]
|
|
914
|
+
)
|
|
915
|
+
defaults = {}
|
|
916
|
+
Clacky::Providers::MEDIA_KINDS.each do |t|
|
|
917
|
+
defaults[t] = {
|
|
918
|
+
provider: provider_id,
|
|
919
|
+
model: provider_id ? Clacky::Providers.default_media_model(provider_id, t) : nil,
|
|
920
|
+
available: provider_id ? Clacky::Providers.media_models(provider_id, t) : []
|
|
921
|
+
}
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
json_response(res, 200, { media: out, default_provider: defaults })
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# PATCH /api/config/media/:kind
|
|
928
|
+
# Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
|
|
929
|
+
# anthropic_format? }
|
|
930
|
+
# off / auto — remove any custom entry; "auto" lets the virtual
|
|
931
|
+
# derivation in AgentConfig#find_model_by_type take over.
|
|
932
|
+
# custom — replace any existing custom entry with the supplied fields.
|
|
933
|
+
def api_update_media_config(kind, req, res)
|
|
934
|
+
body = parse_json_body(req) || {}
|
|
935
|
+
source = body["source"].to_s
|
|
936
|
+
unless %w[off auto custom].include?(source)
|
|
937
|
+
return json_response(res, 422, { error: "invalid source" })
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
@agent_config.models.reject! { |m| m["type"] == kind }
|
|
941
|
+
|
|
942
|
+
if source == "custom"
|
|
943
|
+
model = body["model"].to_s.strip
|
|
944
|
+
base_url = body["base_url"].to_s.strip
|
|
945
|
+
api_key = body["api_key"].to_s
|
|
946
|
+
if model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
|
|
947
|
+
return json_response(res, 422, { error: "model, base_url, api_key are required" })
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
@agent_config.models << {
|
|
951
|
+
"id" => SecureRandom.uuid,
|
|
952
|
+
"model" => model,
|
|
953
|
+
"base_url" => base_url,
|
|
954
|
+
"api_key" => api_key,
|
|
955
|
+
"anthropic_format" => body["anthropic_format"] || false,
|
|
956
|
+
"type" => kind
|
|
957
|
+
}
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
@agent_config.save
|
|
961
|
+
json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
|
|
962
|
+
rescue => e
|
|
963
|
+
json_response(res, 422, { error: e.message })
|
|
964
|
+
end
|
|
965
|
+
|
|
700
966
|
# POST /api/onboard/complete
|
|
701
967
|
# Called after key setup is done (soul_setup is optional/skipped).
|
|
702
968
|
# Creates the default session if none exists yet, returns it.
|
|
@@ -1614,7 +1880,7 @@ module Clacky
|
|
|
1614
1880
|
# Returns current config and running status for all supported platforms.
|
|
1615
1881
|
# POST /api/tool/browser
|
|
1616
1882
|
# Executes a browser tool action via the shared BrowserManager daemon.
|
|
1617
|
-
# Used by skill scripts
|
|
1883
|
+
# Used by skill scripts to reuse the server's
|
|
1618
1884
|
# existing Chrome connection without spawning a second MCP daemon.
|
|
1619
1885
|
#
|
|
1620
1886
|
# Request body: JSON with same params as the browser tool
|
|
@@ -2486,16 +2752,28 @@ module Clacky
|
|
|
2486
2752
|
root = File.expand_path(agent.working_dir.to_s)
|
|
2487
2753
|
return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
|
|
2488
2754
|
|
|
2489
|
-
|
|
2490
|
-
rel =
|
|
2491
|
-
|
|
2755
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
2756
|
+
rel = query["path"].to_s
|
|
2757
|
+
absolute_mode = query["absolute"] == "true"
|
|
2758
|
+
|
|
2759
|
+
# Absolute mode: allow browsing outside working directory (e.g., root "/")
|
|
2760
|
+
if absolute_mode
|
|
2761
|
+
target = File.expand_path(rel.empty? ? "/" : rel)
|
|
2762
|
+
display_root = target
|
|
2763
|
+
# Normalize rel for API response
|
|
2764
|
+
rel = target
|
|
2765
|
+
else
|
|
2766
|
+
rel = rel.sub(%r{\A/+}, "").strip
|
|
2767
|
+
target = File.expand_path(File.join(root, rel))
|
|
2768
|
+
display_root = root
|
|
2492
2769
|
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2770
|
+
# Reject traversal outside the working directory.
|
|
2771
|
+
unless target == root || target.start_with?("#{root}/")
|
|
2772
|
+
return json_response(res, 403, { error: "Path outside working directory" })
|
|
2773
|
+
end
|
|
2496
2774
|
end
|
|
2497
|
-
return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
|
|
2498
2775
|
|
|
2776
|
+
return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
|
|
2499
2777
|
entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
|
|
2500
2778
|
|
|
2501
2779
|
items = entries.filter_map do |name|
|
|
@@ -2505,7 +2783,7 @@ module Clacky
|
|
|
2505
2783
|
next unless File.exist?(full)
|
|
2506
2784
|
{
|
|
2507
2785
|
name: name,
|
|
2508
|
-
path:
|
|
2786
|
+
path: "#{rel}/#{name}".gsub(%r{/+}, "/"),
|
|
2509
2787
|
type: is_dir ? "dir" : "file",
|
|
2510
2788
|
size: is_dir ? nil : (File.size(full) rescue nil)
|
|
2511
2789
|
}
|
|
@@ -2516,7 +2794,7 @@ module Clacky
|
|
|
2516
2794
|
# Directories first, then files; both case-insensitive alphabetical.
|
|
2517
2795
|
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
2518
2796
|
|
|
2519
|
-
json_response(res, 200, { root:
|
|
2797
|
+
json_response(res, 200, { root: display_root, path: rel, entries: items })
|
|
2520
2798
|
rescue StandardError => e
|
|
2521
2799
|
json_response(res, 500, { error: e.message })
|
|
2522
2800
|
end
|
|
@@ -3285,8 +3563,13 @@ module Clacky
|
|
|
3285
3563
|
type: m["type"]
|
|
3286
3564
|
}
|
|
3287
3565
|
end
|
|
3288
|
-
# Filter out auto-injected models (
|
|
3289
|
-
|
|
3566
|
+
# Filter out auto-injected models (lite, derived media) AND media
|
|
3567
|
+
# entries (image/video/audio) — those are managed via the dedicated
|
|
3568
|
+
# media-config UI, not the chat-model card list.
|
|
3569
|
+
models.reject! do |m|
|
|
3570
|
+
raw = @agent_config.models[m[:index]]
|
|
3571
|
+
raw["auto_injected"] || Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s)
|
|
3572
|
+
end
|
|
3290
3573
|
json_response(res, 200, {
|
|
3291
3574
|
models: models,
|
|
3292
3575
|
current_index: @agent_config.current_model_index,
|
|
@@ -3506,29 +3789,51 @@ module Clacky
|
|
|
3506
3789
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
3507
3790
|
|
|
3508
3791
|
api_key = body["api_key"].to_s
|
|
3509
|
-
# If masked, use the stored key from the matching model (by index or current)
|
|
3510
3792
|
if api_key.include?("****")
|
|
3511
3793
|
idx = body["index"]&.to_i || @agent_config.current_model_index
|
|
3512
3794
|
api_key = @agent_config.models.dig(idx, "api_key").to_s
|
|
3513
3795
|
end
|
|
3514
3796
|
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
json_response(res, 200, { ok: false, message:
|
|
3797
|
+
model = body["model"].to_s
|
|
3798
|
+
base_url = body["base_url"].to_s
|
|
3799
|
+
anthropic_format = body["anthropic_format"] || false
|
|
3800
|
+
|
|
3801
|
+
result, used_base_url = try_test_with_base_url(api_key, base_url, model, anthropic_format)
|
|
3802
|
+
|
|
3803
|
+
if result[:success] && used_base_url != base_url
|
|
3804
|
+
json_response(res, 200, {
|
|
3805
|
+
ok: true,
|
|
3806
|
+
message: "Connected (auto-corrected base_url to add /v1)",
|
|
3807
|
+
effective_base_url: used_base_url
|
|
3808
|
+
})
|
|
3809
|
+
elsif result[:success]
|
|
3810
|
+
json_response(res, 200, { ok: true, message: "Connected successfully" })
|
|
3811
|
+
else
|
|
3812
|
+
json_response(res, 200, { ok: false, message: result[:error].to_s })
|
|
3531
3813
|
end
|
|
3814
|
+
rescue => e
|
|
3815
|
+
json_response(res, 200, { ok: false, message: e.message })
|
|
3816
|
+
end
|
|
3817
|
+
|
|
3818
|
+
private def try_test_with_base_url(api_key, base_url, model, anthropic_format)
|
|
3819
|
+
result = run_test_connection(api_key, base_url, model, anthropic_format)
|
|
3820
|
+
return [result, base_url] if result[:success]
|
|
3821
|
+
return [result, base_url] unless result[:status] == 404
|
|
3822
|
+
return [result, base_url] if base_url.match?(%r{/v\d+/?\z})
|
|
3823
|
+
|
|
3824
|
+
candidate = "#{base_url.chomp("/")}/v1"
|
|
3825
|
+
retried = run_test_connection(api_key, candidate, model, anthropic_format)
|
|
3826
|
+
retried[:success] ? [retried, candidate] : [result, base_url]
|
|
3827
|
+
end
|
|
3828
|
+
|
|
3829
|
+
private def run_test_connection(api_key, base_url, model, anthropic_format)
|
|
3830
|
+
client = Clacky::Client.new(
|
|
3831
|
+
api_key,
|
|
3832
|
+
base_url: base_url,
|
|
3833
|
+
model: model,
|
|
3834
|
+
anthropic_format: anthropic_format
|
|
3835
|
+
)
|
|
3836
|
+
client.test_connection(model: model)
|
|
3532
3837
|
end
|
|
3533
3838
|
|
|
3534
3839
|
# GET /api/providers — return built-in provider presets for quick setup
|