openclacky 1.1.0 → 1.1.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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +28 -7
  4. data/lib/clacky/agent/llm_caller.rb +23 -1
  5. data/lib/clacky/agent/session_serializer.rb +6 -1
  6. data/lib/clacky/agent/skill_manager.rb +18 -5
  7. data/lib/clacky/agent.rb +14 -5
  8. data/lib/clacky/anthropic_stream_aggregator.rb +135 -0
  9. data/lib/clacky/bedrock_stream_aggregator.rb +137 -0
  10. data/lib/clacky/brand_config.rb +68 -15
  11. data/lib/clacky/cli.rb +18 -19
  12. data/lib/clacky/client.rb +146 -17
  13. data/lib/clacky/default_skills/onboard/SKILL.md +6 -2
  14. data/lib/clacky/default_skills/onboard/scripts/import_external_skills.rb +50 -6
  15. data/lib/clacky/openai_stream_aggregator.rb +130 -0
  16. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +169 -6
  17. data/lib/clacky/server/channel/channel_ui_controller.rb +6 -0
  18. data/lib/clacky/server/http_server.rb +9 -3
  19. data/lib/clacky/server/web_ui_controller.rb +8 -4
  20. data/lib/clacky/tools/terminal.rb +11 -0
  21. data/lib/clacky/ui2/components/input_area.rb +10 -1
  22. data/lib/clacky/ui2/components/todo_area.rb +22 -2
  23. data/lib/clacky/ui2/layout_manager.rb +70 -14
  24. data/lib/clacky/ui2/progress_handle.rb +86 -15
  25. data/lib/clacky/ui2/ui_controller.rb +47 -7
  26. data/lib/clacky/utils/logger.rb +7 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +6 -4
  29. data/lib/clacky/web/i18n.js +21 -6
  30. data/lib/clacky/web/index.html +8 -6
  31. data/lib/clacky/web/sessions.js +171 -58
  32. data/lib/clacky/web/vendor/katex/auto-render.min.js +1 -0
  33. data/lib/clacky/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  34. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  35. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  36. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  37. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  38. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  39. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  40. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  41. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  42. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  43. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  45. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  46. data/lib/clacky/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  47. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  48. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  49. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  50. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  51. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  52. data/lib/clacky/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  53. data/lib/clacky/web/vendor/katex/katex.min.css +1 -0
  54. data/lib/clacky/web/vendor/katex/katex.min.js +1 -0
  55. data/lib/clacky/web/ws-dispatcher.js +19 -4
  56. data/lib/clacky.rb +3 -0
  57. data/scripts/build/src/install.sh.cc +15 -5
  58. data/scripts/install.ps1 +14 -3
  59. data/scripts/install.sh +15 -5
  60. metadata +28 -2
@@ -71,7 +71,7 @@ module Clacky
71
71
  class ProgressHandle
72
72
  # Default tick interval (seconds). Matches the old global spinner
73
73
  # cadence. Tests may pass a smaller interval for speed.
74
- DEFAULT_TICK_INTERVAL = 0.5
74
+ DEFAULT_TICK_INTERVAL = 0.25
75
75
 
76
76
  # Style hint for the renderer. The owner decides what colors to use;
77
77
  # the handle only forwards the hint as part of the frame metadata
@@ -93,6 +93,12 @@ module Clacky
93
93
  # frame would be visual noise.
94
94
  FAST_FINISH_THRESHOLD_SECONDS = 2
95
95
 
96
+ # Show "Thinking for Ns" once the gap since the last LLM stream
97
+ # chunk reaches this many seconds. Bedrock often pauses 5–18s
98
+ # while generating large content blocks (long tool_use JSON in
99
+ # particular); without this hint users assume the agent is stuck.
100
+ IDLE_HINT_THRESHOLD_SECONDS = 2
101
+
96
102
  # @param owner [#register_progress, #unregister_progress, #render_frame]
97
103
  # @param message [String] Initial progress message.
98
104
  # @param style [Symbol] :primary or :quiet (see VALID_STYLES).
@@ -122,6 +128,7 @@ module Clacky
122
128
  @ticker = nil
123
129
  @state = :fresh # :fresh → :running → :closed
124
130
  @metadata = {}
131
+ @last_chunk_at = nil
125
132
  @monitor = Monitor.new
