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,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Server
|
|
9
|
+
# Routes /api/ext/<name>/<sub-path> requests to the matching ApiExtension
|
|
10
|
+
# subclass. Wraps each handler invocation with a timeout and a unified
|
|
11
|
+
# JSON error envelope so a misbehaving extension cannot break neighboring
|
|
12
|
+
# extensions or the host process.
|
|
13
|
+
module ApiExtensionDispatcher
|
|
14
|
+
MOUNT_PREFIX = "/api/ext/"
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Public entry called from HttpServer#_dispatch_rest.
|
|
18
|
+
# Returns true to indicate the request was handled (200/4xx/5xx).
|
|
19
|
+
def handle(req, res, http_server:)
|
|
20
|
+
ext_id, sub_path = parse_path(req.path)
|
|
21
|
+
return not_found(res, "extension id missing") unless ext_id
|
|
22
|
+
|
|
23
|
+
klass = Clacky::ApiExtension.registry[ext_id]
|
|
24
|
+
return not_found(res, "extension '#{ext_id}' not found") unless klass
|
|
25
|
+
|
|
26
|
+
method = req.request_method.to_s.downcase.to_sym
|
|
27
|
+
route, params = find_route(klass, method, sub_path)
|
|
28
|
+
return not_found(res, "no route for #{req.request_method} #{req.path}") unless route
|
|
29
|
+
|
|
30
|
+
# Public-endpoint check is done at HttpServer level (it owns access-key
|
|
31
|
+
# logic); by the time we get here, auth has already been resolved.
|
|
32
|
+
|
|
33
|
+
timeout_sec = route.options[:timeout] || klass.class_timeout || Clacky::ApiExtension::DEFAULT_TIMEOUT
|
|
34
|
+
invoke_route(klass, route, params, req, res, http_server, timeout_sec)
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Tells HttpServer whether a given /api/ext/... path can skip access-key
|
|
39
|
+
# auth, so the host can keep its single-source-of-truth auth logic.
|
|
40
|
+
def public_path?(path, method)
|
|
41
|
+
ext_id, sub_path = parse_path(path)
|
|
42
|
+
return false unless ext_id
|
|
43
|
+
|
|
44
|
+
klass = Clacky::ApiExtension.registry[ext_id]
|
|
45
|
+
return false unless klass
|
|
46
|
+
return false if klass.public_paths.empty?
|
|
47
|
+
|
|
48
|
+
route, _params = find_route(klass, method.to_s.downcase.to_sym, sub_path)
|
|
49
|
+
return false unless route
|
|
50
|
+
|
|
51
|
+
klass.public_paths.include?(route.pattern)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def parse_path(path)
|
|
55
|
+
return [nil, nil] unless path.to_s.start_with?(MOUNT_PREFIX)
|
|
56
|
+
|
|
57
|
+
tail = path[MOUNT_PREFIX.length..]
|
|
58
|
+
slash = tail.index("/")
|
|
59
|
+
if slash
|
|
60
|
+
[tail[0...slash], tail[slash..]]
|
|
61
|
+
else
|
|
62
|
+
[tail, "/"]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private def find_route(klass, method, sub_path)
|
|
67
|
+
klass.routes.each do |route|
|
|
68
|
+
next unless route.method == method
|
|
69
|
+
next unless (m = route.regex.match(sub_path))
|
|
70
|
+
|
|
71
|
+
params = {}
|
|
72
|
+
route.param_names.each_with_index do |name, i|
|
|
73
|
+
params[name] = URI.decode_www_form_component(m[i + 1].to_s)
|
|
74
|
+
end
|
|
75
|
+
return [route, params]
|
|
76
|
+
end
|
|
77
|
+
[nil, nil]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private def invoke_route(klass, route, params, req, res, http_server, timeout_sec)
|
|
81
|
+
instance = klass.new(req: req, res: res, route: route, params: params, http_server: http_server)
|
|
82
|
+
Timeout.timeout(timeout_sec) { instance.invoke }
|
|
83
|
+
|
|
84
|
+
# Handler exited without writing — empty 204
|
|
85
|
+
empty_response(res)
|
|
86
|
+
rescue Clacky::ApiExtension::Halt => halt
|
|
87
|
+
write_response(res, halt.status, halt.payload, halt.content_type)
|
|
88
|
+
rescue Timeout::Error
|
|
89
|
+
Clacky::Logger.warn("[api_ext:#{klass.ext_id}] Timed out after #{timeout_sec}s on #{route.method.upcase} #{route.pattern}")
|
|
90
|
+
write_json(res, 503, error: "request timed out")
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Clacky::Logger.warn("[api_ext:#{klass.ext_id}] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
93
|
+
write_json(res, 500, error: e.message)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def write_response(res, status, body, content_type)
|
|
97
|
+
res.status = status
|
|
98
|
+
res.content_type = content_type
|
|
99
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
100
|
+
res.body = body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private def write_json(res, status, payload)
|
|
104
|
+
write_response(res, status, JSON.generate(payload), "application/json; charset=utf-8")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private def empty_response(res)
|
|
108
|
+
res.status = 204
|
|
109
|
+
res["Access-Control-Allow-Origin"] = "*"
|
|
110
|
+
res.body = ""
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private def not_found(res, message)
|
|
114
|
+
write_json(res, 404, error: message)
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -43,6 +43,8 @@ module Clacky
|
|
|
43
43
|
@running = false
|
|
44
44
|
@mutex = Mutex.new
|
|
45
45
|
@session_counters = Hash.new(0)
|
|
46
|
+
@dedup_mutex = Mutex.new
|
|
47
|
+
@last_message = {} # channel_key => [digest, monotonic_time]
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# Start all enabled adapters in background threads. Non-blocking.
|
|
@@ -242,6 +244,16 @@ module Clacky
|
|
|
242
244
|
files = event[:files] || []
|
|
243
245
|
return if (text.nil? || text.empty?) && files.empty?
|
|
244
246
|
|
|
247
|
+
# Safety net against adapter-side message storms: drop an identical message
|
|
248
|
+
# repeated on the same channel within a short window. A real user never
|
|
249
|
+
# sends the exact same text+files twice within DEDUP_WINDOW seconds, but a
|
|
250
|
+
# misbehaving adapter (or noisy IM system messages) can, which would
|
|
251
|
+
# otherwise spin the interrupt→restart loop below indefinitely.
|
|
252
|
+
if duplicate_message?(event, text, files)
|
|
253
|
+
Clacky::Logger.info("[ChannelManager] Dropping duplicate message on #{channel_key(event)} within #{DEDUP_WINDOW}s")
|
|
254
|
+
return
|
|
255
|
+
end
|
|
256
|
+
|
|
245
257
|
# Handle built-in commands
|
|
246
258
|
if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
|
|
247
259
|
handle_command(adapter, event, text)
|
|
@@ -749,6 +761,20 @@ module Clacky
|
|
|
749
761
|
adapter.send_text(chat_id, "Recent sessions:\n#{lines.join("\n")}\n\nUse `/bind <n>` to switch.")
|
|
750
762
|
end
|
|
751
763
|
|
|
764
|
+
DEDUP_WINDOW = 2.0 # seconds; identical messages on the same channel within this window are dropped
|
|
765
|
+
|
|
766
|
+
private def duplicate_message?(event, text, files)
|
|
767
|
+
key = channel_key(event)
|
|
768
|
+
digest = [text, files.map { |f| f.is_a?(Hash) ? f[:name] : f.to_s }].hash
|
|
769
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
770
|
+
|
|
771
|
+
@dedup_mutex.synchronize do
|
|
772
|
+
prev_digest, prev_time = @last_message[key]
|
|
773
|
+
@last_message[key] = [digest, now]
|
|
774
|
+
!prev_digest.nil? && prev_digest == digest && (now - prev_time) < DEDUP_WINDOW
|
|
775
|
+
end
|
|
776
|
+
end
|
|
777
|
+
|
|
752
778
|
def channel_key(event)
|
|
753
779
|
platform = event[:platform].to_s
|
|
754
780
|
case @binding_mode
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
module Server
|
|
7
|
+
# Read-mostly git operations scoped to a session's working directory, backing
|
|
8
|
+
# the official "git" WebUI panel. Commands run with explicit argv (no shell),
|
|
9
|
+
# so user-supplied values (paths, messages) cannot inject. Write operations
|
|
10
|
+
# are limited to a guarded `commit`; history-rewriting / remote-mutating
|
|
11
|
+
# commands are never exposed here.
|
|
12
|
+
module GitPanel
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Run a git subcommand in `dir` with argv-style args (no shell). Returns
|
|
16
|
+
# [stdout, stderr, success_bool]. Never raises on git failure.
|
|
17
|
+
def git(dir, *args)
|
|
18
|
+
out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
|
|
19
|
+
[out, err, status.success?]
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
["", e.message, false]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether `dir` is inside a git work tree.
|
|
25
|
+
def repo?(dir)
|
|
26
|
+
out, _err, ok = git(dir, "rev-parse", "--is-inside-work-tree")
|
|
27
|
+
ok && out.strip == "true"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# { branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] }
|
|
31
|
+
# Parsed from `git status --porcelain=v2 --branch`.
|
|
32
|
+
def status(dir)
|
|
33
|
+
out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
|
|
34
|
+
return { branch: nil, files: [] } unless ok
|
|
35
|
+
|
|
36
|
+
branch = nil
|
|
37
|
+
ahead = behind = 0
|
|
38
|
+
files = []
|
|
39
|
+
out.each_line do |line|
|
|
40
|
+
line = line.chomp
|
|
41
|
+
if line.start_with?("# branch.head ")
|
|
42
|
+
branch = line.sub("# branch.head ", "")
|
|
43
|
+
elsif line.start_with?("# branch.ab ")
|
|
44
|
+
m = line.match(/\+(\d+) -(\d+)/)
|
|
45
|
+
ahead, behind = m[1].to_i, m[2].to_i if m
|
|
46
|
+
elsif line.start_with?("1 ", "2 ")
|
|
47
|
+
xy = line.split(" ")[1]
|
|
48
|
+
path = line.split(" ", 9).last
|
|
49
|
+
files << { path: path, x: xy[0], y: xy[1],
|
|
50
|
+
staged: xy[0] != ".", untracked: false }
|
|
51
|
+
elsif line.start_with?("? ")
|
|
52
|
+
files << { path: line.sub("? ", ""), x: "?", y: "?",
|
|
53
|
+
staged: false, untracked: true }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
{ branch: branch, ahead: ahead, behind: behind, files: files }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Unified diff. `file` (optional, relative) limits to one path; omitted =
|
|
60
|
+
# whole working tree (tracked changes). `--` guards path from being read
|
|
61
|
+
# as an option.
|
|
62
|
+
def diff(dir, file: nil)
|
|
63
|
+
args = ["diff"]
|
|
64
|
+
args += ["--", file] if file && !file.empty?
|
|
65
|
+
out, _err, _ok = git(dir, *args)
|
|
66
|
+
out
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Recent commits: [{ hash:, short:, author:, date:, subject: }].
|
|
70
|
+
def log(dir, limit: 50)
|
|
71
|
+
limit = limit.to_i.clamp(1, 200)
|
|
72
|
+
fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
|
|
73
|
+
out, _err, ok = git(dir, "log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}")
|
|
74
|
+
return [] unless ok
|
|
75
|
+
|
|
76
|
+
out.each_line.filter_map do |line|
|
|
77
|
+
h, short, author, date, subject = line.chomp.split("\x1f")
|
|
78
|
+
next unless h
|
|
79
|
+
{ hash: h, short: short, author: author, date: date, subject: subject }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# [{ name:, current: bool }] from `git branch`.
|
|
84
|
+
def branches(dir)
|
|
85
|
+
out, _err, ok = git(dir, "branch", "--format=%(refname:short)%00%(HEAD)")
|
|
86
|
+
return [] unless ok
|
|
87
|
+
|
|
88
|
+
out.each_line.filter_map do |line|
|
|
89
|
+
name, head = line.chomp.split("\x00")
|
|
90
|
+
next unless name && !name.empty?
|
|
91
|
+
{ name: name, current: head == "*" }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stage `files` (relative paths) and commit with `message`. Returns
|
|
96
|
+
# { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
|
|
97
|
+
# argv so paths/message cannot inject; no --no-verify, no amend.
|
|
98
|
+
def commit(dir, message:, files:)
|
|
99
|
+
msg = message.to_s.strip
|
|
100
|
+
paths = Array(files).map(&:to_s).reject(&:empty?)
|
|
101
|
+
return { ok: false, error: "commit message is required" } if msg.empty?
|
|
102
|
+
return { ok: false, error: "no files selected" } if paths.empty?
|
|
103
|
+
|
|
104
|
+
_out, add_err, add_ok = git(dir, "add", "--", *paths)
|
|
105
|
+
return { ok: false, error: "git add failed: #{add_err.strip}" } unless add_ok
|
|
106
|
+
|
|
107
|
+
_out, c_err, c_ok = git(dir, "commit", "-m", msg, "--", *paths)
|
|
108
|
+
return { ok: false, error: "git commit failed: #{c_err.strip}" } unless c_ok
|
|
109
|
+
|
|
110
|
+
head, _err, _ok = git(dir, "rev-parse", "--short", "HEAD")
|
|
111
|
+
{ ok: true, hash: head.strip }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|