openclacky 1.3.11 → 1.4.1

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/lib/clacky/agent/session_serializer.rb +11 -4
  4. data/lib/clacky/agent/skill_manager.rb +1 -0
  5. data/lib/clacky/agent.rb +4 -5
  6. data/lib/clacky/brand_config.rb +53 -3
  7. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +202 -3
  8. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +610 -60
  9. data/lib/clacky/default_skills/media-gen/SKILL.md +182 -7
  10. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +5 -3
  11. data/lib/clacky/extension/packager.rb +21 -1
  12. data/lib/clacky/extension/verifier.rb +1 -1
  13. data/lib/clacky/media/generator.rb +41 -0
  14. data/lib/clacky/media/volcengine.rb +394 -0
  15. data/lib/clacky/platform_http_client.rb +9 -7
  16. data/lib/clacky/providers.rb +4 -4
  17. data/lib/clacky/server/http_server.rb +67 -22
  18. data/lib/clacky/tools/grep.rb +6 -1
  19. data/lib/clacky/tools/terminal.rb +24 -8
  20. data/lib/clacky/ui2/layout_manager.rb +4 -0
  21. data/lib/clacky/ui2/screen_buffer.rb +31 -3
  22. data/lib/clacky/utils/environment_detector.rb +16 -9
  23. data/lib/clacky/utils/file_processor.rb +30 -28
  24. data/lib/clacky/utils/model_pricing.rb +49 -0
  25. data/lib/clacky/version.rb +1 -1
  26. data/lib/clacky/web/app.css +229 -31
  27. data/lib/clacky/web/components/onboard.js +14 -4
  28. data/lib/clacky/web/features/backup/view.js +4 -3
  29. data/lib/clacky/web/features/extensions/store.js +2 -2
  30. data/lib/clacky/web/features/extensions/view.js +37 -7
  31. data/lib/clacky/web/features/mcp/view.js +5 -2
  32. data/lib/clacky/web/features/skills/view.js +61 -1
  33. data/lib/clacky/web/features/workspace/store.js +11 -0
  34. data/lib/clacky/web/features/workspace/view.js +10 -4
  35. data/lib/clacky/web/i18n.js +14 -2
  36. data/lib/clacky/web/index.html +29 -13
  37. data/lib/clacky/web/sessions.js +45 -8
  38. data/scripts/uninstall.sh +1 -1
  39. metadata +2 -1
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "uri"
6
+ require "base64"
7
+ require_relative "base"
8
+
9
+ module Clacky
10
+ module Media
11
+ # Volcengine Ark (ByteDance Doubao Seedance) video generation provider.
12
+ #
13
+ # Ark is NOT OpenAI-compatible for video. It uses an asynchronous task
14
+ # model with its own request envelope: submit a task to
15
+ # POST /api/v3/contents/generations/tasks, then poll
16
+ # GET /api/v3/contents/generations/tasks/{id} until it succeeds, fails,
17
+ # or expires. Unlike the blocking Veo path, we surface that async model
18
+ # end to end: #generate_video submits and returns the task id immediately,
19
+ # and #video_status polls a single time (downloading the MP4 once the task
20
+ # has succeeded). This keeps a slow (e.g. 4k) render from blocking the HTTP
21
+ # request past its timeout and dropping the task id.
22
+ #
23
+ # Routing: Generator sends any base_url under *.volces.com here. We
24
+ # derive the API root from the host so users can paste the standard
25
+ # base_url they use for Ark chat models (…/api/v3) and still get working
26
+ # video generation.
27
+ #
28
+ # Seedance content is multimodal: alongside the text prompt, callers may
29
+ # attach a first frame, a last frame, reference images (0-9), reference
30
+ # videos (0-3) and reference audios (0-3). Each media item may be a
31
+ # public http(s) URL, a data URL, a local file path (encoded to a data
32
+ # URL), or a { "b64_json" => ..., "mime_type" => ... } hash.
33
+ class Volcengine < Base
34
+ TASKS_PATH = "/api/v3/contents/generations/tasks"
35
+ PROVIDER_ID = "volcengine"
36
+
37
+ # aspect_ratio -> Ark ratio. Ark also accepts more granular ratios and
38
+ # "adaptive"; when the caller passes one of those verbatim we forward it
39
+ # unchanged (see #resolve_ratio).
40
+ ASPECT_TO_RATIO = {
41
+ "landscape" => "16:9",
42
+ "portrait" => "9:16",
43
+ "square" => "1:1"
44
+ }.freeze
45
+
46
+ # Ratios Ark accepts directly. Anything else falls back to the
47
+ # aspect_ratio mapping / default.
48
+ ARK_RATIOS = %w[16:9 4:3 1:1 3:4 9:16 21:9 adaptive].freeze
49
+
50
+ DEFAULT_RATIO = "16:9"
51
+
52
+ # Explicit cost-control default: when the caller omits resolution we pin
53
+ # 720p rather than relying on Ark's server-side default (which could
54
+ # change and silently raise the user's bill).
55
+ DEFAULT_RESOLUTION = "720p"
56
+
57
+ # @param prompt [String] the video prompt (may be empty when driven
58
+ # purely by reference media, but Ark still recommends text)
59
+ # @param aspect_ratio [String] "landscape"/"portrait"/"square", or an
60
+ # Ark ratio string ("16:9", "9:16", "adaptive", ...)
61
+ # @param duration_seconds [Integer, nil] target duration; -1 lets the
62
+ # model choose (Seedance 2.0 / 1.5 Pro only)
63
+ # @param first_frame [String, Hash, nil] first frame image
64
+ # @param last_frame [String, Hash, nil] last frame image
65
+ # @param reference_images [Array, String, Hash, nil] reference images
66
+ # @param reference_videos [Array, String, Hash, nil] reference videos
67
+ # @param reference_audios [Array, String, Hash, nil] reference audios
68
+ # @param resolution [String, nil] "480p"/"720p"/"1080p"/"4k"
69
+ # @param generate_audio [Boolean, nil] whether to synthesize audio
70
+ # @param watermark [Boolean, nil] whether to add a watermark
71
+ # @param seed [Integer, nil] random seed
72
+ def generate_video(prompt:, aspect_ratio: "landscape", duration_seconds: nil, output_dir: nil,
73
+ image: nil, first_frame: nil, last_frame: nil,
74
+ reference_images: nil, reference_videos: nil, reference_audios: nil,
75
+ resolution: nil, generate_audio: nil, watermark: nil, seed: nil, **_kwargs)
76
+ # `image` is the legacy single first-frame field used across the media
77
+ # stack; treat it as first_frame when no explicit first_frame is given.
78
+ first_frame ||= image
79
+
80
+ content, build_err = build_content(
81
+ prompt: prompt,
82
+ first_frame: first_frame,
83
+ last_frame: last_frame,
84
+ reference_images: reference_images,
85
+ reference_videos: reference_videos,
86
+ reference_audios: reference_audios
87
+ )
88
+ if build_err
89
+ return video_error_response(
90
+ error: build_err, error_type: "invalid_argument",
91
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
92
+ )
93
+ end
94
+
95
+ has_text = prompt.to_s.strip != ""
96
+ has_media = content.length > (has_text ? 1 : 0)
97
+ if !has_text && !has_media
98
+ return video_error_response(
99
+ error: "A text prompt or at least one reference image/video is required",
100
+ error_type: "invalid_argument",
101
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
102
+ )
103
+ end
104
+
105
+ if @api_key.to_s.empty?
106
+ return video_error_response(
107
+ error: "api_key not configured for video model '#{@model}'",
108
+ error_type: "auth_required",
109
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
110
+ )
111
+ end
112
+
113
+ ratio = resolve_ratio(aspect_ratio)
114
+ payload = { model: @model, content: content }
115
+ payload[:ratio] = ratio unless ratio.nil?
116
+ payload[:duration] = duration_seconds.to_i if duration_seconds
117
+ payload[:resolution] = (resolution && !resolution.to_s.empty?) ? resolution.to_s : DEFAULT_RESOLUTION
118
+ payload[:generate_audio] = generate_audio unless generate_audio.nil?
119
+ payload[:watermark] = watermark unless watermark.nil?
120
+ payload[:seed] = seed.to_i if seed
121
+
122
+ begin
123
+ response = connection.post(TASKS_PATH) do |req|
124
+ req.headers["Content-Type"] = "application/json"
125
+ req.headers["Authorization"] = "Bearer #{@api_key}"
126
+ req.body = JSON.generate(payload)
127
+ end
128
+ rescue Faraday::Error => e
129
+ return video_error_response(
130
+ error: "HTTP request failed: #{e.message}",
131
+ error_type: "network_error",
132
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
133
+ )
134
+ end
135
+
136
+ body = parse_json(response.body)
137
+ unless body.is_a?(Hash)
138
+ return video_error_response(
139
+ error: "Invalid JSON response from upstream",
140
+ error_type: "invalid_response",
141
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
142
+ )
143
+ end
144
+
145
+ # Ark reports submission errors via a nested "error" object.
146
+ if body["error"].is_a?(Hash)
147
+ err = body["error"]
148
+ return video_error_response(
149
+ error: "Upstream error #{err["code"]}: #{err["message"]}",
150
+ error_type: "api_error",
151
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
152
+ )
153
+ end
154
+
155
+ unless response.success?
156
+ return video_error_response(
157
+ error: "Upstream #{response.status}: #{truncate(response.body, 500)}",
158
+ error_type: "api_error",
159
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
160
+ )
161
+ end
162
+
163
+ task_id = body["id"]
164
+ if task_id.nil? || task_id.to_s.empty?
165
+ return video_error_response(
166
+ error: "Upstream did not return a task id",
167
+ error_type: "empty_response",
168
+ provider: PROVIDER_ID, prompt: prompt, aspect_ratio: aspect_ratio
169
+ )
170
+ end
171
+
172
+ # Submit only: return the task id immediately. The caller polls
173
+ # GET /api/media/video/status so a long (e.g. 4k) render never blocks
174
+ # the HTTP request past its timeout — which used to drop the task id
175
+ # and push the model into resubmitting, doubling the bill.
176
+ {
177
+ "success" => true,
178
+ "status" => "submitted",
179
+ "task_id" => task_id,
180
+ "provider" => PROVIDER_ID,
181
+ "model" => @model,
182
+ "prompt" => prompt,
183
+ "aspect_ratio" => aspect_ratio,
184
+ "ratio" => ratio,
185
+ "duration_seconds" => (duration_seconds ? duration_seconds.to_i : nil)
186
+ }.compact
187
+ end
188
+
189
+ # Poll a previously submitted task once. On "succeeded" the MP4 is
190
+ # downloaded and a local path is returned; other states map to
191
+ # running / failed without blocking.
192
+ def video_status(task_id:, output_dir: nil)
193
+ if task_id.to_s.strip.empty?
194
+ return video_error_response(
195
+ error: "task_id is required", error_type: "invalid_argument",
196
+ provider: PROVIDER_ID
197
+ )
198
+ end
199
+
200
+ state, detail = fetch_task(task_id)
201
+ case state
202
+ when :running
203
+ { "success" => true, "status" => "running", "task_id" => task_id, "provider" => PROVIDER_ID, "model" => @model }
204
+ when :succeeded
205
+ local_path = save_image_from_url(detail, output_dir: output_dir || Dir.pwd, prefix: "vid", extension: "mp4")
206
+ if local_path.nil?
207
+ return {
208
+ "success" => false, "status" => "failed", "task_id" => task_id, "provider" => PROVIDER_ID,
209
+ "model" => @model, "error" => "Failed to download generated video from #{detail}",
210
+ "error_type" => "download_failed"
211
+ }
212
+ end
213
+ {
214
+ "success" => true, "status" => "succeeded", "video" => local_path,
215
+ "task_id" => task_id, "provider" => PROVIDER_ID, "model" => @model
216
+ }
217
+ else # :failed
218
+ {
219
+ "success" => false, "status" => "failed", "task_id" => task_id, "provider" => PROVIDER_ID,
220
+ "model" => @model, "error" => detail, "error_type" => "task_failed"
221
+ }
222
+ end
223
+ end
224
+
225
+ # Query a task once (no sleeping/looping). Returns one of:
226
+ # [:running, nil]
227
+ # [:succeeded, video_url]
228
+ # [:failed, error_message]
229
+ private def fetch_task(task_id)
230
+ begin
231
+ resp = connection.get("#{TASKS_PATH}/#{task_id}") do |req|
232
+ req.headers["Authorization"] = "Bearer #{@api_key}"
233
+ end
234
+ rescue Faraday::Error => e
235
+ return [:failed, "Polling request failed: #{e.message}"]
236
+ end
237
+
238
+ task = parse_json(resp.body)
239
+ return [:failed, "Invalid polling response JSON"] unless task.is_a?(Hash)
240
+
241
+ case task["status"]
242
+ when "succeeded"
243
+ url = task.dig("content", "video_url")
244
+ return [:failed, "Task succeeded but returned no video_url"] if url.nil? || url.empty?
245
+ [:succeeded, url]
246
+ when "failed"
247
+ msg = task.dig("error", "message") || task["error"] || "Unknown error"
248
+ [:failed, "Task failed: #{msg}"]
249
+ when "cancelled", "canceled"
250
+ [:failed, "Task was cancelled"]
251
+ when "expired"
252
+ [:failed, "Task expired before completion"]
253
+ else # "queued" / "running"
254
+ [:running, nil]
255
+ end
256
+ end
257
+
258
+ # Build the Ark content[] array. Returns [content, nil] on success or
259
+ # [nil, error_message] when a media item can't be resolved.
260
+ private def build_content(prompt:, first_frame:, last_frame:, reference_images:, reference_videos:, reference_audios:)
261
+ has_frame = !normalize_list(first_frame).empty? || !normalize_list(last_frame).empty?
262
+ has_reference = !normalize_list(reference_images).empty? ||
263
+ !normalize_list(reference_videos).empty? ||
264
+ !normalize_list(reference_audios).empty?
265
+ if has_frame && has_reference
266
+ return [nil, "first_frame/last_frame cannot be combined with reference_images/videos/audios. " \
267
+ "Use first_frame (and optionally last_frame) for first/last-frame image-to-video, " \
268
+ "OR use reference_* for multimodal / video editing / video extension — not both."]
269
+ end
270
+
271
+ content = []
272
+ content << { type: "text", text: prompt.to_s } unless prompt.to_s.strip.empty?
273
+
274
+ {
275
+ "first_frame" => normalize_list(first_frame),
276
+ "last_frame" => normalize_list(last_frame),
277
+ "reference_image" => normalize_list(reference_images)
278
+ }.each do |role, items|
279
+ items.each do |item|
280
+ url, err = to_media_url(item, kind: :image)
281
+ return [nil, err] if err
282
+ content << { type: "image_url", image_url: { url: url }, role: role }
283
+ end
284
+ end
285
+
286
+ normalize_list(reference_videos).each do |item|
287
+ url, err = to_media_url(item, kind: :video)
288
+ return [nil, err] if err
289
+ content << { type: "video_url", video_url: { url: url }, role: "reference_video" }
290
+ end
291
+
292
+ normalize_list(reference_audios).each do |item|
293
+ url, err = to_media_url(item, kind: :audio)
294
+ return [nil, err] if err
295
+ content << { type: "audio_url", audio_url: { url: url }, role: "reference_audio" }
296
+ end
297
+
298
+ [content, nil]
299
+ end
300
+
301
+ # Accept a single value or an array; drop nils/blanks.
302
+ private def normalize_list(value)
303
+ list = value.is_a?(Array) ? value : [value]
304
+ list.reject { |v| v.nil? || (v.is_a?(String) && v.strip.empty?) }
305
+ end
306
+
307
+ # Resolve one media item into a URL Ark accepts (http(s) URL or data
308
+ # URL). Accepts: an http(s)/data URL string, a local file path, or a
309
+ # { "b64_json" => ..., "mime_type" => ... } hash. Returns [url, nil] or
310
+ # [nil, error_message].
311
+ private def to_media_url(item, kind:)
312
+ if item.is_a?(Hash)
313
+ b64 = item["b64_json"] || item[:b64_json]
314
+ mime = (item["mime_type"] || item[:mime_type]).to_s
315
+ return [nil, "media hash is missing b64_json"] if b64.to_s.empty?
316
+ mime = default_mime(kind) if mime.empty?
317
+ return ["data:#{mime};base64,#{b64}", nil]
318
+ end
319
+
320
+ s = item.to_s.strip
321
+ return [nil, "empty media reference"] if s.empty?
322
+ return [s, nil] if s.start_with?("http://", "https://", "data:")
323
+
324
+ unless File.file?(s)
325
+ return [nil, "media reference is neither an existing file path, a URL, nor base64 data: #{s}"]
326
+ end
327
+ bytes = File.binread(s)
328
+ mime = mime_for_path(s, kind)
329
+ ["data:#{mime};base64,#{Base64.strict_encode64(bytes)}", nil]
330
+ end
331
+
332
+ private def resolve_ratio(aspect_ratio)
333
+ s = aspect_ratio.to_s
334
+ return s if ARK_RATIOS.include?(s)
335
+ ASPECT_TO_RATIO[s] || DEFAULT_RATIO
336
+ end
337
+
338
+ private def default_mime(kind)
339
+ case kind
340
+ when :video then "video/mp4"
341
+ when :audio then "audio/mpeg"
342
+ else "image/png"
343
+ end
344
+ end
345
+
346
+ private def mime_for_path(path, kind)
347
+ case File.extname(path).downcase
348
+ when ".jpg", ".jpeg" then "image/jpeg"
349
+ when ".webp" then "image/webp"
350
+ when ".gif" then "image/gif"
351
+ when ".mp4" then "video/mp4"
352
+ when ".mov" then "video/quicktime"
353
+ when ".webm" then "video/webm"
354
+ when ".mp3" then "audio/mpeg"
355
+ when ".wav" then "audio/wav"
356
+ when ".m4a" then "audio/mp4"
357
+ else default_mime(kind)
358
+ end
359
+ end
360
+
361
+ private def connection
362
+ Faraday.new(url: endpoint_base) do |f|
363
+ f.options.timeout = 240
364
+ f.options.open_timeout = 10
365
+ end
366
+ end
367
+
368
+ # Derive the API root (scheme + host) from the configured base_url,
369
+ # discarding any path the user pasted (e.g. /api/v3). The task path is
370
+ # appended by the request methods. Falls back to the Beijing host.
371
+ private def endpoint_base
372
+ uri = URI.parse(@base_url.to_s)
373
+ if uri.scheme && uri.host
374
+ "#{uri.scheme}://#{uri.host}"
375
+ else
376
+ "https://ark.cn-beijing.volces.com"
377
+ end
378
+ rescue URI::InvalidURIError
379
+ "https://ark.cn-beijing.volces.com"
380
+ end
381
+
382
+ private def parse_json(body)
383
+ JSON.parse(body)
384
+ rescue JSON::ParserError
385
+ nil
386
+ end
387
+
388
+ private def truncate(str, max)
389
+ s = str.to_s
390
+ s.length > max ? "#{s[0, max]}..." : s
391
+ end
392
+ end
393
+ end
394
+ end
@@ -24,17 +24,19 @@ module Clacky
24
24
  # # result => { success: true, data: {...} }
