openclacky 1.5.21 → 1.5.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/lib/clacky/billing/platform_billing.rb +3 -0
  4. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  5. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  6. data/lib/clacky/providers.rb +43 -17
  7. data/lib/clacky/search_config.rb +33 -11
  8. data/lib/clacky/server/git_panel.rb +360 -34
  9. data/lib/clacky/server/http_server.rb +145 -47
  10. data/lib/clacky/server/preview.rb +118 -17
  11. data/lib/clacky/server/scheduler.rb +81 -2
  12. data/lib/clacky/server/web_ui_controller.rb +35 -27
  13. data/lib/clacky/session_manager.rb +52 -5
  14. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  15. data/lib/clacky/utils/model_pricing.rb +60 -2
  16. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  17. data/lib/clacky/version.rb +1 -1
  18. data/lib/clacky/web/app.css +650 -96
  19. data/lib/clacky/web/app.js +59 -6
  20. data/lib/clacky/web/auth.js +1 -1
  21. data/lib/clacky/web/components/composer.js +107 -8
  22. data/lib/clacky/web/components/image-zoom.js +165 -0
  23. data/lib/clacky/web/components/model-picker.js +6 -4
  24. data/lib/clacky/web/components/notify.js +2 -2
  25. data/lib/clacky/web/core/aside.js +60 -3
  26. data/lib/clacky/web/features/billing/view.js +2 -2
  27. data/lib/clacky/web/features/channels/view.js +5 -2
  28. data/lib/clacky/web/features/extensions/view.js +2 -2
  29. data/lib/clacky/web/features/new-session/store.js +79 -2
  30. data/lib/clacky/web/features/new-session/view.js +496 -58
  31. data/lib/clacky/web/features/skills/store.js +1 -1
  32. data/lib/clacky/web/features/tasks/view.js +162 -51
  33. data/lib/clacky/web/features/workspace/store.js +13 -6
  34. data/lib/clacky/web/features/workspace/view.js +153 -163
  35. data/lib/clacky/web/i18n.js +238 -56
  36. data/lib/clacky/web/index.html +173 -157
  37. data/lib/clacky/web/projects.js +37 -4
  38. data/lib/clacky/web/sessions.js +181 -105
  39. data/lib/clacky/web/settings.js +26 -8
  40. data/lib/clacky/web/skills.js +2 -2
  41. data/lib/clacky/web/theme.js +17 -2
  42. data/lib/clacky/web/utils.js +17 -2
  43. data/lib/clacky/web/weixin-qr.html +2 -1
  44. data/lib/clacky/web/ws-dispatcher.js +8 -29
  45. metadata +2 -1
@@ -20,7 +20,7 @@ module Clacky
20
20
  @broadcaster.call(@session_id, { type: "input_queue", session_id: @session_id, entries: entries })
21
21
  end
22
22
 
23
- attr_reader :session_id
23
+ attr_reader :session_id, :last_complete
24
24
 
25
25
  def initialize(session_id, broadcaster)
26
26
  @session_id = session_id
@@ -70,23 +70,24 @@ module Clacky
70
70
 
71
71
  # Use the same contract as the CLI. The server owns the deadline;
72
72
  # typing cancels automatic continuation but keeps the answer wait alive.
73
+ # Leaving auto mode ends the countdown like an intervention, so the task
74
+ # finishes as awaiting_feedback instead of blocking while marked running.
73
75
  def request_feedback_with_countdown(seconds: 20)
74
76
  pending = { id: @feedback_request_id || SecureRandom.uuid, cond: ConditionVariable.new,
75
77
  deadline_ms: (Time.now.to_f * 1000).to_i + seconds * 1000 }
76
- pending[:seconds] = seconds
77
78
  pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
78
79
  @mutex.synchronize do
79
- pending[:manual] = @feedback_permission_mode == :confirm_all
80
+ return :intervened if @feedback_permission_mode == :confirm_all
80
81
  @pending_feedback = pending
81
82
  end
82
- emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms])
83
+ emit("feedback_countdown", request_id: pending[:id], deadline_ms: pending[:deadline_ms])
83
84
  @mutex.synchronize do
84
85
  until pending[:result]
85
86
  remaining = pending[:deadline] - Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
