openclacky 1.0.3 → 1.0.5
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 +34 -1
- data/benchmark/fixtures/sample_project/Gemfile +3 -0
- data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
- data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
- data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
- data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
- data/benchmark/results/EVALUATION_REPORT.md +165 -0
- data/benchmark/results/baseline_20260511_174424.json +128 -0
- data/benchmark/results/report_20260511_175256.json +271 -0
- data/benchmark/results/report_20260511_175444.json +271 -0
- data/benchmark/results/treatment_20260511_175103.json +130 -0
- data/benchmark/runner.rb +441 -0
- data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
- data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
- data/lib/clacky/agent/cost_tracker.rb +8 -2
- data/lib/clacky/agent/memory_updater.rb +41 -30
- data/lib/clacky/agent/skill_manager.rb +5 -2
- data/lib/clacky/agent/skill_reflector.rb +10 -1
- data/lib/clacky/agent.rb +4 -0
- data/lib/clacky/client.rb +15 -0
- data/lib/clacky/default_agents/base_prompt.md +20 -20
- data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
- data/lib/clacky/default_skills/channel-setup/SKILL.md +190 -14
- data/lib/clacky/default_skills/channel-setup/discord_setup.rb +199 -0
- data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
- data/lib/clacky/providers.rb +77 -10
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +229 -0
- data/lib/clacky/server/channel/adapters/discord/api_client.rb +107 -0
- data/lib/clacky/server/channel/adapters/discord/gateway_client.rb +272 -0
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +375 -0
- data/lib/clacky/server/channel/adapters/telegram/api_client.rb +205 -0
- data/lib/clacky/server/channel/channel_config.rb +11 -0
- data/lib/clacky/server/channel.rb +2 -0
- data/lib/clacky/server/http_server.rb +69 -3
- data/lib/clacky/ui2/ui_controller.rb +2 -1
- data/lib/clacky/utils/file_processor.rb +71 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +44 -0
- data/lib/clacky/web/channels.js +16 -0
- data/lib/clacky/web/i18n.js +24 -2
- data/lib/clacky/web/index.html +6 -1
- data/lib/clacky/web/settings.js +4 -0
- data/lib/clacky/web/version.js +52 -1
- metadata +37 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "net/https"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Clacky
|
|
11
|
+
module Channel
|
|
12
|
+
module Adapters
|
|
13
|
+
module Telegram
|
|
14
|
+
# Telegram Bot API HTTP client.
|
|
15
|
+
# Spec: https://core.telegram.org/bots/api
|
|
16
|
+
#
|
|
17
|
+
# All requests POST JSON to https://<base>/bot<TOKEN>/<method>.
|
|
18
|
+
# File downloads use https://<base>/file/bot<TOKEN>/<file_path>.
|
|
19
|
+
#
|
|
20
|
+
# `base_url` is configurable to allow self-hosted Bot API servers
|
|
21
|
+
# (https://github.com/tdlib/telegram-bot-api), which is the practical
|
|
22
|
+
# escape hatch for users on networks where api.telegram.org is blocked.
|
|
23
|
+
class ApiClient
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.telegram.org"
|
|
25
|
+
LONG_POLL_TIMEOUT = 25 # seconds; server holds the request open up to this long
|
|
26
|
+
OPEN_TIMEOUT = 10
|
|
27
|
+
# Read timeout must comfortably exceed the long-poll window so we
|
|
28
|
+
# don't tear down healthy connections mid-poll.
|
|
29
|
+
POLL_READ_TIMEOUT = LONG_POLL_TIMEOUT + 10
|
|
30
|
+
|
|
31
|
+
class ApiError < StandardError
|
|
32
|
+
attr_reader :code, :description
|
|
33
|
+
def initialize(code, description)
|
|
34
|
+
@code = code
|
|
35
|
+
@description = description
|
|
36
|
+
super("Telegram API error #{code}: #{description}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class TimeoutError < StandardError; end
|
|
41
|
+
|
|
42
|
+
def initialize(token:, base_url: DEFAULT_BASE_URL)
|
|
43
|
+
@token = token.to_s
|
|
44
|
+
@base_url = (base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url).chomp("/")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Long-poll for updates. Returns the raw `result` array (possibly empty).
|
|
48
|
+
# `offset` is the highest update_id + 1 from the previous batch.
|
|
49
|
+
def get_updates(offset: nil, allowed_updates: %w[message])
|
|
50
|
+
params = { timeout: LONG_POLL_TIMEOUT, allowed_updates: allowed_updates }
|
|
51
|
+
params[:offset] = offset if offset
|
|
52
|
+
post("getUpdates", params, read_timeout: POLL_READ_TIMEOUT)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Send a plain or Markdown-formatted message. Returns the Message hash.
|
|
56
|
+
def send_message(chat_id:, text:, parse_mode: nil, reply_to_message_id: nil, message_thread_id: nil, disable_web_page_preview: true)
|
|
57
|
+
params = {
|
|
58
|
+
chat_id: chat_id,
|
|
59
|
+
text: text,
|
|
60
|
+
disable_web_page_preview: disable_web_page_preview
|
|
61
|
+
}
|
|
62
|
+
params[:parse_mode] = parse_mode if parse_mode
|
|
63
|
+
params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
|
64
|
+
params[:message_thread_id] = message_thread_id if message_thread_id
|
|
65
|
+
post("sendMessage", params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Edit the text of a previously sent message. Returns the edited Message hash.
|
|
69
|
+
def edit_message_text(chat_id:, message_id:, text:, parse_mode: nil, disable_web_page_preview: true)
|
|
70
|
+
params = {
|
|
71
|
+
chat_id: chat_id,
|
|
72
|
+
message_id: message_id,
|
|
73
|
+
text: text,
|
|
74
|
+
disable_web_page_preview: disable_web_page_preview
|
|
75
|
+
}
|
|
76
|
+
params[:parse_mode] = parse_mode if parse_mode
|
|
77
|
+
post("editMessageText", params)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Send a chat action (e.g. "typing") — auto-expires after 5s client-side.
|
|
81
|
+
def send_chat_action(chat_id:, action: "typing", message_thread_id: nil)
|
|
82
|
+
params = { chat_id: chat_id, action: action }
|
|
83
|
+
params[:message_thread_id] = message_thread_id if message_thread_id
|
|
84
|
+
post("sendChatAction", params)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Send a photo by local file path. Returns the Message hash.
|
|
88
|
+
def send_photo(chat_id:, photo_path:, caption: nil, reply_to_message_id: nil)
|
|
89
|
+
params = { chat_id: chat_id }
|
|
90
|
+
params[:caption] = caption if caption
|
|
91
|
+
params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
|
92
|
+
post_multipart("sendPhoto", params, file_field: "photo", file_path: photo_path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Send a document (arbitrary file). Returns the Message hash.
|
|
96
|
+
def send_document(chat_id:, document_path:, filename: nil, caption: nil, reply_to_message_id: nil)
|
|
97
|
+
params = { chat_id: chat_id }
|
|
98
|
+
params[:caption] = caption if caption
|
|
99
|
+
params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
|
100
|
+
post_multipart("sendDocument", params, file_field: "document", file_path: document_path, filename: filename)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Resolve a file_id to a file_path via getFile, then download the bytes.
|
|
104
|
+
# Returns the raw byte string.
|
|
105
|
+
def download_file(file_id)
|
|
106
|
+
file = post("getFile", { file_id: file_id })
|
|
107
|
+
path = file["file_path"]
|
|
108
|
+
raise ApiError.new(0, "getFile returned no file_path") if path.to_s.empty?
|
|
109
|
+
|
|
110
|
+
uri = URI("#{@base_url}/file/bot#{@token}/#{path}")
|
|
111
|
+
http_get_raw(uri)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def post(method_name, params, read_timeout: 30)
|
|
116
|
+
uri = URI("#{@base_url}/bot#{@token}/#{method_name}")
|
|
117
|
+
http = build_http(uri, read_timeout: read_timeout)
|
|
118
|
+
|
|
119
|
+
req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
120
|
+
req.body = JSON.generate(params)
|
|
121
|
+
|
|
122
|
+
res = http.request(req)
|
|
123
|
+
body = parse_body(res)
|
|
124
|
+
unwrap(body, method_name)
|
|
125
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
126
|
+
raise TimeoutError, "#{method_name} timed out"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def post_multipart(method_name, params, file_field:, file_path:, filename: nil)
|
|
130
|
+
uri = URI("#{@base_url}/bot#{@token}/#{method_name}")
|
|
131
|
+
boundary = "----clacky-tg-#{SecureRandom.hex(8)}"
|
|
132
|
+
body = String.new(encoding: "BINARY")
|
|
133
|
+
|
|
134
|
+
params.each do |k, v|
|
|
135
|
+
body << "--#{boundary}\r\n"
|
|
136
|
+
body << %(Content-Disposition: form-data; name="#{k}"\r\n\r\n)
|
|
137
|
+
body << v.to_s.dup.force_encoding("BINARY")
|
|
138
|
+
body << "\r\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
file_bytes = File.binread(file_path)
|
|
142
|
+
body << "--#{boundary}\r\n"
|
|
143
|
+
body << %(Content-Disposition: form-data; name="#{file_field}"; filename="#{filename || File.basename(file_path)}"\r\n)
|
|
144
|
+
body << "Content-Type: #{mime_for(file_path)}\r\n\r\n"
|
|
145
|
+
body << file_bytes
|
|
146
|
+
body << "\r\n--#{boundary}--\r\n"
|
|
147
|
+
|
|
148
|
+
http = build_http(uri, read_timeout: 60)
|
|
149
|
+
req = Net::HTTP::Post.new(uri.request_uri,
|
|
150
|
+
"Content-Type" => "multipart/form-data; boundary=#{boundary}")
|
|
151
|
+
req.body = body
|
|
152
|
+
|
|
153
|
+
unwrap(parse_body(http.request(req)), method_name)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def http_get_raw(uri)
|
|
157
|
+
http = build_http(uri, read_timeout: 60)
|
|
158
|
+
res = http.request(Net::HTTP::Get.new(uri.request_uri))
|
|
159
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
160
|
+
raise ApiError.new(res.code.to_i, "GET #{uri.path} → HTTP #{res.code}: #{res.body.to_s.slice(0, 200)}")
|
|
161
|
+
end
|
|
162
|
+
res.body
|
|
163
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
164
|
+
raise TimeoutError, "file download timed out"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_http(uri, read_timeout:)
|
|
168
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
169
|
+
http.use_ssl = uri.scheme == "https"
|
|
170
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
171
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
172
|
+
http.read_timeout = read_timeout
|
|
173
|
+
http
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_body(res)
|
|
177
|
+
JSON.parse(res.body)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
raise ApiError.new(res.code.to_i, "non-JSON response from Telegram: #{res.body.to_s.slice(0, 200)}")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def unwrap(body, method_name)
|
|
183
|
+
if body["ok"]
|
|
184
|
+
body["result"]
|
|
185
|
+
else
|
|
186
|
+
raise ApiError.new(body["error_code"].to_i, "#{method_name}: #{body["description"]}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def mime_for(path)
|
|
191
|
+
case File.extname(path).downcase
|
|
192
|
+
when ".png" then "image/png"
|
|
193
|
+
when ".gif" then "image/gif"
|
|
194
|
+
when ".webp" then "image/webp"
|
|
195
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
196
|
+
when ".pdf" then "application/pdf"
|
|
197
|
+
when ".txt", ".md" then "text/plain"
|
|
198
|
+
else "application/octet-stream"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -109,6 +109,17 @@ module Clacky
|
|
|
109
109
|
base_url: raw["base_url"],
|
|
110
110
|
allowed_users: raw["allowed_users"]
|
|
111
111
|
}.compact
|
|
112
|
+
when :discord
|
|
113
|
+
{
|
|
114
|
+
bot_token: raw["bot_token"]
|
|
115
|
+
}.compact
|
|
116
|
+
when :telegram
|
|
117
|
+
{
|
|
118
|
+
bot_token: raw["bot_token"],
|
|
119
|
+
base_url: raw["base_url"],
|
|
120
|
+
parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
|
|
121
|
+
allowed_users: raw["allowed_users"]
|
|
122
|
+
}.compact
|
|
112
123
|
else
|
|
113
124
|
# Unknown platform — pass all non-meta keys as symbol-keyed hash
|
|
114
125
|
raw.reject { |k, _| k == "enabled" }
|
|
@@ -24,6 +24,8 @@ require_relative "channel/adapters/base"
|
|
|
24
24
|
require_relative "channel/adapters/feishu/adapter"
|
|
25
25
|
require_relative "channel/adapters/wecom/adapter"
|
|
26
26
|
require_relative "channel/adapters/weixin/adapter"
|
|
27
|
+
require_relative "channel/adapters/discord/adapter"
|
|
28
|
+
require_relative "channel/adapters/telegram/adapter"
|
|
27
29
|
|
|
28
30
|
require_relative "channel/channel_config"
|
|
29
31
|
require_relative "channel/channel_ui_controller"
|
|
@@ -57,7 +57,9 @@ module Clacky
|
|
|
57
57
|
def show_assistant_message(content, files:)
|
|
58
58
|
return if content.nil? || content.to_s.strip.empty?
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
# Rewrite local image paths to /api/local-image proxy URLs for browser rendering
|
|
61
|
+
rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
|
|
62
|
+
@events << { type: "assistant_message", session_id: @session_id, content: rewritten }
|
|
61
63
|
end
|
|
62
64
|
|
|
63
65
|
def show_tool_call(name, args)
|
|
@@ -242,7 +244,15 @@ module Clacky
|
|
|
242
244
|
Clacky::Logger.warn("[HttpServer] Forced exit after graceful shutdown timeout.")
|
|
243
245
|
exit!(0)
|
|
244
246
|
end
|
|
245
|
-
#
|
|
247
|
+
# Detach the inherited (shared) listen socket BEFORE shutdown so that
|
|
248
|
+
# WEBrick's cleanup_listener does not call shutdown(SHUT_RDWR)+close on
|
|
249
|
+
# it — that would propagate to every process sharing the underlying
|
|
250
|
+
# kernel socket (Master + new worker), breaking subsequent accept()
|
|
251
|
+
# on Linux. macOS's BSD stack tolerates this; Linux does not.
|
|
252
|
+
if @inherited_socket && server.listeners.include?(@inherited_socket)
|
|
253
|
+
server.listeners.delete(@inherited_socket)
|
|
254
|
+
Clacky::Logger.info("[HttpServer PID=#{Process.pid}] detached inherited socket fd=#{@inherited_socket.fileno} before shutdown")
|
|
255
|
+
end
|
|
246
256
|
t1 = Thread.new { @channel_manager.stop rescue nil }
|
|
247
257
|
t2 = Thread.new { Clacky::BrowserManager.instance.stop rescue nil }
|
|
248
258
|
t1.join(1.5)
|
|
@@ -397,6 +407,7 @@ module Clacky
|
|
|
397
407
|
when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
|
|
398
408
|
when ["POST", "/api/upload"] then api_upload_file(req, res)
|
|
399
409
|
when ["POST", "/api/open-file"] then api_open_file(req, res)
|
|
410
|
+
when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
|
|
400
411
|
when ["GET", "/api/version"] then api_get_version(res)
|
|
401
412
|
when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
|
|
402
413
|
when ["POST", "/api/restart"] then api_restart(req, res)
|
|
@@ -1002,10 +1013,14 @@ module Clacky
|
|
|
1002
1013
|
def api_get_version(res)
|
|
1003
1014
|
current = Clacky::VERSION
|
|
1004
1015
|
latest = fetch_latest_version_cached
|
|
1016
|
+
brand = Clacky::BrandConfig.load
|
|
1017
|
+
cli_cmd = brand.branded? && brand.package_name && !brand.package_name.empty? ? brand.package_name : "openclacky"
|
|
1005
1018
|
json_response(res, 200, {
|
|
1006
1019
|
current: current,
|
|
1007
1020
|
latest: latest,
|
|
1008
|
-
needs_update: latest ? version_older?(current, latest) : false
|
|
1021
|
+
needs_update: latest ? version_older?(current, latest) : false,
|
|
1022
|
+
launcher: ENV["CLACKY_LAUNCHER"] || "cli",
|
|
1023
|
+
cli_command: cli_cmd
|
|
1009
1024
|
})
|
|
1010
1025
|
end
|
|
1011
1026
|
|
|
@@ -1555,6 +1570,43 @@ module Clacky
|
|
|
1555
1570
|
json_response(res, 500, { ok: false, error: e.message })
|
|
1556
1571
|
end
|
|
1557
1572
|
|
|
1573
|
+
# GET /api/local-image?path=file:///path/to/image.png
|
|
1574
|
+
# GET /api/local-image?path=/path/to/image.png
|
|
1575
|
+
#
|
|
1576
|
+
# Serves a local image file with the correct Content-Type.
|
|
1577
|
+
# Used by the Web UI to render local images that would otherwise be blocked
|
|
1578
|
+
# by the browser's security policy (file:// from http:// origin).
|
|
1579
|
+
#
|
|
1580
|
+
def api_serve_local_image(req, res)
|
|
1581
|
+
raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
|
|
1582
|
+
return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
|
|
1583
|
+
|
|
1584
|
+
# Strip file:// prefix if present
|
|
1585
|
+
path = raw_path.sub(%r{\Afile://}, "")
|
|
1586
|
+
path = CGI.unescape(path)
|
|
1587
|
+
path = File.expand_path(path)
|
|
1588
|
+
|
|
1589
|
+
# On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
|
|
1590
|
+
# Convert it to the Linux-side path so File.exist? works.
|
|
1591
|
+
path = Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
1592
|
+
|
|
1593
|
+
# Security: only serve image files
|
|
1594
|
+
ext = File.extname(path).downcase
|
|
1595
|
+
unless Utils::FileProcessor::LOCAL_IMAGE_EXTENSIONS.include?(ext)
|
|
1596
|
+
return json_response(res, 403, { error: "not an image file" })
|
|
1597
|
+
end
|
|
1598
|
+
|
|
1599
|
+
return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
|
|
1600
|
+
|
|
1601
|
+
mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
|
|
1602
|
+
res.status = 200
|
|
1603
|
+
res["Content-Type"] = mime
|
|
1604
|
+
res["Cache-Control"] = "private, max-age=3600"
|
|
1605
|
+
res.body = File.binread(path)
|
|
1606
|
+
rescue => e
|
|
1607
|
+
json_response(res, 500, { error: e.message })
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1558
1610
|
# POST /api/channels/:platform
|
|
1559
1611
|
# Body: { fields... } (platform-specific credential fields)
|
|
1560
1612
|
# Saves credentials and optionally (re)starts the adapter.
|
|
@@ -1568,6 +1620,7 @@ module Clacky
|
|
|
1568
1620
|
|
|
1569
1621
|
# Record when the token was last updated so clients can detect re-login
|
|
1570
1622
|
fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
|
|
1623
|
+
fields[:token_updated_at] = Time.now.to_i if platform == :discord && fields.key?(:bot_token)
|
|
1571
1624
|
|
|
1572
1625
|
# Validate credentials against live API before persisting.
|
|
1573
1626
|
# Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
|
|
@@ -1649,6 +1702,19 @@ module Clacky
|
|
|
1649
1702
|
has_token: !raw["token"].to_s.strip.empty?,
|
|
1650
1703
|
token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
|
|
1651
1704
|
}
|
|
1705
|
+
when :discord
|
|
1706
|
+
{
|
|
1707
|
+
allowed_users: raw["allowed_users"] || [],
|
|
1708
|
+
has_token: !raw["bot_token"].to_s.strip.empty?,
|
|
1709
|
+
token_updated_at: raw["token_updated_at"]
|
|
1710
|
+
}
|
|
1711
|
+
when :telegram
|
|
1712
|
+
{
|
|
1713
|
+
base_url: raw["base_url"] || Clacky::Channel::Adapters::Telegram::ApiClient::DEFAULT_BASE_URL,
|
|
1714
|
+
parse_mode: raw.key?("parse_mode") ? raw["parse_mode"] : "Markdown",
|
|
1715
|
+
allowed_users: raw["allowed_users"] || [],
|
|
1716
|
+
has_token: !raw["bot_token"].to_s.strip.empty?
|
|
1717
|
+
}
|
|
1652
1718
|
else
|
|
1653
1719
|
{}
|
|
1654
1720
|
end
|
|
@@ -733,8 +733,9 @@ module Clacky
|
|
|
733
733
|
@legacy_progress_handles ||= {}
|
|
734
734
|
|
|
735
735
|
if phase.to_s == "done"
|
|
736
|
-
handle = @legacy_progress_handles
|
|
736
|
+
handle = @legacy_progress_handles[type]
|
|
737
737
|
handle&.finish(final_message: message)
|
|
738
|
+
@legacy_progress_handles.delete(type)
|
|
738
739
|
return
|
|
739
740
|
end
|
|
740
741
|
|
|
@@ -523,8 +523,79 @@ module Clacky
|
|
|
523
523
|
nil
|
|
524
524
|
end
|
|
525
525
|
|
|
526
|
+
# Image extensions that can be inlined as data URLs in markdown content.
|
|
527
|
+
LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
|
|
528
|
+
|
|
529
|
+
# Replace local image paths in markdown content with base64 data URLs.
|
|
530
|
+
#
|
|
531
|
+
# Handles both `file:///path/to/img.png` and bare `/path/to/img.png` in
|
|
532
|
+
# markdown image syntax ``.
|
|
533
|
+
#
|
|
534
|
+
# @param content [String] markdown text potentially containing local image references
|
|
535
|
+
# @return [String] content with local images replaced by data URLs
|
|
536
|
+
def self.inline_local_images(content)
|
|
537
|
+
return content if content.nil? || content.empty?
|
|
538
|
+
|
|
539
|
+
content.gsub(%r{(!\[[^\]]*\])\((file://)?(/[^)]+)\)}) do
|
|
540
|
+
prefix = $1
|
|
541
|
+
_scheme = $2
|
|
542
|
+
raw_path = $3
|
|
543
|
+
path = CGI.unescape(raw_path)
|
|
544
|
+
ext = File.extname(path).downcase
|
|
545
|
+
full_match = $&
|
|
546
|
+
|
|
547
|
+
unless LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
|
|
548
|
+
next full_match
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
begin
|
|
552
|
+
data_url = image_path_to_data_url(path)
|
|
553
|
+
Clacky::Logger.info("file_processor.inline_local_images", path: path, size: File.size(path))
|
|
554
|
+
"#{prefix}(#{data_url})"
|
|
555
|
+
rescue StandardError => e
|
|
556
|
+
Clacky::Logger.warn("file_processor.inline_local_images.failed", path: path, error: e.message)
|
|
557
|
+
full_match
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
526
562
|
private_class_method :parse_zip_listing, :parse_tar_listing, :save_preview, :sanitize_filename,
|
|
527
563
|
:downscale_png_chunky, :downscale_via_cli
|
|
564
|
+
|
|
565
|
+
# -------------------------------------------------------------------------
|
|
566
|
+
# Local image URL rewriting
|
|
567
|
+
# -------------------------------------------------------------------------
|
|
568
|
+
|
|
569
|
+
# Rewrite local image paths in markdown content to use the /api/local-image proxy.
|
|
570
|
+
#
|
|
571
|
+
# Matches two patterns inside ``:
|
|
572
|
+
# 1. file:// URLs → 
|
|
573
|
+
# 2. bare absolute paths → 
|
|
574
|
+
#
|
|
575
|
+
# https:// URLs and non-image files are left untouched.
|
|
576
|
+
#
|
|
577
|
+
# @param content [String, nil] markdown text
|
|
578
|
+
# @return [String, nil] rewritten content (or original if nothing matched)
|
|
579
|
+
def self.rewrite_local_image_urls(content)
|
|
580
|
+
return content if content.nil? || content.empty?
|
|
581
|
+
|
|
582
|
+
content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |match|
|
|
583
|
+
alt = Regexp.last_match(1)
|
|
584
|
+
href = Regexp.last_match(2)
|
|
585
|
+
|
|
586
|
+
# Extract the filesystem path from the href
|
|
587
|
+
path = href.sub(%r{\Afile://}, "")
|
|
588
|
+
path = CGI.unescape(path)
|
|
589
|
+
|
|
590
|
+
ext = File.extname(path).downcase
|
|
591
|
+
if LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
|
|
592
|
+
encoded = CGI.escape(href)
|
|
593
|
+
""
|
|
594
|
+
else
|
|
595
|
+
match # return original match unchanged
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
end
|
|
528
599
|
end
|
|
529
600
|
end
|
|
530
601
|
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -5764,6 +5764,16 @@ body.setup-mode[data-theme="dark"] {
|
|
|
5764
5764
|
color: #fff;
|
|
5765
5765
|
}
|
|
5766
5766
|
|
|
5767
|
+
.channel-logo-discord {
|
|
5768
|
+
background: linear-gradient(135deg, #5865f2, #4752c4);
|
|
5769
|
+
color: #fff;
|
|
5770
|
+
}
|
|
5771
|
+
|
|
5772
|
+
.channel-logo-telegram {
|
|
5773
|
+
background: linear-gradient(135deg, #2aabee, #229ed9);
|
|
5774
|
+
color: #fff;
|
|
5775
|
+
}
|
|
5776
|
+
|
|
5767
5777
|
.channel-card-name {
|
|
5768
5778
|
font-size: 15px;
|
|
5769
5779
|
font-weight: 600;
|
|
@@ -6210,6 +6220,40 @@ body.setup-mode[data-theme="dark"] {
|
|
|
6210
6220
|
line-height: 1.5;
|
|
6211
6221
|
}
|
|
6212
6222
|
|
|
6223
|
+
/* Restart-failed state — shows both recovery paths (tray + CLI) */
|
|
6224
|
+
.vup-restart-failed {
|
|
6225
|
+
padding: 4px 0 2px;
|
|
6226
|
+
}
|
|
6227
|
+
.vup-restart-failed-title {
|
|
6228
|
+
margin: 0 0 6px;
|
|
6229
|
+
font-size: 13px;
|
|
6230
|
+
font-weight: 600;
|
|
6231
|
+
color: var(--color-error, #ef4444);
|
|
6232
|
+
}
|
|
6233
|
+
.vup-restart-failed-desc {
|
|
6234
|
+
margin: 0 0 8px;
|
|
6235
|
+
font-size: 12px;
|
|
6236
|
+
color: var(--color-text-muted);
|
|
6237
|
+
line-height: 1.5;
|
|
6238
|
+
}
|
|
6239
|
+
.vup-restart-failed-options {
|
|
6240
|
+
margin: 0 0 10px;
|
|
6241
|
+
padding-left: 18px;
|
|
6242
|
+
font-size: 12px;
|
|
6243
|
+
color: var(--color-text-primary);
|
|
6244
|
+
line-height: 1.6;
|
|
6245
|
+
}
|
|
6246
|
+
.vup-restart-failed-options li + li {
|
|
6247
|
+
margin-top: 4px;
|
|
6248
|
+
}
|
|
6249
|
+
.vup-cmd {
|
|
6250
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
6251
|
+
font-size: 11.5px;
|
|
6252
|
+
background: var(--color-surface-muted, rgba(127,127,127,.12));
|
|
6253
|
+
padding: 1px 6px;
|
|
6254
|
+
border-radius: 4px;
|
|
6255
|
+
}
|
|
6256
|
+
|
|
6213
6257
|
|
|
6214
6258
|
|
|
6215
6259
|
/* ── Profile / My Data Panel ───────────────────────────────────────────────
|
data/lib/clacky/web/channels.js
CHANGED
|
@@ -35,6 +35,22 @@ const Channels = (() => {
|
|
|
35
35
|
setupCmd: "/channel-setup setup weixin",
|
|
36
36
|
testCmd: "/channel-setup doctor",
|
|
37
37
|
},
|
|
38
|
+
discord: {
|
|
39
|
+
logo: "D",
|
|
40
|
+
logoClass: "channel-logo-discord",
|
|
41
|
+
name: "Discord",
|
|
42
|
+
desc: I18n.t("channels.discord.desc"),
|
|
43
|
+
setupCmd: "/channel-setup setup discord",
|
|
44
|
+
testCmd: "/channel-setup doctor",
|
|
45
|
+
},
|
|
46
|
+
telegram: {
|
|
47
|
+
logo: "T",
|
|
48
|
+
logoClass: "channel-logo-telegram",
|
|
49
|
+
name: "Telegram",
|
|
50
|
+
desc: I18n.t("channels.telegram.desc"),
|
|
51
|
+
setupCmd: "/channel-setup setup telegram",
|
|
52
|
+
testCmd: "/channel-setup doctor",
|
|
53
|
+
},
|
|
38
54
|
};
|
|
39
55
|
}
|
|
40
56
|
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -130,6 +130,11 @@ const I18n = (() => {
|
|
|
130
130
|
"upgrade.failed": "Upgrade failed. Try again or run manually: gem update openclacky",
|
|
131
131
|
"upgrade.reconnecting": "Restarting server…",
|
|
132
132
|
"upgrade.restart.success": "✓ Restarted successfully!",
|
|
133
|
+
"upgrade.restart.timeout.title": "Hot restart didn't complete",
|
|
134
|
+
"upgrade.restart.timeout.desc": "The server didn't come back within 30 s. Please recover using one of the methods below:",
|
|
135
|
+
"upgrade.restart.timeout.tray": "From the tray menu, choose “Quit” and start the app again.",
|
|
136
|
+
"upgrade.restart.timeout.cli": "If you launched from a terminal, run: {{cmd}}",
|
|
137
|
+
"upgrade.restart.timeout.retry": "Try Again",
|
|
133
138
|
"upgrade.tooltip.upgrading": "Upgrading — click to see progress",
|
|
134
139
|
"upgrade.tooltip.new": "v{{latest}} available — click to upgrade",
|
|
135
140
|
"upgrade.tooltip.ok": "v{{current}} (up to date)",
|
|
@@ -347,7 +352,7 @@ const I18n = (() => {
|
|
|
347
352
|
|
|
348
353
|
// ── Channels panel ──
|
|
349
354
|
"channels.title": "Channels",
|
|
350
|
-
"channels.subtitle": "Connect IM platforms so your users can chat with the assistant via Feishu, WeCom or
|
|
355
|
+
"channels.subtitle": "Connect IM platforms so your users can chat with the assistant via Feishu, WeCom, Weixin or Telegram",
|
|
351
356
|
"channels.loading": "Loading…",
|
|
352
357
|
"channels.badge.running": "Running",
|
|
353
358
|
"channels.badge.enabled": "Enabled",
|
|
@@ -368,6 +373,8 @@ const I18n = (() => {
|
|
|
368
373
|
"channels.feishu.desc": "Connect via Feishu open platform WebSocket long connection",
|
|
369
374
|
"channels.wecom.desc": "Connect via WeCom intelligent robot WebSocket",
|
|
370
375
|
"channels.weixin.desc": "Connect via WeChat iLink bot (QR login, HTTP long-poll)",
|
|
376
|
+
"channels.discord.desc": "Connect via Discord bot, invite via OAuth2 authorization link",
|
|
377
|
+
"channels.telegram.desc": "Connect via Telegram Bot API (HTTPS long-poll, token from @BotFather)",
|
|
371
378
|
|
|
372
379
|
// ── Settings panel ──
|
|
373
380
|
"settings.title": "Settings",
|
|
@@ -383,6 +390,8 @@ const I18n = (() => {
|
|
|
383
390
|
"settings.models.field.baseurl": "Base URL",
|
|
384
391
|
"settings.models.field.apikey": "API Key",
|
|
385
392
|
"settings.models.field.getApiKey": "How to get →",
|
|
393
|
+
"settings.models.field.docsGuide.question": "New to AI keys?",
|
|
394
|
+
"settings.models.field.docsGuide.cta": "See the guide →",
|
|
386
395
|
"settings.models.placeholder.provider": "— Choose provider —",
|
|
387
396
|
"settings.models.placeholder.model": "e.g. claude-sonnet-4-5",
|
|
388
397
|
"settings.models.placeholder.baseurl": "https://api.anthropic.com",
|
|
@@ -471,6 +480,8 @@ const I18n = (() => {
|
|
|
471
480
|
"onboard.key.baseurl": "Base URL",
|
|
472
481
|
"onboard.key.apikey": "API Key",
|
|
473
482
|
"onboard.key.getApiKey": "How to get →",
|
|
483
|
+
"onboard.key.docsGuide.question": "New to AI keys?",
|
|
484
|
+
"onboard.key.docsGuide.cta": "See the guide →",
|
|
474
485
|
"onboard.key.btn.test": "Test & Continue →",
|
|
475
486
|
"onboard.key.btn.back": "← Back",
|
|
476
487
|
"onboard.provider.custom": "Custom",
|
|
@@ -628,6 +639,11 @@ const I18n = (() => {
|
|
|
628
639
|
"upgrade.failed": "升级失败,请重试或手动执行:gem update openclacky",
|
|
629
640
|
"upgrade.reconnecting": "服务重启中…",
|
|
630
641
|
"upgrade.restart.success": "✓ 重启成功!",
|
|
642
|
+
"upgrade.restart.timeout.title": "热重启未完成",
|
|
643
|
+
"upgrade.restart.timeout.desc": "服务在 30 秒内未恢复。请通过以下任一方式重启:",
|
|
644
|
+
"upgrade.restart.timeout.tray": "在系统托盘菜单中选择「退出」,然后重新打开应用。",
|
|
645
|
+
"upgrade.restart.timeout.cli": "如果是终端启动,请执行命令: {{cmd}}",
|
|
646
|
+
"upgrade.restart.timeout.retry": "重试",
|
|
631
647
|
"upgrade.tooltip.upgrading": "升级中,点击查看进度",
|
|
632
648
|
"upgrade.tooltip.new": "v{{latest}} 可用,点击升级",
|
|
633
649
|
"upgrade.tooltip.ok": "v{{current}}(已是最新)",
|
|
@@ -844,7 +860,7 @@ const I18n = (() => {
|
|
|
844
860
|
|
|
845
861
|
// ── Channels panel ──
|
|
846
862
|
"channels.title": "频道",
|
|
847
|
-
"channels.subtitle": "
|
|
863
|
+
"channels.subtitle": "连接即时通讯平台,让用户通过飞书、企业微信、微信或 Telegram 与助手对话",
|
|
848
864
|
"channels.loading": "加载中…",
|
|
849
865
|
"channels.loadError": "加载频道失败:{{msg}}",
|
|
850
866
|
"channels.badge.running": "运行中",
|
|
@@ -865,6 +881,8 @@ const I18n = (() => {
|
|
|
865
881
|
"channels.feishu.desc": "通过飞书开放平台 WebSocket 长连接接入",
|
|
866
882
|
"channels.wecom.desc": "通过企业微信智能机器人 WebSocket 接入",
|
|
867
883
|
"channels.weixin.desc": "通过微信 iLink 机器人接入(扫码登录,HTTP 长轮询)",
|
|
884
|
+
"channels.discord.desc": "通过 Discord 机器人接入,授权链接邀请 Bot 加入服务器",
|
|
885
|
+
"channels.telegram.desc": "通过 Telegram Bot API 接入(HTTPS 长轮询,token 来自 @BotFather)",
|
|
868
886
|
|
|
869
887
|
// ── Settings panel ──
|
|
870
888
|
"settings.title": "设置",
|
|
@@ -880,6 +898,8 @@ const I18n = (() => {
|
|
|
880
898
|
"settings.models.field.baseurl": "Base URL",
|
|
881
899
|
"settings.models.field.apikey": "API Key",
|
|
882
900
|
"settings.models.field.getApiKey": "如何获取 →",
|
|
901
|
+
"settings.models.field.docsGuide.question": "不会配置 AI 模型?",
|
|
902
|
+
"settings.models.field.docsGuide.cta": "查看指南 →",
|
|
883
903
|
"settings.models.placeholder.provider": "— 选择服务商 —",
|
|
884
904
|
"settings.models.placeholder.model": "如 claude-sonnet-4-5",
|
|
885
905
|
"settings.models.placeholder.baseurl": "https://api.anthropic.com",
|
|
@@ -968,6 +988,8 @@ const I18n = (() => {
|
|
|
968
988
|
"onboard.key.baseurl": "Base URL",
|
|
969
989
|
"onboard.key.apikey": "API Key",
|
|
970
990
|
"onboard.key.getApiKey": "如何获取 →",
|
|
991
|
+
"onboard.key.docsGuide.question": "不会配置 AI 模型?",
|
|
992
|
+
"onboard.key.docsGuide.cta": "查看指南 →",
|
|
971
993
|
"onboard.key.btn.test": "测试并继续 →",
|
|
972
994
|
"onboard.key.btn.back": "← 返回",
|
|
973
995
|
"onboard.provider.custom": "自定义",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -883,7 +883,12 @@
|
|
|
883
883
|
</div>
|
|
884
884
|
</div>
|
|
885
885
|
|
|
886
|
-
<div
|
|
886
|
+
<div class="setup-docs-hint" style="font-size:12px;margin-top:2px;text-align:left;">
|
|
887
|
+
<span style="color:var(--muted,#6b7280);" data-i18n="onboard.key.docsGuide.question">New to AI keys?</span>
|
|
888
|
+
<a id="setup-docs-link" href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:4px;color:var(--accent,#6366f1);text-decoration:none;" data-i18n="onboard.key.docsGuide.cta">See the guide →</a>
|
|
889
|
+
</div>
|
|
890
|
+
|
|
891
|
+
<div id="setup-test-result" class="setup-test-result" style="min-height:0;"></div>
|
|
887
892
|
|
|
888
893
|
<div class="setup-key-actions">
|
|
889
894
|
<button id="setup-btn-back" class="setup-back-btn" data-i18n="onboard.key.btn.back">← Back</button>
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -141,6 +141,10 @@ const Settings = (() => {
|
|
|
141
141
|
</div>
|
|
142
142
|
|
|
143
143
|
<div class="model-card-footer">
|
|
144
|
+
${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:12px;">
|
|
145
|
+
<span style="color:var(--muted,#6b7280);">${I18n.t("settings.models.field.docsGuide.question")}</span>
|
|
146
|
+
<a href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:4px;color:var(--accent,#6366f1);text-decoration:none;">${I18n.t("settings.models.field.docsGuide.cta")}</a>
|
|
147
|
+
</span>` : ""}
|
|
144
148
|
<span class="model-test-result" data-index="${index}"></span>
|
|
145
149
|
<div class="model-card-actions-row">
|
|
146
150
|
${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="${I18n.t("settings.models.btn.setDefault")}">${I18n.t("settings.models.btn.setDefault")}</button>` : ""}
|