openclacky 0.8.9 → 0.9.0
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 +7 -1
- data/lib/clacky/server/http_server.rb +131 -3
- data/lib/clacky/tools/glob.rb +14 -3
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +313 -0
- data/lib/clacky/web/i18n.js +32 -0
- data/lib/clacky/web/index.html +11 -0
- data/lib/clacky/web/version.js +362 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 732978cd6072f33b558e6260af317ffa188529f922913193d9b7505745f0bb2b
|
|
4
|
+
data.tar.gz: 3a03afdbb194bbf3e52cd89d173a05186e01ce4cb8564410e294e0e5bb7db501
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ad076fe8aa80fe40b8f9908cdf6bab87c08110c8940c76aa3d8fe5944886f565735b7dd06008e9f54fc0a13a3b1d5966800d3bb02bf1d7268cf99faab181dd4
|
|
7
|
+
data.tar.gz: e29e2688839d08a271b105830df847517cfaab57ecae36d50787687180b1ba9a41e15a68f6e3afc21a800dd78b6eea6c7556c97c503aabdc46c2763bdf1c0bb8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.9.0] - 2026-03-14
|
|
11
11
|
|
|
12
12
|
### Added
|
|
13
|
+
- **Version check and one-click upgrade in WebUI**: a version badge in the sidebar shows when a newer gem is available; clicking it opens an upgrade popover with a live install log and a restart button — no terminal needed
|
|
14
|
+
- **Upgrade badge state machine**: the badge cycles through four visual states — amber pulsing dot (update available), spinning ring (installing), orange bouncing dot (restart needed), green check (restarted successfully)
|
|
13
15
|
- **Markdown rendering in WebUI chat**: assistant responses are now rendered as rich markdown — headings, bold, code blocks, lists, and inline code are all formatted properly instead of displayed as raw text
|
|
14
16
|
- **Session naming with auto-name and inline rename**: sessions are automatically named after the first exchange; users can double-click any session in the sidebar to rename it inline
|
|
15
17
|
- **Session info bar with live status animation**: a slim bar below the chat header shows the session name, working directory, and a pulsing animation while the agent is thinking or executing tools
|
|
@@ -18,10 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
18
20
|
- **Idle compression in WebUI**: the agent now compresses long conversation history automatically when the session has been idle, keeping context efficient without manual intervention
|
|
19
21
|
|
|
20
22
|
### Improved
|
|
23
|
+
- **Glob tool recursive search**: bare pattern names like `controller` are now automatically expanded to `**/*controller*` so searches always return results across all subdirectories
|
|
21
24
|
- **Onboard flow**: soul setup is now non-blocking; the confirmation page is skipped for a faster first-run experience; onboard now asks the user to name the AI first, then collects the user profile
|
|
22
25
|
- **Token usage display ordering**: the token usage line in WebUI now always appears below the assistant message bubble, not above it
|
|
26
|
+
- **i18n coverage**: settings panel dynamically-rendered fields are now translated correctly at render time
|
|
23
27
|
|
|
24
28
|
### Fixed
|
|
29
|
+
- **Upgrade popover stays open during install and reconnect**: the popover is now locked while a gem install or server restart is in progress, preventing accidental dismissal that would leave the badge stuck in a spinning state
|
|
30
|
+
- **Session auto-name respects default placeholders**: session names are now assigned based on message history only, not the agent's internal name field, so placeholder names like "Session 1" no longer block the auto-naming logic
|
|
25
31
|
- **Token usage line disappears after page refresh**: token usage data is now persisted in session history and correctly re-rendered when the page is reloaded
|
|
26
32
|
- **Shell tool hangs on background commands**: commands containing `&` (background operator) no longer cause the shell tool to block indefinitely
|
|
27
33
|
- **White flash on page load**: the page is now hidden until boot completes, preventing a flash of unstyled content or the wrong view on startup
|
|
@@ -131,10 +131,20 @@ module Clacky
|
|
|
131
131
|
@agent_config = agent_config
|
|
132
132
|
@client_factory = client_factory # callable: -> { Clacky::Client.new(...) }
|
|
133
133
|
@brand_test = brand_test # when true, skip remote API calls for license activation
|
|
134
|
+
# Capture the absolute path of the entry script and original ARGV at startup,
|
|
135
|
+
# so api_restart can re-exec the correct binary even if cwd changes later.
|
|
136
|
+
@restart_script = File.expand_path($0)
|
|
137
|
+
@restart_argv = ARGV.dup
|
|
134
138
|
@registry = SessionRegistry.new
|
|
135
139
|
@session_manager = Clacky::SessionManager.new
|
|
136
140
|
@ws_clients = {} # session_id => [WebSocketConnection, ...]
|
|
137
141
|
@ws_mutex = Mutex.new
|
|
142
|
+
# Version cache: { latest: "x.y.z", checked_at: Time }
|
|
143
|
+
@version_cache = nil
|
|
144
|
+
@version_mutex = Mutex.new
|
|
145
|
+
# Version cache: { latest: "x.y.z", checked_at: Time }
|
|
146
|
+
@version_cache = nil
|
|
147
|
+
@version_mutex = Mutex.new
|
|
138
148
|
@scheduler = Scheduler.new(
|
|
139
149
|
session_registry: @registry,
|
|
140
150
|
session_builder: method(:build_session)
|
|
@@ -264,6 +274,9 @@ module Clacky
|
|
|
264
274
|
when ["GET", "/api/brand"] then api_brand_info(res)
|
|
265
275
|
when ["GET", "/api/channels"] then api_list_channels(res)
|
|
266
276
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
277
|
+
when ["GET", "/api/version"] then api_get_version(res)
|
|
278
|
+
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
279
|
+
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
267
280
|
else
|
|
268
281
|
if method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
|
|
269
282
|
platform = path.sub("/api/channels/", "").sub("/test", "")
|
|
@@ -591,6 +604,111 @@ module Clacky
|
|
|
591
604
|
json_response(res, 200, brand.to_h)
|
|
592
605
|
end
|
|
593
606
|
|
|
607
|
+
# ── Version API ───────────────────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
# GET /api/version
|
|
610
|
+
# Returns current version and latest version from RubyGems (cached for 1 hour).
|
|
611
|
+
def api_get_version(res)
|
|
612
|
+
current = Clacky::VERSION
|
|
613
|
+
latest = fetch_latest_version_cached
|
|
614
|
+
json_response(res, 200, {
|
|
615
|
+
current: current,
|
|
616
|
+
latest: latest,
|
|
617
|
+
needs_update: latest ? version_older?(current, latest) : false
|
|
618
|
+
})
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# POST /api/version/upgrade
|
|
622
|
+
# Runs `gem update openclacky --no-document` via Clacky::Tools::Shell (login shell)
|
|
623
|
+
# in a background thread, streaming output via WebSocket broadcast.
|
|
624
|
+
# On success, re-execs the process so the new gem version is loaded.
|
|
625
|
+
def api_upgrade_version(req, res)
|
|
626
|
+
json_response(res, 202, { ok: true, message: "Upgrade started" })
|
|
627
|
+
|
|
628
|
+
Thread.new do
|
|
629
|
+
begin
|
|
630
|
+
broadcast_all(type: "upgrade_log", line: "Starting upgrade: gem update openclacky --no-document\n")
|
|
631
|
+
|
|
632
|
+
shell = Clacky::Tools::Shell.new
|
|
633
|
+
result = shell.execute(command: "gem update openclacky --no-document",
|
|
634
|
+
soft_timeout: 300, hard_timeout: 600)
|
|
635
|
+
output = [result[:stdout], result[:stderr]].join
|
|
636
|
+
success = result[:exit_code] == 0
|
|
637
|
+
|
|
638
|
+
broadcast_all(type: "upgrade_log", line: output)
|
|
639
|
+
|
|
640
|
+
if success
|
|
641
|
+
broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
|
|
642
|
+
broadcast_all(type: "upgrade_complete", success: true)
|
|
643
|
+
else
|
|
644
|
+
broadcast_all(type: "upgrade_log", line: "\n✗ Upgrade failed. Please try manually: gem update openclacky\n")
|
|
645
|
+
broadcast_all(type: "upgrade_complete", success: false)
|
|
646
|
+
end
|
|
647
|
+
rescue StandardError => e
|
|
648
|
+
broadcast_all(type: "upgrade_log", line: "\n✗ Error during upgrade: #{e.message}\n")
|
|
649
|
+
broadcast_all(type: "upgrade_complete", success: false)
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# POST /api/restart
|
|
655
|
+
# Re-execs the current process so the newly installed gem version is loaded.
|
|
656
|
+
# Uses the absolute script path captured at startup to avoid relative-path issues.
|
|
657
|
+
# Responds 200 first, then waits briefly for WEBrick to flush the response before exec.
|
|
658
|
+
def api_restart(req, res)
|
|
659
|
+
json_response(res, 200, { ok: true, message: "Restarting…" })
|
|
660
|
+
|
|
661
|
+
script = @restart_script
|
|
662
|
+
argv = @restart_argv
|
|
663
|
+
Thread.new do
|
|
664
|
+
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
665
|
+
Clacky::Logger.info("[Restart] exec: #{RbConfig.ruby} #{script} #{argv.join(' ')}")
|
|
666
|
+
exec(RbConfig.ruby, script, *argv)
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Fetch the latest gem version using `gem list -r`, with a 1-hour in-memory cache.
|
|
671
|
+
# Uses Clacky::Tools::Shell (login shell) so rbenv/mise shims and gem mirrors work correctly.
|
|
672
|
+
private def fetch_latest_version_cached
|
|
673
|
+
@version_mutex.synchronize do
|
|
674
|
+
now = Time.now
|
|
675
|
+
if @version_cache && (now - @version_cache[:checked_at]) < 3600
|
|
676
|
+
return @version_cache[:latest]
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# Fetch outside the mutex to avoid blocking other requests
|
|
681
|
+
latest = fetch_latest_version_from_gem
|
|
682
|
+
|
|
683
|
+
@version_mutex.synchronize do
|
|
684
|
+
@version_cache = { latest: latest, checked_at: Time.now }
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
latest
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Query the latest openclacky version via `gem list -r openclacky`.
|
|
691
|
+
# Runs through login shell so gem source mirrors configured via rbenv/mise work correctly.
|
|
692
|
+
# Output format: "openclacky (0.9.0)"
|
|
693
|
+
private def fetch_latest_version_from_gem
|
|
694
|
+
shell = Clacky::Tools::Shell.new
|
|
695
|
+
result = shell.execute(command: "gem list -r openclacky", soft_timeout: 15, hard_timeout: 30)
|
|
696
|
+
return nil unless result[:exit_code] == 0
|
|
697
|
+
|
|
698
|
+
out = result[:stdout].to_s
|
|
699
|
+
match = out.match(/^openclacky\s+\(([^)]+)\)/)
|
|
700
|
+
match ? match[1].strip : nil
|
|
701
|
+
rescue StandardError
|
|
702
|
+
nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Returns true if version string `a` is strictly older than `b`.
|
|
706
|
+
private def version_older?(a, b)
|
|
707
|
+
Gem::Version.new(a) < Gem::Version.new(b)
|
|
708
|
+
rescue ArgumentError
|
|
709
|
+
false
|
|
710
|
+
end
|
|
711
|
+
|
|
594
712
|
# ── Channel API ───────────────────────────────────────────────────────────
|
|
595
713
|
|
|
596
714
|
# GET /api/channels
|
|
@@ -1354,8 +1472,10 @@ module Clacky
|
|
|
1354
1472
|
end
|
|
1355
1473
|
content = [content, *file_refs].join("\n") unless file_refs.empty?
|
|
1356
1474
|
|
|
1357
|
-
# Auto-name the session from the first user message (before agent starts running)
|
|
1358
|
-
|
|
1475
|
+
# Auto-name the session from the first user message (before agent starts running).
|
|
1476
|
+
# Check messages.empty? only — agent.name may already hold a default placeholder
|
|
1477
|
+
# like "Session 1" assigned at creation time, so it's not a reliable signal.
|
|
1478
|
+
if agent.messages.empty?
|
|
1359
1479
|
auto_name = content.gsub(/\s+/, " ").strip[0, 30]
|
|
1360
1480
|
auto_name += "…" if content.strip.length > 30
|
|
1361
1481
|
agent.rename(auto_name)
|
|
@@ -1635,15 +1755,23 @@ module Clacky
|
|
|
1635
1755
|
|
|
1636
1756
|
pid = File.read(pid_file).strip.to_i
|
|
1637
1757
|
return if pid <= 0
|
|
1758
|
+
# After exec-restart, the new process inherits the same PID as the old one.
|
|
1759
|
+
# Skip sending TERM to ourselves — we are already the new server.
|
|
1760
|
+
if pid == Process.pid
|
|
1761
|
+
Clacky::Logger.info("[Server] exec-restart detected (PID=#{pid}), skipping self-kill.")
|
|
1762
|
+
return
|
|
1763
|
+
end
|
|
1638
1764
|
|
|
1639
1765
|
begin
|
|
1640
1766
|
Process.kill("TERM", pid)
|
|
1767
|
+
Clacky::Logger.info("[Server] Stopped existing server (PID=#{pid}) on port #{port}.")
|
|
1641
1768
|
puts "Stopped existing server (PID: #{pid}) on port #{port}."
|
|
1642
1769
|
# Give it a moment to release the port
|
|
1643
1770
|
sleep 0.5
|
|
1644
1771
|
rescue Errno::ESRCH
|
|
1645
|
-
|
|
1772
|
+
Clacky::Logger.info("[Server] Existing server PID=#{pid} already gone.")
|
|
1646
1773
|
rescue Errno::EPERM
|
|
1774
|
+
Clacky::Logger.warn("[Server] Could not stop existing server (PID=#{pid}) — permission denied.")
|
|
1647
1775
|
puts "Could not stop existing server (PID: #{pid}) — permission denied."
|
|
1648
1776
|
ensure
|
|
1649
1777
|
File.delete(pid_file) if File.exist?(pid_file)
|
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -63,11 +63,22 @@ module Clacky
|
|
|
63
63
|
ignored: 0
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
# Auto-expand bare patterns (no slash, no **) to recursive search.
|
|
67
|
+
# e.g. "*install*" -> "**/*install*", "*.rb" -> "**/*.rb"
|
|
68
|
+
# This avoids surprising empty results when files are in subdirectories.
|
|
69
|
+
effective_pattern = if !File.absolute_path?(pattern) &&
|
|
70
|
+
!pattern.include?("/") &&
|
|
71
|
+
!pattern.start_with?("**")
|
|
72
|
+
"**/#{pattern}"
|
|
73
|
+
else
|
|
74
|
+
pattern
|
|
75
|
+
end
|
|
76
|
+
|
|
66
77
|
# Build full pattern - handle absolute paths correctly
|
|
67
|
-
full_pattern = if File.absolute_path?(
|
|
68
|
-
|
|
78
|
+
full_pattern = if File.absolute_path?(effective_pattern)
|
|
79
|
+
effective_pattern
|
|
69
80
|
else
|
|
70
|
-
File.join(base_path,
|
|
81
|
+
File.join(base_path, effective_pattern)
|
|
71
82
|
end
|
|
72
83
|
# Always-ignored directory names that should never appear in results
|
|
73
84
|
always_ignored_dirs = Clacky::Utils::FileIgnoreHelper::ALWAYS_IGNORED_DIRS
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
--color-button-primary: #60a5fa;
|
|
35
35
|
--color-button-primary-hover: #3d8bd8;
|
|
36
|
+
--color-button-primary-text: #ffffff;
|
|
36
37
|
|
|
37
38
|
--color-info: #38bdf8;
|
|
38
39
|
--color-secondary: #34d399;
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
|
|
78
79
|
--color-button-primary: #3b82f6;
|
|
79
80
|
--color-button-primary-hover: #2563eb;
|
|
81
|
+
--color-button-primary-text: #ffffff;
|
|
80
82
|
|
|
81
83
|
--color-info: #38bdf8;
|
|
82
84
|
--color-secondary: #10b981;
|
|
@@ -3060,3 +3062,314 @@ body {
|
|
|
3060
3062
|
border: 1px dashed var(--color-border-primary);
|
|
3061
3063
|
border-radius: 8px;
|
|
3062
3064
|
}
|
|
3065
|
+
|
|
3066
|
+
/* ── Version badge (inline in Settings row, right-aligned) ──────────────── */
|
|
3067
|
+
|
|
3068
|
+
/* Settings button stretches to fill width; version badge pushes to the right */
|
|
3069
|
+
#btn-settings {
|
|
3070
|
+
position: relative;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
.version-badge {
|
|
3074
|
+
display: inline-flex;
|
|
3075
|
+
align-items: center;
|
|
3076
|
+
gap: 5px;
|
|
3077
|
+
margin-left: auto; /* push to right inside flex btn-settings */
|
|
3078
|
+
padding: 2px 6px;
|
|
3079
|
+
border-radius: 4px;
|
|
3080
|
+
cursor: default;
|
|
3081
|
+
transition: background .15s;
|
|
3082
|
+
flex-shrink: 0;
|
|
3083
|
+
}
|
|
3084
|
+
/* When actionable, clicking the badge opens the popover.
|
|
3085
|
+
We intercept click on the badge span, stop propagation to avoid
|
|
3086
|
+
opening the settings panel at the same time. */
|
|
3087
|
+
.version-badge.has-update,
|
|
3088
|
+
.version-badge.upgrade-done,
|
|
3089
|
+
.version-badge.is-upgrading { cursor: pointer; }
|
|
3090
|
+
|
|
3091
|
+
.version-text {
|
|
3092
|
+
font-size: 10.5px;
|
|
3093
|
+
color: var(--color-text-muted);
|
|
3094
|
+
font-family: monospace;
|
|
3095
|
+
letter-spacing: 0.03em;
|
|
3096
|
+
transition: color .15s;
|
|
3097
|
+
line-height: 1;
|
|
3098
|
+
}
|
|
3099
|
+
/* Highlight version text when badge is actionable and btn is hovered */
|
|
3100
|
+
#btn-settings:hover .version-text { color: var(--color-text-tertiary); }
|
|
3101
|
+
.version-badge.has-update .version-text,
|
|
3102
|
+
.version-badge.upgrade-done .version-text { color: var(--color-accent-primary); }
|
|
3103
|
+
|
|
3104
|
+
/* Amber pulsing dot — update available */
|
|
3105
|
+
.version-update-dot {
|
|
3106
|
+
width: 7px;
|
|
3107
|
+
height: 7px;
|
|
3108
|
+
border-radius: 50%;
|
|
3109
|
+
background: var(--color-warning);
|
|
3110
|
+
flex-shrink: 0;
|
|
3111
|
+
box-shadow: 0 0 5px var(--color-warning);
|
|
3112
|
+
animation: vbadge-pulse 2s ease-in-out infinite;
|
|
3113
|
+
}
|
|
3114
|
+
@keyframes vbadge-pulse {
|
|
3115
|
+
0%, 100% { opacity: 1; box-shadow: 0 0 5px var(--color-warning); }
|
|
3116
|
+
50% { opacity: 0.4; box-shadow: 0 0 2px var(--color-warning); }
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
/* Tiny spinning ring — upgrade in progress */
|
|
3120
|
+
.version-spinner {
|
|
3121
|
+
width: 9px;
|
|
3122
|
+
height: 9px;
|
|
3123
|
+
border: 1.5px solid var(--color-border-primary);
|
|
3124
|
+
border-top-color: var(--color-accent-primary);
|
|
3125
|
+
border-radius: 50%;
|
|
3126
|
+
flex-shrink: 0;
|
|
3127
|
+
animation: vbadge-spin .7s linear infinite;
|
|
3128
|
+
}
|
|
3129
|
+
@keyframes vbadge-spin { to { transform: rotate(360deg); } }
|
|
3130
|
+
|
|
3131
|
+
/* Orange bouncing dot — needs restart (upgrade done, waiting for restart) */
|
|
3132
|
+
.version-restart-dot {
|
|
3133
|
+
width: 7px;
|
|
3134
|
+
height: 7px;
|
|
3135
|
+
border-radius: 50%;
|
|
3136
|
+
background: #f97316; /* orange-500 */
|
|
3137
|
+
flex-shrink: 0;
|
|
3138
|
+
animation: vbadge-bounce 1s ease-in-out infinite;
|
|
3139
|
+
}
|
|
3140
|
+
@keyframes vbadge-bounce {
|
|
3141
|
+
0%, 100% { transform: translateY(0); opacity: 1; }
|
|
3142
|
+
50% { transform: translateY(-3px); opacity: 0.7; }
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/* Green check — restarted successfully */
|
|
3146
|
+
.version-done-check {
|
|
3147
|
+
font-size: 10px;
|
|
3148
|
+
color: #22c55e;
|
|
3149
|
+
flex-shrink: 0;
|
|
3150
|
+
line-height: 1;
|
|
3151
|
+
animation: vbadge-popin .25s cubic-bezier(.34,1.56,.64,1) both;
|
|
3152
|
+
}
|
|
3153
|
+
@keyframes vbadge-popin {
|
|
3154
|
+
from { transform: scale(0); opacity: 0; }
|
|
3155
|
+
to { transform: scale(1); opacity: 1; }
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/* ── Upgrade popover (fixed, floats above badge) ─────────────────────────── */
|
|
3159
|
+
.vup {
|
|
3160
|
+
position: fixed;
|
|
3161
|
+
z-index: 9999;
|
|
3162
|
+
width: 240px;
|
|
3163
|
+
background: var(--color-bg-secondary);
|
|
3164
|
+
border: 1px solid var(--color-border-primary);
|
|
3165
|
+
border-radius: 10px;
|
|
3166
|
+
box-shadow: 0 8px 32px rgba(0,0,0,.22), 0 2px 8px rgba(0,0,0,.12);
|
|
3167
|
+
padding: 14px 16px 12px;
|
|
3168
|
+
display: none;
|
|
3169
|
+
/* Slide-up + fade entrance */
|
|
3170
|
+
opacity: 0;
|
|
3171
|
+
transform: translateY(6px);
|
|
3172
|
+
transition: opacity .18s ease, transform .18s ease;
|
|
3173
|
+
}
|
|
3174
|
+
.vup--visible {
|
|
3175
|
+
opacity: 1;
|
|
3176
|
+
transform: translateY(0);
|
|
3177
|
+
}
|
|
3178
|
+
/* Tiny arrow pointing down toward badge */
|
|
3179
|
+
.vup::after {
|
|
3180
|
+
content: "";
|
|
3181
|
+
position: absolute;
|
|
3182
|
+
bottom: -6px;
|
|
3183
|
+
left: 20px;
|
|
3184
|
+
width: 10px;
|
|
3185
|
+
height: 10px;
|
|
3186
|
+
background: var(--color-bg-secondary);
|
|
3187
|
+
border-right: 1px solid var(--color-border-primary);
|
|
3188
|
+
border-bottom: 1px solid var(--color-border-primary);
|
|
3189
|
+
transform: rotate(45deg);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
/* Confirm state */
|
|
3193
|
+
.vup-desc {
|
|
3194
|
+
font-size: 12px;
|
|
3195
|
+
color: var(--color-text-secondary);
|
|
3196
|
+
line-height: 1.55;
|
|
3197
|
+
margin: 0 0 8px;
|
|
3198
|
+
}
|
|
3199
|
+
.vup-versions {
|
|
3200
|
+
font-size: 12px;
|
|
3201
|
+
font-family: monospace;
|
|
3202
|
+
color: var(--color-text-primary);
|
|
3203
|
+
margin: 0 0 12px;
|
|
3204
|
+
font-weight: 600;
|
|
3205
|
+
}
|
|
3206
|
+
.vup-arrow {
|
|
3207
|
+
color: var(--color-accent-primary);
|
|
3208
|
+
margin: 0 4px;
|
|
3209
|
+
}
|
|
3210
|
+
.vup-actions {
|
|
3211
|
+
display: flex;
|
|
3212
|
+
gap: 8px;
|
|
3213
|
+
}
|
|
3214
|
+
.vup-btn-primary {
|
|
3215
|
+
flex: 1;
|
|
3216
|
+
padding: 7px 0;
|
|
3217
|
+
background: var(--color-button-primary);
|
|
3218
|
+
color: var(--color-button-primary-text);
|
|
3219
|
+
border: none;
|
|
3220
|
+
border-radius: 6px;
|
|
3221
|
+
font-size: 12px;
|
|
3222
|
+
font-weight: 600;
|
|
3223
|
+
cursor: pointer;
|
|
3224
|
+
transition: background .15s, transform .1s;
|
|
3225
|
+
}
|
|
3226
|
+
.vup-btn-primary:hover { background: var(--color-button-primary-hover); }
|
|
3227
|
+
.vup-btn-primary:active { transform: scale(.97); }
|
|
3228
|
+
.vup-btn-cancel {
|
|
3229
|
+
padding: 7px 12px;
|
|
3230
|
+
background: transparent;
|
|
3231
|
+
color: var(--color-text-muted);
|
|
3232
|
+
border: 1px solid var(--color-border-primary);
|
|
3233
|
+
border-radius: 6px;
|
|
3234
|
+
font-size: 12px;
|
|
3235
|
+
cursor: pointer;
|
|
3236
|
+
transition: background .15s, color .15s;
|
|
3237
|
+
}
|
|
3238
|
+
.vup-btn-cancel:hover { background: var(--color-bg-hover); color: var(--color-text-secondary); }
|
|
3239
|
+
|
|
3240
|
+
/* Progress state */
|
|
3241
|
+
.vup-progress-header {
|
|
3242
|
+
display: flex;
|
|
3243
|
+
align-items: center;
|
|
3244
|
+
gap: 8px;
|
|
3245
|
+
margin-bottom: 10px;
|
|
3246
|
+
}
|
|
3247
|
+
.vup-installing-label {
|
|
3248
|
+
font-size: 12px;
|
|
3249
|
+
color: var(--color-text-secondary);
|
|
3250
|
+
}
|
|
3251
|
+
/* Animated 3-dot ellipsis */
|
|
3252
|
+
.vup-installing-dot {
|
|
3253
|
+
display: inline-flex;
|
|
3254
|
+
gap: 3px;
|
|
3255
|
+
}
|
|
3256
|
+
.vup-installing-dot::before,
|
|
3257
|
+
.vup-installing-dot::after,
|
|
3258
|
+
.vup-installing-dot b {
|
|
3259
|
+
content: "";
|
|
3260
|
+
display: inline-block;
|
|
3261
|
+
width: 5px;
|
|
3262
|
+
height: 5px;
|
|
3263
|
+
border-radius: 50%;
|
|
3264
|
+
background: var(--color-accent-primary);
|
|
3265
|
+
animation: vup-bounce 1.2s ease-in-out infinite;
|
|
3266
|
+
}
|
|
3267
|
+
.vup-installing-dot::after { animation-delay: .2s; }
|
|
3268
|
+
/* We use a pseudo trick — actual 3 dots via box-shadow instead */
|
|
3269
|
+
.vup-installing-dot {
|
|
3270
|
+
width: 5px;
|
|
3271
|
+
height: 5px;
|
|
3272
|
+
border-radius: 50%;
|
|
3273
|
+
background: var(--color-accent-primary);
|
|
3274
|
+
animation: vup-bounce 1.2s ease-in-out infinite;
|
|
3275
|
+
box-shadow: 10px 0 0 var(--color-accent-primary), 20px 0 0 var(--color-accent-primary);
|
|
3276
|
+
margin-right: 24px; /* make room for box-shadow dots */
|
|
3277
|
+
flex-shrink: 0;
|
|
3278
|
+
}
|
|
3279
|
+
.vup-installing-dot::before,
|
|
3280
|
+
.vup-installing-dot::after { display: none; }
|
|
3281
|
+
|
|
3282
|
+
/* Stagger the shadow dots via custom animation composition */
|
|
3283
|
+
.vup-installing-dot {
|
|
3284
|
+
animation: vup-bounce1 1.2s ease-in-out infinite;
|
|
3285
|
+
}
|
|
3286
|
+
@keyframes vup-bounce1 {
|
|
3287
|
+
0%, 80%, 100% { box-shadow: 10px 0 0 var(--color-accent-primary), 20px 0 0 var(--color-accent-primary); opacity: 1; }
|
|
3288
|
+
40% { box-shadow: 10px 0 0 var(--color-accent-primary), 20px 0 0 var(--color-accent-primary); opacity: .4; }
|
|
3289
|
+
}
|
|
3290
|
+
@keyframes vup-bounce { 0%, 80%, 100% { opacity: 1; } 40% { opacity: .3; } }
|
|
3291
|
+
|
|
3292
|
+
.vup-log {
|
|
3293
|
+
background: var(--color-bg-primary);
|
|
3294
|
+
border: 1px solid var(--color-border-primary);
|
|
3295
|
+
border-radius: 6px;
|
|
3296
|
+
padding: 8px 10px;
|
|
3297
|
+
font-size: 10.5px;
|
|
3298
|
+
font-family: monospace;
|
|
3299
|
+
line-height: 1.5;
|
|
3300
|
+
color: var(--color-text-muted);
|
|
3301
|
+
max-height: 140px;
|
|
3302
|
+
min-height: 60px;
|
|
3303
|
+
overflow-y: auto;
|
|
3304
|
+
white-space: pre-wrap;
|
|
3305
|
+
word-break: break-all;
|
|
3306
|
+
margin: 0;
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
/* Done state */
|
|
3310
|
+
.vup-done-header {
|
|
3311
|
+
display: flex;
|
|
3312
|
+
align-items: center;
|
|
3313
|
+
gap: 8px;
|
|
3314
|
+
margin-bottom: 12px;
|
|
3315
|
+
font-size: 12px;
|
|
3316
|
+
color: var(--color-text-secondary);
|
|
3317
|
+
}
|
|
3318
|
+
.vup-done-icon {
|
|
3319
|
+
width: 20px;
|
|
3320
|
+
height: 20px;
|
|
3321
|
+
border-radius: 50%;
|
|
3322
|
+
background: #22c55e;
|
|
3323
|
+
color: #fff;
|
|
3324
|
+
display: flex;
|
|
3325
|
+
align-items: center;
|
|
3326
|
+
justify-content: center;
|
|
3327
|
+
font-size: 11px;
|
|
3328
|
+
font-weight: 700;
|
|
3329
|
+
flex-shrink: 0;
|
|
3330
|
+
animation: vbadge-popin .3s cubic-bezier(.34,1.56,.64,1) both;
|
|
3331
|
+
}
|
|
3332
|
+
.vup-btn-restart {
|
|
3333
|
+
width: 100%;
|
|
3334
|
+
padding: 9px 0;
|
|
3335
|
+
background: var(--color-button-primary);
|
|
3336
|
+
color: var(--color-button-primary-text);
|
|
3337
|
+
border: none;
|
|
3338
|
+
border-radius: 6px;
|
|
3339
|
+
font-size: 13px;
|
|
3340
|
+
font-weight: 600;
|
|
3341
|
+
cursor: pointer;
|
|
3342
|
+
transition: background .15s, transform .1s;
|
|
3343
|
+
letter-spacing: .02em;
|
|
3344
|
+
}
|
|
3345
|
+
.vup-btn-restart:hover { background: var(--color-button-primary-hover); }
|
|
3346
|
+
.vup-btn-restart:active { transform: scale(.97); }
|
|
3347
|
+
|
|
3348
|
+
/* Reconnect state */
|
|
3349
|
+
.vup-reconnect {
|
|
3350
|
+
text-align: center;
|
|
3351
|
+
padding: 8px 0 4px;
|
|
3352
|
+
}
|
|
3353
|
+
.vup-reconnect-spinner {
|
|
3354
|
+
width: 28px;
|
|
3355
|
+
height: 28px;
|
|
3356
|
+
border: 2.5px solid var(--color-border-primary);
|
|
3357
|
+
border-top-color: var(--color-accent-primary);
|
|
3358
|
+
border-radius: 50%;
|
|
3359
|
+
animation: vbadge-spin .7s linear infinite;
|
|
3360
|
+
margin: 0 auto 10px;
|
|
3361
|
+
}
|
|
3362
|
+
.vup-reconnect-msg {
|
|
3363
|
+
font-size: 12px;
|
|
3364
|
+
color: var(--color-text-muted);
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
/* Error state */
|
|
3368
|
+
.vup-error {
|
|
3369
|
+
font-size: 12px;
|
|
3370
|
+
color: var(--color-error, #ef4444);
|
|
3371
|
+
margin: 0;
|
|
3372
|
+
line-height: 1.5;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -49,6 +49,22 @@ const I18n = (() => {
|
|
|
49
49
|
"modal.yes": "Yes",
|
|
50
50
|
"modal.no": "No",
|
|
51
51
|
|
|
52
|
+
// ── Version / Upgrade ──
|
|
53
|
+
"upgrade.desc": "A new version is available. It will install in the background — you can keep using the app.",
|
|
54
|
+
"upgrade.btn.upgrade": "Upgrade Now",
|
|
55
|
+
"upgrade.btn.cancel": "Cancel",
|
|
56
|
+
"upgrade.btn.restart": "↻ Restart Now",
|
|
57
|
+
"upgrade.installing": "Installing…",
|
|
58
|
+
"upgrade.done": "Upgrade complete!",
|
|
59
|
+
"upgrade.failed": "Upgrade failed. Please try again.",
|
|
60
|
+
"upgrade.reconnecting": "Restarting server…",
|
|
61
|
+
"upgrade.restart.success": "✓ Restarted successfully!",
|
|
62
|
+
"upgrade.tooltip.upgrading": "Upgrading — click to see progress",
|
|
63
|
+
"upgrade.tooltip.new": "v{{latest}} available — click to upgrade",
|
|
64
|
+
"upgrade.tooltip.ok": "v{{current}} (up to date)",
|
|
65
|
+
"upgrade.tooltip.needs_restart": "Upgrade complete — click to restart",
|
|
66
|
+
"upgrade.tooltip.done": "Restarted successfully",
|
|
67
|
+
|
|
52
68
|
// ── Tasks panel ──
|
|
53
69
|
"tasks.title": "Scheduled Tasks",
|
|
54
70
|
"tasks.subtitle": "Manage and schedule automated tasks for your assistant",
|
|
@@ -260,6 +276,22 @@ const I18n = (() => {
|
|
|
260
276
|
"modal.yes": "确认",
|
|
261
277
|
"modal.no": "取消",
|
|
262
278
|
|
|
279
|
+
// ── Version / Upgrade ──
|
|
280
|
+
"upgrade.desc": "有新版本可用,将在后台安装,升级期间可继续使用。",
|
|
281
|
+
"upgrade.btn.upgrade": "立即升级",
|
|
282
|
+
"upgrade.btn.cancel": "取消",
|
|
283
|
+
"upgrade.btn.restart": "↻ 立即重启",
|
|
284
|
+
"upgrade.installing": "安装中…",
|
|
285
|
+
"upgrade.done": "升级完成!",
|
|
286
|
+
"upgrade.failed": "升级失败,请重试。",
|
|
287
|
+
"upgrade.reconnecting": "服务重启中…",
|
|
288
|
+
"upgrade.restart.success": "✓ 重启成功!",
|
|
289
|
+
"upgrade.tooltip.upgrading": "升级中,点击查看进度",
|
|
290
|
+
"upgrade.tooltip.new": "v{{latest}} 可用,点击升级",
|
|
291
|
+
"upgrade.tooltip.ok": "v{{current}}(已是最新)",
|
|
292
|
+
"upgrade.tooltip.needs_restart": "升级完成,点击重启",
|
|
293
|
+
"upgrade.tooltip.done": "重启成功",
|
|
294
|
+
|
|
263
295
|
// ── Tasks panel ──
|
|
264
296
|
"tasks.title": "定时任务",
|
|
265
297
|
"tasks.subtitle": "管理和调度助手的自动化任务",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -107,6 +107,14 @@
|
|
|
107
107
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
108
108
|
</svg>
|
|
109
109
|
<span data-i18n="sidebar.settings">Settings</span>
|
|
110
|
+
<!-- Version badge: inline in settings row, right-aligned -->
|
|
111
|
+
<span id="version-badge" class="version-badge" style="display:none">
|
|
112
|
+
<span id="version-text" class="version-text"></span>
|
|
113
|
+
<span id="version-update-dot" class="version-update-dot" style="display:none"></span>
|
|
114
|
+
<span id="version-restart-dot" class="version-restart-dot" style="display:none"></span>
|
|
115
|
+
<span id="version-spinner" class="version-spinner" style="display:none"></span>
|
|
116
|
+
<span id="version-done-check" class="version-done-check" style="display:none">✓</span>
|
|
117
|
+
</span>
|
|
110
118
|
</button>
|
|
111
119
|
</div>
|
|
112
120
|
</aside>
|
|
@@ -467,6 +475,8 @@
|
|
|
467
475
|
</div>
|
|
468
476
|
</div>
|
|
469
477
|
|
|
478
|
+
|
|
479
|
+
|
|
470
480
|
<script src="/marked.min.js"></script>
|
|
471
481
|
<script src="/i18n.js"></script>
|
|
472
482
|
<script src="/theme.js"></script>
|
|
@@ -478,6 +488,7 @@
|
|
|
478
488
|
<script src="/settings.js"></script>
|
|
479
489
|
<script src="/onboard.js"></script>
|
|
480
490
|
<script src="/brand.js"></script>
|
|
491
|
+
<script src="/version.js"></script>
|
|
481
492
|
<script src="/app.js"></script>
|
|
482
493
|
</body>
|
|
483
494
|
</html>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// ── Version — version check and upgrade flow ───────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Badge states:
|
|
4
|
+
// (none) → up-to-date, muted version text
|
|
5
|
+
// has-update → amber pulsing dot: new version available
|
|
6
|
+
// is-upgrading → spinning ring: gem install in progress
|
|
7
|
+
// needs-restart → orange bouncing dot: upgrade done, waiting for restart
|
|
8
|
+
// upgrade-done → green check: restarted & reconnected successfully
|
|
9
|
+
//
|
|
10
|
+
// Flow:
|
|
11
|
+
// 1. Page load → checkVersion() → badge shows version number
|
|
12
|
+
// 2. needs_update: badge shows amber pulsing dot
|
|
13
|
+
// 3. Click badge → fixed popover (confirm state)
|
|
14
|
+
// 4. Click "Upgrade" → popover → progress state (live log)
|
|
15
|
+
// 5. upgrade_complete (success) → badge: needs-restart; popover: restart button
|
|
16
|
+
// 6. Click "Restart" → /api/restart → popover: reconnecting spinner
|
|
17
|
+
// → poll /api/version until server back → badge: upgrade-done (green ✓) → reload
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const Version = (() => {
|
|
21
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
22
|
+
let _current = null;
|
|
23
|
+
let _latest = null;
|
|
24
|
+
let _needsUpdate = false;
|
|
25
|
+
let _upgrading = false;
|
|
26
|
+
let _needsRestart = false; // upgrade done, waiting for restart
|
|
27
|
+
let _reconnecting = false; // restart sent, polling for server to come back
|
|
28
|
+
let _upgradeDone = false; // restarted and reconnected successfully
|
|
29
|
+
let _popoverOpen = false;
|
|
30
|
+
let _reconnectTimer = null;
|
|
31
|
+
let _logLines = [];
|
|
32
|
+
|
|
33
|
+
// ── DOM helpers ────────────────────────────────────────────────────────
|
|
34
|
+
const $ = id => document.getElementById(id);
|
|
35
|
+
const el = (tag, attrs = {}, ...children) => {
|
|
36
|
+
const e = document.createElement(tag);
|
|
37
|
+
Object.entries(attrs).forEach(([k, v]) => {
|
|
38
|
+
if (k === "className") e.className = v;
|
|
39
|
+
else if (k === "innerHTML") e.innerHTML = v;
|
|
40
|
+
else e.setAttribute(k, v);
|
|
41
|
+
});
|
|
42
|
+
children.forEach(c => c && e.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
|
|
43
|
+
return e;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Version check ──────────────────────────────────────────────────────
|
|
47
|
+
async function checkVersion() {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch("/api/version");
|
|
50
|
+
if (!res.ok) return;
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
_current = data.current;
|
|
53
|
+
_latest = data.latest;
|
|
54
|
+
_needsUpdate = !!data.needs_update;
|
|
55
|
+
_renderBadge();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.warn("[Version] check failed:", e);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Badge render ───────────────────────────────────────────────────────
|
|
62
|
+
function _renderBadge() {
|
|
63
|
+
const badge = $("version-badge");
|
|
64
|
+
const text = $("version-text");
|
|
65
|
+
const dot = $("version-update-dot");
|
|
66
|
+
const restartDot = $("version-restart-dot");
|
|
67
|
+
const check = $("version-done-check");
|
|
68
|
+
const spinner = $("version-spinner");
|
|
69
|
+
if (!badge || !text) return;
|
|
70
|
+
|
|
71
|
+
text.textContent = _current ? `v${_current}` : "";
|
|
72
|
+
|
|
73
|
+
// Reset all indicators
|
|
74
|
+
if (dot) dot.style.display = "none";
|
|
75
|
+
if (restartDot) restartDot.style.display = "none";
|
|
76
|
+
if (check) check.style.display = "none";
|
|
77
|
+
if (spinner) spinner.style.display = "none";
|
|
78
|
+
badge.className = "version-badge";
|
|
79
|
+
|
|
80
|
+
if (_upgrading) {
|
|
81
|
+
// Spinning ring: gem install running
|
|
82
|
+
badge.classList.add("is-upgrading");
|
|
83
|
+
badge.title = I18n.t("upgrade.tooltip.upgrading");
|
|
84
|
+
if (spinner) spinner.style.display = "inline-block";
|
|
85
|
+
} else if (_needsRestart) {
|
|
86
|
+
// Orange bouncing dot: upgrade done, please restart
|
|
87
|
+
badge.classList.add("needs-restart");
|
|
88
|
+
badge.title = I18n.t("upgrade.tooltip.needs_restart");
|
|
89
|
+
if (restartDot) restartDot.style.display = "inline-block";
|
|
90
|
+
} else if (_upgradeDone) {
|
|
91
|
+
// Green check: restarted successfully
|
|
92
|
+
badge.classList.add("upgrade-done");
|
|
93
|
+
badge.title = I18n.t("upgrade.tooltip.done");
|
|
94
|
+
if (check) check.style.display = "inline-block";
|
|
95
|
+
} else if (_needsUpdate) {
|
|
96
|
+
// Amber pulsing dot: new version available
|
|
97
|
+
badge.classList.add("has-update");
|
|
98
|
+
badge.title = I18n.t("upgrade.tooltip.new", { latest: _latest });
|
|
99
|
+
if (dot) dot.style.display = "inline-block";
|
|
100
|
+
} else {
|
|
101
|
+
badge.title = I18n.t("upgrade.tooltip.ok", { current: _current });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
badge.style.display = "flex";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Popover (fixed, positioned above badge) ────────────────────────────
|
|
108
|
+
function _getOrCreatePopover() {
|
|
109
|
+
let pop = $("version-upgrade-popover");
|
|
110
|
+
if (pop) return pop;
|
|
111
|
+
|
|
112
|
+
pop = el("div", { id: "version-upgrade-popover", className: "vup" });
|
|
113
|
+
document.body.appendChild(pop);
|
|
114
|
+
return pop;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _positionPopover() {
|
|
118
|
+
const badge = $("version-badge");
|
|
119
|
+
const pop = $("version-upgrade-popover");
|
|
120
|
+
if (!badge || !pop) return;
|
|
121
|
+
|
|
122
|
+
const rect = badge.getBoundingClientRect();
|
|
123
|
+
// Appear above the badge, right-aligned to sidebar edge
|
|
124
|
+
pop.style.left = rect.left + "px";
|
|
125
|
+
pop.style.bottom = (window.innerHeight - rect.top + 8) + "px";
|
|
126
|
+
pop.style.top = "auto";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _openPopover() {
|
|
130
|
+
if (_popoverOpen) { _positionPopover(); return; }
|
|
131
|
+
_popoverOpen = true;
|
|
132
|
+
|
|
133
|
+
const pop = _getOrCreatePopover();
|
|
134
|
+
pop.innerHTML = "";
|
|
135
|
+
|
|
136
|
+
if (_reconnecting) {
|
|
137
|
+
_renderReconnectState(pop);
|
|
138
|
+
} else if (_upgrading) {
|
|
139
|
+
_renderProgressState(pop);
|
|
140
|
+
} else if (_needsRestart) {
|
|
141
|
+
_renderDoneState(pop);
|
|
142
|
+
} else if (_upgradeDone) {
|
|
143
|
+
_renderDoneState(pop);
|
|
144
|
+
} else {
|
|
145
|
+
_renderConfirmState(pop);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pop.style.display = "block";
|
|
149
|
+
_positionPopover();
|
|
150
|
+
|
|
151
|
+
// Animate in
|
|
152
|
+
requestAnimationFrame(() => pop.classList.add("vup--visible"));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _closePopover() {
|
|
156
|
+
// Don't allow closing while upgrading or waiting for server to come back
|
|
157
|
+
if (_upgrading || _reconnecting) return;
|
|
158
|
+
const pop = $("version-upgrade-popover");
|
|
159
|
+
if (!pop) return;
|
|
160
|
+
pop.classList.remove("vup--visible");
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
pop.style.display = "none";
|
|
163
|
+
_popoverOpen = false;
|
|
164
|
+
}, 180);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Popover states ─────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/** State 1: confirm upgrade */
|
|
170
|
+
function _renderConfirmState(pop) {
|
|
171
|
+
pop.innerHTML = `
|
|
172
|
+
<p class="vup-desc">${I18n.t("upgrade.desc")}</p>
|
|
173
|
+
<p class="vup-versions">v${_current} <span class="vup-arrow">→</span> v${_latest}</p>
|
|
174
|
+
<div class="vup-actions">
|
|
175
|
+
<button id="vup-btn-upgrade" class="vup-btn-primary">${I18n.t("upgrade.btn.upgrade")}</button>
|
|
176
|
+
<button id="vup-btn-cancel" class="vup-btn-cancel">${I18n.t("upgrade.btn.cancel")}</button>
|
|
177
|
+
</div>
|
|
178
|
+
`;
|
|
179
|
+
$("vup-btn-upgrade").addEventListener("click", () => _startUpgrade(pop));
|
|
180
|
+
$("vup-btn-cancel").addEventListener("click", _closePopover);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** State 2: upgrading — show live log */
|
|
184
|
+
function _renderProgressState(pop) {
|
|
185
|
+
pop.innerHTML = `
|
|
186
|
+
<div class="vup-progress-header">
|
|
187
|
+
<span class="vup-installing-dot"></span>
|
|
188
|
+
<span class="vup-installing-label">${I18n.t("upgrade.installing")}</span>
|
|
189
|
+
</div>
|
|
190
|
+
<pre id="vup-log" class="vup-log"></pre>
|
|
191
|
+
`;
|
|
192
|
+
// Replay any logs already received
|
|
193
|
+
const logEl = $("vup-log");
|
|
194
|
+
if (logEl && _logLines.length) {
|
|
195
|
+
logEl.textContent = _logLines.join("\n");
|
|
196
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** State 3: done — show restart button */
|
|
201
|
+
function _renderDoneState(pop) {
|
|
202
|
+
pop.innerHTML = `
|
|
203
|
+
<div class="vup-done-header">
|
|
204
|
+
<span class="vup-done-icon">✓</span>
|
|
205
|
+
<span>${I18n.t("upgrade.done")}</span>
|
|
206
|
+
</div>
|
|
207
|
+
<button id="vup-btn-restart" class="vup-btn-restart">${I18n.t("upgrade.btn.restart")}</button>
|
|
208
|
+
`;
|
|
209
|
+
$("vup-btn-restart").addEventListener("click", _startRestart);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** State 4: reconnecting after restart */
|
|
213
|
+
function _renderReconnectState(pop) {
|
|
214
|
+
pop.innerHTML = `
|
|
215
|
+
<div class="vup-reconnect">
|
|
216
|
+
<div class="vup-reconnect-spinner"></div>
|
|
217
|
+
<p class="vup-reconnect-msg">${I18n.t("upgrade.reconnecting")}</p>
|
|
218
|
+
</div>
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Upgrade ────────────────────────────────────────────────────────────
|
|
223
|
+
async function _startUpgrade(pop) {
|
|
224
|
+
if (_upgrading || _upgradeDone) return;
|
|
225
|
+
_upgrading = true;
|
|
226
|
+
_logLines = [];
|
|
227
|
+
_renderBadge();
|
|
228
|
+
_renderProgressState(pop);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await fetch("/api/version/upgrade", { method: "POST" });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
console.warn("[Version] upgrade request failed:", e);
|
|
234
|
+
_upgrading = false;
|
|
235
|
+
_renderBadge();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Restart ────────────────────────────────────────────────────────────
|
|
240
|
+
async function _startRestart() {
|
|
241
|
+
_reconnecting = true;
|
|
242
|
+
|
|
243
|
+
// Ensure popover is open and showing the reconnect spinner
|
|
244
|
+
const pop = _getOrCreatePopover();
|
|
245
|
+
_renderReconnectState(pop);
|
|
246
|
+
if (!_popoverOpen) {
|
|
247
|
+
_popoverOpen = true;
|
|
248
|
+
pop.style.display = "block";
|
|
249
|
+
_positionPopover();
|
|
250
|
+
requestAnimationFrame(() => pop.classList.add("vup--visible"));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
fetch("/api/restart", { method: "POST" }).catch(() => {});
|
|
255
|
+
} catch (_) {}
|
|
256
|
+
|
|
257
|
+
_waitForReconnect();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function _waitForReconnect() {
|
|
261
|
+
if (_reconnectTimer) clearInterval(_reconnectTimer);
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
_reconnectTimer = setInterval(async () => {
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetch("/api/version", { cache: "no-store" });
|
|
266
|
+
if (res.ok) {
|
|
267
|
+
clearInterval(_reconnectTimer);
|
|
268
|
+
_reconnectTimer = null;
|
|
269
|
+
// Server is back — close popover, badge → green check, then reload
|
|
270
|
+
_reconnecting = false;
|
|
271
|
+
_needsRestart = false;
|
|
272
|
+
_upgradeDone = true;
|
|
273
|
+
_renderBadge();
|
|
274
|
+
_closePopover();
|
|
275
|
+
setTimeout(() => window.location.reload(), 800);
|
|
276
|
+
}
|
|
277
|
+
} catch (_) { /* server not yet up */ }
|
|
278
|
+
}, 2000);
|
|
279
|
+
}, 2500);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── WebSocket events ───────────────────────────────────────────────────
|
|
283
|
+
function _handleWsEvent(event) {
|
|
284
|
+
if (event.type === "upgrade_log") {
|
|
285
|
+
const line = event.line || "";
|
|
286
|
+
_logLines.push(line);
|
|
287
|
+
// Append to live log if popover is open
|
|
288
|
+
const logEl = $("vup-log");
|
|
289
|
+
if (logEl) {
|
|
290
|
+
logEl.textContent += (logEl.textContent ? "\n" : "") + line;
|
|
291
|
+
logEl.scrollTop = logEl.scrollHeight;
|
|
292
|
+
}
|
|
293
|
+
} else if (event.type === "upgrade_complete") {
|
|
294
|
+
_upgrading = false;
|
|
295
|
+
_needsUpdate = false;
|
|
296
|
+
if (event.success) {
|
|
297
|
+
_needsRestart = true; // badge: orange bouncing dot
|
|
298
|
+
_upgradeDone = false;
|
|
299
|
+
}
|
|
300
|
+
_renderBadge();
|
|
301
|
+
// Morph popover to done/error state
|
|
302
|
+
const pop = $("version-upgrade-popover");
|
|
303
|
+
if (pop && _popoverOpen) {
|
|
304
|
+
if (event.success) {
|
|
305
|
+
_renderDoneState(pop);
|
|
306
|
+
} else {
|
|
307
|
+
pop.innerHTML = `<p class="vup-error">${I18n.t("upgrade.failed")}</p>`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Init ───────────────────────────────────────────────────────────────
|
|
314
|
+
function init() {
|
|
315
|
+
const badge = $("version-badge");
|
|
316
|
+
if (badge) {
|
|
317
|
+
badge.addEventListener("click", e => {
|
|
318
|
+
if (!_current) return;
|
|
319
|
+
// Up-to-date: no action, let the click fall through to settings btn
|
|
320
|
+
if (!_needsUpdate && !_upgrading && !_needsRestart && !_reconnecting && !_upgradeDone) return;
|
|
321
|
+
|
|
322
|
+
// Actionable: stop propagation so settings panel doesn't open
|
|
323
|
+
e.stopPropagation();
|
|
324
|
+
|
|
325
|
+
// During reconnect, badge click just keeps popover visible (no toggle)
|
|
326
|
+
if (_reconnecting) { if (!_popoverOpen) _openPopover(); return; }
|
|
327
|
+
|
|
328
|
+
if (_popoverOpen) {
|
|
329
|
+
_closePopover();
|
|
330
|
+
} else {
|
|
331
|
+
_openPopover();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Close on outside click — but not while upgrading or reconnecting
|
|
337
|
+
document.addEventListener("click", e => {
|
|
338
|
+
if (!e.target.closest("#version-badge") && !e.target.closest("#version-upgrade-popover")) {
|
|
339
|
+
if (_popoverOpen && !_upgrading && !_reconnecting) _closePopover();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Reposition on window resize
|
|
344
|
+
window.addEventListener("resize", () => {
|
|
345
|
+
if (_popoverOpen) _positionPopover();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
if (typeof WS !== "undefined") {
|
|
349
|
+
WS.onEvent(_handleWsEvent);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
checkVersion();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (document.readyState === "loading") {
|
|
356
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
357
|
+
} else {
|
|
358
|
+
init();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { checkVersion };
|
|
362
|
+
})();
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openclacky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -394,6 +394,7 @@ files:
|
|
|
394
394
|
- lib/clacky/web/skills.js
|
|
395
395
|
- lib/clacky/web/tasks.js
|
|
396
396
|
- lib/clacky/web/theme.js
|
|
397
|
+
- lib/clacky/web/version.js
|
|
397
398
|
- lib/clacky/web/ws.js
|
|
398
399
|
- scripts/install.sh
|
|
399
400
|
- scripts/uninstall.sh
|