openclacky 1.5.19 → 1.5.21
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 +95 -0
- data/README.md +19 -1
- data/README_CN.md +1 -1
- data/README_JA.md +1 -1
- 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 +17 -3
- data/lib/clacky/agent/message_compressor_helper.rb +36 -2
- data/lib/clacky/agent/session_serializer.rb +45 -4
- data/lib/clacky/agent.rb +135 -55
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +10 -5
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +5 -4
- data/lib/clacky/client.rb +12 -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 +97 -20
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
- 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 +45 -4
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +38 -138
- data/lib/clacky/search_config.rb +47 -13
- 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/http_server.rb +285 -53
- data/lib/clacky/server/model_prices.rb +10 -5
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +37 -7
- data/lib/clacky/server/web_ui_controller.rb +72 -0
- data/lib/clacky/session_manager.rb +28 -6
- 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/model_pricing.rb +242 -39
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1158 -249
- data/lib/clacky/web/app.js +82 -13
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/code-editor.js +6 -2
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +92 -97
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/components/sidebar.js +6 -2
- data/lib/clacky/web/features/archive/store.js +196 -0
- data/lib/clacky/web/features/archive/view.js +409 -0
- data/lib/clacky/web/features/backup/view.js +17 -7
- data/lib/clacky/web/features/billing/view.js +84 -10
- data/lib/clacky/web/features/channels/view.js +75 -29
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/new-session/view.js +1 -0
- data/lib/clacky/web/features/trash/store.js +14 -11
- data/lib/clacky/web/features/trash/view.js +66 -13
- data/lib/clacky/web/features/workspace/view.js +421 -16
- data/lib/clacky/web/i18n.js +242 -70
- data/lib/clacky/web/index.html +270 -117
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +609 -267
- data/lib/clacky/web/settings.js +186 -32
- data/lib/clacky/web/ws-dispatcher.js +33 -21
- data/lib/clacky.rb +4 -2
- metadata +6 -1
|
@@ -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.",
|
|
@@ -112,6 +114,12 @@
|
|
|
112
114
|
"pub.screenshot.err": "Upload failed: {{msg}}",
|
|
113
115
|
"pub.screenshot.err.type": "Only PNG / JPG / GIF are allowed",
|
|
114
116
|
"pub.screenshot.err.size": "File size must be under 5 MB",
|
|
117
|
+
"pub.screenshot.err.unbound": "this device isn't linked to a platform account",
|
|
118
|
+
"pub.screenshot.err.missingFile": "no file received",
|
|
119
|
+
"pub.screenshot.err.extId": "missing extension ID",
|
|
120
|
+
"pub.screenshot.err.service": "the upload service is unavailable, please try again later",
|
|
121
|
+
"pub.screenshot.err.generic": "Upload failed, please try again",
|
|
122
|
+
"pub.screenshot.bind": "Authorize",
|
|
115
123
|
"pub.readme.edit.title": "Edit README",
|
|
116
124
|
"readme.pane.editor": "Markdown editor",
|
|
117
125
|
"readme.pane.preview": "Preview",
|
|
@@ -262,6 +270,8 @@
|
|
|
262
270
|
"err.invalid_version": "版本号格式不对,请使用如 1.0.0 的格式。",
|
|
263
271
|
"err.name_taken": "扩展名「{{id}}」已被其他用户占用。",
|
|
264
272
|
"err.id_folder_mismatch": "扩展 ID 不一致:文件夹名为「{{folder}}」,但 ext.yml 声明的 id 是「{{id}}」。请先把文件夹名或 id 改成一致再发布。",
|
|
273
|
+
"err.contributor_required": "你还不是扩展贡献者,需要先开通才能发布。打开平台并登录后,点「成为扩展贡献者」即刻生效,无需审核。",
|
|
274
|
+
"err.contributor_required.link": "打开「我的扩展」→",
|
|
265
275
|
"rename.label": "请为你的扩展选择一个新 ID:",
|
|
266
276
|
"rename.placeholder": "例如 my-ext-yourname",
|
|
267
277
|
"rename.hint": "只能使用小写字母、数字和连字符。",
|
|
@@ -318,6 +328,12 @@
|
|
|
318
328
|
"pub.screenshot.err": "上传失败:{{msg}}",
|
|
319
329
|
"pub.screenshot.err.type": "仅支持 PNG / JPG / GIF 格式",
|
|
320
330
|
"pub.screenshot.err.size": "文件大小不能超过 5 MB",
|
|
331
|
+
"pub.screenshot.err.unbound": "设备未绑定平台账号",
|
|
332
|
+
"pub.screenshot.err.missingFile": "未收到文件",
|
|
333
|
+
"pub.screenshot.err.extId": "缺少扩展 ID",
|
|
334
|
+
"pub.screenshot.err.service": "上传服务暂不可用,请稍后重试",
|
|
335
|
+
"pub.screenshot.err.generic": "上传失败,请重试",
|
|
336
|
+
"pub.screenshot.bind": "去授权",
|
|
321
337
|
"pub.readme.edit.title": "编辑使用说明",
|
|
322
338
|
"readme.pane.editor": "Markdown 编写",
|
|
323
339
|
"readme.pane.preview": "预览",
|
|
@@ -651,15 +667,17 @@
|
|
|
651
667
|
|
|
652
668
|
const status = el("p", { class: "studio-modal-status", text: t("bind.body") });
|
|
653
669
|
const codeLine = el("p", { class: "studio-modal-code" });
|
|
654
|
-
const link = el("a", { class: "
|
|
670
|
+
const link = el("a", { class: "btn-primary", target: "_blank", rel: "noopener", "data-open-external": "1", text: t("bind.openLink") });
|
|
655
671
|
link.style.display = "none";
|
|
656
672
|
|
|
657
673
|
const modal = openModal({
|
|
658
674
|
title: t("bind.title"),
|
|
659
|
-
body: el("div", null, [status, codeLine
|
|
675
|
+
body: el("div", null, [status, codeLine]),
|
|
660
676
|
onClose: () => { polling = false; resolve(false); },
|
|
661
677
|
buttons: [{ label: t("bind.cancel"), keepOpen: false, onClick: () => { polling = false; resolve(false); } }],
|
|
662
678
|
});
|
|
679
|
+
// A real link (not a button) so it still opens when the browser blocks our popup.
|
|
680
|
+
modal.footer.appendChild(link);
|
|
663
681
|
|
|
664
682
|
function finish(ok) { polling = false; modal.close(); resolve(ok); }
|
|
665
683
|
|
|
@@ -799,6 +817,18 @@
|
|
|
799
817
|
status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
|
|
800
818
|
}
|
|
801
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
|
+
|
|
802
832
|
function syncVersionState({ normalize = false, updateFeedback = true } = {}) {
|
|
803
833
|
if (normalize) {
|
|
804
834
|
const raw = verInput.value;
|
|
@@ -891,6 +921,8 @@
|
|
|
891
921
|
verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
|
|
892
922
|
} else if (mismatch) {
|
|
893
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"));
|
|
894
926
|
} else {
|
|
895
927
|
setProgress(t("err.generic", { msg }), true);
|
|
896
928
|
}
|
|
@@ -1342,21 +1374,38 @@
|
|
|
1342
1374
|
|
|
1343
1375
|
const screenshotErr = el("p", { class: "studio-modal-status studio-modal-status-error", style: "display:none;margin-top:6px" });
|
|
1344
1376
|
screenshotsSection.appendChild(screenshotErr);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
screenshotErr.
|
|
1350
|
-
if (
|
|
1351
|
-
screenshotErr.
|
|
1352
|
-
screenshotErr.
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
return;
|
|
1377
|
+
|
|
1378
|
+
// Shows a localized error; passing retryFile appends an "Authorize" link that
|
|
1379
|
+
// reopens device binding and re-uploads that file once bound.
|
|
1380
|
+
function showScreenshotError(text, retryFile) {
|
|
1381
|
+
screenshotErr.textContent = text;
|
|
1382
|
+
if (retryFile) {
|
|
1383
|
+
screenshotErr.appendChild(document.createTextNode(" "));
|
|
1384
|
+
screenshotErr.appendChild(el("button", {
|
|
1385
|
+
type: "button",
|
|
1386
|
+
class: "studio-link-btn",
|
|
1387
|
+
text: t("pub.screenshot.bind"),
|
|
1388
|
+
onclick: () => bindThenUploadScreenshot(retryFile),
|
|
1389
|
+
}));
|
|
1359
1390
|
}
|
|
1391
|
+
screenshotErr.style.display = "";
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function screenshotErrorText(e) {
|
|
1395
|
+
if (e.status === 422 && e.message === "missing file") return t("pub.screenshot.err", { msg: t("pub.screenshot.err.missingFile") });
|
|
1396
|
+
if (e.status === 422 && e.message === "ext_id required") return t("pub.screenshot.err", { msg: t("pub.screenshot.err.extId") });
|
|
1397
|
+
if (e.status === 502) return t("pub.screenshot.err", { msg: t("pub.screenshot.err.service") });
|
|
1398
|
+
return t("pub.screenshot.err.generic");
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async function bindThenUploadScreenshot(file) {
|
|
1402
|
+
screenshotErr.style.display = "none";
|
|
1403
|
+
if (await runBindingFlow()) return uploadScreenshot(file);
|
|
1404
|
+
showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.unbound") }), file);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function uploadScreenshot(file) {
|
|
1408
|
+
let needsBinding = false;
|
|
1360
1409
|
uploadBtn.disabled = true;
|
|
1361
1410
|
uploadBtn.textContent = t("pub.screenshot.uploading");
|
|
1362
1411
|
screenshotErr.style.display = "none";
|
|
@@ -1366,7 +1415,12 @@
|
|
|
1366
1415
|
form.append("file", file);
|
|
1367
1416
|
const res = await fetch("/api/ext/ext-studio/screenshot", { method: "POST", body: form });
|
|
1368
1417
|
const json = await res.json();
|
|
1369
|
-
if (!res.ok || !json.ok)
|
|
1418
|
+
if (!res.ok || !json.ok) {
|
|
1419
|
+
const err = new Error(json.error || `Request failed (${res.status})`);
|
|
1420
|
+
err.status = res.status;
|
|
1421
|
+
err.data = json;
|
|
1422
|
+
throw err;
|
|
1423
|
+
}
|
|
1370
1424
|
// Insert  at cursor position in the editor.
|
|
1371
1425
|
// Ensure the image tag is on its own line by adding newlines if needed.
|
|
1372
1426
|
const mdImg = ``;
|
|
@@ -1382,12 +1436,33 @@
|
|
|
1382
1436
|
readmeArea.focus();
|
|
1383
1437
|
readmeArea.dispatchEvent(new Event("input"));
|
|
1384
1438
|
} catch (e) {
|
|
1385
|
-
|
|
1386
|
-
|
|
1439
|
+
if (e.status === 428 && e.data && e.data.needs_binding) {
|
|
1440
|
+
needsBinding = true;
|
|
1441
|
+
} else {
|
|
1442
|
+
console.error("[ext-studio] screenshot upload failed:", e);
|
|
1443
|
+
showScreenshotError(screenshotErrorText(e));
|
|
1444
|
+
}
|
|
1387
1445
|
} finally {
|
|
1388
1446
|
uploadBtn.disabled = false;
|
|
1389
1447
|
uploadBtn.textContent = t("pub.screenshot.upload");
|
|
1390
1448
|
}
|
|
1449
|
+
if (needsBinding) await bindThenUploadScreenshot(file);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
fileInput.addEventListener("change", () => {
|
|
1453
|
+
const file = fileInput.files[0];
|
|
1454
|
+
if (!file) return;
|
|
1455
|
+
fileInput.value = "";
|
|
1456
|
+
screenshotErr.style.display = "none";
|
|
1457
|
+
if (!["image/png", "image/jpeg", "image/gif"].includes(file.type)) {
|
|
1458
|
+
showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.type") }));
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
1462
|
+
showScreenshotError(t("pub.screenshot.err", { msg: t("pub.screenshot.err.size") }));
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
uploadScreenshot(file);
|
|
1391
1466
|
});
|
|
1392
1467
|
|
|
1393
1468
|
box.appendChild(screenshotsSection);
|
|
@@ -2308,11 +2383,13 @@
|
|
|
2308
2383
|
.studio-pub-units::before { content: "·"; margin-right: 10px; }
|
|
2309
2384
|
.studio-modal-status { margin: 0 0 8px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
|
|
2310
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; }
|
|
2311
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; }
|
|
2312
2389
|
.studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
|
|
2313
2390
|
.studio-input-error { border-color: var(--color-error) !important; }
|
|
2314
2391
|
.studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
|
|
2315
|
-
.studio-modal-
|
|
2392
|
+
.studio-modal .modal-footer a.btn-primary { display: inline-flex; align-items: center; font-size: 14px; text-decoration: none; }
|
|
2316
2393
|
.studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
|
|
2317
2394
|
.studio-section { margin-bottom: 20px; }
|
|
2318
2395
|
.studio-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px; }
|
|
@@ -392,6 +392,7 @@
|
|
|
392
392
|
});
|
|
393
393
|
if (res.ok) {
|
|
394
394
|
state.footEl.textContent = res.message || t("tm.restored", "已恢复");
|
|
395
|
+
if (Clacky.Workspace) Clacky.Workspace.notifyTaskCompleted(state.sessionId);
|
|
395
396
|
state.expanded = null;
|
|
396
397
|
closeDetail(state);
|
|
397
398
|
await loadHistory(state);
|
|
@@ -562,7 +562,7 @@ Confirm once more:
|
|
|
562
562
|
```
|
|
563
563
|
|
|
564
564
|
On confirmation, use the `trash` tool (NOT raw `rm`) so the file lands in
|
|
565
|
-
|
|
565
|
+
the Recycle Bin and can be recovered from there. Report what you did.
|
|
566
566
|
|
|
567
567
|
### C.7. Close
|
|
568
568
|
|
|
@@ -288,10 +288,12 @@ module Clacky
|
|
|
288
288
|
end
|
|
289
289
|
|
|
290
290
|
|
|
291
|
-
# A layer's fingerprint is the
|
|
291
|
+
# A layer's fingerprint is the list of [ext_id, manifest path, mtime] for
|
|
292
292
|
# every container manifest present. Cheap to compute (one stat per
|
|
293
293
|
# manifest); the whole map compared as a Hash so a container appearing
|
|
294
|
-
# or disappearing also invalidates.
|
|
294
|
+
# or disappearing also invalidates. The path belongs in the key: two
|
|
295
|
+
# roots can hold the same ext_id with manifests whose mtimes collide, and
|
|
296
|
+
# reusing a result across roots would hand back stale dirs.
|
|
295
297
|
private def fingerprint_layers(layers)
|
|
296
298
|
layers.each_with_object({}) do |(layer, root), acc|
|
|
297
299
|
next unless root && Dir.exist?(root)
|
|
@@ -302,7 +304,7 @@ module Clacky
|
|
|
302
304
|
manifest = File.join(root, ext_id, MANIFEST)
|
|
303
305
|
next unless File.file?(manifest)
|
|
304
306
|
|
|
305
|
-
entries << [ext_id, File.mtime(manifest).to_f]
|
|
307
|
+
entries << [ext_id, manifest, File.mtime(manifest).to_f]
|
|
306
308
|
end
|
|
307
309
|
acc[layer] = entries
|
|
308
310
|
end
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -15,15 +15,15 @@ module Clacky
|
|
|
15
15
|
"llm.error.403.access_denied" => "Access denied, please check your API key permissions",
|
|
16
16
|
"llm.error.403.default" => "Access denied",
|
|
17
17
|
"llm.error.endpoint_not_found" => "API endpoint not found, please check your service URL",
|
|
18
|
-
"llm.error.rate_limit_429" => "Rate limit exceeded,
|
|
18
|
+
"llm.error.rate_limit_429" => "Rate limit exceeded. Please switch to another model and retry, or wait a moment",
|
|
19
19
|
"llm.error.quota_exhausted" => "API quota exhausted (key usage limit reached). Please raise the quota or upgrade your plan in the console",
|
|
20
20
|
"llm.error.server_error" => "Service temporarily unavailable (%<status>d), retrying...",
|
|
21
|
-
"llm.error.unexpected" => "
|
|
21
|
+
"llm.error.unexpected" => "Request failed (%<status>d). Please switch to another model and retry",
|
|
22
22
|
"llm.error.html_response" => "Service temporarily unavailable (received HTML error page), retrying...",
|
|
23
23
|
"llm.error.bad_request" => "Model request failed. Please switch to another model and retry",
|
|
24
|
-
"llm.error.request_timeout" => "Request timed out after %<retries>d retries",
|
|
25
|
-
"llm.error.network_failed" => "Network connection failed after %<retries>d retries",
|
|
26
|
-
"llm.error.service_unavailable" => "Service unavailable after %<retries>d retries",
|
|
24
|
+
"llm.error.request_timeout" => "Request timed out after %<retries>d retries. Please switch to another model and retry",
|
|
25
|
+
"llm.error.network_failed" => "Network connection failed after %<retries>d retries. If the problem persists, switch to another model and retry, or check your account balance",
|
|
26
|
+
"llm.error.service_unavailable" => "Service unavailable after %<retries>d retries. Please switch to another model and retry",
|
|
27
27
|
"platform.error.invalid_proof" => "Invalid license key — please check and try again.",
|
|
28
28
|
"platform.error.invalid_signature" => "Invalid request signature.",
|
|
29
29
|
"platform.error.nonce_replayed" => "Duplicate request detected. Please try again.",
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -15,15 +15,15 @@ module Clacky
|
|
|
15
15
|
"llm.error.403.access_denied" => "访问被拒绝,请检查 API 密钥权限",
|
|
16
16
|
"llm.error.403.default" => "访问被拒绝",
|
|
17
17
|
"llm.error.endpoint_not_found" => "API 端点不存在,请检查服务地址配置",
|
|
18
|
-
"llm.error.rate_limit_429" => "
|
|
18
|
+
"llm.error.rate_limit_429" => "请求过于频繁,请暂时切换其他模型重试或稍后重试",
|
|
19
19
|
"llm.error.quota_exhausted" => "API 配额已用尽(已达 Key 的用量上限),请前往控制台提升配额或升级套餐后继续",
|
|
20
20
|
"llm.error.server_error" => "服务暂时不可用(%<status>d),正在重试...",
|
|
21
|
-
"llm.error.unexpected" => "请求失败(%<status>d
|
|
21
|
+
"llm.error.unexpected" => "请求失败(%<status>d),请暂时切换其他模型重试",
|
|
22
22
|
"llm.error.html_response" => "服务暂时不可用(收到 HTML 错误页),正在重试...",
|
|
23
23
|
"llm.error.bad_request" => "请求模型出错,请暂时切换其他模型重试",
|
|
24
|
-
"llm.error.request_timeout" => "请求超时(已重试 %<retries>d
|
|
25
|
-
"llm.error.network_failed" => "网络连接失败(已重试 %<retries>d
|
|
26
|
-
"llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d
|
|
24
|
+
"llm.error.request_timeout" => "请求超时(已重试 %<retries>d 次),请暂时切换其他模型重试",
|
|
25
|
+
"llm.error.network_failed" => "网络连接失败(已重试 %<retries>d 次),若持续失败,请暂时切换其他模型重试或检查账户余额",
|
|
26
|
+
"llm.error.service_unavailable" => "服务暂时不可用(已重试 %<retries>d 次),请暂时切换其他模型重试",
|
|
27
27
|
"platform.error.invalid_proof" => "许可证密钥无效,请检查后重试。",
|
|
28
28
|
"platform.error.invalid_signature" => "请求签名无效。",
|
|
29
29
|
"platform.error.nonce_replayed" => "检测到重复请求,请重试。",
|
|
@@ -19,6 +19,10 @@ module Clacky
|
|
|
19
19
|
module Anthropic
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
|
+
# Same "no explicit level" set the OpenAI adapter uses: the sessionbar and
|
|
23
|
+
# config paths only ever emit these values or a concrete low..max level.
|
|
24
|
+
OFF_EFFORT_VALUES = %w[off nothink disabled none].freeze
|
|
25
|
+
|
|
22
26
|
# Normalise a /v1/messages usage block to the codebase's canonical
|
|
23
27
|
# (OpenAI-style) prompt_tokens: total input including cache_read but
|
|
24
28
|
# excluding cache_creation.
|
|
@@ -122,6 +126,22 @@ module Clacky
|
|
|
122
126
|
end
|
|
123
127
|
body[:thinking] = { type: "enabled" }
|
|
124
128
|
body[:output_config] = { effort: k3_effort } if k3_effort
|
|
129
|
+
elsif model.to_s.match?(/\Aminimax-m3[.]1-flash\z/i)
|
|
130
|
+
# Flash always thinks; use its lightest supported effort for "off".
|
|
131
|
+
effort = if OFF_EFFORT_VALUES.include?(reasoning_effort.to_s)
|
|
132
|
+
"low"
|
|
133
|
+
else
|
|
134
|
+
normalized_effort(reasoning_effort)
|
|
135
|
+
end
|
|
136
|
+
body[:output_config] = { effort: effort } if effort
|
|
137
|
+
elsif model.to_s.match?(/\Aminimax-m3\z/i)
|
|
138
|
+
# M3 defaults to disabled on this transport and ignores effort.
|
|
139
|
+
effort_str = reasoning_effort.to_s
|
|
140
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
141
|
+
body[:thinking] = { type: "disabled" }
|
|
142
|
+
elsif !effort_str.empty?
|
|
143
|
+
body[:thinking] = { type: "adaptive" }
|
|
144
|
+
end
|
|
125
145
|
elsif (effort = normalized_effort(reasoning_effort))
|
|
126
146
|
body[:thinking] = { type: "adaptive" }
|
|
127
147
|
body[:output_config] = { effort: effort }
|
|
@@ -224,17 +224,23 @@ module Clacky
|
|
|
224
224
|
|
|
225
225
|
# Returns the token-limit field name for the given model.
|
|
226
226
|
# Most OpenAI-compatible APIs use :max_tokens; MiMo-V2.5 (Xiaomi) and
|
|
227
|
-
# MiniMax-M3 use :max_completion_tokens to cap the combined thinking +
|
|
227
|
+
# MiniMax-M3 / M3.1-Flash use :max_completion_tokens to cap the combined thinking +
|
|
228
228
|
# answer length (M3 documents max_completion_tokens over the legacy
|
|
229
229
|
# max_tokens field).
|
|
230
230
|
private_class_method def self.token_field_for(model)
|
|
231
|
-
if model.to_s.match?(/^mimo-v2/i) || model.to_s.match?(/^minimax-m3
|
|
231
|
+
if model.to_s.match?(/^mimo-v2/i) || model.to_s.match?(/^minimax-m3([.]1-flash)?$/i)
|
|
232
232
|
:max_completion_tokens
|
|
233
233
|
else
|
|
234
234
|
:max_tokens
|
|
235
235
|
end
|
|
236
236
|
end
|
|
237
237
|
|
|
238
|
+
# Values the sessionbar/config use to mean "no explicit level". They are
|
|
239
|
+
# model-specific on the wire (some families map them to their lightest
|
|
240
|
+
# effort, others to thinking disabled), so every branch below handles
|
|
241
|
+
# them explicitly — the generic fallback must never forward them.
|
|
242
|
+
OFF_EFFORT_VALUES = %w[off nothink disabled none].freeze
|
|
243
|
+
|
|
238
244
|
# Apply model-specific reasoning / thinking parameters to the request body.
|
|
239
245
|
#
|
|
240
246
|
# Different model families expose extended reasoning through different API
|
|
@@ -259,7 +265,7 @@ module Clacky
|
|
|
259
265
|
# error (Z.ai migration guide), so "off" maps to the lightest effort
|
|
260
266
|
# "low". Effort accepts low/high/max only; server default is max.
|
|
261
267
|
body[:thinking] = { type: "enabled" }
|
|
262
|
-
if
|
|
268
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
263
269
|
body[:reasoning_effort] = "low"
|
|
264
270
|
elsif !effort_str.empty?
|
|
265
271
|
body[:reasoning_effort] =
|
|
@@ -275,7 +281,7 @@ module Clacky
|
|
|
275
281
|
# ({type: "enabled"|"disabled"}) plus a restricted reasoning_effort
|
|
276
282
|
# that only accepts "max" or "high". Other effort levels collapse
|
|
277
283
|
# to "high". Send both fields so GLM activates thinking correctly.
|
|
278
|
-
if
|
|
284
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
279
285
|
body[:thinking] = { type: "disabled" }
|
|
280
286
|
elsif !effort_str.empty?
|
|
281
287
|
glm_effort =
|
|
@@ -293,7 +299,7 @@ module Clacky
|
|
|
293
299
|
# Thinking is always enabled and cannot be turned off; "off" maps
|
|
294
300
|
# to the lowest intensity "low". Unsupported effort levels (medium,
|
|
295
301
|
# xhigh) would be rejected or ignored by the server.
|
|
296
|
-
if
|
|
302
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
297
303
|
body[:reasoning_effort] = "low"
|
|
298
304
|
elsif !effort_str.empty?
|
|
299
305
|
body[:reasoning_effort] =
|
|
@@ -309,11 +315,19 @@ module Clacky
|
|
|
309
315
|
# thinking:{type:...} field rather than reasoning_effort. Map the
|
|
310
316
|
# internal reasoning_effort value to thinking on/off so MiMo does
|
|
311
317
|
# not receive an unsupported parameter.
|
|
312
|
-
if
|
|
318
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
313
319
|
body[:thinking] = { type: "disabled" }
|
|
314
320
|
elsif !effort_str.empty?
|
|
315
321
|
body[:thinking] = { type: "enabled" }
|
|
316
322
|
end
|
|
323
|
+
elsif model.to_s.match?(/^minimax-m3[.]1-flash$/i)
|
|
324
|
+
# M3.1-Flash always thinks and rejects thinking disabled / effort none,
|
|
325
|
+
# so "off" maps to the lightest effort "low".
|
|
326
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
327
|
+
body[:reasoning_effort] = "low"
|
|
328
|
+
elsif %w[low medium high xhigh max].include?(effort_str)
|
|
329
|
+
body[:reasoning_effort] = effort_str
|
|
330
|
+
end
|
|
317
331
|
elsif model.to_s.match?(/^minimax-m3$/i)
|
|
318
332
|
# MiniMax-M3 controls thinking via a top-level "thinking" field with
|
|
319
333
|
# { type: "adaptive" | "disabled" } — the same envelope shape MiMo
|
|
@@ -326,20 +340,23 @@ module Clacky
|
|
|
326
340
|
# decides per request); we let the server pick the intensity.
|
|
327
341
|
# M2.7 cannot disable thinking and ignores this field, so sending
|
|
328
342
|
# it is harmless for the rest of the MiniMax lineup.
|
|
329
|
-
if
|
|
343
|
+
if OFF_EFFORT_VALUES.include?(effort_str)
|
|
330
344
|
body[:thinking] = { type: "disabled" }
|
|
331
345
|
elsif !effort_str.empty?
|
|
332
346
|
body[:thinking] = { type: "adaptive" }
|
|
333
347
|
end
|
|
334
348
|
elsif model.to_s.match?(/deepseek/i)
|
|
335
349
|
# DeepSeek V4 reasoning_effort only accepts low/high/max.
|
|
336
|
-
# xhigh is not recognized — map it to max.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
# xhigh is not recognized — map it to max. Off-like values are not
|
|
351
|
+
# valid either, so they keep the provider default untouched.
|
|
352
|
+
unless OFF_EFFORT_VALUES.include?(effort_str)
|
|
353
|
+
effort = case effort_str
|
|
354
|
+
when "xhigh" then "max"
|
|
355
|
+
else effort_str
|
|
356
|
+
end
|
|
357
|
+
assign_effort(body, effort, responses_api)
|
|
358
|
+
end
|
|
359
|
+
elsif reasoning_effort && !effort_str.empty? && !OFF_EFFORT_VALUES.include?(effort_str)
|
|
343
360
|
assign_effort(body, effort_str, responses_api)
|
|
344
361
|
end
|
|
345
362
|
end
|