openclacky 1.3.2 → 1.3.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 +54 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +52 -7
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +15 -3
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +547 -15
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +511 -101
- data/lib/clacky/web/app.js +95 -14
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
- data/lib/clacky/web/core/aside.js +117 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +331 -0
- data/lib/clacky/web/features/skills/view.js +581 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +68 -6
- data/lib/clacky/web/index.html +113 -62
- data/lib/clacky/web/sessions.js +493 -39
- data/lib/clacky/web/settings.js +143 -49
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -1
- metadata +81 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# Discovers and loads user-defined HTTP API extensions from
|
|
7
|
+
# ~/.clacky/api_ext/<name>/handler.rb. Each handler is expected to define a
|
|
8
|
+
# subclass of Clacky::ApiExtension; the subclass is auto-registered with the
|
|
9
|
+
# framework and its routes become available under /api/ext/<name>/.
|
|
10
|
+
#
|
|
11
|
+
# A broken extension (syntax error, missing base class, route conflict) is
|
|
12
|
+
# isolated: skipped with a logged warning, never aborts the load of others.
|
|
13
|
+
module ApiExtensionLoader
|
|
14
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/api_ext")
|
|
15
|
+
DISABLED_DIR = "_disabled"
|
|
16
|
+
|
|
17
|
+
Result = Struct.new(:loaded, :skipped, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def load_all(dir: DEFAULT_DIR)
|
|
21
|
+
result = Result.new(loaded: [], skipped: [])
|
|
22
|
+
Clacky::ApiExtension.reset_registry!
|
|
23
|
+
|
|
24
|
+
if Dir.exist?(dir)
|
|
25
|
+
Dir.glob(File.join(dir, "*", "handler.rb")).sort.each do |handler_path|
|
|
26
|
+
ext_dir = File.dirname(handler_path)
|
|
27
|
+
ext_id = File.basename(ext_dir)
|
|
28
|
+
next if ext_id == DISABLED_DIR || ext_id.start_with?("_")
|
|
29
|
+
load_one(ext_id, ext_dir, handler_path, result)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@last_result = result
|
|
34
|
+
log_summary(result)
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def last_result
|
|
39
|
+
@last_result || load_all
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load_one(ext_id, ext_dir, handler_path, result)
|
|
43
|
+
meta = read_meta(ext_dir)
|
|
44
|
+
before = Clacky::ApiExtension.pending_subclasses.size
|
|
45
|
+
|
|
46
|
+
require handler_path
|
|
47
|
+
|
|
48
|
+
new_subclasses = Clacky::ApiExtension.pending_subclasses[before..] || []
|
|
49
|
+
klass = new_subclasses.last
|
|
50
|
+
|
|
51
|
+
unless klass
|
|
52
|
+
result.skipped << [ext_id, "no Clacky::ApiExtension subclass defined in handler.rb"]
|
|
53
|
+
log_skip(ext_id, result.skipped.last[1])
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
klass.ext_id = ext_id
|
|
58
|
+
klass.ext_dir = ext_dir
|
|
59
|
+
klass.meta = meta
|
|
60
|
+
|
|
61
|
+
if klass.routes.empty?
|
|
62
|
+
result.skipped << [ext_id, "no routes declared (use get/post/... DSL)"]
|
|
63
|
+
log_skip(ext_id, result.skipped.last[1])
|
|
64
|
+
Clacky::ApiExtension.registry.delete(ext_id)
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if (gap = validate_public_endpoints(klass, meta))
|
|
69
|
+
result.skipped << [ext_id, gap]
|
|
70
|
+
log_skip(ext_id, gap)
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Clacky::ApiExtension.register(ext_id, klass)
|
|
75
|
+
result.loaded << ext_id
|
|
76
|
+
public_count = klass.public_paths.size
|
|
77
|
+
suffix = public_count > 0 ? " (#{public_count} public)" : ""
|
|
78
|
+
Clacky::Logger.info("[ApiExtensionLoader] Loaded '#{ext_id}' — #{klass.routes.size} route(s)#{suffix}")
|
|
79
|
+
rescue StandardError, ScriptError => e
|
|
80
|
+
result.skipped << [ext_id, e.message]
|
|
81
|
+
log_skip(ext_id, e.message)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private def read_meta(ext_dir)
|
|
85
|
+
path = File.join(ext_dir, "meta.yml")
|
|
86
|
+
return {} unless File.exist?(path)
|
|
87
|
+
|
|
88
|
+
YAMLCompat.load_file(path) || {}
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Clacky::Logger.warn("[ApiExtensionLoader] Failed to read meta.yml in #{ext_dir}: #{e.message}")
|
|
91
|
+
{}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private def validate_public_endpoints(klass, meta)
|
|
95
|
+
return nil if klass.public_paths.empty?
|
|
96
|
+
return nil if meta["public"] == true
|
|
97
|
+
|
|
98
|
+
"uses public_endpoint but meta.yml is missing 'public: true'"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private def log_skip(ext_id, reason)
|
|
102
|
+
Clacky::Logger.warn("[ApiExtensionLoader] Skipped '#{ext_id}': #{reason}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private def log_summary(result)
|
|
106
|
+
return if result.loaded.empty? && result.skipped.empty?
|
|
107
|
+
|
|
108
|
+
total_routes = result.loaded.sum { |id| Clacky::ApiExtension.registry[id]&.routes&.size || 0 }
|
|
109
|
+
Clacky::Logger.info("[ApiExtensionLoader] #{result.loaded.size} extension(s), #{total_routes} route(s); #{result.skipped.size} skipped")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Generate a starter handler.rb at ~/.clacky/api_ext/<name>/handler.rb.
|
|
113
|
+
# Returns the path to the generated file.
|
|
114
|
+
def scaffold(name, dir: DEFAULT_DIR)
|
|
115
|
+
slug = name.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
116
|
+
raise ArgumentError, "invalid api_ext name: #{name.inspect}" if slug.empty?
|
|
117
|
+
|
|
118
|
+
target_dir = File.join(dir, slug)
|
|
119
|
+
path = File.join(target_dir, "handler.rb")
|
|
120
|
+
raise ArgumentError, "api_ext already exists: #{path}" if File.exist?(path)
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(target_dir)
|
|
123
|
+
File.write(path, skeleton(slug))
|
|
124
|
+
path
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private def skeleton(slug)
|
|
128
|
+
const = slug.split(/[-_]/).map(&:capitalize).join + "Ext"
|
|
129
|
+
<<~RUBY
|
|
130
|
+
# frozen_string_literal: true
|
|
131
|
+
|
|
132
|
+
# Custom HTTP API extension mounted at /api/ext/#{slug}/
|
|
133
|
+
# Scaffolded by `clacky api_ext_new #{slug}` — fill in the routes you need.
|
|
134
|
+
class #{const} < Clacky::ApiExtension
|
|
135
|
+
get "/hello" do
|
|
136
|
+
json(message: "hello from #{slug}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Examples — uncomment and adapt:
|
|
140
|
+
#
|
|
141
|
+
# post "/items" do
|
|
142
|
+
# body = json_body
|
|
143
|
+
# error!("name required", status: 422) unless body["name"]
|
|
144
|
+
# File.write(data_path("items.json"), body.to_json)
|
|
145
|
+
# json(ok: true)
|
|
146
|
+
# end
|
|
147
|
+
#
|
|
148
|
+
# get "/items/:id" do
|
|
149
|
+
# json(id: params[:id])
|
|
150
|
+
# end
|
|
151
|
+
end
|
|
152
|
+
RUBY
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -268,8 +268,9 @@ module Clacky
|
|
|
268
268
|
config.save
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
-
# Refresh UI bar
|
|
271
|
+
# Refresh UI bar and model list
|
|
272
272
|
ui_controller.config[:model] = config.model_name
|
|
273
|
+
ui_controller.available_models = config.model_names
|
|
273
274
|
ui_controller.update_sessionbar(
|
|
274
275
|
tasks: agent.total_tasks,
|
|
275
276
|
cost: agent.total_cost
|
|
@@ -836,11 +837,12 @@ module Clacky
|
|
|
836
837
|
say "Error: Rich UI requires Ruby >= 2.6. Use --ui ui2 on Ruby #{RUBY_VERSION}.", :red
|
|
837
838
|
exit 1
|
|
838
839
|
end
|
|
839
|
-
require_relative "
|
|
840
|
+
require_relative "rich_ui"
|
|
840
841
|
RichUIController.new(
|
|
841
842
|
working_dir: working_dir,
|
|
842
843
|
mode: agent_config.permission_mode.to_s,
|
|
843
844
|
model: agent_config.model_name,
|
|
845
|
+
model_names: agent_config.model_names,
|
|
844
846
|
theme: options[:theme]
|
|
845
847
|
)
|
|
846
848
|
else
|
|
@@ -901,6 +903,21 @@ module Clacky
|
|
|
901
903
|
agent_config.permission_mode = new_mode.to_sym
|
|
902
904
|
end
|
|
903
905
|
|
|
906
|
+
# Set up model switch handler (from /model slash command)
|
|
907
|
+
ui_controller.on_model_switch do |model, persist|
|
|
908
|
+
next unless agent_config.switch_model_by_name(model)
|
|
909
|
+
|
|
910
|
+
id = agent_config.current_model_id
|
|
911
|
+
agent.switch_model_by_id(id)
|
|
912
|
+
if persist
|
|
913
|
+
agent_config.set_default_model_by_id(id)
|
|
914
|
+
agent_config.save
|
|
915
|
+
ui_controller.show_success("Model switched to #{model} (saved)")
|
|
916
|
+
else
|
|
917
|
+
ui_controller.show_success("Model switched to #{model} (session only)")
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
|
|
904
921
|
# Set up time machine handler (ESC key)
|
|
905
922
|
ui_controller.on_time_machine do
|
|
906
923
|
handle_time_machine_command(ui_controller, agent, session_manager)
|
|
@@ -921,6 +938,16 @@ module Clacky
|
|
|
921
938
|
end
|
|
922
939
|
|
|
923
940
|
if (not current_task_thread&.alive?) && input_was_empty
|
|
941
|
+
# Rich UI: require double-tap Ctrl+C to exit. When the user
|
|
942
|
+
# just copied terminal-native text selection, the viewport
|
|
943
|
+
# has no knowledge of the selection, yet Ctrl+C must not exit.
|
|
944
|
+
# First press only sets the warning; second press exits.
|
|
945
|
+
if ui_controller.respond_to?(:ctrl_c_warning) && !ui_controller.ctrl_c_warning
|
|
946
|
+
ui_controller.instance_variable_set(:@ctrl_c_warning, "Press Ctrl+C again to exit")
|
|
947
|
+
ui_controller.set_input_tips("Press Ctrl+C again to exit.", type: :info)
|
|
948
|
+
next
|
|
949
|
+
end
|
|
950
|
+
|
|
924
951
|
# Save final session state before exit
|
|
925
952
|
if session_manager && agent.total_tasks > 0
|
|
926
953
|
session_data = agent.to_session_data(status: :exited)
|
|
@@ -1038,7 +1065,13 @@ module Clacky
|
|
|
1038
1065
|
# Update session bar with agent's cumulative stats
|
|
1039
1066
|
ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
|
|
1040
1067
|
rescue Clacky::AgentInterrupted, StandardError => e
|
|
1041
|
-
|
|
1068
|
+
begin
|
|
1069
|
+
handle_agent_exception(ui_controller, agent, session_manager, e)
|
|
1070
|
+
rescue StandardError => ex
|
|
1071
|
+
# If handle_agent_exception itself raises (e.g. UI in bad state),
|
|
1072
|
+
# prevent the thread from dying with an unhandled exception.
|
|
1073
|
+
$stderr.puts "[cli] handle_agent_exception failed: #{ex.class}: #{ex.message}"
|
|
1074
|
+
end
|
|
1042
1075
|
ensure
|
|
1043
1076
|
current_task_thread = nil
|
|
1044
1077
|
# Start idle timer after agent completes
|
|
@@ -1181,6 +1214,63 @@ module Clacky
|
|
|
1181
1214
|
exit 1 if result.skipped.any?
|
|
1182
1215
|
end
|
|
1183
1216
|
|
|
1217
|
+
desc "api_ext_new NAME", "Scaffold a custom HTTP API extension at ~/.clacky/api_ext/NAME/"
|
|
1218
|
+
long_desc <<-LONGDESC
|
|
1219
|
+
Generate a ready-to-edit HTTP API extension skeleton. The skeleton mounts
|
|
1220
|
+
a sample route under /api/ext/NAME/ — fill in your routes, then verify with
|
|
1221
|
+
`clacky api_ext_verify`.
|
|
1222
|
+
|
|
1223
|
+
Examples:
|
|
1224
|
+
$ clacky api_ext_new my-dashboard
|
|
1225
|
+
LONGDESC
|
|
1226
|
+
def api_ext_new(name)
|
|
1227
|
+
path = Clacky::ApiExtensionLoader.scaffold(name)
|
|
1228
|
+
puts "Created api extension: #{path}"
|
|
1229
|
+
puts "Edit the routes, then run: clacky api_ext_verify"
|
|
1230
|
+
rescue ArgumentError => e
|
|
1231
|
+
warn "Error: #{e.message}"
|
|
1232
|
+
exit 1
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
desc "api_ext_verify", "Load user API extensions and report which are valid"
|
|
1236
|
+
def api_ext_verify
|
|
1237
|
+
result = Clacky::ApiExtensionLoader.load_all
|
|
1238
|
+
|
|
1239
|
+
if result.loaded.empty? && result.skipped.empty?
|
|
1240
|
+
puts "No api extensions found in ~/.clacky/api_ext/"
|
|
1241
|
+
return
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
result.loaded.each do |id|
|
|
1245
|
+
klass = Clacky::ApiExtension.registry[id]
|
|
1246
|
+
public_count = klass.public_paths.size
|
|
1247
|
+
suffix = public_count > 0 ? " (#{public_count} public)" : ""
|
|
1248
|
+
puts "[OK] #{id} — #{klass.routes.size} route(s)#{suffix}"
|
|
1249
|
+
end
|
|
1250
|
+
result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
|
|
1251
|
+
exit 1 if result.skipped.any?
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
desc "api_ext_list", "List loaded API extensions and their routes"
|
|
1255
|
+
def api_ext_list
|
|
1256
|
+
Clacky::ApiExtensionLoader.load_all if Clacky::ApiExtension.registry.empty?
|
|
1257
|
+
|
|
1258
|
+
if Clacky::ApiExtension.registry.empty?
|
|
1259
|
+
puts "No api extensions loaded."
|
|
1260
|
+
return
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
Clacky::ApiExtension.registry.each do |id, klass|
|
|
1264
|
+
public_tag = klass.public_paths.any? ? " (public)" : ""
|
|
1265
|
+
puts "#{id}#{public_tag}"
|
|
1266
|
+
klass.routes.each do |route|
|
|
1267
|
+
full_path = "/api/ext/#{id}#{route.pattern}".chomp("/")
|
|
1268
|
+
full_path = "/api/ext/#{id}/" if full_path == "/api/ext/#{id}"
|
|
1269
|
+
puts " #{route.method.to_s.upcase.ljust(6)} #{full_path}"
|
|
1270
|
+
end
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1184
1274
|
desc "billing", "Show billing summary and usage statistics"
|
|
1185
1275
|
long_desc <<-LONGDESC
|
|
1186
1276
|
Display billing summary with token usage and cost breakdown.
|
data/lib/clacky/client.rb
CHANGED
|
@@ -515,6 +515,12 @@ module Clacky
|
|
|
515
515
|
end
|
|
516
516
|
end
|
|
517
517
|
|
|
518
|
+
def reset_connections!
|
|
519
|
+
@bedrock_connection = nil
|
|
520
|
+
@openai_connection = nil
|
|
521
|
+
@anthropic_connection = nil
|
|
522
|
+
end
|
|
523
|
+
|
|
518
524
|
def bedrock_connection
|
|
519
525
|
current_epoch = Clacky::ProxyConfig.epoch
|
|
520
526
|
if @bedrock_connection.nil? ||
|
|
@@ -598,11 +604,24 @@ module Clacky
|
|
|
598
604
|
return { success: true, status: response.status } if response.status == 200
|
|
599
605
|
|
|
600
606
|
error_body = JSON.parse(response.body) rescue nil
|
|
607
|
+
error_code = extract_error_code(error_body)
|
|
608
|
+
|
|
609
|
+
translated = case response.status
|
|
610
|
+
when 402 then I18n.t("llm.error.insufficient_credit")
|
|
611
|
+
when 400 then I18n.t("llm.error.rate_limit_400")
|
|
612
|
+
when 401 then I18n.t("llm.error.invalid_api_key")
|
|
613
|
+
when 403 then I18n.t("llm.error.403.#{error_code || "default"}")
|
|
614
|
+
when 404 then I18n.t("llm.error.endpoint_not_found")
|
|
615
|
+
when 429 then I18n.t("llm.error.rate_limit_429")
|
|
616
|
+
when 500..599 then I18n.t("llm.error.server_error", status: response.status)
|
|
617
|
+
else extract_error_message(error_body, response.body)
|
|
618
|
+
end
|
|
619
|
+
|
|
601
620
|
{
|
|
602
621
|
success: false,
|
|
603
622
|
status: response.status,
|
|
604
|
-
error:
|
|
605
|
-
error_code:
|
|
623
|
+
error: translated,
|
|
624
|
+
error_code: error_code
|
|
606
625
|
}
|
|
607
626
|
end
|
|
608
627
|
|
|
@@ -620,7 +639,7 @@ module Clacky
|
|
|
620
639
|
|
|
621
640
|
if error_code == "insufficient_credit" || response.status == 402
|
|
622
641
|
raise InsufficientCreditError.new(
|
|
623
|
-
"[LLM]
|
|
642
|
+
"[LLM] #{I18n.t("llm.error.insufficient_credit")}",
|
|
624
643
|
error_code: "insufficient_credit",
|
|
625
644
|
provider_id: @provider_id
|
|
626
645
|
)
|
|
@@ -632,19 +651,25 @@ module Clacky
|
|
|
632
651
|
# However, some proxy/relay providers do — so we inspect the message first.
|
|
633
652
|
# Also, Bedrock returns ThrottlingException as 400 instead of 429.
|
|
634
653
|
if error_message.match?(/ThrottlingException|unavailable|quota/i)
|
|
635
|
-
|
|
636
|
-
raise RetryableError, "[LLM] Rate limit or service issue: #{error_message}#{hint}"
|
|
654
|
+
raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_400")}"
|
|
637
655
|
end
|
|
638
656
|
|
|
639
657
|
# True bad request — our message was malformed. Roll back history so the
|
|
640
658
|
# broken message is not replayed on the next user turn.
|
|
641
|
-
raise BadRequestError
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
when
|
|
646
|
-
when
|
|
647
|
-
|
|
659
|
+
raise BadRequestError.new(
|
|
660
|
+
"[LLM] Client request error: #{error_message}",
|
|
661
|
+
display_message: "[LLM] #{I18n.t("llm.error.bad_request")}"
|
|
662
|
+
)
|
|
663
|
+
when 401 then raise AgentError, "[LLM] #{I18n.t("llm.error.invalid_api_key")}"
|
|
664
|
+
when 403
|
|
665
|
+
i18n_key = "llm.error.403.#{error_code}"
|
|
666
|
+
translated = I18n.t(i18n_key)
|
|
667
|
+
translated = I18n.t("llm.error.403.default") if translated == i18n_key
|
|
668
|
+
raise AgentError, "[LLM] #{translated}"
|
|
669
|
+
when 404 then raise AgentError, "[LLM] #{I18n.t("llm.error.endpoint_not_found")}"
|
|
670
|
+
when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
|
|
671
|
+
when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
|
|
672
|
+
else raise AgentError, "[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}"
|
|
648
673
|
end
|
|
649
674
|
end
|
|
650
675
|
|
|
@@ -652,7 +677,7 @@ module Clacky
|
|
|
652
677
|
def check_html_response(response)
|
|
653
678
|
body = response.body.to_s.lstrip
|
|
654
679
|
if body.start_with?("<!DOCTYPE", "<!doctype", "<html", "<HTML")
|
|
655
|
-
raise RetryableError, "[LLM]
|
|
680
|
+
raise RetryableError, "[LLM] #{I18n.t("llm.error.html_response")}"
|
|
656
681
|
end
|
|
657
682
|
end
|
|
658
683
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ── Official panel: changes (git, made friendly) ──────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// "改动 / Changes": a non-technical view of what the AI changed, backed by the
|
|
4
|
+
// built-in git API (GET/POST /api/sessions/:id/git/*). Mounted as a tab in the
|
|
5
|
+
// "session.aside" slot, scoped to agents declaring `panels: [git]`.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately hides git jargon: no porcelain status codes (M/??), no
|
|
8
|
+
// branch/ahead/behind unless the branch is NOT the main line (main/master) —
|
|
9
|
+
// then it's surfaced as a gentle notice. The only write is a zero-input
|
|
10
|
+
// "save version" that auto-generates the commit message.
|
|
11
|
+
//
|
|
12
|
+
// Native DOM + textContent on all git output (paths, branch) so nothing can
|
|
13
|
+
// inject. tab.badge tracks the number of changed files.
|
|
14
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
(() => {
|
|
17
|
+
if (!window.Clacky || !Clacky.ext) return;
|
|
18
|
+
|
|
19
|
+
const MAIN_BRANCHES = { main: true, master: true };
|
|
20
|
+
const t = (k, fallback) => {
|
|
21
|
+
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
22
|
+
return (v && v !== k) ? v : fallback;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (!document.getElementById("changes-panel-style")) {
|
|
26
|
+
const style = document.createElement("style");
|
|
27
|
+
style.id = "changes-panel-style";
|
|
28
|
+
style.textContent = `
|
|
29
|
+
.changes-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
30
|
+
.changes-summary { flex: none; padding: 14px 16px 10px; border-bottom: 1px solid var(--color-border-secondary); }
|
|
31
|
+
.changes-summary .h { font-size: 13px; color: var(--color-text-secondary); }
|
|
32
|
+
.changes-summary .h b { color: var(--color-text-primary); }
|
|
33
|
+
.changes-summary .sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 3px; }
|
|
34
|
+
.changes-branch { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 5px 8px; border-radius: var(--radius-sm); background: var(--color-warning-bg); color: var(--color-warning); font-size: 11.5px; }
|
|
35
|
+
.changes-branch code { font-family: ui-monospace, monospace; font-weight: 600; }
|
|
36
|
+
.changes-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 8px; }
|
|
37
|
+
.change-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
|
38
|
+
.change-row:hover { background: var(--color-bg-hover); }
|
|
39
|
+
.change-tag { flex: none; font-size: 11px; padding: 1px 7px; border-radius: var(--radius-pill); font-weight: 500; }
|
|
40
|
+
.change-tag.add { background: var(--color-success-bg); color: var(--color-success); }
|
|
41
|
+
.change-tag.mod { background: #eff6ff; color: #2563eb; }
|
|
42
|
+
.change-tag.del { background: var(--color-error-bg); color: var(--color-error); }
|
|
43
|
+
.change-path { font-size: 13px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
44
|
+
.change-dir { color: var(--color-text-tertiary); }
|
|
45
|
+
.changes-foot { flex: none; padding: 12px 16px; border-top: 1px solid var(--color-border-primary); }
|
|
46
|
+
.changes-save-btn { width: 100%; padding: 9px; border: none; border-radius: var(--radius-md); background: var(--color-accent-primary); color: var(--color-text-inverse); font-size: 13px; font-weight: 500; cursor: pointer; }
|
|
47
|
+
.changes-save-btn:hover:not(:disabled) { background: var(--color-accent-hover); }
|
|
48
|
+
.changes-save-btn:disabled { opacity: 0.5; cursor: default; }
|
|
49
|
+
.changes-hint { text-align: center; font-size: 11px; color: var(--color-text-tertiary); margin-top: 7px; min-height: 1em; }
|
|
50
|
+
.changes-empty, .changes-loading, .changes-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
|
|
51
|
+
.changes-error { color: var(--color-error); }
|
|
52
|
+
`;
|
|
53
|
+
document.head.appendChild(style);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function el(tag, attrs, ...kids) {
|
|
57
|
+
const node = document.createElement(tag);
|
|
58
|
+
if (attrs) {
|
|
59
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
60
|
+
if (k === "class") node.className = v;
|
|
61
|
+
else if (k === "text") node.textContent = v;
|
|
62
|
+
else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
|
|
63
|
+
else node.setAttribute(k, v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
kids.forEach((c) => node.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function api(sessionId, action, opts) {
|
|
71
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/git/${action}`, opts);
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Map a porcelain status entry to a friendly kind without exposing codes.
|
|
76
|
+
function classify(f) {
|
|
77
|
+
if (f.untracked) return "add";
|
|
78
|
+
const code = `${f.x || ""}${f.y || ""}`;
|
|
79
|
+
if (code.includes("D")) return "del";
|
|
80
|
+
if (code.includes("A")) return "add";
|
|
81
|
+
return "mod";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TAG_LABEL = {
|
|
85
|
+
add: () => t("changes.tag.add", "新增"),
|
|
86
|
+
mod: () => t("changes.tag.mod", "修改"),
|
|
87
|
+
del: () => t("changes.tag.del", "删除"),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function splitPath(path) {
|
|
91
|
+
const i = path.lastIndexOf("/");
|
|
92
|
+
return i < 0 ? { dir: "", name: path } : { dir: path.slice(0, i + 1), name: path.slice(i + 1) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function autoMessage() {
|
|
96
|
+
const d = new Date();
|
|
97
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
98
|
+
const stamp = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
99
|
+
return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderFiles(files) {
|
|
103
|
+
const list = el("div", { class: "changes-list" });
|
|
104
|
+
files.forEach((f) => {
|
|
105
|
+
const kind = classify(f);
|
|
106
|
+
const { dir, name } = splitPath(f.path);
|
|
107
|
+
const path = el("span", { class: "change-path" });
|
|
108
|
+
if (dir) path.appendChild(el("span", { class: "change-dir", text: dir }));
|
|
109
|
+
path.appendChild(document.createTextNode(name));
|
|
110
|
+
list.appendChild(el("div", { class: "change-row" },
|
|
111
|
+
el("span", { class: `change-tag ${kind}`, text: TAG_LABEL[kind]() }),
|
|
112
|
+
path,
|
|
113
|
+
));
|
|
114
|
+
});
|
|
115
|
+
return list;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function refresh(sessionId, root, body, ctx) {
|
|
119
|
+
body.replaceChildren(el("div", { class: "changes-loading", text: t("changes.loading", "正在读取改动…") }));
|
|
120
|
+
|
|
121
|
+
let status;
|
|
122
|
+
try {
|
|
123
|
+
status = await api(sessionId, "status");
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
body.replaceChildren(el("div", { class: "changes-error", text: t("changes.error", "读取改动失败") }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!status.repo) {
|
|
129
|
+
if (ctx && ctx.setBadge) ctx.setBadge(null);
|
|
130
|
+
body.replaceChildren(el("div", { class: "changes-empty", text: t("changes.noRepo", "这个项目还没有启用版本管理。") }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const files = status.files || [];
|
|
135
|
+
if (ctx && ctx.setBadge) ctx.setBadge(files.length || null);
|
|
136
|
+
|
|
137
|
+
const count = files.length;
|
|
138
|
+
const summary = el("div", { class: "changes-summary" });
|
|
139
|
+
const h = el("div", { class: "h" });
|
|
140
|
+
if (count === 0) {
|
|
141
|
+
h.textContent = t("changes.cleanTitle", "暂无改动");
|
|
142
|
+
} else {
|
|
143
|
+
h.appendChild(document.createTextNode(t("changes.changedPre", "AI 改了 ")));
|
|
144
|
+
h.appendChild(el("b", { text: `${count} ${t("changes.filesUnit", "个文件")}` }));
|
|
145
|
+
}
|
|
146
|
+
summary.appendChild(h);
|
|
147
|
+
summary.appendChild(el("div", { class: "sub", text: t("changes.sub", "由 Git 管理 · 自上次存档以来") }));
|
|
148
|
+
|
|
149
|
+
const branch = (status.branch || "").trim();
|
|
150
|
+
if (branch && !MAIN_BRANCHES[branch.toLowerCase()]) {
|
|
151
|
+
const note = el("div", { class: "changes-branch" });
|
|
152
|
+
note.appendChild(document.createTextNode(t("changes.branchPre", "当前分支:")));
|
|
153
|
+
note.appendChild(el("code", { text: branch }));
|
|
154
|
+
summary.appendChild(note);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const hint = el("div", { class: "changes-hint" });
|
|
158
|
+
const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
|
|
159
|
+
saveBtn.disabled = count === 0;
|
|
160
|
+
saveBtn.addEventListener("click", async () => {
|
|
161
|
+
saveBtn.disabled = true;
|
|
162
|
+
hint.textContent = t("changes.save.saving", "正在存档…");
|
|
163
|
+
try {
|
|
164
|
+
const paths = files.map((f) => f.path);
|
|
165
|
+
const res = await api(sessionId, "commit", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify({ message: autoMessage(), files: paths }),
|
|
169
|
+
});
|
|
170
|
+
if (res.ok) {
|
|
171
|
+
hint.textContent = t("changes.save.done", "已存档,可在「时光机」里回到这个版本");
|
|
172
|
+
refresh(sessionId, root, body, ctx);
|
|
173
|
+
} else {
|
|
174
|
+
hint.textContent = res.error || t("changes.save.failed", "存档失败");
|
|
175
|
+
saveBtn.disabled = false;
|
|
176
|
+
}
|
|
177
|
+
} catch (_e) {
|
|
178
|
+
hint.textContent = t("changes.save.failed", "存档失败");
|
|
179
|
+
saveBtn.disabled = false;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
body.replaceChildren(
|
|
184
|
+
summary,
|
|
185
|
+
count === 0 ? el("div", { class: "changes-empty", text: t("changes.clean", "工作区是干净的,没有未存档的改动。") }) : renderFiles(files),
|
|
186
|
+
el("div", { class: "changes-foot" }, saveBtn, hint),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
Clacky.ext.ui.mount("session.aside", (ctx) => {
|
|
191
|
+
if (!ctx || !ctx.sessionId) return null;
|
|
192
|
+
const body = el("div", { class: "changes-panel" });
|
|
193
|
+
const root = el("div", { class: "changes-root", "data-panel": "changes" }, body);
|
|
194
|
+
refresh(ctx.sessionId, root, body, ctx);
|
|
195
|
+
return root;
|
|
196
|
+
}, {
|
|
197
|
+
panel: "git",
|
|
198
|
+
order: 10,
|
|
199
|
+
tab: { id: "changes", label: () => t("changes.tab") },
|
|
200
|
+
});
|
|
201
|
+
})();
|