openclacky 1.2.2 → 1.2.4
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 +36 -0
- data/lib/clacky/agent.rb +11 -0
- data/lib/clacky/client.rb +8 -4
- data/lib/clacky/default_skills/browser-setup/SKILL.md +90 -0
- data/lib/clacky/default_skills/channel-manager/SKILL.md +1 -0
- data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +28 -8
- data/lib/clacky/mcp/stdio_transport.rb +6 -1
- data/lib/clacky/providers.rb +5 -7
- data/lib/clacky/server/browser_manager.rb +16 -3
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +46 -1
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +38 -13
- data/lib/clacky/server/channel/channel_manager.rb +14 -4
- data/lib/clacky/server/channel/channel_ui_controller.rb +27 -11
- data/lib/clacky/server/http_server.rb +13 -3
- data/lib/clacky/tools/browser.rb +3 -3
- data/lib/clacky/tools/glob.rb +19 -4
- data/lib/clacky/tools/grep.rb +13 -1
- data/lib/clacky/tools/security.rb +1 -2
- data/lib/clacky/tools/terminal.rb +210 -14
- data/lib/clacky/ui2/ui_controller.rb +20 -2
- data/lib/clacky/utils/file_ignore_helper.rb +78 -5
- data/lib/clacky/utils/login_shell.rb +3 -1
- data/lib/clacky/utils/model_pricing.rb +28 -3
- data/lib/clacky/utils/scripts_manager.rb +1 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1 -0
- data/lib/clacky/web/settings.js +5 -0
- data/lib/clacky/web/weixin-qr.html +5 -4
- data/scripts/build/lib/apt.sh +71 -1
- data/scripts/build/src/install.sh.cc +1 -1
- data/scripts/build/src/install_rails_deps.sh.cc +4 -4
- data/scripts/build/src/install_system_deps.sh.cc +1 -1
- data/scripts/install.ps1 +44 -17
- data/scripts/install.sh +72 -2
- data/scripts/install_rails_deps.sh +75 -5
- data/scripts/install_system_deps.sh +72 -2
- data/scripts/wsl_network_doctor.ps1 +196 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80e1b5549b0e55760ec4e0e905eea3ab50bc9a66bd8c256e628a1afdb8531ba0
|
|
4
|
+
data.tar.gz: 54142ef66c3b74d3998214202fa73618dd9b22ebdb06586c8d6896aafcb56d9d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d69d02391cc1929d26f2cfc2a260d27cb6b1f6840c254ae67f7ad358f621ba16eb2476fd5cee28308f7eaf2e0eb21d5983b49de4f84c6905863292625644641a
|
|
7
|
+
data.tar.gz: 54dbeb6d31f187edc30ea4e6030776e988cf50ee7ed44a2177a77473d00c47ae358df25dfd9bd526ea93126b94733feaca5a63048cde8657201c4c1e7cd05edd
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.4] - 2026-05-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Browser/agent snapshot now supports `with_page` for paginated results
|
|
12
|
+
- Prefer WSL1 over WSL2 for better compatibility
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Feishu post message type and WeCom mixed message type not handled properly
|
|
16
|
+
- WeChat stale QR code session not detected when reconfiguring channel
|
|
17
|
+
- Channel adapter using stale reference after reconfiguration
|
|
18
|
+
- Benchmark terminated early by 10-second outer request timeout
|
|
19
|
+
- Model card action buttons overflowing on narrow viewports
|
|
20
|
+
- Save button staying disabled when reopening model edit modal
|
|
21
|
+
- WSL pipe breaking when `redirect_exe_stdin` appends `</dev/null`
|
|
22
|
+
- MCP registry not hot-reloading after writing config
|
|
23
|
+
- MCP server breaking from bashrc PS1 customization
|
|
24
|
+
- Terminal realtime output not streaming correctly
|
|
25
|
+
- PowerShell UTF-8 encoding issues
|
|
26
|
+
- WSL bashrc not loading in non-interactive shells
|
|
27
|
+
- Qwen 3.7 Max incorrectly treated as vision-capable model
|
|
28
|
+
|
|
29
|
+
### More
|
|
30
|
+
- Log service start failure reason for easier diagnostics
|
|
31
|
+
|
|
32
|
+
## [1.2.3] - 2026-05-27
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- Qwen 3.7 model support
|
|
36
|
+
- WSL network doctor PowerShell script
|
|
37
|
+
- Limit on glob/grep file walker to prevent excessive traversal
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- Terminal single-line mode issue
|
|
41
|
+
- Terminal execution hang
|
|
42
|
+
- apt try logic
|
|
43
|
+
|
|
8
44
|
## [1.2.2] - 2026-05-25
|
|
9
45
|
|
|
10
46
|
### More
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -910,6 +910,17 @@ module Clacky
|
|
|
910
910
|
# Inject working_dir so tools don't rely on Dir.chdir global state
|
|
911
911
|
args[:working_dir] = @working_dir if @working_dir
|
|
912
912
|
|
|
913
|
+
# For terminal: stream live stdout chunks to the UI as they arrive,
|
|
914
|
+
# so the user sees real-time output (e.g. build logs) instead of a
|
|
915
|
+
# blank spinner. The UI buffers lines for Ctrl+O fullscreen view
|
|
916
|
+
# (CLI) and emits tool_stdout WS events (WebUI) that the browser
|
|
917
|
+
# appends to the running .tool-item.
|
|
918
|
+
if call[:name] == "terminal" && @ui.respond_to?(:show_tool_stdout)
|
|
919
|
+
args[:on_output] = ->(chunk) {
|
|
920
|
+
@ui.show_tool_stdout([chunk])
|
|
921
|
+
}
|
|
922
|
+
end
|
|
923
|
+
|
|
913
924
|
# Show progress immediately for every tool execution so the user
|
|
914
925
|
# always knows the agent is working. Using +with_progress+ wraps
|
|
915
926
|
# the execution in an +ensure+ block so the spinner/ticker is
|
data/lib/clacky/client.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Clacky
|
|
|
10
10
|
|
|
11
11
|
attr_reader :provider_id
|
|
12
12
|
|
|
13
|
-
def initialize(api_key, base_url:, model:, anthropic_format: false)
|
|
13
|
+
def initialize(api_key, base_url:, model:, anthropic_format: false, read_timeout: nil)
|
|
14
14
|
@api_key = api_key
|
|
15
15
|
@base_url = base_url
|
|
16
16
|
@model = model
|
|
@@ -38,6 +38,10 @@ module Clacky
|
|
|
38
38
|
# Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
|
|
39
39
|
# content blocks; the conversion layer strips them when this is false.
|
|
40
40
|
@vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
|
|
41
|
+
|
|
42
|
+
# Optional override for Faraday read_timeout (e.g. benchmark calls).
|
|
43
|
+
# nil means use the default (300s for streaming).
|
|
44
|
+
@read_timeout = read_timeout
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
# Returns true when the client is using the AWS Bedrock Converse API.
|
|
@@ -447,7 +451,7 @@ module Clacky
|
|
|
447
451
|
@bedrock_connection ||= Faraday.new(url: @base_url) do |conn|
|
|
448
452
|
conn.headers["Content-Type"] = "application/json"
|
|
449
453
|
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
450
|
-
conn.options.timeout = 300
|
|
454
|
+
conn.options.timeout = @read_timeout || 300
|
|
451
455
|
conn.options.open_timeout = 10
|
|
452
456
|
conn.ssl.verify = false
|
|
453
457
|
conn.adapter Faraday.default_adapter
|
|
@@ -458,7 +462,7 @@ module Clacky
|
|
|
458
462
|
@openai_connection ||= Faraday.new(url: @base_url) do |conn|
|
|
459
463
|
conn.headers["Content-Type"] = "application/json"
|
|
460
464
|
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
461
|
-
conn.options.timeout = 300
|
|
465
|
+
conn.options.timeout = @read_timeout || 300
|
|
462
466
|
conn.options.open_timeout = 10
|
|
463
467
|
conn.ssl.verify = false
|
|
464
468
|
conn.adapter Faraday.default_adapter
|
|
@@ -494,7 +498,7 @@ module Clacky
|
|
|
494
498
|
if @provider_id == "kimi-coding"
|
|
495
499
|
conn.headers["User-Agent"] = "claude-cli/1.0.51 (external, cli)"
|
|
496
500
|
end
|
|
497
|
-
conn.options.timeout = 300
|
|
501
|
+
conn.options.timeout = @read_timeout || 300
|
|
498
502
|
conn.options.open_timeout = 10
|
|
499
503
|
conn.ssl.verify = false
|
|
500
504
|
conn.adapter Faraday.default_adapter
|
|
@@ -111,6 +111,96 @@ chrome-devtools-mcp --version 2>/dev/null
|
|
|
111
111
|
|
|
112
112
|
If still missing after user confirms, stop with error message.
|
|
113
113
|
|
|
114
|
+
### Step 2.5 — WSL networking setup (only when session context shows `OS: WSL/Windows`)
|
|
115
|
+
|
|
116
|
+
**Skip this entire step on macOS / Linux.** Look at the session context line that begins with `[Session context: ...]` — only run this step if it includes `OS: WSL/Windows`.
|
|
117
|
+
|
|
118
|
+
#### Background (read this so you know what to do)
|
|
119
|
+
|
|
120
|
+
The browser tool runs inside WSL but Chrome/Edge runs on Windows. By default WSL2 uses NAT networking, which means `127.0.0.1` inside WSL **cannot** reach Windows' Chrome debug port. The fix is to enable WSL2 **mirrored networking** (`networkingMode=mirrored` in `%USERPROFILE%\.wslconfig`), which makes WSL share Windows' network stack so `127.0.0.1` works directly.
|
|
121
|
+
|
|
122
|
+
We have a helper script that handles all the Windows-side details:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
~/.clacky/scripts/wsl_network_doctor.ps1
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
It exposes three subcommands:
|
|
129
|
+
|
|
130
|
+
| Subcommand | What it does | Exit code |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `status` | Check whether mirrored is configured (auto-passes on WSL1) | `0` OK / `10` NEED_ENABLE |
|
|
133
|
+
| `enable` | Write `networkingMode=mirrored` to `.wslconfig` (does NOT shut down WSL) | `0` success / `1` fail |
|
|
134
|
+
| `repair` | Restart Windows Host Network Service (HNS) via UAC prompt | `0` launched / `1` fail |
|
|
135
|
+
|
|
136
|
+
Invoke it from WSL like this:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" <subcommand>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### Step 2.5.1 — Check status
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" status
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- Exit `0` (output starts with `OK:`) → either mirrored is configured (WSL2) or
|
|
149
|
+
Ubuntu is running on WSL1 (which shares the Windows network stack and needs no
|
|
150
|
+
config). Either way, proceed to Step 3.
|
|
151
|
+
- Exit `10` (output starts with `NEED_ENABLE:`) → continue to Step 2.5.2.
|
|
152
|
+
- Any other failure → show the output to the user and ask them to retry. Stop here.
|
|
153
|
+
|
|
154
|
+
#### Step 2.5.2 — Enable mirrored (only when NEED_ENABLE)
|
|
155
|
+
|
|
156
|
+
Tell the user what's about to happen (in their language):
|
|
157
|
+
|
|
158
|
+
> WSL doesn't have mirrored networking enabled yet — the browser tool needs it to reach Chrome on Windows.
|
|
159
|
+
> I'll add one line to `%USERPROFILE%\.wslconfig`. Your current WSL session will NOT be restarted.
|
|
160
|
+
|
|
161
|
+
Run:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" enable
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
If the script exits `0`:
|
|
168
|
+
|
|
169
|
+
> ✅ `.wslconfig` updated. Tell the user (in their language):
|
|
170
|
+
>
|
|
171
|
+
> The config takes effect only after WSL restarts, but we can't restart WSL from inside WSL.
|
|
172
|
+
> Please:
|
|
173
|
+
>
|
|
174
|
+
> 1. Open **PowerShell** on Windows
|
|
175
|
+
> 2. Run: `wsl --shutdown`
|
|
176
|
+
> 3. Reopen the Clacky terminal
|
|
177
|
+
> 4. Run `/browser-setup` again
|
|
178
|
+
>
|
|
179
|
+
> Stop here. Wait for the user to come back in a new session.
|
|
180
|
+
|
|
181
|
+
If the script exits non-zero, show the output to the user and stop. Do NOT proceed to Step 3 — without mirrored networking the browser tool will not work.
|
|
182
|
+
|
|
183
|
+
#### Step 2.5.3 — When to run repair
|
|
184
|
+
|
|
185
|
+
Do NOT run `repair` proactively. Only run it later if **all** of the following are true:
|
|
186
|
+
|
|
187
|
+
- `status` returned `OK` (mirrored is configured)
|
|
188
|
+
- The user has restarted WSL since the config was written
|
|
189
|
+
- Step 3's `browser(action="status")` still fails with a "Chrome/Edge is not running or remote debugging is not enabled" error
|
|
190
|
+
|
|
191
|
+
In that situation, tell the user (in their language):
|
|
192
|
+
|
|
193
|
+
> The config looks correct but the browser still can't connect. Windows Host Network Service may be stuck — I'll restart it.
|
|
194
|
+
> **A Windows User Account Control (UAC) prompt will appear shortly. Please click "Yes".**
|
|
195
|
+
|
|
196
|
+
Then run:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" repair
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
After it returns, tell the user to run `wsl --shutdown` in PowerShell and reopen Clacky. Stop and wait.
|
|
203
|
+
|
|
114
204
|
### Step 3 — Verify Chrome/Edge is running with remote debugging
|
|
115
205
|
|
|
116
206
|
**CRITICAL**: Do NOT attempt `browser()` calls yet. First check if the browser is reachable using the API:
|
|
@@ -341,6 +341,7 @@ Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
|
|
|
341
341
|
|
|
342
342
|
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:
|
|
343
343
|
- Exit code is 0 → success
|
|
344
|
+
- Output contains "stale-session" → the qrcode_id was already consumed by a prior login; **immediately restart from Step 1** (do NOT retry with same id)
|
|
344
345
|
- Output contains "expired" → QR expired, offer to restart from Step 1
|
|
345
346
|
- Output contains "timed out" → offer to restart from Step 1
|
|
346
347
|
- 3 retries exhausted → show error and offer to restart from Step 1
|
|
@@ -49,16 +49,20 @@ GIVEN_QRCODE_ID = QRCODE_ID_IDX ? ARGV[QRCODE_ID_IDX + 1] : nil
|
|
|
49
49
|
# Logging (suppress in --fetch-qr mode so stdout is clean JSON)
|
|
50
50
|
# ---------------------------------------------------------------------------
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
def
|
|
52
|
+
WEIXIN_LOG_FILE = File.expand_path("~/.clacky/weixin_setup_debug.log")
|
|
53
|
+
def wlog(msg)
|
|
54
|
+
File.open(WEIXIN_LOG_FILE, "a") { |f| f.puts("[#{Time.now.strftime("%H:%M:%S")}] #{msg}") }
|
|
55
|
+
rescue StandardError
|
|
56
|
+
# ignore — debug log is best-effort
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def step(msg); $stderr.puts("[weixin-setup] #{msg}") unless FETCH_QR_MODE; wlog(msg); end
|
|
60
|
+
def ok(msg); $stderr.puts("[weixin-setup] ✅ #{msg}") unless FETCH_QR_MODE; wlog("✅ #{msg}"); end
|
|
54
61
|
|
|
55
62
|
# In fetch-qr mode, write to stderr so stdout stays clean JSON
|
|
56
63
|
def log(msg)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
else
|
|
60
|
-
$stderr.puts("[weixin-setup] #{msg}")
|
|
61
|
-
end
|
|
64
|
+
$stderr.puts("[weixin-setup] #{msg}")
|
|
65
|
+
wlog(msg)
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
def fail!(msg)
|
|
@@ -169,6 +173,7 @@ end
|
|
|
169
173
|
def poll_until_confirmed(qrcode)
|
|
170
174
|
deadline = Time.now + LOGIN_DEADLINE_S
|
|
171
175
|
scanned_once = false
|
|
176
|
+
started_at = Time.now
|
|
172
177
|
|
|
173
178
|
loop do
|
|
174
179
|
fail!("Login timed out. Please run setup again.") if Time.now > deadline
|
|
@@ -179,7 +184,12 @@ def poll_until_confirmed(qrcode)
|
|
|
179
184
|
timeout: QR_POLL_TIMEOUT_S
|
|
180
185
|
)
|
|
181
186
|
|
|
182
|
-
|
|
187
|
+
if resp.nil?
|
|
188
|
+
wlog("poll: timeout/nil, retrying...")
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
wlog("poll response: #{resp.to_json}")
|
|
183
193
|
|
|
184
194
|
case resp["status"]
|
|
185
195
|
when "wait"
|
|
@@ -190,10 +200,19 @@ def poll_until_confirmed(qrcode)
|
|
|
190
200
|
scanned_once = true
|
|
191
201
|
end
|
|
192
202
|
when "confirmed"
|
|
203
|
+
elapsed = Time.now - started_at
|
|
193
204
|
token = resp["bot_token"].to_s.strip
|
|
194
205
|
base_url = resp["baseurl"].to_s.strip
|
|
195
206
|
base_url = ILINK_BASE_URL if base_url.empty?
|
|
196
207
|
fail!("Login confirmed but no token received") if token.empty?
|
|
208
|
+
# If confirmed arrived within 3 seconds of starting, this is almost certainly
|
|
209
|
+
# iLink returning the existing login state (account already logged in),
|
|
210
|
+
# not the result of the user scanning this QR code.
|
|
211
|
+
if elapsed < 3 && !scanned_once
|
|
212
|
+
wlog("confirmed too fast (#{elapsed.round(1)}s), treating as stale session")
|
|
213
|
+
fail!("[stale-session] QR session confirmed immediately — account already logged in. Run --fetch-qr to get a fresh QR code.")
|
|
214
|
+
end
|
|
215
|
+
wlog("confirmed after #{elapsed.round(1)}s")
|
|
197
216
|
return { token: token, base_url: base_url }
|
|
198
217
|
when "expired"
|
|
199
218
|
fail!("QR code expired. Please run setup again.")
|
|
@@ -214,6 +233,7 @@ end
|
|
|
214
233
|
if FETCH_QR_MODE
|
|
215
234
|
$stderr.puts("[weixin-setup] Fetching QR code from iLink...")
|
|
216
235
|
qr_resp = ilink_get("ilink/bot/get_bot_qrcode?bot_type=#{CGI.escape(BOT_TYPE)}")
|
|
236
|
+
wlog("fetch-qr response: #{qr_resp.to_json}")
|
|
217
237
|
fail!("No qrcode in response: #{qr_resp.inspect}") unless qr_resp&.dig("qrcode")
|
|
218
238
|
|
|
219
239
|
qrcode = qr_resp["qrcode"]
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "open3"
|
|
5
5
|
require "monitor"
|
|
6
|
+
require "shellwords"
|
|
6
7
|
|
|
7
8
|
require_relative "transport"
|
|
9
|
+
require_relative "../utils/login_shell"
|
|
8
10
|
|
|
9
11
|
module Clacky
|
|
10
12
|
module Mcp
|
|
@@ -29,7 +31,10 @@ module Clacky
|
|
|
29
31
|
opts = { unsetenv_others: false }
|
|
30
32
|
opts[:chdir] = @cwd if @cwd && File.directory?(@cwd)
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
inner = ([@command] + @args).map { |a| Shellwords.shellescape(a) }.join(" ")
|
|
35
|
+
wrapped = Clacky::Utils::LoginShell.login_shell_command(inner)
|
|
36
|
+
|
|
37
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(full_env, *wrapped, opts)
|
|
33
38
|
@stdin.sync = true
|
|
34
39
|
|
|
35
40
|
Thread.new do
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -335,28 +335,26 @@ module Clacky
|
|
|
335
335
|
"name" => "Qwen (Alibaba)",
|
|
336
336
|
"base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
337
337
|
"api" => "openai-completions",
|
|
338
|
-
"default_model" => "qwen3.
|
|
338
|
+
"default_model" => "qwen3.7-max",
|
|
339
339
|
"models" => [
|
|
340
|
+
"qwen3.7-max",
|
|
340
341
|
"qwen3.6-plus",
|
|
341
342
|
"qwen3.6-max",
|
|
342
343
|
"qwen3.6-27b",
|
|
343
344
|
"qwen3.6-flash",
|
|
344
345
|
"qwen-plus-latest",
|
|
345
|
-
"qwen-vl-plus",
|
|
346
|
-
"qwen-vl-max"
|
|
347
346
|
],
|
|
348
347
|
"endpoint_variants" => [
|
|
349
348
|
{ "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
|
|
350
349
|
{ "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
|
|
351
350
|
{ "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
|
|
352
351
|
].freeze,
|
|
353
|
-
"capabilities" => { "vision" =>
|
|
352
|
+
"capabilities" => { "vision" => true }.freeze,
|
|
354
353
|
"model_capabilities" => {
|
|
355
|
-
"qwen3.
|
|
356
|
-
"qwen-vl-plus" => { "vision" => true }.freeze,
|
|
357
|
-
"qwen-vl-max" => { "vision" => true }.freeze
|
|
354
|
+
"qwen3.7-max" => { "vision" => false }.freeze
|
|
358
355
|
}.freeze,
|
|
359
356
|
"lite_models" => {
|
|
357
|
+
"qwen3.7-max" => "qwen3.6-flash",
|
|
360
358
|
"qwen3.6-plus" => "qwen3.6-flash",
|
|
361
359
|
"qwen3.6-max" => "qwen3.6-flash",
|
|
362
360
|
"qwen3.6-27b" => "qwen3.6-flash",
|
|
@@ -258,7 +258,19 @@ module Clacky
|
|
|
258
258
|
# close_others: true prevents inheriting the server's listening socket (port 7070).
|
|
259
259
|
# The MCP daemon is an independent external process and should not hold server fds.
|
|
260
260
|
stdin, stdout, stderr_io, wait_thr = Open3.popen3(*wrapped, close_others: true)
|
|
261
|
-
|
|
261
|
+
stderr_buf = String.new
|
|
262
|
+
stderr_thr = Thread.new do
|
|
263
|
+
stderr_io.each_line { |line| stderr_buf << line }
|
|
264
|
+
rescue IOError
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Give the process a moment to fail fast (e.g. command not found)
|
|
268
|
+
sleep 0.1
|
|
269
|
+
unless wait_thr.alive?
|
|
270
|
+
stderr_thr.join(1)
|
|
271
|
+
Clacky::Logger.error("[BrowserManager] MCP daemon exited immediately (exit=#{wait_thr.value.exitstatus}). stderr:\n#{stderr_buf}")
|
|
272
|
+
raise "chrome-devtools-mcp failed to start (exit=#{wait_thr.value.exitstatus}): #{stderr_buf.lines.last(5).join}"
|
|
273
|
+
end
|
|
262
274
|
|
|
263
275
|
# MCP handshake
|
|
264
276
|
init_msg = json_rpc("initialize", {
|
|
@@ -280,9 +292,10 @@ module Clacky
|
|
|
280
292
|
init_resp = read_response(stdout, target_id: 1,
|
|
281
293
|
timeout: Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT)
|
|
282
294
|
unless init_resp
|
|
283
|
-
|
|
295
|
+
stderr_thr.join(0.5)
|
|
296
|
+
Clacky::Logger.error("[BrowserManager] MCP initialize handshake timed out after #{Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT}s. stderr:\n#{stderr_buf}")
|
|
284
297
|
Process.kill("TERM", wait_thr.pid) rescue nil
|
|
285
|
-
raise "Chrome MCP initialize handshake timed out"
|
|
298
|
+
raise "Chrome MCP initialize handshake timed out. stderr: #{stderr_buf.lines.last(5).join}"
|
|
286
299
|
end
|
|
287
300
|
|
|
288
301
|
Clacky::Logger.debug("[BrowserManager] MCP initialize successful, sending initialized notification...")
|
|
@@ -55,7 +55,10 @@ module Clacky
|
|
|
55
55
|
|
|
56
56
|
msg_type = message["message_type"]
|
|
57
57
|
Clacky::Logger.info("[feishu] msg_type=#{msg_type} content=#{message["content"].to_s[0..300]}")
|
|
58
|
-
|
|
58
|
+
unless %w[text image file post].include?(msg_type)
|
|
59
|
+
Clacky::Logger.info("[feishu] dropping unsupported msg_type=#{msg_type}")
|
|
60
|
+
return nil
|
|
61
|
+
end
|
|
59
62
|
|
|
60
63
|
content_raw = message["content"]
|
|
61
64
|
return nil unless content_raw
|
|
@@ -77,6 +80,11 @@ module Clacky
|
|
|
77
80
|
file_name = content["file_name"]
|
|
78
81
|
return nil unless file_key
|
|
79
82
|
file_attachments = [{ key: file_key, name: file_name.to_s }]
|
|
83
|
+
when "post"
|
|
84
|
+
parsed = parse_post_content(content)
|
|
85
|
+
text = parsed[:text]
|
|
86
|
+
image_keys = parsed[:image_keys]
|
|
87
|
+
return nil if text.empty? && image_keys.empty?
|
|
80
88
|
end
|
|
81
89
|
|
|
82
90
|
chat_id = message["chat_id"]
|
|
@@ -122,6 +130,43 @@ module Clacky
|
|
|
122
130
|
# Feishu mentions are formatted as <at user_id="...">Name</at>
|
|
123
131
|
text.gsub(/<at[^>]*>.*?<\/at>/, "").strip
|
|
124
132
|
end
|
|
133
|
+
|
|
134
|
+
# Parse a Feishu post content body into text and image_keys.
|
|
135
|
+
# post content structure from event payloads:
|
|
136
|
+
# {"title": "", "content": [[{tag, text, ...}, ...], ...]}
|
|
137
|
+
def parse_post_content(content)
|
|
138
|
+
rows = content["content"]
|
|
139
|
+
return { text: "", image_keys: [] } unless rows.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
text_lines = []
|
|
142
|
+
image_keys = []
|
|
143
|
+
|
|
144
|
+
rows.each do |row|
|
|
145
|
+
next unless row.is_a?(Array)
|
|
146
|
+
line_parts = []
|
|
147
|
+
row.each do |element|
|
|
148
|
+
next unless element.is_a?(Hash)
|
|
149
|
+
case element["tag"]
|
|
150
|
+
when "text", "md", "code_block"
|
|
151
|
+
part = element["text"].to_s
|
|
152
|
+
line_parts << part unless part.empty?
|
|
153
|
+
when "a"
|
|
154
|
+
part = element["text"].to_s
|
|
155
|
+
part = element["href"].to_s if part.empty?
|
|
156
|
+
line_parts << part unless part.empty?
|
|
157
|
+
when "img"
|
|
158
|
+
key = element["image_key"].to_s
|
|
159
|
+
image_keys << key unless key.empty?
|
|
160
|
+
when "at"
|
|
161
|
+
# skipped — mention identity resolved via top-level mentions field
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
line = line_parts.join.strip
|
|
165
|
+
text_lines << line unless line.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
{ text: text_lines.join("\n"), image_keys: image_keys }
|
|
169
|
+
end
|
|
125
170
|
end
|
|
126
171
|
end
|
|
127
172
|
end
|
|
@@ -77,7 +77,8 @@ module Clacky
|
|
|
77
77
|
|
|
78
78
|
def handle_raw_message(raw)
|
|
79
79
|
msgtype = raw["msgtype"]
|
|
80
|
-
|
|
80
|
+
Clacky::Logger.info("[wecom] msgtype=#{msgtype} raw=#{raw.to_s[0..300]}")
|
|
81
|
+
return unless %w[text image file mixed].include?(msgtype)
|
|
81
82
|
|
|
82
83
|
chat_id = raw["chatid"] || raw.dig("from", "userid")
|
|
83
84
|
return unless chat_id
|
|
@@ -92,18 +93,9 @@ module Clacky
|
|
|
92
93
|
text = raw.dig("text", "content").to_s.strip
|
|
93
94
|
return if text.empty?
|
|
94
95
|
when "image"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
result = MediaDownloader.download(url, aeskey)
|
|
99
|
-
mime = MediaDownloader.detect_mime(result[:body])
|
|
100
|
-
if result[:body].bytesize > MAX_IMAGE_BYTES
|
|
101
|
-
@ws_client.send_message(chat_id, "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
|
|
102
|
-
return
|
|
103
|
-
end
|
|
104
|
-
require "base64"
|
|
105
|
-
data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
|
|
106
|
-
files = [{ name: "image.jpg", mime_type: mime, data_url: data_url }]
|
|
96
|
+
file = download_image(raw["image"], chat_id)
|
|
97
|
+
return unless file
|
|
98
|
+
files = [file]
|
|
107
99
|
when "file"
|
|
108
100
|
url = raw.dig("file", "url")
|
|
109
101
|
aeskey = raw.dig("file", "aeskey")
|
|
@@ -113,6 +105,9 @@ module Clacky
|
|
|
113
105
|
filename = result[:filename] || filename
|
|
114
106
|
saved = Clacky::Utils::FileProcessor.save(body: result[:body], filename: filename)
|
|
115
107
|
files = [saved]
|
|
108
|
+
when "mixed"
|
|
109
|
+
text, files = parse_mixed(raw.dig("mixed", "msg_item") || [], chat_id)
|
|
110
|
+
return if text.empty? && files.empty?
|
|
116
111
|
end
|
|
117
112
|
|
|
118
113
|
event = {
|
|
@@ -138,6 +133,36 @@ module Clacky
|
|
|
138
133
|
end
|
|
139
134
|
end
|
|
140
135
|
|
|
136
|
+
private def download_image(image_data, chat_id)
|
|
137
|
+
url = image_data&.[]("url")
|
|
138
|
+
aeskey = image_data&.[]("aeskey")
|
|
139
|
+
return nil unless url
|
|
140
|
+
result = MediaDownloader.download(url, aeskey)
|
|
141
|
+
mime = MediaDownloader.detect_mime(result[:body])
|
|
142
|
+
if result[:body].bytesize > MAX_IMAGE_BYTES
|
|
143
|
+
@ws_client.send_message(chat_id, "Image too large (#{(result[:body].bytesize / 1024.0).round(0).to_i}KB), max #{MAX_IMAGE_BYTES / 1024}KB")
|
|
144
|
+
return nil
|
|
145
|
+
end
|
|
146
|
+
require "base64"
|
|
147
|
+
data_url = "data:#{mime};base64,#{Base64.strict_encode64(result[:body])}"
|
|
148
|
+
{ name: "image.jpg", mime_type: mime, data_url: data_url }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private def parse_mixed(items, chat_id)
|
|
152
|
+
text_parts = []
|
|
153
|
+
files = []
|
|
154
|
+
items.each do |item|
|
|
155
|
+
case item["msgtype"]
|
|
156
|
+
when "text"
|
|
157
|
+
text_parts << item.dig("text", "content").to_s.strip
|
|
158
|
+
when "image"
|
|
159
|
+
file = download_image(item["image"], chat_id)
|
|
160
|
+
files << file if file
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
[text_parts.join("\n").strip, files]
|
|
164
|
+
end
|
|
165
|
+
|
|
141
166
|
MAX_IMAGE_BYTES = Clacky::Utils::FileProcessor::MAX_IMAGE_BYTES
|
|
142
167
|
end
|
|
143
168
|
|
|
@@ -81,6 +81,16 @@ module Clacky
|
|
|
81
81
|
#
|
|
82
82
|
# For Weixin (iLink protocol) a context_token is required for every outbound
|
|
83
83
|
# message. This method looks up the most-recently cached token for user_id.
|
|
84
|
+
|
|
85
|
+
# Return the currently-live adapter for a given platform, or nil if none running.
|
|
86
|
+
# Thread-safe — acquires @mutex to read from @adapters.
|
|
87
|
+
# @param platform [Symbol, String]
|
|
88
|
+
# @return [Object, nil]
|
|
89
|
+
def adapter_for(platform)
|
|
90
|
+
platform = platform.to_sym
|
|
91
|
+
@mutex.synchronize { @adapters.find { |a| a.platform_id == platform } }
|
|
92
|
+
end
|
|
93
|
+
|
|
84
94
|
# If no token is found the message cannot be delivered and nil is returned.
|
|
85
95
|
#
|
|
86
96
|
# For Feishu and WeCom the chat_id / user_id is sufficient — no token needed.
|
|
@@ -91,7 +101,7 @@ module Clacky
|
|
|
91
101
|
# @return [Hash, nil] adapter result hash, or nil on failure
|
|
92
102
|
def send_to_user(platform, user_id, message)
|
|
93
103
|
platform = platform.to_sym
|
|
94
|
-
adapter =
|
|
104
|
+
adapter = adapter_for(platform)
|
|
95
105
|
|
|
96
106
|
unless adapter
|
|
97
107
|
Clacky::Logger.warn("[ChannelManager] send_to_user: no running adapter for :#{platform}")
|
|
@@ -114,7 +124,7 @@ module Clacky
|
|
|
114
124
|
# @return [Array<String>]
|
|
115
125
|
def known_users(platform)
|
|
116
126
|
platform = platform.to_sym
|
|
117
|
-
adapter =
|
|
127
|
+
adapter = adapter_for(platform)
|
|
118
128
|
return [] unless adapter
|
|
119
129
|
|
|
120
130
|
# Weixin adapter exposes @context_tokens whose keys are user_ids
|
|
@@ -307,7 +317,7 @@ module Clacky
|
|
|
307
317
|
if channel_ui
|
|
308
318
|
@registry.with_session(old_session_id) { |s| s[:ui]&.unsubscribe_channel(channel_ui); s.delete(:channel_ui) }
|
|
309
319
|
else
|
|
310
|
-
channel_ui = ChannelUIController.new(event,
|
|
320
|
+
channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
|
|
311
321
|
end
|
|
312
322
|
|
|
313
323
|
bind_key_to_session(key, session_id)
|
|
@@ -384,7 +394,7 @@ module Clacky
|
|
|
384
394
|
# Create a long-lived ChannelUIController for this session and subscribe it
|
|
385
395
|
# to the session's WebUIController. It stays for the session's full lifetime
|
|
386
396
|
# so all events (agent output, errors, status) flow through web_ui → channel_ui.
|
|
387
|
-
channel_ui = ChannelUIController.new(event,
|
|
397
|
+
channel_ui = ChannelUIController.new(event, -> { adapter_for(event[:platform]) })
|
|
388
398
|
@registry.with_session(session_id) do |s|
|
|
389
399
|
s[:ui]&.subscribe_channel(channel_ui)
|
|
390
400
|
s[:channel_ui] = channel_ui
|
|
@@ -22,13 +22,18 @@ module Clacky
|
|
|
22
22
|
|
|
23
23
|
attr_reader :platform, :chat_id
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@
|
|
25
|
+
# @param event [Hash] inbound IM event
|
|
26
|
+
# @param adapter_resolver [Proc] callable returning the current live adapter for this platform.
|
|
27
|
+
# Using a resolver (instead of caching the adapter instance) ensures that after
|
|
28
|
+
# reload_platform replaces an adapter, in-flight sessions automatically pick up the
|
|
29
|
+
# new one — no swap/patch needed.
|
|
30
|
+
def initialize(event, adapter_resolver)
|
|
31
|
+
@platform = event[:platform]
|
|
32
|
+
@chat_id = event[:chat_id]
|
|
33
|
+
@message_id = event[:message_id] # original message to reply under
|
|
34
|
+
@adapter_resolver = adapter_resolver
|
|
35
|
+
@buffer = []
|
|
36
|
+
@mutex = Mutex.new
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
# Update the reply context for the current inbound message.
|
|
@@ -185,15 +190,25 @@ module Clacky
|
|
|
185
190
|
text = text.to_s.gsub(/<think>[\s\S]*?<\/think>\n*/i, "").strip
|
|
186
191
|
return if text.empty?
|
|
187
192
|
|
|
188
|
-
|
|
193
|
+
adapter = @adapter_resolver.call
|
|
194
|
+
unless adapter
|
|
195
|
+
Clacky::Logger.warn("[ChannelUI] send_text: no live adapter for :#{@platform}")
|
|
196
|
+
return nil
|
|
197
|
+
end
|
|
198
|
+
adapter.send_text(@chat_id, text, reply_to: @message_id)
|
|
189
199
|
rescue StandardError => e
|
|
190
200
|
Clacky::Logger.warn("[ChannelUI] send_text failed", platform: @platform, chat_id: @chat_id, error: e)
|
|
191
201
|
nil
|
|
192
202
|
end
|
|
193
203
|
|
|
194
204
|
def send_file(path, name = nil)
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
adapter = @adapter_resolver.call
|
|
206
|
+
unless adapter
|
|
207
|
+
Clacky::Logger.warn("[ChannelUI] send_file: no live adapter for :#{@platform}")
|
|
208
|
+
return nil
|
|
209
|
+
end
|
|
210
|
+
if adapter.respond_to?(:send_file)
|
|
211
|
+
adapter.send_file(@chat_id, path, name: name)
|
|
197
212
|
else
|
|
198
213
|
# Fallback for adapters that don't support file sending
|
|
199
214
|
send_text("File: #{name || File.basename(path)}\n#{path}")
|
|
@@ -222,7 +237,8 @@ module Clacky
|
|
|
222
237
|
end
|
|
223
238
|
|
|
224
239
|
def flush_adapter_pending
|
|
225
|
-
|
|
240
|
+
adapter = @adapter_resolver.call
|
|
241
|
+
adapter.flush_pending(@chat_id) if adapter&.respond_to?(:flush_pending)
|
|
226
242
|
end
|
|
227
243
|
end
|
|
228
244
|
end
|