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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. 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 = 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
- )
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 = 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
- )
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 = 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
- )
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
- # Masked placeholders are never a valid api_key on creation
4843
- # a brand-new model MUST come with a real key.
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 = session[:pending_task]
5787
- working_dir = session[:pending_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
- run_agent_task(session_id, agent) { agent.run(prompt) }
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
- @registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
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(e.message, code: code, top_up_url: top_up_url)
5880
- @session_manager.save(agent.to_session_data(status: :error, error_message: e.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