openclacky 0.9.24 → 0.9.25
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 +22 -0
- data/lib/clacky/agent/message_compressor_helper.rb +5 -1
- data/lib/clacky/agent/session_serializer.rb +31 -4
- data/lib/clacky/default_skills/channel-setup/SKILL.md +24 -8
- data/lib/clacky/default_skills/product-help/SKILL.md +14 -1
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +47 -14
- data/lib/clacky/server/http_server.rb +72 -16
- data/lib/clacky/server/server_master.rb +1 -0
- data/lib/clacky/server/session_registry.rb +11 -6
- data/lib/clacky/tools/safe_shell.rb +4 -0
- data/lib/clacky/tools/shell.rb +66 -28
- data/lib/clacky/utils/file_processor.rb +34 -2
- data/lib/clacky/utils/scripts_manager.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.js +4 -0
- data/lib/clacky/web/index.html +1 -1
- data/lib/clacky/web/sessions.js +0 -1
- data/lib/clacky/web/weixin-qr.html +108 -3
- data/scripts/install.ps1 +1 -1
- data/scripts/install_browser.sh +48 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 931a504ff578fcee85cf5d361feeca8b772a0aafe3438c4f27cc7da52573453a
|
|
4
|
+
data.tar.gz: f1cef39e28c531fef2b2b0fb3cf435ffb05729fdeaae12dadb4e338df073ec1e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b8bc2a7dd702beb4570e40264b63b75f170c26dea56a354b20c9afdd1d97fb0ef1e2a8010457170d23823ed6bc82353bbfd390c9ef8f6137ad9bbf73e15ee17
|
|
7
|
+
data.tar.gz: dc13201eda667f31435309a3664503fd53f1973983e0494f2b29b2ff5c0fca336b9801c265aa18830e1878e0a970237de16ab41833992119d8ea24f2eea66b62
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.25] - 2026-04-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **CSV file upload support**: you can now upload `.csv` files in the Web UI — agent can read and analyse tabular data directly
|
|
14
|
+
- **Browser install tips**: when a browser-dependent command fails, the agent now shows a clear install tip with instructions to set up Chrome/Edge, rather than a cryptic error
|
|
15
|
+
- **Auto-focus on file upload dialog**: the file input field is now auto-focused when the upload dialog opens, improving keyboard UX
|
|
16
|
+
- **Session ID search in Web UI**: you can now search sessions by session ID in addition to session name
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **WeChat (Weixin) file upload**: fixed a bug where file attachments sent via WeChat were not correctly forwarded to the agent
|
|
20
|
+
- **WeChat without browser**: WeChat channel now works even when no browser tool is configured — falls back gracefully
|
|
21
|
+
- **API message timeout**: fixed a race condition in message compression / session serialisation that could cause API requests to time out mid-conversation
|
|
22
|
+
- **Session chunk replay**: fixed a bug where streaming (chunk-based) messages were incorrectly replayed when restoring a session
|
|
23
|
+
|
|
24
|
+
### Improved
|
|
25
|
+
- **Shell tool robustness**: `pkill` commands are now scope-limited to prevent accidental process kills; server process cleans up properly when the terminal is closed
|
|
26
|
+
- **Broken pipe handling**: improved error handling in the HTTP server and shell tool to avoid noisy broken-pipe errors on abrupt connection close
|
|
27
|
+
|
|
28
|
+
### More
|
|
29
|
+
- Updated product-help skill with new session search and CSV upload documentation
|
|
30
|
+
- Updated channel-setup skill with improved WeChat non-browser setup guide
|
|
31
|
+
|
|
10
32
|
## [0.9.24] - 2026-04-02
|
|
11
33
|
|
|
12
34
|
### Added
|
|
@@ -128,7 +128,11 @@ module Clacky
|
|
|
128
128
|
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
129
129
|
|
|
130
130
|
# Archive compressed messages to a chunk MD file before discarding them
|
|
131
|
-
|
|
131
|
+
# Count existing compressed_summary messages in history to determine the next chunk index.
|
|
132
|
+
# Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
|
|
133
|
+
# chunk files, creating circular chunk references. Counting from history is always accurate.
|
|
134
|
+
existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
|
|
135
|
+
chunk_index = existing_chunk_count + 1
|
|
132
136
|
chunk_path = save_compressed_chunk(
|
|
133
137
|
original_messages,
|
|
134
138
|
compression_context[:recent_messages],
|
|
@@ -152,6 +152,16 @@ module Clacky
|
|
|
152
152
|
# Compressed summary sitting before any user rounds — expand it from chunk md
|
|
153
153
|
chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
|
|
154
154
|
rounds.concat(chunk_rounds)
|
|
155
|
+
# After expanding, treat the last chunk round as the current round so that
|
|
156
|
+
# any orphaned assistant/tool messages that follow in session.json (belonging
|
|
157
|
+
# to the same task whose user message was compressed into the chunk) get
|
|
158
|
+
# appended here instead of being silently discarded.
|
|
159
|
+
current_round = rounds.last
|
|
160
|
+
elsif rounds.last
|
|
161
|
+
# Orphaned non-user message with no current_round yet (e.g. recent_messages
|
|
162
|
+
# after compression started mid-task with no leading user message).
|
|
163
|
+
# Attach to the last known round rather than drop silently.
|
|
164
|
+
rounds.last[:events] << msg
|
|
155
165
|
end
|
|
156
166
|
end
|
|
157
167
|
|
|
@@ -211,10 +221,27 @@ module Clacky
|
|
|
211
221
|
#
|
|
212
222
|
# @param chunk_path [String] Path to the chunk md file
|
|
213
223
|
# @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
|
|
214
|
-
private def parse_chunk_md_to_rounds(chunk_path)
|
|
215
|
-
return [] unless chunk_path
|
|
224
|
+
private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new)
|
|
225
|
+
return [] unless chunk_path
|
|
226
|
+
|
|
227
|
+
# 1. Try the stored absolute path first (same machine, normal case).
|
|
228
|
+
# 2. If not found, fall back to basename + SESSIONS_DIR (cross-user / cross-machine).
|
|
229
|
+
resolved = chunk_path.to_s
|
|
230
|
+
unless File.exist?(resolved)
|
|
231
|
+
resolved = File.join(Clacky::SessionManager::SESSIONS_DIR, File.basename(resolved))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
return [] unless File.exist?(resolved)
|
|
235
|
+
|
|
236
|
+
# Guard against circular chunk references (e.g. chunk-3 → chunk-2 → chunk-1 → chunk-9 → … → chunk-3)
|
|
237
|
+
canonical = File.expand_path(resolved)
|
|
238
|
+
if visited.include?(canonical)
|
|
239
|
+
Clacky::Logger.warn("parse_chunk_md_to_rounds: circular reference detected, skipping #{canonical}")
|
|
240
|
+
return []
|
|
241
|
+
end
|
|
242
|
+
visited = visited.dup.add(canonical)
|
|
216
243
|
|
|
217
|
-
raw = File.read(
|
|
244
|
+
raw = File.read(resolved)
|
|
218
245
|
|
|
219
246
|
# Parse YAML front matter to get archived_at for synthetic timestamps
|
|
220
247
|
archived_at = nil
|
|
@@ -275,7 +302,7 @@ module Clacky
|
|
|
275
302
|
|
|
276
303
|
# Nested chunk: expand it recursively, prepend before current rounds
|
|
277
304
|
if sec[:nested_chunk]
|
|
278
|
-
nested = parse_chunk_md_to_rounds(sec[:nested_chunk])
|
|
305
|
+
nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
|
|
279
306
|
rounds = nested + rounds unless nested.empty?
|
|
280
307
|
# Also render its summary text as an assistant event in current round if any
|
|
281
308
|
if current_round && !text.empty?
|
|
@@ -206,7 +206,7 @@ On success: "✅ WeCom channel configured. WeCom client → Contacts → Smart B
|
|
|
206
206
|
|
|
207
207
|
Weixin uses a QR code login — no app_id/app_secret needed. The token from the QR scan is saved directly in `channels.yml`.
|
|
208
208
|
|
|
209
|
-
#### Step 1 — Fetch QR code
|
|
209
|
+
#### Step 1 — Fetch QR code
|
|
210
210
|
|
|
211
211
|
Run the script in `--fetch-qr` mode to get the QR URL without blocking:
|
|
212
212
|
|
|
@@ -221,17 +221,29 @@ Parse the JSON output:
|
|
|
221
221
|
|
|
222
222
|
If the output contains `"error"`, show it and stop.
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
> Opening the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
|
|
224
|
+
#### Step 2 — Show QR code to user (browser or manual fallback)
|
|
226
225
|
|
|
227
|
-
|
|
226
|
+
Build the local QR page URL (include current Unix timestamp as `since` to detect new logins only):
|
|
227
|
+
```
|
|
228
|
+
http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>&since=<current_unix_timestamp>
|
|
229
|
+
```
|
|
228
230
|
|
|
231
|
+
**Try browser first** — attempt to open the QR page using the browser tool:
|
|
229
232
|
```
|
|
230
|
-
|
|
233
|
+
browser(action="navigate", url="<qr_page_url>")
|
|
231
234
|
```
|
|
232
235
|
|
|
233
|
-
|
|
234
|
-
|
|
236
|
+
**If browser succeeds:** Tell the user:
|
|
237
|
+
> I've opened the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
|
|
238
|
+
|
|
239
|
+
**If browser fails (not configured or unavailable):** Fall back to manual — tell the user:
|
|
240
|
+
> Please open the following link in your browser to scan the WeChat QR code:
|
|
241
|
+
>
|
|
242
|
+
> `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
|
|
243
|
+
>
|
|
244
|
+
> Scan the QR code with WeChat, confirm in the app, then reply "done".
|
|
245
|
+
|
|
246
|
+
The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
|
|
235
247
|
|
|
236
248
|
#### Step 3 — Wait for scan and save credentials
|
|
237
249
|
|
|
@@ -243,7 +255,11 @@ ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
|
|
|
243
255
|
|
|
244
256
|
Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
|
|
245
257
|
|
|
246
|
-
|
|
258
|
+
Run this command with `timeout: 60`. If it doesn't succeed, **retry up to 3 times with the same `$QRCODE_ID`** — the QR code stays valid for 5 minutes. Only stop retrying if:
|
|
259
|
+
- Exit code is 0 → success
|
|
260
|
+
- Output contains "expired" → QR expired, offer to restart from Step 1
|
|
261
|
+
- Output contains "timed out" → offer to restart from Step 1
|
|
262
|
+
- 3 retries exhausted → show error and offer to restart from Step 1
|
|
247
263
|
|
|
248
264
|
Tell the user while waiting:
|
|
249
265
|
> Waiting for you to scan the QR code and confirm in WeChat... (this may take a moment)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: product-help
|
|
3
|
-
description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, or
|
|
3
|
+
description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, troubleshooting, or restarting the server. Do NOT trigger for general coding tasks unrelated to me.'
|
|
4
4
|
fork_agent: true
|
|
5
5
|
user-invocable: false
|
|
6
6
|
auto_summarize: true
|
|
@@ -90,3 +90,16 @@ web_fetch(url: "<URL>", max_length: 5000)
|
|
|
90
90
|
- If the fetched page doesn't answer the question, try the next most relevant URL (max 2 fetches)
|
|
91
91
|
- If still no answer, tell the user: "请访问 https://www.openclacky.com/docs 查看完整文档"
|
|
92
92
|
- Keep answers concise — extract what's relevant, don't paste the whole page
|
|
93
|
+
|
|
94
|
+
## Restarting the server
|
|
95
|
+
|
|
96
|
+
If the user asks to restart the clacky/openclacky server (e.g. "重启", "restart", "请重启openclacky"):
|
|
97
|
+
|
|
98
|
+
**Do NOT fetch any docs.** Just return this answer directly:
|
|
99
|
+
|
|
100
|
+
> To restart the server gracefully (hot restart, zero downtime):
|
|
101
|
+
> ```
|
|
102
|
+
> kill -USR1 $CLACKY_MASTER_PID
|
|
103
|
+
> ```
|
|
104
|
+
> This sends USR1 to the Master process, which spawns a new Worker and gracefully stops the old one.
|
|
105
|
+
> The `$CLACKY_MASTER_PID` environment variable is already set in the current session.
|
|
@@ -260,14 +260,17 @@ module Clacky
|
|
|
260
260
|
# Extract and materialize file attachments from an inbound item_list.
|
|
261
261
|
#
|
|
262
262
|
# Images are downloaded from CDN and converted to data_url so the agent's
|
|
263
|
-
# vision pipeline (partition_files → resolve_vision_images) picks them up
|
|
264
|
-
#
|
|
265
|
-
#
|
|
263
|
+
# vision pipeline (partition_files → resolve_vision_images) picks them up.
|
|
264
|
+
# Files (PDF, DOCX, etc.) are downloaded to clacky-uploads so the agent's
|
|
265
|
+
# file processing pipeline (process_path) can parse them.
|
|
266
|
+
# Voice/video are kept as cdn_media metadata only (no local download).
|
|
266
267
|
#
|
|
267
|
-
# Returns Array of Hashes. Image entries
|
|
268
|
+
# Returns Array of Hashes. Image entries:
|
|
268
269
|
# { type: :image, name: String, mime_type: String, data_url: String }
|
|
269
|
-
#
|
|
270
|
-
# { type: :file
|
|
270
|
+
# File entries (downloaded):
|
|
271
|
+
# { type: :file, name: String, path: String }
|
|
272
|
+
# Voice/video entries:
|
|
273
|
+
# { type: :voice/:video, name: String, cdn_media: Hash }
|
|
271
274
|
def extract_files(item_list)
|
|
272
275
|
files = []
|
|
273
276
|
item_list.each do |item|
|
|
@@ -311,16 +314,46 @@ module Clacky
|
|
|
311
314
|
name: "voice.amr",
|
|
312
315
|
cdn_media: v["media"]
|
|
313
316
|
}
|
|
314
|
-
when 4 # FILE
|
|
317
|
+
when 4 # FILE — download to disk so agent can parse it
|
|
315
318
|
fi = item["file_item"]
|
|
316
319
|
next unless fi
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
320
|
+
cdn_media = fi["media"]
|
|
321
|
+
file_name = fi["file_name"].to_s
|
|
322
|
+
file_name = "attachment" if file_name.empty?
|
|
323
|
+
file_md5 = fi["md5"].to_s
|
|
324
|
+
file_len = fi["len"].to_s
|
|
325
|
+
|
|
326
|
+
if cdn_media
|
|
327
|
+
begin
|
|
328
|
+
raw_bytes = @api_client.download_media(cdn_media, ApiClient::MEDIA_TYPE_FILE)
|
|
329
|
+
saved = Clacky::Utils::FileProcessor.save(body: raw_bytes, filename: file_name)
|
|
330
|
+
Clacky::Logger.info("[WeixinAdapter] file downloaded to #{saved[:path]} (#{raw_bytes.bytesize} bytes)")
|
|
331
|
+
files << {
|
|
332
|
+
type: :file,
|
|
333
|
+
name: saved[:name],
|
|
334
|
+
path: saved[:path],
|
|
335
|
+
md5: file_md5.empty? ? nil : file_md5,
|
|
336
|
+
len: file_len.empty? ? nil : file_len
|
|
337
|
+
}
|
|
338
|
+
rescue => e
|
|
339
|
+
Clacky::Logger.warn("[WeixinAdapter] Failed to download file #{file_name}: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
|
|
340
|
+
# Fall back to metadata-only so the agent at least knows a file was attached
|
|
341
|
+
files << {
|
|
342
|
+
type: :file,
|
|
343
|
+
name: file_name,
|
|
344
|
+
cdn_media: cdn_media,
|
|
345
|
+
md5: file_md5.empty? ? nil : file_md5,
|
|
346
|
+
len: file_len.empty? ? nil : file_len
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
else
|
|
350
|
+
files << {
|
|
351
|
+
type: :file,
|
|
352
|
+
name: file_name,
|
|
353
|
+
md5: file_md5.empty? ? nil : file_md5,
|
|
354
|
+
len: file_len.empty? ? nil : file_len
|
|
355
|
+
}
|
|
356
|
+
end
|
|
324
357
|
when 5 # VIDEO
|
|
325
358
|
vi = item["video_item"]
|
|
326
359
|
next unless vi
|
|
@@ -9,6 +9,7 @@ require "tmpdir"
|
|
|
9
9
|
require "uri"
|
|
10
10
|
require "open3"
|
|
11
11
|
require "securerandom"
|
|
12
|
+
require "timeout"
|
|
12
13
|
require_relative "session_registry"
|
|
13
14
|
require_relative "web_ui_controller"
|
|
14
15
|
require_relative "scheduler"
|
|
@@ -317,12 +318,32 @@ module Clacky
|
|
|
317
318
|
path = req.path
|
|
318
319
|
method = req.request_method
|
|
319
320
|
|
|
320
|
-
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# WebSocket upgrade — no timeout applied (long-lived connection)
|
|
321
324
|
if websocket_upgrade?(req)
|
|
322
325
|
handle_websocket(req, res)
|
|
323
326
|
return
|
|
324
327
|
end
|
|
325
328
|
|
|
329
|
+
# Wrap all REST handlers in a timeout so a hung handler (e.g. infinite
|
|
330
|
+
# recursion in chunk parsing) returns a proper 503 instead of an empty 200.
|
|
331
|
+
timeout_sec = 10
|
|
332
|
+
Timeout.timeout(timeout_sec) do
|
|
333
|
+
_dispatch_rest(req, res)
|
|
334
|
+
end
|
|
335
|
+
rescue Timeout::Error
|
|
336
|
+
Clacky::Logger.warn("[HTTP 503] #{method} #{path} timed out after #{timeout_sec}s")
|
|
337
|
+
json_response(res, 503, { error: "Request timed out" })
|
|
338
|
+
rescue => e
|
|
339
|
+
Clacky::Logger.warn("[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
|
340
|
+
json_response(res, 500, { error: e.message })
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def _dispatch_rest(req, res)
|
|
344
|
+
path = req.path
|
|
345
|
+
method = req.request_method
|
|
346
|
+
|
|
326
347
|
case [method, path]
|
|
327
348
|
when ["GET", "/api/sessions"] then api_list_sessions(req, res)
|
|
328
349
|
when ["POST", "/api/sessions"] then api_create_session(req, res)
|
|
@@ -395,9 +416,6 @@ module Clacky
|
|
|
395
416
|
not_found(res)
|
|
396
417
|
end
|
|
397
418
|
end
|
|
398
|
-
rescue => e
|
|
399
|
-
$stderr.puts "[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
400
|
-
json_response(res, 500, { error: e.message })
|
|
401
419
|
end
|
|
402
420
|
|
|
403
421
|
# ── REST API ──────────────────────────────────────────────────────────────
|
|
@@ -1102,6 +1120,9 @@ module Clacky
|
|
|
1102
1120
|
fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
|
|
1103
1121
|
fields = fields.transform_values { |v| v.is_a?(String) ? v.strip : v }
|
|
1104
1122
|
|
|
1123
|
+
# Record when the token was last updated so clients can detect re-login
|
|
1124
|
+
fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
|
|
1125
|
+
|
|
1105
1126
|
# Validate credentials against live API before persisting.
|
|
1106
1127
|
# Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
|
|
1107
1128
|
klass = Clacky::Channel::Adapters.find(platform)
|
|
@@ -1177,10 +1198,10 @@ module Clacky
|
|
|
1177
1198
|
}
|
|
1178
1199
|
when :weixin
|
|
1179
1200
|
{
|
|
1180
|
-
base_url:
|
|
1181
|
-
allowed_users:
|
|
1182
|
-
|
|
1183
|
-
|
|
1201
|
+
base_url: raw["base_url"] || Clacky::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
|
|
1202
|
+
allowed_users: raw["allowed_users"] || [],
|
|
1203
|
+
has_token: !raw["token"].to_s.strip.empty?,
|
|
1204
|
+
token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
|
|
1184
1205
|
}
|
|
1185
1206
|
else
|
|
1186
1207
|
{}
|
|
@@ -1624,6 +1645,7 @@ module Clacky
|
|
|
1624
1645
|
# Returns a list of UI events (same format as WS events) for the frontend to render.
|
|
1625
1646
|
def api_session_messages(session_id, req, res)
|
|
1626
1647
|
unless @registry.ensure(session_id)
|
|
1648
|
+
Clacky::Logger.warn("[messages] registry.ensure failed", session_id: session_id)
|
|
1627
1649
|
return json_response(res, 404, { error: "Session not found" })
|
|
1628
1650
|
end
|
|
1629
1651
|
|
|
@@ -1636,6 +1658,7 @@ module Clacky
|
|
|
1636
1658
|
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
1637
1659
|
|
|
1638
1660
|
unless agent
|
|
1661
|
+
Clacky::Logger.warn("[messages] agent is nil", session_id: session_id)
|
|
1639
1662
|
return json_response(res, 200, { events: [], has_more: false })
|
|
1640
1663
|
end
|
|
1641
1664
|
|
|
@@ -1691,7 +1714,7 @@ module Clacky
|
|
|
1691
1714
|
handshake = WebSocket::Handshake::Server.new
|
|
1692
1715
|
handshake << build_handshake_request(req)
|
|
1693
1716
|
unless handshake.finished? && handshake.valid?
|
|
1694
|
-
|
|
1717
|
+
Clacky::Logger.warn("WebSocket handshake invalid")
|
|
1695
1718
|
return
|
|
1696
1719
|
end
|
|
1697
1720
|
|
|
@@ -1730,8 +1753,8 @@ module Clacky
|
|
|
1730
1753
|
end
|
|
1731
1754
|
end
|
|
1732
1755
|
end
|
|
1733
|
-
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
1734
|
-
# Client disconnected
|
|
1756
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
|
|
1757
|
+
# Client disconnected or socket became invalid
|
|
1735
1758
|
ensure
|
|
1736
1759
|
on_ws_close(conn)
|
|
1737
1760
|
socket.close rescue nil
|
|
@@ -1741,7 +1764,7 @@ module Clacky
|
|
|
1741
1764
|
res.instance_variable_set(:@header, {})
|
|
1742
1765
|
res.status = -1
|
|
1743
1766
|
rescue => e
|
|
1744
|
-
|
|
1767
|
+
Clacky::Logger.error("WebSocket handler error: #{e.class}: #{e.message}")
|
|
1745
1768
|
end
|
|
1746
1769
|
|
|
1747
1770
|
# Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
|
|
@@ -1946,15 +1969,28 @@ module Clacky
|
|
|
1946
1969
|
end
|
|
1947
1970
|
|
|
1948
1971
|
# Broadcast an event to all clients subscribed to a session.
|
|
1972
|
+
# Dead connections (broken pipe / closed socket) are removed automatically.
|
|
1949
1973
|
def broadcast(session_id, event)
|
|
1950
1974
|
clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
|
|
1951
|
-
clients.
|
|
1975
|
+
dead = clients.reject { |conn| conn.send_json(event) }
|
|
1976
|
+
return if dead.empty?
|
|
1977
|
+
|
|
1978
|
+
@ws_mutex.synchronize do
|
|
1979
|
+
(@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
|
|
1980
|
+
end
|
|
1952
1981
|
end
|
|
1953
1982
|
|
|
1954
1983
|
# Broadcast an event to every connected client (regardless of session subscription).
|
|
1984
|
+
# Dead connections are removed automatically.
|
|
1955
1985
|
def broadcast_all(event)
|
|
1956
1986
|
clients = @ws_mutex.synchronize { @all_ws_conns.dup }
|
|
1957
|
-
clients.
|
|
1987
|
+
dead = clients.reject { |conn| conn.send_json(event) }
|
|
1988
|
+
return if dead.empty?
|
|
1989
|
+
|
|
1990
|
+
@ws_mutex.synchronize do
|
|
1991
|
+
@all_ws_conns.reject! { |conn| dead.include?(conn) }
|
|
1992
|
+
@ws_clients.each_value { |list| list.reject! { |conn| dead.include?(conn) } }
|
|
1993
|
+
end
|
|
1958
1994
|
end
|
|
1959
1995
|
|
|
1960
1996
|
# Broadcast a session_update event to all clients so they can patch their
|
|
@@ -2163,16 +2199,29 @@ module Clacky
|
|
|
2163
2199
|
@socket = socket
|
|
2164
2200
|
@version = version
|
|
2165
2201
|
@send_mutex = Mutex.new
|
|
2202
|
+
@closed = false
|
|
2166
2203
|
end
|
|
2167
2204
|
|
|
2205
|
+
# Returns true if the underlying socket has been detected as dead.
|
|
2206
|
+
def closed?
|
|
2207
|
+
@closed
|
|
2208
|
+
end
|
|
2209
|
+
|
|
2210
|
+
# Send a JSON-serializable object over the WebSocket.
|
|
2211
|
+
# Returns true on success, false if the connection is dead.
|
|
2168
2212
|
def send_json(data)
|
|
2169
2213
|
send_raw(:text, JSON.generate(data))
|
|
2170
2214
|
rescue => e
|
|
2171
|
-
|
|
2215
|
+
Clacky::Logger.debug("WS send error (connection dead): #{e.message}")
|
|
2216
|
+
false
|
|
2172
2217
|
end
|
|
2173
2218
|
|
|
2219
|
+
# Send a raw WebSocket frame.
|
|
2220
|
+
# Returns true on success, false on broken/closed socket.
|
|
2174
2221
|
def send_raw(type, data)
|
|
2175
2222
|
@send_mutex.synchronize do
|
|
2223
|
+
return false if @closed
|
|
2224
|
+
|
|
2176
2225
|
outgoing = WebSocket::Frame::Outgoing::Server.new(
|
|
2177
2226
|
version: @version,
|
|
2178
2227
|
data: data,
|
|
@@ -2180,8 +2229,15 @@ module Clacky
|
|
|
2180
2229
|
)
|
|
2181
2230
|
@socket.write(outgoing.to_s)
|
|
2182
2231
|
end
|
|
2232
|
+
true
|
|
2233
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
|
|
2234
|
+
@closed = true
|
|
2235
|
+
Clacky::Logger.debug("WS send_raw error (client disconnected): #{e.message}")
|
|
2236
|
+
false
|
|
2183
2237
|
rescue => e
|
|
2184
|
-
|
|
2238
|
+
@closed = true
|
|
2239
|
+
Clacky::Logger.debug("WS send_raw unexpected error: #{e.message}")
|
|
2240
|
+
false
|
|
2185
2241
|
end
|
|
2186
2242
|
end
|
|
2187
2243
|
end
|
|
@@ -58,6 +58,7 @@ module Clacky
|
|
|
58
58
|
Signal.trap("USR1") { @restart_requested = true }
|
|
59
59
|
Signal.trap("TERM") { @shutdown_requested = true }
|
|
60
60
|
Signal.trap("INT") { @shutdown_requested = true }
|
|
61
|
+
Signal.trap("HUP") { @shutdown_requested = true }
|
|
61
62
|
|
|
62
63
|
# 4. Spawn first worker
|
|
63
64
|
@worker_pid = spawn_worker
|
|
@@ -64,15 +64,17 @@ module Clacky
|
|
|
64
64
|
session_data = nil
|
|
65
65
|
|
|
66
66
|
@mutex.synchronize do
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
# Another thread is currently restoring this session — wait for it.
|
|
67
|
+
# Another thread is currently restoring this session (including the case where
|
|
68
|
+
# @registry.create was already called but with_session agent-set is not yet done) —
|
|
69
|
+
# wait for it to finish so callers never see agent=nil.
|
|
71
70
|
if @restoring[session_id]
|
|
72
71
|
@restore_cond.wait(@mutex) until !@restoring[session_id]
|
|
73
72
|
return @sessions.key?(session_id)
|
|
74
73
|
end
|
|
75
74
|
|
|
75
|
+
# Already fully ready (not being restored) — fast path.
|
|
76
|
+
return true if @sessions.key?(session_id)
|
|
77
|
+
|
|
76
78
|
return false unless @session_manager && @session_restorer
|
|
77
79
|
|
|
78
80
|
session_data = @session_manager.load(session_id)
|
|
@@ -174,10 +176,13 @@ module Clacky
|
|
|
174
176
|
# ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
|
|
175
177
|
all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
|
|
176
178
|
|
|
177
|
-
# ── name search
|
|
179
|
+
# ── name / id search ─────────────────────────────────────────────────
|
|
178
180
|
if q && !q.empty?
|
|
179
181
|
q_down = q.downcase
|
|
180
|
-
all = all.select { |s|
|
|
182
|
+
all = all.select { |s|
|
|
183
|
+
(s[:name] || "").downcase.include?(q_down) ||
|
|
184
|
+
(s[:session_id] || "").downcase.include?(q_down)
|
|
185
|
+
}
|
|
181
186
|
end
|
|
182
187
|
|
|
183
188
|
all = all.select { |s| (s[:created_at] || "") < before } if before
|
|
@@ -341,6 +341,10 @@ module Clacky
|
|
|
341
341
|
@safe_check_command = Clacky::Utils::Encoding.safe_check(command)
|
|
342
342
|
|
|
343
343
|
case @safe_check_command
|
|
344
|
+
when /pkill.*clacky|killall.*clacky|kill\s+.*\bclacky\b/i
|
|
345
|
+
raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
|
|
346
|
+
when /clacky\s+server/
|
|
347
|
+
raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
|
|
344
348
|
when /^rm\s+/
|
|
345
349
|
replace_rm_command(command)
|
|
346
350
|
when /^chmod\s+x/
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -63,7 +63,7 @@ module Clacky
|
|
|
63
63
|
[/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
|
|
64
64
|
[/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^\>\s*$/, 'repl'],
|
|
65
65
|
[/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
|
|
66
|
-
[/Are you sure|Continue\?|Proceed\?|
|
|
66
|
+
[/Are you sure|Continue\?|Proceed\?|\bConfirm\b|\bConfirm\?|Overwrite/i, 'question'],
|
|
67
67
|
[/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
|
|
68
68
|
[/Select an option|Choose|Which one|select one/i, 'selection']
|
|
69
69
|
].freeze
|
|
@@ -251,45 +251,83 @@ module Clacky
|
|
|
251
251
|
end
|
|
252
252
|
end
|
|
253
253
|
|
|
254
|
-
# Wrap command in a login shell
|
|
255
|
-
#
|
|
254
|
+
# Wrap command in a login shell when shell config files have changed since
|
|
255
|
+
# the last check. This ensures PATH updates (nvm, rbenv, mise, brew, etc.)
|
|
256
|
+
# are picked up without paying the ~1s startup cost on every command.
|
|
256
257
|
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
258
|
+
# Strategy:
|
|
259
|
+
# - On first call: snapshot MD5 hashes of all shell config files in memory.
|
|
260
|
+
# - On subsequent calls: re-hash and compare. If any file changed, use
|
|
261
|
+
# `shell -l -i -c ...` once to get the fresh environment, then update snapshot.
|
|
262
|
+
# - If nothing changed: run command directly (zero overhead).
|
|
261
263
|
#
|
|
262
|
-
# -
|
|
263
|
-
#
|
|
264
|
-
#
|
|
264
|
+
# We avoid -i (interactive) in normal mode because it causes SIGTTIN when the
|
|
265
|
+
# process is not in the terminal's foreground group (pgroup: 0).
|
|
266
|
+
# When configs have changed we do use -l -i so that all shell init hooks
|
|
267
|
+
# (nvm, rbenv init, mise activate, etc.) run in the correct order.
|
|
265
268
|
def wrap_with_shell(command)
|
|
266
|
-
|
|
267
|
-
# shell = '/bin/bash' if shell.empty?
|
|
269
|
+
shell = current_shell
|
|
268
270
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
+
if shell_configs_changed?(shell)
|
|
272
|
+
"#{shell} -l -i -c #{Shellwords.escape(command)}"
|
|
273
|
+
else
|
|
274
|
+
command
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Detect the user's current shell binary.
|
|
279
|
+
private def current_shell
|
|
280
|
+
shell = ENV['SHELL'].to_s
|
|
281
|
+
shell.empty? ? '/bin/bash' : shell
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Returns true (and updates the in-memory snapshot) when any shell config
|
|
285
|
+
# file has changed since the last call. Returns false on the very first call
|
|
286
|
+
# so the initial snapshot is established without triggering a reload.
|
|
287
|
+
#
|
|
288
|
+
# Config files checked per shell:
|
|
289
|
+
# zsh — ~/.zshrc, ~/.zprofile, ~/.zshenv
|
|
290
|
+
# bash — ~/.bashrc, ~/.bash_profile, ~/.profile
|
|
291
|
+
# fish — ~/.config/fish/config.fish
|
|
292
|
+
# (all shells also check ~/.profile as a fallback)
|
|
293
|
+
private def shell_configs_changed?(shell)
|
|
294
|
+
require "digest"
|
|
295
|
+
|
|
296
|
+
current = shell_config_hashes(shell)
|
|
297
|
+
|
|
298
|
+
if @shell_config_hashes.nil?
|
|
299
|
+
# First call: establish baseline, do NOT trigger reload
|
|
300
|
+
@shell_config_hashes = current
|
|
301
|
+
return false
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
if current != @shell_config_hashes
|
|
305
|
+
@shell_config_hashes = current
|
|
306
|
+
return true
|
|
307
|
+
end
|
|
271
308
|
|
|
272
|
-
|
|
273
|
-
return command
|
|
309
|
+
false
|
|
274
310
|
end
|
|
275
311
|
|
|
276
|
-
# Returns a
|
|
277
|
-
|
|
278
|
-
private def rc_source_snippet(shell)
|
|
312
|
+
# Returns a hash of { filepath => md5_hex } for existing config files.
|
|
313
|
+
private def shell_config_hashes(shell)
|
|
279
314
|
shell_name = File.basename(shell)
|
|
280
315
|
home = ENV['HOME'].to_s
|
|
281
316
|
|
|
282
|
-
|
|
283
|
-
when 'zsh'
|
|
284
|
-
|
|
285
|
-
when '
|
|
317
|
+
files = case shell_name
|
|
318
|
+
when 'zsh'
|
|
319
|
+
%w[.zshrc .zprofile .zshenv].map { |f| File.join(home, f) }
|
|
320
|
+
when 'bash'
|
|
321
|
+
%w[.bashrc .bash_profile .profile].map { |f| File.join(home, f) }
|
|
322
|
+
when 'fish'
|
|
323
|
+
[File.join(home, '.config', 'fish', 'config.fish')]
|
|
324
|
+
else
|
|
325
|
+
[File.join(home, '.profile')]
|
|
286
326
|
end
|
|
287
327
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
# errors in the user's rc file don't prevent the command from running.
|
|
292
|
-
"source #{Shellwords.escape(rc_file)} 2>/dev/null;"
|
|
328
|
+
files
|
|
329
|
+
.select { |f| File.exist?(f) }
|
|
330
|
+
.to_h { |f| [f, Digest::MD5.file(f).hexdigest] }
|
|
293
331
|
end
|
|
294
332
|
|
|
295
333
|
def determine_timeouts(command, soft_timeout, hard_timeout)
|
|
@@ -62,7 +62,8 @@ module Clacky
|
|
|
62
62
|
".pdf" => :pdf,
|
|
63
63
|
".zip" => :zip, ".gz" => :zip, ".tar" => :zip, ".rar" => :zip, ".7z" => :zip,
|
|
64
64
|
".png" => :image, ".jpg" => :image, ".jpeg" => :image,
|
|
65
|
-
".gif" => :image, ".webp" => :image
|
|
65
|
+
".gif" => :image, ".webp" => :image,
|
|
66
|
+
".csv" => :csv
|
|
66
67
|
}.freeze
|
|
67
68
|
|
|
68
69
|
# FileRef: result of process / process_path.
|
|
@@ -109,6 +110,17 @@ module Clacky
|
|
|
109
110
|
when ".png", ".jpg", ".jpeg", ".gif", ".webp"
|
|
110
111
|
FileRef.new(name: name, type: :image, original_path: path)
|
|
111
112
|
|
|
113
|
+
when ".csv"
|
|
114
|
+
# CSV is plain text — read directly, no external parser needed.
|
|
115
|
+
# Try UTF-8 first, then GBK (common in Chinese-origin CSV), then binary with replacement.
|
|
116
|
+
begin
|
|
117
|
+
text = read_text_with_encoding_fallback(path)
|
|
118
|
+
preview_path = save_preview(text, path)
|
|
119
|
+
FileRef.new(name: name, type: :csv, original_path: path, preview_path: preview_path)
|
|
120
|
+
rescue => e
|
|
121
|
+
FileRef.new(name: name, type: :csv, original_path: path, parse_error: e.message)
|
|
122
|
+
end
|
|
123
|
+
|
|
112
124
|
else
|
|
113
125
|
result = Utils::ParserManager.parse(path)
|
|
114
126
|
if result[:success]
|
|
@@ -225,7 +237,27 @@ module Clacky
|
|
|
225
237
|
base.empty? ? 'upload' : base
|
|
226
238
|
end
|
|
227
239
|
|
|
228
|
-
|
|
240
|
+
# Read a text file with automatic encoding detection.
|
|
241
|
+
# Tries UTF-8, then GBK (common for Chinese-origin CSV/text files), then
|
|
242
|
+
# falls back to binary read with invalid byte replacement.
|
|
243
|
+
def self.read_text_with_encoding_fallback(path)
|
|
244
|
+
# Try UTF-8 first (most common, fastest path)
|
|
245
|
+
raw = File.binread(path)
|
|
246
|
+
utf8 = raw.dup.force_encoding("UTF-8")
|
|
247
|
+
return utf8.encode("UTF-8") if utf8.valid_encoding?
|
|
248
|
+
|
|
249
|
+
# Try GBK (GB2312 superset — common in Chinese Windows/Excel exports)
|
|
250
|
+
begin
|
|
251
|
+
return raw.encode("UTF-8", "GBK", invalid: :replace, undef: :replace, replace: "?")
|
|
252
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
253
|
+
# fall through
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Last resort: binary read with replacement characters
|
|
257
|
+
raw.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "?")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private_class_method :parse_zip_listing, :save_preview, :sanitize_filename, :read_text_with_encoding_fallback
|
|
229
261
|
end
|
|
230
262
|
end
|
|
231
263
|
end
|
|
@@ -14,7 +14,7 @@ module Clacky
|
|
|
14
14
|
# via gem version stamp in ~/.clacky/scripts/.version).
|
|
15
15
|
module ScriptsManager
|
|
16
16
|
SCRIPTS_DIR = File.expand_path("~/.clacky/scripts").freeze
|
|
17
|
-
DEFAULT_SCRIPTS_DIR = File.expand_path("
|
|
17
|
+
DEFAULT_SCRIPTS_DIR = File.expand_path("../../../scripts", __dir__).freeze
|
|
18
18
|
VERSION_FILE = File.join(SCRIPTS_DIR, ".version").freeze
|
|
19
19
|
|
|
20
20
|
SCRIPTS = %w[
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.js
CHANGED
|
@@ -498,6 +498,8 @@ const ACCEPTED_DOC_TYPES = [
|
|
|
498
498
|
"application/vnd.ms-excel", // .xls
|
|
499
499
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
|
500
500
|
"application/vnd.ms-powerpoint", // .ppt
|
|
501
|
+
"text/csv", // .csv
|
|
502
|
+
"application/csv", // .csv (some browsers)
|
|
501
503
|
];
|
|
502
504
|
|
|
503
505
|
function _docTypeIcon(mimeType) {
|
|
@@ -506,6 +508,7 @@ function _docTypeIcon(mimeType) {
|
|
|
506
508
|
if (mimeType.includes("wordprocessingml") || mimeType === "application/msword") return "📝";
|
|
507
509
|
if (mimeType.includes("spreadsheetml") || mimeType === "application/vnd.ms-excel") return "📊";
|
|
508
510
|
if (mimeType.includes("presentationml") || mimeType === "application/vnd.ms-powerpoint") return "📋";
|
|
511
|
+
if (mimeType === "text/csv" || mimeType === "application/csv") return "📊";
|
|
509
512
|
return "📎";
|
|
510
513
|
}
|
|
511
514
|
|
|
@@ -586,6 +589,7 @@ function _addGenericFile(file) {
|
|
|
586
589
|
mime_type: file.type
|
|
587
590
|
});
|
|
588
591
|
_renderAttachmentPreviews();
|
|
592
|
+
setTimeout(() => $("user-input").focus(), 100);
|
|
589
593
|
})
|
|
590
594
|
.catch(err => alert(`Upload error: ${err.message}`));
|
|
591
595
|
}
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -256,7 +256,7 @@
|
|
|
256
256
|
<div id="image-preview-strip" style="display:none"></div>
|
|
257
257
|
<div id="input-bar">
|
|
258
258
|
<!-- Hidden file picker -->
|
|
259
|
-
<input type="file" id="image-file-input" accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip,application/x-zip-compressed,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-powerpoint" multiple style="display:none">
|
|
259
|
+
<input type="file" id="image-file-input" accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip,application/x-zip-compressed,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-powerpoint,text/csv,application/csv,.csv" multiple style="display:none">
|
|
260
260
|
<button id="btn-attach" title="Attach image (or drag & drop / Ctrl+V)">
|
|
261
261
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
262
262
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -794,7 +794,6 @@ const Sessions = (() => {
|
|
|
794
794
|
// ── Apply client-side filter (mirrors server params for instant feedback) ─
|
|
795
795
|
const { q, date, type } = _filter;
|
|
796
796
|
let visible = [..._sessions].sort(byTime);
|
|
797
|
-
if (q) visible = visible.filter(s => (s.name || "").toLowerCase().includes(q.toLowerCase()));
|
|
798
797
|
if (date) visible = visible.filter(s => (s.created_at || "").startsWith(date));
|
|
799
798
|
if (type) {
|
|
800
799
|
visible = type === "coding"
|
|
@@ -66,6 +66,57 @@
|
|
|
66
66
|
background: #fff5f5;
|
|
67
67
|
border-radius: 8px;
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/* Success overlay */
|
|
71
|
+
#success-overlay {
|
|
72
|
+
display: none;
|
|
73
|
+
position: fixed;
|
|
74
|
+
inset: 0;
|
|
75
|
+
background: rgba(255,255,255,0.92);
|
|
76
|
+
align-items: center;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
gap: 16px;
|
|
80
|
+
z-index: 100;
|
|
81
|
+
}
|
|
82
|
+
#success-overlay.show {
|
|
83
|
+
display: flex;
|
|
84
|
+
}
|
|
85
|
+
.success-icon {
|
|
86
|
+
width: 72px;
|
|
87
|
+
height: 72px;
|
|
88
|
+
background: linear-gradient(135deg, #2dc100, #1aad19);
|
|
89
|
+
border-radius: 50%;
|
|
90
|
+
display: flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
font-size: 36px;
|
|
94
|
+
animation: pop 0.3s ease-out;
|
|
95
|
+
}
|
|
96
|
+
.success-title {
|
|
97
|
+
font-size: 20px;
|
|
98
|
+
font-weight: 700;
|
|
99
|
+
color: #111;
|
|
100
|
+
}
|
|
101
|
+
.success-sub {
|
|
102
|
+
font-size: 14px;
|
|
103
|
+
color: #888;
|
|
104
|
+
}
|
|
105
|
+
@keyframes pop {
|
|
106
|
+
0% { transform: scale(0.5); opacity: 0; }
|
|
107
|
+
80% { transform: scale(1.1); }
|
|
108
|
+
100% { transform: scale(1); opacity: 1; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Scanned state */
|
|
112
|
+
.scanned-hint {
|
|
113
|
+
display: none;
|
|
114
|
+
font-size: 13px;
|
|
115
|
+
color: #1aad19;
|
|
116
|
+
font-weight: 500;
|
|
117
|
+
margin-top: 8px;
|
|
118
|
+
}
|
|
119
|
+
.scanned-hint.show { display: block; }
|
|
69
120
|
</style>
|
|
70
121
|
</head>
|
|
71
122
|
<body>
|
|
@@ -75,13 +126,25 @@
|
|
|
75
126
|
<p>使用微信扫描下方二维码<br>在手机上点击「确认登录」</p>
|
|
76
127
|
<div id="qrcode"></div>
|
|
77
128
|
<div class="hint">二维码有效期 5 分钟</div>
|
|
129
|
+
<div class="scanned-hint" id="scanned-hint">✅ 已扫码,请在手机上确认…</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<!-- Success overlay shown after polling detects token -->
|
|
133
|
+
<div id="success-overlay">
|
|
134
|
+
<div class="success-icon">✓</div>
|
|
135
|
+
<div class="success-title">登录成功</div>
|
|
136
|
+
<div class="success-sub">微信已连接,可以开始聊天了</div>
|
|
78
137
|
</div>
|
|
79
138
|
|
|
80
139
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
|
81
140
|
<script>
|
|
82
|
-
const params
|
|
83
|
-
const url
|
|
84
|
-
|
|
141
|
+
const params = new URLSearchParams(location.search);
|
|
142
|
+
const url = params.get("url");
|
|
143
|
+
// since: Unix timestamp (seconds) passed by the setup skill when opening this page.
|
|
144
|
+
// We only show success if token_updated_at > since, preventing false positives
|
|
145
|
+
// when the user already had a token from a previous login.
|
|
146
|
+
const since = parseInt(params.get("since") || "0", 10);
|
|
147
|
+
const el = document.getElementById("qrcode");
|
|
85
148
|
|
|
86
149
|
if (!url) {
|
|
87
150
|
el.innerHTML = '<div class="error">缺少 url 参数</div>';
|
|
@@ -99,6 +162,48 @@
|
|
|
99
162
|
el.innerHTML = '<div class="error">二维码生成失败: ' + e.message + '</div>';
|
|
100
163
|
}
|
|
101
164
|
}
|
|
165
|
+
|
|
166
|
+
// Poll GET /api/channels every 2s; show success overlay once weixin has_token = true
|
|
167
|
+
const POLL_INTERVAL_MS = 2000;
|
|
168
|
+
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — matches QR expiry
|
|
169
|
+
const startedAt = Date.now();
|
|
170
|
+
let pollTimer = null;
|
|
171
|
+
let prevHasToken = false;
|
|
172
|
+
|
|
173
|
+
function showSuccess() {
|
|
174
|
+
clearTimeout(pollTimer);
|
|
175
|
+
document.getElementById("success-overlay").classList.add("show");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function pollChannelStatus() {
|
|
179
|
+
if (Date.now() - startedAt > POLL_TIMEOUT_MS) return; // QR expired, stop quietly
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch("/api/channels", { cache: "no-store" });
|
|
183
|
+
const data = await res.json();
|
|
184
|
+
const weixin = (data.channels || []).find(c => c.platform === "weixin");
|
|
185
|
+
|
|
186
|
+
if (weixin && weixin.has_token) {
|
|
187
|
+
const updatedAt = weixin.token_updated_at || 0;
|
|
188
|
+
if (updatedAt > since) {
|
|
189
|
+
showSuccess();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Show "scanned, waiting for confirm" hint once token appears in-progress
|
|
195
|
+
// (iLink doesn't expose a "scanned" state via this API, so we just keep polling)
|
|
196
|
+
} catch (_) {
|
|
197
|
+
// Server temporarily unreachable — keep polling
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pollTimer = setTimeout(pollChannelStatus, POLL_INTERVAL_MS);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Start polling only when QR code is actually shown
|
|
204
|
+
if (url) {
|
|
205
|
+
pollTimer = setTimeout(pollChannelStatus, POLL_INTERVAL_MS);
|
|
206
|
+
}
|
|
102
207
|
</script>
|
|
103
208
|
</body>
|
|
104
209
|
</html>
|
data/scripts/install.ps1
CHANGED
|
@@ -120,7 +120,7 @@ function Invoke-WslStatusExitCode {
|
|
|
120
120
|
$psi.UseShellExecute = $false
|
|
121
121
|
$psi.RedirectStandardOutput = $true
|
|
122
122
|
$psi.RedirectStandardError = $true
|
|
123
|
-
$p = [System.Diagnostics.Process]::Start($psi)
|
|
123
|
+
try { $p = [System.Diagnostics.Process]::Start($psi) } catch { return 1 }
|
|
124
124
|
$finished = $p.WaitForExit(10000) # 10 seconds
|
|
125
125
|
if (-not $finished) {
|
|
126
126
|
$p.Kill()
|
data/scripts/install_browser.sh
CHANGED
|
@@ -29,6 +29,10 @@ CN_NPM_REGISTRY="https://registry.npmmirror.com"
|
|
|
29
29
|
CN_NODE_MIRROR_URL="https://cdn.npmmirror.com/binaries/node/"
|
|
30
30
|
DEFAULT_MISE_INSTALL_URL="https://mise.run"
|
|
31
31
|
CN_MISE_INSTALL_URL="https://oss.1024code.com/mise.sh"
|
|
32
|
+
# Chrome DMG — update CHROME_VERSION when re-uploading a newer build to OSS
|
|
33
|
+
CHROME_VERSION="134"
|
|
34
|
+
DEFAULT_CHROME_DMG_URL="https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg"
|
|
35
|
+
CN_CHROME_DMG_URL="https://oss.1024code.com/browsers/googlechrome-mac-${CHROME_VERSION}.dmg"
|
|
32
36
|
MISE_INSTALL_URL="$DEFAULT_MISE_INSTALL_URL"
|
|
33
37
|
NPM_REGISTRY_URL="$DEFAULT_NPM_REGISTRY"
|
|
34
38
|
NODE_MIRROR_URL=""
|
|
@@ -71,6 +75,48 @@ detect_network_region() {
|
|
|
71
75
|
fi
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
# --------------------------------------------------------------------------
|
|
79
|
+
# Ensure Chrome is installed (macOS only)
|
|
80
|
+
# Downloads DMG to Desktop, opens it, then exits with instructions.
|
|
81
|
+
# Re-run the script after Chrome is installed.
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
ensure_chrome_macos() {
|
|
84
|
+
print_step "Checking Google Chrome..."
|
|
85
|
+
|
|
86
|
+
if [ -d "/Applications/Google Chrome.app" ] || [ -d "$HOME/Applications/Google Chrome.app" ]; then
|
|
87
|
+
print_success "Google Chrome is already installed"
|
|
88
|
+
return 0
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
print_warning "Google Chrome not found — downloading..."
|
|
92
|
+
|
|
93
|
+
local dmg_url
|
|
94
|
+
if [ "$USE_CN_MIRRORS" = true ]; then
|
|
95
|
+
dmg_url="$CN_CHROME_DMG_URL"
|
|
96
|
+
print_info "Using OSS mirror (Chrome ${CHROME_VERSION})"
|
|
97
|
+
else
|
|
98
|
+
dmg_url="$DEFAULT_CHROME_DMG_URL"
|
|
99
|
+
print_info "Using official Google download"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
local dmg_path="$HOME/Desktop/googlechrome.dmg"
|
|
103
|
+
print_info "Downloading Chrome (~238 MB) to Desktop..."
|
|
104
|
+
|
|
105
|
+
if ! curl -L --progress-bar "$dmg_url" -o "$dmg_path"; then
|
|
106
|
+
print_error "Download failed"
|
|
107
|
+
print_info "Please download manually: ${dmg_url}"
|
|
108
|
+
exit 1
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
print_success "Downloaded: ${dmg_path}"
|
|
112
|
+
print_info "Opening DMG installer..."
|
|
113
|
+
open "$dmg_path"
|
|
114
|
+
|
|
115
|
+
echo ""
|
|
116
|
+
print_info "Please drag 'Google Chrome' to the Applications folder, then re-run this script."
|
|
117
|
+
exit 0
|
|
118
|
+
}
|
|
119
|
+
|
|
74
120
|
# --------------------------------------------------------------------------
|
|
75
121
|
# Ensure mise is available
|
|
76
122
|
# --------------------------------------------------------------------------
|
|
@@ -178,6 +224,8 @@ main() {
|
|
|
178
224
|
echo "========================"
|
|
179
225
|
|
|
180
226
|
detect_network_region
|
|
227
|
+
# Install Chrome first on macOS if not present
|
|
228
|
+
[[ "$(uname)" == "Darwin" ]] && ensure_chrome_macos
|
|
181
229
|
ensure_node || exit 1
|
|
182
230
|
install_chrome_devtools_mcp || exit 1
|
|
183
231
|
|