openclacky 1.5.20 → 1.5.22
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 +110 -0
- data/README.md +18 -0
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +8 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +66 -31
- data/lib/clacky/search_config.rb +71 -15
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +334 -89
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +78 -47
- data/lib/clacky/session_manager.rb +58 -5
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +192 -15
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1019 -204
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +9 -5
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +82 -5
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +342 -84
- data/lib/clacky/web/index.html +262 -216
- data/lib/clacky/web/projects.js +93 -5
- data/lib/clacky/web/sessions.js +423 -234
- data/lib/clacky/web/settings.js +169 -16
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +17 -42
- data/lib/clacky.rb +1 -0
- metadata +5 -1
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -158,19 +158,21 @@ module Clacky
|
|
|
158
158
|
DEFAULT_COMPRESSION_THRESHOLD = 150_000
|
|
159
159
|
DEFAULT_MESSAGE_COUNT_THRESHOLD = 200
|
|
160
160
|
|
|
161
|
-
attr_accessor :input_behavior, :permission_mode, :max_tokens, :verbose,
|
|
161
|
+
attr_accessor :ask_user_timeout_seconds, :input_behavior, :permission_mode, :max_tokens, :verbose,
|
|
162
162
|
:enable_compression, :enable_idle_compression, :enable_prompt_caching,
|
|
163
163
|
:compression_threshold, :message_count_threshold,
|
|
164
164
|
:models, :current_model_index, :current_model_id,
|
|
165
165
|
:memory_update_enabled, :skill_evolution,
|
|
166
166
|
:max_running_agents, :max_idle_agents,
|
|
167
|
-
:default_working_dir,
|
|
167
|
+
:default_working_dir, :pid_dir,
|
|
168
168
|
:proxy_url,
|
|
169
169
|
:clacky_license_server
|
|
170
170
|
|
|
171
171
|
def initialize(options = {})
|
|
172
172
|
@permission_mode = validate_permission_mode(options[:permission_mode])
|
|
173
173
|
@input_behavior = %w[queue steer interrupt].include?(options[:input_behavior].to_s) ? options[:input_behavior].to_s : "queue"
|
|
174
|
+
timeout = options.fetch(:ask_user_timeout_seconds, 20)
|
|
175
|
+
@ask_user_timeout_seconds = timeout.is_a?(Integer) && timeout >= 0 ? timeout : 20
|
|
174
176
|
@max_tokens = options[:max_tokens] || 16384
|
|
175
177
|
@verbose = options[:verbose] || false
|
|
176
178
|
@enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
|
|
@@ -220,6 +222,7 @@ module Clacky
|
|
|
220
222
|
@max_idle_agents = options[:max_idle_agents] || 10
|
|
221
223
|
|
|
222
224
|
@default_working_dir = options[:default_working_dir] || ENV["CLACKY_WORKSPACE_DIR"]
|
|
225
|
+
@pid_dir = options[:pid_dir]
|
|
223
226
|
|
|
224
227
|
# HTTP proxy policy. The user's shell ENV (HTTP_PROXY etc.) is always
|
|
225
228
|
# ignored — set proxy_url here to route Clacky's outbound HTTP through
|
|
@@ -455,9 +458,9 @@ module Clacky
|
|
|
455
458
|
CONFIG_SETTINGS_KEYS = %w[
|
|
456
459
|
enable_compression enable_idle_compression enable_prompt_caching
|
|
457
460
|
compression_threshold message_count_threshold
|
|
458
|
-
memory_update_enabled input_behavior
|
|
461
|
+
memory_update_enabled input_behavior ask_user_timeout_seconds
|
|
459
462
|
skill_evolution max_running_agents max_idle_agents
|
|
460
|
-
default_working_dir
|
|
463
|
+
default_working_dir pid_dir
|
|
461
464
|
proxy_url clacky_license_server
|
|
462
465
|
].freeze
|
|
463
466
|
|
|
@@ -470,6 +473,7 @@ module Clacky
|
|
|
470
473
|
end
|
|
471
474
|
settings = {
|
|
472
475
|
"input_behavior" => @input_behavior,
|
|
476
|
+
"ask_user_timeout_seconds" => @ask_user_timeout_seconds,
|
|
473
477
|
"enable_compression" => @enable_compression,
|
|
474
478
|
"enable_idle_compression" => @enable_idle_compression,
|
|
475
479
|
"enable_prompt_caching" => @enable_prompt_caching,
|
|
@@ -480,6 +484,7 @@ module Clacky
|
|
|
480
484
|
"max_running_agents" => @max_running_agents,
|
|
481
485
|
"max_idle_agents" => @max_idle_agents,
|
|
482
486
|
"default_working_dir" => @default_working_dir,
|
|
487
|
+
"pid_dir" => @pid_dir,
|
|
483
488
|
"proxy_url" => @proxy_url,
|
|
484
489
|
"clacky_license_server" => @clacky_license_server
|
|
485
490
|
}
|
|
@@ -715,6 +720,20 @@ module Clacky
|
|
|
715
720
|
@models.find { |m| m["type"] == type }
|
|
716
721
|
end
|
|
717
722
|
|
|
723
|
+
# Everything Client.new needs from the current card; also the staleness key for a live Client.
|
|
724
|
+
def client_options
|
|
725
|
+
entry = current_model
|
|
726
|
+
{
|
|
727
|
+
api_key: api_key,
|
|
728
|
+
base_url: base_url,
|
|
729
|
+
model: model_name,
|
|
730
|
+
anthropic_format: anthropic_format?,
|
|
731
|
+
api_format: api_format,
|
|
732
|
+
provider_id: provider_id_for(entry),
|
|
733
|
+
capabilities: entry && entry["capabilities"]
|
|
734
|
+
}
|
|
735
|
+
end
|
|
736
|
+
|
|
718
737
|
# Resolve the provider id for a model entry. An explicit `provider_id`
|
|
719
738
|
# naming a known preset wins over base_url/api_key heuristics — that lets
|
|
720
739
|
# a custom base_url inherit a preset's lineup (media sidecars, OCR models,
|
|
@@ -731,6 +750,65 @@ module Clacky
|
|
|
731
750
|
)
|
|
732
751
|
end
|
|
733
752
|
|
|
753
|
+
# Find the API key to use for DeepSeek web search / page fetch.
|
|
754
|
+
# Resolution order:
|
|
755
|
+
# 1. Explicit key stored in ~/.clacky/search.yml, when DeepSeek is selected
|
|
756
|
+
# 2. Key from the model in use, when its provider declares deepseek_web_tools
|
|
757
|
+
# 3. Key from any other configured model declaring the same capability
|
|
758
|
+
# 4. nil if none is configured
|
|
759
|
+
def deepseek_web_key
|
|
760
|
+
if Clacky::SearchConfig.deepseek_web?
|
|
761
|
+
explicit = Clacky::SearchConfig.key.to_s.strip
|
|
762
|
+
return explicit unless explicit.empty?
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
entry = web_tool_candidates.find do |m|
|
|
766
|
+
next false unless m.is_a?(Hash)
|
|
767
|
+
next false if m["api_key"].to_s.strip.empty?
|
|
768
|
+
Clacky::Providers.deepseek_web_tools?(provider_id_for(m))
|
|
769
|
+
end
|
|
770
|
+
entry ? entry["api_key"].to_s.strip : nil
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# True when the paid DeepSeek web tools are usable: they are the selected
|
|
774
|
+
# search provider and a key could be resolved.
|
|
775
|
+
def deepseek_web_ready?
|
|
776
|
+
return false unless Clacky::SearchConfig.deepseek_web?
|
|
777
|
+
|
|
778
|
+
!deepseek_web_key.to_s.strip.empty?
|
|
779
|
+
rescue StandardError
|
|
780
|
+
false
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Endpoint base URL for DeepSeek web tools given the resolved key.
|
|
784
|
+
# If the key belongs to an OpenClacky model entry (or starts with clacky-),
|
|
785
|
+
# route to that model's base_url so the platform gateway can proxy and bill
|
|
786
|
+
# through the unified balance. Otherwise direct to DeepSeek official.
|
|
787
|
+
def deepseek_web_base_url(resolved_key = nil)
|
|
788
|
+
key = resolved_key || deepseek_web_key
|
|
789
|
+
default_url = Clacky::DeepSeekWebTools::DEFAULT_BASE_URL
|
|
790
|
+
return default_url if key.nil? || key.empty?
|
|
791
|
+
|
|
792
|
+
matching = web_tool_candidates.find { |m| m.is_a?(Hash) && m["api_key"].to_s.strip == key }
|
|
793
|
+
if matching
|
|
794
|
+
pid = provider_id_for(matching)
|
|
795
|
+
if pid == Clacky::Providers::OPENCLACKY_ID && matching["base_url"].to_s.strip != ""
|
|
796
|
+
return matching["base_url"].to_s.chomp("/")
|
|
797
|
+
end
|
|
798
|
+
elsif key.start_with?("clacky-")
|
|
799
|
+
return Clacky::Providers::PRESETS.fetch(Clacky::Providers::OPENCLACKY_ID)["base_url"].chomp("/")
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
default_url
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Current model first (so a session-level switch or overlay wins), then the
|
|
806
|
+
# rest of the configured models as fallback.
|
|
807
|
+
private def web_tool_candidates
|
|
808
|
+
current = current_model
|
|
809
|
+
current ? [current] + @models : @models
|
|
810
|
+
end
|
|
811
|
+
|
|
734
812
|
private def derive_media_model(kind, model_override: nil)
|
|
735
813
|
anchor = current_model || find_model_by_type("default")
|
|
736
814
|
return nil unless anchor
|
|
@@ -18,7 +18,10 @@ module Clacky
|
|
|
18
18
|
# Retired aliases stay here so usage recorded under them still resolves.
|
|
19
19
|
ALIAS_TO_REAL = {
|
|
20
20
|
# deepseek (dsk-)
|
|
21
|
+
# REAL_TO_ALIAS is built with Hash#invert, which keeps the last entry
|
|
22
|
+
# per real id — list the current alias after its retired predecessor.
|
|
21
23
|
"dsk-deepseek-flash" => "deepseek-flash",
|
|
24
|
+
"dsk-deepseek-v4.1-flash" => "deepseek-flash",
|
|
22
25
|
"dsk-deepseek-v4-pro" => "deepseek-v4-pro",
|
|
23
26
|
"dsk-deepseek-v4-flash" => "deepseek-v4-flash",
|
|
24
27
|
"dsk-deepseek-v4-flash-vision-exp" => "deepseek-v4-flash-vision-exp",
|
|
@@ -44,11 +47,15 @@ module Clacky
|
|
|
44
47
|
"abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
|
|
45
48
|
"abs-claude-haiku-5-5" => "global.anthropic.claude-haiku-5-5",
|
|
46
49
|
"abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
|
|
47
|
-
# GLM / Kimi / Grok via TokHub (oc-). The gateway books these under the
|
|
50
|
+
# GLM / Kimi / Grok / GPT via TokHub (oc-). The gateway books these under the
|
|
48
51
|
# alias itself, so the mapping is an identity — it exists so the billing UI
|
|
49
52
|
# can resolve the alias instead of dropping the record as unknown.
|
|
50
53
|
"oc-glm-5.3" => "oc-glm-5.3",
|
|
51
54
|
"oc-kimi-k3" => "oc-kimi-k3",
|
|
55
|
+
"oc-gpt-6.1-sol" => "oc-gpt-6.1-sol",
|
|
56
|
+
"oc-gpt-6-astra" => "oc-gpt-6-astra",
|
|
57
|
+
"oc-gpt-6-sol" => "oc-gpt-6-sol",
|
|
58
|
+
"oc-gpt-6-luna" => "oc-gpt-6-luna",
|
|
52
59
|
"oc-grok-4.7" => "oc-grok-4.7",
|
|
53
60
|
# gemini chat (or-)
|
|
54
61
|
"or-gemini-4-argon" => "gemini-4-argon",
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -683,7 +683,7 @@ module Clacky
|
|
|
683
683
|
already_exists = body["code"].to_s.include?("taken") ||
|
|
684
684
|
body["code"].to_s.include?("already") ||
|
|
685
685
|
result[:error].to_s.include?("HTTP 409")
|
|
686
|
-
{ success: false, error: msg, already_exists: already_exists }
|
|
686
|
+
{ success: false, error: msg, already_exists: already_exists, error_code: code }
|
|
687
687
|
end
|
|
688
688
|
rescue StandardError => e
|
|
689
689
|
{ success: false, error: "Network error: #{e.message}" }
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -346,8 +346,9 @@ module Clacky
|
|
|
346
346
|
end
|
|
347
347
|
|
|
348
348
|
# Handle the `/think` slash command — pick the reasoning effort level
|
|
349
|
-
# for the current session. "off"
|
|
350
|
-
#
|
|
349
|
+
# for the current session. "off" is kept as an explicit choice so model
|
|
350
|
+
# adapters can resolve it per family (MiniMax Flash and GLM-5.3 turn it
|
|
351
|
+
# into their lightest effort instead of the provider's deepest default).
|
|
351
352
|
private def handle_think_command(ui_controller, agent, session_manager = nil)
|
|
352
353
|
choice = ui_controller.show_reasoning_effort_menu(agent.reasoning_effort)
|
|
353
354
|
return if choice.nil?
|
data/lib/clacky/client.rb
CHANGED
|
@@ -129,7 +129,7 @@ module Clacky
|
|
|
129
129
|
response = bedrock_connection.post(bedrock_endpoint(api_model)) { |r| r.body = body.to_json }
|
|
130
130
|
parse_simple_bedrock_response(response)
|
|
131
131
|
elsif anthropic_format?
|
|
132
|
-
body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
|
|
132
|
+
body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false, reasoning_effort: reasoning_effort)
|
|
133
133
|
response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
|
|
134
134
|
parse_simple_anthropic_response(response)
|
|
135
135
|
elsif responses_format?
|
|
@@ -356,10 +356,10 @@ module Clacky
|
|
|
356
356
|
end
|
|
357
357
|
|
|
358
358
|
unless response.status == 200
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
raise_error(
|
|
359
|
+
# Recover the streamed error body on the original response so headers
|
|
360
|
+
# remain available to error classification and routed-tier attribution.
|
|
361
|
+
response.env.body = sse_buf if response.body.to_s.empty?
|
|
362
|
+
raise_error(response)
|
|
363
363
|
end
|
|
364
364
|
|
|
365
365
|
result = aggregator.to_h
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
# Client for DeepSeek hosted web tools:
|
|
9
|
+
# POST /v1/tools/search
|
|
10
|
+
# POST /v1/tools/fetch
|
|
11
|
+
#
|
|
12
|
+
# DeepSeek charges per successful request (CNY 5 / 1000 calls).
|
|
13
|
+
# Handles endpoint routing, auth header, request formatting, rate-limit (429)
|
|
14
|
+
# backoff, and cleans up DeepSeek-specific Markdown escapes (\# \- \_).
|
|
15
|
+
module DeepSeekWebTools
|
|
16
|
+
DEFAULT_BASE_URL = "https://api.deepseek.com"
|
|
17
|
+
SEARCH_TIMEOUT = 30
|
|
18
|
+
FETCH_TIMEOUT = 45
|
|
19
|
+
MAX_RETRIES = 2
|
|
20
|
+
|
|
21
|
+
# A failed call (bad key, empty balance, outage) keeps failing, and each
|
|
22
|
+
# attempt costs a timeout on the user's critical path. Once one fails, stay
|
|
23
|
+
# off DeepSeek and let the free engines serve until it expires.
|
|
24
|
+
# The window is owned by WebSearch so hosted and free providers share it.
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# True while DeepSeek should be skipped after a recent failure.
|
|
28
|
+
def cooling_down?
|
|
29
|
+
until_at = @cooldown_until
|
|
30
|
+
return false if until_at.nil?
|
|
31
|
+
|
|
32
|
+
Time.now < until_at
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mark_unavailable!
|
|
36
|
+
@cooldown_until = Time.now + Clacky::Tools::WebSearch::PROVIDER_COOLDOWN_SECONDS
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_cooldown!
|
|
40
|
+
@cooldown_until = nil
|
|
41
|
+
end
|
|
42
|
+
# Perform web search via DeepSeek.
|
|
43
|
+
# @param query [String]
|
|
44
|
+
# @param count [Integer] results count (1..50)
|
|
45
|
+
# @param api_key [String]
|
|
46
|
+
# @param base_url [String]
|
|
47
|
+
# @return [Array<Hash>] [{ title:, url:, snippet: }]
|
|
48
|
+
def search(query:, count: 10, api_key:, base_url: DEFAULT_BASE_URL)
|
|
49
|
+
payload = {
|
|
50
|
+
"query" => query.to_s,
|
|
51
|
+
"count" => [[count.to_i, 1].max, 50].min
|
|
52
|
+
}
|
|
53
|
+
res = post_with_retry(
|
|
54
|
+
path: "/v1/tools/search",
|
|
55
|
+
payload: payload,
|
|
56
|
+
api_key: api_key,
|
|
57
|
+
base_url: base_url,
|
|
58
|
+
timeout: SEARCH_TIMEOUT
|
|
59
|
+
)
|
|
60
|
+
data = JSON.parse(res.body) rescue {}
|
|
61
|
+
raw_results = data["results"] || []
|
|
62
|
+
|
|
63
|
+
raw_results.filter_map do |item|
|
|
64
|
+
url = item["url"].to_s.strip
|
|
65
|
+
next if url.empty?
|
|
66
|
+
|
|
67
|
+
title = clean_text(item["title"].to_s.strip)
|
|
68
|
+
title = url if title.empty?
|
|
69
|
+
|
|
70
|
+
# Prefer summary (400-800 chars rich text), fall back to snippet
|
|
71
|
+
summary = item["summary"].to_s.strip
|
|
72
|
+
snippet = summary.empty? ? item["snippet"].to_s.strip : summary
|
|
73
|
+
snippet = clean_text(snippet)[0, 400]
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
title: title,
|
|
77
|
+
url: url,
|
|
78
|
+
snippet: snippet
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fetch web page content rendered as Markdown via DeepSeek.
|
|
84
|
+
# @param url [String]
|
|
85
|
+
# @param api_key [String]
|
|
86
|
+
# @param base_url [String]
|
|
87
|
+
# @return [Hash] { title:, content: }
|
|
88
|
+
def fetch(url:, api_key:, base_url: DEFAULT_BASE_URL)
|
|
89
|
+
payload = { "url" => url.to_s }
|
|
90
|
+
res = post_with_retry(
|
|
91
|
+
path: "/v1/tools/fetch",
|
|
92
|
+
payload: payload,
|
|
93
|
+
api_key: api_key,
|
|
94
|
+
base_url: base_url,
|
|
95
|
+
timeout: FETCH_TIMEOUT
|
|
96
|
+
)
|
|
97
|
+
data = JSON.parse(res.body) rescue {}
|
|
98
|
+
content = clean_text(data["content"].to_s)
|
|
99
|
+
title = clean_text(data["title"].to_s.strip)
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
title: title,
|
|
103
|
+
content: content
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Strip excessive DeepSeek backslash-escapes on Markdown syntax:
|
|
108
|
+
# e.g. "\#\# title", "\- bullet", "\_italic\_", "\[link\]".
|
|
109
|
+
def clean_text(text)
|
|
110
|
+
return "" if text.nil?
|
|
111
|
+
|
|
112
|
+
text.to_s.gsub(/\\([#\-_*`\[\]()!+>|~])/, '\1')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private def post_with_retry(path:, payload:, api_key:, base_url:, timeout:)
|
|
116
|
+
raise ArgumentError, "DeepSeek API key is required" if api_key.nil? || api_key.to_s.strip.empty?
|
|
117
|
+
|
|
118
|
+
root = (base_url && !base_url.empty?) ? base_url.chomp("/") : DEFAULT_BASE_URL
|
|
119
|
+
uri = URI.parse("#{root}#{path}")
|
|
120
|
+
|
|
121
|
+
attempts = 0
|
|
122
|
+
loop do
|
|
123
|
+
attempts += 1
|
|
124
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
125
|
+
req["Content-Type"] = "application/json"
|
|
126
|
+
req["Authorization"] = "Bearer #{api_key}"
|
|
127
|
+
req.body = JSON.generate(payload)
|
|
128
|
+
|
|
129
|
+
response = Net::HTTP.start(
|
|
130
|
+
uri.hostname,
|
|
131
|
+
uri.port,
|
|
132
|
+
use_ssl: uri.scheme == "https",
|
|
133
|
+
read_timeout: timeout,
|
|
134
|
+
open_timeout: 10
|
|
135
|
+
) do |http|
|
|
136
|
+
http.request(req)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
case response.code.to_i
|
|
140
|
+
when 200..299
|
|
141
|
+
return response
|
|
142
|
+
when 429
|
|
143
|
+
if attempts <= MAX_RETRIES
|
|
144
|
+
sleep(1.0 * attempts)
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
raise "DeepSeek web tool rate limit exceeded (429)"
|
|
148
|
+
else
|
|
149
|
+
err_msg = response.body.to_s[0, 200]
|
|
150
|
+
raise "DeepSeek web tool #{path} returned HTTP #{response.code}: #{err_msg}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -119,6 +119,14 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
119
119
|
# their extension before publishing.
|
|
120
120
|
json(ok: false, name_taken: true,
|
|
121
121
|
error: result[:error])
|
|
122
|
+
elsif result[:error_code] == "contributor_required"
|
|
123
|
+
error!(
|
|
124
|
+
result[:error] || "publishing requires contributor status",
|
|
125
|
+
status: 403,
|
|
126
|
+
error_code: "contributor_required",
|
|
127
|
+
action_url: "#{Clacky::PlatformHttpClient.base_url}/my/extensions",
|
|
128
|
+
action_label: "open_extensions_page"
|
|
129
|
+
)
|
|
122
130
|
else
|
|
123
131
|
error!(result[:error] || "publish failed", status: 502)
|
|
124
132
|
end
|
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
"err.invalid_version": "Invalid version. Use format like 1.0.0.",
|
|
57
57
|
"err.name_taken": "The name \"{{id}}\" is already taken by another user.",
|
|
58
58
|
"err.id_folder_mismatch": "Extension ID mismatch: the folder is named \"{{folder}}\" but ext.yml declares id \"{{id}}\". Rename the folder or the id so they match before publishing.",
|
|
59
|
+
"err.contributor_required": "You need contributor status to publish. Open the platform, sign in, then click \"Become a contributor\" — it takes effect immediately, no review.",
|
|
60
|
+
"err.contributor_required.link": "Open My Extensions →",
|
|
59
61
|
"rename.label": "Choose a new ID for your extension:",
|
|
60
62
|
"rename.placeholder": "e.g. my-ext-yourname",
|
|
61
63
|
"rename.hint": "Only lowercase letters, digits and hyphens. This will rename the local folder and update ext.yml.",
|
|
@@ -268,6 +270,8 @@
|
|
|
268
270
|
"err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
|
|
269
271
|
"err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
|
|
270
272
|
"err.id_folder_mismatch": "扩展 ID 不一致:文件夹名为「{{folder}}」,但 ext.yml 声明的 id 是「{{id}}」。请先把文件夹名或 id 改成一致再发布。",
|
|
273
|
+
"err.contributor_required": "你还不是扩展贡献者,需要先开通才能发布。打开平台并登录后,点「成为扩展贡献者」即刻生效,无需审核。",
|
|
274
|
+
"err.contributor_required.link": "打开「我的扩展」→",
|
|
271
275
|
"rename.label": "请为你的扩展选择一个新 ID:",
|
|
272
276
|
"rename.placeholder": "例如 my-ext-yourname",
|
|
273
277
|
"rename.hint": "只能使用小写字母、数字和连字符。",
|
|
@@ -663,7 +667,7 @@
|
|
|
663
667
|
|
|
664
668
|
const status = el("p", { class: "studio-modal-status", text: t("bind.body") });
|
|
665
669
|
const codeLine = el("p", { class: "studio-modal-code" });
|
|
666
|
-
const link = el("a", { class: "btn-primary", target: "_blank", rel: "noopener", text: t("bind.openLink") });
|
|
670
|
+
const link = el("a", { class: "btn-primary", target: "_blank", rel: "noopener", "data-open-external": "1", text: t("bind.openLink") });
|
|
667
671
|
link.style.display = "none";
|
|
668
672
|
|
|
669
673
|
const modal = openModal({
|
|
@@ -813,6 +817,18 @@
|
|
|
813
817
|
status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
|
|
814
818
|
}
|
|
815
819
|
|
|
820
|
+
// Same as setProgress, but for errors the user fixes elsewhere: keeps the
|
|
821
|
+
// message neutral (a wall of red reads as a crash) and tucks the fix
|
|
822
|
+
// behind a link that opens in a new tab so the modal survives.
|
|
823
|
+
function setProgressWithLink(msg, url, linkText) {
|
|
824
|
+
setProgress("", false);
|
|
825
|
+
status.textContent = msg + " ";
|
|
826
|
+
status.appendChild(el("a", {
|
|
827
|
+
class: "studio-notice-link", href: url, target: "_blank", rel: "noopener",
|
|
828
|
+
"data-open-external": "1", text: linkText,
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
831
|
+
|
|
816
832
|
function syncVersionState({ normalize = false, updateFeedback = true } = {}) {
|
|
817
833
|
if (normalize) {
|
|
818
834
|
const raw = verInput.value;
|
|
@@ -905,6 +921,8 @@
|
|
|
905
921
|
verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
|
|
906
922
|
} else if (mismatch) {
|
|
907
923
|
setProgress(t("err.id_folder_mismatch", { folder: mismatch[1], id: mismatch[2] }), true);
|
|
924
|
+
} else if (e.data && e.data.error_code === "contributor_required" && e.data.action_url) {
|
|
925
|
+
setProgressWithLink(t("err.contributor_required"), e.data.action_url, t("err.contributor_required.link"));
|
|
908
926
|
} else {
|
|
909
927
|
setProgress(t("err.generic", { msg }), true);
|
|
910
928
|
}
|
|
@@ -2365,6 +2383,8 @@
|
|
|
2365
2383
|
.studio-pub-units::before { content: "·"; margin-right: 10px; }
|
|
2366
2384
|
.studio-modal-status { margin: 0 0 8px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
|
|
2367
2385
|
.studio-modal-status-error { color: var(--color-error); }
|
|
2386
|
+
.studio-notice-link { font-size: 12.5px; font-weight: 500; color: var(--color-accent-primary); text-decoration: none; white-space: nowrap; }
|
|
2387
|
+
.studio-notice-link:hover { text-decoration: underline; }
|
|
2368
2388
|
.studio-input { width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 7px 10px; color: var(--color-text-primary); font-size: 13px; font-family: monospace; }
|
|
2369
2389
|
.studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
|
|
2370
2390
|
.studio-input-error { border-color: var(--color-error) !important; }
|