openclacky 1.5.1 → 1.5.3

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/agent/llm_caller.rb +48 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +7 -0
  5. data/lib/clacky/agent/session_serializer.rb +42 -0
  6. data/lib/clacky/agent/skill_manager.rb +7 -0
  7. data/lib/clacky/agent.rb +58 -1
  8. data/lib/clacky/agent_config.rb +71 -1
  9. data/lib/clacky/brand_config.rb +88 -59
  10. data/lib/clacky/client.rb +13 -2
  11. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -1
  12. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +15 -3
  13. data/lib/clacky/extension/packager.rb +17 -0
  14. data/lib/clacky/locales/en.rb +4 -2
  15. data/lib/clacky/locales/zh.rb +5 -3
  16. data/lib/clacky/message_format/anthropic.rb +56 -1
  17. data/lib/clacky/message_format/open_ai.rb +69 -4
  18. data/lib/clacky/message_history.rb +15 -1
  19. data/lib/clacky/providers.rb +73 -6
  20. data/lib/clacky/server/http_server.rb +61 -29
  21. data/lib/clacky/server/session_registry.rb +4 -2
  22. data/lib/clacky/skill.rb +4 -0
  23. data/lib/clacky/tools/invoke_skill.rb +21 -0
  24. data/lib/clacky/tools/security.rb +11 -13
  25. data/lib/clacky/ui_interface.rb +2 -0
  26. data/lib/clacky/utils/model_pricing.rb +8 -0
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +210 -21
  29. data/lib/clacky/web/app.js +8 -3
  30. data/lib/clacky/web/components/code-editor.js +50 -12
  31. data/lib/clacky/web/features/billing/view.js +7 -1
  32. data/lib/clacky/web/features/extensions/store.js +23 -7
  33. data/lib/clacky/web/features/extensions/view.js +8 -4
  34. data/lib/clacky/web/features/new-session/view.js +61 -0
  35. data/lib/clacky/web/features/workspace/store.js +9 -0
  36. data/lib/clacky/web/features/workspace/view.js +8 -1
  37. data/lib/clacky/web/i18n.js +14 -0
  38. data/lib/clacky/web/index.html +4 -2
  39. data/lib/clacky/web/settings.js +49 -14
  40. metadata +2 -2
@@ -139,6 +139,67 @@ const NewSessionView = (() => {
139
139
 
140
140
  _updatePlaceholder();
141
141
  _renderAgentPanels();
142
+ _renderStarterPrompts();
143
+ }
144
+
145
+ // ── Starter prompts ────────────────────────────────────────────────────
146
+ // Hardcoded UI hints per agent id. These are purely presentational and
147
+ // help users discover what to ask without needing to think from scratch.
148
+ const STARTER_PROMPTS = {
149
+ "ext-developer": [
150
+ {
151
+ en: "Help me build a Xiaohongshu content publishing extension, with a custom panel in the right sidebar",
152
+ zh: "帮我开发一个小红书内容发布管理扩展,在会话右侧边栏添加一个自定义面板",
153
+ },
154
+ {
155
+ en: "I want to build a GitHub PR review assistant extension that auto-analyzes code changes, with an entry in the bottom-left sidebar",
156
+ zh: "我想做一个 GitHub PR 审查助手扩展,自动分析代码变更,入口放在左底部侧边栏",
157
+ },
158
+ {
159
+ en: "Help me build a Pomodoro + task tracking extension with a left sidebar entry and custom Agent",
160
+ zh: "帮我开发一个番茄钟 + 任务追踪扩展,带左侧边栏访问入口和自定义 Agent",
161
+ },
162
+ ],
163
+ };
164
+
165
+ function _renderStarterPrompts() {
166
+ const container = $("new-session-starter-prompts");
167
+ if (!container) return;
168
+ const agent = NewSessionStore.currentAgent();
169
+ const prompts = agent && STARTER_PROMPTS[agent.id];
170
+ if (!prompts || !prompts.length) {
171
+ container.style.display = "none";
172
+ container.replaceChildren();
173
+ return;
174
+ }
175
+
176
+ const zh = _isZh();
177
+ container.replaceChildren();
178
+
179
+ const label = document.createElement("div");
180
+ label.className = "ns-starter-label";
181
+ label.textContent = I18n.t("sessions.new.tryAsking");
182
+ container.appendChild(label);
183
+
184
+ const list = document.createElement("div");
185
+ list.className = "ns-starter-list";
186
+ prompts.forEach((p) => {
187
+ const btn = document.createElement("button");
188
+ btn.type = "button";
189
+ btn.className = "ns-starter-item";
190
+ const text = zh ? p.zh : p.en;
191
+ btn.textContent = "\u201c" + text + "\u201d";
192
+ btn.addEventListener("click", () => {
193
+ const input = $("new-session-input");
194
+ if (!input) return;
195
+ input.value = zh ? p.zh : p.en;
196
+ input.focus();
197
+ _updateSendButton();
198
+ });
199
+ list.appendChild(btn);
200
+ });
201
+ container.appendChild(list);
202
+ container.style.display = "block";
142
203
  }
143
204
 
144
205
  function _renderAgentPanels() {
@@ -102,6 +102,15 @@ const WorkspaceStore = (() => {
102
102
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
103
103
  return resp.text();
104
104
  },
105
+
106
+ async saveFileText(entry, content) {
107
+ const resp = await fetch("/api/file-action", {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ path: _absPath(entry.path), action: "save", content })
111
+ });
112
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
113
+ },
105
114
  };
