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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/clacky/agent/llm_caller.rb +3 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +6 -5
  5. data/lib/clacky/agent/session_serializer.rb +4 -0
  6. data/lib/clacky/agent.rb +9 -0
  7. data/lib/clacky/agent_config.rb +111 -8
  8. data/lib/clacky/brand_config.rb +1 -0
  9. data/lib/clacky/cli.rb +49 -22
  10. data/lib/clacky/client.rb +6 -2
  11. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/idle_compression_timer.rb +38 -15
  14. data/lib/clacky/media/base.rb +68 -0
  15. data/lib/clacky/media/gemini.rb +36 -0
  16. data/lib/clacky/media/generator.rb +78 -0
  17. data/lib/clacky/media/openai_compat.rb +168 -0
  18. data/lib/clacky/providers.rb +89 -2
  19. data/lib/clacky/rich_ui_controller.rb +1549 -0
  20. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
  21. data/lib/clacky/server/channel/channel_manager.rb +89 -2
  22. data/lib/clacky/server/http_server.rb +334 -29
  23. data/lib/clacky/session_manager.rb +9 -8
  24. data/lib/clacky/telemetry.rb +26 -6
  25. data/lib/clacky/ui2/layout_manager.rb +11 -7
  26. data/lib/clacky/ui2/ui_controller.rb +2 -2
  27. data/lib/clacky/ui_interface.rb +1 -1
  28. data/lib/clacky/utils/model_pricing.rb +75 -53
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +393 -14
  31. data/lib/clacky/web/billing.js +1 -1
  32. data/lib/clacky/web/i18n.js +86 -4
  33. data/lib/clacky/web/index.html +23 -3
  34. data/lib/clacky/web/model-tester.js +58 -0
  35. data/lib/clacky/web/onboard.js +17 -30
  36. data/lib/clacky/web/sessions.js +443 -2
  37. data/lib/clacky/web/settings.js +372 -97
  38. data/lib/clacky/web/workspace.js +9 -1
  39. data/lib/clacky.rb +3 -0
  40. data/scripts/build/lib/network.sh +61 -30
  41. data/scripts/install.ps1 +16 -4
  42. data/scripts/install.sh +61 -30
  43. data/scripts/install_browser.sh +61 -30
  44. data/scripts/install_full.sh +61 -30
  45. data/scripts/install_rails_deps.sh +61 -30
  46. data/scripts/install_system_deps.sh +61 -30
  47. metadata +12 -3
  48. 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: name || File.basename(file_path),
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
- session_id = auto_create_session(adapter, event) unless session_id
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 (e.g. feishu_setup.rb) to reuse the server's
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
- rel = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
2490
- rel = rel.sub(%r{\A/+}, "").strip
2491
- target = File.expand_path(File.join(root, rel))
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
- # Reject traversal outside the working directory.
2494
- unless target == root || target.start_with?("#{root}/")
2495
- return json_response(res, 403, { error: "Path outside working directory" })
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: rel.empty? ? name : "#{rel}/#{name}",
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: root, path: rel, entries: items })
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 (like lite) from UI display
3289
- models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
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
- begin
3516
- model = body["model"].to_s
3517
- test_client = Clacky::Client.new(
3518
- api_key,
3519
- base_url: body["base_url"].to_s,
3520
- model: model,
3521
- anthropic_format: body["anthropic_format"] || false
3522
- )
3523
- result = test_client.test_connection(model: model)
3524
- if result[:success]
3525
- json_response(res, 200, { ok: true, message: "Connected successfully" })
3526
- else
3527
- json_response(res, 200, { ok: false, message: result[:error].to_s })
3528
- end
3529
- rescue => e
3530
- json_response(res, 200, { ok: false, message: e.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