25
25
  # # or { success: false, error: "...", data: {} }
26
26
  class PlatformHttpClient
27
- # Primary CDN-accelerated endpoint
28
- PRIMARY_HOST = "https://www.openclacky.com"
27
+ # Primary endpoint
28
+ PRIMARY_HOST = "https://www.openclacky.com"
29
+ # Secondary CDN-accelerated endpoint (China mainland)
30
+ SECONDARY_HOST = "https://api.1024code.com"
29
31
  # Direct fallback — bypasses EdgeOne, used when the primary times out
30
- FALLBACK_HOST = "https://openclacky.up.railway.app"
32
+ FALLBACK_HOST = "https://openclacky.up.railway.app"
31
33
 
32
34
  # Number of attempts per domain (1 = no retry within the same domain)
33
- ATTEMPTS_PER_HOST = 2
35
+ ATTEMPTS_PER_HOST = 1
34
36
  # Initial back-off between retries within the same domain (seconds)
35
37
  INITIAL_BACKOFF = 0.5
36
38
  # Connection / read timeouts (seconds) for API calls
37
- OPEN_TIMEOUT = 8
39
+ OPEN_TIMEOUT = 5
38
40
  READ_TIMEOUT = 15
39
41
  # Read timeout for streaming large file downloads (seconds)