126
133
  end
127
134
 
@@ -133,9 +140,10 @@ module Clacky
133
140
  @monitor.synchronize do
134
141
  return self unless @state == :fresh
135
142
 
136
- @state = :running
137
- @start_time = @clock.call
138
- @entry_id = @owner.register_progress(self)
143
+ @state = :running
144
+ @start_time = @clock.call
145
+ @last_chunk_at = @start_time
146
+ @entry_id = @owner.register_progress(self)
139
147
  end
140
148
 
141
149
  # Fire one initial frame synchronously so the user sees the
@@ -156,9 +164,11 @@ module Clacky
156
164
  @monitor.synchronize do
157
165
  return if @state != :running
158
166
  @message = message.to_s if message
159
- @metadata = metadata if metadata
167
+ if metadata
168
+ @metadata = metadata
169
+ @last_chunk_at = @clock.call
170
+ end
160
171
  end
161
- render_now
162
172
  end
163
173
 
164
174
  # Stop the ticker, render one final frame, and unregister from the
@@ -203,7 +213,7 @@ module Clacky
203
213
  # +render_frame+ and is responsible for writing it into the entry.
204
214
  def current_frame
205
215
  @monitor.synchronize do
206
- compose_frame(@message, elapsed_seconds, @metadata)
216
+ compose_frame(@message, elapsed_seconds, @metadata, idle_seconds)
207
217
  end
208
218
  end
209
219
 
@@ -225,6 +235,15 @@ module Clacky
225
235
  render_now
226
236
  end
227
237
 
238
+ # Like __reattach_entry! but skips the render_now hop. Used by the
239
+ # owner when it has just painted a frame into the new entry itself
240
+ # (e.g. while rotating the handle to remain at the buffer tail) and
241
+ # is still inside its own synchronization — calling render_now there
242
+ # would re-enter the owner's mutex.
243
+ def __rebind_entry!(new_entry_id)
244
+ @monitor.synchronize { @entry_id = new_entry_id }
245
+ end
246
+
228
247
  # Test hook: force a synchronous render regardless of tick cadence.
229
248
  def __force_render!
230
249
  render_now
@@ -269,16 +288,68 @@ module Clacky
269
288
  (@clock.call - @start_time).to_i
270
289
  end
271
290
 
272
- # Live-frame format: "<message>… (<elapsed>s)"
273
- # Metadata like { attempt:, total: } is appended as "[i/N]" when
274
- # present, to keep renderer-agnostic callers (e.g. tests) readable.
275
- private def compose_frame(message, elapsed, metadata)
276
- parts = [message.to_s]
291
+ # Seconds since the last metadata update (i.e. the last LLM stream
292
+ # chunk that carried token info). Used to surface "Thinking for Ns"
293
+ # in the live frame so users can see the agent isn't stuck even
294
+ # when token counts plateau during long Bedrock content blocks.
295
+ private def idle_seconds
296
+ return 0 unless @last_chunk_at
297
+ (@clock.call - @last_chunk_at).to_i
298
+ end
299
+
300
+ # Live-frame format:
301
+ # "<message>… (<elapsed>s · ↓N tokens · reasoning…)"
302
+ # The "reasoning" tail only appears once tokens have started
303
+ # streaming AND the gap since the last chunk reaches the threshold
304
+ # — signalling the model is between tool_use blocks doing extended
305
+ # thinking. No seconds shown there to avoid duplicating elapsed;
306
+ # animated dots (1→2→3) provide the "still alive" cue.
307
+ private def compose_frame(message, elapsed, metadata, idle = 0)
308
+ head = message.to_s
277
309
  if metadata && (attempt = metadata[:attempt]) && (total = metadata[:total])
