openclacky 1.3.2 → 1.3.3

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +49 -5
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -41,22 +41,39 @@ module Clacky
41
41
  @task_parents ||= {} # { task_id => parent_id }
42
42
  @current_task_id ||= 0 # Latest created task ID
43
43
  @active_task_id ||= 0 # Current active task ID (for undo/redo)
44
+ @task_meta ||= {} # { task_id => { title:, started_at:, ended_at: } }
45
+ @latest_after_dirty = false if @latest_after_dirty.nil?
44
46
  end
45
47
 
46
48
  # Start a new task and establish parent relationship
49
+ # @param title [String, nil] Short label for this turn (typically the
50
+ # user's first message, truncated). Used by the UI to label snapshots
51
+ # even after the original conversation has been compressed out of
52
+ # @history. nil → leave unset; the UI falls back to "Task N".
47
53
  # Made public for testing
48
- def start_new_task
54
+ def start_new_task(title: nil)
49
55
  # Before the currently-active task stops being the latest, freeze its
50
56
  # end-of-task disk state into an AFTER snapshot. Without this, a task
51
57
  # that later gets superseded by a sibling branch would have no record
52
58
  # of its result, making a forward switch back to it impossible.
53
59
  checkpoint_latest_task_after
54
60
 
61
+ # Close out the task we're leaving.
62
+ if @active_task_id.to_i > 0 && @task_meta[@active_task_id]
63
+ @task_meta[@active_task_id][:ended_at] ||= Time.now.to_f
64
+ end
65
+
55
66
  parent_id = @active_task_id
56
67
  @current_task_id += 1
57
68
  @active_task_id = @current_task_id
58
69
  @task_parents[@current_task_id] = parent_id
59
70
 
71
+ @task_meta[@current_task_id] = {
72
+ title: title ? truncate_task_title(title) : nil,
73
+ started_at: Time.now.to_f,
74
+ ended_at: nil,
75
+ }
76
+
60
77
  # Claim ownership of this task for the current thread.
61
78
  # If a stale thread (e.g. a slow subagent) wakes up later it will see
62
79
  # @task_thread != Thread.current via check_stale! and self-terminate
@@ -68,6 +85,21 @@ module Clacky
68
85
  @current_task_id
69
86
  end
70
87
 
88
+ # Update the title of the currently-active task. Used by callers that
89
+ # only learn the user-facing label after start_new_task has run.
90
+ def set_current_task_title(title)
91
+ return if @active_task_id.to_i <= 0
92
+ @task_meta[@active_task_id] ||= { started_at: Time.now.to_f, ended_at: nil }
93
+ @task_meta[@active_task_id][:title] = truncate_task_title(title)
94
+ end
95
+
96
+ private def truncate_task_title(text)
97
+ s = text.to_s
98
+ # Collapse whitespace so multi-line inputs render as a single label.
99
+ s = s.gsub(/\s+/, " ").strip
100
+ s.length > 60 ? "#{s[0...57]}..." : s
101
+ end
102
+
71
103
  # Record a file's BEFORE state for the current task, the first time the
72
104
  # task touches it. Call this immediately before a tool mutates the file.
73
105
  # Subsequent calls within the same task are no-ops so the earliest state
@@ -157,10 +189,27 @@ module Clacky
157
189
  # Freeze the task we're leaving so a later forward switch can return.
158
190
  checkpoint_latest_task_after
159
191
 
192
+ plan = build_restore_plan(task_id)
193
+ plan.each do |rel, decision|
194
+ target = File.join(@working_dir, rel)
195
+ if decision[:action] == :delete
196
+ FileUtils.rm_f(target)
197
+ else
198
+ FileUtils.mkdir_p(File.dirname(target))
199
+ FileUtils.cp(decision[:source], target)
200
+ end
201
+ end
202
+ rescue StandardError
203
+ raise
204
+ end
205
+
206
+ # Decide, for every file the session has ever touched, whether restoring
207
+ # to `task_id` should overwrite it with a snapshot or delete it. Pure
208
+ # function over the snapshot tree — does not touch the working dir.
209
+ # @return [Hash{String => Hash}] rel_path => { action: :delete | :restore, source: String|nil }
210
+ private def build_restore_plan(task_id)
160
211
  session_root = TimeMachine.session_dir(@session_id)
