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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +18 -0
  4. data/lib/clacky/agent/chunk_index.rb +8 -1
  5. data/lib/clacky/agent/feedback.rb +30 -0
  6. data/lib/clacky/agent/history_navigation.rb +17 -3
  7. data/lib/clacky/agent/hook_manager.rb +6 -1
  8. data/lib/clacky/agent/llm_caller.rb +1 -0
  9. data/lib/clacky/agent/message_compressor_helper.rb +28 -2
  10. data/lib/clacky/agent/session_serializer.rb +20 -2
  11. data/lib/clacky/agent.rb +56 -43
  12. data/lib/clacky/agent_config.rb +82 -4
  13. data/lib/clacky/billing/platform_billing.rb +8 -1
  14. data/lib/clacky/brand_config.rb +1 -1
  15. data/lib/clacky/cli.rb +3 -2
  16. data/lib/clacky/client.rb +5 -5
  17. data/lib/clacky/deepseek_web_tools.rb +156 -0
  18. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
  19. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
  20. data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
  21. data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
  22. data/lib/clacky/extension/loader.rb +5 -3
  23. data/lib/clacky/locales/en.rb +5 -5
  24. data/lib/clacky/locales/zh.rb +5 -5
  25. data/lib/clacky/message_format/anthropic.rb +20 -0
  26. data/lib/clacky/message_format/open_ai.rb +31 -14
  27. data/lib/clacky/message_history.rb +21 -3
  28. data/lib/clacky/platform_http_client.rb +6 -2
  29. data/lib/clacky/providers.rb +66 -31
  30. data/lib/clacky/search_config.rb +71 -15
  31. data/lib/clacky/server/browser_manager.rb +35 -5
  32. data/lib/clacky/server/channel/channel_manager.rb +6 -6
  33. data/lib/clacky/server/discover.rb +5 -4
  34. data/lib/clacky/server/git_panel.rb +360 -34
  35. data/lib/clacky/server/http_server.rb +334 -89
  36. data/lib/clacky/server/model_prices.rb +5 -3
  37. data/lib/clacky/server/pid_files.rb +25 -0
  38. data/lib/clacky/server/preview.rb +118 -17
  39. data/lib/clacky/server/scheduler.rb +81 -2
  40. data/lib/clacky/server/server_master.rb +9 -3
  41. data/lib/clacky/server/session_registry.rb +19 -7
  42. data/lib/clacky/server/web_ui_controller.rb +78 -47
  43. data/lib/clacky/session_manager.rb +58 -5
  44. data/lib/clacky/skill.rb +1 -1
  45. data/lib/clacky/tools/ask_user.rb +4 -1
  46. data/lib/clacky/tools/base.rb +3 -2
  47. data/lib/clacky/tools/browser.rb +18 -2
  48. data/lib/clacky/tools/file_reader.rb +1 -1
  49. data/lib/clacky/tools/terminal/session_manager.rb +19 -1
  50. data/lib/clacky/tools/terminal.rb +65 -23
  51. data/lib/clacky/tools/trash_manager.rb +5 -3
  52. data/lib/clacky/tools/web_fetch.rb +81 -22
  53. data/lib/clacky/tools/web_search.rb +45 -4
  54. data/lib/clacky/ui2/ui_controller.rb +1 -1
  55. data/lib/clacky/ui_interface.rb +4 -3
  56. data/lib/clacky/utils/file_processor.rb +51 -0
  57. data/lib/clacky/utils/mac_app_detector.rb +8 -7
  58. data/lib/clacky/utils/model_pricing.rb +192 -15
  59. data/lib/clacky/utils/windows_app_detector.rb +3 -1
  60. data/lib/clacky/version.rb +1 -1
  61. data/lib/clacky/web/app.css +1019 -204
  62. data/lib/clacky/web/app.js +59 -6
  63. data/lib/clacky/web/auth.js +1 -1
  64. data/lib/clacky/web/components/chat-navigator.js +8 -1
  65. data/lib/clacky/web/components/composer.js +107 -8
  66. data/lib/clacky/web/components/image-zoom.js +165 -0
  67. data/lib/clacky/web/components/mentions.js +2 -13
  68. data/lib/clacky/web/components/model-picker.js +9 -5
  69. data/lib/clacky/web/components/notify.js +2 -2
  70. data/lib/clacky/web/components/onboard.js +13 -10
  71. data/lib/clacky/web/core/aside.js +60 -3
  72. data/lib/clacky/web/features/archive/store.js +30 -16
  73. data/lib/clacky/web/features/archive/view.js +20 -10
  74. data/lib/clacky/web/features/backup/view.js +5 -5
  75. data/lib/clacky/web/features/billing/view.js +2 -2
  76. data/lib/clacky/web/features/channels/view.js +5 -2
  77. data/lib/clacky/web/features/extensions/view.js +2 -2
  78. data/lib/clacky/web/features/new-session/store.js +82 -5
  79. data/lib/clacky/web/features/new-session/view.js +496 -58
  80. data/lib/clacky/web/features/skills/store.js +1 -1
  81. data/lib/clacky/web/features/tasks/view.js +162 -51
  82. data/lib/clacky/web/features/trash/store.js +10 -7
  83. data/lib/clacky/web/features/trash/view.js +9 -4
  84. data/lib/clacky/web/features/workspace/store.js +13 -6
  85. data/lib/clacky/web/features/workspace/view.js +153 -163
  86. data/lib/clacky/web/i18n.js +342 -84
  87. data/lib/clacky/web/index.html +262 -216
  88. data/lib/clacky/web/projects.js +93 -5
  89. data/lib/clacky/web/sessions.js +423 -234
  90. data/lib/clacky/web/settings.js +169 -16
  91. data/lib/clacky/web/skills.js +2 -2
  92. data/lib/clacky/web/theme.js +17 -2
  93. data/lib/clacky/web/utils.js +17 -2
  94. data/lib/clacky/web/weixin-qr.html +2 -1
  95. data/lib/clacky/web/ws-dispatcher.js +17 -42
  96. data/lib/clacky.rb +1 -0
  97. metadata +5 -1
@@ -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",
@@ -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" normalizes to nil (provider default),
350
- # matching the Web UI's reasoning_effort switcher semantics.
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
- recovered_body = response.body.to_s
360
- recovered_body = sse_buf.to_s if recovered_body.empty?
361
- recovered = Struct.new(:status, :body).new(response.status, recovered_body)
362
- raise_error(recovered)
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; }