278
- parts << "[#{attempt}/#{total}]"
310
+ head = "#{head} [#{attempt}/#{total}]"
311
+ end
312
+
313
+ token_part = metadata && format_token_progress(metadata)
314
+
315
+ suffix_parts = []
316
+ suffix_parts << "#{elapsed}s" if elapsed > 0
317
+ suffix_parts << token_part if token_part
318
+ if token_part && idle >= IDLE_HINT_THRESHOLD_SECONDS
319
+ suffix_parts << "reasoning #{spinner_frame} "
320
+ end
321
+
322
+ return "#{head}…" if suffix_parts.empty?
323
+ "#{head}… (#{suffix_parts.join(" · ")})"
324
+ end
325
+
326
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
327
+ SPINNER_INTERVAL_MS = 250
328
+
329
+ private def spinner_frame
330
+ ms = (@clock.call.to_f * 1000).to_i
331
+ SPINNER_FRAMES[(ms / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length]
332
+ end
333
+
334
+ # Render LLM streaming token counts as "↑1.2k ↓234 tokens".
335
+ # When input_tokens is unknown (e.g. OpenAI-compat streaming where
336
+ # prompt_tokens only arrives in the final frame), shows "↑—" so the
337
+ # column doesn't flicker between absent / present.
338
+ private def format_token_progress(metadata)
339
+ output = metadata[:output_tokens]
340
+ return nil if output.nil? || output.to_i <= 0
341
+ "↓ #{compact_count(output.to_i)} tokens"
342
+ end
343
+
344
+ private def compact_count(n)
345
+ return n.to_s if n < 1000
346
+ if n < 1_000_000
347
+ k = n / 1000.0
348
+ k >= 10 ? "#{k.to_i}k" : "%.1fk" % k
349
+ else
350
+ m = n / 1_000_000.0
351
+ m >= 10 ? "#{m.to_i}M" : "%.1fM" % m
279
352
  end
280
- head = parts.join(" ")
281
- elapsed > 0 ? "#{head}… (#{elapsed}s)" : "#{head}…"
282
353
  end
283
354
 
284
355
  # Final frame (used by +finish+). Same as +compose_frame+ but we
@@ -49,6 +49,7 @@ module Clacky
49
49
  @time_machine_callback = nil
50
50
  @tasks_count = 0
51
51
  @total_cost = 0.0
52
+ @session_id = nil
52
53
  @last_diff_lines = nil
53
54
 
54
55
  # ── Progress subsystem (v2: owned handles, stacked) ──────────────
@@ -73,6 +74,7 @@ module Clacky
73
74
 
74
75
  # Set session bar data before initializing screen
75
76
  @input_area.update_sessionbar(
77
+ session_id: @session_id,
76
78
  working_dir: @config[:working_dir],
77
79
  mode: @config[:mode],
78
80
  model: @config[:model],
@@ -109,10 +111,13 @@ module Clacky
109
111
  # @param cost_source [Symbol, nil] :api / :price / :default (optional)
110
112
  # @param status [String] Workspace status ('idle' or 'working') (optional)
111
113
  # @param latency [Hash, nil] Latency metrics; accepted but not displayed in the TUI.
112
- def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil)
114
+ # @param session_id [String, nil] Full session id; rendered as first 8 chars (parity with WebUI).
115
+ def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
113
116
  @tasks_count = tasks if tasks
114
117
  @total_cost = cost if cost
118
+ @session_id = session_id if session_id
115
119
  @input_area.update_sessionbar(
120
+ session_id: @session_id,
116
121
  working_dir: @config[:working_dir],
117
122
  mode: @config[:mode],
118
123
  model: @config[:model],
@@ -193,9 +198,35 @@ module Clacky
193
198
  @input_area.set_agent(agent, agent_profile)
194
199
  end
195
200
 
196
- # Append output to the output area
197
- # @param content [String] Content to append
201
+ # Append output to the output area.
202
+ #
203
+ # If a progress indicator is currently active (somewhere in the
204
+ # buffer), rotate it to the tail after the append: business content
205
+ # ends up above, the spinner stays at the bottom. Without this,
206
+ # every subsequent ticker tick on a non-tail progress entry would
207
+ # trigger a full output repaint (visible flicker) and the visual
208
+ # order would have business messages appearing below the spinner.
198
209
  def append_output(content)
210
+ @progress_mutex.synchronize do
211
+ top = @progress_stack.last
212
+ if top && top.entry_id
213
+ @layout.remove_entry(top.entry_id)
214
+ top.__detach_entry!
215
+ new_id = @layout.append_output(content)
216
+ progress_id = @layout.append_output(render_for(top))
217
+ top.__rebind_entry!(progress_id)
218
+ new_id
219
+ else
220
+ @layout.append_output(content)
221
+ end
222
+ end
223
+ end
224
+
225
+ # Internal append that bypasses the progress-rotation logic and the
226
+ # @progress_mutex. Used by register_progress / unregister_progress,
227
+ # which already hold the mutex and are themselves placing a fresh
228
+ # progress entry at the tail.
229
+ private def append_output_unlocked(content)
199
230
  @layout.append_output(content)
200
231
  end
201
232
 
@@ -481,6 +512,8 @@ module Clacky
481
512
 
482
513
  # Clear user tip when agent stops working
483
514
  @input_area.clear_user_tip
515
+ # Hide todo area while idle (data preserved, restored on next work)
516
+ @layout.hide_todos
484
517
  @layout.render_input
485
518
 
486
519
  # Don't show completion message if awaiting user feedback
@@ -597,7 +630,7 @@ module Clacky
597
630
  end
598
631
 
599
632
  @progress_stack.push(handle)
600
- entry_id = append_output(render_for(handle))
633
+ entry_id = append_output_unlocked(render_for(handle))
601
634
  recompute_sessionbar_status
602
635
  entry_id
603
636
  end
@@ -623,7 +656,7 @@ module Clacky
623
656
  # Restore the new top, if any: allocate a fresh entry and let it
624
657
  # resume rendering from where it left off.
625
658
  if (restored = @progress_stack.last)
626
- new_id = append_output(render_for(restored))
659
+ new_id = append_output_unlocked(render_for(restored))
627
660
  restored.__reattach_entry!(new_id)
628
661
  end
629
662
 
@@ -826,6 +859,8 @@ module Clacky
826
859
  @last_sessionbar_status = 'idle'
827
860
  # Clear user tip when agent stops working
828
861
  @input_area.clear_user_tip
862
+ # Hide todo area while idle (data preserved, restored on next work)
863
+ @layout.hide_todos
829
864
  @layout.render_input
830
865
  end
831
866
 
@@ -848,6 +883,8 @@ module Clacky
848
883
  # Set workspace status to working (called when agent starts working)
849
884
  def set_working_status
850
885
  update_sessionbar(status: 'working')
886
+ # Restore todo area if it was hidden during idle
887
+ @layout.show_todos
851
888
  # Show a random user tip with 40% probability when agent starts working
852
889
  @input_area.show_user_tip(probability: 0.4)
853
890
  @layout.render_input
@@ -1281,6 +1318,7 @@ module Clacky
1281
1318
 
1282
1319
  # Update session bar data (will be rendered by request_confirmation's render_all)
1283
1320
  @input_area.update_sessionbar(
1321
+ session_id: @session_id,
1284
1322
  working_dir: @config[:working_dir],
1285
1323
  mode: @config[:mode],
1286
1324
  model: @config[:model],
@@ -1346,8 +1384,10 @@ module Clacky
1346
1384
  # Add action buttons
1347
1385
  choices << { name: "─" * 50, disabled: true }
1348
1386
  choices << { name: "[+] Add New Model", value: { action: :add } }
1349
- choices << { name: "[*] Edit Current Model", value: { action: :edit } }
1350
- choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1387
+ if current_config.models.length > 0
1388
+ choices << { name: "[*] Edit Current Model", value: { action: :edit } }
1389
+ choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1390
+ end
1351
1391
  choices << { name: "[X] Close", value: { action: :close } }
1352
1392
 
1353
1393
  # Show menu
@@ -35,6 +35,13 @@ module Clacky
35
35
  @console ||= false
36
36
  end
37
37
 
38
+ # Path of the log file currently being written to (today's file).
39
+ # File may not exist yet if no log has been emitted today — callers
40
+ # should check File.exist? before reading.
41
+ def current_log_file
42
+ log_file_path(Time.now)
43
+ end
44
+
38
45
  # Log at DEBUG level.
39
46
  def debug(message, **context)
40
47
  write_log(:debug, message, context)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.2"
5
5
  end
@@ -943,9 +943,9 @@ body {
943
943
  align-self: center;
944
944
  }
945
945
  .session-item:hover .session-actions-btn { display: flex; }
946
- .session-actions-btn:hover {
947
- background: var(--color-border-primary);
948
- color: var(--color-text-primary);
946
+ .session-actions-btn:hover {
947
+ background: var(--color-border-primary);
948
+ color: var(--color-text-primary);
949
949
  }
950
950
 
951
951
  /* Pin icon in session name */
@@ -1808,6 +1808,8 @@ body {
1808
1808
  .msg-assistant em { font-style: italic; color: var(--color-text-secondary); }
1809
1809
  .msg-tool { background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); font-family: monospace; font-size: 12px; color: var(--color-text-secondary); align-self: flex-start; }
1810
1810
  .msg-info { color: var(--color-text-secondary); font-size: 12px; align-self: center; font-style: italic; }
1811
+ .msg-info.msg-info-main { font-style: normal; }
1812
+ .msg-info-sub { color: var(--color-text-secondary); font-size: 11px; align-self: center; opacity: 0.7; margin-top: -27px; }
1811
1813
 
1812
1814
  /* ── Feedback request card ──────────────────────────────────────────────── */
1813
1815
  .feedback-card {
@@ -1926,7 +1928,7 @@ body {
1926
1928
  }
1927
1929
  .msg-success { color: var(--color-success); align-self: flex-start; font-size: 13px; }
1928
1930
  .tool-name { color: var(--color-warning); font-weight: 600; }
1929
- .progress-msg { color: var(--color-accent-primary); font-size: 12px; align-self: center; animation: pulse 1.2s infinite; }
1931
+ .progress-msg { color: var(--color-accent-primary); font-size: 12px; align-self: center; }
1930
1932
 
1931
1933
  /* ── Token usage line ────────────────────────────────────────────────────── */
1932
1934
  .token-usage-line {
@@ -37,6 +37,7 @@ const I18n = (() => {
37
37
  "chat.status.running": "running",
38
38
  "chat.status.error": "error",
39
39
  "chat.input.placeholder": "Message… (Enter to send, Shift+Enter for newline)",
40
+ "chat.input.placeholderRunning": "AI is working — you can still send extra info anytime...",
40
41
  "chat.btn.send": "Send",
41
42
  "chat.thinking": "Thinking…",
42
43
  "chat.retrying": "Retrying",
@@ -44,6 +45,8 @@ const I18n = (() => {
44
45
  "chat.history_start": "No more history",
45
46
  "chat.image_expired": "Expired",
46
47
  "chat.done": "Done — {{n}} iteration(s), {{cost}}",
48
+ "chat.done.duration": " · {{duration}}s",
49
+ "chat.done.cache": "Cache hit {{rate}}% ({{hits}}/{{total}}) · {{tokens}} tokens reused",
47
50
  "chat.interrupted": "Interrupted.",
48
51
  "chat.feedback_hint": "Or type your own answer below ↓",
49
52
  "chat.newMessageHint": "New messages ↓",
@@ -66,11 +69,15 @@ const I18n = (() => {
66
69
  "sessions.deleteTitle": "Delete session",
67
70
  "sessions.createError": "Error: ",
68
71
  "sessions.dirNotEmpty": "Directory already exists and is not empty.",
69
- "sessions.export.tooltip": "Download session files (session.json + chunks) for debugging",
72
+ "sessions.export.tooltip": "Download session files (session.json + chunks + today's log) for debugging",
70
73
  "sessions.export.failed": "Failed to download session",
71
74
  "sessions.actions.tooltip": "Click for session actions",
72
75
  "sessions.actions.download": "Download session files",
73
76
  "sessions.actions.downloadHint": "for debugging",
77
+ "sib.dir.tooltip": "Click to change directory",
78
+ "sib.dir.changePrompt": "Change working directory:",
79
+ "sib.model.tooltip": "Click to switch model",
80
+ "sib.signal.tooltip": "Recent LLM latency",
74
81
  "sessions.thinking": "Thinking…",
75
82
  "sessions.default_name": "Session {{n}}",
76
83
  "sessions.badge.cron": "Auto",
@@ -449,8 +456,8 @@ const I18n = (() => {
449
456
  "settings.brand.label.qrHint": "Scan with your phone camera",
450
457
  "settings.brand.btn.change": "Change Serial Number",
451
458
  "settings.brand.btn.unbind": "Unbind License",
452
- "settings.brand.confirmRebind": "Warning: all previously installed brand skills will be deleted and cannot be used. Continue?",
453
- "settings.brand.confirmUnbind": "Are you sure you want to unbind this license? All brand skills will be deleted and this device will no longer have access to branded features.",
459
+ "settings.brand.confirmRebind": "Enter a new serial number to rebind. If it belongs to the same brand, your installed brand skills are preserved; switching to a different brand will remove them. Continue?",
460
+ "settings.brand.confirmUnbind": "Unbind this license? All installed brand skills will be removed, and this device will no longer have access to branded features.",
454
461
  "settings.brand.unbindSuccess": "License unbound successfully.",
455
462
  "settings.brand.unbindFailed": "Failed to unbind license. Please try again.",
456
463
  "settings.brand.badge.active": "Active",
@@ -558,6 +565,7 @@ const I18n = (() => {
558
565
  "chat.status.running": "运行中",
559
566
  "chat.status.error": "出错",
560
567
  "chat.input.placeholder": "输入消息…(Enter 发送,Shift+Enter 换行)",
568
+ "chat.input.placeholderRunning": "AI 正在工作,你仍然可以随时补充新信息给它...",
561
569
  "chat.btn.send": "发送",
562
570
  "chat.thinking": "思考中…",
563
571
  "chat.retrying": "正在重试",
@@ -565,6 +573,8 @@ const I18n = (() => {
565
573
  "chat.history_start": "没有更多历史了",
566
574
  "chat.image_expired": "已过期",
567
575
  "chat.done": "完成 — {{n}} 步,{{cost}}",
576
+ "chat.done.duration": " · {{duration}}s",
577
+ "chat.done.cache": "缓存命中 {{rate}}% ({{hits}}/{{total}}) · 复用 {{tokens}} tokens",
568
578
  "chat.interrupted": "已中断。",
569
579
  "chat.feedback_hint": "或在下方输入框自由作答 ↓",
570
580
  "chat.newMessageHint": "有新消息 ↓",
@@ -585,11 +595,15 @@ const I18n = (() => {
585
595
  "sessions.deleteTitle": "删除对话",
586
596
  "sessions.createError": "错误:",
587
597
  "sessions.dirNotEmpty": "该目录已存在且不为空,请换一个目录名。",
588
- "sessions.export.tooltip": "下载会话文件(session.json + 归档片段),用于调试",
598
+ "sessions.export.tooltip": "下载会话文件(session.json + 归档片段 + 当天日志),用于调试",
589
599
  "sessions.export.failed": "下载会话文件失败",
590
600
  "sessions.actions.tooltip": "点击查看会话操作",
591
601
  "sessions.actions.download": "下载会话文件",
592
602
  "sessions.actions.downloadHint": "用于调试",
603
+ "sib.dir.tooltip": "点击切换工作目录",
604
+ "sib.dir.changePrompt": "切换工作目录:",
605
+ "sib.model.tooltip": "点击切换模型",
606
+ "sib.signal.tooltip": "最近一次 LLM 响应延迟",
593
607
  "sessions.thinking": "思考中…",
594
608
  "sessions.default_name": "对话 {{n}}",
595
609
  "sessions.badge.cron": "定时",
@@ -612,6 +626,7 @@ const I18n = (() => {
612
626
  "sessions.search.typeCoding": "Coding",
613
627
  "sessions.search.typeSetup": "配置",
614
628
  "sessions.search.datePlaceholder": "日期",
629
+ "modal.yes": "确定",
615
630
  "modal.no": "取消",
616
631
  "modal.ok": "确定",
617
632
  "modal.cancel": "取消",
@@ -964,8 +979,8 @@ const I18n = (() => {
964
979
  "settings.brand.label.qrHint": "使用手机扫描二维码",
965
980
  "settings.brand.btn.change": "更换序列号",
966
981
  "settings.brand.btn.unbind": "解绑授权",
967
- "settings.brand.confirmRebind": "警告:所有已安装的历史品牌技能将被删除,无法继续使用。确认继续?",
968
- "settings.brand.confirmUnbind": "确定要解绑此授权吗?所有品牌技能将被删除,本设备将无法再访问品牌功能。",
982
+ "settings.brand.confirmRebind": "请输入新序列号以重新绑定。若属于同一品牌,已安装的品牌技能将保留;切换为其他品牌时才会清理。是否继续?",
983
+ "settings.brand.confirmUnbind": "确定要解绑此授权吗?所有已安装的品牌技能将被删除,本设备将无法再访问品牌功能。",
969
984
  "settings.brand.unbindSuccess": "授权解绑成功。",
970
985
  "settings.brand.unbindFailed": "解绑授权失败,请重试。",
971
986
  "settings.brand.badge.active": "已激活",
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title id="page-title">{{BRAND_NAME}}</title>
7
7
  <link rel="icon" type="image/svg+xml" href="/icon.svg">
8
+ <link rel="stylesheet" href="/vendor/katex/katex.min.css">
8
9
  <link rel="stylesheet" href="/app.css">
9
10
  <script>
10
11
  // Inline theme init — must run before CSS renders to prevent flash of wrong theme.
@@ -300,10 +301,10 @@
300
301
  <div id="sib-actions-dropdown" class="sib-actions-dropdown" style="display:none" role="menu"></div>
301
302
  </span>
302
303
  <span class="sib-sep sib-sep-after-id">│</span>
303
- <span id="sib-dir" title="Click to change directory"></span>
304
+ <span id="sib-dir" data-i18n-title="sib.dir.tooltip" title="Click to change directory"></span>
304
305
  <span class="sib-sep sib-sep-after-dir">│</span>
305
306
  <span id="sib-model-wrap">
306
- <span id="sib-model" class="sib-model-clickable" title="Click to switch model"></span>
307
+ <span id="sib-model" class="sib-model-clickable" data-i18n-title="sib.model.tooltip" title="Click to switch model"></span>
307
308
  <div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
308
309
  </span>
309
310
  <span class="sib-sep sib-sep-after-model">│</span>
@@ -311,7 +312,7 @@
311
312
  call completes (see updateInfoBar / Sessions.renderSignalBars). Click
312
313
  opens a mini benchmark panel (see Step 3/4 — not yet implemented). -->
313
314
  <span id="sib-signal-wrap" style="display:none">
314
- <span id="sib-signal" class="sib-signal-clickable" title="Recent LLM latency">
315
+ <span id="sib-signal" class="sib-signal-clickable" data-i18n-title="sib.signal.tooltip" title="Recent LLM latency">
315
316
  <span class="sig-bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
316
317
  <span class="sig-text"></span>
317
318
  </span>
@@ -905,7 +906,6 @@
905
906
  <div class="modal-box new-session-modal">
906
907
  <div class="modal-header">
907
908
  <h3 class="modal-title" data-i18n="sessions.modal.title">Create New Session</h3>
908
- <button id="new-session-modal-close" class="modal-close-btn" title="Close">×</button>
909
909
  </div>
910
910
  <div class="modal-body">
911
911
  <div class="modal-field">
@@ -945,8 +945,8 @@
945
945
  </div>
946
946
  </div>
947
947
  <div class="modal-footer">
948
- <button id="new-session-create" class="btn-primary" data-i18n="sessions.modal.create">Create Session</button>
949
948
  <button id="new-session-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
949
+ <button id="new-session-create" class="btn-primary" data-i18n="sessions.modal.create">Create Session</button>
950
950
  </div>
951
951
  </div>
952
952
  </div>
@@ -968,8 +968,8 @@
968
968
  <div id="prompt-modal-message" style="font-size:14px;line-height:1.6;margin-bottom:12px"></div>
969
969
  <input type="text" id="prompt-modal-input" class="prompt-modal-input" autocomplete="off" spellcheck="false">
970
970
  <div class="modal-actions">
971
- <button id="prompt-modal-ok" class="btn-primary" data-i18n="modal.ok">OK</button>
972
971
  <button id="prompt-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
972
+ <button id="prompt-modal-ok" class="btn-primary" data-i18n="modal.ok">OK</button>
973
973
  </div>
974
974
  </div>
975
975
  </div>
@@ -997,6 +997,8 @@
997
997
 
998
998
 
999
999
  <script src="/marked.min.js"></script>
1000
+ <script src="/vendor/katex/katex.min.js"></script>
1001
+ <script src="/vendor/katex/auto-render.min.js"></script>
1000
1002
  <script src="/i18n.js"></script>
1001
1003
  <script src="/auth.js"></script>
1002
1004
  <script src="/theme.js"></script>