openclacky 1.3.2 → 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 +28 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +49 -5
- 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/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 +497 -12
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +473 -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} +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 +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 +56 -6
- data/lib/clacky/web/index.html +117 -58
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +118 -22
- data/lib/clacky/web/skills.js +3 -863
- 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 -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/{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)
|
|
@@ -683,6 +727,16 @@ module Clacky
|
|
|
683
727
|
json_response(res, 200, { sessions: sessions, has_more: has_more, cron_count: @registry.cron_count })
|
|
684
728
|
end
|
|
685
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
|
+
|
|
686
740
|
def api_create_session(req, res)
|
|
687
741
|
body = parse_json_body(req)
|
|
688
742
|
name = body["name"]
|
|
@@ -949,9 +1003,195 @@ module Clacky
|
|
|
949
1003
|
@agent_config.save
|
|
950
1004
|
end
|
|
951
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
|
|
952
1068
|
|
|
953
|
-
#
|
|
954
|
-
|
|
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?
|
|
1156
|
+
|
|
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
|
|
955
1195
|
def api_browser_status(res)
|
|
956
1196
|
json_response(res, 200, @browser_manager.status)
|
|
957
1197
|
end
|
|
@@ -1060,13 +1300,18 @@ module Clacky
|
|
|
1060
1300
|
|
|
1061
1301
|
aspect_ratio = body["aspect_ratio"].to_s
|
|
1062
1302
|
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
1063
|
-
output_dir =
|
|
1064
|
-
|
|
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
|
+
)
|
|
1065
1308
|
|
|
1066
1309
|
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
1067
1310
|
prompt: prompt,
|
|
1068
1311
|
aspect_ratio: aspect_ratio,
|
|
1069
|
-
output_dir: output_dir
|
|
1312
|
+
output_dir: output_dir,
|
|
1313
|
+
image: body["image"],
|
|
1314
|
+
images: body["images"]
|
|
1070
1315
|
)
|
|
1071
1316
|
if result["success"]
|
|
1072
1317
|
log_media_usage(result, prompt: prompt)
|
|
@@ -1090,8 +1335,11 @@ module Clacky
|
|
|
1090
1335
|
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
1091
1336
|
duration = body["duration_seconds"]
|
|
1092
1337
|
image = body["image"]
|
|
1093
|
-
output_dir =
|
|
1094
|
-
|
|
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
|
+
)
|
|
1095
1343
|
|
|
1096
1344
|
result = Clacky::Media::Generator.new(@agent_config).generate_video(
|
|
1097
1345
|
prompt: prompt,
|
|
@@ -1119,8 +1367,11 @@ module Clacky
|
|
|
1119
1367
|
end
|
|
1120
1368
|
|
|
1121
1369
|
voice = body["voice"]
|
|
1122
|
-
output_dir =
|
|
1123
|
-
|
|
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
|
+
)
|
|
1124
1375
|
|
|
1125
1376
|
result = Clacky::Media::Generator.new(@agent_config).generate_speech(
|
|
1126
1377
|
input: input,
|
|
@@ -3369,7 +3620,147 @@ module Clacky
|
|
|
3369
3620
|
json_response(res, 200, { skills: skill_data })
|
|
3370
3621
|
end
|
|
3371
3622
|
|
|
3372
|
-
# 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
|
|
3373
3764
|
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
3374
3765
|
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
3375
3766
|
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
@@ -4347,7 +4738,83 @@ module Clacky
|
|
|
4347
4738
|
json_response(res, 422, { error: e.message })
|
|
4348
4739
|
end
|
|
4349
4740
|
|
|
4350
|
-
#
|
|
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
|
|
4351
4818
|
# DEPRECATED: this endpoint previously accepted the entire models array
|
|
4352
4819
|
# and replaced @models in place. That design was fragile — any missing
|
|
4353
4820
|
# or stale field on ANY row could wipe other rows' api_keys. It has
|
|
@@ -5085,6 +5552,10 @@ module Clacky
|
|
|
5085
5552
|
conn.send_json(type: "error", message: "Session not found: #{session_id}")
|
|
5086
5553
|
end
|
|
5087
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
|
+
|
|
5088
5559
|
when "message"
|
|
5089
5560
|
session_id = msg["session_id"] || conn.session_id
|
|
5090
5561
|
# Merge legacy images array into files as { data_url:, name:, mime_type: } entries
|
|
@@ -5135,6 +5606,20 @@ module Clacky
|
|
|
5135
5606
|
|
|
5136
5607
|
# ── Session actions ───────────────────────────────────────────────────────
|
|
5137
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
|
+
|
|
5138
5623
|
def handle_user_message(session_id, content, files = [])
|
|
5139
5624
|
return unless @registry.exist?(session_id)
|
|
5140
5625
|
|
|
@@ -36,6 +36,7 @@ module Clacky
|
|
|
36
36
|
@worker_pid = nil
|
|
37
37
|
@restart_requested = false
|
|
38
38
|
@shutdown_requested = false
|
|
39
|
+
@shutdown_signal = nil
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def run
|
|
@@ -68,9 +69,9 @@ module Clacky
|
|
|
68
69
|
|
|
69
70
|
# 3. Signal handlers
|
|
70
71
|
Signal.trap("USR1") { @restart_requested = true }
|
|
71
|
-
Signal.trap("TERM") { @shutdown_requested = true }
|
|
72
|
-
Signal.trap("INT") { @shutdown_requested = true }
|
|
73
|
-
Signal.trap("HUP") { @shutdown_requested = true }
|
|
72
|
+
Signal.trap("TERM") { @shutdown_signal = "TERM"; @shutdown_requested = true }
|
|
73
|
+
Signal.trap("INT") { @shutdown_signal = "INT"; @shutdown_requested = true }
|
|
74
|
+
Signal.trap("HUP") { @shutdown_signal = "HUP"; @shutdown_requested = true }
|
|
74
75
|
|
|
75
76
|
# 4. Spawn first worker
|
|
76
77
|
@worker_pid = spawn_worker
|
|
@@ -184,7 +185,8 @@ module Clacky
|
|
|
184
185
|
end
|
|
185
186
|
|
|
186
187
|
def shutdown
|
|
187
|
-
|
|
188
|
+
reason = @shutdown_signal ? "signal=SIG#{@shutdown_signal}" : "signal=none"
|
|
189
|
+
Clacky::Logger.info("[Master] Shutting down (worker PID=#{@worker_pid}) #{reason}...")
|
|
188
190
|
if @worker_pid
|
|
189
191
|
begin
|
|
190
192
|
# TERM the entire worker process group so grandchildren (node MCP, etc.)
|
data/lib/clacky/version.rb
CHANGED