openclacky 1.5.19 → 1.5.21
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 +95 -0
- data/README.md +19 -1
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +17 -3
- data/lib/clacky/agent/message_compressor_helper.rb +36 -2
- data/lib/clacky/agent/session_serializer.rb +45 -4
- data/lib/clacky/agent.rb +135 -55
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +10 -5
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +5 -4
- data/lib/clacky/client.rb +12 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +97 -20
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +45 -4
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +38 -138
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +285 -53
- data/lib/clacky/server/model_prices.rb +10 -5
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +37 -7
- data/lib/clacky/server/web_ui_controller.rb +72 -0
- data/lib/clacky/session_manager.rb +28 -6
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +242 -39
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1158 -249
- data/lib/clacky/web/app.js +82 -13
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/code-editor.js +6 -2
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +92 -97
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/components/sidebar.js +6 -2
- data/lib/clacky/web/features/archive/store.js +196 -0
- data/lib/clacky/web/features/archive/view.js +409 -0
- data/lib/clacky/web/features/backup/view.js +17 -7
- data/lib/clacky/web/features/billing/view.js +84 -10
- data/lib/clacky/web/features/channels/view.js +75 -29
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/new-session/view.js +1 -0
- data/lib/clacky/web/features/trash/store.js +14 -11
- data/lib/clacky/web/features/trash/view.js +66 -13
- data/lib/clacky/web/features/workspace/view.js +421 -16
- data/lib/clacky/web/i18n.js +242 -70
- data/lib/clacky/web/index.html +270 -117
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +609 -267
- data/lib/clacky/web/settings.js +186 -32
- data/lib/clacky/web/ws-dispatcher.js +33 -21
- data/lib/clacky.rb +4 -2
- metadata +6 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "pid_files"
|
|
4
4
|
require "socket"
|
|
5
5
|
|
|
6
6
|
module Clacky
|
|
7
7
|
module Server
|
|
8
8
|
# Discover locally-running Clacky server(s) by scanning PID files
|
|
9
|
-
# written by Master
|
|
9
|
+
# written by Master in the configured PID directory.
|
|
10
10
|
#
|
|
11
11
|
# Used by the CLI (bare `clacky agent` mode) to auto-detect a sibling
|
|
12
12
|
# server process, so skills that call back into the server (channels,
|
|
@@ -17,7 +17,6 @@ module Clacky
|
|
|
17
17
|
# Does NOT probe the TCP port (avoids false positives from stale files
|
|
18
18
|
# but also avoids noisy connection attempts).
|
|
19
19
|
module Discover
|
|
20
|
-
PID_FILE_GLOB = File.join(Dir.tmpdir, "clacky-master-*.pid").freeze
|
|
21
20
|
PID_FILE_REGEX = /clacky-master-(\d+)\.pid\z/.freeze
|
|
22
21
|
|
|
23
22
|
module_function
|
|
@@ -41,7 +40,9 @@ module Clacky
|
|
|
41
40
|
#
|
|
42
41
|
# @return [Array<Hash>] sorted by port ascending
|
|
43
42
|
def find_all_local
|
|
44
|
-
|
|
43
|
+
directory = PidFiles.directory
|
|
44
|
+
Dir.glob("clacky-master-*.pid", base: directory).filter_map do |filename|
|
|
45
|
+
path = File.join(directory, filename)
|
|
45
46
|
m = path.match(PID_FILE_REGEX)
|
|
46
47
|
next nil unless m
|
|
47
48
|
|
|
@@ -110,6 +110,15 @@ module Clacky
|
|
|
110
110
|
|
|
111
111
|
def show_tool_call(name, args)
|
|
112
112
|
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
113
|
+
if Clacky::Tools::AskUser.feedback_tool?(name)
|
|
114
|
+
questions = Clacky::Tools::AskUser.normalize_questions(args_data)
|
|
115
|
+
first = questions.first || {}
|
|
116
|
+
@events << { type: "request_feedback", session_id: @session_id,
|
|
117
|
+
request_id: Clacky::Tools::AskUser.fetch_key(args_data, :request_id),
|
|
118
|
+
context: Clacky::Tools::AskUser.fetch_key(args_data, :context).to_s,
|
|
119
|
+
question: first[:question].to_s, options: first[:options] || [], questions: questions }
|
|
120
|
+
return
|
|
121
|
+
end
|
|
113
122
|
summary = tool_call_summary(name, args_data)
|
|
114
123
|
@events << stamp({ type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary })
|
|
115
124
|
end
|
|
@@ -700,6 +709,7 @@ module Clacky
|
|
|
700
709
|
when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
|
|
701
710
|
when ["POST", "/api/browser/reload"] then api_browser_reload(res)
|
|
702
711
|
when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
|
|
712
|
+
when ["POST", "/api/browser/settings"] then api_browser_settings(req, res)
|
|
703
713
|
when ["GET", "/api/backup/status"] then api_backup_status(res)
|
|
704
714
|
when ["POST", "/api/backup/run"] then api_backup_run(res)
|
|
705
715
|
when ["GET", "/api/backup/download"] then api_backup_download(res)
|
|
@@ -732,7 +742,8 @@ module Clacky
|
|
|
732
742
|
when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
|
|
733
743
|
when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
|
|
734
744
|
when ["POST", "/api/trash/sessions/restore"] then api_trash_session_restore(req, res)
|
|
735
|
-
when ["
|
|
745
|
+
when ["POST", "/api/trash/sessions/delete"] then api_trash_sessions_delete(req, res)
|
|
746
|
+
when ["POST", "/api/archive/sessions/delete"] then api_archive_sessions_delete(req, res)
|
|
736
747
|
when ["GET", "/api/profile"] then api_profile_get(res)
|
|
737
748
|
when ["PUT", "/api/profile"] then api_profile_put(req, res)
|
|
738
749
|
when ["GET", "/api/memories"] then api_memories_list(res)
|
|
@@ -869,6 +880,9 @@ module Clacky
|
|
|
869
880
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
|
|
870
881
|
session_id = path.sub("/api/sessions/", "").sub("/project", "")
|
|
871
882
|
api_update_session_project(session_id, req, res)
|
|
883
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/permission_mode$})
|
|
884
|
+
session_id = path.split("/")[3]
|
|
885
|
+
api_switch_session_permission_mode(session_id, req, res)
|
|
872
886
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
|
|
873
887
|
session_id = path.sub("/api/sessions/", "").sub("/model", "")
|
|
874
888
|
api_switch_session_model(session_id, req, res)
|
|
@@ -949,6 +963,9 @@ module Clacky
|
|
|
949
963
|
elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
|
|
950
964
|
project_id = path.sub("/api/projects/", "")
|
|
951
965
|
api_update_project(project_id, req, res)
|
|
966
|
+
elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/sessions/archive$})
|
|
967
|
+
project_id = path.sub("/api/projects/", "").sub("/sessions/archive", "")
|
|
968
|
+
api_archive_project_sessions(project_id, res)
|
|
952
969
|
elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
|
|
953
970
|
project_id = path.sub("/api/projects/", "").sub("/sessions", "")
|
|
954
971
|
api_delete_project_sessions(project_id, res)
|
|
@@ -967,11 +984,13 @@ module Clacky
|
|
|
967
984
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
968
985
|
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
|
|
969
986
|
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
987
|
+
before_id = query["before_id"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
970
988
|
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
971
989
|
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
972
990
|
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
973
991
|
type = query["type"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
974
992
|
exclude_type = query["exclude_type"].to_s.split(",").map(&:strip).reject(&:empty?).then { |v| v.empty? ? nil : v }
|
|
993
|
+
archived = %w[1 true yes].include?(query["archived"].to_s.strip.downcase)
|
|
975
994
|
# Backward-compat: ?source=<x> and ?profile=coding → type
|
|
976
995
|
type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
977
996
|
type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
@@ -981,16 +1000,24 @@ module Clacky
|
|
|
981
1000
|
# in the project section, so they must not eat into this page's quota.
|
|
982
1001
|
# Search (no before) keeps the original semantics: without a type filter
|
|
983
1002
|
# it may still match project sessions.
|
|
984
|
-
|
|
985
|
-
|
|
1003
|
+
#
|
|
1004
|
+
# The archive view is the exception: it groups archived sessions by
|
|
1005
|
+
# project by hand, so project membership must survive both filters.
|
|
1006
|
+
exclude_project = archived ? false : (before ? true : !!type)
|
|
1007
|
+
sessions = @registry.list(limit: limit + 1, before: before, before_id: before_id, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project, archived: archived)
|
|
986
1008
|
|
|
987
1009
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
988
1010
|
has_more = non_pinned_part.size > limit
|
|
989
1011
|
non_pinned_part = non_pinned_part.first(limit)
|
|
990
1012
|
sessions = pinned_part + non_pinned_part
|
|
991
1013
|
|
|
992
|
-
|
|
993
|
-
|
|
1014
|
+
payload = { sessions: sessions, has_more: has_more, next_cursor: non_pinned_part.last&.dig(:cursor),
|
|
1015
|
+
groups: @registry.group_stats_all }
|
|
1016
|
+
# The archive page offers "delete all", so it needs the size of the whole
|
|
1017
|
+
# archive — the paged `sessions` array only ever shows one page of it.
|
|
1018
|
+
payload[:archived_count] = @registry.archived_count if archived
|
|
1019
|
+
|
|
1020
|
+
json_response(res, 200, payload)
|
|
994
1021
|
end
|
|
995
1022
|
|
|
996
1023
|
# GET /api/sessions/:id — fetch a single session by id (memory + disk merged).
|
|
@@ -1673,6 +1700,22 @@ module Clacky
|
|
|
1673
1700
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1674
1701
|
end
|
|
1675
1702
|
|
|
1703
|
+
# POST /api/browser/settings
|
|
1704
|
+
# Body: { screenshot_max_base64_bytes: 300000 }
|
|
1705
|
+
# The field is optional — omitting it keeps the current value.
|
|
1706
|
+
def api_browser_settings(req, res)
|
|
1707
|
+
body = JSON.parse(req.body.to_s) rescue {}
|
|
1708
|
+
|
|
1709
|
+
settings = @browser_manager.update_screenshot_settings(
|
|
1710
|
+
max_base64_bytes: body["screenshot_max_base64_bytes"]
|
|
1711
|
+
)
|
|
1712
|
+
json_response(res, 200, { ok: true }.merge(settings))
|
|
1713
|
+
rescue ArgumentError => e
|
|
1714
|
+
json_response(res, 422, { ok: false, error: e.message })
|
|
1715
|
+
rescue StandardError => e
|
|
1716
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1676
1719
|
# GET /api/backup/status
|
|
1677
1720
|
def api_backup_status(res)
|
|
1678
1721
|
json_response(res, 200, BackupManager.status)
|
|
@@ -2284,38 +2327,66 @@ module Clacky
|
|
|
2284
2327
|
end
|
|
2285
2328
|
|
|
2286
2329
|
# PATCH /api/config/search
|
|
2287
|
-
# Body: { provider
|
|
2330
|
+
# Body: { provider?: "tavily"|"deepseek_web"|"", key?: "..." }
|
|
2331
|
+
# Omitted fields keep their current value.
|
|
2288
2332
|
def api_update_search_config(req, res)
|
|
2289
2333
|
body = parse_json_body(req)
|
|
2290
2334
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2291
2335
|
|
|
2292
2336
|
provider = body["provider"].to_s.strip
|
|
2293
|
-
|
|
2337
|
+
if body.key?("provider") && !search_provider_known?(provider)
|
|
2294
2338
|
return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
|
|
2295
2339
|
end
|
|
2296
2340
|
|
|
2297
2341
|
key = body["key"].to_s
|
|
2298
2342
|
key = Clacky::SearchConfig.key if key.include?("****")
|
|
2299
2343
|
|
|
2300
|
-
Clacky::SearchConfig.
|
|
2301
|
-
|
|
2344
|
+
current = Clacky::SearchConfig.load
|
|
2345
|
+
final_provider = body.key?("provider") ? provider : current["provider"]
|
|
2346
|
+
final_key = body.key?("key") ? key : current["key"]
|
|
2347
|
+
|
|
2348
|
+
# deepseek_web falls back to the configured model key, so it may be
|
|
2349
|
+
# saved without one. Every other searcher is a script that reads the
|
|
2350
|
+
# key from CLACKY_SEARCH_KEY and cannot run without it.
|
|
2351
|
+
if !final_provider.empty? &&
|
|
2352
|
+
final_provider != Clacky::SearchConfig::DEEPSEEK_WEB &&
|
|
2353
|
+
final_key.to_s.strip.empty?
|
|
2354
|
+
return json_response(res, 422, {
|
|
2355
|
+
error: "API key is required for search provider '#{final_provider}'"
|
|
2356
|
+
})
|
|
2357
|
+
end
|
|
2358
|
+
|
|
2359
|
+
Clacky::SearchConfig.save(
|
|
2360
|
+
provider: body.key?("provider") ? provider : nil,
|
|
2361
|
+
key: body.key?("key") ? key : nil
|
|
2362
|
+
)
|
|
2363
|
+
config = Clacky::SearchConfig.load
|
|
2364
|
+
json_response(res, 200, { ok: true, provider: config["provider"] })
|
|
2302
2365
|
rescue => e
|
|
2303
2366
|
json_response(res, 422, { error: e.message })
|
|
2304
2367
|
end
|
|
2305
2368
|
|
|
2369
|
+
private def search_provider_known?(provider)
|
|
2370
|
+
return true if provider.empty?
|
|
2371
|
+
return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
|
|
2372
|
+
|
|
2373
|
+
!Clacky::Utils::SearcherManager.path_for(provider).nil?
|
|
2374
|
+
end
|
|
2375
|
+
|
|
2306
2376
|
# POST /api/config/search/test
|
|
2307
|
-
# Runs the
|
|
2308
|
-
# answer before saving, rather than discovering a bad key mid-task.
|
|
2377
|
+
# Runs the configured engine with a fixed query so the user gets a
|
|
2378
|
+
# definitive answer before saving, rather than discovering a bad key mid-task.
|
|
2309
2379
|
def api_test_search_config(req, res)
|
|
2310
2380
|
body = parse_json_body(req) || {}
|
|
2311
2381
|
provider = body["provider"].to_s.strip
|
|
2312
2382
|
return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
|
|
2313
2383
|
|
|
2314
|
-
script = Clacky::Utils::SearcherManager.path_for(provider)
|
|
2315
|
-
return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
|
|
2316
|
-
|
|
2317
2384
|
key = body["key"].to_s
|
|
2318
2385
|
key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
|
|
2386
|
+
return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
|
|
2387
|
+
|
|
2388
|
+
script = Clacky::Utils::SearcherManager.path_for(provider)
|
|
2389
|
+
return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
|
|
2319
2390
|
|
|
2320
2391
|
stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
|
|
2321
2392
|
{ "CLACKY_SEARCH_KEY" => key },
|
|
@@ -2344,6 +2415,23 @@ module Clacky
|
|
|
2344
2415
|
json_response(res, 200, { ok: false, message: e.message })
|
|
2345
2416
|
end
|
|
2346
2417
|
|
|
2418
|
+
private def test_deepseek_web_search(res, key)
|
|
2419
|
+
key = @agent_config.deepseek_web_key.to_s if key.empty?
|
|
2420
|
+
if key.empty?
|
|
2421
|
+
return json_response(res, 200, {
|
|
2422
|
+
ok: false, message: "No DeepSeek key: fill one in or configure a DeepSeek / OpenClacky model"
|
|
2423
|
+
})
|
|
2424
|
+
end
|
|
2425
|
+
|
|
2426
|
+
results = Clacky::DeepSeekWebTools.search(
|
|
2427
|
+
query: "openclacky", count: 1, api_key: key,
|
|
2428
|
+
base_url: @agent_config.deepseek_web_base_url(key)
|
|
2429
|
+
)
|
|
2430
|
+
json_response(res, 200, { ok: true, message: "Returned #{results.size} result(s)" })
|
|
2431
|
+
rescue => e
|
|
2432
|
+
json_response(res, 200, { ok: false, message: e.message })
|
|
2433
|
+
end
|
|
2434
|
+
|
|
2347
2435
|
# POST /api/internal/ocr-image
|
|
2348
2436
|
# Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
|
|
2349
2437
|
# transcribe a single image via the configured OCR sidecar. Localhost-
|
|
@@ -4679,19 +4767,39 @@ module Clacky
|
|
|
4679
4767
|
return
|
|
4680
4768
|
end
|
|
4681
4769
|
|
|
4682
|
-
# Support HTTP
|
|
4770
|
+
# Support a single HTTP byte range for media seeking. Browsers normally
|
|
4771
|
+
# send an explicit or open-ended start, while other clients may use the
|
|
4772
|
+
# standard suffix form ("bytes=-N").
|
|
4683
4773
|
range_header = req["Range"]
|
|
4684
4774
|
if range_header && range_header =~ /\Abytes=(\d*)-(\d*)\z/
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4775
|
+
first, last = $1, $2
|
|
4776
|
+
unsatisfiable = file_size.zero?
|
|
4777
|
+
|
|
4778
|
+
if first.empty?
|
|
4779
|
+
suffix_length = last.to_i
|
|
4780
|
+
unsatisfiable ||= suffix_length.zero?
|
|
4781
|
+
start_byte = [file_size - suffix_length, 0].max
|
|
4782
|
+
end_byte = file_size - 1
|
|
4783
|
+
else
|
|
4784
|
+
start_byte = first.to_i
|
|
4785
|
+
end_byte = last.empty? ? file_size - 1 : [last.to_i, file_size - 1].min
|
|
4786
|
+
unsatisfiable ||= start_byte >= file_size || end_byte < start_byte
|
|
4787
|
+
end
|
|
4688
4788
|
|
|
4689
|
-
res
|
|
4690
|
-
res["Content-Type"] = mime
|
|
4691
|
-
res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
|
|
4789
|
+
res["Content-Type"] = mime
|
|
4692
4790
|
res["Accept-Ranges"] = "bytes"
|
|
4693
|
-
|
|
4694
|
-
|
|
4791
|
+
if unsatisfiable
|
|
4792
|
+
res.status = 416
|
|
4793
|
+
res["Content-Range"] = "bytes */#{file_size}"
|
|
4794
|
+
res["Content-Length"] = "0"
|
|
4795
|
+
res.body = ""
|
|
4796
|
+
else
|
|
4797
|
+
length = end_byte - start_byte + 1
|
|
4798
|
+
res.status = 206
|
|
4799
|
+
res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
|
|
4800
|
+
res["Content-Length"] = length.to_s
|
|
4801
|
+
res.body = IO.binread(path, length, start_byte)
|
|
4802
|
+
end
|
|
4695
4803
|
else
|
|
4696
4804
|
res.status = 200
|
|
4697
4805
|
res["Content-Type"] = mime
|
|
@@ -6005,20 +6113,23 @@ module Clacky
|
|
|
6005
6113
|
json_response(res, 200, { ok: true, session_id: session_id })
|
|
6006
6114
|
end
|
|
6007
6115
|
|
|
6008
|
-
#
|
|
6009
|
-
#
|
|
6116
|
+
# POST /api/trash/sessions/delete
|
|
6117
|
+
# Body: { session_ids: ["...", ...] }
|
|
6118
|
+
# Bulk: permanently delete exactly these sessions. The caller decides the
|
|
6119
|
+
# scope (the page's filters live in the browser), so an empty list is
|
|
6120
|
+
# rejected rather than read as "everything".
|
|
6010
6121
|
private def api_trash_sessions_delete(req, res)
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
days_i = days_old.empty? ? 7 : days_old.to_i
|
|
6122
|
+
raw = parse_json_body(req)["session_ids"]
|
|
6123
|
+
ids = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
|
|
6014
6124
|
|
|
6015
|
-
|
|
6125
|
+
if ids.empty?
|
|
6126
|
+
json_response(res, 400, { ok: false, error: "session_ids is required" })
|
|
6127
|
+
return
|
|
6128
|
+
end
|
|
6016
6129
|
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
days_old: days_i
|
|
6021
|
-
})
|
|
6130
|
+
deleted = @session_manager.permanent_delete_trash_sessions(ids)
|
|
6131
|
+
|
|
6132
|
+
json_response(res, 200, { ok: true, deleted_count: deleted })
|
|
6022
6133
|
end
|
|
6023
6134
|
|
|
6024
6135
|
# ── Trash helpers (private) ─────────────────────────────────────
|
|
@@ -6468,9 +6579,17 @@ module Clacky
|
|
|
6468
6579
|
provider_id: m["provider_id"],
|
|
6469
6580
|
provider_name: provider && provider["name"],
|
|
6470
6581
|
provider_name_key: provider && provider["name_key"],
|
|
6582
|
+
# Where the row's provider manages keys / billing — the picker
|
|
6583
|
+
# links to it from the per-row top-up shortcut.
|
|
6584
|
+
website_url: provider && provider["website_url"],
|
|
6471
6585
|
capabilities: m["capabilities"],
|
|
6472
6586
|
remark: m["remark"],
|
|
6473
|
-
type: m["type"]
|
|
6587
|
+
type: m["type"],
|
|
6588
|
+
# Sub-model list for this row: the models of the provider its
|
|
6589
|
+
# endpoint belongs to (same rule the session status bar uses), so
|
|
6590
|
+
# the picker can offer the switcher on every row and not only on
|
|
6591
|
+
# the session's current one.
|
|
6592
|
+
sub_models: provider_id ? Clacky::Providers.models(provider_id) : []
|
|
6474
6593
|
}
|
|
6475
6594
|
end
|
|
6476
6595
|
# Sidecar capability configs (media/ocr) and auto-injected entries are
|
|
@@ -7213,6 +7332,45 @@ module Clacky
|
|
|
7213
7332
|
json_response(res, 500, { error: e.message })
|
|
7214
7333
|
end
|
|
7215
7334
|
|
|
7335
|
+
# POST /api/projects/:id/sessions/archive - archive every live session in a project
|
|
7336
|
+
def api_archive_project_sessions(project_id, res)
|
|
7337
|
+
return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
|
|
7338
|
+
|
|
7339
|
+
archived = _archive_project_sessions(project_id)
|
|
7340
|
+
json_response(res, 200, { ok: true, archived: archived })
|
|
7341
|
+
rescue => e
|
|
7342
|
+
json_response(res, 500, { error: e.message })
|
|
7343
|
+
end
|
|
7344
|
+
|
|
7345
|
+
# Sessions already in the archive keep their original archived_at, so the
|
|
7346
|
+
# archive page stays ordered by when the user actually put them away.
|
|
7347
|
+
private def _archive_project_sessions(project_id)
|
|
7348
|
+
archived = 0
|
|
7349
|
+
now = Time.now.iso8601
|
|
7350
|
+
|
|
7351
|
+
@session_manager.all_sessions.each do |session_data|
|
|
7352
|
+
next unless session_data[:project_id].to_s == project_id
|
|
7353
|
+
next if session_data[:archived_at]
|
|
7354
|
+
|
|
7355
|
+
sid = session_data[:session_id]
|
|
7356
|
+
live = nil
|
|
7357
|
+
@registry.with_session(sid) { |s| live = s[:agent] }
|
|
7358
|
+
|
|
7359
|
+
if live
|
|
7360
|
+
live.archived_at = now
|
|
7361
|
+
@session_manager.save(live.to_session_data(updated_at: Time.now))
|
|
7362
|
+
else
|
|
7363
|
+
# Untouched on disk: patch the loaded row rather than restoring an
|
|
7364
|
+
# agent for every session in the project just to stamp one field.
|
|
7365
|
+
@session_manager.save(session_data.merge(archived_at: now, updated_at: now))
|
|
7366
|
+
end
|
|
7367
|
+
|
|
7368
|
+
broadcast(sid, { type: "session_updated", session_id: sid, archived_at: now })
|
|
7369
|
+
archived += 1
|
|
7370
|
+
end
|
|
7371
|
+
archived
|
|
7372
|
+
end
|
|
7373
|
+
|
|
7216
7374
|
private def _delete_project_sessions(project_id)
|
|
7217
7375
|
deleted = 0
|
|
7218
7376
|
@session_manager.all_sessions.each do |session_data|
|
|
@@ -7264,6 +7422,11 @@ module Clacky
|
|
|
7264
7422
|
body = parse_json_body(req)
|
|
7265
7423
|
new_name = body["name"]&.to_s&.strip
|
|
7266
7424
|
pinned = body["pinned"]
|
|
7425
|
+
archived = body["archived"]
|
|
7426
|
+
|
|
7427
|
+
if !archived.nil? && archived != true && archived != false
|
|
7428
|
+
return json_response(res, 400, { error: "archived must be true or false" })
|
|
7429
|
+
end
|
|
7267
7430
|
|
|
7268
7431
|
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
7269
7432
|
|
|
@@ -7279,6 +7442,11 @@ module Clacky
|
|
|
7279
7442
|
if !pinned.nil?
|
|
7280
7443
|
agent.pinned = pinned
|
|
7281
7444
|
end
|
|
7445
|
+
|
|
7446
|
+
# Update archived status if provided
|
|
7447
|
+
if !archived.nil?
|
|
7448
|
+
agent.archived_at = archived ? Time.now.iso8601 : nil
|
|
7449
|
+
end
|
|
7282
7450
|
|
|
7283
7451
|
# Save session data
|
|
7284
7452
|
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
@@ -7287,16 +7455,36 @@ module Clacky
|
|
|
7287
7455
|
update_data = { type: "session_updated", session_id: session_id }
|
|
7288
7456
|
update_data[:name] = new_name if new_name && !new_name.empty?
|
|
7289
7457
|
update_data[:pinned] = pinned unless pinned.nil?
|
|
7458
|
+
update_data[:archived_at] = agent.archived_at unless archived.nil?
|
|
7290
7459
|
broadcast(session_id, update_data)
|
|
7291
7460
|
|
|
7292
7461
|
response_data = { ok: true }
|
|
7293
7462
|
response_data[:name] = new_name if new_name && !new_name.empty?
|
|
7294
7463
|
response_data[:pinned] = pinned unless pinned.nil?
|
|
7464
|
+
response_data[:archived_at] = agent.archived_at unless archived.nil?
|
|
7295
7465
|
json_response(res, 200, response_data)
|
|
7296
7466
|
rescue => e
|
|
7297
7467
|
json_response(res, 500, { error: e.message })
|
|
7298
7468
|
end
|
|
7299
7469
|
|
|
7470
|
+
# PATCH /api/sessions/:id/permission_mode — session-only, never global.
|
|
7471
|
+
def api_switch_session_permission_mode(session_id, req, res)
|
|
7472
|
+
mode = parse_json_body(req)["permission_mode"].to_s
|
|
7473
|
+
unless %w[confirm_all auto_approve].include?(mode)
|
|
7474
|
+
return json_response(res, 400, { error: "Invalid permission mode" })
|
|
7475
|
+
end
|
|
7476
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
7477
|
+
|
|
7478
|
+
@registry.with_session(session_id) do |session|
|
|
7479
|
+
agent = session[:agent]
|
|
7480
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
7481
|
+
agent.permission_mode = mode
|
|
7482
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
7483
|
+
end
|
|
7484
|
+
broadcast_session_update(session_id)
|
|
7485
|
+
json_response(res, 200, { ok: true, permission_mode: mode })
|
|
7486
|
+
end
|
|
7487
|
+
|
|
7300
7488
|
def api_switch_session_model(session_id, req, res)
|
|
7301
7489
|
body = parse_json_body(req)
|
|
7302
7490
|
model_id = body["model_id"].to_s.strip
|
|
@@ -7540,6 +7728,29 @@ module Clacky
|
|
|
7540
7728
|
json_response(res, 201, { session: @registry.snapshot(fork_id) })
|
|
7541
7729
|
end
|
|
7542
7730
|
|
|
7731
|
+
# POST /api/archive/sessions/delete — soft-deletes into the recycle bin.
|
|
7732
|
+
# With `session_ids`, only those still-archived chats go; without a body,
|
|
7733
|
+
# the whole archive (the page is paged, so only the server can list it).
|
|
7734
|
+
# An explicit empty list is rejected so "matches nothing" never means "all".
|
|
7735
|
+
def api_archive_sessions_delete(req, res)
|
|
7736
|
+
body = parse_json_body(req)
|
|
7737
|
+
ids = @registry.list(archived: true).map { |s| s[:id] }
|
|
7738
|
+
|
|
7739
|
+
if !body.is_a?(Hash) || body.key?("session_ids")
|
|
7740
|
+
raw = body.is_a?(Hash) ? body["session_ids"] : nil
|
|
7741
|
+
requested = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
|
|
7742
|
+
return json_response(res, 400, { ok: false, error: "session_ids must not be empty" }) if requested.empty?
|
|
7743
|
+
|
|
7744
|
+
ids &= requested
|
|
7745
|
+
end
|
|
7746
|
+
|
|
7747
|
+
# `list` just read these ids off disk, so skip the per-id existence
|
|
7748
|
+
# probe — `load` re-parses every session file for each call.
|
|
7749
|
+
ids.each { |session_id| soft_delete_session!(session_id, on_disk: true) }
|
|
7750
|
+
|
|
7751
|
+
json_response(res, 200, { ok: true, deleted_count: ids.size })
|
|
7752
|
+
end
|
|
7753
|
+
|
|
7543
7754
|
def api_delete_session(session_id, res)
|
|
7544
7755
|
# A session exists if it's either in the runtime registry OR on disk.
|
|
7545
7756
|
# Old sessions that were never restored into memory this server run
|
|
@@ -7547,28 +7758,36 @@ module Clacky
|
|
|
7547
7758
|
# must still be able to delete them. Previously this endpoint only
|
|
7548
7759
|
# consulted @registry and returned 404 for disk-only sessions,
|
|
7549
7760
|
# causing the "can't delete old sessions" bug.
|
|
7550
|
-
|
|
7551
|
-
on_disk = !@session_manager.load(session_id).nil?
|
|
7761
|
+
return json_response(res, 404, { error: "Session not found" }) unless soft_delete_session!(session_id)
|
|
7552
7762
|
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7763
|
+
json_response(res, 200, { ok: true })
|
|
7764
|
+
end
|
|
7765
|
+
|
|
7766
|
+
# Move one session to the recycle bin and tell everyone who can see it.
|
|
7767
|
+
# Returns false when the session is unknown — neither live nor on disk.
|
|
7768
|
+
#
|
|
7769
|
+
# `on_disk` skips the existence probe for callers that already scanned the
|
|
7770
|
+
# disk themselves: `SessionManager#load` re-parses every session file until
|
|
7771
|
+
# it finds a match, which is far too expensive to run per id in a bulk path.
|
|
7772
|
+
private def soft_delete_session!(session_id, on_disk: nil)
|
|
7773
|
+
in_registry = @registry.exist?(session_id)
|
|
7774
|
+
on_disk = !@session_manager.load(session_id).nil? if on_disk.nil?
|
|
7775
|
+
return false unless in_registry || on_disk
|
|
7556
7776
|
|
|
7557
|
-
# Registry delete is best-effort — only meaningful when the session
|
|
7558
|
-
#
|
|
7559
|
-
#
|
|
7560
|
-
#
|
|
7777
|
+
# Registry delete is best-effort — only meaningful when the session is
|
|
7778
|
+
# actually live (cancels idle timer, interrupts the agent thread). For
|
|
7779
|
+
# disk-only sessions this is a no-op and returns false, which is fine and
|
|
7780
|
+
# no longer blocks the disk cleanup below.
|
|
7561
7781
|
@registry.delete(session_id) if in_registry
|
|
7562
7782
|
|
|
7563
7783
|
# Soft-delete: move session to trash instead of permanently destroying it.
|
|
7564
7784
|
@session_manager.soft_delete(session_id) if on_disk
|
|
7565
7785
|
|
|
7566
|
-
# Notify any still-connected clients (mainly matters when the
|
|
7567
|
-
#
|
|
7786
|
+
# Notify any still-connected clients (mainly matters when the session was
|
|
7787
|
+
# live, but harmless otherwise) so other tabs drop the row too.
|
|
7568
7788
|
broadcast(session_id, { type: "session_deleted", session_id: session_id })
|
|
7569
7789
|
unsubscribe_all(session_id)
|
|
7570
|
-
|
|
7571
|
-
json_response(res, 200, { ok: true })
|
|
7790
|
+
true
|
|
7572
7791
|
end
|
|
7573
7792
|
|
|
7574
7793
|
# Export a session bundle as a .zip download containing:
|
|
@@ -7769,6 +7988,15 @@ module Clacky
|
|
|
7769
7988
|
end
|
|
7770
7989
|
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
|
|
7771
7990
|
|
|
7991
|
+
when "feedback_intervene"
|
|
7992
|
+
session_id = msg["session_id"] || conn.session_id
|
|
7993
|
+
session = @registry.get(session_id)
|
|
7994
|
+
if session&.dig(:ui)&.intervene_feedback(msg["request_id"])
|
|
7995
|
+
# Finish the handoff before processing the next message on this connection.
|
|
7996
|
+
# An immediate submission then uses the ordinary awaiting-feedback path.
|
|
7997
|
+
session[:thread]&.join
|
|
7998
|
+
end
|
|
7999
|
+
|
|
7772
8000
|
when "confirmation"
|
|
7773
8001
|
session_id = msg["session_id"] || conn.session_id
|
|
7774
8002
|
deliver_confirmation(session_id, msg["id"], msg["result"])
|
|
@@ -7787,7 +8015,9 @@ module Clacky
|
|
|
7787
8015
|
pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
|
|
7788
8016
|
non_pinned_limit = [10 - pinned_part.size, 0].max
|
|
7789
8017
|
has_more = non_pinned_part.size > non_pinned_limit
|
|
7790
|
-
|
|
8018
|
+
non_pinned_part = non_pinned_part.first(non_pinned_limit)
|
|
8019
|
+
next_cursor = non_pinned_part.last&.dig(:cursor)
|
|
8020
|
+
all_sessions = pinned_part + non_pinned_part
|
|
7791
8021
|
projects = @project_manager.all
|
|
7792
8022
|
if projects.any?
|
|
7793
8023
|
project_ids = projects.map { |p| p[:id] }
|
|
@@ -7799,7 +8029,7 @@ module Clacky
|
|
|
7799
8029
|
paged_ids = all_sessions.map { |s| s[:id] }.to_set
|
|
7800
8030
|
all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
|
|
7801
8031
|
end
|
|
7802
|
-
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
8032
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, next_cursor: next_cursor,
|
|
7803
8033
|
groups: groups, projects: projects)
|
|
7804
8034
|
|
|
7805
8035
|
when "run_task"
|
|
@@ -7871,7 +8101,6 @@ module Clacky
|
|
|
7871
8101
|
return unless @registry.exist?(session_id)
|
|
7872
8102
|
|
|
7873
8103
|
session = @registry.get(session_id)
|
|
7874
|
-
|
|
7875
8104
|
mode = @agent_config.input_behavior
|
|
7876
8105
|
queued = false
|
|
7877
8106
|
queued_created_at = nil
|
|
@@ -7916,8 +8145,8 @@ module Clacky
|
|
|
7916
8145
|
# Auto-name the session from the first user message (before agent starts running).
|
|
7917
8146
|
# Skip if the name looks like it was set by the user (not a system-generated "Session N").
|
|
7918
8147
|
if agent.history.empty? && agent.name.match?(/\ASession \d+\z/)
|
|
7919
|
-
auto_name = content.gsub(/\s+/, " ").strip[0,
|
|
7920
|
-
auto_name += "…" if content.strip.length >
|
|
8148
|
+
auto_name = content.gsub(/\s+/, " ").strip[0, 80]
|
|
8149
|
+
auto_name += "…" if content.strip.length > 80
|
|
7921
8150
|
agent.rename(auto_name)
|
|
7922
8151
|
broadcast(session_id, { type: "session_renamed", session_id: session_id, name: auto_name })
|
|
7923
8152
|
end
|
|
@@ -8227,11 +8456,14 @@ module Clacky
|
|
|
8227
8456
|
if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
|
|
8228
8457
|
preset = Clacky::Providers::PRESETS[e.provider_id]
|
|
8229
8458
|
top_up_url = preset && preset["website_url"]
|
|
8459
|
+
elsif e.respond_to?(:action_url) && e.action_url
|
|
8460
|
+
top_up_url = e.action_url
|
|
8230
8461
|
end
|
|
8231
8462
|
user_message = e.respond_to?(:display_message) && e.display_message ? e.display_message : e.message
|
|
8232
8463
|
raw_message = e.respond_to?(:raw_message) ? e.raw_message : nil
|
|
8233
8464
|
next unless @registry.update_if_epoch(session_id, epoch, status: :error, error: user_message, error_code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
8234
8465
|
broadcast_session_update(session_id)
|
|
8466
|
+
agent.record_error_event(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
8235
8467
|
web_ui&.show_error(user_message, code: code, top_up_url: top_up_url, raw_message: raw_message)
|
|
8236
8468
|
@session_manager.save(agent.to_session_data(status: :error, error_message: user_message, raw_message: raw_message, updated_at: Time.now))
|
|
8237
8469
|
end
|
|
@@ -6,7 +6,10 @@ module Clacky
|
|
|
6
6
|
# Resolves each model's input/output price (USD per 1M tokens) from
|
|
7
7
|
# Clacky::ModelPricing plus a cost ratio vs the baseline model, so the
|
|
8
8
|
# web UI never hardcodes prices - updating PRICING_TABLE is enough.
|
|
9
|
-
# Prices are
|
|
9
|
+
# Prices are what the platform actually bills: post-promotion rates plus
|
|
10
|
+
# any lane markup. `discount` is present when a promotion applied,
|
|
11
|
+
# carrying both the rate and its reason ("channel" or "promo") so the UI
|
|
12
|
+
# can pick the right explanation.
|
|
10
13
|
module ModelPrices
|
|
11
14
|
BASELINE_MODEL = "claude-sonnet-5"
|
|
12
15
|
|
|
@@ -21,15 +24,17 @@ module Clacky
|
|
|
21
24
|
pricing = Clacky::ModelPricing.get_pricing(name)
|
|
22
25
|
pricing = Clacky::ModelPricing.resolve_deepseek_tier(pricing, now) if pricing && pricing[:deepseek]
|
|
23
26
|
next unless pricing
|
|
24
|
-
|
|
27
|
+
info = Clacky::ModelPricing.discount_info(name)
|
|
28
|
+
rate = info ? info[:rate] : 1.0
|
|
29
|
+
markup = Clacky::ModelPricing.markup_rate(name)
|
|
25
30
|
# Rates are not powers of two, so the discounted product carries float
|
|
26
31
|
# noise (3.0 * 0.95 == 2.8499999999999996) that would surface verbatim
|
|
27
32
|
# in the picker tooltips. Four decimals is already finer than any
|
|
28
33
|
# listed price.
|
|
29
|
-
pin = (pricing[:input][:default] * rate).round(4)
|
|
30
|
-
pout = (pricing[:output][:default] * rate).round(4)
|
|
34
|
+
pin = (pricing[:input][:default] * rate * markup).round(4)
|
|
35
|
+
pout = (pricing[:output][:default] * rate * markup).round(4)
|
|
31
36
|
entry = { in: pin, out: pout, ratio: (pin + pout) / base_total }
|
|
32
|
-
entry[:discount] = { rate: rate } if rate < 1.0
|
|
37
|
+
entry[:discount] = { rate: rate, reason: info[:reason] } if rate < 1.0
|
|
33
38
|
prices[name] = entry
|
|
34
39
|
end
|
|
35
40
|
|