openclacky 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +4 -0
- data/README_CN.md +198 -0
- data/docs/engineering-article.md +1 -1
- data/lib/clacky/agent/llm_caller.rb +1 -0
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +22 -1
- data/lib/clacky/brand_config.rb +87 -5
- data/lib/clacky/client.rb +15 -11
- data/lib/clacky/message_format/anthropic.rb +13 -1
- data/lib/clacky/message_format/bedrock.rb +13 -1
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/server/http_server.rb +130 -15
- data/lib/clacky/server/session_registry.rb +9 -6
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1278 -1116
- data/lib/clacky/web/brand.js +20 -5
- data/lib/clacky/web/i18n.js +42 -0
- data/lib/clacky/web/index.html +25 -6
- data/lib/clacky/web/sessions.js +194 -11
- data/lib/clacky/web/settings.js +34 -5
- data/lib/clacky/web/skills.js +53 -31
- data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
- data/scripts/install_browser.sh +2 -1
- data/scripts/install_full.sh +2 -1
- data/scripts/install_rails_deps.sh +30 -9
- data/scripts/install_system_deps.sh +30 -9
- metadata +5 -2
|
@@ -407,7 +407,7 @@ module Clacky
|
|
|
407
407
|
when ["GET", "/api/channels"] then api_list_channels(res)
|
|
408
408
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
409
409
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
410
|
-
when ["POST", "/api/
|
|
410
|
+
when ["POST", "/api/file-action"] then api_file_action(req, res)
|
|
411
411
|
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
412
412
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
413
413
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
@@ -448,6 +448,9 @@ module Clacky
|
|
|
448
448
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
|
|
449
449
|
session_id = path.sub("/api/sessions/", "").sub("/model", "")
|
|
450
450
|
api_switch_session_model(session_id, req, res)
|
|
451
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
|
|
452
|
+
session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
|
|
453
|
+
api_switch_session_reasoning_effort(session_id, req, res)
|
|
451
454
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
|
|
452
455
|
session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
|
|
453
456
|
api_benchmark_session_models(session_id, req, res)
|
|
@@ -752,6 +755,9 @@ module Clacky
|
|
|
752
755
|
else
|
|
753
756
|
Clacky::Logger.debug("[Brand] async distribution refresh skipped/failed — #{result[:message]}")
|
|
754
757
|
end
|
|
758
|
+
# Free-mode skill sync: branded + unactivated installs need their
|
|
759
|
+
# creator's free skills auto-installed for the "no serial number" UX.
|
|
760
|
+
brand.sync_free_skills_async!
|
|
755
761
|
rescue StandardError => e
|
|
756
762
|
Clacky::Logger.warn("[Brand] async distribution refresh raised: #{e.class}: #{e.message}")
|
|
757
763
|
ensure
|
|
@@ -797,12 +803,30 @@ module Clacky
|
|
|
797
803
|
refresh_pending = true
|
|
798
804
|
end
|
|
799
805
|
|
|
806
|
+
# Free-mode counts: synchronous fetch is acceptable here because
|
|
807
|
+
# this endpoint is polled lazily and the platform call is cached
|
|
808
|
+
# via http keep-alive. On error we just return zero counts and the
|
|
809
|
+
# banner falls back to the legacy "not activated" message.
|
|
810
|
+
free_count = 0
|
|
811
|
+
paid_count = 0
|
|
812
|
+
begin
|
|
813
|
+
result = brand.fetch_free_skills!
|
|
814
|
+
if result[:success]
|
|
815
|
+
free_count = result[:skills].size
|
|
816
|
+
paid_count = result[:paid_skills_count].to_i
|
|
817
|
+
end
|
|
818
|
+
rescue StandardError
|
|
819
|
+
# Network errors are non-fatal here.
|
|
820
|
+
end
|
|
821
|
+
|
|
800
822
|
json_response(res, 200, {
|
|
801
823
|
branded: true,
|
|
802
824
|
needs_activation: true,
|
|
803
825
|
product_name: brand.product_name,
|
|
804
826
|
test_mode: @brand_test,
|
|
805
|
-
distribution_refresh_pending: refresh_pending
|
|
827
|
+
distribution_refresh_pending: refresh_pending,
|
|
828
|
+
free_skills_count: free_count,
|
|
829
|
+
paid_skills_count: paid_count
|
|
806
830
|
})
|
|
807
831
|
return
|
|
808
832
|
end
|
|
@@ -918,7 +942,30 @@ module Clacky
|
|
|
918
942
|
brand = Clacky::BrandConfig.load
|
|
919
943
|
|
|
920
944
|
unless brand.activated?
|
|
921
|
-
|
|
945
|
+
# Free-mode: branded but no license. Return the unencrypted skills
|
|
946
|
+
# available to anonymous installs so the Brand Skills tab is not
|
|
947
|
+
# empty and the user can install/use them without a serial number.
|
|
948
|
+
# Each skill is tagged is_free=true so the UI can show a "Free" badge.
|
|
949
|
+
result = brand.fetch_free_skills!
|
|
950
|
+
|
|
951
|
+
if result[:success]
|
|
952
|
+
free_skills = result[:skills].map { |s| s.merge("is_free" => true) }
|
|
953
|
+
json_response(res, 200, {
|
|
954
|
+
ok: true,
|
|
955
|
+
skills: free_skills,
|
|
956
|
+
free_mode: true,
|
|
957
|
+
paid_skills_count: result[:paid_skills_count].to_i
|
|
958
|
+
})
|
|
959
|
+
else
|
|
960
|
+
json_response(res, 200, {
|
|
961
|
+
ok: true,
|
|
962
|
+
skills: [],
|
|
963
|
+
free_mode: true,
|
|
964
|
+
paid_skills_count: 0,
|
|
965
|
+
warning_code: "remote_unavailable",
|
|
966
|
+
warning: result[:error] || "Could not reach the license server."
|
|
967
|
+
})
|
|
968
|
+
end
|
|
922
969
|
return
|
|
923
970
|
end
|
|
924
971
|
|
|
@@ -963,8 +1010,29 @@ module Clacky
|
|
|
963
1010
|
def api_brand_skill_install(slug, req, res)
|
|
964
1011
|
brand = Clacky::BrandConfig.load
|
|
965
1012
|
|
|
1013
|
+
# Free-mode: branded but not activated. Fall back to the public free
|
|
1014
|
+
# skills endpoint and install with encrypted: false. Paid (encrypted)
|
|
1015
|
+
# skills still require activation and will return 404 here.
|
|
966
1016
|
unless brand.activated?
|
|
967
|
-
|
|
1017
|
+
fetch_result = brand.fetch_free_skills!
|
|
1018
|
+
unless fetch_result[:success]
|
|
1019
|
+
json_response(res, 422, { ok: false, error: fetch_result[:error] })
|
|
1020
|
+
return
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
skill_info = fetch_result[:skills].find { |s| s["name"] == slug }
|
|
1024
|
+
unless skill_info
|
|
1025
|
+
json_response(res, 404, { ok: false, error: "Skill '#{slug}' is not a free skill — activate your license to access it." })
|
|
1026
|
+
return
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
result = brand.install_free_skill!(skill_info)
|
|
1030
|
+
if result[:success]
|
|
1031
|
+
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
|
|
1032
|
+
json_response(res, 200, { ok: true, name: result[:name], version: result[:version] })
|
|
1033
|
+
else
|
|
1034
|
+
json_response(res, 422, { ok: false, error: result[:error] })
|
|
1035
|
+
end
|
|
968
1036
|
return
|
|
969
1037
|
end
|
|
970
1038
|
|
|
@@ -1304,7 +1372,11 @@ module Clacky
|
|
|
1304
1372
|
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
1305
1373
|
|
|
1306
1374
|
if @master_pid
|
|
1307
|
-
# Worker mode: tell master to hot-restart
|
|
1375
|
+
# Worker mode: tell master to hot-restart. Master will TERM us after the
|
|
1376
|
+
# new worker boots; our trap("TERM") then runs shutdown_proc, which detaches
|
|
1377
|
+
# the inherited listen socket before WEBrick shutdown. Do NOT exit(0) here —
|
|
1378
|
+
# that bypasses trap handlers and lets the OS close(fd) on a socket shared
|
|
1379
|
+
# with master+new worker, corrupting the listener on Linux/WSL.
|
|
1308
1380
|
Clacky::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
|
|
1309
1381
|
begin
|
|
1310
1382
|
Process.kill("USR1", @master_pid)
|
|
@@ -1312,7 +1384,6 @@ module Clacky
|
|
|
1312
1384
|
Clacky::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
|
|
1313
1385
|
standalone_exec_restart
|
|
1314
1386
|
end
|
|
1315
|
-
exit(0)
|
|
1316
1387
|
else
|
|
1317
1388
|
# Standalone mode (no master): fall back to the original exec approach.
|
|
1318
1389
|
standalone_exec_restart
|
|
@@ -1549,12 +1620,16 @@ module Clacky
|
|
|
1549
1620
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1550
1621
|
end
|
|
1551
1622
|
|
|
1552
|
-
# POST /api/
|
|
1553
|
-
#
|
|
1554
|
-
#
|
|
1555
|
-
# file
|
|
1556
|
-
|
|
1557
|
-
|
|
1623
|
+
# POST /api/file-action
|
|
1624
|
+
# Unified file action endpoint — open locally or download.
|
|
1625
|
+
# Body: { path: String, action: "open" | "download" }
|
|
1626
|
+
# open: opens the file with the OS default handler (local deployments).
|
|
1627
|
+
# download: returns the file as a download (remote deployments).
|
|
1628
|
+
def api_file_action(req, res)
|
|
1629
|
+
body = parse_json_body(req)
|
|
1630
|
+
path = body["path"]
|
|
1631
|
+
action = body["action"] || "open"
|
|
1632
|
+
|
|
1558
1633
|
return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
|
|
1559
1634
|
|
|
1560
1635
|
# Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
|
|
@@ -1567,13 +1642,33 @@ module Clacky
|
|
|
1567
1642
|
|
|
1568
1643
|
return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
|
|
1569
1644
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1645
|
+
case action
|
|
1646
|
+
when "open"
|
|
1647
|
+
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
1648
|
+
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
1649
|
+
json_response(res, 200, { ok: true })
|
|
1650
|
+
when "download"
|
|
1651
|
+
serve_file_download(res, linux_path)
|
|
1652
|
+
else
|
|
1653
|
+
json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
|
|
1654
|
+
end
|
|
1573
1655
|
rescue => e
|
|
1574
1656
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1575
1657
|
end
|
|
1576
1658
|
|
|
1659
|
+
# Stream a file to the client as a download.
|
|
1660
|
+
# Content-Type is always application/octet-stream — the browser determines
|
|
1661
|
+
# file type and handling from the filename extension in Content-Disposition.
|
|
1662
|
+
def serve_file_download(res, path)
|
|
1663
|
+
filename = File.basename(path)
|
|
1664
|
+
|
|
1665
|
+
res.status = 200
|
|
1666
|
+
res["Content-Type"] = "application/octet-stream"
|
|
1667
|
+
res["Content-Disposition"] = "attachment; filename=\"#{filename}\""
|
|
1668
|
+
res["Content-Length"] = File.size(path).to_s
|
|
1669
|
+
res.body = File.binread(path)
|
|
1670
|
+
end
|
|
1671
|
+
|
|
1577
1672
|
# GET /api/local-image?path=file:///path/to/image.png
|
|
1578
1673
|
# GET /api/local-image?path=/path/to/image.png
|
|
1579
1674
|
#
|
|
@@ -2999,6 +3094,26 @@ module Clacky
|
|
|
2999
3094
|
json_response(res, 500, { error: e.message })
|
|
3000
3095
|
end
|
|
3001
3096
|
|
|
3097
|
+
# PATCH /api/sessions/:id/reasoning_effort
|
|
3098
|
+
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
|
|
3099
|
+
def api_switch_session_reasoning_effort(session_id, req, res)
|
|
3100
|
+
body = parse_json_body(req)
|
|
3101
|
+
raw = body["reasoning_effort"]
|
|
3102
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
3103
|
+
|
|
3104
|
+
agent = nil
|
|
3105
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
3106
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
3107
|
+
|
|
3108
|
+
agent.reasoning_effort = raw
|
|
3109
|
+
@session_manager.save(agent.to_session_data)
|
|
3110
|
+
broadcast_session_update(session_id)
|
|
3111
|
+
|
|
3112
|
+
json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
|
|
3113
|
+
rescue => e
|
|
3114
|
+
json_response(res, 500, { error: e.message })
|
|
3115
|
+
end
|
|
3116
|
+
|
|
3002
3117
|
# POST /api/sessions/:id/benchmark
|
|
3003
3118
|
#
|
|
3004
3119
|
# Speed-test every configured model in one shot so the user can pick the
|
|
@@ -165,11 +165,12 @@ module Clacky
|
|
|
165
165
|
model_info = s[:agent]&.current_model_info
|
|
166
166
|
live_name = s[:agent]&.name
|
|
167
167
|
live_name = nil if live_name&.empty?
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
live_cost_source = s[:agent]&.cost_source
|
|
169
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
|
|
170
|
+
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
|
|
171
|
+
cost_source: live_cost_source,
|
|
172
|
+
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
173
|
+
latest_latency: s[:agent]&.latest_latency }
|
|
173
174
|
end
|
|
174
175
|
end
|
|
175
176
|
|
|
@@ -241,6 +242,7 @@ module Clacky
|
|
|
241
242
|
{ status: s[:status], error: s[:error], model: model_info&.dig(:model),
|
|
242
243
|
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
243
244
|
total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
|
|
245
|
+
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
244
246
|
latest_latency: s[:agent]&.latest_latency }
|
|
245
247
|
end
|
|
246
248
|
|
|
@@ -271,6 +273,7 @@ module Clacky
|
|
|
271
273
|
# per-assistant-message `latency` fields in messages[]. Reloaded
|
|
272
274
|
# sessions start with nil and get populated on the next LLM call.
|
|
273
275
|
latest_latency: ls&.dig(:latest_latency),
|
|
276
|
+
reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
|
|
274
277
|
pinned: s[:pinned] || false,
|
|
275
278
|
}
|
|
276
279
|
end
|
|
@@ -382,7 +385,7 @@ module Clacky
|
|
|
382
385
|
return nil unless agent
|
|
383
386
|
|
|
384
387
|
model_info = agent.current_model_info
|
|
385
|
-
|
|
388
|
+
|
|
386
389
|
{
|
|
387
390
|
id: session[:id],
|
|
388
391
|
name: agent.name,
|
data/lib/clacky/version.rb
CHANGED