40
42
  DOWNLOAD_READ_TIMEOUT = 120
@@ -53,12 +55,12 @@ module Clacky
53
55
 
54
56
  # Auto-detects the target host(s):
55
57
  # - When CLACKY_LICENSE_SERVER is set → single host (dev override, no failover)
56
- # - Otherwise → [PRIMARY_HOST, FALLBACK_HOST]
58
+ # - Otherwise → [PRIMARY_HOST, SECONDARY_HOST, FALLBACK_HOST]
57
59
  def initialize
58
60
  if (override = ENV["CLACKY_LICENSE_SERVER"]) && !override.empty?
59
61
  @hosts = [override]
60
62
  else
61
- @hosts = [PRIMARY_HOST, FALLBACK_HOST]
63
+ @hosts = [PRIMARY_HOST, SECONDARY_HOST, FALLBACK_HOST]
62
64
  end
63
65
  end
64
66
 
@@ -260,8 +260,8 @@ module Clacky
260
260
  "name" => "Kimi (Moonshot)",
261
261
  "base_url" => "https://api.moonshot.cn/v1",
262
262
  "api" => "openai-completions",
263
- "default_model" => "kimi-k2.6",
264
- "models" => ["kimi-k2.6", "kimi-k2.5"],
263
+ "default_model" => "kimi-k3",
264
+ "models" => ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"],
265
265
  # Moonshot operates two regional endpoints with identical APIs & model