161
212
 
162
- # Ancestor chain from the target task up to (and excluding) root 0,
163
- # ordered nearest-first so the closest writer of each file wins.
164
213
  ancestors = []
165
214
  tid = task_id
166
215
  until tid.nil? || tid <= 0 || ancestors.include?(tid)
@@ -168,22 +217,20 @@ module Clacky
168
217
  tid = @task_parents[tid]
169
218
  end
170
219
 
171
- # Every file ever touched by any task in this session.
172
220
  all_rels = Set.new
173
221
  Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path|
174
222
  next if File.directory?(path)
175
-
176
223
  rel = path.sub(%r{\A.*/before/}, "")
177
224
  rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
178
225
  all_rels << rel
179
226
  end
180
227
 
228
+ plan = {}
181
229
  all_rels.each do |rel|
182
230
  action = :delete
183
231
  source = nil
184
232
  matched = false
185
233
 
186
- # Closest ancestor (starting at the target) that produced this file.
187
234
  ancestors.each do |aid|
188
235
  after_dir = File.join(session_root, "task-#{aid}", "after")
189
236
  content_path = File.join(after_dir, rel)
@@ -201,11 +248,6 @@ module Clacky
201
248
  end
202
249
  end
203
250
 
204
- # No task on the chain produced this file. Restore the session's
205
- # INITIAL content for it — captured as the earliest BEFORE recorded
206
- # for this file by any task (BEFORE = state just before that task
207
- # ran; the smallest task id therefore holds the pre-session state).
208
- # No BEFORE at all => the file never existed initially, so delete.
209
251
  unless matched
210
252
  initial = earliest_before_snapshot(session_root, rel)
211
253
  if initial
@@ -216,16 +258,49 @@ module Clacky
216
258
  end
217
259
  end
218
260
 
261
+ plan[rel] = { action: action, source: source }
262
+ end
263
+
264
+ plan
265
+ end
266
+
267
+ # Preview the file-level effect of restore_to_task_state(task_id) without
268
+ # touching disk. Compares the resolved restore plan against the current
269
+ # working-dir state and returns only files that would actually change.
270
+ # @return [Array<Hash>] [{ path:, action: "create"|"modify"|"delete" }]
271
+ def preview_restore_to_task(task_id)
272
+ return [] unless task_id.is_a?(Integer) && task_id >= 0
273
+
274
+ checkpoint_latest_task_after
275
+ plan = build_restore_plan(task_id)
276
+ changes = []
277
+
278
+ plan.each do |rel, decision|
219
279
  target = File.join(@working_dir, rel)
220
- if action == :delete
221
- FileUtils.rm_f(target)
280
+ target_exists = File.exist?(target)
281
+
282
+ if decision[:action] == :delete
283
+ changes << { path: rel, action: "delete" } if target_exists
222
284
  else
223
- FileUtils.mkdir_p(File.dirname(target))
224
- FileUtils.cp(source, target)
285
+ src = decision[:source]
286
+ next unless src && File.exist?(src)
287
+
288
+ if !target_exists
289
+ changes << { path: rel, action: "create" }
290
+ elsif !files_equal?(src, target)
291
+ changes << { path: rel, action: "modify" }
292
+ end
225
293
  end
226
294
  end
295
+
296
+ changes.sort_by { |c| c[:path] }
297
+ end
298
+
299
+ private def files_equal?(a, b)
300
+ return false unless File.size(a) == File.size(b)
301
+ File.binread(a) == File.binread(b)
227
302
  rescue StandardError
228
- raise
303
+ false
229
304
  end
230
305
 
231
306
  # The initial (pre-session) content path for a file, taken from the
@@ -331,6 +406,143 @@ module Clacky
331
406
  @task_parents.select { |_, parent| parent == task_id }.keys
332
407
  end
333
408
 