- if !pending[:manual] && remaining <= 0
87
+ if remaining <= 0
87
88
  pending[:result] = :timeout
88
89
  else
89
- pending[:cond].wait(@mutex, pending[:manual] ? nil : remaining)
90
+ pending[:cond].wait(@mutex, remaining)
90
91
  end
91
92
  end
92
93
  pending[:result]
@@ -105,21 +106,14 @@ module Clacky
105
106
  end
106
107
 
107
108
  def update_permission_mode(mode)
108
- countdown = @mutex.synchronize do
109
+ @mutex.synchronize do
109
110
  @feedback_permission_mode = mode.to_sym
110
111
  pending = @pending_feedback
111
- if pending && !pending[:result]
112
- if mode.to_sym == :auto_approve && pending[:manual]
113
- pending[:deadline] = Process.clock_gettime(Process::CLOCK_MONOTONIC) + pending[:seconds]
114
- pending[:deadline_ms] = (Time.now.to_f * 1000).to_i + pending[:seconds] * 1000
115
- end
116
- pending[:manual] = mode.to_sym != :auto_approve
112
+ if pending && !pending[:result] && mode.to_sym != :auto_approve
113
+ pending[:result] = :intervened
117
114
  pending[:cond].signal
118
- { request_id: pending[:id],
119
- deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] }
120
115
  end
121
116
  end
122
- emit("feedback_countdown", **countdown) if countdown
123
117
  end
124
118
 
125
119
  def intervene_feedback(request_id)
@@ -142,24 +136,37 @@ module Clacky
142
136
  data[:skill_command] = skill_command if skill_command
143
137
  data[:skill_command_display] = skill_command_display if skill_command_display
144
138
  data[:references] = Array(references) unless Array(references).empty?
145
- # Build ev.images for the frontend renderer (history_user_message):
146
- # - Images with data_url → pass the data_url directly (<img> thumbnail)
147
- # - Disk image files (type=="image", has path) → /api/local-image proxy URL
148
- # - Disk files (PDF, doc, etc., no data_url, no image path) → "pdf:name" sentinel
149
- rendered = Array(files).filter_map do |f|
139
+ # Images keep using the lightbox renderer. Other attachments carry their
140
+ # path separately so the browser can open them through the Files viewer.
141
+ # A missing path is deliberately omitted: the card remains visible but
142
+ # is no longer presented as an actionable control.
143
+ rendered_images = []
144
+ rendered_attachments = []
145
+ Array(files).each do |f|
146
+ next if f[:reference] || f["reference"]
147
+
150
148
  url = f[:data_url] || f["data_url"]
151
149
  name = f[:name] || f["name"]
152
150
  type = f[:type] || f["type"]
153
151
  path = f[:path] || f["path"]
152
+ mime = f[:mime_type] || f["mime_type"]
153
+ image = type.to_s == "image" || mime.to_s.start_with?("image/")
154
+
154
155
  if url
155
- url
156
- elsif type.to_s == "image" && path && File.exist?(path.to_s)
157
- "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
156
+ rendered_images << url
157
+ elsif image && path && File.exist?(path.to_s)
158
+ rendered_images << "/api/local-image?path=#{CGI.escape(path.to_s)}&v=#{File.mtime(path.to_s).to_i}"
159
+ elsif image && name
160
+ rendered_images << "expired:#{name}"
158
161
  elsif name
159
- "pdf:#{name}"
162
+ attachment_type = type.to_s.empty? ? (mime || "file") : type
163
+ attachment = { name: name, type: attachment_type }
164
+ attachment[:path] = path if path && File.exist?(path.to_s)
165
+ rendered_attachments << attachment
160
166
  end
161
167
  end
162
- data[:images] = rendered unless rendered.empty?
168
+ data[:images] = rendered_images unless rendered_images.empty?
169
+ data[:attachments] = rendered_attachments unless rendered_attachments.empty?
163
170
  emit("history_user_message", **data)
164
171
  # Only forward to channel subscribers when the message originated from the WebUI,
165
172
  # to avoid echoing channel messages back to the same channel.
@@ -270,6 +277,7 @@ module Clacky
270
277
  data[:cache_stats] = cache_stats if cache_stats
271
278
  data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
272
279
  data[:cost_source] = cost_source.to_s if cost_source
