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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent.rb +3 -2
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
- data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/http_server.rb +150 -103
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -59
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +18 -2
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/skills/store.js +30 -2
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +32 -20
- data/lib/clacky/web/index.html +9 -17
- data/lib/clacky/web/sessions.js +286 -28
- data/lib/clacky/web/settings.js +109 -111
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -2
- metadata +38 -2
- 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 =
|
|
1304
|
-
|
|
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 =
|
|
1339
|
-
|
|
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 =
|
|
1371
|
-
|
|
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
|
-
#
|
|
4843
|
-
#
|
|
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
|
-
|
|
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(
|
|
5880
|
-
@session_manager.save(agent.to_session_data(status: :error, error_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]
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -67,8 +67,20 @@ module Clacky
|
|
|
67
67
|
}
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# Perform replacement
|
|
71
|
-
|
|
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
|
data/lib/clacky/version.rb
CHANGED