openclacky 1.3.3 → 1.3.4

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/llm_caller.rb +10 -4
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent.rb +3 -2
  8. data/lib/clacky/agent_config.rb +2 -14
  9. data/lib/clacky/api_extension.rb +262 -0
  10. data/lib/clacky/api_extension_loader.rb +156 -0
  11. data/lib/clacky/cli.rb +93 -3
  12. data/lib/clacky/client.rb +38 -13
  13. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  14. data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
  15. data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
  16. data/lib/clacky/idle_compression_timer.rb +3 -1
  17. data/lib/clacky/locales/en.rb +26 -0
  18. data/lib/clacky/locales/i18n.rb +26 -0
  19. data/lib/clacky/locales/zh.rb +26 -0
  20. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  21. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  22. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  23. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  24. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  25. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  26. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  27. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  28. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  29. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  30. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  31. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  32. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  33. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  34. data/lib/clacky/rich_ui.rb +57 -0
  35. data/lib/clacky/rich_ui_controller.rb +3 -1549
  36. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  37. data/lib/clacky/server/http_server.rb +150 -103
  38. data/lib/clacky/server/session_registry.rb +1 -1
  39. data/lib/clacky/shell_hook_loader.rb +1 -1
  40. data/lib/clacky/tools/edit.rb +14 -2
  41. data/lib/clacky/ui2/ui_controller.rb +7 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky/web/app.css +56 -59
  44. data/lib/clacky/web/app.js +65 -7
  45. data/lib/clacky/web/components/onboard.js +18 -2
  46. data/lib/clacky/web/core/aside.js +8 -3
  47. data/lib/clacky/web/core/ext.js +1 -1
  48. data/lib/clacky/web/features/skills/store.js +30 -2
  49. data/lib/clacky/web/features/skills/view.js +32 -1
  50. data/lib/clacky/web/features/workspace/view.js +1 -1
  51. data/lib/clacky/web/i18n.js +32 -20
  52. data/lib/clacky/web/index.html +9 -17
  53. data/lib/clacky/web/sessions.js +286 -28
  54. data/lib/clacky/web/settings.js +109 -111
  55. data/lib/clacky/web/ws-dispatcher.js +7 -3
  56. data/lib/clacky.rb +17 -2
  57. metadata +38 -2
  58. data/lib/clacky/media/output_dir.rb +0 -43
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ module Clacky
8
+ module Server
9
+ # Routes /api/ext/<name>/<sub-path> requests to the matching ApiExtension
10
+ # subclass. Wraps each handler invocation with a timeout and a unified
11
+ # JSON error envelope so a misbehaving extension cannot break neighboring
12
+ # extensions or the host process.
13
+ module ApiExtensionDispatcher
14
+ MOUNT_PREFIX = "/api/ext/"
15
+
16
+ class << self
17
+ # Public entry called from HttpServer#_dispatch_rest.
18
+ # Returns true to indicate the request was handled (200/4xx/5xx).
19
+ def handle(req, res, http_server:)
20
+ ext_id, sub_path = parse_path(req.path)
21
+ return not_found(res, "extension id missing") unless ext_id
22
+
23
+ klass = Clacky::ApiExtension.registry[ext_id]
24
+ return not_found(res, "extension '#{ext_id}' not found") unless klass
25
+
26
+ method = req.request_method.to_s.downcase.to_sym
27
+ route, params = find_route(klass, method, sub_path)
28
+ return not_found(res, "no route for #{req.request_method} #{req.path}") unless route
29
+
30
+ # Public-endpoint check is done at HttpServer level (it owns access-key
31
+ # logic); by the time we get here, auth has already been resolved.
32
+
33
+ timeout_sec = route.options[:timeout] || klass.class_timeout || Clacky::ApiExtension::DEFAULT_TIMEOUT
34
+ invoke_route(klass, route, params, req, res, http_server, timeout_sec)
35
+ true
36
+ end
37
+
38
+ # Tells HttpServer whether a given /api/ext/... path can skip access-key
39
+ # auth, so the host can keep its single-source-of-truth auth logic.
40
+ def public_path?(path, method)
41
+ ext_id, sub_path = parse_path(path)
42
+ return false unless ext_id
43
+
44
+ klass = Clacky::ApiExtension.registry[ext_id]
45
+ return false unless klass
46
+ return false if klass.public_paths.empty?
47
+
48
+ route, _params = find_route(klass, method.to_s.downcase.to_sym, sub_path)
49
+ return false unless route
50
+
51
+ klass.public_paths.include?(route.pattern)
52
+ end
53
+
54
+ private def parse_path(path)
55
+ return [nil, nil] unless path.to_s.start_with?(MOUNT_PREFIX)
56
+
57
+ tail = path[MOUNT_PREFIX.length..]
58
+ slash = tail.index("/")
59
+ if slash
60
+ [tail[0...slash], tail[slash..]]
61
+ else
62
+ [tail, "/"]
63
+ end
64
+ end
65
+
66
+ private def find_route(klass, method, sub_path)
67
+ klass.routes.each do |route|
68
+ next unless route.method == method
69
+ next unless (m = route.regex.match(sub_path))
70
+
71
+ params = {}
72
+ route.param_names.each_with_index do |name, i|
73
+ params[name] = URI.decode_www_form_component(m[i + 1].to_s)
74
+ end
75
+ return [route, params]
76
+ end
77
+ [nil, nil]
78
+ end
79
+
80
+ private def invoke_route(klass, route, params, req, res, http_server, timeout_sec)
81
+ instance = klass.new(req: req, res: res, route: route, params: params, http_server: http_server)
82
+ Timeout.timeout(timeout_sec) { instance.invoke }
83
+
84
+ # Handler exited without writing — empty 204
85
+ empty_response(res)
86
+ rescue Clacky::ApiExtension::Halt => halt
87
+ write_response(res, halt.status, halt.payload, halt.content_type)
88
+ rescue Timeout::Error
89
+ Clacky::Logger.warn("[api_ext:#{klass.ext_id}] Timed out after #{timeout_sec}s on #{route.method.upcase} #{route.pattern}")
90
+ write_json(res, 503, error: "request timed out")
91
+ rescue StandardError => e
92
+ Clacky::Logger.warn("[api_ext:#{klass.ext_id}] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
93
+ write_json(res, 500, error: e.message)
94
+ end
95
+
96
+ private def write_response(res, status, body, content_type)
97
+ res.status = status
98
+ res.content_type = content_type
99
+ res["Access-Control-Allow-Origin"] = "*"
100
+ res.body = body
101
+ end
102
+
103
+ private def write_json(res, status, payload)
104
+ write_response(res, status, JSON.generate(payload), "application/json; charset=utf-8")
105
+ end
106
+
107
+ private def empty_response(res)
108
+ res.status = 204
109
+ res["Access-Control-Allow-Origin"] = "*"
110
+ res.body = ""
111
+ end
112
+
113
+ private def not_found(res, message)
114
+ write_json(res, 404, error: message)
115
+ true
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -218,6 +218,7 @@ module Clacky
218
218
  end
219
219
 
220
220
  def start
221
+ @start_time = Time.now
221
222
  # One-time migration: move legacy trash contents into file-trash/ subdirectory.
222
223
  Clacky::TrashDirectory.migrate_legacy_if_needed
223
224
 
@@ -381,6 +382,12 @@ module Clacky
381
382
  # Auto-create a default session on startup
382
383
  create_default_session
383
384
 
385
+ # Load user-defined HTTP API extensions from ~/.clacky/api_ext/.
386
+ # Done here (not at gem load) so handlers can resolve session_manager
387
+ # and other host helpers as soon as they are wired up.
388
+ # The loader logs its own summary via Clacky::Logger.
389
+ Clacky::ApiExtensionLoader.load_all
390
+
384
391
  # Start the background scheduler
385
392
  @scheduler.start
386
393
  puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
@@ -412,6 +419,8 @@ module Clacky
412
419
  path = req.path
413
420
  method = req.request_method
414
421
 
422
+ Thread.current[:lang] = req["X-Lang"].to_s.strip.then { |l| l.empty? ? nil : l }
423
+
415
424
  # Access key guard (skip for WebSocket upgrades)
416
425
  return unless check_access_key(req, res)
417
426
 
@@ -430,6 +439,11 @@ module Clacky
430
439
  # generous 90s so retry + failover can complete without being cut short.
431
440
  timeout_sec = if path.start_with?("/api/brand")
432
441
  90
442
+ elsif path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
443
+ # api_ext dispatcher applies its own per-route timeout (capped at
444
+ # ApiExtension::MAX_TIMEOUT). Use the upper bound here so the outer
445
+ # guard never cuts a long-running custom handler short.
446
+ Clacky::ApiExtension::MAX_TIMEOUT + 30
433
447
  elsif path == "/api/tool/browser"
434
448
  30
435
449
  elsif path == "/api/exchange-rate"
@@ -469,6 +483,13 @@ module Clacky
469
483
  path = req.path
470
484
  method = req.request_method
471
485
 
486
+ # User-defined HTTP API extensions live under ~/.clacky/api_ext/<name>/
487
+ # and mount at /api/ext/<name>/... Routed through a separate dispatcher
488
+ # so the host's giant case table stays focused on built-in endpoints.
489
+ if path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
490
+ return if Clacky::Server::ApiExtensionDispatcher.handle(req, res, http_server: self)
491
+ end
492
+
472
493
  case [method, path]
473
494
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
474
495
  when ["POST", "/api/sessions"] then api_create_session(req, res)
@@ -483,8 +504,6 @@ module Clacky
483
504
  when ["POST", "/api/config/test"] then api_test_config(req, res)
484
505
  when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
485
506
  when ["GET", "/api/config/media"] then api_get_media_config(res)
486
- when ["GET", "/api/config/media-output-dir"] then api_get_media_output_dir(res)
487
- when ["PATCH", "/api/config/media-output-dir"] then api_update_media_output_dir(req, res)
488
507
  when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
489
508
  when ["PATCH", "/api/config/ocr"] then api_update_ocr_config(req, res)
490
509
  when ["POST", "/api/config/ocr/test"] then api_test_ocr_config(req, res)
@@ -608,6 +627,8 @@ module Clacky
608
627
  api_session_time_machine_restore_preview(session_id, task_id, res)
609
628
  elsif method == "GET" && path == "/api/dirs"
610
629
  api_browse_dirs(req, res)
630
+ elsif method == "POST" && path == "/api/dirs/mkdir"
631
+ api_dirs_mkdir(req, res)
611
632
  elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
612
633
  session_id = path.sub("/api/sessions/", "").sub("/files", "")
613
634
  api_session_files(session_id, req, res)
@@ -671,6 +692,12 @@ module Clacky
671
692
  elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
672
693
  name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
673
694
  api_toggle_skill(name, req, res)
695
+ elsif method == "GET" && path.match?(%r{^/api/skills/[^/]+/content$})
696
+ name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/content", ""))
697
+ api_skill_content_get(name, res)
698
+ elsif method == "PUT" && path.match?(%r{^/api/skills/[^/]+/content$})
699
+ name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/content", ""))
700
+ api_skill_content_update(name, req, res)
674
701
  elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
675
702
  name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
676
703
  api_delete_skill(name, res)
@@ -1300,11 +1327,8 @@ module Clacky
1300
1327
 
1301
1328
  aspect_ratio = body["aspect_ratio"].to_s
1302
1329
  aspect_ratio = "landscape" if aspect_ratio.empty?
1303
- output_dir = Clacky::Media::OutputDir.resolve(
1304
- param: body["output_dir"],
1305
- configured: @agent_config.media_output_dir,
1306
- fallback: @agent_config.default_working_dir || Dir.pwd
1307
- )
1330
+ output_dir = body["output_dir"].to_s
1331
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1308
1332
 
1309
1333
  result = Clacky::Media::Generator.new(@agent_config).generate_image(
1310
1334
  prompt: prompt,
@@ -1335,11 +1359,8 @@ module Clacky
1335
1359
  aspect_ratio = "landscape" if aspect_ratio.empty?
1336
1360
  duration = body["duration_seconds"]
1337
1361
  image = body["image"]
1338
- output_dir = Clacky::Media::OutputDir.resolve(
1339
- param: body["output_dir"],
1340
- configured: @agent_config.media_output_dir,
1341
- fallback: @agent_config.default_working_dir || Dir.pwd
1342
- )
1362
+ output_dir = body["output_dir"].to_s
1363
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1343
1364
 
1344
1365
  result = Clacky::Media::Generator.new(@agent_config).generate_video(
1345
1366
  prompt: prompt,
@@ -1367,11 +1388,8 @@ module Clacky
1367
1388
  end
1368
1389
 
1369
1390
  voice = body["voice"]
1370
- output_dir = Clacky::Media::OutputDir.resolve(
1371
- param: body["output_dir"],
1372
- configured: @agent_config.media_output_dir,
1373
- fallback: @agent_config.default_working_dir || Dir.pwd
1374
- )
1391
+ output_dir = body["output_dir"].to_s
1392
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1375
1393
 
1376
1394
  result = Clacky::Media::Generator.new(@agent_config).generate_speech(
1377
1395
  input: input,
@@ -2415,6 +2433,13 @@ module Clacky
2415
2433
  # skills/curl talk to the server without an access key.
2416
2434
  return true if loopback_ip?(ip)
2417
2435
 
2436
+ # Public API extension endpoints (declared via public_endpoint + meta.yml
2437
+ # public:true) are intentionally exposed without auth — used for
2438
+ # third-party webhooks where the extension does its own signature check.
2439
+ if Clacky::Server::ApiExtensionDispatcher.public_path?(req.path, req.request_method)
2440
+ return true
2441
+ end
2442
+
2418
2443
  candidate = extract_key(req)
2419
2444
 
2420
2445
  # Lazily evict expired lockout entries to prevent unbounded memory growth.
@@ -3821,7 +3846,7 @@ module Clacky
3821
3846
  # Directories first, then files; both case-insensitive alphabetical.
3822
3847
  items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
3823
3848
 
3824
- json_response(res, 200, { root: display_root, path: rel, entries: items })
3849
+ json_response(res, 200, { root: display_root, path: rel, home: Dir.home, default: default_working_dir, entries: items })
3825
3850
  rescue StandardError => e
3826
3851
  json_response(res, 500, { error: e.message })
3827
3852
  end
@@ -3859,11 +3884,56 @@ module Clacky
3859
3884
  end
3860
3885
  items.sort_by! { |it| it[:name].downcase }
3861
3886
 
3862
- json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, entries: items })
3887
+ json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, default: default_working_dir, entries: items })
3863
3888
  rescue StandardError => e
