openclacky 1.1.2 → 1.1.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +27 -31
  3. data/CHANGELOG.md +30 -0
  4. data/Dockerfile +28 -0
  5. data/README.md +4 -0
  6. data/README_CN.md +198 -0
  7. data/docs/engineering-article.md +343 -0
  8. data/lib/clacky/agent/llm_caller.rb +2 -5
  9. data/lib/clacky/agent/session_serializer.rb +4 -0
  10. data/lib/clacky/agent.rb +22 -1
  11. data/lib/clacky/brand_config.rb +87 -5
  12. data/lib/clacky/cli.rb +1 -1
  13. data/lib/clacky/client.rb +15 -11
  14. data/lib/clacky/message_format/anthropic.rb +30 -2
  15. data/lib/clacky/message_format/bedrock.rb +13 -1
  16. data/lib/clacky/message_format/open_ai.rb +5 -1
  17. data/lib/clacky/providers.rb +34 -0
  18. data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
  19. data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
  20. data/lib/clacky/server/http_server.rb +130 -15
  21. data/lib/clacky/server/session_registry.rb +9 -6
  22. data/lib/clacky/ui2/ui_controller.rb +14 -0
  23. data/lib/clacky/ui_interface.rb +14 -0
  24. data/lib/clacky/utils/model_pricing.rb +96 -25
  25. data/lib/clacky/version.rb +1 -1
  26. data/lib/clacky/web/app.css +1286 -1116
  27. data/lib/clacky/web/brand.js +20 -5
  28. data/lib/clacky/web/i18n.js +42 -0
  29. data/lib/clacky/web/index.html +26 -7
  30. data/lib/clacky/web/onboard.js +6 -0
  31. data/lib/clacky/web/sessions.js +194 -11
  32. data/lib/clacky/web/settings.js +51 -10
  33. data/lib/clacky/web/skills.js +53 -31
  34. data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
  35. data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
  36. data/scripts/build/lib/apt.sh +30 -10
  37. data/scripts/build/lib/network.sh +3 -2
  38. data/scripts/install.sh +30 -9
  39. data/scripts/install_browser.sh +2 -1
  40. data/scripts/install_full.sh +2 -1
  41. data/scripts/install_rails_deps.sh +30 -9
  42. data/scripts/install_system_deps.sh +30 -9
  43. metadata +7 -17
  44. data/docs/HOW-TO-USE-CN.md +0 -96
  45. data/docs/HOW-TO-USE.md +0 -94
  46. data/docs/browser-cdp-native-design.md +0 -195
  47. data/docs/c-end-user-positioning.md +0 -64
  48. data/docs/config.example.yml +0 -27
  49. data/docs/deploy-architecture.md +0 -619
  50. data/docs/deploy_subagent_design.md +0 -540
  51. data/docs/install-script-simplification.md +0 -89
  52. data/docs/memory-architecture.md +0 -343
  53. data/docs/openclacky_cloud_api_reference.md +0 -584
  54. data/docs/security-design.md +0 -109
  55. data/docs/session-management-redesign.md +0 -202
  56. data/docs/system-skill-authoring-guide.md +0 -47
  57. data/docs/why-developer.md +0 -371
  58. data/docs/why-openclacky.md +0 -266
@@ -48,7 +48,7 @@ module Clacky
48
48
  # @param max_tokens [Integer]
49
49
  # @param caching_enabled [Boolean]
50
50
  # @return [Hash] ready to serialize as JSON body
51
- def build_request_body(messages, model, tools, max_tokens, caching_enabled)
51
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled, reasoning_effort: nil)
52
52
  system_messages = messages.select { |m| m[:role] == "system" }
53
53
  regular_messages = messages.reject { |m| m[:role] == "system" }
54
54
 
@@ -64,9 +64,21 @@ module Clacky
64
64
  body = { model: model, max_tokens: max_tokens, messages: api_messages }
65
65
  body[:system] = system_text unless system_text.empty?