266
266
  # lineup — mainland China (.cn) and international (.ai). These are the
267
267
  # pay-as-you-go Open Platform endpoints; the subscription-billed
@@ -277,9 +277,9 @@ module Clacky
277
277
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
278
278
  { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
279
279
  ].freeze,
280
- # k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
280
+ # k3 / k2.7-code / k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
281
281
  "capabilities" => { "vision" => true }.freeze,
282
- "default_ocr_model" => "kimi-k2.5",
282
+ "default_ocr_model" => "kimi-k3",
283
283
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
284
284
  }.freeze,
285
285
 
@@ -36,9 +36,10 @@ module Clacky
36
36
  @events = events
37
37
  end
38
38
 
39
- def show_user_message(content, created_at: nil, files: [])
39
+ def show_user_message(content, created_at: nil, files: [], editable: true)
40
40
  ev = { type: "history_user_message", session_id: @session_id, content: content }
41
41
  ev[:created_at] = created_at if created_at
42
+ ev[:editable] = false unless editable
42
43
  rendered = Array(files).filter_map do |f|
43
44
  url = f[:data_url] || f["data_url"]
44
45
  name = f[:name] || f["name"]
@@ -53,7 +54,7 @@ module Clacky
53
54
  # encode + downscale on every history replay (2-3s lag for sessions