3864
3889
  json_response(res, 500, { error: e.message })
3865
3890
  end
3866
3891
 
3892
+ # ── Directory mutation API used by the path picker ─────────────────
3893
+ # Validate a folder name supplied by the picker UI:
3894
+ # non-empty, no path separators, not "."/"..", short-ish.
3895
+ private def picker_valid_name?(name)
3896
+ return false if name.nil?
3897
+ return false if name.empty? || name.length > 255
3898
+ return false if name == "." || name == ".."
3899
+ # Reject path separators (forward slash and backslash).
3900
+ return false if name.match?(%r{[/\\]})
3901
+ true
3902
+ end
3903
+
3904
+ # POST /api/dirs/mkdir
3905
+ # Body: { parent: "/abs/parent", name: "New Folder" }
3906
+ def api_dirs_mkdir(req, res)
3907
+ body = parse_json_body(req)
3908
+ parent = body["parent"].to_s
3909
+ name = body["name"].to_s.strip
3910
+
3911
+ return json_response(res, 422, { error: "parent must be an absolute path" }) unless parent.start_with?("/")
3912
+ return json_response(res, 422, { error: "name is invalid" }) unless picker_valid_name?(name)
3913
+
3914
+ parent = File.expand_path(parent)
3915
+ return json_response(res, 404, { error: "Parent directory not found" }) unless Dir.exist?(parent)
3916
+
3917
+ target = File.join(parent, name)
3918
+ return json_response(res, 422, { error: "Already exists" }) if File.exist?(target)
3919
+
3920
+ FileUtils.mkdir_p(target)
3921
+ json_response(res, 200, { ok: true, path: target, name: name })
3922
+ rescue StandardError => e
3923
+ json_response(res, 500, { error: e.message })
3924
+ end
3925
+
3926
+ # NOTE: there is NO PATCH /api/dirs/rename endpoint.
3927
+ # Directory rename was intentionally removed from the picker —
3928
+ # too dangerous for a one-click UI affordance (renaming an in-use
3929
+ # workspace mid-session can break tasks, sessions, MCP configs, …).
3930
+ # Use the terminal for that.
3931
+
3932
+ # NOTE: there is NO DELETE /api/dirs/delete endpoint.
3933
+ # Directory deletion was intentionally removed from the picker —
3934
+ # too dangerous for a one-click UI affordance, even with a trash
3935
+ # bucket fallback. Use the terminal (safe_rm) for that.
3936
+
3867
3937
  # Body: { enabled: true/false }
