openclacky 1.5.13 → 1.5.14
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 +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Server
|
|
9
|
+
# Preview panel routes: static workspace-file serving
|
|
10
|
+
# (/preview/s/<session>/<path>) and localhost dev-server proxying
|
|
11
|
+
# (/preview/p/<port>/<path>). Mixed into HttpServer so the route table
|
|
12
|
+
# (send(:serve_preview, ...)) dispatches into it unchanged.
|
|
13
|
+
module Preview
|
|
14
|
+
# MIME types served by the /preview/s/<session>/<path> route. Anything
|
|
15
|
+
# not listed falls back to a download (application/octet-stream) so an
|
|
16
|
+
# AI-generated page's sibling files can never execute as HTML.
|
|
17
|
+
PREVIEW_CONTENT_TYPES = {
|
|
18
|
+
".html" => "text/html; charset=utf-8",
|
|
19
|
+
".htm" => "text/html; charset=utf-8",
|
|
20
|
+
".css" => "text/css; charset=utf-8",
|
|
21
|
+
".js" => "text/javascript; charset=utf-8",
|
|
22
|
+
".mjs" => "text/javascript; charset=utf-8",
|
|
23
|
+
".json" => "application/json; charset=utf-8",
|
|
24
|
+
".map" => "application/json; charset=utf-8",
|
|
25
|
+
".svg" => "image/svg+xml",
|
|
26
|
+
".png" => "image/png",
|
|
27
|
+
".jpg" => "image/jpeg",
|
|
28
|
+
".jpeg" => "image/jpeg",
|
|
29
|
+
".gif" => "image/gif",
|
|
30
|
+
".webp" => "image/webp",
|
|
31
|
+
".avif" => "image/avif",
|
|
32
|
+
".ico" => "image/x-icon",
|
|
33
|
+
".bmp" => "image/bmp",
|
|
34
|
+
".woff" => "font/woff",
|
|
35
|
+
".woff2" => "font/woff2",
|
|
36
|
+
".ttf" => "font/ttf",
|
|
37
|
+
".otf" => "font/otf",
|
|
38
|
+
".eot" => "application/vnd.ms-fontobject",
|
|
39
|
+
".txt" => "text/plain; charset=utf-8",
|
|
40
|
+
".xml" => "application/xml; charset=utf-8",
|
|
41
|
+
".csv" => "text/csv; charset=utf-8",
|
|
42
|
+
".wasm" => "application/wasm",
|
|
43
|
+
".mp4" => "video/mp4",
|
|
44
|
+
".webm" => "video/webm",
|
|
45
|
+
".mp3" => "audio/mpeg",
|
|
46
|
+
".wav" => "audio/wav",
|
|
47
|
+
".ogg" => "audio/ogg"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# Injected into preview HTML responses. The preview iframe is sandboxed
|
|
51
|
+
# without allow-same-origin, so the host page cannot read its location
|
|
52
|
+
# (and the iframe's src property does not follow internal navigations).
|
|
53
|
+
# Each served page therefore announces its own URL to the parent.
|
|
54
|
+
PREVIEW_NAV_REPORT = '<script>(function(){try{parent.postMessage({__clackyPreview:1,href:location.href},"*")}catch(e){}})()</script>'
|
|
55
|
+
|
|
56
|
+
# Headers dropped when proxying a localhost dev server through the
|
|
57
|
+
# preview panel. X-Frame-Options is what blocks the sandboxed iframe;
|
|
58
|
+
# hop-by-hop headers and Set-Cookie must not cross the proxy boundary
|
|
59
|
+
# (WEBrick recomputes the former, cookies would leak the upstream's
|
|
60
|
+
# domain into ours). CSP frame-ancestors is handled separately so the
|
|
61
|
+
# rest of the policy survives.
|
|
62
|
+
PROXY_SKIP_HEADERS = %w[
|
|
63
|
+
x-frame-options
|
|
64
|
+
transfer-encoding
|
|
65
|
+
content-length
|
|
66
|
+
connection
|
|
67
|
+
keep-alive
|
|
68
|
+
proxy-authenticate
|
|
69
|
+
proxy-authorization
|
|
70
|
+
te
|
|
71
|
+
trailer
|
|
72
|
+
upgrade
|
|
73
|
+
set-cookie
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
# Connection-stage failures when dialing the localhost dev server. The
|
|
77
|
+
# proxy retries the next loopback stack on these and answers 502 only
|
|
78
|
+
# when both stacks fail.
|
|
79
|
+
PROXY_CONNECT_ERRORS = [
|
|
80
|
+
Errno::ECONNREFUSED,
|
|
81
|
+
Errno::ECONNRESET,
|
|
82
|
+
Errno::EHOSTUNREACH,
|
|
83
|
+
Net::OpenTimeout,
|
|
84
|
+
SocketError
|
|
85
|
+
].freeze
|
|
86
|
+
|
|
87
|
+
private def preview_not_found(res)
|
|
88
|
+
res.status = 404
|
|
89
|
+
res.body = "not found"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private def preview_method_not_allowed(res)
|
|
93
|
+
res.status = 405
|
|
94
|
+
res["Allow"] = "GET, HEAD"
|
|
95
|
+
res.body = "method not allowed"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private def preview_invalid_port(res)
|
|
99
|
+
res.status = 400
|
|
100
|
+
res.body = "invalid port"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private def preview_unreachable(res, port)
|
|
104
|
+
res.status = 502
|
|
105
|
+
res.body = "cannot reach localhost:#{port}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# GET /preview/s/<session_id>/<rel> — serve a workspace file for the
|
|
109
|
+
# preview panel. The session id resolves to that session's working_dir
|
|
110
|
+
# and rel must stay inside it. Directory requests fall back to
|
|
111
|
+
# index.html so the page's relative asset references keep resolving.
|
|
112
|
+
# Extensions missing from PREVIEW_CONTENT_TYPES download (octet-stream)
|
|
113
|
+
# instead of rendering, so a non-listed file can never execute as HTML.
|
|
114
|
+
private def serve_preview(req, res)
|
|
115
|
+
unless req.request_method == "GET" || req.request_method == "HEAD"
|
|
116
|
+
preview_method_not_allowed(res)
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
rest = preview_decode_path(req.path.delete_prefix("/preview/"))
|
|
121
|
+
sid, _, rel = rest.delete_prefix("s/").partition("/")
|
|
122
|
+
|
|
123
|
+
session = sid.empty? ? nil : @session_manager.load(sid)
|
|
124
|
+
working_dir = session ? session[:working_dir].to_s : ""
|
|
125
|
+
if working_dir.empty?
|
|
126
|
+
preview_not_found(res)
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
root = File.expand_path(working_dir)
|
|
131
|
+
abs = File.expand_path(File.join(root, rel))
|
|
132
|
+
unless abs == root || abs.start_with?(root + File::SEPARATOR)
|
|
133
|
+
preview_not_found(res)
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
serve_preview_file(req, res, abs)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# GET /preview/f/<absolute-path> — serve a local file outside the
|
|
141
|
+
# workspace, addressed by its absolute path (the panel maps file:// URLs
|
|
142
|
+
# here). Restricted to the user's home directory so it cannot read
|
|
143
|
+
# system files elsewhere.
|
|
144
|
+
private def serve_preview_local(req, res)
|
|
145
|
+
unless req.request_method == "GET" || req.request_method == "HEAD"
|
|
146
|
+
preview_method_not_allowed(res)
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
abs = preview_decode_path(req.path.delete_prefix("/preview/f"))
|
|
151
|
+
return preview_not_found(res) if abs.empty? || !abs.start_with?("/")
|
|
152
|
+
|
|
153
|
+
abs = File.expand_path(abs)
|
|
154
|
+
home = File.expand_path(Dir.home)
|
|
155
|
+
unless abs == home || abs.start_with?(home + File::SEPARATOR)
|
|
156
|
+
preview_not_found(res)
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
serve_preview_file(req, res, abs)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Shared tail for the file-serving routes: resolve a directory to its
|
|
164
|
+
# index.html, then stream the file with the MIME whitelist and the
|
|
165
|
+
# injected navigation reporter.
|
|
166
|
+
private def serve_preview_file(req, res, abs)
|
|
167
|
+
abs = File.join(abs, "index.html") if File.directory?(abs)
|
|
168
|
+
unless File.file?(abs)
|
|
169
|
+
preview_not_found(res)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
res.status = 200
|
|
174
|
+
res["Content-Type"] = PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "application/octet-stream"
|
|
175
|
+
res["Cache-Control"] = "no-store"
|
|
176
|
+
res["Pragma"] = "no-cache"
|
|
177
|
+
res["X-Content-Type-Options"] = "nosniff"
|
|
178
|
+
if req.request_method == "HEAD"
|
|
179
|
+
res["Content-Length"] = File.size(abs).to_s
|
|
180
|
+
res.body = ""
|
|
181
|
+
else
|
|
182
|
+
body = preview_body(abs)
|
|
183
|
+
res["Content-Length"] = body.bytesize.to_s
|
|
184
|
+
res.body = body
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Injected right after <head> (falling back to <html>, <!DOCTYPE>, or
|
|
189
|
+
# the document start) so the reporter runs before page scripts while a
|
|
190
|
+
# leading <!DOCTYPE> stays first — prepending would flip the document
|
|
191
|
+
# into quirks mode.
|
|
192
|
+
private def preview_body(abs)
|
|
193
|
+
body = File.binread(abs)
|
|
194
|
+
return body unless (PREVIEW_CONTENT_TYPES[File.extname(abs).downcase] || "").start_with?("text/html")
|
|
195
|
+
|
|
196
|
+
html = body.force_encoding(Encoding::UTF_8)
|
|
197
|
+
if (m = html.match(/<head[^>]*>/i))
|
|
198
|
+
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
199
|
+
elsif (m = html.match(/<html[^>]*>/i))
|
|
200
|
+
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
201
|
+
elsif (m = html.match(/<!DOCTYPE[^>]*>/i))
|
|
202
|
+
html[m.end(0), 0] = PREVIEW_NAV_REPORT
|
|
203
|
+
else
|
|
204
|
+
html = PREVIEW_NAV_REPORT + html
|
|
205
|
+
end
|
|
206
|
+
html
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Decode %XX escapes in a preview path segment without treating "+" as
|
|
210
|
+
# space: "+" is a legal literal character in file names.
|
|
211
|
+
private def preview_decode_path(seg)
|
|
212
|
+
return seg unless seg.include?("%")
|
|
213
|
+
|
|
214
|
+
seg.gsub(/%([0-9A-Fa-f]{2})/) { |m| m[1, 2].to_i(16).chr }
|
|
215
|
+
.force_encoding(Encoding::UTF_8)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# GET /preview/p/<port>/<path> — proxy a localhost dev server through the
|
|
219
|
+
# preview panel. Rails/Next.js and friends ship X-Frame-Options / CSP
|
|
220
|
+
# frame-ancestors by default, which blocks a sandboxed iframe; the proxy
|
|
221
|
+
# strips those so the page renders. The host is restricted to the loopback
|
|
222
|
+
# interfaces (127.0.0.1 then ::1 — the port is the only variable), so this
|
|
223
|
+
# route can never be pointed at the public internet or another internal
|
|
224
|
+
# host — no SSRF.
|
|
225
|
+
private def serve_preview_proxy(req, res)
|
|
226
|
+
unless req.request_method == "GET" || req.request_method == "HEAD"
|
|
227
|
+
preview_method_not_allowed(res)
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
port_str, _, rel = req.path.delete_prefix("/preview/p/").partition("/")
|
|
232
|
+
unless port_str.match?(/\A\d+\z/)
|
|
233
|
+
preview_invalid_port(res)
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
port = port_str.to_i
|
|
237
|
+
if port < 1 || port > 65_535
|
|
238
|
+
preview_invalid_port(res)
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
target = "/" + rel
|
|
243
|
+
target += "?" + req.query_string if req.query_string && !req.query_string.empty?
|
|
244
|
+
|
|
245
|
+
klass = req.request_method == "HEAD" ? Net::HTTP::Head : Net::HTTP::Get
|
|
246
|
+
|
|
247
|
+
resp = nil
|
|
248
|
+
# Vite 5 binds only the IPv6 loopback (::1) on macOS, so connecting to
|
|
249
|
+
# 127.0.0.1 alone 502s even while the dev server is up. Try both stacks.
|
|
250
|
+
%w[127.0.0.1 ::1].each do |host|
|
|
251
|
+
up_req = klass.new(target)
|
|
252
|
+
# Accept-Encoding is dropped so the upstream answers with plain bytes
|
|
253
|
+
# instead of gzip.
|
|
254
|
+
%w[Accept User-Agent Cookie Accept-Language Referer Range].each do |h|
|
|
255
|
+
v = req[h]
|
|
256
|
+
up_req[h] = v if v && !v.empty?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
http = Net::HTTP.new(host, port)
|
|
260
|
+
http.open_timeout = 2
|
|
261
|
+
http.read_timeout = 20
|
|
262
|
+
begin
|
|
263
|
+
resp = http.request(up_req)
|
|
264
|
+
break
|
|
265
|
+
rescue *PROXY_CONNECT_ERRORS
|
|
266
|
+
next
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
if resp.nil?
|
|
271
|
+
preview_unreachable(res, port)
|
|
272
|
+
return
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
res.status = resp.code.to_i
|
|
276
|
+
is_html = false
|
|
277
|
+
is_css = false
|
|
278
|
+
upstream_frameable = true
|
|
279
|
+
resp.each_capitalized do |name, value|
|
|
280
|
+
down = name.downcase
|
|
281
|
+
upstream_frameable = false if down == "x-frame-options"
|
|
282
|
+
if down == "content-security-policy" || down == "content-security-policy-report-only"
|
|
283
|
+
upstream_frameable = false if value =~ /frame-ancestors/i
|
|
284
|
+
value = strip_frame_ancestors(value)
|
|
285
|
+
next if value.nil? || value.empty?
|
|
286
|
+
end
|
|
287
|
+
next if PROXY_SKIP_HEADERS.include?(down)
|
|
288
|
+
if down == "content-type"
|
|
289
|
+
is_html = value.start_with?("text/html")
|
|
290
|
+
is_css = value.start_with?("text/css")
|
|
291
|
+
end
|
|
292
|
+
res[name] = value
|
|
293
|
+
end
|
|
294
|
+
# Tells the preview panel whether the upstream can be framed directly.
|
|
295
|
+
# Vite-style dev servers send no X-Frame-Options, and their JS relies
|
|
296
|
+
# on absolute-path ESM imports the proxy cannot rewrite — for those,
|
|
297
|
+
# a direct iframe (which also keeps HMR websockets alive) is the only
|
|
298
|
+
# rendering path that works.
|
|
299
|
+
res["X-Clacky-Upstream-Frameable"] = upstream_frameable ? "1" : "0"
|
|
300
|
+
if req.request_method != "HEAD"
|
|
301
|
+
body = resp.body.to_s
|
|
302
|
+
if is_html
|
|
303
|
+
body = rewrite_preview_paths(body, port)
|
|
304
|
+
elsif is_css
|
|
305
|
+
body = rewrite_preview_css(body, port)
|
|
306
|
+
end
|
|
307
|
+
res.body = body
|
|
308
|
+
end
|
|
309
|
+
rescue Net::ReadTimeout, Timeout::Error, EOFError
|
|
310
|
+
preview_unreachable(res, port)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Removes the frame-ancestors directive from a Content-Security-Policy
|
|
314
|
+
# value so the proxied page may be framed, keeping the rest of the
|
|
315
|
+
# policy intact. Returns nil when nothing (or an empty policy) remains.
|
|
316
|
+
private def strip_frame_ancestors(csp)
|
|
317
|
+
stripped = csp.to_s.gsub(/frame-ancestors[^;]*;?/i, "")
|
|
318
|
+
stripped = stripped.gsub(/;\s*;/, ";").gsub(/^\s*;|;\s*$/, "").strip
|
|
319
|
+
stripped.empty? ? nil : stripped
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Rewrites absolute-path resource URLs (/assets/app.js) in a proxied HTML
|
|
323
|
+
# page to the proxy prefix so the browser resolves them against the dev
|
|
324
|
+
# server instead of the host origin. Protocol-relative (//), already
|
|
325
|
+
# proxied and scheme URLs are left untouched.
|
|
326
|
+
private def rewrite_preview_paths(html, port)
|
|
327
|
+
prefix = "/preview/p/#{port}"
|
|
328
|
+
html.gsub(/(\b(?:src|href)\s*=\s*["'])(\/[^"']*?)(["'])/i) do
|
|
329
|
+
if $2.start_with?("//", "/preview/p/")
|
|
330
|
+
$&
|
|
331
|
+
else
|
|
332
|
+
"#{$1}#{prefix}#{$2}#{$3}"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Rewrites absolute-path url() references inside a proxied stylesheet so
|
|
338
|
+
# fonts/images resolve against the dev server instead of the host origin.
|
|
339
|
+
private def rewrite_preview_css(css, port)
|
|
340
|
+
prefix = "/preview/p/#{port}"
|
|
341
|
+
css.gsub(/url\(\s*(["']?)(\/[^"')]*?)\1\s*\)/i) do
|
|
342
|
+
if $2.start_with?("//", "/preview/p/")
|
|
343
|
+
$&
|
|
344
|
+
else
|
|
345
|
+
"url(#{$1}#{prefix}#{$2}#{$1})"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -105,7 +105,7 @@ module Clacky
|
|
|
105
105
|
# Channel subscribers receive the original content so they can deliver
|
|
106
106
|
# local images as native attachments via send_file().
|
|
107
107
|
web_content = Clacky::Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
108
|
-
emit("assistant_message", content: web_content, files: files, created_at: created_at)
|
|
108
|
+
emit("assistant_message", content: web_content, files: files, created_at: created_at, interim: interim)
|
|
109
109
|
forward_to_subscribers { |sub| sub.show_assistant_message(content, files: files, interim: interim) }
|
|
110
110
|
end
|
|
111
111
|
|
data/lib/clacky/skill.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require "json"
|
|
4
5
|
require "pathname"
|
|
5
6
|
require_relative "utils/file_ignore_helper"
|
|
6
7
|
require_relative "utils/gitignore_parser"
|
|
@@ -618,6 +619,53 @@ module Clacky
|
|
|
618
619
|
content
|
|
619
620
|
end
|
|
620
621
|
|
|
622
|
+
class << self
|
|
623
|
+
# Rebuild a raw SKILL.md string with the editable frontmatter fields
|
|
624
|
+
# (name/name_zh/description/description_zh) and body updated, preserving
|
|
625
|
+
# every other frontmatter line untouched.
|
|
626
|
+
def update_frontmatter_fields(raw_content, fields)
|
|
627
|
+
scalar = lambda do |value|
|
|
628
|
+
s = value.to_s
|
|
629
|
+
if s.empty? || s != s.strip
|
|
630
|
+
JSON.generate(s)
|
|
631
|
+
elsif s.match?(/\A[a-zA-Z0-9][a-zA-Z0-9 _.\-\/]*\z/)
|
|
632
|
+
s
|
|
633
|
+
else
|
|
634
|
+
JSON.generate(s)
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
editable = {
|
|
639
|
+
"name" => fields["name"].to_s.strip,
|
|
640
|
+
"name_zh" => fields["name_zh"].to_s.strip,
|
|
641
|
+
"description" => fields["description"].to_s.strip,
|
|
642
|
+
"description_zh" => fields["description_zh"].to_s.strip
|
|
643
|
+
}
|
|
644
|
+
body = fields["body"].to_s.sub(/\A\n+/, "")
|
|
645
|
+
|
|
646
|
+
match = raw_content.match(/\A---\n(.*?)\n---[ \t]*\n?/m)
|
|
647
|
+
yaml_block = match ? match[1] : ""
|
|
648
|
+
|
|
649
|
+
lines = yaml_block.empty? ? [] : yaml_block.split("\n", -1)
|
|
650
|
+
updated = {}
|
|
651
|
+
new_lines = lines.map do |line|
|
|
652
|
+
key = line[/\A([a-zA-Z0-9_-]+):/, 1]
|
|
653
|
+
if editable.key?(key) && !updated[key]
|
|
654
|
+
updated[key] = true
|
|
655
|
+
"#{key}: #{scalar.call(editable[key])}"
|
|
656
|
+
else
|
|
657
|
+
line
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
editable.each do |key, value|
|
|
661
|
+
next if updated[key]
|
|
662
|
+
new_lines << "#{key}: #{scalar.call(value)}"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
"---\n#{new_lines.join("\n")}\n---\n\n#{body}"
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
621
669
|
|
|
622
670
|
end
|
|
623
671
|
end
|