openclacky 1.5.20 → 1.5.22
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 +110 -0
- data/README.md +18 -0
- 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 +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +8 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -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 +21 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- 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 +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +66 -31
- data/lib/clacky/search_config.rb +71 -15
- 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/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +334 -89
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +78 -47
- data/lib/clacky/session_manager.rb +58 -5
- 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/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +192 -15
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1019 -204
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +9 -5
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +82 -5
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +342 -84
- data/lib/clacky/web/index.html +262 -216
- data/lib/clacky/web/projects.js +93 -5
- data/lib/clacky/web/sessions.js +423 -234
- data/lib/clacky/web/settings.js +169 -16
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +17 -42
- data/lib/clacky.rb +1 -0
- metadata +5 -1
|
@@ -75,26 +75,38 @@ module Clacky
|
|
|
75
75
|
ev[:skill_command_display] = skill_command_display if skill_command_display
|
|
76
76
|
ev[:editable] = false unless editable
|
|
77
77
|
ev[:references] = references unless Array(references).empty?
|
|
78
|
-
|
|
78
|
+
rendered_images = []
|
|
79
|
+
rendered_attachments = []
|
|
80
|
+
Array(files).each do |f|
|
|
81
|
+
next if f[:reference] || f["reference"]
|
|
82
|
+
|
|
79
83
|
url = f[:data_url] || f["data_url"]
|
|
80
84
|
name = f[:name] || f["name"]
|
|
81
85
|
path = f[:path] || f["path"]
|
|
82
86
|
type = f[:type] || f["type"] || ""
|
|
87
|
+
mime = f[:mime_type] || f["mime_type"]
|
|
88
|
+
image = type.to_s == "image" || mime.to_s.start_with?("image/")
|
|
83
89
|
|
|
84
90
|
if url
|
|
85
|
-
url
|
|
86
|
-
elsif
|
|
91
|
+
rendered_images << url
|
|
92
|
+
elsif image && path && File.exist?(path.to_s)
|
|
87
93
|
# Serve via the /api/local-image proxy instead of inlining a base64
|
|
88
94
|
# data URL. Inlining forced a synchronous disk-read + full base64
|
|
89
95
|
# encode + downscale on every history replay (2-3s lag for sessions
|
|
90
96
|
# with downgraded text-model images). The proxy lets the browser
|
|
91
97
|
# lazy-load + cache the image, keeping the replay response tiny.
|
|
92
|
-
"/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
98
|
+
rendered_images << "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
|
|
99
|
+
elsif image && name
|
|
100
|
+
rendered_images << "expired:#{name}"
|
|
93
101
|
elsif name
|
|
94
|
-
type.to_s
|
|
102
|
+
attachment_type = type.to_s.empty? ? (mime || "file") : type
|
|
103
|
+
attachment = { name: name, type: attachment_type }
|
|
104
|
+
attachment[:path] = path if path && File.exist?(path.to_s)
|
|
105
|
+
rendered_attachments << attachment
|
|
95
106
|
end
|
|
96
107
|
end
|
|
97
|
-
ev[:images] =
|
|
108
|
+
ev[:images] = rendered_images unless rendered_images.empty?
|
|
109
|
+
ev[:attachments] = rendered_attachments unless rendered_attachments.empty?
|
|
98
110
|
@events << ev
|
|
99
111
|
end
|
|
100
112
|
|
|
@@ -110,6 +122,15 @@ module Clacky
|
|
|
110
122
|
|
|
111
123
|
def show_tool_call(name, args)
|
|
112
124
|
args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
125
|
+
if Clacky::Tools::AskUser.feedback_tool?(name)
|
|
126
|
+
questions = Clacky::Tools::AskUser.normalize_questions(args_data)
|
|
127
|
+
first = questions.first || {}
|
|
128
|
+
@events << { type: "request_feedback", session_id: @session_id,
|
|
129
|
+
request_id: Clacky::Tools::AskUser.fetch_key(args_data, :request_id),
|
|
130
|
+
context: Clacky::Tools::AskUser.fetch_key(args_data, :context).to_s,
|
|
131
|
+
question: first[:question].to_s, options: first[:options] || [], questions: questions }
|
|
132
|
+
return
|
|
133
|
+
end
|
|
113
134
|
summary = tool_call_summary(name, args_data)
|
|
114
135
|
@events << stamp({ type: "tool_call", session_id: @session_id, name: name, args: args_data, summary: summary })
|
|
115
136
|
end
|
|
@@ -700,6 +721,7 @@ module Clacky
|
|
|
700
721
|
when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
|
|
701
722
|
when ["POST", "/api/browser/reload"] then api_browser_reload(res)
|
|
702
723
|
when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
|
|
724
|
+
when ["POST", "/api/browser/settings"] then api_browser_settings(req, res)
|
|
703
725
|
when ["GET", "/api/backup/status"] then api_backup_status(res)
|
|
704
726
|
when ["POST", "/api/backup/run"] then api_backup_run(res)
|
|
705
727
|
when ["GET", "/api/backup/download"] then api_backup_download(res)
|
|
@@ -732,8 +754,8 @@ module Clacky
|
|
|
732
754
|
when ["DELETE", "/api/trash"] then api_trash_delete(req, res)
|
|
733
755
|
when ["GET", "/api/trash/sessions"] then api_trash_sessions(req, res)
|
|
734
756
|
when ["POST", "/api/trash/sessions/restore"] then api_trash_session_restore(req, res)
|
|
735
|
-
when ["
|
|
736
|
-
when ["
|
|
757
|
+
when ["POST", "/api/trash/sessions/delete"] then api_trash_sessions_delete(req, res)
|
|
758
|
+
when ["POST", "/api/archive/sessions/delete"] then api_archive_sessions_delete(req, res)
|
|
737
759
|
when ["GET", "/api/profile"] then api_profile_get(res)
|
|
738
760
|
when ["PUT", "/api/profile"] then api_profile_put(req, res)
|
|
739
761
|
when ["GET", "/api/memories"] then api_memories_list(res)
|
|
@@ -828,9 +850,6 @@ module Clacky
|
|
|
828
850
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
829
851
|
action = path[%r{/git/([a-z]+)$}, 1]
|
|
830
852
|
api_session_git(session_id, action, req, res)
|
|
831
|
-
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
|
|
832
|
-
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
833
|
-
api_session_git_commit(session_id, req, res)
|
|
834
853
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
|
|
835
854
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
836
855
|
api_session_git_restore(session_id, req, res)
|
|
@@ -870,6 +889,9 @@ module Clacky
|
|
|
870
889
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/project$})
|
|
871
890
|
session_id = path.sub("/api/sessions/", "").sub("/project", "")
|
|
872
891
|
api_update_session_project(session_id, req, res)
|
|
892
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/permission_mode$})
|
|
893
|
+
session_id = path.split("/")[3]
|
|
894
|
+
api_switch_session_permission_mode(session_id, req, res)
|
|
873
895
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
|
|
874
896
|
session_id = path.sub("/api/sessions/", "").sub("/model", "")
|
|
875
897
|
api_switch_session_model(session_id, req, res)
|
|
@@ -947,9 +969,18 @@ module Clacky
|
|
|
947
969
|
elsif method == "DELETE" && path.match?(%r{^/api/memories/[^/]+$})
|
|
948
970
|
filename = URI.decode_www_form_component(path.sub("/api/memories/", ""))
|
|
949
971
|
api_memories_delete(filename, res)
|
|
972
|
+
elsif method == "GET" && path.match?(%r{^/api/projects/[^/]+/git$})
|
|
973
|
+
project_id = path.sub("/api/projects/", "").sub("/git", "")
|
|
974
|
+
api_project_git(project_id, req, res)
|
|
975
|
+
elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/git/checkout$})
|
|
976
|
+
project_id = path.sub("/api/projects/", "").sub("/git/checkout", "")
|
|
977
|
+
api_project_git_checkout(project_id, req, res)
|
|
950
978
|
elsif method == "PATCH" && path.match?(%r{^/api/projects/[^/]+$})
|
|
951
979
|
project_id = path.sub("/api/projects/", "")
|
|
952
980
|
api_update_project(project_id, req, res)
|
|
981
|
+
elsif method == "POST" && path.match?(%r{^/api/projects/[^/]+/sessions/archive$})
|
|
982
|
+
project_id = path.sub("/api/projects/", "").sub("/sessions/archive", "")
|
|
983
|
+
api_archive_project_sessions(project_id, res)
|
|
953
984
|
elsif method == "DELETE" && path.match?(%r{^/api/projects/[^/]+/sessions$})
|
|
954
985
|
project_id = path.sub("/api/projects/", "").sub("/sessions", "")
|
|
955
986
|
api_delete_project_sessions(project_id, res)
|
|
@@ -968,6 +999,7 @@ module Clacky
|
|
|
968
999
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
969
1000
|
limit = [query["limit"].to_i.then { |n| n > 0 ? n : 15 }, 50].min
|
|
970
1001
|
before = query["before"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
1002
|
+
before_id = query["before_id"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
971
1003
|
q = query["q"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
972
1004
|
q_scope = query["q_scope"].to_s.strip.then { |v| %w[name content].include?(v) ? v : "name" }
|
|
973
1005
|
date = query["date"].to_s.strip.then { |v| v.empty? ? nil : v }
|
|
@@ -987,14 +1019,15 @@ module Clacky
|
|
|
987
1019
|
# The archive view is the exception: it groups archived sessions by
|
|
988
1020
|
# project by hand, so project membership must survive both filters.
|
|
989
1021
|
exclude_project = archived ? false : (before ? true : !!type)
|
|
990
|
-
sessions = @registry.list(limit: limit + 1, before: before, q: q, q_scope: q_scope, date: date, type: type, exclude_type: exclude_type, exclude_project: exclude_project, archived: archived)
|
|
1022
|
+
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)
|
|
991
1023
|
|
|
992
1024
|
pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
|
|
993
1025
|
has_more = non_pinned_part.size > limit
|
|
994
1026
|
non_pinned_part = non_pinned_part.first(limit)
|
|
995
1027
|
sessions = pinned_part + non_pinned_part
|
|
996
1028
|
|
|
997
|
-
payload = { sessions: sessions, has_more: has_more,
|
|
1029
|
+
payload = { sessions: sessions, has_more: has_more, next_cursor: non_pinned_part.last&.dig(:cursor),
|
|
1030
|
+
groups: @registry.group_stats_all }
|
|
998
1031
|
# The archive page offers "delete all", so it needs the size of the whole
|
|
999
1032
|
# archive — the paged `sessions` array only ever shows one page of it.
|
|
1000
1033
|
payload[:archived_count] = @registry.archived_count if archived
|
|
@@ -1682,6 +1715,22 @@ module Clacky
|
|
|
1682
1715
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1683
1716
|
end
|
|
1684
1717
|
|
|
1718
|
+
# POST /api/browser/settings
|
|
1719
|
+
# Body: { screenshot_max_base64_bytes: 300000 }
|
|
1720
|
+
# The field is optional — omitting it keeps the current value.
|
|
1721
|
+
def api_browser_settings(req, res)
|
|
1722
|
+
body = JSON.parse(req.body.to_s) rescue {}
|
|
1723
|
+
|
|
1724
|
+
settings = @browser_manager.update_screenshot_settings(
|
|
1725
|
+
max_base64_bytes: body["screenshot_max_base64_bytes"]
|
|
1726
|
+
)
|
|
1727
|
+
json_response(res, 200, { ok: true }.merge(settings))
|
|
1728
|
+
rescue ArgumentError => e
|
|
1729
|
+
json_response(res, 422, { ok: false, error: e.message })
|
|
1730
|
+
rescue StandardError => e
|
|
1731
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
1732
|
+
end
|
|
1733
|
+
|
|
1685
1734
|
# GET /api/backup/status
|
|
1686
1735
|
def api_backup_status(res)
|
|
1687
1736
|
json_response(res, 200, BackupManager.status)
|
|
@@ -2287,45 +2336,77 @@ module Clacky
|
|
|
2287
2336
|
json_response(res, 200, {
|
|
2288
2337
|
ok: true,
|
|
2289
2338
|
provider: config["provider"],
|
|
2290
|
-
|
|
2339
|
+
keys_masked: masked_search_keys(config),
|
|
2291
2340
|
available: Clacky::Utils::SearcherManager.available
|
|
2292
2341
|
})
|
|
2293
2342
|
end
|
|
2294
2343
|
|
|
2295
2344
|
# PATCH /api/config/search
|
|
2296
|
-
# Body: { provider
|
|
2345
|
+
# Body: { provider?: "tavily"|"deepseek_web"|"", key?: "..." }
|
|
2346
|
+
# Omitted fields keep their current value.
|
|
2297
2347
|
def api_update_search_config(req, res)
|
|
2298
2348
|
body = parse_json_body(req)
|
|
2299
2349
|
return json_response(res, 400, { error: "Invalid JSON" }) unless body
|
|
2300
2350
|
|
|
2301
2351
|
provider = body["provider"].to_s.strip
|
|
2302
|
-
|
|
2352
|
+
if body.key?("provider") && !search_provider_known?(provider)
|
|
2303
2353
|
return json_response(res, 422, { error: "Unknown search provider: #{provider}" })
|
|
2304
2354
|
end
|
|
2305
2355
|
|
|
2356
|
+
current = Clacky::SearchConfig.load
|
|
2357
|
+
final_provider = body.key?("provider") ? provider : current["provider"]
|
|
2358
|
+
# A masked value only ever stands for the same provider's stored key.
|
|
2306
2359
|
key = body["key"].to_s
|
|
2307
|
-
key = Clacky::SearchConfig.
|
|
2360
|
+
key = Clacky::SearchConfig.key_for(final_provider) if key.include?("****")
|
|
2361
|
+
final_key = body.key?("key") ? key : Clacky::SearchConfig.key_for(final_provider)
|
|
2362
|
+
|
|
2363
|
+
# deepseek_web falls back to the configured model key, so it may be
|
|
2364
|
+
# saved without one. Every other searcher is a script that reads the
|
|
2365
|
+
# key from CLACKY_SEARCH_KEY and cannot run without it.
|
|
2366
|
+
if !final_provider.empty? &&
|
|
2367
|
+
final_provider != Clacky::SearchConfig::DEEPSEEK_WEB &&
|
|
2368
|
+
final_key.to_s.strip.empty?
|
|
2369
|
+
return json_response(res, 422, {
|
|
2370
|
+
error: "API key is required for search provider '#{final_provider}'"
|
|
2371
|
+
})
|
|
2372
|
+
end
|
|
2308
2373
|
|
|
2309
|
-
Clacky::SearchConfig.save(
|
|
2310
|
-
|
|
2374
|
+
Clacky::SearchConfig.save(
|
|
2375
|
+
provider: body.key?("provider") ? provider : nil,
|
|
2376
|
+
key: body.key?("key") ? key : nil
|
|
2377
|
+
)
|
|
2378
|
+
config = Clacky::SearchConfig.load
|
|
2379
|
+
json_response(res, 200, { ok: true, provider: config["provider"], keys_masked: masked_search_keys(config) })
|
|
2311
2380
|
rescue => e
|
|
2312
2381
|
json_response(res, 422, { error: e.message })
|
|
2313
2382
|
end
|
|
2314
2383
|
|
|
2384
|
+
private def masked_search_keys(config)
|
|
2385
|
+
config["keys"].transform_values { |key| mask_api_key(key) }
|
|
2386
|
+
end
|
|
2387
|
+
|
|
2388
|
+
private def search_provider_known?(provider)
|
|
2389
|
+
return true if provider.empty?
|
|
2390
|
+
return true if provider == Clacky::SearchConfig::DEEPSEEK_WEB
|
|
2391
|
+
|
|
2392
|
+
!Clacky::Utils::SearcherManager.path_for(provider).nil?
|
|
2393
|
+
end
|
|
2394
|
+
|
|
2315
2395
|
# POST /api/config/search/test
|
|
2316
|
-
# Runs the
|
|
2317
|
-
# answer before saving, rather than discovering a bad key mid-task.
|
|
2396
|
+
# Runs the configured engine with a fixed query so the user gets a
|
|
2397
|
+
# definitive answer before saving, rather than discovering a bad key mid-task.
|
|
2318
2398
|
def api_test_search_config(req, res)
|
|
2319
2399
|
body = parse_json_body(req) || {}
|
|
2320
2400
|
provider = body["provider"].to_s.strip
|
|
2321
2401
|
return json_response(res, 200, { ok: false, message: "provider is required" }) if provider.empty?
|
|
2322
2402
|
|
|
2403
|
+
key = body["key"].to_s
|
|
2404
|
+
key = Clacky::SearchConfig.key_for(provider) if key.empty? || key.include?("****")
|
|
2405
|
+
return test_deepseek_web_search(res, key) if provider == Clacky::SearchConfig::DEEPSEEK_WEB
|
|
2406
|
+
|
|
2323
2407
|
script = Clacky::Utils::SearcherManager.path_for(provider)
|
|
2324
2408
|
return json_response(res, 200, { ok: false, message: "searcher not found: #{provider}" }) unless script
|
|
2325
2409
|
|
|
2326
|
-
key = body["key"].to_s
|
|
2327
|
-
key = Clacky::SearchConfig.key if key.empty? || key.include?("****")
|
|
2328
|
-
|
|
2329
2410
|
stdout, stderr, status = Clacky::Utils::ParserManager.capture3_with_timeout(
|
|
2330
2411
|
{ "CLACKY_SEARCH_KEY" => key },
|
|
2331
2412
|
Clacky::Utils::SearcherManager.interpreter_for(script),
|
|
@@ -2353,6 +2434,23 @@ module Clacky
|
|
|
2353
2434
|
json_response(res, 200, { ok: false, message: e.message })
|
|
2354
2435
|
end
|
|
2355
2436
|
|
|
2437
|
+
private def test_deepseek_web_search(res, key)
|
|
2438
|
+
key = @agent_config.deepseek_web_key.to_s if key.empty?
|
|
2439
|
+
if key.empty?
|
|
2440
|
+
return json_response(res, 200, {
|
|
2441
|
+
ok: false, message: "No DeepSeek key: fill one in or configure a DeepSeek / OpenClacky model"
|
|
2442
|
+
})
|
|
2443
|
+
end
|
|
2444
|
+
|
|
2445
|
+
results = Clacky::DeepSeekWebTools.search(
|
|
2446
|
+
query: "openclacky", count: 1, api_key: key,
|
|
2447
|
+
base_url: @agent_config.deepseek_web_base_url(key)
|
|
2448
|
+
)
|
|
2449
|
+
json_response(res, 200, { ok: true, message: "Returned #{results.size} result(s)" })
|
|
2450
|
+
rescue => e
|
|
2451
|
+
json_response(res, 200, { ok: false, message: e.message })
|
|
2452
|
+
end
|
|
2453
|
+
|
|
2356
2454
|
# POST /api/internal/ocr-image
|
|
2357
2455
|
# Internal endpoint used by parser scripts (e.g. pdf_parser_vlm.py) to
|
|
2358
2456
|
# transcribe a single image via the configured OCR sidecar. Localhost-
|
|
@@ -4511,10 +4609,12 @@ module Clacky
|
|
|
4511
4609
|
|
|
4512
4610
|
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
4513
4611
|
ext = File.extname(path).downcase.sub(/\A\./, "")
|
|
4612
|
+
# Extension-less files (Dockerfile, Makefile, Gemfile) declare no
|
|
4613
|
+
# document type, so no app lists them. Plain text is the closest
|
|
4614
|
+
# approximation and keeps the picker useful.
|
|
4615
|
+
ext = "txt" if ext.empty?
|
|
4514
4616
|
|
|
4515
|
-
apps = if
|
|
4516
|
-
[]
|
|
4517
|
-
elsif Utils::EnvironmentDetector.os_type == :wsl
|
|
4617
|
+
apps = if Utils::EnvironmentDetector.os_type == :wsl
|
|
4518
4618
|
Utils::WindowsAppDetector.apps_for_ext(ext)
|
|
4519
4619
|
else
|
|
4520
4620
|
Utils::MacAppDetector.apps_for_ext(ext)
|
|
@@ -4531,9 +4631,7 @@ module Clacky
|
|
|
4531
4631
|
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
4532
4632
|
|
|
4533
4633
|
path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
|
|
4534
|
-
app = if
|
|
4535
|
-
nil
|
|
4536
|
-
elsif Utils::EnvironmentDetector.os_type == :wsl
|
|
4634
|
+
app = if Utils::EnvironmentDetector.os_type == :wsl
|
|
4537
4635
|
Utils::WindowsAppDetector.default_app_for(path)
|
|
4538
4636
|
else
|
|
4539
4637
|
Utils::MacAppDetector.default_app_for(path)
|
|
@@ -4603,6 +4701,9 @@ module Clacky
|
|
|
4603
4701
|
when "open"
|
|
4604
4702
|
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
4605
4703
|
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
4704
|
+
# system() returns false when the launcher exits non-zero (broken app
|
|
4705
|
+
# association, no handler). Surface it instead of a silent 200.
|
|
4706
|
+
return json_response(res, 500, { error: "failed to open file" }) unless result
|
|
4606
4707
|
json_response(res, 200, { ok: true })
|
|
4607
4708
|
when "open_with"
|
|
4608
4709
|
result = if Utils::EnvironmentDetector.os_type == :wsl
|
|
@@ -5335,9 +5436,22 @@ module Clacky
|
|
|
5335
5436
|
when "status"
|
|
5336
5437
|
json_response(res, 200, { repo: true }.merge(Clacky::Server::GitPanel.status(dir)))
|
|
5337
5438
|
when "diff"
|
|
5338
|
-
json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(
|
|
5439
|
+
json_response(res, 200, { repo: true, diff: Clacky::Server::GitPanel.diff(
|
|
5440
|
+
dir, file: query["file"], context: query["context"],
|
|
5441
|
+
scope: query["scope"], base: query["base"]
|
|
5442
|
+
) })
|
|
5443
|
+
when "review"
|
|
5444
|
+
if query["scope"].to_s == "lastTurn"
|
|
5445
|
+
json_response(res, 200, Clacky::Server::GitPanel.last_turn_review(git_session_agent(session_id)))
|
|
5446
|
+
else
|
|
5447
|
+
json_response(res, 200, Clacky::Server::GitPanel.review(
|
|
5448
|
+
dir, scope: query["scope"], base: query["base"]
|
|
5449
|
+
))
|
|
5450
|
+
end
|
|
5339
5451
|
when "log"
|
|
5340
|
-
json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(
|
|
5452
|
+
json_response(res, 200, { repo: true, commits: Clacky::Server::GitPanel.log(
|
|
5453
|
+
dir, limit: query["limit"] || 50, unpushed: query["unpushed"].to_s == "1"
|
|
5454
|
+
) })
|
|
5341
5455
|
when "branches"
|
|
5342
5456
|
json_response(res, 200, { repo: true, branches: Clacky::Server::GitPanel.branches(dir) })
|
|
5343
5457
|
else
|
|
@@ -5345,24 +5459,6 @@ module Clacky
|
|
|
5345
5459
|
end
|
|
5346
5460
|
end
|
|
5347
5461
|
|
|
5348
|
-
# POST /api/sessions/:id/git/commit — body: { message:, files: [..] }.
|
|
5349
|
-
def api_session_git_commit(session_id, req, res)
|
|
5350
|
-
dir = git_session_dir(session_id, res)
|
|
5351
|
-
return unless dir
|
|
5352
|
-
|
|
5353
|
-
unless Clacky::Server::GitPanel.repo?(dir)
|
|
5354
|
-
return json_response(res, 400, { error: "Not a git repository" })
|
|
5355
|
-
end
|
|
5356
|
-
|
|
5357
|
-
body = parse_json_body(req)
|
|
5358
|
-
result = Clacky::Server::GitPanel.commit(dir, message: body["message"], files: body["files"])
|
|
5359
|
-
if result[:ok]
|
|
5360
|
-
json_response(res, 200, result)
|
|
5361
|
-
else
|
|
5362
|
-
json_response(res, 422, { error: result[:error] })
|
|
5363
|
-
end
|
|
5364
|
-
end
|
|
5365
|
-
|
|
5366
5462
|
# POST /api/sessions/:id/git/restore - body: { file: }.
|
|
5367
5463
|
# Discards uncommitted changes to one file, back to its HEAD state.
|
|
5368
5464
|
def api_session_git_restore(session_id, req, res)
|
|
@@ -5466,8 +5562,7 @@ module Clacky
|
|
|
5466
5562
|
json_response(res, 404, { error: "Session not found" })
|
|
5467
5563
|
return nil
|
|
5468
5564
|
end
|
|
5469
|
-
|
|
5470
|
-
agent = session && session[:agent]
|
|
5565
|
+
agent = git_session_agent(session_id)
|
|
5471
5566
|
unless agent
|
|
5472
5567
|
json_response(res, 404, { error: "Session not found" })
|
|
5473
5568
|
return nil
|
|
@@ -5479,6 +5574,11 @@ module Clacky
|
|
|
5479
5574
|
end
|
|
5480
5575
|
dir
|
|
5481
5576
|
end
|
|
5577
|
+
|
|
5578
|
+
private def git_session_agent(session_id)
|
|
5579
|
+
session = @registry.get(session_id)
|
|
5580
|
+
session && session[:agent]
|
|
5581
|
+
end
|
|
5482
5582
|
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
5483
5583
|
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
5484
5584
|
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
@@ -5492,7 +5592,11 @@ module Clacky
|
|
|
5492
5592
|
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
5493
5593
|
|
|
5494
5594
|
root = File.expand_path(agent.working_dir.to_s)
|
|
5495
|
-
|
|
5595
|
+
unless Dir.exist?(root)
|
|
5596
|
+
# `code` lets the client tell "the folder was deleted" apart from any
|
|
5597
|
+
# other failure and explain it, instead of showing a generic error.
|
|
5598
|
+
return json_response(res, 404, { error: "Working directory not found", code: "working_dir_missing", path: root })
|
|
5599
|
+
end
|
|
5496
5600
|
|
|
5497
5601
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
5498
5602
|
rel = query["path"].to_s
|
|
@@ -5517,7 +5621,9 @@ module Clacky
|
|
|
5517
5621
|
end
|
|
5518
5622
|
end
|
|
5519
5623
|
|
|
5520
|
-
|
|
5624
|
+
unless Dir.exist?(target)
|
|
5625
|
+
return json_response(res, 404, { error: "Directory not found", code: "directory_missing" })
|
|
5626
|
+
end
|
|
5521
5627
|
entries = Dir.children(target).reject do |name|
|
|
5522
5628
|
IGNORED_FILE_ENTRIES.include?(name) || (!show_hidden && name.start_with?("."))
|
|
5523
5629
|
end
|
|
@@ -6034,20 +6140,23 @@ module Clacky
|
|
|
6034
6140
|
json_response(res, 200, { ok: true, session_id: session_id })
|
|
6035
6141
|
end
|
|
6036
6142
|
|
|
6037
|
-
#
|
|
6038
|
-
#
|
|
6143
|
+
# POST /api/trash/sessions/delete
|
|
6144
|
+
# Body: { session_ids: ["...", ...] }
|
|
6145
|
+
# Bulk: permanently delete exactly these sessions. The caller decides the
|
|
6146
|
+
# scope (the page's filters live in the browser), so an empty list is
|
|
6147
|
+
# rejected rather than read as "everything".
|
|
6039
6148
|
private def api_trash_sessions_delete(req, res)
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
days_i = days_old.empty? ? 7 : days_old.to_i
|
|
6149
|
+
raw = parse_json_body(req)["session_ids"]
|
|
6150
|
+
ids = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
|
|
6043
6151
|
|
|
6044
|
-
|
|
6152
|
+
if ids.empty?
|
|
6153
|
+
json_response(res, 400, { ok: false, error: "session_ids is required" })
|
|
6154
|
+
return
|
|
6155
|
+
end
|
|
6045
6156
|
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
days_old: days_i
|
|
6050
|
-
})
|
|
6157
|
+
deleted = @session_manager.permanent_delete_trash_sessions(ids)
|
|
6158
|
+
|
|
6159
|
+
json_response(res, 200, { ok: true, deleted_count: deleted })
|
|
6051
6160
|
end
|
|
6052
6161
|
|
|
6053
6162
|
# ── Trash helpers (private) ─────────────────────────────────────
|
|
@@ -7198,6 +7307,71 @@ module Clacky
|
|
|
7198
7307
|
json_response(res, 500, { error: e.message })
|
|
7199
7308
|
end
|
|
7200
7309
|
|
|
7310
|
+
# GET /api/projects/:id/git?dir= — branch state of a work tree, used by
|
|
7311
|
+
# the new-session branch picker. `dir` overrides the project's own
|
|
7312
|
+
# working dir, since a session may run in a different repo. `repo: false`
|
|
7313
|
+
# covers a missing working dir, a missing directory, and plain non-git
|
|
7314
|
+
# folders, so the picker can simply hide itself.
|
|
7315
|
+
def api_project_git(project_id, req, res)
|
|
7316
|
+
project = @project_manager.find(project_id)
|
|
7317
|
+
return json_response(res, 404, { error: "Project not found" }) unless project
|
|
7318
|
+
|
|
7319
|
+
dir = git_work_tree(project, git_dir_param(req))
|
|
7320
|
+
return json_response(res, 200, { repo: false }) unless dir
|
|
7321
|
+
|
|
7322
|
+
json_response(res, 200, {
|
|
7323
|
+
repo: true,
|
|
7324
|
+
branch: Clacky::Server::GitPanel.branch_name(dir),
|
|
7325
|
+
branches: Clacky::Server::GitPanel.branches(dir),
|
|
7326
|
+
})
|
|
7327
|
+
end
|
|
7328
|
+
|
|
7329
|
+
# POST /api/projects/:id/git/checkout — body: { branch:, create:?, from:?, dir:? }
|
|
7330
|
+
# Switches a work tree. `create: true` branches off `from`, which may be
|
|
7331
|
+
# a remote ref (origin/feature) to get a tracking branch.
|
|
7332
|
+
def api_project_git_checkout(project_id, req, res)
|
|
7333
|
+
project = @project_manager.find(project_id)
|
|
7334
|
+
return json_response(res, 404, { error: "Project not found" }) unless project
|
|
7335
|
+
|
|
7336
|
+
body = parse_json_body(req)
|
|
7337
|
+
dir = git_work_tree(project, body["dir"])
|
|
7338
|
+
return json_response(res, 400, { error: "Not a git repository" }) unless dir
|
|
7339
|
+
|
|
7340
|
+
result = Clacky::Server::GitPanel.checkout(
|
|
7341
|
+
dir,
|
|
7342
|
+
branch: body["branch"],
|
|
7343
|
+
create: body["create"] == true,
|
|
7344
|
+
from: body["from"]
|
|
7345
|
+
)
|
|
7346
|
+
|
|
7347
|
+
unless result[:ok]
|
|
7348
|
+
return json_response(res, 422, { error: result[:error] })
|
|
7349
|
+
end
|
|
7350
|
+
|
|
7351
|
+
json_response(res, 200, {
|
|
7352
|
+
ok: true,
|
|
7353
|
+
branch: Clacky::Server::GitPanel.branch_name(dir),
|
|
7354
|
+
branches: Clacky::Server::GitPanel.branches(dir),
|
|
7355
|
+
})
|
|
7356
|
+
end
|
|
7357
|
+
|
|
7358
|
+
def git_dir_param(req)
|
|
7359
|
+
URI.decode_www_form(req.query_string.to_s).to_h["dir"]
|
|
7360
|
+
rescue ArgumentError
|
|
7361
|
+
nil
|
|
7362
|
+
end
|
|
7363
|
+
|
|
7364
|
+
# Resolves the work tree a git request targets: the explicit `dir` when
|
|
7365
|
+
# given, otherwise the project's own working dir. Nil when the path is
|
|
7366
|
+
# not an existing git repository.
|
|
7367
|
+
def git_work_tree(project, dir_param)
|
|
7368
|
+
raw = dir_param.to_s.strip
|
|
7369
|
+
dir = raw.empty? ? project[:working_dir].to_s : File.expand_path(raw)
|
|
7370
|
+
return nil if dir.empty? || !Dir.exist?(dir)
|
|
7371
|
+
return nil unless Clacky::Server::GitPanel.repo?(dir)
|
|
7372
|
+
dir
|
|
7373
|
+
end
|
|
7374
|
+
|
|
7201
7375
|
# PATCH /api/projects/:id - update a project
|
|
7202
7376
|
# Body: { name:?, description:?, color:?, working_dir:?, pinned:? } - only provided fields are changed
|
|
7203
7377
|
def api_update_project(project_id, req, res)
|
|
@@ -7250,6 +7424,45 @@ module Clacky
|
|
|
7250
7424
|
json_response(res, 500, { error: e.message })
|
|
7251
7425
|
end
|
|
7252
7426
|
|
|
7427
|
+
# POST /api/projects/:id/sessions/archive - archive every live session in a project
|
|
7428
|
+
def api_archive_project_sessions(project_id, res)
|
|
7429
|
+
return json_response(res, 404, { error: "Project not found" }) unless @project_manager.find(project_id)
|
|
7430
|
+
|
|
7431
|
+
archived = _archive_project_sessions(project_id)
|
|
7432
|
+
json_response(res, 200, { ok: true, archived: archived })
|
|
7433
|
+
rescue => e
|
|
7434
|
+
json_response(res, 500, { error: e.message })
|
|
7435
|
+
end
|
|
7436
|
+
|
|
7437
|
+
# Sessions already in the archive keep their original archived_at, so the
|
|
7438
|
+
# archive page stays ordered by when the user actually put them away.
|
|
7439
|
+
private def _archive_project_sessions(project_id)
|
|
7440
|
+
archived = 0
|
|
7441
|
+
now = Time.now.iso8601
|
|
7442
|
+
|
|
7443
|
+
@session_manager.all_sessions.each do |session_data|
|
|
7444
|
+
next unless session_data[:project_id].to_s == project_id
|
|
7445
|
+
next if session_data[:archived_at]
|
|
7446
|
+
|
|
7447
|
+
sid = session_data[:session_id]
|
|
7448
|
+
live = nil
|
|
7449
|
+
@registry.with_session(sid) { |s| live = s[:agent] }
|
|
7450
|
+
|
|
7451
|
+
if live
|
|
7452
|
+
live.archived_at = now
|
|
7453
|
+
@session_manager.save(live.to_session_data(updated_at: Time.now))
|
|
7454
|
+
else
|
|
7455
|
+
# Untouched on disk: patch the loaded row rather than restoring an
|
|
7456
|
+
# agent for every session in the project just to stamp one field.
|
|
7457
|
+
@session_manager.save(session_data.merge(archived_at: now, updated_at: now))
|
|
7458
|
+
end
|
|
7459
|
+
|
|
7460
|
+
broadcast(sid, { type: "session_updated", session_id: sid, archived_at: now })
|
|
7461
|
+
archived += 1
|
|
7462
|
+
end
|
|
7463
|
+
archived
|
|
7464
|
+
end
|
|
7465
|
+
|
|
7253
7466
|
private def _delete_project_sessions(project_id)
|
|
7254
7467
|
deleted = 0
|
|
7255
7468
|
@session_manager.all_sessions.each do |session_data|
|
|
@@ -7346,6 +7559,24 @@ module Clacky
|
|
|
7346
7559
|
json_response(res, 500, { error: e.message })
|
|
7347
7560
|
end
|
|
7348
7561
|
|
|
7562
|
+
# PATCH /api/sessions/:id/permission_mode — session-only, never global.
|
|
7563
|
+
def api_switch_session_permission_mode(session_id, req, res)
|
|
7564
|
+
mode = parse_json_body(req)["permission_mode"].to_s
|
|
7565
|
+
unless %w[confirm_all auto_approve].include?(mode)
|
|
7566
|
+
return json_response(res, 400, { error: "Invalid permission mode" })
|
|
7567
|
+
end
|
|
7568
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
7569
|
+
|
|
7570
|
+
@registry.with_session(session_id) do |session|
|
|
7571
|
+
agent = session[:agent]
|
|
7572
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
7573
|
+
agent.permission_mode = mode
|
|
7574
|
+
@session_manager.save(agent.to_session_data(updated_at: Time.now))
|
|
7575
|
+
end
|
|
7576
|
+
broadcast_session_update(session_id)
|
|
7577
|
+
json_response(res, 200, { ok: true, permission_mode: mode })
|
|
7578
|
+
end
|
|
7579
|
+
|
|
7349
7580
|
def api_switch_session_model(session_id, req, res)
|
|
7350
7581
|
body = parse_json_body(req)
|
|
7351
7582
|
model_id = body["model_id"].to_s.strip
|
|
@@ -7589,15 +7820,21 @@ module Clacky
|
|
|
7589
7820
|
json_response(res, 201, { session: @registry.snapshot(fork_id) })
|
|
7590
7821
|
end
|
|
7591
7822
|
|
|
7592
|
-
#
|
|
7593
|
-
#
|
|
7594
|
-
# the
|
|
7595
|
-
#
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7823
|
+
# POST /api/archive/sessions/delete — soft-deletes into the recycle bin.
|
|
7824
|
+
# With `session_ids`, only those still-archived chats go; without a body,
|
|
7825
|
+
# the whole archive (the page is paged, so only the server can list it).
|
|
7826
|
+
# An explicit empty list is rejected so "matches nothing" never means "all".
|
|
7827
|
+
def api_archive_sessions_delete(req, res)
|
|
7828
|
+
body = parse_json_body(req)
|
|
7829
|
+
ids = @registry.list(archived: true).map { |s| s[:id] }
|
|
7830
|
+
|
|
7831
|
+
if !body.is_a?(Hash) || body.key?("session_ids")
|
|
7832
|
+
raw = body.is_a?(Hash) ? body["session_ids"] : nil
|
|
7833
|
+
requested = raw.is_a?(Array) ? raw.map { |id| id.to_s.strip }.reject(&:empty?) : []
|
|
7834
|
+
return json_response(res, 400, { ok: false, error: "session_ids must not be empty" }) if requested.empty?
|
|
7835
|
+
|
|
7836
|
+
ids &= requested
|
|
7837
|
+
end
|
|
7601
7838
|
|
|
7602
7839
|
# `list` just read these ids off disk, so skip the per-id existence
|
|
7603
7840
|
# probe — `load` re-parses every session file for each call.
|
|
@@ -7841,14 +8078,16 @@ module Clacky
|
|
|
7841
8078
|
raw_images = (msg["images"] || []).map do |data_url|
|
|
7842
8079
|
{ "data_url" => data_url, "name" => "image.jpg", "mime_type" => "image/jpeg" }
|
|
7843
8080
|
end
|
|
7844
|
-
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || []
|
|
8081
|
+
handle_user_message(session_id, msg["content"].to_s, (msg["files"] || []) + raw_images, references: msg["references"] || [])
|
|
7845
8082
|
|
|
7846
|
-
when "
|
|
8083
|
+
when "feedback_intervene"
|
|
7847
8084
|
session_id = msg["session_id"] || conn.session_id
|
|
7848
|
-
@registry.get(session_id)
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
8085
|
+
session = @registry.get(session_id)
|
|
8086
|
+
if session&.dig(:ui)&.intervene_feedback(msg["request_id"])
|
|
8087
|
+
# Finish the handoff before processing the next message on this connection.
|
|
8088
|
+
# An immediate submission then uses the ordinary awaiting-feedback path.
|
|
8089
|
+
session[:thread]&.join
|
|
8090
|
+
end
|
|
7852
8091
|
|
|
7853
8092
|
when "confirmation"
|
|
7854
8093
|
session_id = msg["session_id"] || conn.session_id
|
|
@@ -7868,7 +8107,9 @@ module Clacky
|
|
|
7868
8107
|
pinned_part, non_pinned_part = page.partition { |s| s[:pinned] }
|
|
7869
8108
|
non_pinned_limit = [10 - pinned_part.size, 0].max
|
|
7870
8109
|
has_more = non_pinned_part.size > non_pinned_limit
|
|
7871
|
-
|
|
8110
|
+
non_pinned_part = non_pinned_part.first(non_pinned_limit)
|
|
8111
|
+
next_cursor = non_pinned_part.last&.dig(:cursor)
|
|
8112
|
+
all_sessions = pinned_part + non_pinned_part
|
|
7872
8113
|
projects = @project_manager.all
|
|
7873
8114
|
if projects.any?
|
|
7874
8115
|
project_ids = projects.map { |p| p[:id] }
|
|
@@ -7880,7 +8121,7 @@ module Clacky
|
|
|
7880
8121
|
paged_ids = all_sessions.map { |s| s[:id] }.to_set
|
|
7881
8122
|
all_sessions += project_sessions.reject { |s| paged_ids.include?(s[:id]) }
|
|
7882
8123
|
end
|
|
7883
|
-
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more,
|
|
8124
|
+
conn.send_json(type: "session_list", sessions: all_sessions, has_more: has_more, next_cursor: next_cursor,
|
|
7884
8125
|
groups: groups, projects: projects)
|
|
7885
8126
|
|
|
7886
8127
|
when "run_task"
|
|
@@ -7948,12 +8189,10 @@ module Clacky
|
|
|
7948
8189
|
end
|
|
7949
8190
|
end
|
|
7950
8191
|
|
|
7951
|
-
def handle_user_message(session_id, content, files = [], references: []
|
|
8192
|
+
def handle_user_message(session_id, content, files = [], references: [])
|
|
7952
8193
|
return unless @registry.exist?(session_id)
|
|
7953
8194
|
|
|
7954
8195
|
session = @registry.get(session_id)
|
|
7955
|
-
feedback_options = feedback ? { feedback: feedback } : {}
|
|
7956
|
-
|
|
7957
8196
|
mode = @agent_config.input_behavior
|
|
7958
8197
|
queued = false
|
|
7959
8198
|
queued_created_at = nil
|
|
@@ -7961,7 +8200,7 @@ module Clacky
|
|
|
7961
8200
|
if s[:status] == :running && %w[queue steer].include?(mode)
|
|
7962
8201
|
queued_created_at = Time.now.to_f
|
|
7963
8202
|
s[:agent].enqueue_input(content, delivery: mode.to_sym, files: files, references_display: references,
|
|
7964
|
-
reference_contexts: build_reference_contexts(references), created_at: queued_created_at
|
|
8203
|
+
reference_contexts: build_reference_contexts(references), created_at: queued_created_at)
|
|
7965
8204
|
queued = true
|
|
7966
8205
|
end
|
|
7967
8206
|
end
|
|
@@ -7984,7 +8223,7 @@ module Clacky
|
|
|
7984
8223
|
if old_thread && !old_thread.join(2)
|
|
7985
8224
|
retained_created_at = Time.now.to_f
|
|
7986
8225
|
session[:agent].enqueue_input(content, files: files, references_display: references,
|
|
7987
|
-
reference_contexts: build_reference_contexts(references), created_at: retained_created_at
|
|
8226
|
+
reference_contexts: build_reference_contexts(references), created_at: retained_created_at)
|
|
7988
8227
|
broadcast(session_id, { type: "input_enqueued", session_id: session_id, created_at: retained_created_at })
|
|
7989
8228
|
broadcast(session_id, { type: "input_queue_notice", session_id: session_id, key: "chat.input.stoppingDelayed" })
|
|
7990
8229
|
return
|
|
@@ -8042,7 +8281,7 @@ module Clacky
|
|
|
8042
8281
|
# "Thinking..." forever. A web-originated turn bound to an IM channel gets a
|
|
8043
8282
|
# card too, so tools/narration render like a native IM turn (#603).
|
|
8044
8283
|
@channel_manager.channel_ui_for_session(session_id)&.start_task(reply_to: nil)
|
|
8045
|
-
agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references
|
|
8284
|
+
agent.run(content, files: files, reference_contexts: reference_contexts, created_at: msg_created_at, references_display: references)
|
|
8046
8285
|
end
|
|
8047
8286
|
end
|
|
8048
8287
|
|
|
@@ -8256,6 +8495,7 @@ module Clacky
|
|
|
8256
8495
|
Thread.current[:task_epoch] = epoch
|
|
8257
8496
|
run_result = task.call
|
|
8258
8497
|
awaiting = false
|
|
8498
|
+
web_ui = nil
|
|
8259
8499
|
owns_epoch = true
|
|
8260
8500
|
loop do
|
|
8261
8501
|
owns_epoch = @registry.current_epoch(session_id).to_i == epoch.to_i
|
|
@@ -8265,6 +8505,7 @@ module Clacky
|
|
|
8265
8505
|
@registry.with_session(session_id) do |s|
|
|
8266
8506
|
owns_epoch = s[:epoch].to_i == epoch.to_i
|
|
8267
8507
|
next unless owns_epoch
|
|
8508
|
+
web_ui = s[:ui]
|
|
8268
8509
|
awaiting = run_result.is_a?(Hash) && run_result[:awaiting_user_feedback]
|
|
8269
8510
|
pending = agent.take_pending_input if Agent.task_completed?(run_result)
|
|
8270
8511
|
unless pending
|
|
@@ -8283,7 +8524,11 @@ module Clacky
|
|
|
8283
8524
|
# session history: a chime is a live cue, never replayed on refresh.
|
|
8284
8525
|
# Also fires when awaiting feedback — that is precisely when the user
|
|
8285
8526
|
# most needs pulling back; the flag lets the client word it differently.
|
|
8286
|
-
|
|
8527
|
+
finished = { type: "task_finished", session_id: session_id, awaiting_feedback: !!awaiting }
|
|
8528
|
+
# Lets clients not viewing this session keep the summary until it is first opened.
|
|
8529
|
+
complete = web_ui&.last_complete
|
|
8530
|
+
finished[:complete] = complete if complete&.dig(:task_id) && run_result.is_a?(Hash) && complete[:task_id] == run_result[:task_id]
|
|
8531
|
+
broadcast_all(finished)
|
|
8287
8532
|
@session_manager.save(agent.to_session_data(status: :success, updated_at: Time.now))
|
|
8288
8533
|
# Start idle compression timer now that the agent is idle
|
|
8289
8534
|
idle_timer&.start
|