openclacky 1.3.1 → 1.3.3
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 +44 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +65 -11
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +12 -1
- data/lib/clacky/agent_config.rb +14 -2
- data/lib/clacky/brand_config.rb +1 -1
- 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 +30 -6
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/media/output_dir.rb +43 -0
- data/lib/clacky/message_history.rb +9 -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 +521 -13
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/utils/environment_detector.rb +16 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +512 -60
- data/lib/clacky/web/app.js +30 -7
- 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/core/aside.js +112 -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} +134 -242
- 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 +303 -0
- data/lib/clacky/web/features/skills/view.js +550 -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 +60 -6
- data/lib/clacky/web/index.html +117 -57
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +121 -25
- data/lib/clacky/web/skills.js +3 -821
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky.rb +1 -0
- metadata +45 -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 -365
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -212
- /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/{onboard.js → components/onboard.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"
|
|
@@ -333,6 +334,14 @@ module Clacky
|
|
|
333
334
|
server.mount("/api", servlet_class)
|
|
334
335
|
server.mount("/ws", servlet_class)
|
|
335
336
|
|
|
337
|
+
# Health check endpoint — no auth, minimal overhead.
|
|
338
|
+
# Docker / orchestrators can probe this to decide container health.
|
|
339
|
+
server.mount_proc("/health") do |_req, res|
|
|
340
|
+
res.status = 200
|
|
341
|
+
res["Content-Type"] = "application/json"
|
|
342
|
+
res.body = '{"status":"ok"}'
|
|
343
|
+
end
|
|
344
|
+
|
|
336
345
|
# Mount static file handler for the entire web directory.
|
|
337
346
|
# Use mount_proc so we can inject no-cache headers on every response,
|
|
338
347
|
# preventing stale JS/CSS from being served after a gem update.
|
|
@@ -347,12 +356,21 @@ module Clacky
|
|
|
347
356
|
server.mount_proc("/") do |req, res|
|
|
348
357
|
if req.path == "/" || req.path == "/index.html"
|
|
349
358
|
product_name = Clacky::BrandConfig.load.product_name || "OpenClacky"
|
|
350
|
-
|
|
359
|
+
pure = req.query["pure"] == "true"
|
|
360
|
+
html = File.read(index_html_path)
|
|
361
|
+
.gsub("{{BRAND_NAME}}", product_name)
|
|
362
|
+
.gsub("{{EXT_SCRIPTS}}", pure ? "" : self.send(:webui_ext_script_tags))
|
|
351
363
|
res.status = 200
|
|
352
364
|
res["Content-Type"] = "text/html; charset=utf-8"
|
|
353
365
|
res["Cache-Control"] = "no-store"
|
|
354
366
|
res["Pragma"] = "no-cache"
|
|
355
367
|
res.body = html
|
|
368
|
+
elsif req.path.start_with?("/webui_ext/")
|
|
369
|
+
self.send(:serve_webui_ext, req, res)
|
|
370
|
+
elsif req.path.start_with?("/agent_ui/")
|
|
371
|
+
self.send(:serve_agent_ui, req, res)
|
|
372
|
+
elsif req.path.start_with?("/panel_ui/")
|
|
373
|
+
self.send(:serve_panel_ui, req, res)
|
|
356
374
|
else
|
|
357
375
|
file_handler.service(req, res)
|
|
358
376
|
res["Cache-Control"] = "no-store"
|
|
@@ -465,6 +483,8 @@ module Clacky
|
|
|
465
483
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
466
484
|
when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
|
|
467
485
|
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)
|
|
468
488
|
when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
|
|
469
489
|
when ["PATCH", "/api/config/ocr"] then api_update_ocr_config(req, res)
|
|
470
490
|
when ["POST", "/api/config/ocr/test"] then api_test_ocr_config(req, res)
|
|
@@ -565,6 +585,27 @@ module Clacky
|
|
|
565
585
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
566
586
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
567
587
|
api_session_skills(session_id, res)
|
|
588
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
|
|
589
|
+
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
590
|
+
action = path[%r{/git/([a-z]+)$}, 1]
|
|
591
|
+
api_session_git(session_id, action, req, res)
|
|
592
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
|
|
593
|
+
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
594
|
+
api_session_git_commit(session_id, req, res)
|
|
595
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
|
|
596
|
+
session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
|
|
597
|
+
api_session_time_machine(session_id, res)
|
|
598
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/time_machine/switch$})
|
|
599
|
+
session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
|
|
600
|
+
api_session_time_machine_switch(session_id, req, res)
|
|
601
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/diff$})
|
|
602
|
+
session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
|
|
603
|
+
task_id = path[%r{/time_machine/(\d+)/diff$}, 1].to_i
|
|
604
|
+
api_session_time_machine_diff(session_id, task_id, req, res)
|
|
605
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine/\d+/restore_preview$})
|
|
606
|
+
session_id = path[%r{^/api/sessions/([^/]+)/time_machine/}, 1]
|
|
607
|
+
task_id = path[%r{/time_machine/(\d+)/restore_preview$}, 1].to_i
|
|
608
|
+
api_session_time_machine_restore_preview(session_id, task_id, res)
|
|
568
609
|
elsif method == "GET" && path == "/api/dirs"
|
|
569
610
|
api_browse_dirs(req, res)
|
|
570
611
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
|
|
@@ -576,6 +617,9 @@ module Clacky
|
|
|
576
617
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/messages$})
|
|
577
618
|
session_id = path.sub("/api/sessions/", "").sub("/messages", "")
|
|
578
619
|
api_session_messages(session_id, req, res)
|
|
620
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+$})
|
|
621
|
+
session_id = path.sub("/api/sessions/", "")
|
|
622
|
+
api_get_session(session_id, res)
|
|
579
623
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+$})
|
|
580
624
|
session_id = path.sub("/api/sessions/", "")
|
|
581
625
|
api_rename_session(session_id, req, res)
|
|
@@ -630,6 +674,9 @@ module Clacky
|
|
|
630
674
|
elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
|
|
631
675
|
name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
|
|
632
676
|
api_delete_skill(name, res)
|
|
677
|
+
elsif method == "DELETE" && path.match?(%r{^/api/brand/skills/[^/]+$})
|
|
678
|
+
slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", ""))
|
|
679
|
+
api_delete_brand_skill(slug, res)
|
|
633
680
|
elsif method == "POST" && path.match?(%r{^/api/brand/skills/[^/]+/install$})
|
|
634
681
|
slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", "").sub("/install", ""))
|
|
635
682
|
api_brand_skill_install(slug, req, res)
|
|
@@ -680,6 +727,16 @@ module Clacky
|
|
|
680
727
|
json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
|
|
681
728
|
end
|
|
682
729
|
|
|
730
|
+
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
731
|
+
# Used by the frontend Router when navigating to a session that isn't in
|
|
732
|
+
# the paged sidebar list (search results, URL deep links, share links,
|
|
733
|
+
# browser back/forward, external notifications, etc.).
|
|
734
|
+
def api_get_session(session_id, res)
|
|
735
|
+
row = @registry.snapshot(session_id)
|
|
736
|
+
return json_response(res, 404, { error: "Session not found" }) unless row
|
|
737
|
+
json_response(res, 200, { session: row })
|
|
738
|
+
end
|
|
739
|
+
|
|
683
740
|
def api_create_session(req, res)
|
|
684
741
|
body = parse_json_body(req)
|
|
685
742
|
name = body["name"]
|
|
@@ -946,9 +1003,195 @@ module Clacky
|
|
|
946
1003
|
@agent_config.save
|
|
947
1004
|
end
|
|
948
1005
|
|
|
1006
|
+
# Absolute path to the user's WebUI extension directory.
|
|
1007
|
+
WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
|
|
1008
|
+
|
|
1009
|
+
# Build the full <script> payload injected at {{EXT_SCRIPTS}}:
|
|
1010
|
+
# 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
|
|
1011
|
+
# 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
|
|
1012
|
+
# 3. official panels — default_agents/_panels/<id>/**/*.js (data-panel)
|
|
1013
|
+
# Prefixed with a registerPanelAgents() call so the client knows which
|
|
1014
|
+
# agent profiles reference each official panel. Never raises.
|
|
1015
|
+
private def webui_ext_script_tags
|
|
1016
|
+
[
|
|
1017
|
+
panel_agents_script,
|
|
1018
|
+
global_ext_script_tags,
|
|
1019
|
+
agent_webui_script_tags,
|
|
1020
|
+
official_panel_script_tags,
|
|
1021
|
+
].reject(&:empty?).join("\n")
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# Inline script registering the panel→agents map (which agent profiles
|
|
1025
|
+
# reference each official panel, from their profile.yml `panels:`).
|
|
1026
|
+
private def panel_agents_script
|
|
1027
|
+
map = panel_agents_map
|
|
1028
|
+
return "" if map.empty?
|
|
1029
|
+
|
|
1030
|
+
"<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# { "git" => ["coding", ...], ... } — scan every agent profile.yml for a
|
|
1034
|
+
# `panels:` list and invert it into panel → referencing agents.
|
|
1035
|
+
private def panel_agents_map
|
|
1036
|
+
result = Hash.new { |h, k| h[k] = [] }
|
|
1037
|
+
agent_profile_dirs.each do |name, dir|
|
|
1038
|
+
yml = File.join(dir, "profile.yml")
|
|
1039
|
+
next unless File.file?(yml)
|
|
1040
|
+
|
|
1041
|
+
data = begin
|
|
1042
|
+
YAML.safe_load(File.read(yml)) || {}
|
|
1043
|
+
rescue StandardError
|
|
1044
|
+
{}
|
|
1045
|
+
end
|
|
1046
|
+
Array(data["panels"]).each { |panel| result[panel.to_s] << name unless result[panel.to_s].include?(name) }
|
|
1047
|
+
end
|
|
1048
|
+
result
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
# { agent_name => resolved_dir } across built-in and user agent dirs,
|
|
1052
|
+
# user dir winning on name collision (matches AgentProfile lookup order).
|
|
1053
|
+
private def agent_profile_dirs
|
|
1054
|
+
dirs = {}
|
|
1055
|
+
[Clacky::AgentProfile::DEFAULT_AGENTS_DIR, Clacky::AgentProfile::USER_AGENTS_DIR].each do |root|
|
|
1056
|
+
next unless Dir.exist?(root)
|
|
1057
|
+
|
|
1058
|
+
Dir.children(root).each do |name|
|
|
1059
|
+
next if name.start_with?("_") # _panels and other non-agent dirs
|
|
1060
|
+
full = File.join(root, name)
|
|
1061
|
+
next unless File.directory?(full) && File.file?(File.join(full, "profile.yml"))
|
|
1062
|
+
|
|
1063
|
+
dirs[name] = full
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
dirs
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
# Global extensions — visible for all agents (unchanged legacy behavior).
|
|
1070
|
+
private def global_ext_script_tags
|
|
1071
|
+
ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# Agent-scoped UI: agents/<name>/webui/**/*.js. Each script is tagged with
|
|
1075
|
+
# its owning agent so the client only mounts it for that profile.
|
|
1076
|
+
private def agent_webui_script_tags
|
|
1077
|
+
agent_profile_dirs.filter_map do |name, dir|
|
|
1078
|
+
webui = File.join(dir, "webui")
|
|
1079
|
+
next unless Dir.exist?(webui)
|
|
1080
|
+
|
|
1081
|
+
ext_script_block(webui, "/agent_ui/#{name}", id_prefix: "agent/#{name}/", agents: [name])
|
|
1082
|
+
end.reject(&:empty?).join("\n")
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# Official panels: default_agents/_panels/<id>/**/*.js. Scope comes from
|
|
1086
|
+
# the panel→agents map (registerPanelAgents), so a panel only mounts for
|
|
1087
|
+
# agents whose profile.yml references it.
|
|
1088
|
+
private def official_panel_script_tags
|
|
1089
|
+
root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels")
|
|
1090
|
+
return "" unless Dir.exist?(root)
|
|
1091
|
+
|
|
1092
|
+
Dir.children(root).sort.filter_map do |panel|
|
|
1093
|
+
dir = File.join(root, panel)
|
|
1094
|
+
next unless File.directory?(dir)
|
|
1095
|
+
|
|
1096
|
+
ext_script_block(dir, "/panel_ui/#{panel}", id_prefix: "panel/#{panel}/", panel: panel)
|
|
1097
|
+
end.reject(&:empty?).join("\n")
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
# Emit begin/script/end triples for every *.js under `root`, served from
|
|
1101
|
+
# `url_base`. `agents`/`panel` carry agent-scoping to _extBegin so the
|
|
1102
|
+
# client can decide visibility. Returns "" when the dir is absent.
|
|
1103
|
+
private def ext_script_block(root, url_base, id_prefix: "", agents: nil, panel: nil)
|
|
1104
|
+
return "" unless Dir.exist?(root)
|
|
1105
|
+
|
|
1106
|
+
Dir.glob(File.join(root, "**", "*.js")).sort.map do |abs|
|
|
1107
|
+
rel = abs.delete_prefix(root + "/")
|
|
1108
|
+
ext_id = id_prefix + rel.delete_suffix(".js")
|
|
1109
|
+
src = "#{url_base}/#{rel}"
|
|
1110
|
+
# Bracket the extension's own <script> with begin/end markers so that
|
|
1111
|
+
# registrations made during its synchronous evaluation are attributed
|
|
1112
|
+
# to it (for crash attribution / disable). Synchronous src scripts run
|
|
1113
|
+
# in document order, so the surrounding inline scripts run immediately
|
|
1114
|
+
# before and after it.
|
|
1115
|
+
"<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{agents.to_json}, #{panel.to_json})</script>" \
|
|
1116
|
+
"<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
|
|
1117
|
+
"<script>Clacky.ext._extEnd()</script>"
|
|
1118
|
+
end.join("\n")
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
# Serve a static file from ~/.clacky/webui_ext/. Read-only, JS/CSS/HTML only,
|
|
1122
|
+
# with strict path containment so a crafted path cannot escape the dir.
|
|
1123
|
+
private def serve_webui_ext(req, res)
|
|
1124
|
+
rel = req.path.delete_prefix("/webui_ext/")
|
|
1125
|
+
abs = File.expand_path(File.join(WEBUI_EXT_ROOT, rel))
|
|
1126
|
+
|
|
1127
|
+
unless abs.start_with?(WEBUI_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
|
|
1128
|
+
res.status = 404
|
|
1129
|
+
res.body = "not found"
|
|
1130
|
+
return
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
ext = File.extname(abs)
|
|
1134
|
+
ctype = { ".js" => "application/javascript", ".css" => "text/css",
|
|
1135
|
+
".html" => "text/html; charset=utf-8" }[ext]
|
|
1136
|
+
unless ctype
|
|
1137
|
+
res.status = 415
|
|
1138
|
+
res.body = "unsupported media type"
|
|
1139
|
+
return
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
res.status = 200
|
|
1143
|
+
res["Content-Type"] = ctype
|
|
1144
|
+
res["Cache-Control"] = "no-store"
|
|
1145
|
+
res["Pragma"] = "no-cache"
|
|
1146
|
+
res.body = File.read(abs)
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# Serve agents/<name>/webui/<file> from built-in or user agent dir.
|
|
1150
|
+
# Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
|
|
1151
|
+
private def serve_agent_ui(req, res)
|
|
1152
|
+
rest = req.path.delete_prefix("/agent_ui/")
|
|
1153
|
+
name, _, rel = rest.partition("/")
|
|
1154
|
+
dir = agent_profile_dirs[name]
|
|
1155
|
+
return (res.status = 404; res.body = "not found") unless dir && !rel.empty?
|
|
949
1156
|
|
|
950
|
-
|
|
951
|
-
|
|
1157
|
+
serve_static_under(File.join(dir, "webui"), rel, res)
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
# Serve official panel assets: /panel_ui/<panel>/<rel>.
|
|
1161
|
+
private def serve_panel_ui(req, res)
|
|
1162
|
+
rest = req.path.delete_prefix("/panel_ui/")
|
|
1163
|
+
panel, _, rel = rest.partition("/")
|
|
1164
|
+
return (res.status = 404; res.body = "not found") if panel.empty? || rel.empty?
|
|
1165
|
+
|
|
1166
|
+
root = File.join(Clacky::AgentProfile::DEFAULT_AGENTS_DIR, "_panels", panel)
|
|
1167
|
+
serve_static_under(root, rel, res)
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
|
|
1171
|
+
# strict path containment so a crafted rel cannot escape `root`.
|
|
1172
|
+
private def serve_static_under(root, rel, res)
|
|
1173
|
+
root = File.expand_path(root)
|
|
1174
|
+
abs = File.expand_path(File.join(root, rel))
|
|
1175
|
+
unless abs.start_with?(root + File::SEPARATOR) && File.file?(abs)
|
|
1176
|
+
res.status = 404
|
|
1177
|
+
res.body = "not found"
|
|
1178
|
+
return
|
|
1179
|
+
end
|
|
1180
|
+
|
|
1181
|
+
ctype = { ".js" => "application/javascript", ".css" => "text/css",
|
|
1182
|
+
".html" => "text/html; charset=utf-8" }[File.extname(abs)]
|
|
1183
|
+
unless ctype
|
|
1184
|
+
res.status = 415
|
|
1185
|
+
res.body = "unsupported media type"
|
|
1186
|
+
return
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
res.status = 200
|
|
1190
|
+
res["Content-Type"] = ctype
|
|
1191
|
+
res["Cache-Control"] = "no-store"
|
|
1192
|
+
res["Pragma"] = "no-cache"
|
|
1193
|
+
res.body = File.read(abs)
|
|
1194
|
+
end
|
|
952
1195
|
def api_browser_status(res)
|
|
953
1196
|
json_response(res, 200, @browser_manager.status)
|
|
954
1197
|
end
|
|
@@ -1057,13 +1300,18 @@ module Clacky
|
|
|
1057
1300
|
|
|
1058
1301
|
aspect_ratio = body["aspect_ratio"].to_s
|
|
1059
1302
|
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
1060
|
-
output_dir =
|
|
1061
|
-
|
|
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
|
+
)
|
|
1062
1308
|
|
|
1063
1309
|
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
1064
1310
|
prompt: prompt,
|
|
1065
1311
|
aspect_ratio: aspect_ratio,
|
|
1066
|
-
output_dir: output_dir
|
|
1312
|
+
output_dir: output_dir,
|
|
1313
|
+
image: body["image"],
|
|
1314
|
+
images: body["images"]
|
|
1067
1315
|
)
|
|
1068
1316
|
if result["success"]
|
|
1069
1317
|
log_media_usage(result, prompt: prompt)
|
|
@@ -1087,8 +1335,11 @@ module Clacky
|
|
|
1087
1335
|
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
1088
1336
|
duration = body["duration_seconds"]
|
|
1089
1337
|
image = body["image"]
|
|
1090
|
-
output_dir =
|
|
1091
|
-
|
|
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
|
+
)
|
|
1092
1343
|
|
|
1093
1344
|
result = Clacky::Media::Generator.new(@agent_config).generate_video(
|
|
1094
1345
|
prompt: prompt,
|
|
@@ -1116,8 +1367,11 @@ module Clacky
|
|
|
1116
1367
|
end
|
|
1117
1368
|
|
|
1118
1369
|
voice = body["voice"]
|
|
1119
|
-
output_dir =
|
|
1120
|
-
|
|
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
|
+
)
|
|
1121
1375
|
|
|
1122
1376
|
result = Clacky::Media::Generator.new(@agent_config).generate_speech(
|
|
1123
1377
|
input: input,
|
|
@@ -1942,6 +2196,23 @@ module Clacky
|
|
|
1942
2196
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1943
2197
|
end
|
|
1944
2198
|
|
|
2199
|
+
# DELETE /api/brand/skills/:slug
|
|
2200
|
+
# Uninstalls a brand skill by removing its files and metadata.
|
|
2201
|
+
def api_delete_brand_skill(slug, res)
|
|
2202
|
+
brand = Clacky::BrandConfig.load
|
|
2203
|
+
installed = brand.installed_brand_skills
|
|
2204
|
+
unless installed.key?(slug)
|
|
2205
|
+
json_response(res, 404, { ok: false, error: "Brand skill '#{slug}' is not installed" })
|
|
2206
|
+
return
|
|
2207
|
+
end
|
|
2208
|
+
|
|
2209
|
+
brand.delete_brand_skill!(slug)
|
|
2210
|
+
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
|
|
2211
|
+
json_response(res, 200, { ok: true })
|
|
2212
|
+
rescue StandardError => e
|
|
2213
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2214
|
+
end
|
|
2215
|
+
|
|
1945
2216
|
# GET /api/brand
|
|
1946
2217
|
# Returns brand metadata consumed by the WebUI on boot
|
|
1947
2218
|
# to dynamically replace branding strings.
|
|
@@ -2895,10 +3166,13 @@ module Clacky
|
|
|
2895
3166
|
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
2896
3167
|
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
2897
3168
|
json_response(res, 200, { ok: true })
|
|
3169
|
+
when "reveal"
|
|
3170
|
+
Utils::EnvironmentDetector.reveal_file(linux_path)
|
|
3171
|
+
json_response(res, 200, { ok: true })
|
|
2898
3172
|
when "download"
|
|
2899
3173
|
serve_file_download(res, linux_path)
|
|
2900
3174
|
else
|
|
2901
|
-
json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
|
|
3175
|
+
json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal' or 'download'" })
|
|
2902
3176
|
end
|
|
2903
3177
|
rescue => e
|
|
2904
3178
|
json_response(res, 500, { ok: false, error: e.message })
|
|
@@ -3346,7 +3620,147 @@ module Clacky
|
|
|
3346
3620
|
json_response(res, 200, { skills: skill_data })
|
|
3347
3621
|
end
|
|
3348
3622
|
|
|
3349
|
-
# GET /api/sessions/:id/
|
|
3623
|
+
# GET /api/sessions/:id/git/<action> — read-only git info for the session's
|
|
3624
|
+
# working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
|
|
3625
|
+
# log accepts ?limit=.
|
|
3626
|
+
def api_session_git(session_id, action, req, res)
|
|
3627
|
+
dir = git_session_dir(session_id, res)
|
|
3628
|
+
return unless dir
|
|
3629
|
+
|
|
3630
|
+
unless Clacky::Server::GitPanel.repo?(dir)
|
|
3631
|
+
return json_response(res, 200, { repo: false })
|
|
3632
|
+
end
|
|
3633
|
+
|
|
3634
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
3635
|
+
case action
|
|
3636
|
+
when "status"
|
|
3637
|
+
json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
|
|
3638
|
+
when "diff"
|
|
3639
|
+
json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(dir, file: query["file"]) })
|
|
3640
|
+
when "log"
|
|
3641
|
+
json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(dir, limit: query["limit"] || 50) })
|
|
3642
|
+
when "branches"
|
|
3643
|
+
json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
|
|
3644
|
+
else
|
|
3645
|
+
json_response(res, 404, { error: "Unknown git action" })
|
|
3646
|
+
end
|
|
3647
|
+
end
|
|
3648
|
+
|
|
3649
|
+
# POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
|
|
3650
|
+
def api_session_git_commit(session_id, req, res)
|
|
3651
|
+
dir = git_session_dir(session_id, res)
|
|
3652
|
+
return unless dir
|
|
3653
|
+
|
|
3654
|
+
unless Clacky::Server::GitPanel.repo?(dir)
|
|
3655
|
+
return json_response(res, 400, { error: "Not a git repository" })
|
|
3656
|
+
end
|
|
3657
|
+
|
|
3658
|
+
body = parse_json_body(req)
|
|
3659
|
+
result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
|
|
3660
|
+
if result[:ok]
|
|
3661
|
+
json_response(res, 200, result)
|
|
3662
|
+
else
|
|
3663
|
+
json_response(res, 422, { error: result[:error] })
|
|
3664
|
+
end
|
|
3665
|
+
end
|
|
3666
|
+
|
|
3667
|
+
# GET /api/sessions/:id/time_machine — task history for the Time Machine
|
|
3668
|
+
# panel. Mirrors the CLI menu: each entry carries id, summary, status
|
|
3669
|
+
# (current/past/undone) and whether it branches.
|
|
3670
|
+
def api_session_time_machine(session_id, res)
|
|
3671
|
+
agent = time_machine_agent(session_id, res)
|
|
3672
|
+
return unless agent
|
|
3673
|
+
|
|
3674
|
+
history = agent.get_task_history(limit: 20)
|
|
3675
|
+
json_response(res, 200, { tasks: history })
|
|
3676
|
+
end
|
|
3677
|
+
|
|
3678
|
+
# POST /api/sessions/:id/time_machine/switch — body: { task_id: }.
|
|
3679
|
+
# Restores the working tree to the end-of-task state of task_id.
|
|
3680
|
+
def api_session_time_machine_switch(session_id, req, res)
|
|
3681
|
+
agent = time_machine_agent(session_id, res)
|
|
3682
|
+
return unless agent
|
|
3683
|
+
|
|
3684
|
+
body = parse_json_body(req)
|
|
3685
|
+
task_id = body["task_id"].to_i
|
|
3686
|
+
result = agent.switch_to_task(task_id)
|
|
3687
|
+
if result[:success]
|
|
3688
|
+
@session_manager.save(agent.to_session_data(status: :success))
|
|
3689
|
+
broadcast_session_update(session_id)
|
|
3690
|
+
json_response(res, 200, { ok: true, message: result[:message], task_id: result[:task_id] })
|
|
3691
|
+
else
|
|
3692
|
+
json_response(res, 422, { ok: false, error: result[:message] })
|
|
3693
|
+
end
|
|
3694
|
+
end
|
|
3695
|
+
|
|
3696
|
+
# GET /api/sessions/:id/time_machine/:task_id/diff
|
|
3697
|
+
# Without ?path: returns the file list this task touched.
|
|
3698
|
+
# With ?path=<rel>: returns the unified diff of that file.
|
|
3699
|
+
def api_session_time_machine_diff(session_id, task_id, req, res)
|
|
3700
|
+
agent = time_machine_agent(session_id, res)
|
|
3701
|
+
return unless agent
|
|
3702
|
+
|
|
3703
|
+
rel = req.query["path"].to_s
|
|
3704
|
+
if rel.empty?
|
|
3705
|
+
json_response(res, 200, { ok: true, task_id: task_id, files: agent.task_diff_files(task_id) })
|
|
3706
|
+
else
|
|
3707
|
+
diff = agent.task_file_diff(task_id, rel)
|
|
3708
|
+
if diff.nil?
|
|
3709
|
+
json_response(res, 404, { ok: false, error: "No diff for #{rel}" })
|
|
3710
|
+
else
|
|
3711
|
+
json_response(res, 200, { ok: true, task_id: task_id }.merge(diff))
|
|
3712
|
+
end
|
|
3713
|
+
end
|
|
3714
|
+
end
|
|
3715
|
+
|
|
3716
|
+
# GET /api/sessions/:id/time_machine/:task_id/restore_preview
|
|
3717
|
+
# Returns the file-level effect of switching back to this task without
|
|
3718
|
+
# actually performing the switch. Lets the UI render an honest
|
|
3719
|
+
# confirmation listing the files that would be overwritten/created/deleted.
|
|
3720
|
+
def api_session_time_machine_restore_preview(session_id, task_id, res)
|
|
3721
|
+
agent = time_machine_agent(session_id, res)
|
|
3722
|
+
return unless agent
|
|
3723
|
+
|
|
3724
|
+
changes = agent.preview_restore_to_task(task_id)
|
|
3725
|
+
json_response(res, 200, { ok: true, task_id: task_id, changes: changes })
|
|
3726
|
+
end
|
|
3727
|
+
|
|
3728
|
+
# Resolve a session's agent for time-machine ops; writes the error
|
|
3729
|
+
# response and returns nil on failure.
|
|
3730
|
+
private def time_machine_agent(session_id, res)
|
|
3731
|
+
unless @registry.ensure(session_id)
|
|
3732
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3733
|
+
return nil
|
|
3734
|
+
end
|
|
3735
|
+
session = @registry.get(session_id)
|
|
3736
|
+
agent = session && session[:agent]
|
|
3737
|
+
unless agent
|
|
3738
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3739
|
+
return nil
|
|
3740
|
+
end
|
|
3741
|
+
agent
|
|
3742
|
+
end
|
|
3743
|
+
|
|
3744
|
+
# Resolve a session's working_dir for git ops; writes the error response
|
|
3745
|
+
# and returns nil on any failure.
|
|
3746
|
+
private def git_session_dir(session_id, res)
|
|
3747
|
+
unless @registry.ensure(session_id)
|
|
3748
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3749
|
+
return nil
|
|
3750
|
+
end
|
|
3751
|
+
session = @registry.get(session_id)
|
|
3752
|
+
agent = session && session[:agent]
|
|
3753
|
+
unless agent
|
|
3754
|
+
json_response(res, 404, { error: "Session not found" })
|
|
3755
|
+
return nil
|
|
3756
|
+
end
|
|
3757
|
+
dir = File.expand_path(agent.working_dir.to_s)
|
|
3758
|
+
unless Dir.exist?(dir)
|
|
3759
|
+
json_response(res, 404, { error: "Working directory not found" })
|
|
3760
|
+
return nil
|
|
3761
|
+
end
|
|
3762
|
+
dir
|
|
3763
|
+
end
|
|
3350
3764
|
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
3351
3765
|
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
3352
3766
|
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
@@ -4324,7 +4738,83 @@ module Clacky
|
|
|
4324
4738
|
json_response(res, 422, { error: e.message })
|
|
4325
4739
|
end
|
|
4326
4740
|
|
|
4327
|
-
#
|
|
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
|
|
4328
4818
|
# DEPRECATED: this endpoint previously accepted the entire models array
|
|
4329
4819
|
# and replaced @models in place. That design was fragile — any missing
|
|
4330
4820
|
# or stale field on ANY row could wipe other rows' api_keys. It has
|
|
@@ -5062,6 +5552,10 @@ module Clacky
|
|
|
5062
5552
|
conn.send_json(type: "error", message: "Session not found: #{session_id}")
|
|
5063
5553
|
end
|
|
5064
5554
|
|
|
5555
|
+
when "edit_message"
|
|
5556
|
+
session_id = msg["session_id"] || conn.session_id
|
|
5557
|
+
handle_edit_message(session_id, msg["content"].to_s, msg["created_at"].to_s)
|
|
5558
|
+
|
|
5065
5559
|
when "message"
|
|
5066
5560
|
session_id = msg["session_id"] || conn.session_id
|
|
5067
5561
|
# Merge legacy images array into files as { data_url:, name:, mime_type: } entries
|
|
@@ -5112,6 +5606,20 @@ module Clacky
|
|
|
5112
5606
|
|
|
5113
5607
|
# ── Session actions ───────────────────────────────────────────────────────
|
|
5114
5608
|
|
|
5609
|
+
def handle_edit_message(session_id, content, created_at)
|
|
5610
|
+
return unless @registry.exist?(session_id)
|
|
5611
|
+
|
|
5612
|
+
agent = nil
|
|
5613
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
5614
|
+
return unless agent
|
|
5615
|
+
|
|
5616
|
+
if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
|
|
5617
|
+
agent.history.truncate_from_created_at(created_at)
|
|
5618
|
+
end
|
|
5619
|
+
|
|
5620
|
+
handle_user_message(session_id, content)
|
|
5621
|
+
end
|
|
5622
|
+
|
|
5115
5623
|
def handle_user_message(session_id, content, files = [])
|
|
5116
5624
|
return unless @registry.exist?(session_id)
|
|
5117
5625
|
|