3868
3938
  def api_toggle_skill(name, req, res)
3869
3939
  body = parse_json_body(req)
@@ -3880,6 +3950,47 @@ module Clacky
3880
3950
  json_response(res, 422, { error: e.message })
3881
3951
  end
3882
3952
 
3953
+ private def api_skill_content_get(name, res)
3954
+ @skill_loader.load_all
3955
+ skill = @skill_loader[name]
3956
+ return json_response(res, 404, { ok: false, error: "Skill not found: #{name}" }) unless skill
3957
+
3958
+ skill_md = File.join(skill.directory.to_s, "SKILL.md")
3959
+ unless File.exist?(skill_md)
3960
+ return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
3961
+ end
3962
+
3963
+ json_response(res, 200, {
3964
+ ok: true,
3965
+ name: skill.identifier,
3966
+ content: File.read(skill_md),
3967
+ path: skill_md
3968
+ })
3969
+ end
3970
+
3971
+ private def api_skill_content_update(name, req, res)
3972
+ @skill_loader.load_all
3973
+ skill = @skill_loader[name]
3974
+ return json_response(res, 404, { ok: false, error: "Skill not found: #{name}" }) unless skill
3975
+
3976
+ if skill.source_path.nil? || @skill_loader.loaded_from[skill.identifier] == :default
3977
+ return json_response(res, 403, { ok: false, error: "System skills cannot be edited" })
3978
+ end
3979
+
3980
+ data = parse_json_body(req)
3981
+ content = data["content"].to_s
3982
+ skill_md = File.join(skill.directory.to_s, "SKILL.md")
3983
+ unless File.exist?(skill_md)
3984
+ return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
3985
+ end
3986
+
3987
+ File.write(skill_md, content)
3988
+ @skill_loader.load_all
3989
+ json_response(res, 200, { ok: true, name: skill.identifier })
3990
+ rescue StandardError => e
3991
+ json_response(res, 500, { ok: false, error: e.message })
3992
+ end
3993
+
3883
3994
  private def api_delete_skill(name, res)