106
115
 
107
116
  return Workspace;
@@ -226,7 +226,14 @@ const WorkspaceView = (() => {
226
226
  }
227
227
  try {
228
228
  const text = await Workspace.fetchFileText(entry);
229
- CodeEditor.open({ filename: entry.name, title: entry.name, content: text, readOnly: true });
229
+ CodeEditor.open({
230
+ filename: entry.name,
231
+ title: entry.name,
232
+ content: text,
233
+ onSave: async (content) => {
234
+ await Workspace.saveFileText(entry, content);
235
+ }
236
+ });
230
237
  } catch (err) {
231
238
  console.error("preview failed:", err);
232
239
  Modal.toast(t("workspace.previewFailed"), "error");
@@ -33,6 +33,7 @@ const I18n = (() => {
33
33
  "sessions.new.subtitle": "Pick who you want to chat with, then type your first message.",
34
34
  "sessions.new.placeholder": "Type your first message to {{agent}}…",
35
35
  "sessions.new.moreOptions": "More options",
36
+ "sessions.new.tryAsking": "Try asking",
36
37
  "sessions.new.send": "Send",
37
38
 
38
39
  // ── Chat panel ──
@@ -666,6 +667,10 @@ const I18n = (() => {
666
667
  "settings.models.baseurl.variant.ark_payg": "Pay-as-you-go",
667
668
  "settings.models.baseurl.variant.ark_coding": "Coding Plan",
668
669
  "settings.models.baseurl.variant.ark_agent": "Agent Plan",
670
+ "settings.models.baseurl.variant.openclacky_primary": "Primary (Global)",
671
+ "settings.models.baseurl.variant.openclacky_secondary": "Secondary (China)",
672
+ "settings.models.baseurl.variant.mimo_payg": "Pay-as-you-go",
673
+ "settings.models.baseurl.variant.mimo_token_plan": "Token Plan",
669
674
  "settings.models.btn.save": "Save",
670
675
  "settings.models.btn.saving": "Saving…",
671
676
  "settings.models.btn.saved": "Saved ✓",
@@ -753,6 +758,7 @@ const I18n = (() => {
753
758
  "settings.brand.badge.active": "Active",
754
759
  "settings.brand.badge.warning": "Expiring Soon",
755
760
  "settings.brand.badge.expired": "Expired",
761
+ "settings.brand.badge.unreachable": "Connection Issue",
756
762
  "settings.brand.desc": "Have a serial number from a brand partner? Enter it below to activate branded mode.",
757
763
  "settings.brand.descNamed": "Enter your {{name}} serial number to activate branded mode.",
758
764
  "settings.brand.btn.getSerial": "Get a Serial Number",
@@ -1004,6 +1010,7 @@ const I18n = (() => {
1004
1010
  "billing.headerHit": "Hit",
1005
1011
  "billing.headerMiss": "Miss",
1006
1012
  "billing.headerOutput": "Output",
1013
+ "billing.disclaimer": "Costs are estimated based on token usage and may differ from your provider's actual bill.",
1007
1014
  },
1008
1015
  zh: {
1009
1016
  // ── Sidebar ──
@@ -1026,6 +1033,7 @@ const I18n = (() => {
1026
1033
  "sessions.new.subtitle": "选一个 Agent,然后输入第一条消息。",
1027
1034
  "sessions.new.placeholder": "输入第一条消息发给 {{agent}}…",
1028
1035
  "sessions.new.moreOptions": "更多选项",
1036
+ "sessions.new.tryAsking": "试试这样问我",
1029
1037
  "sessions.new.send": "发送",
1030
1038
 
1031
1039
  // ── Chat panel ──
@@ -1663,6 +1671,10 @@ const I18n = (() => {
1663
1671
  "settings.models.baseurl.variant.ark_payg": "按量付费",
1664
1672
  "settings.models.baseurl.variant.ark_coding": "Coding Plan",
1665
1673
  "settings.models.baseurl.variant.ark_agent": "Agent Plan",
1674
+ "settings.models.baseurl.variant.openclacky_primary": "主节点(全球)",
1675
+ "settings.models.baseurl.variant.openclacky_secondary": "备用节点(中国)",
1676
+ "settings.models.baseurl.variant.mimo_payg": "按量付费",
1677
+ "settings.models.baseurl.variant.mimo_token_plan": "Token Plan",
1666
1678
  "settings.models.btn.save": "保存",
1667
1679
  "settings.models.btn.saving": "保存中…",
1668
1680
  "settings.models.btn.saved": "已保存 ✓",
@@ -1750,6 +1762,7 @@ const I18n = (() => {
1750
1762
  "settings.brand.badge.active": "已激活",
1751
1763
  "settings.brand.badge.warning": "即将过期",
1752
1764
  "settings.brand.badge.expired": "已过期",
1765
+ "settings.brand.badge.unreachable": "连接异常",
1753
1766
  "settings.brand.desc": "有品牌合作伙伴的序列号?在下方输入以激活品牌模式。",
1754
1767
  "settings.brand.descNamed": "请输入 {{name}} 的序列号以激活品牌模式。",
1755
1768
  "settings.brand.btn.getSerial": "获取序列号",
@@ -2001,6 +2014,7 @@ const I18n = (() => {
2001
2014
  "billing.headerHit": "命中",
2002
2015
  "billing.headerMiss": "未命中",
2003
2016
  "billing.headerOutput": "输出",
2017
+ "billing.disclaimer": "费用为估算值,基于 Token 用量和模型定价计算,可能与 API 提供商实际账单有所差异。",
2004
2018
  }
2005
2019
  };
2006
2020
  // ── State ──────────────────────────────────────────────────────────────────
@@ -335,6 +335,9 @@
335
335
 
336
336
  <div id="new-session-agents" class="new-session-agents"></div>
337
337
 
338
+ <!-- Starter prompts shown when selected agent provides suggestions -->
339
+ <div id="new-session-starter-prompts" class="new-session-starter-prompts" style="display:none"></div>
340
+
338
341
  <!-- Panels the selected agent exposes (filled when an agent is picked) -->
339
342
  <div id="new-session-agent-panels" class="new-session-agent-panels" style="display:none"></div>
340
343
 
@@ -1308,8 +1311,7 @@
1308
1311
  <input type="password" id="model-modal-apikey" class="field-input api-key-input" placeholder="sk-..." autocomplete="off">
1309
1312
  <button class="btn-toggle-key" id="model-modal-toggle-key" type="button" title="Show/hide key">
1310
1313
  <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1311
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
1312
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
1314
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
1313
1315
  </svg>
1314
1316
  </button>
1315
1317
  </div>
@@ -66,7 +66,11 @@ const Settings = (() => {
66
66
  }
67
67
 
68
68
  function _getProviderName(model) {
69
- const p = _findProviderByBaseUrl(model.base_url);
69
+ // Prefer the saved provider_id (survives custom base_url edits) over
70
+ // a reverse base_url lookup so the card always shows what the user picked.
71
+ const p = (model.provider_id
72
+ ? _providers.find(pr => pr.id === model.provider_id) || null
73
+ : null) || _findProviderByBaseUrl(model.base_url);
70
74
  return _providerDisplayName(p);
71
75
  }
72
76
 
@@ -87,7 +91,11 @@ const Settings = (() => {
87
91
  function _renderCard(container, model, index) {
88
92
  const isDefault = model.type === "default";
89
93
  const isLite = model.type === "lite";
90
- const provider = _findProviderByBaseUrl(model.base_url);
94
+ // Prefer saved provider_id over base_url reverse-lookup so the card
95
+ // shows the correct provider even when the user has customised base_url.
96
+ const provider = (model.provider_id
97
+ ? _providers.find(p => p.id === model.provider_id) || null
98
+ : null) || _findProviderByBaseUrl(model.base_url);
91
99
  const providerName = _providerDisplayName(provider);
92
100
  const websiteUrl = provider && provider.website_url;
93
101
  const displayName = model.model || I18n.t("settings.models.unnamed");
@@ -190,12 +198,21 @@ const Settings = (() => {
190
198
  setDefaultCb.checked = isOnlyModel ? true : (model.type === "default");
191
199
  setDefaultCb.disabled = isOnlyModel;
192
200
 
193
- // Set provider dropdown value
201
+ // Set provider dropdown value.
202
+ // Priority: 1) saved provider_id on the model record (explicit user choice,
203
+ // survives base_url edits); 2) reverse-lookup from base_url (legacy records
204
+ // without provider_id); 3) anthropic_format flag (self-hosted proxy hint).
194
205
  const matched = _findProviderByBaseUrl(model.base_url);
195
- // Preserve an explicit anthropic_format=true even if base_url is custom:
196
- // the user may have configured a self-hosted Anthropic-compatible proxy.
197
- _modalSelectedProviderId = matched ? matched.id : (model.anthropic_format ? "anthropic" : null);
198
- const providerName = matched ? matched.name : I18n.t("settings.models.provider.custom");
206
+ const savedProviderId = model.provider_id || null;
207
+ const resolvedProviderId = savedProviderId ||
208
+ (matched ? matched.id : (model.anthropic_format ? "anthropic" : null));
209
+ _modalSelectedProviderId = resolvedProviderId;
210
+ const resolvedProvider = resolvedProviderId
211
+ ? _providers.find(p => p.id === resolvedProviderId) || null
212
+ : null;
213
+ const providerName = resolvedProvider
214
+ ? _providerDisplayName(resolvedProvider)
215
+ : I18n.t("settings.models.provider.custom");
199
216
  const providerValue = document.getElementById("model-modal-provider-value");
200
217
  providerValue.textContent = providerName;
201
218
  providerValue.classList.remove("placeholder");
@@ -398,7 +415,7 @@ const Settings = (() => {
398
415
 
399
416
  const hasId = !!existingId;
400
417
 
401
- const payload = { model, base_url, anthropic_format };
418
+ const payload = { model, base_url, anthropic_format, provider_id: _modalSelectedProviderId || null };
402
419
  const setDefault = document.getElementById("model-modal-set-default").checked;
403
420
  payload.type = setDefault ? "default" : null;
404
421
  if (setDefault) {
@@ -511,8 +528,14 @@ const Settings = (() => {
511
528
 
512
529
  // Toggle API key visibility
513
530
  document.getElementById("model-modal-toggle-key").addEventListener("click", () => {
514
- const input = document.getElementById("model-modal-apikey");
515
- input.type = input.type === "password" ? "text" : "password";
531
+ const input = document.getElementById("model-modal-apikey");
532
+ const eyeSvg = document.getElementById("model-modal-toggle-key").querySelector("svg");
533
+ const isPassword = input.type === "password";
534
+ input.type = isPassword ? "text" : "password";
535
+ // Switch icon: eye-off ↔ eye
536
+ eyeSvg.innerHTML = isPassword
537
+ ? `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>`
538
+ : `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>`;
516
539
  });
517
540
 
518
541
  // Model dropdown functionality
@@ -1389,10 +1412,22 @@ const Settings = (() => {
1389
1412
 
1390
1413
  const badge = document.getElementById("brand-status-badge");
1391
1414
  if (data.warning) {
1392
- // Distinguish between expired (red) and expiring-soon (yellow)
1393
- const isExpired = data.warning && data.warning.toLowerCase().includes("expired");
1394
- badge.textContent = isExpired ? I18n.t("settings.brand.badge.expired") : I18n.t("settings.brand.badge.warning");
1395
- badge.className = "brand-status-value " + (isExpired ? "badge-expired" : "badge-expiring");
1415
+ // Use warning_type from backend for accurate badge display
1416
+ const wt = data.warning_type;
1417
+ let text, cls;
1418
+ if (wt === "expired") {
1419
+ text = I18n.t("settings.brand.badge.expired");
1420
+ cls = "badge-expired";
1421
+ } else if (wt === "unreachable") {
1422
+ text = I18n.t("settings.brand.badge.unreachable");
1423
+ cls = "badge-unreachable";
1424
+ } else {
1425
+ // "expiring" or legacy fallback
1426
+ text = I18n.t("settings.brand.badge.warning");
1427
+ cls = "badge-expiring";
1428
+ }
1429
+ badge.textContent = text;
1430
+ badge.className = "brand-status-value " + cls;
1396
1431
  } else {
1397
1432
  badge.textContent = I18n.t("settings.brand.badge.active");
1398
1433
  badge.className = "brand-status-value badge-active";
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.1
4
+ version: 1.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -745,7 +745,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
745
745
  - !ruby/object:Gem::Version
746
746
  version: '0'
747
747
  requirements: []
748
- rubygems_version: 3.6.9
748
+ rubygems_version: 3.6.8
749
749
  specification_version: 4
750
750
  summary: The most Token-efficient open-source AI Agent — BYOK, Skill-driven, IM-integrated.
751
751
  test_files: []