openclacky 1.3.3 → 1.3.5
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 +53 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +5 -3
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +77 -11
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +343 -0
- data/lib/clacky/api_extension_loader.rb +168 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +47 -25
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/json_ui_controller.rb +1 -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/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- 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_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +291 -109
- data/lib/clacky/server/session_registry.rb +5 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +147 -31
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +201 -81
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +19 -16
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +36 -23
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +48 -13
- data/lib/clacky/web/index.html +24 -17
- data/lib/clacky/web/sessions.js +427 -79
- data/lib/clacky/web/settings.js +143 -113
- data/lib/clacky/web/ws-dispatcher.js +18 -6
- data/lib/clacky.rb +27 -5
- metadata +45 -2
- data/lib/clacky/media/output_dir.rb +0 -43
|
@@ -218,6 +218,7 @@ module Clacky
|
|
|
218
218
|
end
|
|
219
219
|
|
|
220
220
|
def start
|
|
221
|
+
@start_time = Time.now
|
|
221
222
|
# One-time migration: move legacy trash contents into file-trash/ subdirectory.
|
|
222
223
|
Clacky::TrashDirectory.migrate_legacy_if_needed
|
|
223
224
|
|
|
@@ -367,6 +368,8 @@ module Clacky
|
|
|
367
368
|
res.body = html
|
|
368
369
|
elsif req.path.start_with?("/webui_ext/")
|
|
369
370
|
self.send(:serve_webui_ext, req, res)
|
|
371
|
+
elsif req.path.start_with?("/builtin_ext/")
|
|
372
|
+
self.send(:serve_builtin_ext, req, res)
|
|
370
373
|
elsif req.path.start_with?("/agent_ui/")
|
|
371
374
|
self.send(:serve_agent_ui, req, res)
|
|
372
375
|
elsif req.path.start_with?("/panel_ui/")
|
|
@@ -381,6 +384,12 @@ module Clacky
|
|
|
381
384
|
# Auto-create a default session on startup
|
|
382
385
|
create_default_session
|
|
383
386
|
|
|
387
|
+
# Load user-defined HTTP API extensions from ~/.clacky/api_ext/.
|
|
388
|
+
# Done here (not at gem load) so handlers can resolve session_manager
|
|
389
|
+
# and other host helpers as soon as they are wired up.
|
|
390
|
+
# The loader logs its own summary via Clacky::Logger.
|
|
391
|
+
Clacky::ApiExtensionLoader.load_all
|
|
392
|
+
|
|
384
393
|
# Start the background scheduler
|
|
385
394
|
@scheduler.start
|
|
386
395
|
puts " Scheduler: #{@scheduler.schedules.size} task(s) loaded"
|
|
@@ -412,6 +421,8 @@ module Clacky
|
|
|
412
421
|
path = req.path
|
|
413
422
|
method = req.request_method
|
|
414
423
|
|
|
424
|
+
Thread.current[:lang] = req["X-Lang"].to_s.strip.then { |l| l.empty? ? nil : l }
|
|
425
|
+
|
|
415
426
|
# Access key guard (skip for WebSocket upgrades)
|
|
416
427
|
return unless check_access_key(req, res)
|
|
417
428
|
|
|
@@ -430,6 +441,11 @@ module Clacky
|
|
|
430
441
|
# generous 90s so retry + failover can complete without being cut short.
|
|
431
442
|
timeout_sec = if path.start_with?("/api/brand")
|
|
432
443
|
90
|
|
444
|
+
elsif path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
|
|
445
|
+
# api_ext dispatcher applies its own per-route timeout (capped at
|
|
446
|
+
# ApiExtension::MAX_TIMEOUT). Use the upper bound here so the outer
|
|
447
|
+
# guard never cuts a long-running custom handler short.
|
|
448
|
+
Clacky::ApiExtension::MAX_TIMEOUT + 30
|
|
433
449
|
elsif path == "/api/tool/browser"
|
|
434
450
|
30
|
|
435
451
|
elsif path == "/api/exchange-rate"
|
|
@@ -448,6 +464,10 @@ module Clacky
|
|
|
448
464
|
600
|
|
449
465
|
elsif path == "/api/media/audio/speech"
|
|
450
466
|
120
|
|
467
|
+
elsif path == "/api/media/audio/transcriptions"
|
|
468
|
+
30
|
|
469
|
+
elsif path == "/api/media/video/understand"
|
|
470
|
+
60
|
|
451
471
|
elsif path.start_with?("/api/backup/download") || path == "/api/backup/run"
|
|
452
472
|
# Building a tar.gz of ~/.clacky (with session history) can take a while.
|
|
453
473
|
120
|
|
@@ -469,6 +489,13 @@ module Clacky
|
|
|
469
489
|
path = req.path
|
|
470
490
|
method = req.request_method
|
|
471
491
|
|
|
492
|
+
# User-defined HTTP API extensions live under ~/.clacky/api_ext/<name>/
|
|
493
|
+
# and mount at /api/ext/<name>/... Routed through a separate dispatcher
|
|
494
|
+
# so the host's giant case table stays focused on built-in endpoints.
|
|
495
|
+
if path.start_with?(Clacky::Server::ApiExtensionDispatcher::MOUNT_PREFIX)
|
|
496
|
+
return if Clacky::Server::ApiExtensionDispatcher.handle(req, res, http_server: self)
|
|
497
|
+
end
|
|
498
|
+
|
|
472
499
|
case [method, path]
|
|
473
500
|
when ["GET", "/api/sessions"] then api_list_sessions(req, res)
|
|
474
501
|
when ["POST", "/api/sessions"] then api_create_session(req, res)
|
|
@@ -483,8 +510,6 @@ module Clacky
|
|
|
483
510
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
484
511
|
when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
|
|
485
512
|
when ["GET", "/api/config/media"] then api_get_media_config(res)
|
|
486
|
-
when ["GET", "/api/config/media-output-dir"] then api_get_media_output_dir(res)
|
|
487
|
-
when ["PATCH", "/api/config/media-output-dir"] then api_update_media_output_dir(req, res)
|
|
488
513
|
when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
|
|
489
514
|
when ["PATCH", "/api/config/ocr"] then api_update_ocr_config(req, res)
|
|
490
515
|
when ["POST", "/api/config/ocr/test"] then api_test_ocr_config(req, res)
|
|
@@ -530,6 +555,8 @@ module Clacky
|
|
|
530
555
|
when ["POST", "/api/media/image"] then api_media_image(req, res)
|
|
531
556
|
when ["POST", "/api/media/video"] then api_media_video(req, res)
|
|
532
557
|
when ["POST", "/api/media/audio/speech"] then api_media_audio_speech(req, res)
|
|
558
|
+
when ["POST", "/api/media/audio/transcriptions"] then api_media_audio_transcriptions(req, res)
|
|
559
|
+
when ["POST", "/api/media/video/understand"] then api_media_video_understand(req, res)
|
|
533
560
|
when ["GET", "/api/media/types"] then api_media_types(res)
|
|
534
561
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
535
562
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
@@ -608,6 +635,8 @@ module Clacky
|
|
|
608
635
|
api_session_time_machine_restore_preview(session_id, task_id, res)
|
|
609
636
|
elsif method == "GET" && path == "/api/dirs"
|
|
610
637
|
api_browse_dirs(req, res)
|
|
638
|
+
elsif method == "POST" && path == "/api/dirs/mkdir"
|
|
639
|
+
api_dirs_mkdir(req, res)
|
|
611
640
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
|
|
612
641
|
session_id = path.sub("/api/sessions/", "").sub("/files", "")
|
|
613
642
|
api_session_files(session_id, req, res)
|
|
@@ -656,7 +685,7 @@ module Clacky
|
|
|
656
685
|
elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
|
|
657
686
|
id = path.sub("/api/config/models/", "")
|
|
658
687
|
api_delete_model(id, res)
|
|
659
|
-
elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
|
|
688
|
+
elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio|stt|video_understanding)$})
|
|
660
689
|
kind = path.sub("/api/config/media/", "")
|
|
661
690
|
api_update_media_config(kind, req, res)
|
|
662
691
|
elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
|
|
@@ -671,6 +700,12 @@ module Clacky
|
|
|
671
700
|
elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
|
|
672
701
|
name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
|
|
673
702
|
api_toggle_skill(name, req, res)
|
|
703
|
+
elsif method == "GET" && path.match?(%r{^/api/skills/[^/]+/content$})
|
|
704
|
+
name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/content", ""))
|
|
705
|
+
api_skill_content_get(name, res)
|
|
706
|
+
elsif method == "PUT" && path.match?(%r{^/api/skills/[^/]+/content$})
|
|
707
|
+
name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/content", ""))
|
|
708
|
+
api_skill_content_update(name, req, res)
|
|
674
709
|
elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
|
|
675
710
|
name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
|
|
676
711
|
api_delete_skill(name, res)
|
|
@@ -1006,6 +1041,9 @@ module Clacky
|
|
|
1006
1041
|
# Absolute path to the user's WebUI extension directory.
|
|
1007
1042
|
WEBUI_EXT_ROOT = File.expand_path("~/.clacky/webui_ext")
|
|
1008
1043
|
|
|
1044
|
+
# Built-in extensions bundled with the gem (default_extensions/*/**.js).
|
|
1045
|
+
BUILTIN_EXT_ROOT = File.expand_path("../default_extensions", __dir__)
|
|
1046
|
+
|
|
1009
1047
|
# Build the full <script> payload injected at {{EXT_SCRIPTS}}:
|
|
1010
1048
|
# 1. global extensions — ~/.clacky/webui_ext/**/*.js (all agents)
|
|
1011
1049
|
# 2. agent-scoped UI — agents/<name>/webui/**/*.js (data-agent)
|
|
@@ -1015,6 +1053,7 @@ module Clacky
|
|
|
1015
1053
|
private def webui_ext_script_tags
|
|
1016
1054
|
[
|
|
1017
1055
|
panel_agents_script,
|
|
1056
|
+
builtin_ext_script_tags,
|
|
1018
1057
|
global_ext_script_tags,
|
|
1019
1058
|
agent_webui_script_tags,
|
|
1020
1059
|
official_panel_script_tags,
|
|
@@ -1066,6 +1105,21 @@ module Clacky
|
|
|
1066
1105
|
dirs
|
|
1067
1106
|
end
|
|
1068
1107
|
|
|
1108
|
+
# Built-in extensions from default_extensions/*/*.js — loaded before user
|
|
1109
|
+
# extensions so user ~/.clacky/webui_ext/ can override by ext id.
|
|
1110
|
+
private def builtin_ext_script_tags
|
|
1111
|
+
return "" unless Dir.exist?(BUILTIN_EXT_ROOT)
|
|
1112
|
+
|
|
1113
|
+
Dir.glob(File.join(BUILTIN_EXT_ROOT, "*", "**", "*.js")).sort.map do |abs|
|
|
1114
|
+
rel = abs.delete_prefix(BUILTIN_EXT_ROOT + "/")
|
|
1115
|
+
ext_id = "builtin/" + rel.delete_suffix(".js")
|
|
1116
|
+
src = "/builtin_ext/#{rel}"
|
|
1117
|
+
"<script>Clacky.ext._extBegin(#{ext_id.to_json}, #{nil.to_json}, #{nil.to_json})</script>" \
|
|
1118
|
+
"<script src=#{src.to_json} data-ext-id=#{ext_id.to_json}></script>" \
|
|
1119
|
+
"<script>Clacky.ext._extEnd()</script>"
|
|
1120
|
+
end.join("\n")
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1069
1123
|
# Global extensions — visible for all agents (unchanged legacy behavior).
|
|
1070
1124
|
private def global_ext_script_tags
|
|
1071
1125
|
ext_script_block(WEBUI_EXT_ROOT, "/webui_ext")
|
|
@@ -1146,6 +1200,31 @@ module Clacky
|
|
|
1146
1200
|
res.body = File.read(abs)
|
|
1147
1201
|
end
|
|
1148
1202
|
|
|
1203
|
+
private def serve_builtin_ext(req, res)
|
|
1204
|
+
rel = req.path.delete_prefix("/builtin_ext/")
|
|
1205
|
+
abs = File.expand_path(File.join(BUILTIN_EXT_ROOT, rel))
|
|
1206
|
+
|
|
1207
|
+
unless abs.start_with?(BUILTIN_EXT_ROOT + File::SEPARATOR) && File.file?(abs)
|
|
1208
|
+
res.status = 404
|
|
1209
|
+
res.body = "not found"
|
|
1210
|
+
return
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
ext = File.extname(abs)
|
|
1214
|
+
ctype = { ".js" => "application/javascript", ".css" => "text/css" }[ext]
|
|
1215
|
+
unless ctype
|
|
1216
|
+
res.status = 415
|
|
1217
|
+
res.body = "unsupported media type"
|
|
1218
|
+
return
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
res.status = 200
|
|
1222
|
+
res["Content-Type"] = ctype
|
|
1223
|
+
res["Cache-Control"] = "no-store"
|
|
1224
|
+
res["Pragma"] = "no-cache"
|
|
1225
|
+
res.body = File.read(abs)
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1149
1228
|
# Serve agents/<name>/webui/<file> from built-in or user agent dir.
|
|
1150
1229
|
# Path: /agent_ui/<name>/<rel>. User dir wins on name collision.
|
|
1151
1230
|
private def serve_agent_ui(req, res)
|
|
@@ -1300,11 +1379,8 @@ module Clacky
|
|
|
1300
1379
|
|
|
1301
1380
|
aspect_ratio = body["aspect_ratio"].to_s
|
|
1302
1381
|
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
1303
|
-
output_dir =
|
|
1304
|
-
|
|
1305
|
-
configured: @agent_config.media_output_dir,
|
|
1306
|
-
fallback: @agent_config.default_working_dir || Dir.pwd
|
|
1307
|
-
)
|
|
1382
|
+
output_dir = body["output_dir"].to_s
|
|
1383
|
+
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
1308
1384
|
|
|
1309
1385
|
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
1310
1386
|
prompt: prompt,
|
|
@@ -1335,11 +1411,8 @@ module Clacky
|
|
|
1335
1411
|
aspect_ratio = "landscape" if aspect_ratio.empty?
|
|
1336
1412
|
duration = body["duration_seconds"]
|
|
1337
1413
|
image = body["image"]
|
|
1338
|
-
output_dir =
|
|
1339
|
-
|
|
1340
|
-
configured: @agent_config.media_output_dir,
|
|
1341
|
-
fallback: @agent_config.default_working_dir || Dir.pwd
|
|
1342
|
-
)
|
|
1414
|
+
output_dir = body["output_dir"].to_s
|
|
1415
|
+
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
1343
1416
|
|
|
1344
1417
|
result = Clacky::Media::Generator.new(@agent_config).generate_video(
|
|
1345
1418
|
prompt: prompt,
|
|
@@ -1367,11 +1440,8 @@ module Clacky
|
|
|
1367
1440
|
end
|
|
1368
1441
|
|
|
1369
1442
|
voice = body["voice"]
|
|
1370
|
-
output_dir =
|
|
1371
|
-
|
|
1372
|
-
configured: @agent_config.media_output_dir,
|
|
1373
|
-
fallback: @agent_config.default_working_dir || Dir.pwd
|
|
1374
|
-
)
|
|
1443
|
+
output_dir = body["output_dir"].to_s
|
|
1444
|
+
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
1375
1445
|
|
|
1376
1446
|
result = Clacky::Media::Generator.new(@agent_config).generate_speech(
|
|
1377
1447
|
input: input,
|
|
@@ -1387,6 +1457,59 @@ module Clacky
|
|
|
1387
1457
|
json_response(res, 500, { error: e.message })
|
|
1388
1458
|
end
|
|
1389
1459
|
|
|
1460
|
+
def api_media_audio_transcriptions(req, res)
|
|
1461
|
+
body = parse_json_body(req)
|
|
1462
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
1463
|
+
|
|
1464
|
+
audio_b64 = body["audio_base64"].to_s
|
|
1465
|
+
if audio_b64.empty?
|
|
1466
|
+
return json_response(res, 422, { error: "audio_base64 is required" })
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1469
|
+
mime_type = body["mime_type"].to_s
|
|
1470
|
+
mime_type = "audio/webm" if mime_type.empty?
|
|
1471
|
+
|
|
1472
|
+
result = Clacky::Media::Generator.new(@agent_config).generate_transcription(
|
|
1473
|
+
audio_base64: audio_b64,
|
|
1474
|
+
mime_type: mime_type
|
|
1475
|
+
)
|
|
1476
|
+
if result["success"]
|
|
1477
|
+
Clacky::Logger.info("[Media] stt generated model=#{result["model"]} provider=#{result["provider"]} cost_usd=#{result["cost_usd"].to_f}")
|
|
1478
|
+
end
|
|
1479
|
+
status = result["success"] ? 200 : 422
|
|
1480
|
+
json_response(res, status, result)
|
|
1481
|
+
rescue StandardError => e
|
|
1482
|
+
json_response(res, 500, { error: e.message })
|
|
1483
|
+
end
|
|
1484
|
+
|
|
1485
|
+
def api_media_video_understand(req, res)
|
|
1486
|
+
body = parse_json_body(req)
|
|
1487
|
+
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
1488
|
+
|
|
1489
|
+
video_b64 = body["video_base64"].to_s
|
|
1490
|
+
if video_b64.empty?
|
|
1491
|
+
return json_response(res, 422, { error: "video_base64 is required" })
|
|
1492
|
+
end
|
|
1493
|
+
|
|
1494
|
+
mime_type = body["mime_type"].to_s
|
|
1495
|
+
mime_type = "image/png" if mime_type.empty?
|
|
1496
|
+
|
|
1497
|
+
prompt = body["prompt"].to_s
|
|
1498
|
+
|
|
1499
|
+
result = Clacky::Media::Generator.new(@agent_config).understand_video(
|
|
1500
|
+
video_base64: video_b64,
|
|
1501
|
+
mime_type: mime_type,
|
|
1502
|
+
prompt: prompt
|
|
1503
|
+
)
|
|
1504
|
+
if result["success"]
|
|
1505
|
+
Clacky::Logger.info("[Media] video_understanding generated model=#{result["model"]} provider=#{result["provider"]} cost_usd=#{result["cost_usd"].to_f}")
|
|
1506
|
+
end
|
|
1507
|
+
status = result["success"] ? 200 : 422
|
|
1508
|
+
json_response(res, status, result)
|
|
1509
|
+
rescue StandardError => e
|
|
1510
|
+
json_response(res, 500, { error: e.message })
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1390
1513
|
private def log_media_usage(result, prompt:)
|
|
1391
1514
|
usage = result["usage"]
|
|
1392
1515
|
cost = result["cost_usd"]
|
|
@@ -1949,6 +2072,7 @@ module Clacky
|
|
|
1949
2072
|
product_name: brand.product_name,
|
|
1950
2073
|
homepage_url: brand.homepage_url,
|
|
1951
2074
|
logo_url: brand.logo_url,
|
|
2075
|
+
theme_color: brand.theme_color,
|
|
1952
2076
|
test_mode: @brand_test,
|
|
1953
2077
|
distribution_refresh_pending: refresh_pending
|
|
1954
2078
|
})
|
|
@@ -2020,7 +2144,8 @@ module Clacky
|
|
|
2020
2144
|
ok: true,
|
|
2021
2145
|
product_name: result[:product_name] || brand.product_name,
|
|
2022
2146
|
user_id: result[:user_id] || brand.license_user_id,
|
|
2023
|
-
user_licensed: brand.user_licensed
|
|
2147
|
+
user_licensed: brand.user_licensed?,
|
|
2148
|
+
theme_color: brand.theme_color
|
|
2024
2149
|
})
|
|
2025
2150
|
else
|
|
2026
2151
|
json_response(res, 422, { ok: false, error: result[:message] })
|
|
@@ -2415,6 +2540,13 @@ module Clacky
|
|
|
2415
2540
|
# skills/curl talk to the server without an access key.
|
|
2416
2541
|
return true if loopback_ip?(ip)
|
|
2417
2542
|
|
|
2543
|
+
# Public API extension endpoints (declared via public_endpoint + meta.yml
|
|
2544
|
+
# public:true) are intentionally exposed without auth — used for
|
|
2545
|
+
# third-party webhooks where the extension does its own signature check.
|
|
2546
|
+
if Clacky::Server::ApiExtensionDispatcher.public_path?(req.path, req.request_method)
|
|
2547
|
+
return true
|
|
2548
|
+
end
|
|
2549
|
+
|
|
2418
2550
|
candidate = extract_key(req)
|
|
2419
2551
|
|
|
2420
2552
|
# Lazily evict expired lockout entries to prevent unbounded memory growth.
|
|
@@ -3821,7 +3953,7 @@ module Clacky
|
|
|
3821
3953
|
# Directories first, then files; both case-insensitive alphabetical.
|
|
3822
3954
|
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
3823
3955
|
|
|
3824
|
-
json_response(res, 200, { root: display_root, path: rel, entries: items })
|
|
3956
|
+
json_response(res, 200, { root: display_root, path: rel, home: Dir.home, default: default_working_dir, entries: items })
|
|
3825
3957
|
rescue StandardError => e
|
|
3826
3958
|
json_response(res, 500, { error: e.message })
|
|
3827
3959
|
end
|
|
@@ -3859,11 +3991,56 @@ module Clacky
|
|
|
3859
3991
|
end
|
|
3860
3992
|
items.sort_by! { |it| it[:name].downcase }
|
|
3861
3993
|
|
|
3862
|
-
json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, entries: items })
|
|
3994
|
+
json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, default: default_working_dir, entries: items })
|
|
3863
3995
|
rescue StandardError => e
|
|
3864
3996
|
json_response(res, 500, { error: e.message })
|
|
3865
3997
|
end
|
|
3866
3998
|
|
|
3999
|
+
# ── Directory mutation API used by the path picker ─────────────────
|
|
4000
|
+
# Validate a folder name supplied by the picker UI:
|
|
4001
|
+
# non-empty, no path separators, not "."/"..", short-ish.
|
|
4002
|
+
private def picker_valid_name?(name)
|
|
4003
|
+
return false if name.nil?
|
|
4004
|
+
return false if name.empty? || name.length > 255
|
|
4005
|
+
return false if name == "." || name == ".."
|
|
4006
|
+
# Reject path separators (forward slash and backslash).
|
|
4007
|
+
return false if name.match?(%r{[/\\]})
|
|
4008
|
+
true
|
|
4009
|
+
end
|
|
4010
|
+
|
|
4011
|
+
# POST /api/dirs/mkdir
|
|
4012
|
+
# Body: { parent: "/abs/parent", name: "New Folder" }
|
|
4013
|
+
def api_dirs_mkdir(req, res)
|
|
4014
|
+
body = parse_json_body(req)
|
|
4015
|
+
parent = body["parent"].to_s
|
|
4016
|
+
name = body["name"].to_s.strip
|
|
4017
|
+
|
|
4018
|
+
return json_response(res, 422, { error: "parent must be an absolute path" }) unless parent.start_with?("/")
|
|
4019
|
+
return json_response(res, 422, { error: "name is invalid" }) unless picker_valid_name?(name)
|
|
4020
|
+
|
|
4021
|
+
parent = File.expand_path(parent)
|
|
4022
|
+
return json_response(res, 404, { error: "Parent directory not found" }) unless Dir.exist?(parent)
|
|
4023
|
+
|
|
4024
|
+
target = File.join(parent, name)
|
|
4025
|
+
return json_response(res, 422, { error: "Already exists" }) if File.exist?(target)
|
|
4026
|
+
|
|
4027
|
+
FileUtils.mkdir_p(target)
|
|
4028
|
+
json_response(res, 200, { ok: true, path: target, name: name })
|
|
4029
|
+
rescue StandardError => e
|
|
4030
|
+
json_response(res, 500, { error: e.message })
|
|
4031
|
+
end
|
|
4032
|
+
|
|
4033
|
+
# NOTE: there is NO PATCH /api/dirs/rename endpoint.
|
|
4034
|
+
# Directory rename was intentionally removed from the picker —
|
|
4035
|
+
# too dangerous for a one-click UI affordance (renaming an in-use
|
|
4036
|
+
# workspace mid-session can break tasks, sessions, MCP configs, …).
|
|
4037
|
+
# Use the terminal for that.
|
|
4038
|
+
|
|
4039
|
+
# NOTE: there is NO DELETE /api/dirs/delete endpoint.
|
|
4040
|
+
# Directory deletion was intentionally removed from the picker —
|
|
4041
|
+
# too dangerous for a one-click UI affordance, even with a trash
|
|
4042
|
+
# bucket fallback. Use the terminal (safe_rm) for that.
|
|
4043
|
+
|
|
3867
4044
|
# Body: { enabled: true/false }
|
|
3868
4045
|
def api_toggle_skill(name, req, res)
|
|
3869
4046
|
body = parse_json_body(req)
|
|
@@ -3880,6 +4057,47 @@ module Clacky
|
|
|
3880
4057
|
json_response(res, 422, { error: e.message })
|
|
3881
4058
|
end
|
|
3882
4059
|
|
|
4060
|
+
private def api_skill_content_get(name, res)
|
|
4061
|
+
@skill_loader.load_all
|
|
4062
|
+
skill = @skill_loader[name]
|
|
4063
|
+
return json_response(res, 404, { ok: false, error: "Skill not found: #{name}" }) unless skill
|
|
4064
|
+
|
|
4065
|
+
skill_md = File.join(skill.directory.to_s, "SKILL.md")
|
|
4066
|
+
unless File.exist?(skill_md)
|
|
4067
|
+
return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
|
|
4068
|
+
end
|
|
4069
|
+
|
|
4070
|
+
json_response(res, 200, {
|
|
4071
|
+
ok: true,
|
|
4072
|
+
name: skill.identifier,
|
|
4073
|
+
content: File.read(skill_md),
|
|
4074
|
+
path: skill_md
|
|
4075
|
+
})
|
|
4076
|
+
end
|
|
4077
|
+
|
|
4078
|
+
private def api_skill_content_update(name, req, res)
|
|
4079
|
+
@skill_loader.load_all
|
|
4080
|
+
skill = @skill_loader[name]
|
|
4081
|
+
return json_response(res, 404, { ok: false, error: "Skill not found: #{name}" }) unless skill
|
|
4082
|
+
|
|
4083
|
+
if skill.source_path.nil? || @skill_loader.loaded_from[skill.identifier] == :default
|
|
4084
|
+
return json_response(res, 403, { ok: false, error: "System skills cannot be edited" })
|
|
4085
|
+
end
|
|
4086
|
+
|
|
4087
|
+
data = parse_json_body(req)
|
|
4088
|
+
content = data["content"].to_s
|
|
4089
|
+
skill_md = File.join(skill.directory.to_s, "SKILL.md")
|
|
4090
|
+
unless File.exist?(skill_md)
|
|
4091
|
+
return json_response(res, 404, { ok: false, error: "SKILL.md not found" })
|
|
4092
|
+
end
|
|
4093
|
+
|
|
4094
|
+
File.write(skill_md, content)
|
|
4095
|
+
@skill_loader.load_all
|
|
4096
|
+
json_response(res, 200, { ok: true, name: skill.identifier })
|
|
4097
|
+
rescue StandardError => e
|
|
4098
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
4099
|
+
end
|
|
4100
|
+
|
|
3883
4101
|
private def api_delete_skill(name, res)
|
|
3884
4102
|
skill = @skill_loader[name]
|
|
3885
4103
|
return json_response(res, 404, { error: "Skill not found: #{name}" }) unless skill
|
|
@@ -4738,83 +4956,6 @@ module Clacky
|
|
|
4738
4956
|
json_response(res, 422, { error: e.message })
|
|
4739
4957
|
end
|
|
4740
4958
|
|
|
4741
|
-
# GET /api/config/media-output-dir
|
|
4742
|
-
# Returns the user-configured directory under which generated media
|
|
4743
|
-
# files (images / videos / audio) are persisted, plus the default
|
|
4744
|
-
# the system would use if the value is empty. The frontend uses
|
|
4745
|
-
# `default` as a placeholder hint.
|
|
4746
|
-
def api_get_media_output_dir(res)
|
|
4747
|
-
json_response(res, 200, {
|
|
4748
|
-
ok: true,
|
|
4749
|
-
value: @agent_config.media_output_dir.to_s,
|
|
4750
|
-
default: default_media_output_dir
|
|
4751
|
-
})
|
|
4752
|
-
end
|
|
4753
|
-
|
|
4754
|
-
# PATCH /api/config/media-output-dir
|
|
4755
|
-
# Body: { "value": "<absolute or ~-prefixed path, or empty to clear>" }
|
|
4756
|
-
# Empty / blank value clears the override, restoring the legacy
|
|
4757
|
-
# fallback (default_working_dir → Dir.pwd) for new generations.
|
|
4758
|
-
def api_update_media_output_dir(req, res)
|
|
4759
|
-
body = parse_json_body(req)
|
|
4760
|
-
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
4761
|
-
|
|
4762
|
-
raw = body["value"].to_s.strip
|
|
4763
|
-
if raw.empty?
|
|
4764
|
-
@agent_config.media_output_dir = nil
|
|
4765
|
-
@agent_config.save
|
|
4766
|
-
return json_response(res, 200, {
|
|
4767
|
-
ok: true,
|
|
4768
|
-
value: "",
|
|
4769
|
-
default: default_media_output_dir
|
|
4770
|
-
})
|
|
4771
|
-
end
|
|
4772
|
-
|
|
4773
|
-
expanded = File.expand_path(raw)
|
|
4774
|
-
|
|
4775
|
-
# Reject anything that's not an absolute path after `~` expansion.
|
|
4776
|
-
# Relative paths would silently resolve against the server's CWD,
|
|
4777
|
-
# which is exactly the source of confusion this setting exists to fix.
|
|
4778
|
-
unless expanded.start_with?("/")
|
|
4779
|
-
return json_response(res, 422, {
|
|
4780
|
-
error: "media_output_dir must be an absolute path or start with ~"
|
|
4781
|
-
})
|
|
4782
|
-
end
|
|
4783
|
-
|
|
4784
|
-
# Create the directory if missing; surface filesystem errors plainly
|
|
4785
|
-
# so the user can fix permissions / typo without reading server logs.
|
|
4786
|
-
begin
|
|
4787
|
-
FileUtils.mkdir_p(expanded)
|
|
4788
|
-
rescue Errno::EACCES, Errno::EROFS, Errno::ENOSPC, Errno::ENOTDIR => e
|
|
4789
|
-
return json_response(res, 422, {
|
|
4790
|
-
error: "cannot create directory: #{e.message}"
|
|
4791
|
-
})
|
|
4792
|
-
end
|
|
4793
|
-
|
|
4794
|
-
unless File.writable?(expanded)
|
|
4795
|
-
return json_response(res, 422, {
|
|
4796
|
-
error: "directory is not writable: #{expanded}"
|
|
4797
|
-
})
|
|
4798
|
-
end
|
|
4799
|
-
|
|
4800
|
-
@agent_config.media_output_dir = expanded
|
|
4801
|
-
@agent_config.save
|
|
4802
|
-
json_response(res, 200, {
|
|
4803
|
-
ok: true,
|
|
4804
|
-
value: expanded,
|
|
4805
|
-
default: default_media_output_dir
|
|
4806
|
-
})
|
|
4807
|
-
rescue => e
|
|
4808
|
-
json_response(res, 422, { error: e.message })
|
|
4809
|
-
end
|
|
4810
|
-
|
|
4811
|
-
# The path the resolver would pick if media_output_dir is blank.
|
|
4812
|
-
# Mirrors the fallback chain inside Clacky::Media::OutputDir.resolve so
|
|
4813
|
-
# the frontend can render it as a placeholder hint (no second source
|
|
4814
|
-
# of truth — both call sites read default_working_dir).
|
|
4815
|
-
private def default_media_output_dir
|
|
4816
|
-
@agent_config.default_working_dir.to_s.empty? ? Dir.pwd : @agent_config.default_working_dir
|
|
4817
|
-
end
|
|
4818
4959
|
# DEPRECATED: this endpoint previously accepted the entire models array
|
|
4819
4960
|
# and replaced @models in place. That design was fragile — any missing
|
|
4820
4961
|
# or stale field on ANY row could wipe other rows' api_keys. It has
|
|
@@ -4839,8 +4980,15 @@ module Clacky
|
|
|
4839
4980
|
model = body["model"].to_s.strip
|
|
4840
4981
|
base_url = body["base_url"].to_s.strip
|
|
4841
4982
|
api_key = body["api_key"].to_s
|
|
4842
|
-
#
|
|
4843
|
-
#
|
|
4983
|
+
# When duplicating, the frontend sends source_id so we can inherit the
|
|
4984
|
+
# real key without ever transmitting it back to the client.
|
|
4985
|
+
if api_key.empty? || api_key.include?("****")
|
|
4986
|
+
source_id = body["source_id"].to_s
|
|
4987
|
+
unless source_id.empty?
|
|
4988
|
+
source = @agent_config.models.find { |m| m["id"] == source_id }
|
|
4989
|
+
api_key = source["api_key"].to_s if source
|
|
4990
|
+
end
|
|
4991
|
+
end
|
|
4844
4992
|
if api_key.empty? || api_key.include?("****")
|
|
4845
4993
|
return json_response(res, 422, { error: "api_key is required" })
|
|
4846
4994
|
end
|
|
@@ -5558,6 +5706,7 @@ module Clacky
|
|
|
5558
5706
|
|
|
5559
5707
|
when "message"
|
|
5560
5708
|
session_id = msg["session_id"] || conn.session_id
|
|
5709
|
+
Thread.current[:lang] = msg["lang"].to_s.strip.then { |l| l.empty? ? nil : l }
|
|
5561
5710
|
# Merge legacy images array into files as { data_url:, name:, mime_type: } entries
|
|
5562
5711
|
raw_images = (msg["images"] || []).map do |data_url|
|
|
5563
5712
|
{ "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
|
|
@@ -5654,13 +5803,15 @@ module Clacky
|
|
|
5654
5803
|
end
|
|
5655
5804
|
|
|
5656
5805
|
# Broadcast user message through web_ui so channel subscribers (飞书/企微) receive it.
|
|
5806
|
+
# created_at is shared with agent.run so the history entry and the bubble use the same value.
|
|
5807
|
+
msg_created_at = Time.now.to_f
|
|
5657
5808
|
web_ui = nil
|
|
5658
5809
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
5659
|
-
web_ui&.show_user_message(content, source: :web)
|
|
5810
|
+
web_ui&.show_user_message(content, created_at: msg_created_at, source: :web)
|
|
5660
5811
|
|
|
5661
5812
|
# File references are now handled inside agent.run — injected as a system_injected
|
|
5662
5813
|
# message after the user message, so replay_history skips them automatically.
|
|
5663
|
-
run_agent_task(session_id, agent) { agent.run(content, files: files) }
|
|
5814
|
+
run_agent_task(session_id, agent) { agent.run(content, files: files, created_at: msg_created_at) }
|
|
5664
5815
|
end
|
|
5665
5816
|
|
|
5666
5817
|
def deliver_confirmation(session_id, conf_id, result)
|
|
@@ -5776,6 +5927,22 @@ module Clacky
|
|
|
5776
5927
|
Clacky::Logger.error("[interrupt] force_close_session_sockets error: #{e.class}: #{e.message}")
|
|
5777
5928
|
end
|
|
5778
5929
|
|
|
5930
|
+
# Run a task in a session immediately in the background, without waiting
|
|
5931
|
+
# for the client to subscribe. The user bubble is persisted via
|
|
5932
|
+
# display_text (Agent#run → history → replay_history), so the frontend
|
|
5933
|
+
# only needs to navigate over and load history — no realtime broadcast,
|
|
5934
|
+
# no subscribe-timing race. This is the stable entry point for
|
|
5935
|
+
# programmatic "create session + run now" flows (spawn, extensions).
|
|
5936
|
+
def run_session_task(session_id, prompt, display_message: nil)
|
|
5937
|
+
return unless @registry.exist?(session_id)
|
|
5938
|
+
|
|
5939
|
+
agent = nil
|
|
5940
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
5941
|
+
return unless agent
|
|
5942
|
+
|
|
5943
|
+
run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
|
|
5944
|
+
end
|
|
5945
|
+
|
|
5779
5946
|
# Start the pending task for a session.
|
|
5780
5947
|
# Called when the client sends "run_task" over WS — by that point the
|
|
5781
5948
|
# client has already subscribed, so every broadcast will be delivered.
|
|
@@ -5783,18 +5950,29 @@ module Clacky
|
|
|
5783
5950
|
return unless @registry.exist?(session_id)
|
|
5784
5951
|
|
|
5785
5952
|
session = @registry.get(session_id)
|
|
5786
|
-
prompt
|
|
5787
|
-
working_dir
|
|
5953
|
+
prompt = session[:pending_task]
|
|
5954
|
+
working_dir = session[:pending_working_dir]
|
|
5955
|
+
display_message = session[:pending_display_message]
|
|
5788
5956
|
return unless prompt # nothing pending
|
|
5789
5957
|
|
|
5790
5958
|
# Clear the pending fields so a re-connect doesn't re-run
|
|
5791
|
-
@registry.update(session_id, pending_task: nil, pending_working_dir: nil)
|
|
5959
|
+
@registry.update(session_id, pending_task: nil, pending_working_dir: nil, pending_display_message: nil)
|
|
5792
5960
|
|
|
5793
5961
|
agent = nil
|
|
5794
5962
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
5795
5963
|
return unless agent
|
|
5796
5964
|
|
|
5797
|
-
|
|
5965
|
+
# Surface a user message on screen before the agent starts thinking, so
|
|
5966
|
+
# programmatically-submitted tasks (e.g. meeting summarization) don't
|
|
5967
|
+
# appear as a thinking spinner with no preceding message. When a short
|
|
5968
|
+
# display_message is provided we show that instead of the full prompt.
|
|
5969
|
+
if display_message
|
|
5970
|
+
web_ui = nil
|
|
5971
|
+
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
5972
|
+
web_ui&.show_user_message(display_message, source: :web)
|
|
5973
|
+
end
|
|
5974
|
+
|
|
5975
|
+
run_agent_task(session_id, agent) { agent.run(prompt, display_text: display_message) }
|
|
5798
5976
|
end
|
|
5799
5977
|
|
|
5800
5978
|
# Interrupt every running agent thread and persist its session state.
|
|
@@ -5847,7 +6025,9 @@ module Clacky
|
|
|
5847
6025
|
|
|
5848
6026
|
broadcast_session_update(session_id)
|
|
5849
6027
|
|
|
6028
|
+
locale = Thread.current[:lang]
|
|
5850
6029
|
thread = Thread.new do
|
|
6030
|
+
Thread.current[:lang] = locale
|
|
5851
6031
|
task.call
|
|
5852
6032
|
@registry.update(session_id, status: :idle, error: nil)
|
|
5853
6033
|
broadcast_session_update(session_id)
|
|
@@ -5874,10 +6054,12 @@ module Clacky
|
|
|
5874
6054
|
preset = Clacky::Providers::PRESETS[e.provider_id]
|
|
5875
6055
|
top_up_url = preset && preset["website_url"]
|
|
5876
6056
|
end
|
|
5877
|
-
|
|
6057
|
+
user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
|
|
6058
|
+
raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
|
|
6059
|
+
@registry.update(session_id, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
5878
6060
|
broadcast_session_update(session_id)
|
|
5879
|
-
web_ui&.show_error(
|
|
5880
|
-
@session_manager.save(agent.to_session_data(status: :error, error_message:
|
|
6061
|
+
web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
6062
|
+
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message))
|
|
5881
6063
|
end
|
|
5882
6064
|
@registry.with_session(session_id) { |s| s[:thread] = thread }
|
|
5883
6065
|
end
|