3884
3995
  skill = @skill_loader[name]
3885
3996
  return json_response(res, 404, { error: "Skill not found: #{name}" }) unless skill
@@ -4738,83 +4849,6 @@ module Clacky
4738
4849
  json_response(res, 422, { error: e.message })
4739
4850
  end
4740
4851
 
4741
- # GET /api/config/media-output-dir
4742
- # Returns the user-configured directory under which generated media
4743
- # files (images / videos / audio) are persisted, plus the default
4744
- # the system would use if the value is empty. The frontend uses
4745
- # `default` as a placeholder hint.
4746
- def api_get_media_output_dir(res)
4747
- json_response(res, 200, {
4748
- ok: true,
4749
- value: @agent_config.media_output_dir.to_s,
4750
- default: default_media_output_dir
4751
- })
4752
- end
4753
-
4754
- # PATCH /api/config/media-output-dir
4755
- # Body: { "value": "<absolute or ~-prefixed path, or empty to clear>" }
4756
- # Empty / blank value clears the override, restoring the legacy
4757
- # fallback (default_working_dir → Dir.pwd) for new generations.
4758
- def api_update_media_output_dir(req, res)
4759
- body = parse_json_body(req)
4760
- return json_response(res, 400, { error: "Invalid JSON" }) unless body
4761
-
4762
- raw = body["value"].to_s.strip
4763
- if raw.empty?
4764
- @agent_config.media_output_dir = nil
4765
- @agent_config.save
4766
- return json_response(res, 200, {
4767
- ok: true,
4768
- value: "",
4769
- default: default_media_output_dir
4770
- })
4771
- end
4772
-
4773
- expanded = File.expand_path(raw)
4774
-
4775
- # Reject anything that's not an absolute path after `~` expansion.
4776
- # Relative paths would silently resolve against the server's CWD,
4777
- # which is exactly the source of confusion this setting exists to fix.
4778
- unless expanded.start_with?("/")
4779
- return json_response(res, 422, {
4780
- error: "media_output_dir must be an absolute path or start with ~"
4781
- })
4782
- end
4783
-
4784
- # Create the directory if missing; surface filesystem errors plainly
4785
- # so the user can fix permissions / typo without reading server logs.
4786
- begin
4787
- FileUtils.mkdir_p(expanded)
4788
- rescue Errno::EACCES, Errno::EROFS, Errno::ENOSPC, Errno::ENOTDIR => e
4789
- return json_response(res, 422, {
4790
- error: "cannot create directory: #{e.message}"
4791
- })
4792
- end
4793
-
4794
- unless File.writable?(expanded)
4795
- return json_response(res, 422, {
4796
- error: "directory is not writable: #{expanded}"
4797
- })
4798
- end
4799
-
4800
- @agent_config.media_output_dir = expanded
4801
- @agent_config.save
4802
- json_response(res, 200, {
4803
- ok: true,
4804
- value: expanded,
4805
- default: default_media_output_dir
4806
- })
4807
- rescue => e
4808
- json_response(res, 422, { error: e.message })
4809
- end
4810
-
4811
- # The path the resolver would pick if media_output_dir is blank.
4812
- # Mirrors the fallback chain inside Clacky::Media::OutputDir.resolve so
4813
- # the frontend can render it as a placeholder hint (no second source
4814
- # of truth — both call sites read default_working_dir).
4815
- private def default_media_output_dir
4816
- @agent_config.default_working_dir.to_s.empty? ? Dir.pwd : @agent_config.default_working_dir
4817
- end
4818
4852
  # DEPRECATED: this endpoint previously accepted the entire models array