54
55
  # with downgraded text-model images). The proxy lets the browser
55
56
  # lazy-load + cache the image, keeping the replay response tiny.
56
- "/api/local-image?path=#{CGI.escape(path.to_s)}"
57
+ "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
57
58
  elsif name
58
59
  type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
59
60
  end
@@ -463,6 +464,9 @@ module Clacky
463
464
  # with modalities:["image"]); end-to-end latency is commonly
464
465
  # 20-60s and can exceed 2 minutes for or-gpt-image-2 under load.
465
466
  300
467
+ elsif path == "/api/media/video/status"
468
+ # Single upstream task lookup; returns in well under a second.
469
+ 30
466
470
  elsif path == "/api/media/video"
467
471
  # Video generation (Veo via the gateway) runs an async submit+poll
468
472
  # cycle that routinely takes 1-3 minutes and can approach the
@@ -572,6 +576,7 @@ module Clacky
572
576
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
573
577
  when ["POST", "/api/media/image"] then api_media_image(req, res)
574
578
  when ["POST", "/api/media/video"] then api_media_video(req, res)
579
+ when ["GET", "/api/media/video/status"] then api_media_video_status(req, res)
575
580
  when ["POST", "/api/media/audio/speech"] then api_media_audio_speech(req, res)
576
581
  when ["POST", "/api/media/audio/transcriptions"] then api_media_audio_transcriptions(req, res)