280
+ @last_complete = data
273
281
  emit("complete", **data)
274
282
  forward_to_subscribers do |sub|
275
283
  sub.show_complete(iterations: iterations, cost: cost, duration: duration,
@@ -405,7 +413,7 @@ module Clacky
405
413
  def replay_live_state
406
414
  countdown = @mutex.synchronize do
407
415
  pending = @pending_feedback
408
- pending ? { request_id: pending[:id], deadline_ms: pending[:manual] ? nil : pending[:deadline_ms] } : { resolved: true }
416
+ pending ? { request_id: pending[:id], deadline_ms: pending[:deadline_ms] } : { resolved: true }
409
417
  end
410
418
  emit("feedback_countdown", **countdown)
411
419
  return unless @live_progress_state
@@ -20,8 +20,16 @@ module Clacky
20
20
  SecureRandom.hex(8)
21
21
  end
22
22
 
23
+ # How long a parsed session list is shared between callers, in seconds.
24
+ # Only meant to cover the burst of requests the first paint fires; see
25
+ # #load_all_sessions.
26
+ LIST_REUSE_WINDOW = 2.0
27
+
23
28
  def initialize(sessions_dir: nil)
24
29
  @sessions_dir = sessions_dir || SESSIONS_DIR
30
+ @recent_list = nil
31
+ @recent_list_at = 0.0
32
+ @list_mutex = Mutex.new
25
33
  ensure_sessions_dir
26
34
  end
27
35
 
@@ -34,6 +42,7 @@ module Clacky
34
42
  FileUtils.chmod(0o600, filepath)
35
43
 
36
44
  @last_saved_path = filepath
45
+ @recent_list_at = 0.0
37
46
 
38
47
  run_cleanup_async
39
48
 
@@ -213,10 +222,12 @@ module Clacky
213
222
  # Optional filters:
214
223
  # current_dir: (String) if given, sessions matching working_dir come first
215
224
  # limit: (Integer) max number of sessions to return
225
+ #
226
+ # Parsing every session file costs ~160ms for a few hundred sessions, and the
227
+ # first paint asks for this list from several endpoints at once, so the
228
+ # concurrent requests share a single scan (see #load_all_sessions).
216
229
  def all_sessions(current_dir: nil, limit: nil)
217
- sessions = Dir.glob(File.join(@sessions_dir, "*.json")).filter_map do |filepath|
218
- load_session_file(filepath)
219
- end.sort_by { |s| [s[:updated_at] || s[:created_at] || "", s[:session_id].to_s] }.reverse
230
+ sessions = load_all_sessions
220
231
 
221
232
  if current_dir
222
233
  current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
@@ -227,6 +238,36 @@ module Clacky
227
238
  limit ? sessions.first(limit) : sessions
228
239
  end
229
240
 
241
+ # Reuse a scan for a short window: the first paint fires several list
242
+ # requests at once and, without sharing, each one re-parses every session
243
+ # file and they queue up on the GVL. A snapshot older than the window is
244
+ # dropped before the next scan, so this does not grow into a long-lived cache.
245
+ private def load_all_sessions
246
+ if @recent_list && monotonic_now - @recent_list_at < LIST_REUSE_WINDOW
247
+ return @recent_list.dup
248
+ end
249
+
250
+ @list_mutex.synchronize do
251
+ now = monotonic_now
252
+ return @recent_list.dup if @recent_list && now - @recent_list_at < LIST_REUSE_WINDOW
253
+
254
+ # Release the old snapshot before scanning so its parsed messages can be
255
+ # collected instead of being held alongside the new ones.
256
+ @recent_list = nil
257
+ @recent_list = Dir.glob(File.join(@sessions_dir, "*.json"))
258
+ .filter_map { |filepath| load_session_file(filepath) }
259
+ .sort_by { |s| [s[:updated_at] || s[:created_at] || "", s[:session_id].to_s] }
260
+ .reverse
261
+ @recent_list_at = now
262
+ # Callers get their own array so a sort!/push cannot reorder the shared one.
263
+ @recent_list.dup
264
+ end
265
+ end
266
+
267
+ private def monotonic_now
268
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
269
+ end
270
+
230
271
  # Full-text grep over session JSON + chunk MD files.
231
272
  # Case-sensitive: BSD grep -i is ~30x slower; Chinese has no case.
232
273
  # Returns Hash<short_id String => snippet String> (snippet around the first match).
@@ -415,13 +456,19 @@ module Clacky
415
456
  # Soft-delete: stamp deleted_at, move JSON + chunks to sessions-trash/.
416
457
  def soft_delete(session_id)
417
458
  require_relative "tools/trash_manager"
418
- Clacky::Tools::TrashManager.soft_delete_session(session_id, sessions_dir: @sessions_dir)
459
+ deleted = Clacky::Tools::TrashManager.soft_delete_session(session_id, sessions_dir: @sessions_dir)
460
+ # The file is moved outside this object's save path, so drop the shared
461
+ # list explicitly instead of waiting for the reuse window to lapse.
462
+ @recent_list_at = 0.0 if deleted
463
+ deleted
419
464
  end
420
465
 
421
466
  # Restore a soft-deleted session back to the active sessions directory.
422
467
  def restore_session(session_id)
423
468
  require_relative "tools/trash_manager"
424
- Clacky::Tools::TrashManager.restore_session(session_id, sessions_dir: @sessions_dir)
469
+ restored = Clacky::Tools::TrashManager.restore_session(session_id, sessions_dir: @sessions_dir)
470
+ @recent_list_at = 0.0 if restored
471
+ restored
425
472
  end
426
473
 
427
474
  # List all soft-deleted sessions (newest-first).
@@ -59,16 +59,17 @@ module Clacky
59
59
  return nil unless Utils::EnvironmentDetector.os_type == :macos
60
60
 
61
61
  ext = File.extname(path).downcase.sub(/\A\./, "")
62
- return nil if ext.empty?
63
-
62
+ # LaunchServices answers for any URL, extension or not, so a Dockerfile
63
+ # resolves like any other file. Only extension answers are cacheable.
64
64
  @default_app_cache ||= {}
65
- return @default_app_cache[ext] if @default_app_cache.key?(ext)
65
+ return @default_app_cache[ext] if !ext.empty? && @default_app_cache.key?(ext)
66
66
 
67
67
  bundle = default_app_bundle(path)
68
- @default_app_cache[ext] =
69
- if bundle && File.directory?(bundle)
70
- { "name" => File.basename(bundle, ".app"), "path" => bundle }
71
- end
68
+ app = if bundle && File.directory?(bundle)
69
+ { "name" => File.basename(bundle, ".app"), "path" => bundle }
70
+ end
71
+ @default_app_cache[ext] = app unless ext.empty?
72
+ app
72
73
  end
73
74
 
74
75
  # Query LaunchServices for the default handler of `path` via NSWorkspace.
@@ -389,6 +389,38 @@ module Clacky
389
389
  }