409
+ # Cheap version of task_diff_files: just count how many distinct files
410
+ # this task touched, so the timeline can grey out no-op tasks without
411
+ # paying for a full diff walk per row.
412
+ def task_change_count(task_id)
413
+ return 0 unless task_id.is_a?(Integer) && task_id > 0
414
+
415
+ session_root = TimeMachine.session_dir(@session_id)
416
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
417
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
418
+ return 0 unless Dir.exist?(before_dir)
419
+ return 0 if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)
420
+
421
+ rels = Set.new
422
+ [before_dir, after_dir].each do |root|
423
+ next unless Dir.exist?(root)
424
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
425
+ next if File.directory?(path)
426
+ rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
427
+ rels << rel
428
+ end
429
+ end
430
+ rels.size
431
+ end
432
+
433
+ # File-level summary of changes a task introduced. Diff is task-N/before
434
+ # vs task-N/after (after is captured by checkpoint_latest_task_after when
435
+ # the task stops being the latest, so this method has no useful answer
436
+ # for the currently-active task — callers get an empty list back).
437
+ # @return [Array<Hash>] Each entry: { path:, status: "added"|"modified"|"deleted", binary: Bool }
438
+ def task_diff_files(task_id)
439
+ return [] unless task_id.is_a?(Integer) && task_id > 0
440
+
441
+ session_root = TimeMachine.session_dir(@session_id)
442
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
443
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
444
+ return [] unless Dir.exist?(before_dir)
445
+ return [] if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)
446
+
447
+ rels = Set.new
448
+ [before_dir, after_dir].each do |root|
449
+ next unless Dir.exist?(root)
450
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
451
+ next if File.directory?(path)
452
+ rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
453
+ rels << rel
454
+ end
455
+ end
456
+
457
+ rels.sort.map do |rel|
458
+ before_file, before_absent = snapshot_paths(before_dir, rel)
459
+ after_file, after_absent = snapshot_paths(after_dir, rel)
460
+
461
+ status = if before_absent && after_file
462
+ "added"
463
+ elsif before_file && after_absent
464
+ "deleted"
465
+ elsif before_file && after_file
466
+ "modified"
467
+ elsif before_file && !File.exist?(after_dir)
468
+ # No AFTER captured (e.g. the very latest task) — still surface
469
+ # what was touched as "modified" so the UI can list the file.
470
+ "modified"
471
+ else
472
+ "modified"
473
+ end
474
+
475
+ binary = looks_binary?(before_file) || looks_binary?(after_file)
476
+ { path: rel, status: status, binary: binary }
477
+ end
478
+ end
479
+
480
+ # Unified diff of a single file for a task. Returns nil if either side
481
+ # is missing or binary. text format = "@@ ... @@" patch (3-context),
482
+ # ready for the UI to render with a diff renderer.
483
+ # @return [Hash, nil] { path:, before:, after:, patch:, binary: }
484
+ def task_file_diff(task_id, rel_path)
485
+ return nil unless task_id.is_a?(Integer) && task_id > 0
486
+ return nil if rel_path.to_s.include?("..")
487
+
488
+ session_root = TimeMachine.session_dir(@session_id)
489
+ before_dir = File.join(session_root, "task-#{task_id}", "before")
490
+ after_dir = File.join(session_root, "task-#{task_id}", "after")
491
+
492
+ before_file, before_absent = snapshot_paths(before_dir, rel_path)
493
+ after_file, after_absent = snapshot_paths(after_dir, rel_path)
494
+
495
+ before_text = before_absent ? "" : (before_file ? read_text_safe(before_file) : nil)
496
+ after_text = after_absent ? "" : (after_file ? read_text_safe(after_file) : nil)
497
+
498
+ if before_text.nil? && after_text.nil?
499
+ return nil
500
+ end
501
+
502
+ # Detect binary on either side: bail out, the UI will render a stub.
503
+ if (before_file && looks_binary?(before_file)) || (after_file && looks_binary?(after_file))
504
+ return { path: rel_path, before: nil, after: nil, patch: nil, binary: true }
505
+ end
506
+
507
+ require "diffy" unless defined?(Diffy)
508
+ raw = Diffy::Diff.new(before_text || "", after_text || "",
509
+ context: 3, include_diff_info: true).to_s(:text)
510
+ # Strip Diffy's "--- /tmp/diffy.../before" header pair: it leaks
511
+ # tempfile paths and adds noise the UI doesn't need.
512
+ patch = raw.sub(/\A(?:---[^\n]*\n[^\n]*\n)/, "")
513
+
514
+ { path: rel_path, before: before_text, after: after_text, patch: patch, binary: false }
515
+ end
516
+
517
+ private def snapshot_paths(dir, rel)
518
+ content_path = File.join(dir, rel)
519
+ absent_path = "#{content_path}.#{ABSENT_MARKER}"
520
+ if File.exist?(content_path)
521
+ [content_path, false]
522
+ elsif File.exist?(absent_path)
523
+ [nil, true]
524
+ else
525
+ [nil, false]
526
+ end
527
+ end
528
+
529
+ private def looks_binary?(path)
530
+ return false if path.nil? || !File.exist?(path)
531
+ sample = File.binread(path, 8000)
532
+ sample.include?("\x00") || !sample.dup.force_encoding("UTF-8").valid_encoding?
533
+ rescue StandardError
534
+ true
535
+ end
536
+
537
+ private def read_text_safe(path)
538
+ File.read(path, mode: "rb").then do |s|
539
+ s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
540
+ s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
541
+ end
542
+ rescue StandardError
543
+ ""
544
+ end
545
+
334
546
  # Get task history with summaries for UI display
