openclacky 1.0.1 → 1.0.2
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 +18 -0
- data/lib/clacky/agent/llm_caller.rb +185 -0
- data/lib/clacky/agent.rb +53 -2
- data/lib/clacky/default_skills/onboard/SKILL.md +14 -5
- data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
- data/lib/clacky/providers.rb +57 -3
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
- data/lib/clacky/server/channel/channel_manager.rb +12 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
- data/lib/clacky/server/http_server.rb +10 -6
- data/lib/clacky/utils/file_processor.rb +14 -40
- data/lib/clacky/utils/model_pricing.rb +95 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +99 -9
- data/lib/clacky/web/i18n.js +14 -0
- data/lib/clacky/web/index.html +8 -2
- data/lib/clacky/web/onboard.js +77 -1
- data/lib/clacky/web/sessions.js +2 -2
- data/lib/clacky/web/settings.js +127 -6
- data/lib/clacky/web/skills.js +4 -0
- data/lib/clacky.rb +5 -0
- metadata +3 -2
|
@@ -19,7 +19,7 @@ require 'find'
|
|
|
19
19
|
class ZipSkillInstaller
|
|
20
20
|
ZIP_URL_PATTERN = %r{^https?://.+\.zip(\?.*)?$}i
|
|
21
21
|
|
|
22
|
-
def initialize(zip_source, skill_name: nil, target_dir: nil)
|
|
22
|
+
def initialize(zip_source, skill_name: nil, target_dir: nil, skip_if_exists: false)
|
|
23
23
|
@zip_source = zip_source
|
|
24
24
|
@local_path = local_zip_path?(zip_source)
|
|
25
25
|
# skill_name can be provided explicitly (e.g. slug from the store API).
|
|
@@ -27,45 +27,72 @@ class ZipSkillInstaller
|
|
|
27
27
|
# "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max".
|
|
28
28
|
@skill_name = skill_name || infer_skill_name(zip_source)
|
|
29
29
|
@target_dir = target_dir || File.join(Dir.home, '.clacky', 'skills')
|
|
30
|
+
# When true, existing skill directories are preserved and the install for
|
|
31
|
+
# that specific skill is skipped (recorded in @skipped_skills).
|
|
32
|
+
# Default false keeps the legacy "overwrite" behaviour for `install`.
|
|
33
|
+
@skip_if_exists = skip_if_exists
|
|
34
|
+
# Suppresses user-facing puts for programmatic callers (set by `perform`).
|
|
35
|
+
@silent = false
|
|
30
36
|
@installed_skills = []
|
|
31
|
-
@
|
|
37
|
+
@skipped_skills = []
|
|
38
|
+
@errors = []
|
|
32
39
|
end
|
|
33
40
|
|
|
34
|
-
#
|
|
41
|
+
# Programmatic entry point for library-style callers (e.g. onboard pre-install).
|
|
42
|
+
#
|
|
43
|
+
# Unlike `install`, this method:
|
|
44
|
+
# - does NOT print user-facing output
|
|
45
|
+
# - does NOT call `exit` on failure (raises instead)
|
|
46
|
+
# - returns a result hash: { installed: [...], skipped: [...], errors: [...] }
|
|
47
|
+
#
|
|
48
|
+
# The caller is responsible for rendering feedback and deciding whether any
|
|
49
|
+
# error is fatal.
|
|
50
|
+
def perform
|
|
51
|
+
@silent = true
|
|
52
|
+
do_install
|
|
53
|
+
{ installed: @installed_skills, skipped: @skipped_skills, errors: @errors }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Main installation entry point (CLI). Prints progress, prints a final
|
|
57
|
+
# report, and calls `exit` on failure. Use `perform` for programmatic use.
|
|
35
58
|
def install
|
|
59
|
+
do_install
|
|
60
|
+
report_results
|
|
61
|
+
rescue ArgumentError => e
|
|
62
|
+
puts "Error: #{e.message}"
|
|
63
|
+
exit 1
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
puts "Error: Installation failed: #{e.message}"
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Shared core used by both `install` (CLI) and `perform` (library).
|
|
70
|
+
# Raises on invalid input; the caller decides how to surface errors.
|
|
71
|
+
private def do_install
|
|
36
72
|
if @local_path
|
|
37
73
|
# Install directly from a local zip file — no download needed.
|
|
38
|
-
# Expand tilde in path (e.g. ~/Downloads/skill.zip)
|
|
74
|
+
# Expand tilde in path (e.g. ~/Downloads/skill.zip).
|
|
39
75
|
expanded = File.expand_path(@zip_source)
|
|
40
|
-
raise ArgumentError, "File not found: #{@zip_source}"
|
|
41
|
-
raise ArgumentError, "Not a zip file: #{@zip_source}"
|
|
76
|
+
raise ArgumentError, "File not found: #{@zip_source}" unless File.exist?(expanded)
|
|
77
|
+
raise ArgumentError, "Not a zip file: #{@zip_source}" unless expanded.end_with?('.zip')
|
|
42
78
|
|
|
43
79
|
Dir.mktmpdir('clacky-zip-') do |tmpdir|
|
|
44
80
|
extract_zip(expanded, tmpdir)
|
|
45
|
-
|
|
46
|
-
discover_and_install_skills(extracted_dir)
|
|
81
|
+
discover_and_install_skills(File.join(tmpdir, 'extracted'))
|
|
47
82
|
end
|
|
48
83
|
else
|
|
49
84
|
# Install from a remote URL.
|
|
50
85
|
unless valid_zip_url?
|
|
51
|
-
raise ArgumentError, "Invalid zip source: #{@zip_source}\
|
|
86
|
+
raise ArgumentError, "Invalid zip source: #{@zip_source}\n" \
|
|
87
|
+
"Provide an http(s) URL ending with .zip, or an absolute path to a local zip file."
|
|
52
88
|
end
|
|
53
89
|
|
|
54
90
|
Dir.mktmpdir('clacky-zip-') do |tmpdir|
|
|
55
91
|
zip_path = download_zip(tmpdir)
|
|
56
92
|
extract_zip(zip_path, tmpdir)
|
|
57
|
-
|
|
58
|
-
discover_and_install_skills(extracted_dir)
|
|
93
|
+
discover_and_install_skills(File.join(tmpdir, 'extracted'))
|
|
59
94
|
end
|
|
60
95
|
end
|
|
61
|
-
|
|
62
|
-
report_results
|
|
63
|
-
rescue ArgumentError => e
|
|
64
|
-
puts "❌ #{e.message}"
|
|
65
|
-
exit 1
|
|
66
|
-
rescue StandardError => e
|
|
67
|
-
puts "❌ Installation failed: #{e.message}"
|
|
68
|
-
exit 1
|
|
69
96
|
end
|
|
70
97
|
|
|
71
98
|
# Return true if the source looks like a local file path (absolute or relative ending in .zip).
|
|
@@ -93,8 +120,10 @@ class ZipSkillInstaller
|
|
|
93
120
|
|
|
94
121
|
# Download the zip file to tmpdir and return its local path.
|
|
95
122
|
private def download_zip(tmpdir)
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
unless @silent
|
|
124
|
+
puts "Downloading skill package..."
|
|
125
|
+
puts " #{@zip_source}"
|
|
126
|
+
end
|
|
98
127
|
|
|
99
128
|
zip_path = File.join(tmpdir, 'skill.zip')
|
|
100
129
|
uri = URI.parse(@zip_source)
|
|
@@ -129,7 +158,7 @@ class ZipSkillInstaller
|
|
|
129
158
|
|
|
130
159
|
# Extract the zip archive into <tmpdir>/extracted/.
|
|
131
160
|
private def extract_zip(zip_path, tmpdir)
|
|
132
|
-
puts "
|
|
161
|
+
puts "Extracting package..." unless @silent
|
|
133
162
|
extracted_dir = File.join(tmpdir, 'extracted')
|
|
134
163
|
FileUtils.mkdir_p(extracted_dir)
|
|
135
164
|
|
|
@@ -184,7 +213,11 @@ class ZipSkillInstaller
|
|
|
184
213
|
target_path = File.join(@target_dir, name)
|
|
185
214
|
|
|
186
215
|
if File.exist?(target_path)
|
|
187
|
-
|
|
216
|
+
if @skip_if_exists
|
|
217
|
+
@skipped_skills << { name: name, path: target_path, reason: 'already exists' }
|
|
218
|
+
return
|
|
219
|
+
end
|
|
220
|
+
puts "Skill '#{name}' already exists — overwriting..." unless @silent
|
|
188
221
|
FileUtils.rm_rf(target_path)
|
|
189
222
|
end
|
|
190
223
|
|
|
@@ -218,7 +251,7 @@ class ZipSkillInstaller
|
|
|
218
251
|
puts "\n" + "=" * 60
|
|
219
252
|
|
|
220
253
|
if @installed_skills.empty?
|
|
221
|
-
puts "
|
|
254
|
+
puts "No skills were installed."
|
|
222
255
|
if @errors.any?
|
|
223
256
|
puts "\nErrors:"
|
|
224
257
|
@errors.each { |e| puts " • #{e}" }
|
|
@@ -226,7 +259,7 @@ class ZipSkillInstaller
|
|
|
226
259
|
exit 1
|
|
227
260
|
end
|
|
228
261
|
|
|
229
|
-
puts "
|
|
262
|
+
puts "Installation complete!"
|
|
230
263
|
puts "\nInstalled #{@installed_skills.size} skill(s):\n\n"
|
|
231
264
|
@installed_skills.each do |skill|
|
|
232
265
|
puts " ✓ #{skill[:name]}"
|
|
@@ -236,7 +269,7 @@ class ZipSkillInstaller
|
|
|
236
269
|
end
|
|
237
270
|
|
|
238
271
|
if @errors.any?
|
|
239
|
-
puts "
|
|
272
|
+
puts "Warnings:"
|
|
240
273
|
@errors.each { |e| puts " • #{e}" }
|
|
241
274
|
puts
|
|
242
275
|
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -125,6 +125,15 @@ module Clacky
|
|
|
125
125
|
"api" => "openai-completions",
|
|
126
126
|
"default_model" => "MiniMax-M2.7",
|
|
127
127
|
"models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
|
|
128
|
+
# MiniMax operates two regional endpoints with identical APIs & model
|
|
129
|
+
# lineup — mainland China (.com) and international (.io). Listing both
|
|
130
|
+
# lets find_by_base_url identify either one as provider "minimax",
|
|
131
|
+
# so capability checks (vision=false) fire correctly regardless of
|
|
132
|
+
# which endpoint the user configured.
|
|
133
|
+
"endpoint_variants" => [
|
|
134
|
+
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.minimaxi.com/v1", "region" => "cn" }.freeze,
|
|
135
|
+
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.minimax.io/v1", "region" => "intl" }.freeze
|
|
136
|
+
].freeze,
|
|
128
137
|
# MiniMax M2.x does not support multimodal/vision input on this endpoint.
|
|
129
138
|
"capabilities" => { "vision" => false }.freeze,
|
|
130
139
|
"website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
|
|
@@ -136,6 +145,17 @@ module Clacky
|
|
|
136
145
|
"api" => "openai-completions",
|
|
137
146
|
"default_model" => "kimi-k2.6",
|
|
138
147
|
"models" => ["kimi-k2.6", "kimi-k2.5"],
|
|
148
|
+
# Moonshot operates two regional endpoints with identical APIs & model
|
|
149
|
+
# lineup — mainland China (.cn) and international (.ai). Kimi does not
|
|
150
|
+
# distinguish pay-as-you-go vs coding-plan at the base_url level, so
|
|
151
|
+
# only two variants are needed. Listing both here lets find_by_base_url
|
|
152
|
+
# identify either one as provider "kimi", so downstream capability
|
|
153
|
+
# checks, fallback chains, and provider-specific behaviours work
|
|
154
|
+
# regardless of which endpoint the user configured.
|
|
155
|
+
"endpoint_variants" => [
|
|
156
|
+
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
|
|
157
|
+
{ "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
|
|
158
|
+
].freeze,
|
|
139
159
|
# k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
|
|
140
160
|
"capabilities" => { "vision" => true }.freeze,
|
|
141
161
|
"website_url" => "https://platform.moonshot.cn/console/api-keys"
|
|
@@ -192,11 +212,26 @@ module Clacky
|
|
|
192
212
|
}.freeze,
|
|
193
213
|
|
|
194
214
|
"glm" => {
|
|
195
|
-
"name" => "GLM (
|
|
215
|
+
"name" => "GLM (Z.ai / Zhipu)",
|
|
196
216
|
"base_url" => "https://open.bigmodel.cn/api/paas/v4",
|
|
197
217
|
"api" => "openai-completions",
|
|
198
218
|
"default_model" => "glm-5.1",
|
|
199
219
|
"models" => ["glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
220
|
+
# Zhipu / Z.ai expose four functionally-equivalent endpoints:
|
|
221
|
+
# two regional sites (mainland open.bigmodel.cn + international api.z.ai)
|
|
222
|
+
# each with a general-billing and a Coding-Plan subpath. They share the
|
|
223
|
+
# same model lineup & identical capability profile, so a single preset
|
|
224
|
+
# with endpoint_variants is the right shape — one source of truth for
|
|
225
|
+
# vision/model_capabilities, four URLs recognised by find_by_base_url.
|
|
226
|
+
# Without this, users pointing at api.z.ai or the /coding/ path fell
|
|
227
|
+
# through to the conservative "assume vision=true" default and got
|
|
228
|
+
# hallucinated image descriptions on text-only GLM models (C-5563).
|
|
229
|
+
"endpoint_variants" => [
|
|
230
|
+
{ "label" => "Mainland · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.mainland_cn_payg", "base_url" => "https://open.bigmodel.cn/api/paas/v4", "region" => "cn" }.freeze,
|
|
231
|
+
{ "label" => "Mainland · Coding Plan", "label_key" => "settings.models.baseurl.variant.mainland_cn_coding", "base_url" => "https://open.bigmodel.cn/api/coding/paas/v4", "region" => "cn" }.freeze,
|
|
232
|
+
{ "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
|
|
233
|
+
{ "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
|
|
234
|
+
].freeze,
|
|
200
235
|
# GLM models are text-only except glm-5v-turbo which is vision-capable ("v" = visual).
|
|
201
236
|
"capabilities" => { "vision" => false }.freeze,
|
|
202
237
|
"model_capabilities" => {
|
|
@@ -376,14 +411,33 @@ module Clacky
|
|
|
376
411
|
# Find provider ID by base URL.
|
|
377
412
|
# Matches if the given URL starts with the provider's base_url (after normalisation),
|
|
378
413
|
# so both exact matches and sub-path variants (e.g. "/v1") are recognised.
|
|
414
|
+
#
|
|
415
|
+
# Also scans `endpoint_variants` (when present) so providers that operate
|
|
416
|
+
# multiple regional / billing-plan endpoints under the same identity
|
|
417
|
+
# (e.g. GLM on open.bigmodel.cn + api.z.ai, MiniMax on .com + .io) are
|
|
418
|
+
# all recognised as that single provider — one capability definition,
|
|
419
|
+
# N entry URLs. Without this, users configured with a non-default
|
|
420
|
+
# variant fall back to the "unknown provider" path and miss capability
|
|
421
|
+
# enforcement (see C-5563).
|
|
379
422
|
# @param base_url [String] The base URL to look up
|
|
380
423
|
# @return [String, nil] The provider ID or nil if not found
|
|
381
424
|
def find_by_base_url(base_url)
|
|
382
425
|
return nil if base_url.nil? || base_url.empty?
|
|
383
426
|
normalized = base_url.to_s.chomp("/")
|
|
384
427
|
PRESETS.find do |_id, preset|
|
|
385
|
-
|
|
386
|
-
|
|
428
|
+
# Collect every URL this preset claims: the canonical base_url plus
|
|
429
|
+
# any declared endpoint_variants. Dedup so the canonical one showing
|
|
430
|
+
# up in both lists doesn't change behaviour.
|
|
431
|
+
candidates = [preset["base_url"]]
|
|
432
|
+
variants = preset["endpoint_variants"]
|
|
433
|
+
if variants.is_a?(Array)
|
|
434
|
+
variants.each { |v| candidates << v["base_url"] if v.is_a?(Hash) }
|
|
435
|
+
end
|
|
436
|
+
candidates.compact.uniq.any? do |candidate|
|
|
437
|
+
preset_base = candidate.to_s.chomp("/")
|
|
438
|
+
next false if preset_base.empty?
|
|
439
|
+
normalized == preset_base || normalized.start_with?("#{preset_base}/")
|
|
440
|
+
end
|
|
387
441
|
end&.first
|
|
388
442
|
end
|
|
389
443
|
|
|
@@ -166,6 +166,20 @@ module Clacky
|
|
|
166
166
|
# @param event [Hash] Parsed message event
|
|
167
167
|
# @return [void]
|
|
168
168
|
def handle_message_event(event)
|
|
169
|
+
# In group chats, only respond when the bot is explicitly @-mentioned.
|
|
170
|
+
# Private chats always respond.
|
|
171
|
+
# Fail closed: if the bot's own open_id cannot be fetched (API error,
|
|
172
|
+
# bad credentials, etc.), drop group messages instead of responding to
|
|
173
|
+
# every message and spamming the group.
|
|
174
|
+
if event[:chat_type] == :group
|
|
175
|
+
bot_id = @bot.bot_open_id
|
|
176
|
+
if bot_id.nil?
|
|
177
|
+
Clacky::Logger.warn("[feishu] bot_open_id unavailable; dropping group message to avoid spam")
|
|
178
|
+
return
|
|
179
|
+
end
|
|
180
|
+
return unless Array(event[:mentioned_open_ids]).include?(bot_id)
|
|
181
|
+
end
|
|
182
|
+
|
|
169
183
|
allowed_users = @config[:allowed_users]
|
|
170
184
|
if allowed_users && !allowed_users.empty?
|
|
171
185
|
return unless allowed_users.include?(event[:user_id])
|
|
@@ -226,6 +226,16 @@ module Clacky
|
|
|
226
226
|
}.join
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
# Get this bot's own open_id (cached, fetched lazily on first use).
|
|
230
|
+
# Used to detect @bot mentions in group chats.
|
|
231
|
+
# @return [String, nil] bot open_id, or nil if the API call fails
|
|
232
|
+
def bot_open_id
|
|
233
|
+
@bot_open_id ||= get("/open-apis/bot/v3/info").dig("bot", "open_id")
|
|
234
|
+
rescue => e
|
|
235
|
+
Clacky::Logger.warn("[feishu] Failed to fetch bot_open_id: #{e.message}")
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
229
239
|
# Get tenant access token (cached)
|
|
230
240
|
# @return [String] Access token
|
|
231
241
|
def tenant_access_token
|
|
@@ -27,8 +27,14 @@ module Clacky
|
|
|
27
27
|
# @param run_agent_task [Proc] (session_id, agent, &task) — from HttpServer
|
|
28
28
|
# @param interrupt_session [Proc] (session_id) — from HttpServer
|
|
29
29
|
# @param channel_config [Clacky::ChannelConfig]
|
|
30
|
-
# @param binding_mode [:user | :chat] how to map IM identities to sessions
|
|
31
|
-
|
|
30
|
+
# @param binding_mode [:user | :chat | :chat_user] how to map IM identities to sessions.
|
|
31
|
+
# :chat_user (default) — one session per (chat, user) pair. Most natural:
|
|
32
|
+
# private chat = that user's session; in a group each
|
|
33
|
+
# user has their own session; the same user across
|
|
34
|
+
# different groups keeps those contexts separate.
|
|
35
|
+
# :chat — one session per chat (all users in a group share it).
|
|
36
|
+
# :user — one session per user (merges DMs and all groups).
|
|
37
|
+
def initialize(session_registry:, session_builder:, run_agent_task:, interrupt_session:, channel_config:, binding_mode: :chat_user)
|
|
32
38
|
@registry = session_registry
|
|
33
39
|
@session_builder = session_builder
|
|
34
40
|
@run_agent_task = run_agent_task
|
|
@@ -354,8 +360,10 @@ module Clacky
|
|
|
354
360
|
def channel_key(event)
|
|
355
361
|
platform = event[:platform].to_s
|
|
356
362
|
case @binding_mode
|
|
357
|
-
when :chat
|
|
358
|
-
|
|
363
|
+
when :chat then "#{platform}:chat:#{event[:chat_id]}"
|
|
364
|
+
when :user then "#{platform}:user:#{event[:user_id]}"
|
|
365
|
+
else # :chat_user (default)
|
|
366
|
+
"#{platform}:chat:#{event[:chat_id]}:user:#{event[:user_id]}"
|
|
359
367
|
end
|
|
360
368
|
end
|
|
361
369
|
|
|
@@ -33,9 +33,15 @@ module Clacky
|
|
|
33
33
|
|
|
34
34
|
# Update the reply context for the current inbound message.
|
|
35
35
|
# Called at the start of each route_message so replies are threaded correctly.
|
|
36
|
-
#
|
|
36
|
+
# Also updates chat_id — a session may span multiple chats (e.g. same user
|
|
37
|
+
# in both a direct message and a group), and each inbound event dictates
|
|
38
|
+
# where outbound replies should be routed.
|
|
39
|
+
# @param event [Hash] inbound event with :message_id and :chat_id
|
|
37
40
|
def update_message_context(event)
|
|
38
|
-
@mutex.synchronize
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@message_id = event[:message_id]
|
|
43
|
+
@chat_id = event[:chat_id] if event[:chat_id]
|
|
44
|
+
end
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
# === Output display ===
|
|
@@ -2706,12 +2706,16 @@ module Clacky
|
|
|
2706
2706
|
def api_list_providers(res)
|
|
2707
2707
|
providers = Clacky::Providers::PRESETS.map do |id, preset|
|
|
2708
2708
|
{
|
|
2709
|
-
id:
|
|
2710
|
-
name:
|
|
2711
|
-
base_url:
|
|
2712
|
-
default_model:
|
|
2713
|
-
models:
|
|
2714
|
-
|
|
2709
|
+
id: id,
|
|
2710
|
+
name: preset["name"],
|
|
2711
|
+
base_url: preset["base_url"],
|
|
2712
|
+
default_model: preset["default_model"],
|
|
2713
|
+
models: preset["models"] || [],
|
|
2714
|
+
# Frontend uses this to render a Base URL dropdown (regional /
|
|
2715
|
+
# billing-plan variants) when present. Absent for single-endpoint
|
|
2716
|
+
# providers — UI renders a plain text input in that case.
|
|
2717
|
+
endpoint_variants: preset["endpoint_variants"],
|
|
2718
|
+
website_url: preset["website_url"]
|
|
2715
2719
|
}
|
|
2716
2720
|
end
|
|
2717
2721
|
json_response(res, 200, { providers: providers })
|
|
@@ -141,26 +141,14 @@ module Clacky
|
|
|
141
141
|
FileRef.new(name: name, type: :image, original_path: path)
|
|
142
142
|
|
|
143
143
|
when ".csv"
|
|
144
|
-
# CSV is plain text —
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
text = read_text_with_encoding_fallback(path)
|
|
148
|
-
preview_path = save_preview(text, path)
|
|
149
|
-
FileRef.new(name: name, type: :csv, original_path: path, preview_path: preview_path)
|
|
150
|
-
rescue => e
|
|
151
|
-
FileRef.new(name: name, type: :csv, original_path: path, parse_error: e.message)
|
|
152
|
-
end
|
|
144
|
+
# CSV is plain text — the file itself IS the preview. No parser, no copy.
|
|
145
|
+
# FileReader handles encoding fallback via safe_utf8 when it reads the file.
|
|
146
|
+
FileRef.new(name: name, type: :csv, original_path: path, preview_path: path)
|
|
153
147
|
|
|
154
148
|
when *TEXT_PREVIEW_EXTENSIONS
|
|
155
|
-
# Markdown / plain text: the file itself IS the preview.
|
|
156
|
-
# No parser needed — just
|
|
157
|
-
|
|
158
|
-
text = read_text_with_encoding_fallback(path)
|
|
159
|
-
preview_path = save_preview(text, path)
|
|
160
|
-
FileRef.new(name: name, type: :text, original_path: path, preview_path: preview_path)
|
|
161
|
-
rescue => e
|
|
162
|
-
FileRef.new(name: name, type: :text, original_path: path, parse_error: e.message)
|
|
163
|
-
end
|
|
149
|
+
# Markdown / plain text / log: the file itself IS the preview.
|
|
150
|
+
# No parser needed, no tmpdir copy — just point preview_path at the original.
|
|
151
|
+
FileRef.new(name: name, type: :text, original_path: path, preview_path: path)
|
|
164
152
|
|
|
165
153
|
else
|
|
166
154
|
result = Utils::ParserManager.parse(path)
|
|
@@ -397,7 +385,14 @@ module Clacky
|
|
|
397
385
|
end
|
|
398
386
|
|
|
399
387
|
def self.save_preview(content, original_path)
|
|
400
|
-
|
|
388
|
+
# Always write previews to a tmpdir-based path to avoid polluting the
|
|
389
|
+
# user's working directory with .preview.md sidecar files.
|
|
390
|
+
# Use the same UPLOAD_DIR that uploaded files live in; for on-disk files
|
|
391
|
+
# outside that dir (e.g. project files opened by file_reader), we still
|
|
392
|
+
# land in UPLOAD_DIR so the user's tree stays clean.
|
|
393
|
+
FileUtils.mkdir_p(UPLOAD_DIR)
|
|
394
|
+
safe_name = File.basename(original_path.to_s).gsub(/[\/\:\*?"<>|\x00]/, "_")
|
|
395
|
+
dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}.preview.md")
|
|
401
396
|
File.write(dest, content)
|
|
402
397
|
dest
|
|
403
398
|
end
|
|
@@ -413,26 +408,6 @@ module Clacky
|
|
|
413
408
|
base.empty? ? 'upload' : base
|
|
414
409
|
end
|
|
415
410
|
|
|
416
|
-
# Read a text file with automatic encoding detection.
|
|
417
|
-
# Tries UTF-8, then GBK (common for Chinese-origin CSV/text files), then
|
|
418
|
-
# falls back to binary read with invalid byte replacement.
|
|
419
|
-
def self.read_text_with_encoding_fallback(path)
|
|
420
|
-
# Try UTF-8 first (most common, fastest path)
|
|
421
|
-
raw = File.binread(path)
|
|
422
|
-
utf8 = raw.dup.force_encoding("UTF-8")
|
|
423
|
-
return utf8.encode("UTF-8") if utf8.valid_encoding?
|
|
424
|
-
|
|
425
|
-
# Try GBK (GB2312 superset — common in Chinese Windows/Excel exports)
|
|
426
|
-
begin
|
|
427
|
-
return raw.encode("UTF-8", "GBK", invalid: :replace, undef: :replace, replace: "?")
|
|
428
|
-
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
429
|
-
# fall through
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
# Last resort: binary read with replacement characters
|
|
433
|
-
raw.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "?")
|
|
434
|
-
end
|
|
435
|
-
|
|
436
411
|
# Detect the actual image MIME type from raw binary data by inspecting
|
|
437
412
|
# magic bytes, ignoring the file extension. Falls back to extension-based
|
|
438
413
|
# detection when magic bytes don't match any known format.
|
|
@@ -549,7 +524,6 @@ module Clacky
|
|
|
549
524
|
end
|
|
550
525
|
|
|
551
526
|
private_class_method :parse_zip_listing, :parse_tar_listing, :save_preview, :sanitize_filename,
|
|
552
|
-
:read_text_with_encoding_fallback,
|
|
553
527
|
:downscale_png_chunky, :downscale_via_cli
|
|
554
528
|
end
|
|
555
529
|
end
|
|
@@ -279,6 +279,79 @@ module Clacky
|
|
|
279
279
|
}
|
|
280
280
|
},
|
|
281
281
|
|
|
282
|
+
# GLM (Zhipu / Z.ai) — USD per 1M tokens.
|
|
283
|
+
# Source: https://docs.z.ai/guides/overview/pricing (Z.ai international).
|
|
284
|
+
# Pricing policy: we always bill at the Z.ai international flat rate,
|
|
285
|
+
# regardless of which endpoint (mainland bigmodel.cn vs intl z.ai) the
|
|
286
|
+
# user configured. Rationale:
|
|
287
|
+
# 1. Mainland GLM uses tiered pricing (≤32K / >32K / >128K) where the
|
|
288
|
+
# >32K tier is hit by the vast majority of real requests, and is
|
|
289
|
+
# actually a few RMB cheaper than Z.ai's flat rate — displaying the
|
|
290
|
+
# (slightly higher) Z.ai rate gives users a "displayed ≤ actual"
|
|
291
|
+
# experience which is psychologically safer than the reverse.
|
|
292
|
+
# 2. Single flat rate keeps the table shape consistent with every
|
|
293
|
+
# other provider here (no special-case tier logic for just GLM).
|
|
294
|
+
# Cache-write: same convention as DeepSeek/Kimi — OpenAI-compatible
|
|
295
|
+
# endpoints don't charge separately for cache writes (Z.ai's page lists
|
|
296
|
+
# "Cached Input Storage: Limited-time Free"), so bill writes at the
|
|
297
|
+
# regular input miss rate for safe "displayed ≤ actual" behaviour.
|
|
298
|
+
"glm-5.1" => {
|
|
299
|
+
input: { default: 1.40, over_200k: 1.40 },
|
|
300
|
+
output: { default: 4.40, over_200k: 4.40 },
|
|
301
|
+
cache: { write: 1.40, read: 0.26 }
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
"glm-5" => {
|
|
305
|
+
input: { default: 1.00, over_200k: 1.00 },
|
|
306
|
+
output: { default: 3.20, over_200k: 3.20 },
|
|
307
|
+
cache: { write: 1.00, read: 0.20 }
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
"glm-5-turbo" => {
|
|
311
|
+
input: { default: 1.20, over_200k: 1.20 },
|
|
312
|
+
output: { default: 4.00, over_200k: 4.00 },
|
|
313
|
+
cache: { write: 1.20, read: 0.24 }
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
# GLM-5V-Turbo is the multimodal sibling of GLM-5-Turbo (vision capable,
|
|
317
|
+
# see providers.rb model_capabilities override). Same input/output rate
|
|
318
|
+
# as 5-Turbo per Z.ai's Vision Models table.
|
|
319
|
+
"glm-5v-turbo" => {
|
|
320
|
+
input: { default: 1.20, over_200k: 1.20 },
|
|
321
|
+
output: { default: 4.00, over_200k: 4.00 },
|
|
322
|
+
cache: { write: 1.20, read: 0.24 }
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
"glm-4.7" => {
|
|
326
|
+
input: { default: 0.60, over_200k: 0.60 },
|
|
327
|
+
output: { default: 2.20, over_200k: 2.20 },
|
|
328
|
+
cache: { write: 0.60, read: 0.11 }
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
# MiniMax — USD per 1M tokens.
|
|
332
|
+
# Source: https://platform.minimaxi.com (Pay-as-You-Go).
|
|
333
|
+
# MiniMax pricing is identical across mainland (.com) and international
|
|
334
|
+
# (.io) endpoints, verified by the team. Same cache-write convention as
|
|
335
|
+
# DeepSeek/Kimi/GLM: bill writes at the input miss rate (OpenAI-compatible
|
|
336
|
+
# usage responses from MiniMax don't reliably carry a separate
|
|
337
|
+
# cache_creation_input_tokens field, so a distinct write rate would be
|
|
338
|
+
# dead code in practice).
|
|
339
|
+
# Note: providers.rb uses the capitalised "MiniMax-M2.x" model id, but
|
|
340
|
+
# the pricing table keys are lowercased to stay consistent with the
|
|
341
|
+
# rest of this file; normalize_model_name() lowercases incoming model
|
|
342
|
+
# names before lookup.
|
|
343
|
+
"minimax-m2.5" => {
|
|
344
|
+
input: { default: 0.30, over_200k: 0.30 },
|
|
345
|
+
output: { default: 1.20, over_200k: 1.20 },
|
|
346
|
+
cache: { write: 0.30, read: 0.03 }
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
"minimax-m2.7" => {
|
|
350
|
+
input: { default: 0.30, over_200k: 0.30 },
|
|
351
|
+
output: { default: 1.20, over_200k: 1.20 },
|
|
352
|
+
cache: { write: 0.30, read: 0.06 }
|
|
353
|
+
},
|
|
354
|
+
|
|
282
355
|
}.freeze
|
|
283
356
|
|
|
284
357
|
# Threshold for tiered pricing (200K tokens)
|
|
@@ -418,6 +491,28 @@ module Clacky
|
|
|
418
491
|
"kimi-k2.5"
|
|
419
492
|
when /^kimi-k2\.?6$/i
|
|
420
493
|
"kimi-k2.6"
|
|
494
|
+
# GLM (Zhipu / Z.ai) — the five models registered in providers.rb.
|
|
495
|
+
# GLM-5V-Turbo is the vision variant; all five share the same Z.ai
|
|
496
|
+
# international flat-rate pricing regardless of which endpoint
|
|
497
|
+
# (mainland bigmodel.cn vs intl z.ai) the user configured.
|
|
498
|
+
# Strict anchored match so unrelated strings like "glm-5-x-foo"
|
|
499
|
+
# don't silently borrow a nearby model's rate.
|
|
500
|
+
when /^glm-5\.1$/i
|
|
501
|
+
"glm-5.1"
|
|
502
|
+
when /^glm-5v-turbo$/i
|
|
503
|
+
"glm-5v-turbo"
|
|
504
|
+
when /^glm-5-turbo$/i
|
|
505
|
+
"glm-5-turbo"
|
|
506
|
+
when /^glm-5$/i
|
|
507
|
+
"glm-5"
|
|
508
|
+
when /^glm-4\.7$/i
|
|
509
|
+
"glm-4.7"
|
|
510
|
+
# MiniMax — model ids in providers.rb use capitalised "MiniMax-M2.x"
|
|
511
|
+
# but we match case-insensitively and map to the lowercased table key.
|
|
512
|
+
when /^minimax-m2\.5$/i
|
|
513
|
+
"minimax-m2.5"
|
|
514
|
+
when /^minimax-m2\.7$/i
|
|
515
|
+
"minimax-m2.7"
|
|
421
516
|
|
|
422
517
|
# OpenAI GPT-5.x models — match various dashed/dotted/compact forms
|
|
423
518
|
# (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
|
data/lib/clacky/version.rb
CHANGED