openclacky 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +27 -31
- data/CHANGELOG.md +30 -0
- data/Dockerfile +28 -0
- data/README.md +4 -0
- data/README_CN.md +198 -0
- data/docs/engineering-article.md +343 -0
- data/lib/clacky/agent/llm_caller.rb +2 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +22 -1
- data/lib/clacky/brand_config.rb +87 -5
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/client.rb +15 -11
- data/lib/clacky/message_format/anthropic.rb +30 -2
- data/lib/clacky/message_format/bedrock.rb +13 -1
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/providers.rb +34 -0
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
- data/lib/clacky/server/http_server.rb +130 -15
- data/lib/clacky/server/session_registry.rb +9 -6
- data/lib/clacky/ui2/ui_controller.rb +14 -0
- data/lib/clacky/ui_interface.rb +14 -0
- data/lib/clacky/utils/model_pricing.rb +96 -25
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1286 -1116
- data/lib/clacky/web/brand.js +20 -5
- data/lib/clacky/web/i18n.js +42 -0
- data/lib/clacky/web/index.html +26 -7
- data/lib/clacky/web/onboard.js +6 -0
- data/lib/clacky/web/sessions.js +194 -11
- data/lib/clacky/web/settings.js +51 -10
- data/lib/clacky/web/skills.js +53 -31
- data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
- data/scripts/build/lib/apt.sh +30 -10
- data/scripts/build/lib/network.sh +3 -2
- data/scripts/install.sh +30 -9
- data/scripts/install_browser.sh +2 -1
- data/scripts/install_full.sh +2 -1
- data/scripts/install_rails_deps.sh +30 -9
- data/scripts/install_system_deps.sh +30 -9
- metadata +7 -17
- data/docs/HOW-TO-USE-CN.md +0 -96
- data/docs/HOW-TO-USE.md +0 -94
- data/docs/browser-cdp-native-design.md +0 -195
- data/docs/c-end-user-positioning.md +0 -64
- data/docs/config.example.yml +0 -27
- data/docs/deploy-architecture.md +0 -619
- data/docs/deploy_subagent_design.md +0 -540
- data/docs/install-script-simplification.md +0 -89
- data/docs/memory-architecture.md +0 -343
- data/docs/openclacky_cloud_api_reference.md +0 -584
- data/docs/security-design.md +0 -109
- data/docs/session-management-redesign.md +0 -202
- data/docs/system-skill-authoring-guide.md +0 -47
- data/docs/why-developer.md +0 -371
- data/docs/why-openclacky.md +0 -266
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
|
|
7
8
|
module Clacky
|
|
8
9
|
module Channel
|
|
@@ -11,12 +12,31 @@ module Clacky
|
|
|
11
12
|
# DingTalk Bot API client — sends messages via session webhook (Stream Mode).
|
|
12
13
|
class ApiClient
|
|
13
14
|
OPENAPI_BASE = "https://api.dingtalk.com"
|
|
15
|
+
OAPI_BASE = "https://oapi.dingtalk.com"
|
|
16
|
+
|
|
17
|
+
# File extensions accepted by DingTalk sampleFile message (fileType field).
|
|
18
|
+
# Anything outside this list is rejected by DingTalk's API — we surface
|
|
19
|
+
# a friendly text notice to the user instead of attempting upload.
|
|
20
|
+
#
|
|
21
|
+
# Why 9 entries instead of the 6 the public doc lists? The official
|
|
22
|
+
# doc (open.dingtalk.com / sampleFile) explicitly names only:
|
|
23
|
+
# xlsx, pdf, zip, rar, doc, docx
|
|
24
|
+
# However old-format Office types (xls / ppt / pptx) are accepted in
|
|
25
|
+
# practice and were verified by hand during the C-5597 rollout.
|
|
26
|
+
# We deliberately keep the empirical 9-entry list because
|
|
27
|
+
# downgrading to the doc's 6 would silently reject files users
|
|
28
|
+
# routinely send. If DingTalk ever tightens enforcement and the
|
|
29
|
+
# extra 3 start failing, prefer adding a converter (e.g. xls→xlsx)
|
|
30
|
+
# over shrinking the list — the goal is "things users send arrive".
|
|
31
|
+
SUPPORTED_FILE_EXTS = %w[doc docx xls xlsx ppt pptx pdf zip rar].freeze
|
|
14
32
|
|
|
15
33
|
def initialize(client_id:, client_secret:)
|
|
16
34
|
@client_id = client_id
|
|
17
35
|
@client_secret = client_secret
|
|
18
36
|
@token = nil
|
|
19
37
|
@token_expires_at = 0
|
|
38
|
+
@oapi_token = nil
|
|
39
|
+
@oapi_token_expires_at = 0
|
|
20
40
|
end
|
|
21
41
|
|
|
22
42
|
# Send a text (or Markdown) message via the session webhook URL.
|
|
@@ -67,6 +87,26 @@ module Clacky
|
|
|
67
87
|
@token
|
|
68
88
|
end
|
|
69
89
|
|
|
90
|
+
# OAPI access token — required by legacy /media/upload endpoint.
|
|
91
|
+
# Independent token system from /v1.0/oauth2/accessToken.
|
|
92
|
+
def oapi_access_token
|
|
93
|
+
return @oapi_token if @oapi_token && Time.now.to_i < @oapi_token_expires_at - 60
|
|
94
|
+
|
|
95
|
+
uri = URI.parse("#{OAPI_BASE}/gettoken?appkey=#{@client_id}&appsecret=#{@client_secret}")
|
|
96
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
97
|
+
http.use_ssl = true
|
|
98
|
+
resp = http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
99
|
+
data = JSON.parse(resp.body)
|
|
100
|
+
|
|
101
|
+
unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["access_token"]
|
|
102
|
+
raise "DingTalk OAPI token error (#{resp.code}): #{data["errmsg"] || resp.body}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@oapi_token = data["access_token"]
|
|
106
|
+
@oapi_token_expires_at = Time.now.to_i + (data["expires_in"] || 7200).to_i
|
|
107
|
+
@oapi_token
|
|
108
|
+
end
|
|
109
|
+
|
|
70
110
|
# Validate credentials by fetching a token.
|
|
71
111
|
# @return [Hash] { ok: Boolean, error: String? }
|
|
72
112
|
def test_connection
|
|
@@ -75,6 +115,275 @@ module Clacky
|
|
|
75
115
|
rescue => e
|
|
76
116
|
{ ok: false, error: e.message }
|
|
77
117
|
end
|
|
118
|
+
|
|
119
|
+
# Download a file the bot received, given its downloadCode + robotCode
|
|
120
|
+
# from the inbound event. Two-step: exchange downloadCode for a temporary
|
|
121
|
+
# downloadUrl, then persist bytes to UPLOAD_DIR via FileProcessor.save.
|
|
122
|
+
# @param download_code [String]
|
|
123
|
+
# @param robot_code [String]
|
|
124
|
+
# @param prefer_name [String, nil] original filename from inbound event
|
|
125
|
+
# (DingTalk's content.fileName) — used to pick the file extension so
|
|
126
|
+
# downstream consumers (parsers, vision models) route by suffix correctly.
|
|
127
|
+
# @return [Hash, nil] { name:, path:, mime: } or nil on failure
|
|
128
|
+
def download_message_file(download_code, robot_code, prefer_name: nil)
|
|
129
|
+
return nil if download_code.to_s.empty? || robot_code.to_s.empty?
|
|
130
|
+
|
|
131
|
+
url = fetch_download_url(download_code, robot_code)
|
|
132
|
+
return nil unless url
|
|
133
|
+
|
|
134
|
+
download_to_disk(url, prefer_name: prefer_name)
|
|
135
|
+
rescue => e
|
|
136
|
+
Clacky::Logger.warn("[dingtalk] download_message_file failed: #{e.message}")
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private def fetch_download_url(download_code, robot_code)
|
|
141
|
+
uri = URI.parse("#{OPENAPI_BASE}/v1.0/robot/messageFiles/download")
|
|
142
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
143
|
+
http.use_ssl = true
|
|
144
|
+
req = Net::HTTP::Post.new(uri.path,
|
|
145
|
+
"Content-Type" => "application/json",
|
|
146
|
+
"x-acs-dingtalk-access-token" => access_token)
|
|
147
|
+
req.body = JSON.generate(downloadCode: download_code, robotCode: robot_code)
|
|
148
|
+
resp = http.request(req)
|
|
149
|
+
data = JSON.parse(resp.body) rescue {}
|
|
150
|
+
unless resp.code.to_i == 200 && data["downloadUrl"]
|
|
151
|
+
Clacky::Logger.warn("[dingtalk] fetch_download_url rejected (#{resp.code}): #{resp.body}")
|
|
152
|
+
return nil
|
|
153
|
+
end
|
|
154
|
+
data["downloadUrl"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private def download_to_disk(url, prefer_name: nil)
|
|
158
|
+
uri = URI.parse(url)
|
|
159
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
160
|
+
http.use_ssl = uri.scheme == "https"
|
|
161
|
+
resp = http.get(uri.request_uri)
|
|
162
|
+
return nil unless resp.code.to_i == 200
|
|
163
|
+
|
|
164
|
+
mime = resp["content-type"].to_s
|
|
165
|
+
# Prefer extension from the original fileName supplied by DingTalk
|
|
166
|
+
# (e.g. report.pdf, notes.txt). Fall back to MIME mapping, then .bin.
|
|
167
|
+
ext = ext_from_name(prefer_name) || guess_ext(mime) || ".bin"
|
|
168
|
+
|
|
169
|
+
base = prefer_name && !prefer_name.to_s.empty? ?
|
|
170
|
+
File.basename(prefer_name.to_s, ".*") : "dingtalk"
|
|
171
|
+
base = sanitize_basename(base)
|
|
172
|
+
filename = "#{base}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(3)}#{ext}"
|
|
173
|
+
saved = Clacky::Utils::FileProcessor.save(body: resp.body, filename: filename)
|
|
174
|
+
saved.merge(mime: mime)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private def ext_from_name(name)
|
|
178
|
+
return nil if name.to_s.empty?
|
|
179
|
+
ext = File.extname(name.to_s).downcase
|
|
180
|
+
ext.empty? ? nil : ext
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private def sanitize_basename(name)
|
|
184
|
+
# Keep ASCII letters/digits/dash/underscore; drop everything else
|
|
185
|
+
# (CJK, spaces, slashes) so the final filename stays filesystem-safe.
|
|
186
|
+
cleaned = name.to_s.gsub(/[^A-Za-z0-9_\-]/, "_").gsub(/_+/, "_").gsub(/^_+|_+$/, "")
|
|
187
|
+
cleaned.empty? ? "dingtalk" : cleaned
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private def guess_ext(mime)
|
|
191
|
+
case mime.to_s.split(";").first.to_s.strip.downcase
|
|
192
|
+
# Images
|
|
193
|
+
when "image/jpeg", "image/jpg" then ".jpg"
|
|
194
|
+
when "image/png" then ".png"
|
|
195
|
+
when "image/gif" then ".gif"
|
|
196
|
+
when "image/webp" then ".webp"
|
|
197
|
+
when "image/bmp" then ".bmp"
|
|
198
|
+
when "image/svg+xml" then ".svg"
|
|
199
|
+
# Documents
|
|
200
|
+
when "application/pdf" then ".pdf"
|
|
201
|
+
when "application/msword" then ".doc"
|
|
202
|
+
when "application/vnd.openxmlformats-officedocument.wordprocessingml.document" then ".docx"
|
|
203
|
+
when "application/vnd.ms-excel" then ".xls"
|
|
204
|
+
when "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" then ".xlsx"
|
|
205
|
+
when "application/vnd.ms-powerpoint" then ".ppt"
|
|
206
|
+
when "application/vnd.openxmlformats-officedocument.presentationml.presentation" then ".pptx"
|
|
207
|
+
# Archives
|
|
208
|
+
when "application/zip" then ".zip"
|
|
209
|
+
when "application/x-rar-compressed", "application/vnd.rar" then ".rar"
|
|
210
|
+
when "application/x-7z-compressed" then ".7z"
|
|
211
|
+
when "application/x-tar" then ".tar"
|
|
212
|
+
when "application/gzip" then ".gz"
|
|
213
|
+
# Text
|
|
214
|
+
when "text/plain" then ".txt"
|
|
215
|
+
when "text/markdown" then ".md"
|
|
216
|
+
when "text/csv" then ".csv"
|
|
217
|
+
when "text/html" then ".html"
|
|
218
|
+
when "application/json" then ".json"
|
|
219
|
+
when "application/xml", "text/xml" then ".xml"
|
|
220
|
+
when "application/x-yaml", "text/yaml" then ".yml"
|
|
221
|
+
# Audio / video
|
|
222
|
+
when "audio/mpeg" then ".mp3"
|
|
223
|
+
when "audio/wav", "audio/x-wav" then ".wav"
|
|
224
|
+
when "audio/aac" then ".aac"
|
|
225
|
+
when "audio/ogg" then ".ogg"
|
|
226
|
+
when "video/mp4" then ".mp4"
|
|
227
|
+
when "video/quicktime" then ".mov"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Upload a local file to DingTalk and return its media_id.
|
|
232
|
+
# Webhook delivery doesn't support image/file attachments — uploaded
|
|
233
|
+
# mediaId is used by the OAPI sendMessage path below.
|
|
234
|
+
# @param path [String]
|
|
235
|
+
# @param kind [Symbol] :image | :file
|
|
236
|
+
# @return [String, nil] media_id
|
|
237
|
+
def upload_media(path, kind:)
|
|
238
|
+
type_str = kind == :image ? "image" : "file"
|
|
239
|
+
# NB: legacy OAPI /media/upload — the new /v1.0/robot/messageFiles/*
|
|
240
|
+
# path returns 404, this is the only working endpoint as of 2026-05.
|
|
241
|
+
token = oapi_access_token
|
|
242
|
+
uri = URI.parse("#{OAPI_BASE}/media/upload?access_token=#{token}&type=#{type_str}")
|
|
243
|
+
boundary = "----DingTalkBoundary#{rand(1 << 64).to_s(16)}"
|
|
244
|
+
body = build_multipart(path, boundary, type_str)
|
|
245
|
+
|
|
246
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
247
|
+
http.use_ssl = true
|
|
248
|
+
req = Net::HTTP::Post.new(uri.request_uri,
|
|
249
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}")
|
|
250
|
+
req.body = body
|
|
251
|
+
resp = http.request(req)
|
|
252
|
+
data = JSON.parse(resp.body) rescue {}
|
|
253
|
+
unless resp.code.to_i == 200 && data["errcode"].to_i.zero? && data["media_id"]
|
|
254
|
+
Clacky::Logger.warn("[dingtalk] upload_media rejected (#{resp.code}): #{resp.body}")
|
|
255
|
+
return nil
|
|
256
|
+
end
|
|
257
|
+
# Operational log: confirm upload succeeded and surface the
|
|
258
|
+
# media_id shape (length + first 4 chars). We keep this at info
|
|
259
|
+
# level because outbound failures correlate strongly with
|
|
260
|
+
# media_id format drift (e.g. DingTalk silently changing the
|
|
261
|
+
# `@` prefix policy). Avoid logging the full body to keep the
|
|
262
|
+
# token / id from leaking into shared log channels.
|
|
263
|
+
mid = data["media_id"].to_s
|
|
264
|
+
Clacky::Logger.info("[dingtalk] upload_media ok type=#{type_str} media_id_len=#{mid.length} media_id_prefix=#{mid[0, 4].inspect}")
|
|
265
|
+
mid
|
|
266
|
+
rescue => e
|
|
267
|
+
Clacky::Logger.warn("[dingtalk] upload_media failed: #{e.message}")
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Send a media message via OAPI (not webhook).
|
|
272
|
+
# DM → /v1.0/robot/oToMessages/batchSend (needs userIds)
|
|
273
|
+
# Group → /v1.0/robot/groupMessages/send (needs openConversationId)
|
|
274
|
+
# @param conv_type [String] "1"=DM, "2"=group
|
|
275
|
+
# @param kind [Symbol] :image | :file
|
|
276
|
+
def send_media(robot_code:, conv_type:, conv_id:, user_id:, media_id:, kind:, file_name: nil)
|
|
277
|
+
msg_key, msg_param = build_media_message(media_id, kind, file_name)
|
|
278
|
+
|
|
279
|
+
if conv_type == "2"
|
|
280
|
+
path = "/v1.0/robot/groupMessages/send"
|
|
281
|
+
body = {
|
|
282
|
+
msgKey: msg_key,
|
|
283
|
+
msgParam: JSON.generate(msg_param),
|
|
284
|
+
openConversationId: conv_id,
|
|
285
|
+
robotCode: robot_code
|
|
286
|
+
}
|
|
287
|
+
else
|
|
288
|
+
path = "/v1.0/robot/oToMessages/batchSend"
|
|
289
|
+
body = {
|
|
290
|
+
msgKey: msg_key,
|
|
291
|
+
msgParam: JSON.generate(msg_param),
|
|
292
|
+
userIds: [user_id],
|
|
293
|
+
robotCode: robot_code
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
uri = URI.parse("#{OPENAPI_BASE}#{path}")
|
|
298
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
299
|
+
http.use_ssl = true
|
|
300
|
+
req = Net::HTTP::Post.new(uri.request_uri,
|
|
301
|
+
"Content-Type" => "application/json",
|
|
302
|
+
"x-acs-dingtalk-access-token" => access_token)
|
|
303
|
+
req.body = JSON.generate(body)
|
|
304
|
+
resp = http.request(req)
|
|
305
|
+
data = JSON.parse(resp.body) rescue {}
|
|
306
|
+
if resp.code.to_i != 200
|
|
307
|
+
Clacky::Logger.warn("[dingtalk] send_media rejected (#{resp.code}): #{resp.body}")
|
|
308
|
+
return { ok: false, error: data["message"] || resp.body }
|
|
309
|
+
end
|
|
310
|
+
# Operational log: success path. We log msgKey (image vs file)
|
|
311
|
+
# so the operator can correlate "sampleImageMsg" with image
|
|
312
|
+
# delivery and "sampleFile" with file delivery without parsing
|
|
313
|
+
# the full request body.
|
|
314
|
+
Clacky::Logger.info("[dingtalk] send_media ok kind=#{kind} msgKey=#{msg_key}")
|
|
315
|
+
{ ok: true, data: data }
|
|
316
|
+
rescue => e
|
|
317
|
+
Clacky::Logger.warn("[dingtalk] send_media failed: #{e.message}")
|
|
318
|
+
{ ok: false, error: e.message }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private def build_media_message(media_id, kind, file_name)
|
|
322
|
+
# Images: `sampleImageMsg` with the OAPI-uploaded mediaId (type=image)
|
|
323
|
+
# renders an inline original-resolution image in the chat. The doc
|
|
324
|
+
# field is named `photoURL`, but DingTalk explicitly documents that
|
|
325
|
+
# photoURL accepts either a public URL OR a mediaId — and the
|
|
326
|
+
# mediaId form is what we use (the only way to send local files
|
|
327
|
+
# without standing up a public file server).
|
|
328
|
+
#
|
|
329
|
+
# NOTE — implementation pitfalls hard-won (2026-05-19):
|
|
330
|
+
# 1. Pass the raw mediaId returned by /media/upload AS-IS.
|
|
331
|
+
# Despite the sampleLink doc example showing `picUrl: "@lADO..."`
|
|
332
|
+
# with an `@` prefix, sampleImageMsg.photoURL must NOT be
|
|
333
|
+
# prefixed — the upload response already includes whatever
|
|
334
|
+
# shape DingTalk wants. Adding `@` produces "原图加载失败".
|
|
335
|
+
# 2. msgKey is `sampleImageMsg`, NOT `msgtype: "image"`.
|
|
336
|
+
# `msgtype:image` belongs to the corpconversation/asyncsend_v2
|
|
337
|
+
# work-notification protocol and does NOT work on the group
|
|
338
|
+
# robot endpoint we use here.
|
|
339
|
+
# 3. msgParam must be a JSON-stringified object (handled by the
|
|
340
|
+
# caller), not a nested hash.
|
|
341
|
+
return ["sampleImageMsg", { photoURL: media_id }] if kind == :image
|
|
342
|
+
|
|
343
|
+
# Generic files: sampleFile. fileType must be one of
|
|
344
|
+
# SUPPORTED_FILE_EXTS (see top of file for why we keep the
|
|
345
|
+
# empirically-verified 9-entry list rather than the 6-entry
|
|
346
|
+
# doc list). Upstream `Adapter#send_file` already screens out
|
|
347
|
+
# unsupported extensions before we reach here, so we just pass
|
|
348
|
+
# `ext` through.
|
|
349
|
+
ext = File.extname(file_name.to_s).delete(".")
|
|
350
|
+
["sampleFile", { mediaId: media_id, fileName: file_name.to_s, fileType: ext }]
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
private def build_multipart(path, boundary, type_str)
|
|
354
|
+
filename = File.basename(path)
|
|
355
|
+
mime = mime_for(filename)
|
|
356
|
+
content = File.binread(path)
|
|
357
|
+
|
|
358
|
+
# All parts must be ASCII-8BIT before joining; mixing UTF-8 (e.g. a
|
|
359
|
+
# filename with CJK chars) with binary file content raises
|
|
360
|
+
# "incompatible character encodings: UTF-8 and BINARY".
|
|
361
|
+
parts = []
|
|
362
|
+
parts << "--#{boundary}\r\n".b
|
|
363
|
+
parts << "Content-Disposition: form-data; name=\"type\"\r\n\r\n#{type_str}\r\n".b
|
|
364
|
+
parts << "--#{boundary}\r\n".b
|
|
365
|
+
parts << "Content-Disposition: form-data; name=\"media\"; filename=\"#{filename}\"\r\n".b
|
|
366
|
+
parts << "Content-Type: #{mime}\r\n\r\n".b
|
|
367
|
+
parts << content.b
|
|
368
|
+
parts << "\r\n--#{boundary}--\r\n".b
|
|
369
|
+
parts.join
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
private def mime_for(filename)
|
|
373
|
+
case File.extname(filename).downcase
|
|
374
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
375
|
+
when ".png" then "image/png"
|
|
376
|
+
when ".gif" then "image/gif"
|
|
377
|
+
when ".webp" then "image/webp"
|
|
378
|
+
when ".txt", ".log" then "text/plain"
|
|
379
|
+
when ".md" then "text/markdown"
|
|
380
|
+
when ".pdf" then "application/pdf"
|
|
381
|
+
when ".json" then "application/json"
|
|
382
|
+
when ".csv" then "text/csv"
|
|
383
|
+
when ".zip" then "application/zip"
|
|
384
|
+
else "application/octet-stream"
|
|
385
|
+
end
|
|
386
|
+
end
|
|
78
387
|
end
|
|
79
388
|
end
|
|
80
389
|
end
|
|
@@ -407,7 +407,7 @@ module Clacky
|
|
|
407
407
|
when ["GET", "/api/channels"] then api_list_channels(res)
|
|
408
408
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
409
409
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
410
|
-
when ["POST", "/api/
|
|
410
|
+
when ["POST", "/api/file-action"] then api_file_action(req, res)
|
|
411
411
|
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
412
412
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
413
413
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
@@ -448,6 +448,9 @@ module Clacky
|
|
|
448
448
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/model$})
|
|
449
449
|
session_id = path.sub("/api/sessions/", "").sub("/model", "")
|
|
450
450
|
api_switch_session_model(session_id, req, res)
|
|
451
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
|
|
452
|
+
session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
|
|
453
|
+
api_switch_session_reasoning_effort(session_id, req, res)
|
|
451
454
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
|
|
452
455
|
session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
|
|
453
456
|
api_benchmark_session_models(session_id, req, res)
|
|
@@ -752,6 +755,9 @@ module Clacky
|
|
|
752
755
|
else
|
|
753
756
|
Clacky::Logger.debug("[Brand] async distribution refresh skipped/failed — #{result[:message]}")
|
|
754
757
|
end
|
|
758
|
+
# Free-mode skill sync: branded + unactivated installs need their
|
|
759
|
+
# creator's free skills auto-installed for the "no serial number" UX.
|
|
760
|
+
brand.sync_free_skills_async!
|
|
755
761
|
rescue StandardError => e
|
|
756
762
|
Clacky::Logger.warn("[Brand] async distribution refresh raised: #{e.class}: #{e.message}")
|
|
757
763
|
ensure
|
|
@@ -797,12 +803,30 @@ module Clacky
|
|
|
797
803
|
refresh_pending = true
|
|
798
804
|
end
|
|
799
805
|
|
|
806
|
+
# Free-mode counts: synchronous fetch is acceptable here because
|
|
807
|
+
# this endpoint is polled lazily and the platform call is cached
|
|
808
|
+
# via http keep-alive. On error we just return zero counts and the
|
|
809
|
+
# banner falls back to the legacy "not activated" message.
|
|
810
|
+
free_count = 0
|
|
811
|
+
paid_count = 0
|
|
812
|
+
begin
|
|
813
|
+
result = brand.fetch_free_skills!
|
|
814
|
+
if result[:success]
|
|
815
|
+
free_count = result[:skills].size
|
|
816
|
+
paid_count = result[:paid_skills_count].to_i
|
|
817
|
+
end
|
|
818
|
+
rescue StandardError
|
|
819
|
+
# Network errors are non-fatal here.
|
|
820
|
+
end
|
|
821
|
+
|
|
800
822
|
json_response(res, 200, {
|
|
801
823
|
branded: true,
|
|
802
824
|
needs_activation: true,
|
|
803
825
|
product_name: brand.product_name,
|
|
804
826
|
test_mode: @brand_test,
|
|
805
|
-
distribution_refresh_pending: refresh_pending
|
|
827
|
+
distribution_refresh_pending: refresh_pending,
|
|
828
|
+
free_skills_count: free_count,
|
|
829
|
+
paid_skills_count: paid_count
|
|
806
830
|
})
|
|
807
831
|
return
|
|
808
832
|
end
|
|
@@ -918,7 +942,30 @@ module Clacky
|
|
|
918
942
|
brand = Clacky::BrandConfig.load
|
|
919
943
|
|
|
920
944
|
unless brand.activated?
|
|
921
|
-
|
|
945
|
+
# Free-mode: branded but no license. Return the unencrypted skills
|
|
946
|
+
# available to anonymous installs so the Brand Skills tab is not
|
|
947
|
+
# empty and the user can install/use them without a serial number.
|
|
948
|
+
# Each skill is tagged is_free=true so the UI can show a "Free" badge.
|
|
949
|
+
result = brand.fetch_free_skills!
|
|
950
|
+
|
|
951
|
+
if result[:success]
|
|
952
|
+
free_skills = result[:skills].map { |s| s.merge("is_free" => true) }
|
|
953
|
+
json_response(res, 200, {
|
|
954
|
+
ok: true,
|
|
955
|
+
skills: free_skills,
|
|
956
|
+
free_mode: true,
|
|
957
|
+
paid_skills_count: result[:paid_skills_count].to_i
|
|
958
|
+
})
|
|
959
|
+
else
|
|
960
|
+
json_response(res, 200, {
|
|
961
|
+
ok: true,
|
|
962
|
+
skills: [],
|
|
963
|
+
free_mode: true,
|
|
964
|
+
paid_skills_count: 0,
|
|
965
|
+
warning_code: "remote_unavailable",
|
|
966
|
+
warning: result[:error] || "Could not reach the license server."
|
|
967
|
+
})
|
|
968
|
+
end
|
|
922
969
|
return
|
|
923
970
|
end
|
|
924
971
|
|
|
@@ -963,8 +1010,29 @@ module Clacky
|
|
|
963
1010
|
def api_brand_skill_install(slug, req, res)
|
|
964
1011
|
brand = Clacky::BrandConfig.load
|
|
965
1012
|
|
|
1013
|
+
# Free-mode: branded but not activated. Fall back to the public free
|
|
1014
|
+
# skills endpoint and install with encrypted: false. Paid (encrypted)
|
|
1015
|
+
# skills still require activation and will return 404 here.
|
|
966
1016
|
unless brand.activated?
|
|
967
|
-
|
|
1017
|
+
fetch_result = brand.fetch_free_skills!
|
|
1018
|
+
unless fetch_result[:success]
|
|
1019
|
+
json_response(res, 422, { ok: false, error: fetch_result[:error] })
|
|
1020
|
+
return
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
skill_info = fetch_result[:skills].find { |s| s["name"] == slug }
|
|
1024
|
+
unless skill_info
|
|
1025
|
+
json_response(res, 404, { ok: false, error: "Skill '#{slug}' is not a free skill — activate your license to access it." })
|
|
1026
|
+
return
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
result = brand.install_free_skill!(skill_info)
|
|
1030
|
+
if result[:success]
|
|
1031
|
+
@skill_loader = Clacky::SkillLoader.new(working_dir: nil, brand_config: brand)
|
|
1032
|
+
json_response(res, 200, { ok: true, name: result[:name], version: result[:version] })
|
|
1033
|
+
else
|
|
1034
|
+
json_response(res, 422, { ok: false, error: result[:error] })
|
|
1035
|
+
end
|
|
968
1036
|
return
|
|
969
1037
|
end
|
|
970
1038
|
|
|
@@ -1304,7 +1372,11 @@ module Clacky
|
|
|
1304
1372
|
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
1305
1373
|
|
|
1306
1374
|
if @master_pid
|
|
1307
|
-
# Worker mode: tell master to hot-restart
|
|
1375
|
+
# Worker mode: tell master to hot-restart. Master will TERM us after the
|
|
1376
|
+
# new worker boots; our trap("TERM") then runs shutdown_proc, which detaches
|
|
1377
|
+
# the inherited listen socket before WEBrick shutdown. Do NOT exit(0) here —
|
|
1378
|
+
# that bypasses trap handlers and lets the OS close(fd) on a socket shared
|
|
1379
|
+
# with master+new worker, corrupting the listener on Linux/WSL.
|
|
1308
1380
|
Clacky::Logger.info("[Restart] Sending USR1 to master (PID=#{@master_pid})")
|
|
1309
1381
|
begin
|
|
1310
1382
|
Process.kill("USR1", @master_pid)
|
|
@@ -1312,7 +1384,6 @@ module Clacky
|
|
|
1312
1384
|
Clacky::Logger.warn("[Restart] Master PID=#{@master_pid} not found, falling back to exec.")
|
|
1313
1385
|
standalone_exec_restart
|
|
1314
1386
|
end
|
|
1315
|
-
exit(0)
|
|
1316
1387
|
else
|
|
1317
1388
|
# Standalone mode (no master): fall back to the original exec approach.
|
|
1318
1389
|
standalone_exec_restart
|
|
@@ -1549,12 +1620,16 @@ module Clacky
|
|
|
1549
1620
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1550
1621
|
end
|
|
1551
1622
|
|
|
1552
|
-
# POST /api/
|
|
1553
|
-
#
|
|
1554
|
-
#
|
|
1555
|
-
# file
|
|
1556
|
-
|
|
1557
|
-
|
|
1623
|
+
# POST /api/file-action
|
|
1624
|
+
# Unified file action endpoint — open locally or download.
|
|
1625
|
+
# Body: { path: String, action: "open" | "download" }
|
|
1626
|
+
# open: opens the file with the OS default handler (local deployments).
|
|
1627
|
+
# download: returns the file as a download (remote deployments).
|
|
1628
|
+
def api_file_action(req, res)
|
|
1629
|
+
body = parse_json_body(req)
|
|
1630
|
+
path = body["path"]
|
|
1631
|
+
action = body["action"] || "open"
|
|
1632
|
+
|
|
1558
1633
|
return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
|
|
1559
1634
|
|
|
1560
1635
|
# Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
|
|
@@ -1567,13 +1642,33 @@ module Clacky
|
|
|
1567
1642
|
|
|
1568
1643
|
return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
|
|
1569
1644
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1645
|
+
case action
|
|
1646
|
+
when "open"
|
|
1647
|
+
result = Utils::EnvironmentDetector.open_file(linux_path)
|
|
1648
|
+
return json_response(res, 501, { error: "unsupported OS" }) if result.nil?
|
|
1649
|
+
json_response(res, 200, { ok: true })
|
|
1650
|
+
when "download"
|
|
1651
|
+
serve_file_download(res, linux_path)
|
|
1652
|
+
else
|
|
1653
|
+
json_response(res, 400, { error: "invalid action. Must be 'open' or 'download'" })
|
|
1654
|
+
end
|
|
1573
1655
|
rescue => e
|
|
1574
1656
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1575
1657
|
end
|
|
1576
1658
|
|
|
1659
|
+
# Stream a file to the client as a download.
|
|
1660
|
+
# Content-Type is always application/octet-stream — the browser determines
|
|
1661
|
+
# file type and handling from the filename extension in Content-Disposition.
|
|
1662
|
+
def serve_file_download(res, path)
|
|
1663
|
+
filename = File.basename(path)
|
|
1664
|
+
|
|
1665
|
+
res.status = 200
|
|
1666
|
+
res["Content-Type"] = "application/octet-stream"
|
|
1667
|
+
res["Content-Disposition"] = "attachment; filename=\"#{filename}\""
|
|
1668
|
+
res["Content-Length"] = File.size(path).to_s
|
|
1669
|
+
res.body = File.binread(path)
|
|
1670
|
+
end
|
|
1671
|
+
|
|
1577
1672
|
# GET /api/local-image?path=file:///path/to/image.png
|
|
1578
1673
|
# GET /api/local-image?path=/path/to/image.png
|
|
1579
1674
|
#
|
|
@@ -2999,6 +3094,26 @@ module Clacky
|
|
|
2999
3094
|
json_response(res, 500, { error: e.message })
|
|
3000
3095
|
end
|
|
3001
3096
|
|
|
3097
|
+
# PATCH /api/sessions/:id/reasoning_effort
|
|
3098
|
+
# Body: { "reasoning_effort": "off" | "low" | "medium" | "high" }
|
|
3099
|
+
def api_switch_session_reasoning_effort(session_id, req, res)
|
|
3100
|
+
body = parse_json_body(req)
|
|
3101
|
+
raw = body["reasoning_effort"]
|
|
3102
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
3103
|
+
|
|
3104
|
+
agent = nil
|
|
3105
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
3106
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
3107
|
+
|
|
3108
|
+
agent.reasoning_effort = raw
|
|
3109
|
+
@session_manager.save(agent.to_session_data)
|
|
3110
|
+
broadcast_session_update(session_id)
|
|
3111
|
+
|
|
3112
|
+
json_response(res, 200, { ok: true, reasoning_effort: agent.reasoning_effort })
|
|
3113
|
+
rescue => e
|
|
3114
|
+
json_response(res, 500, { error: e.message })
|
|
3115
|
+
end
|
|
3116
|
+
|
|
3002
3117
|
# POST /api/sessions/:id/benchmark
|
|
3003
3118
|
#
|
|
3004
3119
|
# Speed-test every configured model in one shot so the user can pick the
|
|
@@ -165,11 +165,12 @@ module Clacky
|
|
|
165
165
|
model_info = s[:agent]&.current_model_info
|
|
166
166
|
live_name = s[:agent]&.name
|
|
167
167
|
live_name = nil if live_name&.empty?
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
live_cost_source = s[:agent]&.cost_source
|
|
169
|
+
{ status: s[:status], error: s[:error], model: model_info&.dig(:model), name: live_name,
|
|
170
|
+
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
|
|
171
|
+
cost_source: live_cost_source,
|
|
172
|
+
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
173
|
+
latest_latency: s[:agent]&.latest_latency }
|
|
173
174
|
end
|
|
174
175
|
end
|
|
175
176
|
|
|
@@ -241,6 +242,7 @@ module Clacky
|
|
|
241
242
|
{ status: s[:status], error: s[:error], model: model_info&.dig(:model),
|
|
242
243
|
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
243
244
|
total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
|
|
245
|
+
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
244
246
|
latest_latency: s[:agent]&.latest_latency }
|
|
245
247
|
end
|
|
246
248
|
|
|
@@ -271,6 +273,7 @@ module Clacky
|
|
|
271
273
|
# per-assistant-message `latency` fields in messages[]. Reloaded
|
|
272
274
|
# sessions start with nil and get populated on the next LLM call.
|
|
273
275
|
latest_latency: ls&.dig(:latest_latency),
|
|
276
|
+
reasoning_effort: ls&.dig(:reasoning_effort) || s.dig(:config, :reasoning_effort),
|
|
274
277
|
pinned: s[:pinned] || false,
|
|
275
278
|
}
|
|
276
279
|
end
|
|
@@ -382,7 +385,7 @@ module Clacky
|
|
|
382
385
|
return nil unless agent
|
|
383
386
|
|
|
384
387
|
model_info = agent.current_model_info
|
|
385
|
-
|
|
388
|
+
|
|
386
389
|
{
|
|
387
390
|
id: session[:id],
|
|
388
391
|
name: agent.name,
|
|
@@ -795,6 +795,20 @@ module Clacky
|
|
|
795
795
|
@legacy_progress_handles[type] = start_progress(message: display, style: style)
|
|
796
796
|
end
|
|
797
797
|
|
|
798
|
+
# Stream-only update for the live thinking progress. Unlike
|
|
799
|
+
# +show_progress(progress_type: "thinking", phase: "active")+, this
|
|
800
|
+
# NEVER creates a new handle — if no thinking handle is currently
|
|
801
|
+
# alive (e.g. we're inside an idle-compression call_llm where only
|
|
802
|
+
# the quiet "Compressing..." handle is on the stack), the streamed
|
|
803
|
+
# token counts are silently dropped instead of spawning a primary
|
|
804
|
+
# spinner that would push the compression progress off-screen.
|
|
805
|
+
def stream_thinking_progress(input_tokens:, output_tokens:)
|
|
806
|
+
@legacy_progress_handles ||= {}
|
|
807
|
+
existing = @legacy_progress_handles["thinking"]
|
|
808
|
+
return unless existing&.running?
|
|
809
|
+
existing.update(metadata: { input_tokens: input_tokens, output_tokens: output_tokens })
|
|
810
|
+
end
|
|
811
|
+
|
|
798
812
|
# ---------------------------------------------------------------------
|
|
799
813
|
# (Legacy dead-code removed: the old imperative show_progress body
|
|
800
814
|
# used to live here and is now superseded by the shim + owner
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -36,6 +36,20 @@ module Clacky
|
|
|
36
36
|
# metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
|
|
37
37
|
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
|
|
38
38
|
|
|
39
|
+
# Update the live "thinking" progress with streamed token counts.
|
|
40
|
+
# This is *purely decorative*: it must NEVER start a new progress
|
|
41
|
+
# indicator. If no thinking progress is currently active (e.g. during
|
|
42
|
+
# idle compression, where only a quiet "Compressing..." progress is
|
|
43
|
+
# live), the call is a no-op. UI2 overrides this; other UIs delegate
|
|
44
|
+
# to show_progress.
|
|
45
|
+
def stream_thinking_progress(input_tokens:, output_tokens:)
|
|
46
|
+
show_progress(
|
|
47
|
+
progress_type: "thinking",
|
|
48
|
+
phase: "active",
|
|
49
|
+
metadata: { input_tokens: input_tokens, output_tokens: output_tokens }
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
# === Progress (v2: owned handles) ===
|
|
40
54
|
#
|
|
41
55
|
# Start a new progress indicator and return an owned handle. The caller
|