66
66
  body[:tools] = api_tools if api_tools&.any?
67
+
68
+ if (effort = normalized_effort(reasoning_effort))
69
+ body[:thinking] = { type: "adaptive" }
70
+ body[:output_config] = { effort: effort }
71
+ end
72
+
67
73
  body
68
74
  end
69
75
 
76
+ private_class_method def self.normalized_effort(effort)
77
+ return nil if effort.nil? || effort.to_s.empty?
78
+ s = effort.to_s
79
+ %w[low medium high].include?(s) ? s : nil
80
+ end
81
+
70
82
  # ── Response parsing ──────────────────────────────────────────────────────
71
83
 
72
84
  # Parse Anthropic API response into canonical internal format.
@@ -182,7 +194,23 @@ module Clacky
182
194
  func = tc[:function] || tc
183
195
  name = func[:name] || tc[:name]
184
196
  raw_args = func[:arguments] || tc[:arguments]
185
- input = raw_args.is_a?(String) ? JSON.parse(raw_args) : raw_args
197
+ input =
198
+ if raw_args.is_a?(String)
199
+ begin
200
+ JSON.parse(raw_args)
201
+ rescue JSON::ParserError => e
202
+ Clacky::Logger.warn("message_format.anthropic.tool_args_parse_failed",
203
+ tool_name: name.to_s,
204
+ tool_call_id: tc[:id].to_s,
205
+ args_len: raw_args.length,
206
+ args_head: raw_args[0, 120],
207
+ error: e.message
208
+ ) if defined?(Clacky::Logger)
209
+ {}
210
+ end
211
+ else
212
+ raw_args
213
+ end
186
214
  blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
187
215
  end
188
216
 
@@ -52,7 +52,7 @@ module Clacky
52
52
  # @param max_tokens [Integer]
53
53
  # @param caching_enabled [Boolean] (currently unused for Bedrock)
54
54
  # @return [Hash] ready to serialize as JSON body
55
- def build_request_body(messages, model, tools, max_tokens, caching_enabled = false)
55
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled = false, reasoning_effort: nil)
56
56
  system_messages = messages.select { |m| m[:role] == "system" }
57
57
  regular_messages = messages.reject { |m| m[:role] == "system" }
58
58
 
@@ -83,9 +83,21 @@ module Clacky
83
83
  body[:toolConfig] = { tools: tools.map { |t| to_api_tool(t) } }
84
84
  end
85
85
 
86
+ extra = additional_fields_for_effort(reasoning_effort)
87
+ body[:additionalModelRequestFields] = extra if extra
88
+
86
89
  body
87
90
  end
88
91
 
92
+ private_class_method def self.additional_fields_for_effort(effort)
93
+ return nil if effort.nil? || effort.to_s.empty?
94
+ return nil unless %w[low medium high].include?(effort.to_s)
95
+ {
96
+ thinking: { type: "adaptive" },
97
+ output_config: { effort: effort.to_s }
98
+ }
99
+ end
100
+
89
101
  # ── Response parsing ──────────────────────────────────────────────────────
90
102
 
91
103
  # Parse Bedrock Converse API response into canonical internal format.
@@ -44,7 +44,7 @@ module Clacky
44
44
  # @param vision_supported [Boolean] whether the target model accepts
45
45
  # image_url content blocks (default true, conservative)
46
46
  # @return [Hash]
47
- def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true)
47
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled, vision_supported: true, reasoning_effort: nil)
48
48
  api_messages = messages.map { |msg| normalize_message_content(msg, vision_supported: vision_supported) }
49
49
 
50
50
  body = { model: model, max_tokens: max_tokens, messages: api_messages }
@@ -59,6 +59,10 @@ module Clacky
59
59
  end
60
60
  end
61
61
 
62
+ if reasoning_effort && !reasoning_effort.to_s.empty?
63
+ body[:reasoning_effort] = reasoning_effort.to_s
64
+ end
65
+
62
66
  body