335
547
  # @param limit [Integer] Maximum number of recent tasks to return
336
548
  # @return [Array<Hash>] Task history with metadata
@@ -341,17 +553,23 @@ module Clacky
341
553
 
342
554
  tasks = []
343
555
  (1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
344
- # Find first user message for this task
345
- first_user_msg = @history.to_a.find do |msg|
346
- msg[:task_id] == task_id && msg[:role] == "user"
347
- end
556
+ meta = (@task_meta || {})[task_id] || {}
348
557
 
349
- summary = if first_user_msg
350
- content = extract_message_text(first_user_msg[:content])
351
- # Truncate to 60 characters (including "...")
352
- content.length > 60 ? "#{content[0...57]}..." : content
558
+ summary = if meta[:title] && !meta[:title].to_s.empty?
559
+ meta[:title]
353
560
  else
354
- "Task #{task_id}"
561
+ # Best-effort fallback: scan @history for the task's first real
562
+ # user message. Returns nothing for tasks that have already been
563
+ # compressed out — the UI then shows "Task N".
564
+ first = @history.to_a.find do |msg|
565
+ msg[:role] == "user" && msg[:task_id] == task_id && !msg[:system_injected]
566
+ end
567
+ if first
568
+ text = extract_message_text(first[:content]).to_s.gsub(/\s+/, " ").strip
569
+ text.length > 60 ? "#{text[0...57]}..." : text
570
+ else
571
+ "Task #{task_id}"
572
+ end
355
573
  end
356
574
 
357
575
  # Status relative to the ACTIVE task chain (not a linear id compare),
@@ -372,8 +590,11 @@ module Clacky
372
590
  tasks << {
373
591
  task_id: task_id,
374
592
  summary: summary,
593
+ started_at: meta[:started_at],
594
+ ended_at: meta[:ended_at],
375
595
  status: status,
376
- has_branches: has_branches
596
+ has_branches: has_branches,
597
+ change_count: task_change_count(task_id),
377
598
  }
378
599
  end
379
600
 
data/lib/clacky/agent.rb CHANGED
@@ -262,7 +262,7 @@ module Clacky
262
262
  @ui&.show_progress
263
263
 
264
264
  # Start new task for Time Machine
265
- task_id = start_new_task
265
+ task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
266
266
 
267
267
  # Continuation of a previously-interrupted task (e.g. user sent a
268
268
  # supplementary message without stopping the running task) keeps the
@@ -1371,6 +1371,17 @@ module Clacky
1371
1371
  cloned_messages = deep_clone(@history.to_a)
1372
1372
  subagent.instance_variable_set(:@history, MessageHistory.new(cloned_messages))
1373
1373
 
1374
+ # The cloned history carries per-message task_id tags. Without the parent's
1375
+ # Time Machine task state the subagent's @active_task_id stays 0, so
1376
+ # active_task_chain collapses to {0} and active_messages filters out every
1377
+ # message tagged task_id > 0 — silently shrinking the context and busting
1378
+ # prompt caching. Carry the task state alongside @history so the subagent
1379
+ # sees the same chain (and cache prefix) as the parent.
1380
+ subagent.instance_variable_set(:@task_parents, deep_clone(@task_parents))
1381
+ subagent.instance_variable_set(:@current_task_id, @current_task_id)
1382
+ subagent.instance_variable_set(:@active_task_id, @active_task_id)
1383
+ subagent.instance_variable_set(:@task_meta, deep_clone(@task_meta))
1384
+
1374
1385
  # Append system prompt suffix as user message (for cache reuse)
1375
1386
  if system_prompt_suffix
1376
1387
  subagent_history = subagent.history
@@ -165,7 +165,8 @@ module Clacky
165
165
  :memory_update_enabled, :skill_evolution,
166
166
  :max_running_agents, :max_idle_agents,
167
167
  :default_working_dir,
168
- :proxy_url
168
+ :proxy_url,
169
+ :media_output_dir
169
170
 
170
171
  def initialize(options = {})
171
172
  @permission_mode = validate_permission_mode(options[:permission_mode])
@@ -223,6 +224,15 @@ module Clacky
223
224
  # a proxy. Leave nil to go direct.
224
225
  @proxy_url = options[:proxy_url]
225
226
 
227
+ # User-configured directory where generated images / videos / audio
228
+ # land when a /api/media/* call doesn't pass an explicit output_dir.
229
+ # Final on-disk path is `<media_output_dir>/assets/generated/<file>`
230
+ # (the `assets/generated/` suffix is fixed by Media::Base for stable
231
+ # markdown/relative-path semantics across docs).
232
+ # Leave nil → fall back to Dir.pwd (legacy behavior, preserved for
233
+ # older configs that have no key set).
234
+ @media_output_dir = options[:media_output_dir]
235
+
226
236
  # Per-session virtual model overlay.
227
237
  # When set, #current_model returns a *merged* hash (the resolved @models
228
238
  # entry merged with this overlay) without mutating the shared @models
@@ -415,6 +425,7 @@ module Clacky
415
425
  skill_evolution max_running_agents max_idle_agents
416
426
  default_working_dir
417
427
  proxy_url
428
+ media_output_dir
418
429
  ].freeze
