openclacky 1.3.2 → 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 +54 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -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 +52 -7
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +15 -3
- 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 +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
- 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/media/openai_compat.rb +64 -1
- data/lib/clacky/message_history.rb +9 -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/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +547 -15
- data/lib/clacky/server/server_master.rb +6 -4
- 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 +511 -101
- data/lib/clacky/web/app.js +95 -14
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
- data/lib/clacky/web/core/aside.js +117 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +331 -0
- data/lib/clacky/web/features/skills/view.js +581 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +68 -6
- data/lib/clacky/web/index.html +113 -62
- data/lib/clacky/web/sessions.js +493 -39
- data/lib/clacky/web/settings.js +143 -49
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -1
- metadata +81 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -16,6 +16,7 @@ require "timeout"
|
|
|
16
16
|
require "yaml"
|
|
17
17
|
require "date"
|
|
18
18
|
require_relative "session_registry"
|
|
19
|
+
require_relative "git_panel"
|
|
19
20
|
require_relative "web_ui_controller"
|
|
20
21
|
require_relative "scheduler"
|
|
21
22
|
require_relative "../brand_config"
|
|
@@ -217,6 +218,7 @@ module Clacky
|
|
|
217
218
|
end
|
|
218
219
|
|
|
219
220
|
def start
|
|
221
|
+
@start_time = Time.now
|
|
220
222
|
# One-time migration: move legacy trash contents into file-trash/ subdirectory.
|
|
221
223
|
Clacky::TrashDirectory.migrate_legacy_if_needed
|
|
222
224
|
|
|
@@ -333,6 +335,14 @@ module Clacky
|
|
|
333
335
|
server.mount("/api", servlet_class)
|
|
334
336
|
server.mount("/ws", servlet_class)
|
|
335
337
|
|
|
338
|
+
# Health check endpoint — no auth, minimal overhead.
|
|
339
|
+
# Docker / orchestrators can probe this to decide container health.
|
|
340
|
+
server.mount_proc("/health") do |_req, res|
|
|
341
|
+
res.status = 200
|
|
342
|
+
res["Content-Type"] = "application/json"
|
|
343
|
+
res.body = '{"status":"ok"}'
|
|
344
|
+
end
|
|
345
|
+
|
|
336
346
|
# Mount static file handler for the entire web directory.
|
|
337
347
|
# Use mount_proc so we can inject no-cache headers on every response,
|
|
338
348
|
# preventing stale JS/CSS from being served after a gem update.
|
|
@@ -347,12 +357,21 @@ module Clacky
|
|
|
347
357
|
server.mount_proc("/") do |req, res|
|
|
348
358
|
if req.path == "/" || req.path == "/index.html"
|
|
349
359
|
product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
|
|
350
|
-
|
|
360
|
+
pure = req.query["pure"] == "true"
|
|
361
|
+
html = File.read(index_html_path)
|
|
362
|
+
.gsub("{{BRAND_NAME}}", product_name)
|
|
363
|
+
.gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
|
|
351
364
|
res.status = 200
|
|
352
365
|
res["Content-Type"] = "text/html; charset=utf-8"
|
|
353
366
|
res["Cache-Control"] = "no-store"
|
|
354
367
|
res["Pragma"] = "no-cache"
|
|
355
368
|
res.body = html
|
|
369
|
+
elsif req.path.start_with?("/webui_ext/")
|
|
370
|
+
self.send(:serve_webui_ext, req, res)
|
|
371
|
+
elsif req.path.start_with?("/agent_ui/")
|
|
372
|
+
self.send(:serve_agent_ui, req, res)
|
|
373
|
+
elsif req.path.start_with?("/panel_ui/")
|
|
374
|
+
self.send(:serve_panel_ui, req, res)
|
|
356
375
|
else
|
|
357
376
|
file_handler.service(req, res)
|
|
358
377
|
res["Cache-Control"] = "no-store"
|
|
@@ -363,6 +382,12 @@ module Clacky
|
|
|
363
382
|
# Auto-create a default session on startup
|
|
364
383
|
create_default_session
|
|
365
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
|
+
|
|
366
391
|
# Start the background scheduler
|
|
367
392
|
@scheduler.start
|
|
368
393
|
puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
|
|
@@ -394,6 +419,8 @@ module Clacky
|
|
|
394
419
|
path = req.path
|
|
395
420
|
method = req.request_method
|
|
396
421
|
|
|
422
|
+
Thread.current[:lang] = req["X-Lang"].to_s.strip.then { |l| l.empty? ? nil : l }
|
|
423
|
+
|
|
397
424
|
# Access key guard (skip for WebSocket upgrades)
|
|
398
425
|
return unless check_access_key(req, res)
|
|
399
426
|
|
|
@@ -412,6 +439,11 @@ module Clacky
|
|
|
412
439
|
# generous 90s so retry + failover can complete without being cut short.
|
|
413
440
|
timeout_sec = if path.start_with?("/api/brand")
|
|
414
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
|
|
415
447
|
elsif path == "/api/tool/browser"
|
|
416
448
|
30
|
|
417
449
|
elsif path == "/api/exchange-rate"
|
|
@@ -451,6 +483,13 @@ module Clacky
|
|
|
451
483
|
path = req.path
|
|
452
484
|
method = req.request_method
|
|
453
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
|
+
|
|
454
493
|
case [method, path]
|
|
455
494
|
when ["GET", "/api/sessions"] then api_list_sessions(req, res)
|
|
456
495
|
when ["POST", "/api/sessions"] then api_create_session(req, res)
|
|
@@ -565,8 +604,31 @@ module Clacky
|
|
|
565
604
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
566
605
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
567
606
|
api_session_skills(session_id, res)
|
|
607
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
|
|
608
|
+
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
609
|
+
action = path[%r{/git/([a-z]+)$}, 1]
|
|
610
|
+
api_session_git(session_id, action, req, res)
|
|
611
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
|
|
612
|
+
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
613
|
+
api_session_git_commit(session_id, req, res)
|
|
614
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
|
|
615
|
+
session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
|
|
616
|
+
api_session_time_machine(session_id, res)
|
|
617
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/time_machine/switch$})
|
|
618
|
+
session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
|
|
619
|
+
api_session_time_machine_switch(session_id, req, res)
|
|
620
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/diff$})
|
|
621
|
+
session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
|
|
622
|
+
task_id = path[%r{/time_machine/(\d+)/diff$}, 1].to_i
|
|
623
|
+
api_session_time_machine_diff(session_id, task_id, req, res)
|
|
624
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/restore_preview$})
|
|
625
|
+
session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
|
|
626
|
+
task_id = path[%r{/time_machine/(\d+)/restore_preview$}, 1].to_i
|
|
627
|
+
api_session_time_machine_restore_preview(session_id, task_id, res)
|
|
568
628
|
elsif method == "GET" && path == "/api/dirs"
|
|
569
629
|
api_browse_dirs(req, res)
|
|
630
|
+
elsif method == "POST" && path == "/api/dirs/mkdir"
|
|
631
|
+
api_dirs_mkdir(req, res)
|
|
570
632
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
|
|
571
633
|
session_id = path.sub("/api/sessions/", "").sub("/files", "")
|
|
572
634
|
api_session_files(session_id, req, res)
|
|
@@ -576,6 +638,9 @@ module Clacky
|
|
|
576
638
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
|
|
577
639
|
session_id = path.sub("/api/sessions/", "").sub("/messages", "")
|
|
578
640
|
api_session_messages(session_id, req, res)
|
|
641
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+$})
|
|
642
|
+
session_id = path.sub("/api/sessions/", "")
|
|
643
|
+
api_get_session(session_id, res)
|
|
579
644
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
|
|
580
645
|
session_id = path.sub("/api/sessions/", "")
|
|
581
646
|
api_rename_session(session_id, req, res)
|
|
@@ -627,6 +692,12 @@ module Clacky
|
|
|
627
692
|
elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
|
|
628
693
|
name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
|
|
629
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)
|
|
630
701
|
elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
|
|
631
702
|
name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
|
|
632
703
|
api_delete_skill(name, res)
|
|
@@ -683,6 +754,16 @@ module Clacky
|
|
|
683
754
|
json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
|
|
684
755
|
end
|
|
685
756
|
|
|
757
|
+
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
758
|
+
# Used by the frontend Router when navigating to a session that isn't in
|
|
759
|
+
# the paged sidebar list (search results, URL deep links, share links,
|
|
760
|
+
# browser back/forward, external notifications, etc.).
|
|
761
|
+
def api_get_session(session_id, res)
|
|
762
|
+
row = @registry.snapshot(session_id)
|
|
763
|
+
return json_response(res, 404, { error: "Session not found" }) unless row
|
|
764
|
+
json_response(res, 200, { session: row })
|
|
765
|
+
end
|
|
766
|
+
|
|
686
767
|
def api_create_session(req, res)
|
|
687
768
|
body = parse_json_body(req)
|
|
688
769
|
name = body["name"]
|
|
@@ -949,9 +1030,195 @@ module Clacky
|
|
|
949
1030
|
@agent_config.save
|
|
950
1031
|
end
|
|
951
1032
|
|
|
1033
|
+
# Absolute path to the user's WebUI extension directory.
|
|
1034
|
+
WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
|
|
1035
|
+
|
|
1036
|
+
# Build the full <script> payload injected at {{EXT_SCRIPTS}}:
|
|
1037
|
+
# 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
|
|
1038
|
+
# 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
|
|
1039
|
+
# 3. official panels — default_agents/_panels/<id>/**/*.js (data-panel)
|
|
1040
|
+
# Prefixed with a registerPanelAgents() call so the client knows which
|
|
1041
|
+
# agent profiles reference each official panel. Never raises.
|
|
1042
|
+
private def webui_ext_script_tags
|
|
1043
|
+
[
|
|
1044
|
+
panel_agents_script,
|
|
1045
|
+
global_ext_script_tags,
|
|
1046
|
+
agent_webui_script_tags,
|
|
1047
|
+
official_panel_script_tags,
|
|
1048
|
+
].reject(&:empty?).join("\n")
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
# Inline script registering the panel→agents map (which agent profiles
|
|
1052
|
+
# reference each official panel, from their profile.yml `panels:`).
|
|
1053
|
+
private def panel_agents_script
|
|
1054
|
+
map = panel_agents_map
|
|
1055
|
+
return "" if map.empty?
|
|
1056
|
+
|
|
1057
|
+
"<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# { "git" => ["coding", ...], ... } — scan every agent profile.yml for a
|
|
1061
|
+
# `panels:` list and invert it into panel → referencing agents.
|
|
1062
|
+
private def panel_agents_map
|
|
1063
|
+
result = Hash.new { |h, k| h[k] = [] }
|
|
1064
|
+
agent_profile_dirs.each do |name, dir|
|
|
1065
|
+
yml = File.join(dir, "profile.yml")
|
|
1066
|
+
next unless File.file?(yml)
|
|
1067
|
+
|
|
1068
|
+
data = begin
|
|
1069
|
+
YAML.safe_load(File.read(yml)) || {}
|
|
1070
|
+
rescue StandardError
|
|
1071
|
+
{}
|
|
1072
|
+
end
|
|
1073
|
+
Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
|
|
1074
|
+
end
|
|
1075
|
+
result
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
# { agent_name => resolved_dir } across built-in and user agent dirs,
|
|
1079
|
+
# user dir winning on name collision (matches AgentProfile lookup order).
|
|
1080
|
+
private def agent_profile_dirs
|
|
1081
|
+
dirs = {}
|
|
1082
|
+
[Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
|
|
1083
|
+
next unless Dir.exist?(root)
|
|
1084
|
+
|
|
1085
|
+
Dir.children(root).each do |name|
|
|
1086
|
+
next if name.start_with?("_") # _panels and other non-agent dirs
|
|
1087
|
+
full = File.join(root, name)
|
|
1088
|
+
next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
|
|
1089
|
+
|
|
1090
|
+
dirs[name] = full
|
|
1091
|
+
end
|
|
1092
|
+
end
|
|
1093
|
+
dirs
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# Global extensions — visible for all agents (unchanged legacy behavior).
|
|
1097
|
+
private def global_ext_script_tags
|
|
1098
|
+
ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
|
|
1102
|
+
# its owning agent so the client only mounts it for that profile.
|
|
1103
|
+
private def agent_webui_script_tags
|
|
1104
|
+
agent_profile_dirs.filter_map do |name, dir|
|
|
1105
|
+
webui = File.join(dir, "webui")
|
|
1106
|
+
next unless Dir.exist?(webui)
|
|
1107
|
+
|
|
1108
|
+
ext_script_block(webui, "/agent_ui/#{name}", id_prefix: "agent/#{name}/", agents: [name])
|
|
1109
|
+
end.reject(&:empty?).join("\n")
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
|
|
1113
|
+
# the panel→agents map (registerPanelAgents), so a panel only mounts for
|
|
1114
|
+
# agents whose profile.yml references it.
|
|
1115
|
+
private def official_panel_script_tags
|
|
1116
|
+
root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
|
|
1117
|
+
return "" unless Dir.exist?(root)
|
|
1118
|
+
|
|
1119
|
+
Dir.children(root).sort.filter_map do |panel|
|
|
1120
|
+
dir = File.join(root, panel)
|
|
1121
|
+
next unless File.directory?(dir)
|
|
1122
|
+
|
|
1123
|
+
ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
|
|
1124
|
+
end.reject(&:empty?).join("\n")
|
|
1125
|
+
end
|
|
952
1126
|
|
|
953
|
-
#
|
|
954
|
-
#
|
|
1127
|
+
# Emit begin/script/end triples for every *.js under `root`, served from
|
|
1128
|
+
# `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
|
|
1129
|
+
# client can decide visibility. Returns "" when the dir is absent.
|
|
1130
|
+
private def ext_script_block(root, url_base, id_prefix: "", agents: nil, panel: nil)
|
|
1131
|
+
return "" unless Dir.exist?(root)
|
|
1132
|
+
|
|
1133
|
+
Dir.glob(File.join(root, "**", "*.js")).sort.map do |abs|
|
|
1134
|
+
rel = abs.delete_prefix(root + "/")
|
|
1135
|
+
ext_id = id_prefix + rel.delete_suffix(".js")
|
|
1136
|
+
src = "#{url_base}/#{rel}"
|
|
1137
|
+
# Bracket the extension's own <script> with begin/end markers so that
|
|
1138
|
+
# registrations made during its synchronous evaluation are attributed
|
|
1139
|
+
# to it (for crash attribution / disable). Synchronous src scripts run
|
|
1140
|
+
# in document order, so the surrounding inline scripts run immediately
|
|
1141
|
+
# before and after it.
|
|
1142
|
+
"<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{agents.to_json}, #{panel.to_json})</script>" \
|
|
1143
|
+
"<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
|
|
1144
|
+
"<script>Clacky.ext._extEnd()</script>"
|
|
1145
|
+
end.join("\n")
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
|
|
1149
|
+
# with strict path containment so a crafted path cannot escape the dir.
|
|
1150
|
+
private def serve_webui_ext(req, res)
|
|
1151
|
+
rel = req.path.delete_prefix("/webui_ext/")
|
|
1152
|
+
abs = File.expand_path(File.join(WEBUI_EXT_ROOT, rel))
|
|
1153
|
+
|
|
1154
|
+
unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
|
|
1155
|
+
res.status = 404
|
|
1156
|
+
res.body = "not found"
|
|
1157
|
+
return
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
ext = File.extname(abs)
|
|
1161
|
+
ctype = { ".js" => "application/javascript", ".css" => "text/css",
|
|
1162
|
+
".html" => "text/html; charset=utf-8" }[ext]
|
|
1163
|
+
unless ctype
|
|
1164
|
+
res.status = 415
|
|
1165
|
+
res.body = "unsupported media type"
|
|
1166
|
+
return
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
res.status = 200
|
|
1170
|
+
res["Content-Type"] = ctype
|
|
1171
|
+
res["Cache-Control"] = "no-store"
|
|
1172
|
+
res["Pragma"] = "no-cache"
|
|
1173
|
+
res.body = File.read(abs)
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
# Serve agents/<name>/webui/<file> from built-in or user agent dir.
|
|
1177
|
+
# Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
|
|
1178
|
+
private def serve_agent_ui(req, res)
|
|
1179
|
+
rest = req.path.delete_prefix("/agent_ui/")
|
|
1180
|
+
name, _, rel = rest.partition("/")
|
|
1181
|
+
dir = agent_profile_dirs[name]
|
|
1182
|
+
return (res.status = 404; res.body = "not found") unless dir && !rel.empty?
|
|
1183
|
+
|
|
1184
|
+
serve_static_under(File.join(dir, "webui"), rel, res)
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
# Serve official panel assets: /panel_ui/<panel>/<rel>.
|
|
1188
|
+
private def serve_panel_ui(req, res)
|
|
1189
|
+
rest = req.path.delete_prefix("/panel_ui/")
|
|
1190
|
+
panel, _, rel = rest.partition("/")
|
|
1191
|
+
return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
|
|
1192
|
+
|
|
1193
|
+
root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
|
|
1194
|
+
serve_static_under(root, rel, res)
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
# Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
|
|
1198
|
+
# strict path containment so a crafted rel cannot escape `root`.
|
|
1199
|
+
private def serve_static_under(root, rel, res)
|
|
1200
|
+
root = File.expand_path(root)
|
|
1201
|
+
abs = File.expand_path(File.join(root, rel))
|
|
1202
|
+
unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
|
|
1203
|
+
res.status = 404
|
|
1204
|
+
res.body = "not found"
|
|
1205
|
+
return
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
ctype = { ".js" => "application/javascript", ".css" => "text/css",
|
|
1209
|
+
".html" => "text/html; charset=utf-8" }[File.extname(abs)]
|
|
1210
|
+
unless ctype
|
|
1211
|
+
res.status = 415
|
|
1212
|
+
res.body = "unsupported media type"
|
|
1213
|
+
return
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
res.status = 200
|
|
1217
|
+
res["Content-Type"] = ctype
|
|
1218
|
+
res["Cache-Control"] = "no-store"
|
|
1219
|
+
res["Pragma"] = "no-cache"
|
|
1220
|
+
res.body = File.read(abs)
|
|
1221
|
+
end
|
|
955
1222
|
def api_browser_status(res)
|
|
956
1223
|
json_response(res, 200, @browser_manager.status)
|
|
957
1224
|
end
|
|
@@ -1066,7 +1333,9 @@ module Clacky
|
|
|
1066
1333
|
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
1067
1334
|
prompt: prompt,
|
|
1068
1335
|
aspect_ratio: aspect_ratio,
|
|
1069
|
-
output_dir: output_dir
|
|
1336
|
+
output_dir: output_dir,
|
|
1337
|
+
image: body["image"],
|
|
1338
|
+
images: body["images"]
|
|
1070
1339
|
)
|
|
1071
1340
|
if result["success"]
|
|
1072
1341
|
log_media_usage(result, prompt: prompt)
|
|
@@ -2164,6 +2433,13 @@ module Clacky
|
|
|
2164
2433
|
# skills/curl talk to the server without an access key.
|
|
2165
2434
|
return true if loopback_ip?(ip)
|
|
2166
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
|
+
|
|
2167
2443
|
candidate = extract_key(req)
|
|
2168
2444
|
|
|
2169
2445
|
# Lazily evict expired lockout entries to prevent unbounded memory growth.
|
|
@@ -3369,7 +3645,147 @@ module Clacky
|
|
|
3369
3645
|
json_response(res, 200, { skills: skill_data })
|
|
3370
3646
|
end
|
|
3371
3647
|
|
|
3372
|
-
# GET /api/sessions/:id/
|
|
3648
|
+
# GET /api/sessions/:id/git/<action> — read-only git info for the session's
|
|
3649
|
+
# working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
|
|
3650
|
+
# log accepts ?limit=.
|
|
3651
|
+
def api_session_git(session_id, action, req, res)
|
|
3652
|
+
dir = git_session_dir(session_id, res)
|
|
3653
|
+
return unless dir
|
|
3654
|
+
|
|
3655
|
+
unless Clacky::Server::GitPanel.repo?(dir)
|
|
3656
|
+
return json_response(res, 200, { repo: false })
|
|
3657
|
+
end
|
|
3658
|
+
|
|
3659
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
3660
|
+
case action
|
|
3661
|
+
when "status"
|
|
3662
|
+
json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
|
|
3663
|
+
when "diff"
|
|
3664
|
+
json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
|
|
3665
|
+
when "log"
|
|
3666
|
+
json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
|
|
3667
|
+
when "branches"
|
|
3668
|
+
json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
|
|
3669
|
+
else
|
|
3670
|
+
json_response(res, 404, { error: "Unknown git action" })
|
|
3671
|
+
end
|
|
3672
|
+
end
|
|
3673
|
+
|
|
3674
|
+
# POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
|
|
3675
|
+
def api_session_git_commit(session_id, req, res)
|
|
3676
|
+
dir = git_session_dir(session_id, res)
|
|
3677
|
+
return unless dir
|
|
3678
|
+
|
|
3679
|
+
unless Clacky::Server::GitPanel.repo?(dir)
|
|
3680
|
+
return json_response(res, 400, { error: "Not a git repository" })
|
|
3681
|
+
end
|
|
3682
|
+
|
|
3683
|
+
body = parse_json_body(req)
|
|
3684
|
+
result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
|
|
3685
|
+
if result[:ok]
|
|
3686
|
+
json_response(res, 200, result)
|
|
3687
|
+
else
|
|
3688
|
+
json_response(res, 422, { error: result[:error] })
|
|
3689
|
+
end
|
|
3690
|
+
end
|
|
3691
|
+
|
|
3692
|
+
# GET /api/sessions/:id/time_machine — task history for the Time Machine
|
|
3693
|
+
# panel. Mirrors the CLI menu: each entry carries id, summary, status
|
|
3694
|
+
# (current/past/undone) and whether it branches.
|
|
3695
|
+
def api_session_time_machine(session_id, res)
|
|
3696
|
+
agent = time_machine_agent(session_id, res)
|
|
3697
|
+
return unless agent
|
|
3698
|
+
|
|
3699
|
+
history = agent.get_task_history(limit: 20)
|
|
3700
|
+
json_response(res, 200, { tasks: history })
|
|
3701
|
+
end
|
|
3702
|
+
|
|
3703
|
+
# POST /api/sessions/:id/time_machine/switch — body: { task_id: }.
|
|
3704
|
+
# Restores the working tree to the end-of-task state of task_id.
|
|
3705
|
+
def api_session_time_machine_switch(session_id, req, res)
|
|
3706
|
+
agent = time_machine_agent(session_id, res)
|
|
3707
|
+
return unless agent
|
|
3708
|
+
|
|
3709
|
+
body = parse_json_body(req)
|
|
3710
|
+
task_id = body["task_id"].to_i
|
|
3711
|
+
result = agent.switch_to_task(task_id)
|
|
3712
|
+
if result[:success]
|
|
3713
|
+
@session_manager.save(agent.to_session_data(status: :success))
|
|
3714
|
+
broadcast_session_update(session_id)
|
|
3715
|
+
json_response(res, 200, { ok: true, message: result[:message], task_id: result[:task_id] })
|
|
3716
|
+
else
|
|
3717
|
+
json_response(res, 422, { ok: false, error: result[:message] })
|
|
3718
|
+
end
|
|
3719
|
+
end
|
|
3720
|
+
|
|
3721
|
+
# GET /api/sessions/:id/time_machine/:task_id/diff
|
|
3722
|
+
# Without ?path: returns the file list this task touched.
|
|
3723
|
+
# With ?path=<rel>: returns the unified diff of that file.
|
|
3724
|
+
def api_session_time_machine_diff(session_id, task_id, req, res)
|
|
3725
|
+
agent = time_machine_agent(session_id, res)
|
|
3726
|
+
return unless agent
|
|
3727
|
+
|
|
3728
|
+
rel = req.query["path"].to_s
|
|
3729
|
+
if rel.empty?
|
|
3730
|
+
json_response(res, 200, { ok: true, task_id: task_id, files: agent.task_diff_files(task_id) })
|
|
3731
|
+
else
|
|
3732
|
+
diff = agent.task_file_diff(task_id, rel)
|
|
3733
|
+
if diff.nil?
|
|
3734
|
+
json_response(res, 404, { ok: false, error: "No diff for #{rel}" })
|
|
3735
|
+
else
|
|
3736
|
+
json_response(res, 200, { ok: true, task_id: task_id }.merge(diff))
|
|
3737
|
+
end
|
|
3738
|
+
end
|
|
3739
|
+
end
|
|
3740
|
+
|
|
3741
|
+
# GET /api/sessions/:id/time_machine/:task_id/restore_preview
|
|
3742
|
+
# Returns the file-level effect of switching back to this task without
|
|
3743
|
+
# actually performing the switch. Lets the UI render an honest
|
|
3744
|
+
# confirmation listing the files that would be overwritten/created/deleted.
|
|
3745
|
+
def api_session_time_machine_restore_preview(session_id, task_id, res)
|
|
3746
|
+
agent = time_machine_agent(session_id, res)
|
|
3747
|
+
return unless agent
|
|
3748
|
+
|
|
3749
|
+
changes = agent.preview_restore_to_task(task_id)
|
|
3750
|
+
json_response(res, 200, { ok: true, task_id: task_id, changes: changes })
|
|
3751
|
+
end
|
|
3752
|
+
|
|
3753
|
+
# Resolve a session's agent for time-machine ops; writes the error
|
|
3754
|
+
# response and returns nil on failure.
|
|
3755
|
+
private def time_machine_agent(session_id, res)
|
|
3756
|
+
unless @registry.ensure(session_id)
|
|
3757
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3758
|
+
return nil
|
|
3759
|
+
end
|
|
3760
|
+
session = @registry.get(session_id)
|
|
3761
|
+
agent = session && session[:agent]
|
|
3762
|
+
unless agent
|
|
3763
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3764
|
+
return nil
|
|
3765
|
+
end
|
|
3766
|
+
agent
|
|
3767
|
+
end
|
|
3768
|
+
|
|
3769
|
+
# Resolve a session's working_dir for git ops; writes the error response
|
|
3770
|
+
# and returns nil on any failure.
|
|
3771
|
+
private def git_session_dir(session_id, res)
|
|
3772
|
+
unless @registry.ensure(session_id)
|
|
3773
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3774
|
+
return nil
|
|
3775
|
+
end
|
|
3776
|
+
session = @registry.get(session_id)
|
|
3777
|
+
agent = session && session[:agent]
|
|
3778
|
+
unless agent
|
|
3779
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3780
|
+
return nil
|
|
3781
|
+
end
|
|
3782
|
+
dir = File.expand_path(agent.working_dir.to_s)
|
|
3783
|
+
unless Dir.exist?(dir)
|
|
3784
|
+
json_response(res, 404, { error: "Working directory not found" })
|
|
3785
|
+
return nil
|
|
3786
|
+
end
|
|
3787
|
+
dir
|
|
3788
|
+
end
|
|
3373
3789
|
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
3374
3790
|
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
3375
3791
|
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
@@ -3430,7 +3846,7 @@ module Clacky
|
|
|
3430
3846
|
# Directories first, then files; both case-insensitive alphabetical.
|
|
3431
3847
|
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
3432
3848
|
|
|
3433
|
-
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 })
|
|
3434
3850
|
rescue StandardError => e
|
|
3435
3851
|
json_response(res, 500, { error: e.message })
|
|
3436
3852
|
end
|
|
@@ -3468,11 +3884,56 @@ module Clacky
|
|
|
3468
3884
|
end
|
|
3469
3885
|
items.sort_by! { |it| it[:name].downcase }
|
|
3470
3886
|
|
|
3471
|
-
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 })
|
|
3888
|
+
rescue StandardError => e
|
|
3889
|
+
json_response(res, 500, { error: e.message })
|
|
3890
|
+
end
|
|
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 })
|
|
3472
3922
|
rescue StandardError => e
|
|
3473
3923
|
json_response(res, 500, { error: e.message })
|
|
3474
3924
|
end
|
|
3475
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
|
+
|
|
3476
3937
|
# Body: { enabled: true/false }
|
|
3477
3938
|
def api_toggle_skill(name, req, res)
|
|
3478
3939
|
body = parse_json_body(req)
|
|
@@ -3489,6 +3950,47 @@ module Clacky
|
|
|
3489
3950
|
json_response(res, 422, { error: e.message })
|
|
3490
3951
|
end
|
|
3491
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
|
+
|
|
3492
3994
|
private def api_delete_skill(name, res)
|
|
3493
3995
|
skill = @skill_loader[name]
|
|
3494
3996
|
return json_response(res, 404, { error: "Skill not found: #{name}" }) unless skill
|
|
@@ -4347,7 +4849,6 @@ module Clacky
|
|
|
4347
4849
|
json_response(res, 422, { error: e.message })
|
|
4348
4850
|
end
|
|
4349
4851
|
|
|
4350
|
-
# POST /api/config — save updated model list
|
|
4351
4852
|
# DEPRECATED: this endpoint previously accepted the entire models array
|
|
4352
4853
|
# and replaced @models in place. That design was fragile — any missing
|
|
4353
4854
|
# or stale field on ANY row could wipe other rows' api_keys. It has
|
|
@@ -4372,8 +4873,15 @@ module Clacky
|
|
|
4372
4873
|
model = body["model"].to_s.strip
|
|
4373
4874
|
base_url = body["base_url"].to_s.strip
|
|
4374
4875
|
api_key = body["api_key"].to_s
|
|
4375
|
-
#
|
|
4376
|
-
#
|
|
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
|
|
4377
4885
|
if api_key.empty? || api_key.include?("****")
|
|
4378
4886
|
return json_response(res, 422, { error: "api_key is required" })
|
|
4379
4887
|
end
|
|
@@ -5085,8 +5593,13 @@ module Clacky
|
|
|
5085
5593
|
conn.send_json(type: "error", message: "Session not found: #{session_id}")
|
|
5086
5594
|
end
|
|
5087
5595
|
|
|
5596
|
+
when "edit_message"
|
|
5597
|
+
session_id = msg["session_id"] || conn.session_id
|
|
5598
|
+
handle_edit_message(session_id, msg["content"].to_s, msg["created_at"].to_s)
|
|
5599
|
+
|
|
5088
5600
|
when "message"
|
|
5089
5601
|
session_id = msg["session_id"] || conn.session_id
|
|
5602
|
+
Thread.current[:lang] = msg["lang"].to_s.strip.then { |l| l.empty? ? nil : l }
|
|
5090
5603
|
# Merge legacy images array into files as { data_url:, name:, mime_type: } entries
|
|
5091
5604
|
raw_images = (msg["images"] || []).map do |data_url|
|
|
5092
5605
|
{ "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
|
|
@@ -5135,6 +5648,20 @@ module Clacky
|
|
|
5135
5648
|
|
|
5136
5649
|
# ── Session actions ───────────────────────────────────────────────────────
|
|
5137
5650
|
|
|
5651
|
+
def handle_edit_message(session_id, content, created_at)
|
|
5652
|
+
return unless @registry.exist?(session_id)
|
|
5653
|
+
|
|
5654
|
+
agent = nil
|
|
5655
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
5656
|
+
return unless agent
|
|
5657
|
+
|
|
5658
|
+
if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
|
|
5659
|
+
agent.history.truncate_from_created_at(created_at)
|
|
5660
|
+
end
|
|
5661
|
+
|
|
5662
|
+
handle_user_message(session_id, content)
|
|
5663
|
+
end
|
|
5664
|
+
|
|
5138
5665
|
def handle_user_message(session_id, content, files = [])
|
|
5139
5666
|
return unless @registry.exist?(session_id)
|
|
5140
5667
|
|
|
@@ -5169,13 +5696,15 @@ module Clacky
|
|
|
5169
5696
|
end
|
|
5170
5697
|
|
|
5171
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
|
|
5172
5701
|
web_ui = nil
|
|
5173
5702
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
5174
|
-
web_ui&.show_user_message(content, source: :web)
|
|
5703
|
+
web_ui&.show_user_message(content, created_at: msg_created_at, source: :web)
|
|
5175
5704
|
|
|
5176
5705
|
# File references are now handled inside agent.run — injected as a system_injected
|
|
5177
5706
|
# message after the user message, so replay_history skips them automatically.
|
|
5178
|
-
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) }
|
|
5179
5708
|
end
|
|
5180
5709
|
|
|
5181
5710
|
def deliver_confirmation(session_id, conf_id, result)
|
|
@@ -5362,7 +5891,9 @@ module Clacky
|
|
|
5362
5891
|
|
|
5363
5892
|
broadcast_session_update(session_id)
|
|
5364
5893
|
|
|
5894
|
+
locale = Thread.current[:lang]
|
|
5365
5895
|
thread = Thread.new do
|
|
5896
|
+
Thread.current[:lang] = locale
|
|
5366
5897
|
task.call
|
|
5367
5898
|
@registry.update(session_id, status: :idle, error: nil)
|
|
5368
5899
|
broadcast_session_update(session_id)
|
|
@@ -5389,10 +5920,11 @@ module Clacky
|
|
|
5389
5920
|
preset = Clacky::Providers::PRESETS[e.provider_id]
|
|
5390
5921
|
top_up_url = preset && preset["website_url"]
|
|
5391
5922
|
end
|
|
5392
|
-
|
|
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)
|
|
5393
5925
|
broadcast_session_update(session_id)
|
|
5394
|
-
web_ui&.show_error(
|
|
5395
|
-
@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))
|
|
5396
5928
|
end
|
|
5397
5929
|
@registry.with_session(session_id) { |s| s[:thread] = thread }
|
|
5398
5930
|
end
|