4819
4853
  # and replaced @models in place. That design was fragile — any missing
4820
4854
  # or stale field on ANY row could wipe other rows' api_keys. It has
@@ -4839,8 +4873,15 @@ module Clacky
4839
4873
  model = body["model"].to_s.strip
4840
4874
  base_url = body["base_url"].to_s.strip
4841
4875
  api_key = body["api_key"].to_s
4842
- # Masked placeholders are never a valid api_key on creation
4843
- # a brand-new model MUST come with a real key.
4876
+ # When duplicating, the frontend sends source_id so we can inherit the
4877
+ # real key without ever transmitting it back to the client.
4878
+ if api_key.empty? || api_key.include?("****")
4879
+ source_id = body["source_id"].to_s
4880
+ unless source_id.empty?
4881
+ source = @agent_config.models.find { |m| m["id"] == source_id }
4882
+ api_key = source["api_key"].to_s if source
4883
+ end
4884
+ end
4844
4885
  if api_key.empty? || api_key.include?("****")
4845
4886
  return json_response(res, 422, { error: "api_key is required" })
4846
4887
  end
@@ -5558,6 +5599,7 @@ module Clacky
5558
5599
 
5559
5600
  when "message"
5560
5601
  session_id = msg["session_id"] || conn.session_id
5602
+ Thread.current[:lang] = msg["lang"].to_s.strip.then { |l| l.empty? ? nil : l }
5561
5603
  # Merge legacy images array into files as { data_url:, name:, mime_type: } entries