419
430
 
420
431
  # Serialize the current agent configuration to YAML.
@@ -434,7 +445,8 @@ module Clacky
434
445
  "max_running_agents" => @max_running_agents,
435
446
  "max_idle_agents" => @max_idle_agents,
436
447
  "default_working_dir" => @default_working_dir,
437
- "proxy_url" => @proxy_url
448
+ "proxy_url" => @proxy_url,
449
+ "media_output_dir" => @media_output_dir
438
450
  }
439
451
  YAML.dump("settings" => settings, "models" => persistable_models)
440
452
  end
@@ -0,0 +1,201 @@
1
+ // ── Official panel: changes (git, made friendly) ──────────────────────────
2
+ //
3
+ // "改动 / Changes": a non-technical view of what the AI changed, backed by the
4
+ // built-in git API (GET/POST /api/sessions/:id/git/*). Mounted as a tab in the
5
+ // "session.aside" slot, scoped to agents declaring `panels: [git]`.
6
+ //
7
+ // Deliberately hides git jargon: no porcelain status codes (M/??), no
8
+ // branch/ahead/behind unless the branch is NOT the main line (main/master) —
9
+ // then it's surfaced as a gentle notice. The only write is a zero-input
10
+ // "save version" that auto-generates the commit message.
11
+ //
12
+ // Native DOM + textContent on all git output (paths, branch) so nothing can
13
+ // inject. tab.badge tracks the number of changed files.
14
+ // ───────────────────────────────────────────────────────────────────────────
15
+
16
+ (() => {
17
+ if (!window.Clacky || !Clacky.ext) return;
18
+
19
+ const MAIN_BRANCHES = { main: true, master: true };
20
+ const t = (k, fallback) => {
21
+ const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
22
+ return (v && v !== k) ? v : fallback;
23
+ };
24
+
25
+ if (!document.getElementById("changes-panel-style")) {
26
+ const style = document.createElement("style");
27
+ style.id = "changes-panel-style";
28
+ style.textContent = `
29
+ .changes-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
30
+ .changes-summary { flex: none; padding: 14px 16px 10px; border-bottom: 1px solid var(--color-border-secondary); }
31
+ .changes-summary .h { font-size: 13px; color: var(--color-text-secondary); }
32
+ .changes-summary .h b { color: var(--color-text-primary); }
33
+ .changes-summary .sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 3px; }
34
+ .changes-branch { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 5px 8px; border-radius: var(--radius-sm); background: var(--color-warning-bg); color: var(--color-warning); font-size: 11.5px; }
35
+ .changes-branch code { font-family: ui-monospace, monospace; font-weight: 600; }
36
+ .changes-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 8px; }
37
+ .change-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
38
+ .change-row:hover { background: var(--color-bg-hover); }
39
+ .change-tag { flex: none; font-size: 11px; padding: 1px 7px; border-radius: var(--radius-pill); font-weight: 500; }
40
+ .change-tag.add { background: var(--color-success-bg); color: var(--color-success); }
41
+ .change-tag.mod { background: #eff6ff; color: #2563eb; }
42
+ .change-tag.del { background: var(--color-error-bg); color: var(--color-error); }
43
+ .change-path { font-size: 13px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
44
+ .change-dir { color: var(--color-text-tertiary); }
45
+ .changes-foot { flex: none; padding: 12px 16px; border-top: 1px solid var(--color-border-primary); }
46
+ .changes-save-btn { width: 100%; padding: 9px; border: none; border-radius: var(--radius-md); background: var(--color-accent-primary); color: var(--color-text-inverse); font-size: 13px; font-weight: 500; cursor: pointer; }
47
+ .changes-save-btn:hover:not(:disabled) { background: var(--color-accent-hover); }
48
+ .changes-save-btn:disabled { opacity: 0.5; cursor: default; }
49
+ .changes-hint { text-align: center; font-size: 11px; color: var(--color-text-tertiary); margin-top: 7px; min-height: 1em; }
50
+ .changes-empty, .changes-loading, .changes-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
51
+ .changes-error { color: var(--color-error); }
52
+ `;
53
+ document.head.appendChild(style);
54
+ }
55
+
56
+ function el(tag, attrs, ...kids) {
57
+ const node = document.createElement(tag);
58
+ if (attrs) {
59
+ for (const [k, v] of Object.entries(attrs)) {
60
+ if (k === "class") node.className = v;
61
+ else if (k === "text") node.textContent = v;
62
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
63
+ else node.setAttribute(k, v);
64
+ }
65
+ }
66
+ kids.forEach((c) => node.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
67
+ return node;
68
+ }
69
+
70
+ async function api(sessionId, action, opts) {
71
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/git/${action}`, opts);
72
+ return res.json();
73
+ }
74
+
75
+ // Map a porcelain status entry to a friendly kind without exposing codes.
76
+ function classify(f) {
77
+ if (f.untracked) return "add";
78
+ const code = `${f.x || ""}${f.y || ""}`;
79
+ if (code.includes("D")) return "del";
80
+ if (code.includes("A")) return "add";
81
+ return "mod";
82
+ }
83
+
84
+ const TAG_LABEL = {
85
+ add: () => t("changes.tag.add", "新增"),
86
+ mod: () => t("changes.tag.mod", "修改"),
87
+ del: () => t("changes.tag.del", "删除"),
88
+ };
89
+
90
+ function splitPath(path) {
91
+ const i = path.lastIndexOf("/");
92
+ return i < 0 ? { dir: "", name: path } : { dir: path.slice(0, i + 1), name: path.slice(i + 1) };
93
+ }
94
+
95
+ function autoMessage() {
96
+ const d = new Date();
97
+ const pad = (n) => String(n).padStart(2, "0");
98
+ const stamp = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
99
+ return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
100
+ }
101
+
102
+ function renderFiles(files) {
103
+ const list = el("div", { class: "changes-list" });
104
+ files.forEach((f) => {
105
+ const kind = classify(f);
106
+ const { dir, name } = splitPath(f.path);
107
+ const path = el("span", { class: "change-path" });
108
+ if (dir) path.appendChild(el("span", { class: "change-dir", text: dir }));
109
+ path.appendChild(document.createTextNode(name));
110
+ list.appendChild(el("div", { class: "change-row" },
111
+ el("span", { class: `change-tag ${kind}`, text: TAG_LABEL[kind]() }),
112
+ path,
113
+ ));
114
+ });
115
+ return list;
116
+ }
117
+
118
+ async function refresh(sessionId, root, body, ctx) {
119
+ body.replaceChildren(el("div", { class: "changes-loading", text: t("changes.loading", "正在读取改动…") }));
120
+
121
+ let status;
122
+ try {
123
+ status = await api(sessionId, "status");
124
+ } catch (_e) {
125
+ body.replaceChildren(el("div", { class: "changes-error", text: t("changes.error", "读取改动失败") }));
126
+ return;
127
+ }
128
+ if (!status.repo) {
129
+ if (ctx && ctx.setBadge) ctx.setBadge(null);
130
+ body.replaceChildren(el("div", { class: "changes-empty", text: t("changes.noRepo", "这个项目还没有启用版本管理。") }));
131
+ return;
132
+ }
133
+
134
+ const files = status.files || [];
135
+ if (ctx && ctx.setBadge) ctx.setBadge(files.length || null);
136
+
137
+ const count = files.length;
138
+ const summary = el("div", { class: "changes-summary" });
139
+ const h = el("div", { class: "h" });
140
+ if (count === 0) {
141
+ h.textContent = t("changes.cleanTitle", "暂无改动");
142
+ } else {
143
+ h.appendChild(document.createTextNode(t("changes.changedPre", "AI 改了 ")));
144
+ h.appendChild(el("b", { text: `${count} ${t("changes.filesUnit", "个文件")}` }));
145
+ }
146
+ summary.appendChild(h);
147
+ summary.appendChild(el("div", { class: "sub", text: t("changes.sub", "由 Git 管理 · 自上次存档以来") }));
148
+
149
+ const branch = (status.branch || "").trim();
150
+ if (branch && !MAIN_BRANCHES[branch.toLowerCase()]) {
151
+ const note = el("div", { class: "changes-branch" });
152
+ note.appendChild(document.createTextNode(t("changes.branchPre", "当前分支:")));
153
+ note.appendChild(el("code", { text: branch }));
154
+ summary.appendChild(note);
155
+ }
156
+
157
+ const hint = el("div", { class: "changes-hint" });
158
+ const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
159
+ saveBtn.disabled = count === 0;
160
+ saveBtn.addEventListener("click", async () => {
161
+ saveBtn.disabled = true;
162
+ hint.textContent = t("changes.save.saving", "正在存档…");
163
+ try {
164
+ const paths = files.map((f) => f.path);
165
+ const res = await api(sessionId, "commit", {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify({ message: autoMessage(), files: paths }),
169
+ });
170
+ if (res.ok) {
171
+ hint.textContent = t("changes.save.done", "已存档,可在「时光机」里回到这个版本");
172
+ refresh(sessionId, root, body, ctx);
173
+ } else {
174
+ hint.textContent = res.error || t("changes.save.failed", "存档失败");
175
+ saveBtn.disabled = false;
176
+ }
177
+ } catch (_e) {
178
+ hint.textContent = t("changes.save.failed", "存档失败");
179
+ saveBtn.disabled = false;
180
+ }
181
+ });
182
+
183
+ body.replaceChildren(
184
+ summary,
185
+ count === 0 ? el("div", { class: "changes-empty", text: t("changes.clean", "工作区是干净的,没有未存档的改动。") }) : renderFiles(files),
186
+ el("div", { class: "changes-foot" }, saveBtn, hint),
187
+ );
188
+ }
189
+
190
+ Clacky.ext.ui.mount("session.aside", (ctx) => {
191
+ if (!ctx || !ctx.sessionId) return null;
192
+ const body = el("div", { class: "changes-panel" });
193
+ const root = el("div", { class: "changes-root", "data-panel": "changes" }, body);
194
+ refresh(ctx.sessionId, root, body, ctx);
195
+ return root;
196
+ }, {
197
+ panel: "git",
198
+ order: 10,
199
+ tab: { id: "changes", label: (typeof I18n !== "undefined" ? (I18n.t("changes.tab") !== "changes.tab" ? I18n.t("changes.tab") : "Git 管理") : "Git 管理") },
200
+ });
201
+ })();