openclacky 1.2.5 → 1.2.7
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 +43 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +24 -10
- data/lib/clacky/agent/llm_caller.rb +25 -3
- data/lib/clacky/agent/message_compressor.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +6 -2
- data/lib/clacky/agent/session_serializer.rb +23 -4
- data/lib/clacky/agent/tool_executor.rb +14 -0
- data/lib/clacky/agent/tool_registry.rb +0 -7
- data/lib/clacky/agent.rb +43 -10
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/billing/billing_store.rb +62 -4
- data/lib/clacky/brand_config.rb +5 -0
- data/lib/clacky/cli.rb +76 -24
- data/lib/clacky/client.rb +59 -4
- data/lib/clacky/default_parsers/wps_parser.rb +82 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/message_format/anthropic.rb +13 -3
- data/lib/clacky/message_format/bedrock.rb +2 -2
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/platform_http_client.rb +28 -1
- data/lib/clacky/providers.rb +11 -29
- data/lib/clacky/server/channel/channel_manager.rb +148 -12
- data/lib/clacky/server/channel/channel_ui_controller.rb +4 -2
- data/lib/clacky/server/http_server.rb +133 -13
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/tools/browser.rb +4 -13
- data/lib/clacky/tools/terminal.rb +23 -27
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/file_processor.rb +3 -0
- data/lib/clacky/utils/parser_manager.rb +3 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +659 -75
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/billing.js +371 -99
- data/lib/clacky/web/i18n.js +48 -2
- data/lib/clacky/web/index.html +34 -1
- data/lib/clacky/web/sessions.js +213 -82
- data/lib/clacky/web/settings.js +59 -17
- data/lib/clacky/web/workspace.js +204 -0
- data/lib/clacky/web/ws-dispatcher.js +19 -3
- data/lib/clacky.rb +9 -3
- metadata +4 -5
- data/lib/clacky/tools/list_tasks.rb +0 -54
- data/lib/clacky/tools/redo_task.rb +0 -41
- data/lib/clacky/tools/undo_task.rb +0 -35
data/lib/clacky/providers.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Clacky
|
|
|
31
31
|
"api" => "bedrock",
|
|
32
32
|
"default_model" => "abs-claude-sonnet-4-6",
|
|
33
33
|
"models" => [
|
|
34
|
+
"abs-claude-opus-4-8",
|
|
34
35
|
"abs-claude-opus-4-7",
|
|
35
36
|
"abs-claude-opus-4-6",
|
|
36
37
|
"abs-claude-sonnet-4-6",
|
|
@@ -61,6 +62,7 @@ module Clacky
|
|
|
61
62
|
# sibling wired up (yet) on this provider; subagents using the
|
|
62
63
|
# Gemini default will just reuse it for lite work until we add one.
|
|
63
64
|
"lite_models" => {
|
|
65
|
+
"abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
|
|
64
66
|
"abs-claude-opus-4-7" => "abs-claude-haiku-4-5",
|
|
65
67
|
"abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
|
|
66
68
|
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
@@ -88,6 +90,7 @@ module Clacky
|
|
|
88
90
|
# ID manually; this list only seeds the picker.
|
|
89
91
|
"models" => [
|
|
90
92
|
"anthropic/claude-sonnet-4-6",
|
|
93
|
+
"anthropic/claude-opus-4-8",
|
|
91
94
|
"anthropic/claude-opus-4-7",
|
|
92
95
|
"anthropic/claude-opus-4-6",
|
|
93
96
|
"anthropic/claude-haiku-4-5",
|
|
@@ -101,6 +104,7 @@ module Clacky
|
|
|
101
104
|
# cheap/fast sidekick automatically.
|
|
102
105
|
"lite_models" => {
|
|
103
106
|
"anthropic/claude-sonnet-4-6" => "anthropic/claude-haiku-4-5",
|
|
107
|
+
"anthropic/claude-opus-4-8" => "anthropic/claude-haiku-4-5",
|
|
104
108
|
"anthropic/claude-opus-4-7" => "anthropic/claude-haiku-4-5",
|
|
105
109
|
"anthropic/claude-opus-4-6" => "anthropic/claude-haiku-4-5",
|
|
106
110
|
"openai/gpt-5.5" => "openai/gpt-5.4-mini",
|
|
@@ -232,38 +236,11 @@ module Clacky
|
|
|
232
236
|
"name" => "Anthropic (Claude)",
|
|
233
237
|
"base_url" => "https://api.anthropic.com",
|
|
234
238
|
"api" => "anthropic-messages",
|
|
235
|
-
"default_model" => "claude-sonnet-4
|
|
236
|
-
"models" => ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4
|
|
239
|
+
"default_model" => "claude-sonnet-4-6",
|
|
240
|
+
"models" => ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
|
|
237
241
|
"website_url" => "https://console.anthropic.com/settings/keys"
|
|
238
242
|
}.freeze,
|
|
239
243
|
|
|
240
|
-
"clackyai-sea" => {
|
|
241
|
-
"name" => "ClackyAI(Sea)",
|
|
242
|
-
"base_url" => "https://api.clacky.ai",
|
|
243
|
-
"api" => "bedrock",
|
|
244
|
-
"default_model" => "abs-claude-sonnet-4-5",
|
|
245
|
-
"models" => [
|
|
246
|
-
"abs-claude-opus-4-6",
|
|
247
|
-
"abs-claude-sonnet-4-6",
|
|
248
|
-
"abs-claude-sonnet-4-5",
|
|
249
|
-
"abs-claude-haiku-4-5"
|
|
250
|
-
],
|
|
251
|
-
# Claude family — all vision-capable.
|
|
252
|
-
"capabilities" => { "vision" => true }.freeze,
|
|
253
|
-
# Per-primary lite pairing — see openclacky preset for rationale.
|
|
254
|
-
"lite_models" => {
|
|
255
|
-
"abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
|
|
256
|
-
"abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
|
|
257
|
-
"abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5"
|
|
258
|
-
},
|
|
259
|
-
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
260
|
-
# Keys are primary model names; values are the fallback model to use instead.
|
|
261
|
-
"fallback_models" => {
|
|
262
|
-
"abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
|
|
263
|
-
},
|
|
264
|
-
"website_url" => "https://clacky.ai"
|
|
265
|
-
}.freeze,
|
|
266
|
-
|
|
267
244
|
"mimo" => {
|
|
268
245
|
"name" => "MiMo (Xiaomi)",
|
|
269
246
|
"base_url" => "https://api.xiaomimimo.com/v1",
|
|
@@ -572,6 +549,11 @@ module Clacky
|
|
|
572
549
|
return "openclacky"
|
|
573
550
|
end
|
|
574
551
|
|
|
552
|
+
if base_url.is_a?(String) &&
|
|
553
|
+
base_url.match?(%r{\Ahttps?://(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|/|\z)}i)
|
|
554
|
+
return "openclacky"
|
|
555
|
+
end
|
|
556
|
+
|
|
575
557
|
nil
|
|
576
558
|
end
|
|
577
559
|
|
|
@@ -234,7 +234,7 @@ module Clacky
|
|
|
234
234
|
return if (text.nil? || text.empty?) && files.empty?
|
|
235
235
|
|
|
236
236
|
# Handle built-in commands
|
|
237
|
-
if text&.
|
|
237
|
+
if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
|
|
238
238
|
handle_command(adapter, event, text)
|
|
239
239
|
return
|
|
240
240
|
end
|
|
@@ -249,7 +249,8 @@ module Clacky
|
|
|
249
249
|
return
|
|
250
250
|
end
|
|
251
251
|
|
|
252
|
-
|
|
252
|
+
sub_count = web_ui_for_session_diag(session_id)
|
|
253
|
+
Clacky::Logger.info("[ChannelManager] Routing to session #{session_id[0, 8]} (status=#{session[:status]}, text=#{text.inspect}, channel_subs=#{sub_count})")
|
|
253
254
|
|
|
254
255
|
# If session is running, interrupt it automatically (mimics CLI behavior)
|
|
255
256
|
if session[:status] == :running
|
|
@@ -283,7 +284,12 @@ module Clacky
|
|
|
283
284
|
|
|
284
285
|
@run_agent_task.call(session_id, agent) do
|
|
285
286
|
begin
|
|
287
|
+
Clacky::Logger.info("[ChannelManager] agent.run START session=#{session_id[0, 8]} text=#{text.inspect}")
|
|
286
288
|
agent.run(text, files: files)
|
|
289
|
+
Clacky::Logger.info("[ChannelManager] agent.run END session=#{session_id[0, 8]} text=#{text.inspect}")
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
Clacky::Logger.error("[ChannelManager] agent.run RAISED session=#{session_id[0, 8]} #{e.class}: #{e.message}\n#{e.backtrace.first(8).join("\n")}")
|
|
292
|
+
raise
|
|
287
293
|
ensure
|
|
288
294
|
adapter.stop_typing_keepalive(chat_id) if adapter.respond_to?(:stop_typing_keepalive)
|
|
289
295
|
end
|
|
@@ -295,11 +301,24 @@ module Clacky
|
|
|
295
301
|
key = channel_key(event)
|
|
296
302
|
|
|
297
303
|
case text
|
|
304
|
+
when /\A([\?h]|help)\z/i
|
|
305
|
+
adapter.send_text(chat_id, COMMAND_HELP)
|
|
306
|
+
|
|
307
|
+
when "/new", "/clear"
|
|
308
|
+
session_id = auto_create_session(adapter, event)
|
|
309
|
+
adapter.send_text(chat_id, "New session `#{session_id[0, 8]}` created.") if session_id
|
|
310
|
+
|
|
311
|
+
when /\A\/model\b/i
|
|
312
|
+
handle_model_command(adapter, event, text)
|
|
313
|
+
|
|
314
|
+
when /\A\/skills\b/i
|
|
315
|
+
handle_skills_command(adapter, event)
|
|
316
|
+
|
|
298
317
|
when /\A\/bind\s+(\S+)\z/i
|
|
299
318
|
arg = Regexp.last_match(1)
|
|
300
319
|
# Support numeric index from /list (1-based)
|
|
301
320
|
session_id = if arg =~ /\A\d+\z/
|
|
302
|
-
recent = @registry.list.
|
|
321
|
+
recent = @registry.list.first(5)
|
|
303
322
|
idx = arg.to_i - 1
|
|
304
323
|
recent[idx]&.fetch(:id, nil)
|
|
305
324
|
else
|
|
@@ -351,7 +370,9 @@ module Clacky
|
|
|
351
370
|
session_id = resolve_session(event)
|
|
352
371
|
if session_id
|
|
353
372
|
session = @registry.get(session_id)
|
|
354
|
-
|
|
373
|
+
model = session&.dig(:agent)&.current_model_info
|
|
374
|
+
model_name = model&.dig(:model) || "unknown"
|
|
375
|
+
adapter.send_text(chat_id, "Bound to session `#{session_id[0, 8]}` (status: #{session&.dig(:status) || "unknown"}, model: #{model_name})")
|
|
355
376
|
else
|
|
356
377
|
adapter.send_text(chat_id, "No session bound yet. Send any message to auto-create one.")
|
|
357
378
|
end
|
|
@@ -360,16 +381,118 @@ module Clacky
|
|
|
360
381
|
list_sessions(adapter, chat_id)
|
|
361
382
|
|
|
362
383
|
else
|
|
363
|
-
adapter.send_text(chat_id,
|
|
364
|
-
"Commands:\n" \
|
|
365
|
-
" /bind <n|session_id> - switch to a session (use /list to see numbers)\n" \
|
|
366
|
-
" /unbind - remove binding\n" \
|
|
367
|
-
" /stop - interrupt current task\n" \
|
|
368
|
-
" /status - show current binding\n" \
|
|
369
|
-
" /list - show recent sessions")
|
|
384
|
+
adapter.send_text(chat_id, "Unknown command. Type ? for help.")
|
|
370
385
|
end
|
|
371
386
|
end
|
|
372
387
|
|
|
388
|
+
KNOWN_COMMAND = %r{\A/(new|clear|model|skills|bind|stop|unbind|status|list)\b}i
|
|
389
|
+
|
|
390
|
+
COMMAND_HELP = <<~HELP.strip
|
|
391
|
+
Commands:
|
|
392
|
+
? / h / help - show this help
|
|
393
|
+
/new / /clear - start a new session
|
|
394
|
+
/model - show current model & available models
|
|
395
|
+
/model <n> - switch to model n
|
|
396
|
+
/skills - list available skills
|
|
397
|
+
/<skill> <args> - invoke a skill directly
|
|
398
|
+
/bind <n|session_id> - switch to a session (use /list to see numbers)
|
|
399
|
+
/unbind - remove binding
|
|
400
|
+
/stop - interrupt current task
|
|
401
|
+
/status - show current binding
|
|
402
|
+
/list - show recent sessions
|
|
403
|
+
HELP
|
|
404
|
+
|
|
405
|
+
def handle_model_command(adapter, event, text)
|
|
406
|
+
chat_id = event[:chat_id]
|
|
407
|
+
session_id = resolve_session(event)
|
|
408
|
+
|
|
409
|
+
unless session_id
|
|
410
|
+
adapter.send_text(chat_id, "No session bound. Send any message to auto-create one first.")
|
|
411
|
+
return
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
session = @registry.get(session_id)
|
|
415
|
+
agent = session&.dig(:agent)
|
|
416
|
+
unless agent
|
|
417
|
+
adapter.send_text(chat_id, "Session not ready.")
|
|
418
|
+
return
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
arg = text.sub(/\A\/model\s*/i, "").strip
|
|
422
|
+
|
|
423
|
+
if arg.empty?
|
|
424
|
+
# Show current model and available list
|
|
425
|
+
info = agent.current_model_info
|
|
426
|
+
current = info&.dig(:model) || "unknown"
|
|
427
|
+
sub = info&.dig(:sub_model)
|
|
428
|
+
card = info&.dig(:card_model)
|
|
429
|
+
header = "Current model: #{current}"
|
|
430
|
+
header += " (#{card} · #{sub})" if card && sub && sub != current
|
|
431
|
+
header += " (#{card})" if card && !sub
|
|
432
|
+
|
|
433
|
+
models = agent.available_models
|
|
434
|
+
if models.empty?
|
|
435
|
+
adapter.send_text(chat_id, "#{header}\nNo other models available.")
|
|
436
|
+
return
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
lines = models.each_with_index.map do |name, i|
|
|
440
|
+
marker = name == current ? " *" : ""
|
|
441
|
+
"#{i + 1}. #{name}#{marker}"
|
|
442
|
+
end
|
|
443
|
+
adapter.send_text(chat_id, "#{header}\n\nSwitch with /model <n>:\n#{lines.join("\n")}")
|
|
444
|
+
elsif arg =~ /\A\d+\z/
|
|
445
|
+
idx = arg.to_i - 1
|
|
446
|
+
models = agent.config.models
|
|
447
|
+
if idx < 0 || idx >= models.length
|
|
448
|
+
adapter.send_text(chat_id, "Invalid model number. Use /model to see available models.")
|
|
449
|
+
return
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
model_id = models[idx]["id"]
|
|
453
|
+
if agent.switch_model_by_id(model_id)
|
|
454
|
+
new_info = agent.current_model_info
|
|
455
|
+
adapter.send_text(chat_id, "Switched to #{new_info&.dig(:model) || model_id}.")
|
|
456
|
+
else
|
|
457
|
+
adapter.send_text(chat_id, "Failed to switch model.")
|
|
458
|
+
end
|
|
459
|
+
else
|
|
460
|
+
adapter.send_text(chat_id, "Usage: /model to list, /model <n> to switch.")
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def handle_skills_command(adapter, event)
|
|
465
|
+
chat_id = event[:chat_id]
|
|
466
|
+
session_id = resolve_session(event)
|
|
467
|
+
|
|
468
|
+
unless session_id
|
|
469
|
+
adapter.send_text(chat_id, "No session bound. Send any message to auto-create one first.")
|
|
470
|
+
return
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
session = @registry.get(session_id)
|
|
474
|
+
agent = session&.dig(:agent)
|
|
475
|
+
unless agent
|
|
476
|
+
adapter.send_text(chat_id, "Session not ready.")
|
|
477
|
+
return
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
skills = agent.skill_loader.user_invocable_skills
|
|
481
|
+
.reject { |s| s.source == :default }
|
|
482
|
+
.first(10)
|
|
483
|
+
if skills.empty?
|
|
484
|
+
adapter.send_text(chat_id, "No skills available.")
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
lines = skills.each_with_index.map do |s, i|
|
|
489
|
+
desc = s.description.to_s.strip
|
|
490
|
+
desc = desc.empty? ? "(no description)" : desc.length > 50 ? "#{desc[0..49]}..." : desc
|
|
491
|
+
"#{i + 1}. #{s.name} - #{desc}"
|
|
492
|
+
end
|
|
493
|
+
adapter.send_text(chat_id, "Skills:\n#{lines.join("\n")}")
|
|
494
|
+
end
|
|
495
|
+
|
|
373
496
|
def resolve_session(event)
|
|
374
497
|
key = channel_key(event)
|
|
375
498
|
@registry.list.each do |summary|
|
|
@@ -411,6 +534,19 @@ module Clacky
|
|
|
411
534
|
result
|
|
412
535
|
end
|
|
413
536
|
|
|
537
|
+
def web_ui_for_session_diag(session_id)
|
|
538
|
+
result = nil
|
|
539
|
+
@registry.with_session(session_id) do |s|
|
|
540
|
+
ui = s[:ui]
|
|
541
|
+
result = if ui.respond_to?(:channel_subscribed?)
|
|
542
|
+
ui.instance_variable_get(:@channel_subscribers)&.size || 0
|
|
543
|
+
else
|
|
544
|
+
-1
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
result
|
|
548
|
+
end
|
|
549
|
+
|
|
414
550
|
def bind_key_to_session(key, session_id)
|
|
415
551
|
@registry.list.each do |summary|
|
|
416
552
|
@registry.with_session(summary[:id]) { |s| s[:channel_keys]&.delete(key) }
|
|
@@ -422,7 +558,7 @@ module Clacky
|
|
|
422
558
|
end
|
|
423
559
|
|
|
424
560
|
def list_sessions(adapter, chat_id)
|
|
425
|
-
sessions = @registry.list.
|
|
561
|
+
sessions = @registry.list.first(5)
|
|
426
562
|
if sessions.empty?
|
|
427
563
|
adapter.send_text(chat_id, "No sessions available.")
|
|
428
564
|
return
|
|
@@ -145,8 +145,10 @@ module Clacky
|
|
|
145
145
|
send_text("Warning: #{message}")
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
-
def show_error(message)
|
|
149
|
-
|
|
148
|
+
def show_error(message, code: nil, top_up_url: nil)
|
|
149
|
+
text = "Error: #{message}"
|
|
150
|
+
text += "\n#{top_up_url}" if top_up_url
|
|
151
|
+
send_text(text)
|
|
150
152
|
end
|
|
151
153
|
|
|
152
154
|
def show_success(message)
|
|
@@ -436,6 +436,7 @@ module Clacky
|
|
|
436
436
|
when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
|
|
437
437
|
when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
|
|
438
438
|
when ["GET", "/api/billing/records"] then api_billing_records(req, res)
|
|
439
|
+
when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
|
|
439
440
|
when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
|
|
440
441
|
when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
|
|
441
442
|
else
|
|
@@ -480,6 +481,9 @@ module Clacky
|
|
|
480
481
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
481
482
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
482
483
|
api_session_skills(session_id, res)
|
|
484
|
+
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/files$})
|
|
485
|
+
session_id = path.sub("/api/sessions/", "").sub("/files", "")
|
|
486
|
+
api_session_files(session_id, req, res)
|
|
483
487
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
|
|
484
488
|
session_id = path.sub("/api/sessions/", "").sub("/export", "")
|
|
485
489
|
api_export_session(session_id, res)
|
|
@@ -495,6 +499,9 @@ module Clacky
|
|
|
495
499
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
|
|
496
500
|
session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
|
|
497
501
|
api_switch_session_reasoning_effort(session_id, req, res)
|
|
502
|
+
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/submodel$})
|
|
503
|
+
session_id = path.sub("/api/sessions/", "").sub("/submodel", "")
|
|
504
|
+
api_switch_session_submodel(session_id, req, res)
|
|
498
505
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
|
|
499
506
|
session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
|
|
500
507
|
api_benchmark_session_models(session_id, req, res)
|
|
@@ -1113,30 +1120,32 @@ module Clacky
|
|
|
1113
1120
|
|
|
1114
1121
|
# GET /api/billing/summary
|
|
1115
1122
|
# Returns billing summary for a time period
|
|
1116
|
-
# Query params: period (day|week|month|year|all, default: month)
|
|
1123
|
+
# Query params: period (day|week|month|year|all, default: month), model (optional)
|
|
1117
1124
|
def api_billing_summary(req, res)
|
|
1118
1125
|
require_relative "../billing/billing_store"
|
|
1119
1126
|
|
|
1120
1127
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1121
1128
|
period = (query["period"] || "month").to_sym
|
|
1129
|
+
model = query["model"]
|
|
1122
1130
|
|
|
1123
1131
|
store = Clacky::Billing::BillingStore.new
|
|
1124
|
-
summary = store.summary(period: period)
|
|
1132
|
+
summary = store.summary(period: period, model: model)
|
|
1125
1133
|
|
|
1126
1134
|
json_response(res, 200, summary)
|
|
1127
1135
|
end
|
|
1128
1136
|
|
|
1129
1137
|
# GET /api/billing/daily
|
|
1130
1138
|
# Returns daily cost breakdown
|
|
1131
|
-
# Query params: days (default: 30)
|
|
1139
|
+
# Query params: days (default: 30), model (optional)
|
|
1132
1140
|
def api_billing_daily(req, res)
|
|
1133
1141
|
require_relative "../billing/billing_store"
|
|
1134
1142
|
|
|
1135
1143
|
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1136
1144
|
days = [(query["days"] || "30").to_i, 90].min
|
|
1145
|
+
model = query["model"]
|
|
1137
1146
|
|
|
1138
1147
|
store = Clacky::Billing::BillingStore.new
|
|
1139
|
-
daily = store.daily_breakdown(days: days)
|
|
1148
|
+
daily = store.daily_breakdown(days: days, model: model)
|
|
1140
1149
|
|
|
1141
1150
|
json_response(res, 200, { days: daily })
|
|
1142
1151
|
end
|
|
@@ -1161,6 +1170,23 @@ module Clacky
|
|
|
1161
1170
|
})
|
|
1162
1171
|
end
|
|
1163
1172
|
|
|
1173
|
+
# DELETE /api/billing/clear
|
|
1174
|
+
# Clears billing records
|
|
1175
|
+
# Query params: scope (today|all, default: today)
|
|
1176
|
+
def api_billing_clear(req, res)
|
|
1177
|
+
require_relative "../billing/billing_store"
|
|
1178
|
+
|
|
1179
|
+
query = URI.decode_www_form(req.query_string.to_s).to_h
|
|
1180
|
+
scope = query["scope"] || "today"
|
|
1181
|
+
|
|
1182
|
+
store = Clacky::Billing::BillingStore.new
|
|
1183
|
+
deleted = store.clear(scope: scope.to_sym)
|
|
1184
|
+
|
|
1185
|
+
json_response(res, 200, { ok: true, deleted: deleted, scope: scope })
|
|
1186
|
+
rescue => e
|
|
1187
|
+
json_response(res, 500, { error: e.message })
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1164
1190
|
# GET /api/version
|
|
1165
1191
|
# Returns current version and latest version from RubyGems (cached for 1 hour).
|
|
1166
1192
|
def api_get_version(res)
|
|
@@ -2425,7 +2451,56 @@ module Clacky
|
|
|
2425
2451
|
json_response(res, 200, { skills: skill_data })
|
|
2426
2452
|
end
|
|
2427
2453
|
|
|
2428
|
-
#
|
|
2454
|
+
# GET /api/sessions/:id/files?path=<relative dir>
|
|
2455
|
+
# Lists one directory level inside the session's working_dir (lazy, per-layer).
|
|
2456
|
+
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
2457
|
+
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
2458
|
+
|
|
2459
|
+
def api_session_files(session_id, req, res)
|
|
2460
|
+
unless @registry.ensure(session_id)
|
|
2461
|
+
return json_response(res, 404, { error: "Session not found" })
|
|
2462
|
+
end
|
|
2463
|
+
session = @registry.get(session_id)
|
|
2464
|
+
agent = session && session[:agent]
|
|
2465
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
2466
|
+
|
|
2467
|
+
root = File.expand_path(agent.working_dir.to_s)
|
|
2468
|
+
return json_response(res, 404, { error: "Working directory not found" }) unless Dir.exist?(root)
|
|
2469
|
+
|
|
2470
|
+
rel = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
2471
|
+
rel = rel.sub(%r{\A/+}, "").strip
|
|
2472
|
+
target = File.expand_path(File.join(root, rel))
|
|
2473
|
+
|
|
2474
|
+
# Reject traversal outside the working directory.
|
|
2475
|
+
unless target == root || target.start_with?("#{root}/")
|
|
2476
|
+
return json_response(res, 403, { error: "Path outside working directory" })
|
|
2477
|
+
end
|
|
2478
|
+
return json_response(res, 404, { error: "Directory not found" }) unless Dir.exist?(target)
|
|
2479
|
+
|
|
2480
|
+
entries = Dir.children(target).reject { |name| IGNORED_FILE_ENTRIES.include?(name) }
|
|
2481
|
+
|
|
2482
|
+
items = entries.filter_map do |name|
|
|
2483
|
+
full = File.join(target, name)
|
|
2484
|
+
is_dir = File.directory?(full)
|
|
2485
|
+
# Skip symlinks pointing outside the root, and anything unreadable.
|
|
2486
|
+
next unless File.exist?(full)
|
|
2487
|
+
{
|
|
2488
|
+
name: name,
|
|
2489
|
+
path: rel.empty? ? name : "#{rel}/#{name}",
|
|
2490
|
+
type: is_dir ? "dir" : "file",
|
|
2491
|
+
size: is_dir ? nil : (File.size(full) rescue nil)
|
|
2492
|
+
}
|
|
2493
|
+
rescue StandardError
|
|
2494
|
+
nil
|
|
2495
|
+
end
|
|
2496
|
+
|
|
2497
|
+
# Directories first, then files; both case-insensitive alphabetical.
|
|
2498
|
+
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
2499
|
+
|
|
2500
|
+
json_response(res, 200, { root: root, path: rel, entries: items })
|
|
2501
|
+
rescue StandardError => e
|
|
2502
|
+
json_response(res, 500, { error: e.message })
|
|
2503
|
+
end
|
|
2429
2504
|
# Body: { enabled: true/false }
|
|
2430
2505
|
def api_toggle_skill(name, req, res)
|
|
2431
2506
|
body = parse_json_body(req)
|
|
@@ -3581,6 +3656,47 @@ module Clacky
|
|
|
3581
3656
|
json_response(res, 500, { error: e.message })
|
|
3582
3657
|
end
|
|
3583
3658
|
|
|
3659
|
+
# PATCH /api/sessions/:id/submodel
|
|
3660
|
+
# Body: { "model_name": "dsk-deepseek-v4-pro" | null }
|
|
3661
|
+
#
|
|
3662
|
+
# Pin this session to a sub-model under its current card without
|
|
3663
|
+
# touching credentials or the global @models. Pass null/empty to clear
|
|
3664
|
+
# and fall back to the card default. The name must appear in the
|
|
3665
|
+
# provider preset's "models" list — anything else is rejected.
|
|
3666
|
+
def api_switch_session_submodel(session_id, req, res)
|
|
3667
|
+
body = parse_json_body(req)
|
|
3668
|
+
raw = body["model_name"]
|
|
3669
|
+
model_name = raw.is_a?(String) ? raw.strip : nil
|
|
3670
|
+
|
|
3671
|
+
return json_response(res, 404, { error: "Session not found" }) unless @registry.ensure(session_id)
|
|
3672
|
+
|
|
3673
|
+
agent = nil
|
|
3674
|
+
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
3675
|
+
return json_response(res, 404, { error: "Session not found" }) unless agent
|
|
3676
|
+
|
|
3677
|
+
if model_name && !model_name.empty?
|
|
3678
|
+
info = agent.current_model_info
|
|
3679
|
+
provider_id = info && Clacky::Providers.find_by_base_url(info[:base_url])
|
|
3680
|
+
allowed = provider_id ? Clacky::Providers.models(provider_id) : []
|
|
3681
|
+
if allowed.empty?
|
|
3682
|
+
return json_response(res, 400, { error: "Current model has no provider preset; sub-model switching unavailable" })
|
|
3683
|
+
end
|
|
3684
|
+
unless allowed.include?(model_name)
|
|
3685
|
+
return json_response(res, 400, { error: "Sub-model '#{model_name}' not listed under provider '#{provider_id}'" })
|
|
3686
|
+
end
|
|
3687
|
+
else
|
|
3688
|
+
model_name = nil
|
|
3689
|
+
end
|
|
3690
|
+
|
|
3691
|
+
agent.set_session_sub_model(model_name)
|
|
3692
|
+
@session_manager.save(agent.to_session_data)
|
|
3693
|
+
broadcast_session_update(session_id)
|
|
3694
|
+
|
|
3695
|
+
json_response(res, 200, { ok: true, sub_model: agent.current_model_info[:sub_model] })
|
|
3696
|
+
rescue => e
|
|
3697
|
+
json_response(res, 500, { error: e.message })
|
|
3698
|
+
end
|
|
3699
|
+
|
|
3584
3700
|
# POST /api/sessions/:id/benchmark
|
|
3585
3701
|
#
|
|
3586
3702
|
# Speed-test every configured model in one shot so the user can pick the
|
|
@@ -3686,11 +3802,9 @@ module Clacky
|
|
|
3686
3802
|
|
|
3687
3803
|
# Expand ~ to home directory
|
|
3688
3804
|
expanded_dir = File.expand_path(new_dir)
|
|
3689
|
-
|
|
3690
|
-
#
|
|
3691
|
-
|
|
3692
|
-
return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
|
|
3693
|
-
end
|
|
3805
|
+
|
|
3806
|
+
# Auto-create the directory if it doesn't exist yet.
|
|
3807
|
+
FileUtils.mkdir_p(expanded_dir)
|
|
3694
3808
|
|
|
3695
3809
|
agent = nil
|
|
3696
3810
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
@@ -4169,12 +4283,18 @@ module Clacky
|
|
|
4169
4283
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
4170
4284
|
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
4171
4285
|
rescue => e
|
|
4172
|
-
@registry.update(session_id, status: :error, error: e.message)
|
|
4173
|
-
broadcast_session_update(session_id)
|
|
4174
4286
|
# Route error through web_ui so channel subscribers (飞书/企微) receive it too.
|
|
4175
4287
|
web_ui = nil
|
|
4176
4288
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
4177
|
-
|
|
4289
|
+
code = e.is_a?(Clacky::InsufficientCreditError) ? e.error_code : nil
|
|
4290
|
+
top_up_url = nil
|
|
4291
|
+
if e.is_a?(Clacky::InsufficientCreditError) && e.provider_id
|
|
4292
|
+
preset = Clacky::Providers::PRESETS[e.provider_id]
|
|
4293
|
+
top_up_url = preset && preset["website_url"]
|
|
4294
|
+
end
|
|
4295
|
+
@registry.update(session_id, status: :error, error: e.message, error_code: code, top_up_url: top_up_url)
|
|
4296
|
+
broadcast_session_update(session_id)
|
|
4297
|
+
web_ui&.show_error(e.message, code: code, top_up_url: top_up_url)
|
|
4178
4298
|
@session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
|
|
4179
4299
|
end
|
|
4180
4300
|
@registry.with_session(session_id) { |s| s[:thread] = thread }
|
|
@@ -40,6 +40,8 @@ module Clacky
|
|
|
40
40
|
id: session_id,
|
|
41
41
|
status: :idle,
|
|
42
42
|
error: nil,
|
|
43
|
+
error_code: nil,
|
|
44
|
+
top_up_url: nil,
|
|
43
45
|
updated_at: Time.now,
|
|
44
46
|
agent: nil,
|
|
45
47
|
ui: nil,
|
|
@@ -166,11 +168,15 @@ module Clacky
|
|
|
166
168
|
live_name = s[:agent]&.name
|
|
167
169
|
live_name = nil if live_name&.empty?
|
|
168
170
|
live_cost_source = s[:agent]&.cost_source
|
|
169
|
-
{ status: s[:status], error: s[:error],
|
|
171
|
+
{ status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
|
|
172
|
+
model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
|
|
170
173
|
total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
|
|
171
174
|
cost_source: live_cost_source,
|
|
172
175
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
173
|
-
latest_latency: s[:agent]&.latest_latency
|
|
176
|
+
latest_latency: s[:agent]&.latest_latency,
|
|
177
|
+
card_model: model_info&.dig(:card_model),
|
|
178
|
+
sub_model: model_info&.dig(:sub_model),
|
|
179
|
+
sub_model_options: sub_model_options_for(model_info) }
|
|
174
180
|
end
|
|
175
181
|
end
|
|
176
182
|
|
|
@@ -239,11 +245,15 @@ module Clacky
|
|
|
239
245
|
model_info = s[:agent]&.current_model_info
|
|
240
246
|
live_name = s[:agent]&.name
|
|
241
247
|
live_name = nil if live_name&.empty?
|
|
242
|
-
{ status: s[:status], error: s[:error],
|
|
248
|
+
{ status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
|
|
249
|
+
model: model_info&.dig(:model), model_id: model_info&.dig(:id),
|
|
243
250
|
name: live_name, total_tasks: s[:agent]&.total_tasks,
|
|
244
251
|
total_cost: s[:agent]&.total_cost, cost_source: s[:agent]&.cost_source,
|
|
245
252
|
reasoning_effort: s[:agent]&.reasoning_effort,
|
|
246
|
-
latest_latency: s[:agent]&.latest_latency
|
|
253
|
+
latest_latency: s[:agent]&.latest_latency,
|
|
254
|
+
card_model: model_info&.dig(:card_model),
|
|
255
|
+
sub_model: model_info&.dig(:sub_model),
|
|
256
|
+
sub_model_options: sub_model_options_for(model_info) }
|
|
247
257
|
end
|
|
248
258
|
|
|
249
259
|
build_enriched_row(disk, live)
|
|
@@ -259,8 +269,13 @@ module Clacky
|
|
|
259
269
|
name: ls&.dig(:name) || s[:name] || "",
|
|
260
270
|
status: ls ? ls[:status].to_s : "idle",
|
|
261
271
|
error: ls ? ls[:error] : nil,
|
|
272
|
+
error_code: ls&.dig(:error_code),
|
|
273
|
+
top_up_url: ls&.dig(:top_up_url),
|
|
262
274
|
model: ls&.dig(:model),
|
|
263
275
|
model_id: ls&.dig(:model_id),
|
|
276
|
+
card_model: ls&.dig(:card_model),
|
|
277
|
+
sub_model: ls&.dig(:sub_model),
|
|
278
|
+
sub_model_options: ls&.dig(:sub_model_options) || [],
|
|
264
279
|
source: s_source(s),
|
|
265
280
|
agent_profile: (s[:agent_profile] || "general").to_s,
|
|
266
281
|
working_dir: s[:working_dir],
|
|
@@ -280,6 +295,17 @@ module Clacky
|
|
|
280
295
|
end
|
|
281
296
|
|
|
282
297
|
|
|
298
|
+
# Look up the provider preset for the session's current card and
|
|
299
|
+
# return the sub-model list. Empty when the card's base_url isn't
|
|
300
|
+
# in any preset (e.g. self-hosted custom endpoints) — the WebUI
|
|
301
|
+
# treats that as "no sub-model switcher available".
|
|
302
|
+
private def sub_model_options_for(model_info)
|
|
303
|
+
return [] unless model_info && model_info[:base_url]
|
|
304
|
+
provider_id = Clacky::Providers.find_by_base_url(model_info[:base_url])
|
|
305
|
+
return [] unless provider_id
|
|
306
|
+
Clacky::Providers.models(provider_id)
|
|
307
|
+
end
|
|
308
|
+
|
|
283
309
|
# Normalize source field from a disk session hash.
|
|
284
310
|
# "system" is a legacy value renamed to "setup" — treat them as equivalent.
|
|
285
311
|
def s_source(s)
|
|
@@ -210,9 +210,12 @@ module Clacky
|
|
|
210
210
|
forward_to_subscribers { |sub| sub.show_warning(message) }
|
|
211
211
|
end
|
|
212
212
|
|
|
213
|
-
def show_error(message)
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
def show_error(message, code: nil, top_up_url: nil)
|
|
214
|
+
payload = { message: message }
|
|
215
|
+
payload[:code] = code if code
|
|
216
|
+
payload[:top_up_url] = top_up_url if top_up_url
|
|
217
|
+
emit("error", **payload)
|
|
218
|
+
forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url) }
|
|
216
219
|
end
|
|
217
220
|
|
|
218
221
|
def show_success(message)
|