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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/lib/clacky/agent/session_serializer.rb +11 -4
- data/lib/clacky/agent/skill_manager.rb +1 -0
- data/lib/clacky/agent.rb +4 -5
- data/lib/clacky/brand_config.rb +53 -3
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +202 -3
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +610 -60
- data/lib/clacky/default_skills/media-gen/SKILL.md +182 -7
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +5 -3
- data/lib/clacky/extension/packager.rb +21 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/generator.rb +41 -0
- data/lib/clacky/media/volcengine.rb +394 -0
- data/lib/clacky/platform_http_client.rb +9 -7
- data/lib/clacky/providers.rb +4 -4
- data/lib/clacky/server/http_server.rb +67 -22
- data/lib/clacky/tools/grep.rb +6 -1
- data/lib/clacky/tools/terminal.rb +24 -8
- data/lib/clacky/ui2/layout_manager.rb +4 -0
- data/lib/clacky/ui2/screen_buffer.rb +31 -3
- data/lib/clacky/utils/environment_detector.rb +16 -9
- data/lib/clacky/utils/file_processor.rb +30 -28
- data/lib/clacky/utils/model_pricing.rb +49 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +229 -31
- data/lib/clacky/web/components/onboard.js +14 -4
- data/lib/clacky/web/features/backup/view.js +4 -3
- data/lib/clacky/web/features/extensions/store.js +2 -2
- data/lib/clacky/web/features/extensions/view.js +37 -7
- data/lib/clacky/web/features/mcp/view.js +5 -2
- data/lib/clacky/web/features/skills/view.js +61 -1
- data/lib/clacky/web/features/workspace/store.js +11 -0
- data/lib/clacky/web/features/workspace/view.js +10 -4
- data/lib/clacky/web/i18n.js +14 -2
- data/lib/clacky/web/index.html +29 -13
- data/lib/clacky/web/sessions.js +45 -8
- data/scripts/uninstall.sh +1 -1
- 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
|
|
28
|
-
PRIMARY_HOST
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
#
|
|
3663
|
-
#
|
|
3664
|
-
|
|
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 '
|
|
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
|
|
3714
|
-
|
|
3715
|
-
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
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -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(
|
|
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]}"
|