390
390
  },
391
391
 
392
+ # Kimi K3.1 Pro (1M context, native vision, tool calling).
393
+ # Source: https://platform.kimi.ai (USD / 1M tokens)
394
+ "kimi-k3.1-pro" => {
395
+ input: {
396
+ default: 2.00, # $2.00/MTok cache miss
397
+ over_200k: 2.00
398
+ },
399
+ output: {
400
+ default: 10.00, # $10.00/MTok
401
+ over_200k: 10.00
402
+ },
403
+ cache: {
404
+ write: 2.00, # bill at miss rate (= 5m write price)
405
+ read: 0.20 # $0.20/MTok cache hit
406
+ }
407
+ },
408
+
409
+ "kimi-k3.1-pro-highspeed" => {
410
+ input: {
411
+ default: 4.00, # $4.00/MTok cache miss
412
+ over_200k: 4.00
413
+ },
414
+ output: {
415
+ default: 20.00, # $20.00/MTok
416
+ over_200k: 20.00
417
+ },
418
+ cache: {
419
+ write: 4.00, # bill at miss rate (= 5m write price)
420
+ read: 0.20 # $0.20/MTok cache hit
421
+ }
422
+ },
423
+
392
424
  # Kimi K3 flagship model (1M context, native vision, tool calling).
393
425
  # Source: https://platform.moonshot.ai (USD / 1M tokens)
394
426
  "kimi-k3" => {
@@ -597,6 +629,27 @@ module Clacky
597
629
  output: { default: 2.22, over_200k: 2.22 },
598
630
  cache: { write: 0.45, read: 0.09 }
599
631
  },