63
67
  end
64
68
 
@@ -329,6 +329,40 @@ module Clacky
329
329
  "gpt-5.4" => "gpt-5.4-mini"
330
330
  },
331
331
  "website_url" => "https://platform.openai.com/api-keys"
332
+ }.freeze,
333
+
334
+ "qwen" => {
335
+ "name" => "Qwen (Alibaba)",
336
+ "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
337
+ "api" => "openai-completions",
338
+ "default_model" => "qwen3.6-plus",
339
+ "models" => [
340
+ "qwen3.6-plus",
341
+ "qwen3.6-max",
342
+ "qwen3.6-27b",
343
+ "qwen3.6-flash",
344
+ "qwen-plus-latest",
345
+ "qwen-vl-plus",
346
+ "qwen-vl-max"
347
+ ],
348
+ "endpoint_variants" => [
349
+ { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
350
+ { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
351
+ { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
352
+ ].freeze,
353
+ "capabilities" => { "vision" => false }.freeze,
354
+ "model_capabilities" => {
355
+ "qwen3.6-27b" => { "vision" => true }.freeze,
356
+ "qwen-vl-plus" => { "vision" => true }.freeze,
357
+ "qwen-vl-max" => { "vision" => true }.freeze
358
+ }.freeze,
359
+ "lite_models" => {
360
+ "qwen3.6-plus" => "qwen3.6-flash",
361
+ "qwen3.6-max" => "qwen3.6-flash",
362
+ "qwen3.6-27b" => "qwen3.6-flash",
363
+ "qwen-plus-latest" => "qwen3.6-flash"
364
+ },
365
+ "website_url" => "https://bailian.console.aliyun.com/?apiKey=1"
332
366
  }.freeze
333
367
 
334
368
  }.freeze
@@ -53,6 +53,10 @@ module Clacky
53
53
  # and expires (~2h). We cache it from inbound events and validate on send.
54
54
  @webhook_urls = {}
55
55
  @webhook_mutex = Mutex.new
56
+ # chat_id => { robot_code:, conv_id:, user_id:, conv_type: } — needed
57
+ # to route OAPI calls (e.g. send_file) which can't go through webhook.
58
+ @routes = {}
59
+ @routes_mutex = Mutex.new
56
60
  end
57
61
 
58
62
  WEBHOOK_SAFETY_MARGIN_MS = 5 * 60 * 1000
@@ -74,13 +78,69 @@ module Clacky
74
78
  end
75
79
 
76
80
  # @param chat_id [String] — for DingTalk Stream Mode, chat_id == webhook URL
81
+ # Always sent as markdown so AI replies render rich text (headings,
82
+ # bold, lists, links). DingTalk's markdown msgtype renders plain text
83
+ # unchanged, so no detection branch is needed.
77
84
  def send_text(chat_id, text, reply_to: nil)
78
85
  webhook_url = resolve_webhook(chat_id)
79
86
  unless webhook_url
80
87
  Clacky::Logger.warn("[dingtalk] no valid sessionWebhook for chat #{chat_id} (expired or never received)")
81
88
  return { ok: false, error: "session_webhook_expired" }
82
89
  end
83
- @api_client.send_via_webhook(webhook_url, text)
90
+ @api_client.send_via_webhook(webhook_url, text, msg_type: :markdown)
91
+ end
92
+
93
+ # Send a local file (image or generic file) as a native attachment.
94
+ # Webhook can't deliver attachments — use OAPI sendMessage with mediaId.
95
+ # @param chat_id [String]
96
+ # @param path [String] local file path
97
+ # @param name [String, nil] display filename (not used by image msg)
98
+ def send_file(chat_id, path, name: nil, reply_to: nil)
99
+ unless File.exist?(path)
100
+ Clacky::Logger.warn("[dingtalk] send_file: file not found #{path}")
101
+ return { ok: false, error: "file_not_found" }
102
+ end
103
+
104
+ route = resolve_route(chat_id)
105
+ unless route
106
+ Clacky::Logger.warn("[dingtalk] send_file: no routing info for chat #{chat_id}")
107
+ return { ok: false, error: "no_route" }
108
+ end
109
+
110
+ kind = image_file?(path) ? :image : :file
111
+
112
+ # Non-image files outside DingTalk's accepted extension list
113
+ # (sampleFile rejects anything not in SUPPORTED_FILE_EXTS).
114
+ # Surface the failure directly to the user in the chat,
115
+ # disguised as a DingTalk system message so it's clear the
116
+ # restriction comes from the IM platform, not us.
117
+ if kind == :file && !supported_file?(path)
118
+ ext = File.extname(path).delete_prefix(".").downcase
119
+ display_name = name || File.basename(path)
120
+ Clacky::Logger.info("[dingtalk] send_file: unsupported extension .#{ext} (#{display_name})")
121
+ supported_list = ApiClient::SUPPORTED_FILE_EXTS.map { |e| ".#{e}" }.join(", ")
122
+ send_text(
123
+ chat_id,
124
+ %([DingTalk System] ⚠️ Failed to deliver file "#{display_name}": file type ".#{ext}" is not supported. Supported types: #{supported_list}.)
125
+ )
126
+ return { ok: false, error: :unsupported_extension }
127
+ end
128
+
129
+ media_id = @api_client.upload_media(path, kind: kind)
130
+ unless media_id
131
+ Clacky::Logger.warn("[dingtalk] send_file: upload failed for #{path}")
132
+ return { ok: false, error: "upload_failed" }
133
+ end
134
+
135
+ @api_client.send_media(
136
+ robot_code: route[:robot_code],
137
+ conv_type: route[:conv_type],
138
+ conv_id: route[:conv_id],
139
+ user_id: route[:user_id],
140
+ media_id: media_id,
141
+ kind: kind,
142
+ file_name: name || File.basename(path)
143
+ )
84
144
  end
85
145
 
86
146
  def validate_config(config)
@@ -106,16 +166,18 @@ module Clacky
106
166
  chat_id = data.dig("conversationId") || sender_id
107
167
  webhook_url = data.dig("sessionWebhook") || ""
108
168
  expired_ms = (data.dig("sessionWebhookExpiredTime") || 0).to_i
109
- text = extract_text(data)
110
169
  conv_type = data.dig("conversationType").to_s # "1"=DM, "2"=group
170
+ robot_code = data["robotCode"].to_s
111
171
 
112
172
  cache_webhook(chat_id, webhook_url, expired_ms) unless webhook_url.empty?
173
+ cache_route(chat_id, robot_code: robot_code, conv_id: data["conversationId"].to_s,
174
+ user_id: sender_id, conv_type: conv_type)
113
175
 
114
176
  return if sender_id.empty?
115
177
 
116
178
  # Group chats: only respond when @-mentioned
117
179
  if conv_type == "2"
118
- content = data.dig("text", "content").to_s
180
+ content = data.dig("text", "content").to_s
119
181
  at_users = Array(data.dig("atUsers")).map { |u| u.dig("dingtalkId") || u.dig("staffId") || "" }
120
182
  bot_id = data.dig("chatbotUserId").to_s
121
183
  unless at_users.include?(bot_id) || content.include?("@")
@@ -126,22 +188,72 @@ module Clacky
126
188
  allowed = @config[:allowed_users]
127
189
  return if allowed && !allowed.empty? && !allowed.include?(sender_id)
128
190
 
191
+ text, files = extract_payload(data, robot_code)
192
+ return if text.strip.empty? && files.empty?
193
+
129
194
  event = {
130
195
  platform: :dingtalk,
131
196
  user_id: sender_id,
132
197
  chat_id: chat_id,
133
198
  message_id: data.dig("msgId") || "",
134
199
  text: text,
135
- files: [],
200
+ files: files,
136
201
  chat_type: conv_type == "2" ? :group : :direct
137
202
  }
138
203
 
139
- Clacky::Logger.info("[dingtalk] message from #{sender_id}: #{text.to_s[0, 80]}")
204
+ log_parts = []
205
+ log_parts << text.slice(0, 80) unless text.strip.empty?
206
+ log_parts << "#{files.size} file(s)" unless files.empty?
207
+ Clacky::Logger.info("[dingtalk] message from #{sender_id}: #{log_parts.join(' | ')}")
140
208
  @on_message&.call(event)
141
209
  rescue => e
142
210
  Clacky::Logger.warn("[dingtalk] handle_frame error: #{e.message}")
143
211
  end
144
212
 
213
+ # Parse text + attachments from inbound event by msgtype.
214
+ # Returns [text, files] where files is an array of { path:, mime:, name: }.
215
+ private def extract_payload(data, robot_code)
216
+ msgtype = data["msgtype"].to_s
217
+ text = ""
218
+ files = []
219
+
220
+ case msgtype
221
+ when "text"
222
+ text = extract_text(data)
223
+ when "picture"
224
+ code = data.dig("content", "downloadCode")
225
+ file = download_one(code, robot_code)
226
+ files << file if file
227
+ when "file"
228
+ # Inbound file message — DingTalk's downloadCode → downloadUrl path
229
+ # works for any file type (no whitelist on the inbound side).
230
+ code = data.dig("content", "downloadCode")
231
+ name = data.dig("content", "fileName")
232
+ file = download_one(code, robot_code, prefer_name: name)
233
+ files << file if file
234
+ when "richText"
235
+ Array(data.dig("content", "richText")).each do |part|
236
+ if part["text"]
237
+ text += part["text"].to_s
238
+ elsif part["downloadCode"] && part["type"] == "picture"
239
+ file = download_one(part["downloadCode"], robot_code)
240
+ files << file if file
241
+ end
242
+ end
243
+ else
244
+ Clacky::Logger.info("[dingtalk] unsupported msgtype=#{msgtype}, ignoring")
245
+ end
246
+
247
+ [text, files]
248
+ end
249
+
250
+ private def download_one(download_code, robot_code, prefer_name: nil)
251
+ res = @api_client.download_message_file(download_code, robot_code, prefer_name: prefer_name)
252
+ return nil unless res
253
+ name = (prefer_name && !prefer_name.to_s.empty?) ? prefer_name.to_s : File.basename(res[:path])
254
+ { path: res[:path], mime: res[:mime], name: name }
255
+ end
256
+
145
257
  private def extract_text(data)
146
258
  content = data.dig("text", "content").to_s.strip
147
259
  # Strip leading @bot mention if present
@@ -168,6 +280,31 @@ module Clacky
168
280
  end
169
281
  entry[:url]
170
282
  end
283
+
284
+ private def cache_route(chat_id, robot_code:, conv_id:, user_id:, conv_type:)
285
+ return if robot_code.empty?
286
+ @routes_mutex.synchronize do
287
+ @routes[chat_id] = {
288
+ robot_code: robot_code,
289
+ conv_id: conv_id,
290
+ user_id: user_id,
291
+ conv_type: conv_type
292
+ }
293
+ end
294
+ end
295
+
296
+ private def resolve_route(chat_id)
297
+ @routes_mutex.synchronize { @routes[chat_id] }
298
+ end
299
+
300
+ private def image_file?(path)
301
+ %w[.jpg .jpeg .png .gif .webp].include?(File.extname(path).downcase)
302
+ end
303
+
304
+ private def supported_file?(path)
305
+ ext = File.extname(path).delete_prefix(".").downcase
306
+ ApiClient::SUPPORTED_FILE_EXTS.include?(ext)
307
+ end
171
308
  end
172
309
 
173
310
  Adapters.register(:dingtalk, Adapter)