577
582
  when ["POST", "/api/media/video/understand"] then api_media_video_understand(req, res)
@@ -960,11 +965,13 @@ module Clacky
960
965
  # Phase "key_setup" → no API key configured yet
961
966
  # Phase "soul_setup" → key configured, but ~/.clacky/agents/SOUL.md missing
962
967
  # needs_onboard: false → fully set up
968
+ # branded: true → running under a brand; hide the OpenClacky AI Keys block
963
969
  def api_onboard_status(res)
970
+ branded = Clacky::BrandConfig.load.branded?
964
971
  if !@agent_config.models_configured?
965
- json_response(res, 200, { needs_onboard: true, phase: "key_setup" })
972
+ json_response(res, 200, { needs_onboard: true, phase: "key_setup", branded: branded })
966
973
  else
967
- json_response(res, 200, { needs_onboard: false })
974
+ json_response(res, 200, { needs_onboard: false, branded: branded })
968
975
  end
969
976
  end
970
977
 
@@ -1539,7 +1546,16 @@ module Clacky
1539
1546
  aspect_ratio: aspect_ratio,
1540
1547
  duration_seconds: duration,
1541
1548
  output_dir: output_dir,
1542
- image: image
1549
+ image: image,
1550
+ first_frame: body["first_frame"],
1551
+ last_frame: body["last_frame"],
1552
+ reference_images: body["reference_images"],
1553
+ reference_videos: body["reference_videos"],
1554
+ reference_audios: body["reference_audios"],
1555
+ resolution: body["resolution"],
1556
+ generate_audio: body["generate_audio"],
1557
+ watermark: body["watermark"],
1558
+ seed: body["seed"]
1543
1559
  )