5562
5604
  raw_images = (msg["images"] || []).map do |data_url|
5563
5605
  { "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
@@ -5654,13 +5696,15 @@ module Clacky
5654
5696
  end
5655
5697
 
5656
5698
  # Broadcast user message through web_ui so channel subscribers (飞书/企微) receive it.
5699
+ # created_at is shared with agent.run so the history entry and the bubble use the same value.
5700
+ msg_created_at = Time.now.to_f
5657
5701
  web_ui = nil
5658
5702
  @registry.with_session(session_id) { |s| web_ui = s[:ui] }
5659
- web_ui&.show_user_message(content, source: :web)
5703
+ web_ui&.show_user_message(content, created_at: msg_created_at, source: :web)
5660
5704
 
5661
5705
  # File references are now handled inside agent.run — injected as a system_injected
5662
5706
  # message after the user message, so replay_history skips them automatically.
5663
- run_agent_task(session_id, agent) { agent.run(content, files: files) }
5707
+ run_agent_task(session_id, agent) { agent.run(content, files: files, created_at: msg_created_at) }
5664
5708
  end
5665
5709
 
5666
5710
  def deliver_confirmation(session_id, conf_id, result)
@@ -5847,7 +5891,9 @@ module Clacky
5847
5891
 
5848
5892
  broadcast_session_update(session_id)
5849
5893
 
5894
+ locale = Thread.current[:lang]
5850
5895
  thread = Thread.new do
5896
+ Thread.current[:lang] = locale
5851
5897
  task.call
5852
5898
  @registry.update(session_id, status: :idle, error: nil)
5853
5899
  broadcast_session_update(session_id)
@@ -5874,10 +5920,11 @@ module Clacky
5874
5920
  preset = Clacky::Providers::PRESETS[e.provider_id]
5875
5921
  top_up_url = preset && preset["website_url"]
5876
5922
  end
5877
- @registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
5923
+ user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
5924
+ @registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url)
5878
5925
  broadcast_session_update(session_id)