632
+ "doubao-seed-2.1-lite" => {
633
+ input: { default: 0.12, over_200k: 0.12 },
634
+ output: { default: 0.40, over_200k: 0.40 },
635
+ cache: { write: 0.12, read: 0.03 }
636
+ },
637
+ # Ark's peak window (Mon-Fri 09-12/14-18 Beijing) equals DeepSeek's, so
638
+ # the economy tier reuses the deepseek peak/off-peak resolution. Cache
639
+ # reads keep three decimals: rounding up to the cent would double them.
640
+ "deepseek-v4.1-flash-economy" => {
641
+ deepseek: true,
642
+ peak: {
643
+ input: { default: 0.24, over_200k: 0.24 },
644
+ output: { default: 0.95, over_200k: 0.95 },
645
+ cache: { write: 0.24, read: 0.005 }
646
+ },
647
+ off_peak: {
648
+ input: { default: 0.12, over_200k: 0.12 },
649
+ output: { default: 0.48, over_200k: 0.48 },
650
+ cache: { write: 0.12, read: 0.003 }
651
+ }
652
+ },
600
653
  "doubao-seed-2.0-lite" => {
601
654
  input: { default: 0.14, over_200k: 0.27 },
602
655
  output: { default: 0.80, over_200k: 1.60 },
@@ -1214,7 +1267,7 @@ module Clacky
1214
1267
  # served through a third party.
1215
1268
  # The gateway mirrors this list - see seriesDiscount in llm_proxy.
1216
1269
  DISCOUNT_RULES = [
1217
- { match: /\Aabs-claude-/, rate: 0.6, reason: "channel" }, # Claude via ciyuan cc-max - 40% off
1270
+ { match: /\Aabs-claude-/, rate: 0.45, reason: "channel" }, # Claude via ciyuan cc-max - 55% off
1218
1271
  { match: /\Aoc-glm-/, rate: 0.95, reason: "channel" }, # GLM via ciyuan glm - 5% off
1219
1272
  { match: /\Aoc-kimi-/, rate: 0.9, reason: "channel" }, # Kimi K3 via ciyuan - 10% off
1220
1273
  { match: /\Aoc-grok-/, rate: 0.3, reason: "channel" }, # Grok 4.7 via ciyuan grok - 70% off
@@ -1429,6 +1482,11 @@ module Clacky
1429
1482
  "claude-3-5-haiku-20241022"
1430
1483
  when /deepseek-v4-pro/i, /deepseek.*v4.*pro/i
1431
1484
  "deepseek-v4-pro"
1485
+ # V4.1 Flash — canonical generation id; natively multimodal. Must be
1486
+ # matched BEFORE the v4-flash rules below, whose `.*v4.*flash` tails
1487
+ # would otherwise catch "v4.1-flash".
1488
+ when /deepseek-v4[.-]1-flash/i
1489
+ "deepseek-flash"
1432
1490
  # Vision variant must be matched BEFORE the v4-flash rule below —
1433
1491
  # "deepseek-v4-flash-vision-exp" would otherwise be substring-matched
1434
1492
  # to v4-flash and billed at the wrong model's rate.
@@ -1436,7 +1494,7 @@ module Clacky
1436
1494
  "deepseek-v4-flash-vision-exp"
1437
1495
  when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
1438
1496
  "deepseek-v4-flash"
1439
- # V4.1 Flash — new canonical id; native multimodal.
1497
+ # Bare upstream id for V4.1 Flash.
1440
1498
  when /deepseek-flash/i
1441
1499
  "deepseek-flash"
1442
1500
  # Legacy aliases: deepseek-chat and deepseek-reasoner are being
@@ -219,7 +219,9 @@ module Clacky
219
219
  return nil unless wsl?
220
220
 
221
221
  ext = normalize_ext(File.extname(path))
222
- return nil if ext.empty?
222
+ # Windows has no extension to look up for a Dockerfile or Makefile;
223
+ # ask what handles plain text instead of reporting nothing.
224
+ ext = "txt" if ext.empty?
223
225
 
224
226
  data = detect_for_ext(ext)
225
227
  data ? data["default"] : nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.21"
4
+ VERSION = "1.5.22"
5
5
  end