openclacky 1.2.6 → 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 +20 -0
- data/README.md +34 -0
- data/README_CN.md +34 -0
- data/lib/clacky/agent/cost_tracker.rb +7 -1
- 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.rb +43 -2
- data/lib/clacky/agent_config.rb +54 -6
- data/lib/clacky/brand_config.rb +0 -6
- data/lib/clacky/cli.rb +2 -1
- data/lib/clacky/client.rb +24 -3
- data/lib/clacky/default_skills/onboard/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +5 -2
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +11 -2
- 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 +109 -9
- data/lib/clacky/server/session_registry.rb +30 -4
- data/lib/clacky/server/web_ui_controller.rb +6 -3
- data/lib/clacky/tools/terminal.rb +22 -26
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +237 -1
- data/lib/clacky/web/app.js +0 -1
- data/lib/clacky/web/i18n.js +24 -0
- data/lib/clacky/web/index.html +33 -0
- data/lib/clacky/web/sessions.js +203 -14
- 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 -0
- metadata +3 -2
|
@@ -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)
|
|
@@ -481,6 +481,9 @@ module Clacky
|
|
|
481
481
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
482
482
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
483
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)
|
|
484
487
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/export$})
|
|
485
488
|
session_id = path.sub("/api/sessions/", "").sub("/export", "")
|
|
486
489
|
api_export_session(session_id, res)
|
|
@@ -496,6 +499,9 @@ module Clacky
|
|
|
496
499
|
elsif method == "PATCH" && path.match?(%r{^/api/sessions/[^/]+/reasoning_effort$})
|
|
497
500
|
session_id = path.sub("/api/sessions/", "").sub("/reasoning_effort", "")
|
|
498
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)
|
|
499
505
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/benchmark$})
|
|
500
506
|
session_id = path.sub("/api/sessions/", "").sub("/benchmark", "")
|
|
501
507
|
api_benchmark_session_models(session_id, req, res)
|
|
@@ -2445,7 +2451,56 @@ module Clacky
|
|
|
2445
2451
|
json_response(res, 200, { skills: skill_data })
|
|
2446
2452
|
end
|
|
2447
2453
|
|
|
2448
|
-
#
|
|
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
|
|
2449
2504
|
# Body: { enabled: true/false }
|
|
2450
2505
|
def api_toggle_skill(name, req, res)
|
|
2451
2506
|
body = parse_json_body(req)
|
|
@@ -3601,6 +3656,47 @@ module Clacky
|
|
|
3601
3656
|
json_response(res, 500, { error: e.message })
|
|
3602
3657
|
end
|
|
3603
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
|
+
|
|
3604
3700
|
# POST /api/sessions/:id/benchmark
|
|
3605
3701
|
#
|
|
3606
3702
|
# Speed-test every configured model in one shot so the user can pick the
|
|
@@ -3706,11 +3802,9 @@ module Clacky
|
|
|
3706
3802
|
|
|
3707
3803
|
# Expand ~ to home directory
|
|
3708
3804
|
expanded_dir = File.expand_path(new_dir)
|
|
3709
|
-
|
|
3710
|
-
#
|
|
3711
|
-
|
|
3712
|
-
return json_response(res, 400, { error: "Directory does not exist: #{expanded_dir}" })
|
|
3713
|
-
end
|
|
3805
|
+
|
|
3806
|
+
# Auto-create the directory if it doesn't exist yet.
|
|
3807
|
+
FileUtils.mkdir_p(expanded_dir)
|
|
3714
3808
|
|
|
3715
3809
|
agent = nil
|
|
3716
3810
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
@@ -4189,12 +4283,18 @@ module Clacky
|
|
|
4189
4283
|
broadcast(session_id, { type: "interrupted", session_id: session_id })
|
|
4190
4284
|
@session_manager.save(agent.to_session_data(status: :interrupted))
|
|
4191
4285
|
rescue => e
|
|
4192
|
-
@registry.update(session_id, status: :error, error: e.message)
|
|
4193
|
-
broadcast_session_update(session_id)
|
|
4194
4286
|
# Route error through web_ui so channel subscribers (飞书/企微) receive it too.
|
|
4195
4287
|
web_ui = nil
|
|
4196
4288
|
@registry.with_session(session_id) { |s| web_ui = s[:ui] }
|
|
4197
|
-
|
|
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)
|
|
4198
4298
|
@session_manager.save(agent.to_session_data(status: :error, error_message: e.message))
|
|
4199
4299
|
end
|
|
4200
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)
|
|
@@ -340,12 +340,6 @@ module Clacky
|
|
|
340
340
|
project_root: cwd || Dir.pwd
|
|
341
341
|
)
|
|
342
342
|
|
|
343
|
-
# WSL interop fix: Windows .exe processes inherit the PTY's stdin fd
|
|
344
|
-
# and attempt to use it as a Windows Console, causing them to hang
|
|
345
|
-
# indefinitely. Redirect stdin from /dev/null for any .exe invocation
|
|
346
|
-
# that doesn't already have an explicit stdin redirect.
|
|
347
|
-
safe_command = redirect_exe_stdin(safe_command)
|
|
348
|
-
|
|
349
343
|
# PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
|
|
350
344
|
# UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
|
|
351
345
|
safe_command = force_powershell_utf8(safe_command)
|
|
@@ -988,10 +982,31 @@ module Clacky
|
|
|
988
982
|
# exit codes are also swallowed so the *user* command's $? is what
|
|
989
983
|
# lands in `__clacky_ec`.
|
|
990
984
|
hooks_line = with_hooks ? hooks_prefix_for(session) : ""
|
|
991
|
-
|
|
985
|
+
# WSL interop fix: Windows .exe processes inherit the PTY slave fd
|
|
986
|
+
# as their stdin and treat it like a Windows Console — they sit
|
|
987
|
+
# there waiting for input nobody will ever send, hanging the whole
|
|
988
|
+
# session. Wrapping the user command's group with `</dev/null` gives
|
|
989
|
+
# every process inside it (including .exe interop children) an
|
|
990
|
+
# immediate EOF on stdin, so they exit cleanly.
|
|
991
|
+
#
|
|
992
|
+
# We only do this on WSL when the command actually mentions `.exe`,
|
|
993
|
+
# so Linux interactive commands like `read -p` / `python` REPL on
|
|
994
|
+
# non-WSL hosts (and on WSL when not invoking Windows binaries)
|
|
995
|
+
# keep their PTY stdin and continue to behave as before.
|
|
996
|
+
stdin_redirect = exe_needs_stdin_isolation?(command) ? " </dev/null" : ""
|
|
997
|
+
line = %Q|#{hooks_line}{ #{command}\n}#{stdin_redirect}; __clacky_ec=$?; printf "\n__CLACKY_DONE_#{token}_%s__\n" "$__clacky_ec"\n|
|
|
992
998
|
session.mutex.synchronize { session.writer.write(line) }
|
|
993
999
|
end
|
|
994
1000
|
|
|
1001
|
+
# True when the command should run with stdin redirected from
|
|
1002
|
+
# /dev/null. Currently only triggers on WSL when the command string
|
|
1003
|
+
# mentions a Windows `.exe` binary — see write_user_command for the
|
|
1004
|
+
# full rationale.
|
|
1005
|
+
private def exe_needs_stdin_isolation?(command)
|
|
1006
|
+
return false unless Clacky::Utils::EnvironmentDetector.wsl?
|
|
1007
|
+
command.to_s =~ /\.exe\b/i ? true : false
|
|
1008
|
+
end
|
|
1009
|
+
|
|
995
1010
|
# Build the "run hooks" prefix line. Empty string for shells where
|
|
996
1011
|
# we don't know how to introspect hook registries.
|
|
997
1012
|
private def hooks_prefix_for(session)
|
|
@@ -1508,25 +1523,6 @@ module Clacky
|
|
|
1508
1523
|
lines.last(DISPLAY_TAIL_LINES).join("\n")
|
|
1509
1524
|
end
|
|
1510
1525
|
|
|
1511
|
-
# WSL interop fix: Windows .exe processes inherit the PTY stdin fd
|
|
1512
|
-
# and try to use it as a Windows Console, which hangs indefinitely.
|
|
1513
|
-
# Detect .exe invocations and redirect stdin from /dev/null unless
|
|
1514
|
-
# the command already has an explicit stdin redirect.
|
|
1515
|
-
private def redirect_exe_stdin(command)
|
|
1516
|
-
return command unless Clacky::Utils::EnvironmentDetector.wsl?
|
|
1517
|
-
return command unless command =~ /\.exe\b/i
|
|
1518
|
-
return command if command =~ /<\s*[^\s|&;]/
|
|
1519
|
-
|
|
1520
|
-
# If the command has a shell-level pipe, insert </dev/null before
|
|
1521
|
-
# the first pipe so only the .exe segment gets its stdin redirected,
|
|
1522
|
-
# rather than starving a downstream pipe reader (e.g. `tr`, `grep`).
|
|
1523
|
-
if command =~ /\|/
|
|
1524
|
-
command.sub(/\s*\|/, ' </dev/null |')
|
|
1525
|
-
else
|
|
1526
|
-
"#{command} </dev/null"
|
|
1527
|
-
end
|
|
1528
|
-
end
|
|
1529
|
-
|
|
1530
1526
|
# PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
|
|
1531
1527
|
# to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
|
|
1532
1528
|
# `???`. Inject UTF-8 setup into the user's PowerShell command so the
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Clacky
|
|
|
26
26
|
# === Status messages ===
|
|
27
27
|
def show_info(message, prefix_newline: true); end
|
|
28
28
|
def show_warning(message); end
|
|
29
|
-
def show_error(message); end
|
|
29
|
+
def show_error(message, code: nil, top_up_url: nil); end
|
|
30
30
|
def show_success(message); end
|
|
31
31
|
def log(message, level: :info); end
|
|
32
32
|
|
data/lib/clacky/version.rb
CHANGED