1544
1560
  if result["success"]
1545
1561
  log_media_usage(result, prompt: prompt, session_id: session_id)
@@ -1550,6 +1566,25 @@ module Clacky
1550
1566
  json_response(res, 500, { error: e.message })
1551
1567
  end
1552
1568
 
1569
+ def api_media_video_status(req, res)
1570
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1571
+ task_id = query["task_id"].to_s
1572
+ if task_id.strip.empty?
1573
+ return json_response(res, 422, { error: "task_id is required" })
1574
+ end
1575
+
1576
+ output_dir = query["output_dir"].to_s
1577
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
1578
+
1579
+ result = Clacky::Media::Generator.new(@agent_config).video_status(
1580
+ task_id: task_id,
1581
+ output_dir: output_dir
1582
+ )
1583
+ json_response(res, 200, result)
1584
+ rescue StandardError => e
1585
+ json_response(res, 500, { error: e.message })
1586
+ end
1587
+
1553
1588
  def api_media_audio_speech(req, res)
1554
1589
  body = parse_json_body(req)
1555
1590
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
@@ -2376,6 +2411,8 @@ module Clacky
2376
2411
  {
2377
2412
  "id" => ext_id,
2378
2413
  "name" => market ? (market["name"] || ext_id) : ext_id,
2414
+ "display_name" => market&.dig("display_name"),
2415
+ "display_name_zh" => market&.dig("display_name_zh"),
2379
2416
  "name_zh" => market&.dig("name_zh"),
2380
2417
  "name_en" => market&.dig("name_en"),
2381
2418
  "slug" => ext_id,
@@ -3659,13 +3696,11 @@ module Clacky
3659
3696
 
3660
3697
  return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
3661
3698
 
3662
- # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
3663
- # Ruby's File.exist? does NOT automatically expand ~ that's a shell feature.
3664
- path = File.expand_path(path)
3665
-
3666
- # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
3667
- # Convert it to the Linux-side path so File.exist? works.
3699
+ # Path arrives already percent-decoded by the click handler; normalize
3700
+ # Windows drive letters (WSL) BEFORE expand_path or the drive is treated
3701
+ # as relative and corrupted. No-op on macOS/Linux.
3668
3702
  linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
3703
+ linux_path = File.expand_path(linux_path)
3669
3704
 
3670
3705
  return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
3671
3706
 
@@ -3679,8 +3714,11 @@ module Clacky
3679
3714
  json_response(res, 200, { ok: true })
3680
3715
  when "download"
3681
3716
  serve_file_download(res, linux_path)
3717
+ when "display-path"
3718
+ display = Utils::EnvironmentDetector.linux_to_win_path(linux_path)
3719
+ json_response(res, 200, { ok: true, path: display })
3682
3720
  else
3683
- json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal' or 'download'" })
3721
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download' or 'display-path'" })
3684
3722
  end
3685
3723
  rescue => e
3686
3724
  json_response(res, 500, { ok: false, error: e.message })
@@ -3710,14 +3748,9 @@ module Clacky
3710
3748
  raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
3711
3749
  return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
3712
3750
 
3713
- # Strip file:// prefix if present
3714
- path = raw_path.sub(%r{\Afile://}, "")
3715
- path = CGI.unescape(path)
3716
- path = File.expand_path(path)
3717
-
3718
- # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
3719
- # Convert it to the Linux-side path so File.exist? works.
3720
- path = Utils::EnvironmentDetector.win_to_linux_path(path)
3751
+ # Strip file://, decode, WSL drive-letter normalize, then expand — in
3752
+ # this order (normalize must precede expand_path). No-op on macOS/Linux.
3753
+ path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
3721
3754
 
3722
3755
  # Security: only serve media files (images + videos)
3723
3756
  ext = File.extname(path).downcase
@@ -3730,6 +3763,20 @@ module Clacky
3730
3763
  file_size = File.size(path)
3731
3764
  mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
3732
3765
 
3766
+ # ETag from mtime+size so an overwritten same-name file invalidates the
3767
+ # browser cache. Use no-cache (revalidate every time) rather than
3768
+ # max-age: unchanged files return 304 (no body), changed files return
3769
+ # the new bytes.
3770
+ stat = File.stat(path)
3771
+ etag = %(W/"#{stat.mtime.to_i}-#{stat.size}")
3772
+ res["Cache-Control"] = "private, no-cache"
3773
+ res["ETag"] = etag
3774
+ if req["If-None-Match"] == etag
3775
+ res.status = 304
3776
+ res.body = ""
3777
+ return
3778
+ end
3779
+
3733
3780
  # Support HTTP Range requests for video seeking
3734
3781
  range_header = req["Range"]
3735
3782
  if range_header && range_header =~ /\Abytes=(\d*)-(\d*)\z/
@@ -3741,14 +3788,12 @@ module Clacky
3741
3788
  res["Content-Type"] = mime
3742
3789
  res["Content-Range"] = "bytes #{start_byte}-#{end_byte}/#{file_size}"
3743
3790
  res["Accept-Ranges"] = "bytes"
3744
- res["Cache-Control"] = "private, max-age=3600"
3745
3791
  res["Content-Length"] = (end_byte - start_byte + 1).to_s
3746
3792
  IO.binread(path, end_byte - start_byte + 1, start_byte).then { |data| res.body = data }
3747
3793
  else
3748
3794
  res.status = 200
3749
3795
  res["Content-Type"] = mime
3750
3796
  res["Accept-Ranges"] = "bytes"
3751
- res["Cache-Control"] = "private, max-age=3600"
3752
3797
  res.body = File.binread(path)
3753
3798
  end
3754
3799
  rescue => e
@@ -121,12 +121,17 @@ module Clacky
121
121
  files = if File.file?(expanded_path)
122
122
  [expanded_path]
123
123
  else
124
+ # Auto-expand bare extension patterns (e.g. "*.ts") to recursive
125
+ # (e.g. "**/*.ts"), matching ripgrep's default behaviour.
126
+ # Patterns already containing "/" are left as-is — the user is
127
+ # intentionally scoping to a specific directory.
128
+ effective_pattern = file_pattern.include?("/") ? file_pattern : "**/#{file_pattern}"
124
129
  fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
125
130
  collected = []
126
131
  walk_status = {}
127
132
  Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |f|
128
133
  relative = f[(expanded_path.length + 1)..]
129
- collected << f if File.fnmatch(file_pattern, relative, fnmatch_flags)
134
+ collected << f if File.fnmatch(effective_pattern, relative, fnmatch_flags)
130
135
  end
131
136
  if walk_status[:truncated]
132
137
  truncation_reason ||= "walk #{walk_status[:truncation_reason]}"