5879
- web_ui&.show_error(e.message, code: code, top_up_url: top_up_url)
5880
- @session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
5926
+ web_ui&.show_error(user_message, code: code, top_up_url: top_up_url)
5927
+ @session_manager.save(agent.to_session_data(status: :error, error_message: user_message))
5881
5928
  end
5882
5929
  @registry.with_session(session_id) { |s| s[:thread] = thread }
5883
5930
  end
@@ -440,7 +440,7 @@ module Clacky
440
440
 
441
441
  private def persist_and_release(id, session)
442
442
  agent = session[:agent]
443
- @session_manager&.save(agent.to_session_data(status: :success)) if agent
443
+ @session_manager&.save(agent.to_session_data(status: :success, preserve_updated_at: true)) if agent
444
444
 
445
445
  @mutex.synchronize do
446
446
  s = @sessions[id]
@@ -178,4 +178,4 @@ module Clacky
178
178
  base
179
179
  end
180
180
  end
181
- end
181
+ end
@@ -67,8 +67,20 @@ module Clacky
67
67
  }
68
68
  end
69
69
 
70
- # Perform replacement
71
- content = replace_all ? content.gsub(actual_old_string, new_string) : content.sub(actual_old_string, new_string)
70
+ # Perform literal replacement.
71
+ #
72
+ # NOTE: Use the block form (`sub(old) { new }`) instead of the
73
+ # two-arg form (`sub(old, new)`). The two-arg form interprets
74
+ # backslash escapes (\&, \1, \`, \', \\) in the replacement
75
+ # as sed-style backreferences, silently mangling literal
76
+ # backslashes and these escape sequences. The block form
77
+ # performs no such interpretation, matching the edit tool's
78
+ # literal-replacement contract.
79
+ content = if replace_all
80
+ content.gsub(actual_old_string) { new_string }
81
+ else
82
+ content.sub(actual_old_string) { new_string }
83
+ end
72
84
 
73
85
  File.write(path, content)
74
86
 
@@ -47,6 +47,7 @@ module Clacky
47
47
  @input_callback = nil
48
48
  @interrupt_callback = nil
49
49
  @time_machine_callback = nil
50
+ @model_switch_callback = nil
50
51
  @tasks_count = 0
51
52
  @total_cost = 0.0
52
53
  @session_id = nil
@@ -192,6 +193,12 @@ module Clacky
192
193
  @time_machine_callback = block
193
194
  end
194
195
 
196
+ # Set callback for model switch (from /model slash command)
197
+ # @param block [Proc] Callback to execute on model switch
198
+ def on_model_switch(&block)
199
+ @model_switch_callback = block
200
+ end
201
+
195
202
  # Set agent for command suggestions
196
203
  # @param agent [Clacky::Agent] The agent instance with skill management
197
204
  # @param agent_profile [Clacky::AgentProfile, nil] Current agent profile for skill filtering
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.3"
4
+ VERSION = "1.3.4"
5
5
  end