openclacky 0.8.7 → 0.8.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38f9805e951dec0f87bda1b64033e0ea7f0c5c6d1c4fd2427f57dfc13aec0835
4
- data.tar.gz: f6f0d08206ead392ffbbc073bb92c5b8e5b4c9f4ecf37172153c4bf46f4963e0
3
+ metadata.gz: 94fe2f9fd0477231e7411c762aacfecc9eb15b87f027366d7598c73e738499c0
4
+ data.tar.gz: 41e84ac897d4d120dec9c7d88f69b35f82779adc2963a13dd099fd686d926ec3
5
5
  SHA512:
6
- metadata.gz: d7400735f1f2cbf9fa6b74e56aaa9264e881ab0618885e87b9757458b3b87bde01c5319db6d6f6833573792229c8aa635d5c09bab43cdde15e8cddfe2ce3e418
7
- data.tar.gz: ef4dede49038208ff386f5b536ba4c64158e5b72f5599694f14ecf83bd3259b51be6af52bef10fbdea88fbc23f2b2b11c9316e1bdbb1f350c355a0fedeb23bd1
6
+ metadata.gz: 1f28d84aab760ca2c53cc0c558ec82fb3b0bebc8b2fe5279021f3d0cc5c896ebe5dda7fc8f060f7d77609bb6b6aedffa064ef1897185db01a9b6f4e022364646
7
+ data.tar.gz: 64b71cd3c7793201f16600b100088cd739671c9c414252dadc6a593600f15271fc79a953877e59bc2014af929888384e3640ee78e7c1227c8b72e81f919ca72c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.8] - 2026-03-13
11
+
12
+ ### Added
13
+ - **i18n system with zh/en runtime switching**: WebUI now supports Chinese and English; all UI text is served through an `I18n` module and switches instantly without a page reload
14
+ - **Onboard language selection step**: first-time setup now opens with a language picker (中文 / English) before any configuration, so the entire onboard experience is conducted in the user's chosen language
15
+ - **Onboard "what's your name" step**: onboard flow now asks for the user's preferred name early on and addresses them by name throughout the rest of the setup
16
+ - **Chinese SOUL.md default**: when a user onboards in Chinese and skips the soul-setup conversation, a Chinese-language SOUL.md is written automatically so the assistant responds in Chinese by default
17
+
18
+ ### Fixed
19
+ - **Onboard WS race condition**: fixed a bug where the first auto-triggered `/onboard` command was silently lost — the WebSocket `session_list` event arrived before the session view was active and redirected the UI to the welcome screen, hiding the agent's response
20
+
10
21
  ## [0.8.7] - 2026-03-13
11
22
 
12
23
  ### Added
@@ -14,22 +14,72 @@ All structured input is gathered through `request_user_feedback` cards — no fr
14
14
 
15
15
  ## Steps
16
16
 
17
+ ### 0. Detect language
18
+
19
+ The user's language was set during the onboarding intro screen. The skill is invoked with
20
+ a `lang:` argument in the slash command, e.g. `/onboard lang:zh` or `/onboard lang:en`.
21
+
22
+ Check the invocation message for `lang:zh` or `lang:en`:
23
+ - If `lang:zh` is present → conduct the **entire** onboard in **Chinese**, write SOUL.md & USER.md in Chinese.
24
+ - Otherwise (or if missing) → use **English** throughout.
25
+
26
+ If the `lang:` argument is absent, infer from the user's first reply; default to English.
27
+
17
28
  ### 1. Greet the user
18
29
 
19
- Send a short, warm welcome message (2–3 sentences). Detect the user's language from any
20
- text they've already typed; default to English. Do NOT ask any questions yet.
30
+ Send a short, warm welcome message (2–3 sentences). Use the language determined in Step 0.
31
+ Do NOT ask any questions yet.
21
32
 
22
33
  Example (English):
23
34
  > Hi! I'm your personal assistant ⚡
24
35
  > Let's take 30 seconds to personalize your experience — I'll ask just a couple of quick things.
25
36
 
26
- ### 2. Collect AI personality (card)
37
+ Example (Chinese):
38
+ > 嗨!我是你的专属 AI 助手 ⚡
39
+ > 只需 30 秒完成个性化设置,我会问你两个简单问题。
40
+
41
+ ### 2. Ask the user's name (card)
42
+
43
+ Call `request_user_feedback` to get the user's preferred name.
44
+
45
+ If `lang == "zh"`, use:
46
+ ```json
47
+ {
48
+ "question": "先告诉我,我该怎么称呼你?"
49
+ }
50
+ ```
51
+
52
+ Otherwise (English):
53
+ ```json
54
+ {
55
+ "question": "First, what should I call you?"
56
+ }
57
+ ```
58
+
59
+ Store the reply as `user.name` (default `"there"` for English, `"朋友"` for Chinese if blank).
60
+
61
+ ### 3. Collect AI personality (card)
27
62
 
28
- Call `request_user_feedback` with a card to set the assistant's name and personality:
63
+ Call `request_user_feedback` with a card to set the assistant's personality.
64
+ Address the user by `user.name` in the question.
65
+
66
+ If `lang == "zh"`, use:
67
+ ```json
68
+ {
69
+ "question": "好的,[user.name]!来设置一下你的助手吧。",
70
+ "options": [
71
+ "🎯 专业型 — 精准、结构化、不废话",
72
+ "😊 友好型 — 热情、鼓励、像一位博学的朋友",
73
+ "🎨 创意型 — 富有想象力,善用比喻,充满热情",
74
+ "⚡ 简洁型 — 极度简短,用要点,信噪比最高"
75
+ ]
76
+ }
77
+ ```
29
78
 
79
+ Otherwise (English):
30
80
  ```json
31
81
  {
32
- "question": "First, let's set up your assistant.",
82
+ "question": "Nice to meet you, [user.name]! Now let's set up your assistant.",
33
83
  "options": [
34
84
  "🎯 Professional — Precise, structured, minimal filler",
35
85
  "😊 Friendly — Warm, encouraging, like a knowledgeable friend",
@@ -39,48 +89,49 @@ Call `request_user_feedback` with a card to set the assistant's name and persona
39
89
  }
40
90
  ```
41
91
 
42
- Also ask for a custom name in the same message if the platform supports a text field;
43
- otherwise follow up with: "What should I call myself? (leave blank to keep 'Clacky')"
44
-
45
92
  Map the chosen option to a personality key:
46
93
  - Option 1 → `professional`
47
94
  - Option 2 → `friendly`
48
95
  - Option 3 → `creative`
49
96
  - Option 4 → `concise`
50
97
 
51
- Store: `ai.name` (default `"Clacky"`), `ai.personality`.
98
+ Store: `ai.personality`.
52
99
 
53
- ### 3. Collect user profile (card)
100
+ ### 4. Collect user profile (card)
54
101
 
55
- Call `request_user_feedback` again:
102
+ Call `request_user_feedback` again.
56
103
 
104
+ If `lang == "zh"`, use:
57
105
  ```json
58
106
  {
59
- "question": "Now a bit about you all optional, skip anything you like.",
107
+ "question": "再简单了解一下你自己吧 —— 全部可选,随便填:\n• 职业\n• 最希望用 AI 做什么\n• 社交 / 作品链接(GitHub、微博、个人网站等)—— AI 会读取公开信息来了解你",
60
108
  "options": []
61
109
  }
62
110
  ```
63
111
 
64
- Ask for the following in the question text (as labeled fields description, since options is empty):
65
- - Name / nickname
66
- - Occupation
67
- - What you want to use AI for most
68
- - Social / portfolio links (GitHub, Twitter/X, personal site…) — AI will read them to learn about you
112
+ Otherwise (English):
113
+ ```json
114
+ {
115
+ "question": "Now a bit about you — all optional, skip anything you like.\n• Occupation\n• What you want to use AI for most\n• Social / portfolio links (GitHub, Twitter/X, personal site…) — AI will read them to learn about you",
116
+ "options": []
117
+ }
118
+ ```
69
119
 
70
120
  Parse the user's reply as free text; extract whatever they provide.
71
121
 
72
- ### 4. Learn from links (if any)
122
+ ### 5. Learn from links (if any)
73
123
 
74
124
  For each URL the user provided, use the `web_search` tool or fetch the page to read
75
125
  publicly available info: bio, projects, tech stack, interests, writing style, etc.
76
126
  Note key facts for the USER.md. Skip silently if a URL is unreachable.
77
127
 
78
- ### 5. Write SOUL.md
128
+ ### 6. Write SOUL.md
79
129
 
80
130
  Write to `~/.clacky/agents/SOUL.md`.
81
131
 
82
132
  Use `ai.name` and `ai.personality` to shape the content.
83
- If the user's language appears to be non-English (detected from their replies), write in that language.
133
+ Write in the language determined in Step 0 (`zh` Chinese, otherwise English).
134
+ If `lang == "zh"`, add a line: `**始终用中文回复用户。**` near the top of the Identity section.
84
135
 
85
136
  **Personality style guide:**
86
137
 
@@ -113,7 +164,7 @@ I am [AI Name], a personal assistant and technical co-founder.
113
164
  [2–3 sentences about how I approach tasks, matching the personality.]
114
165
  ```
115
166
 
116
- ### 6. Write USER.md
167
+ ### 7. Write USER.md
117
168
 
118
169
  Write to `~/.clacky/agents/USER.md`.
119
170
 
@@ -121,7 +172,7 @@ Write to `~/.clacky/agents/USER.md`.
121
172
  # User Profile
122
173
 
123
174
  ## About
124
- - **Name**: [nickname or "Not provided"]
175
+ - **Name**: [user.name, or "Not provided"]
125
176
  - **Occupation**: [or "Not provided"]
126
177
  - **Primary Goal**: [or "Not provided"]
127
178
 
@@ -133,9 +184,31 @@ Write to `~/.clacky/agents/USER.md`.
133
184
  [1–2 sentences tailored to the user's goal and background.]
134
185
  ```
135
186
 
136
- ### 7. Confirm and close
187
+ ### 7b. Write USER.md (Chinese version, if applicable)
188
+
189
+ If `lang == "zh"`, write `~/.clacky/agents/USER.md` in Chinese:
190
+
191
+ ```markdown
192
+ # 用户档案
193
+
194
+ ## 基本信息
195
+ - **姓名**: [user.name,未填则写「未填写」]
196
+ - **职业**: [未填则写「未填写」]
197
+ - **主要目标**: [未填则写「未填写」]
198
+
199
+ ## 背景与兴趣
200
+ [如有链接:3–5 条从公开信息中提取的要点。否则:写「暂无更多背景信息。」]
201
+
202
+ ## 如何最好地帮助用户
203
+ [1–2 句话,根据用户目标和背景量身定制。]
204
+ ```
205
+
206
+ ### 8. Confirm and close
207
+
208
+ If `lang == "zh"`, reply:
209
+ > 全部设置完成!我已保存你的偏好。关掉这个对话,开启一个新对话就可以开始了 —— 尽情享用吧!🚀
137
210
 
138
- Reply with a single short message, e.g.:
211
+ Otherwise:
139
212
  > All set! I've saved your preferences. Feel free to close this tab and start a fresh session — enjoy! 🚀
140
213
 
141
214
  Do NOT open a new session — the UI handles navigation after the skill finishes.
@@ -96,6 +96,29 @@ module Clacky
96
96
  - Breaking big goals into small, executable steps
97
97
  MD
98
98
 
99
+ # Default SOUL.md for Chinese-language users.
100
+ DEFAULT_SOUL_MD_ZH = <<~MD.freeze
101
+ # Clacky — 助手灵魂
102
+
103
+ 你是 Clacky,一位友好、能干的 AI 编程助手和技术联合创始人。
104
+ 你思维敏锐、言简意赅、主动积极。你说话直接,不喜欢过度客套。
105
+ 你热爱帮助用户打造优秀的软件产品。
106
+
107
+ **重要:始终用中文回复用户。**
108
+
109
+ ## 性格特点
110
+ - 热情鼓励,但直接诚实
111
+ - 行动前先思考;简要说明你的推理过程
112
+ - 重行动而非空谈 —— 善用工具,写代码,交付结果
113
+ - 根据用户的风格调整语气和表达方式
114
+
115
+ ## 核心能力
116
+ - 全栈软件开发(Ruby、Python、JS 等)
117
+ - 架构设计与代码审查
118
+ - 耐心细致地调试复杂问题
119
+ - 将大目标拆解为可执行的小步骤
120
+ MD
121
+
99
122
  def initialize(host: "127.0.0.1", port: 7070, agent_config:, client_factory:, brand_test: false)
100
123
  @host = host
101
124
  @port = port
@@ -227,7 +250,7 @@ module Clacky
227
250
  when ["GET", "/api/providers"] then api_list_providers(res)
228
251
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
229
252
  when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
230
- when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(res)
253
+ when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
231
254
  when ["GET", "/api/store/skills"] then api_store_skills(res)
232
255
  when ["GET", "/api/brand/status"] then api_brand_status(res)
233
256
  when ["POST", "/api/brand/activate"] then api_brand_activate(req, res)
@@ -349,12 +372,16 @@ module Clacky
349
372
  # POST /api/onboard/skip-soul
350
373
  # Writes a minimal SOUL.md so the soul_setup phase is not re-triggered
351
374
  # on the next server start when the user chooses to skip the conversation.
352
- def api_onboard_skip_soul(res)
375
+ def api_onboard_skip_soul(req, res)
376
+ body = parse_json_body(req)
377
+ lang = body["lang"].to_s.strip
378
+ soul_content = lang == "zh" ? DEFAULT_SOUL_MD_ZH : DEFAULT_SOUL_MD
379
+
353
380
  agents_dir = File.expand_path("~/.clacky/agents")
354
381
  FileUtils.mkdir_p(agents_dir)
355
382
  soul_path = File.join(agents_dir, "SOUL.md")
356
383
  unless File.exist?(soul_path)
357
- File.write(soul_path, DEFAULT_SOUL_MD)
384
+ File.write(soul_path, soul_content)
358
385
  end
359
386
  json_response(res, 200, { ok: true })
360
387
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.8.7"
4
+ VERSION = "0.8.8"
5
5
  end
@@ -2344,6 +2344,77 @@ body {
2344
2344
  background: var(--color-border-primary);
2345
2345
  }
2346
2346
 
2347
+ /* ── Language selection phase ── */
2348
+ #onboard-phase-lang {
2349
+ width: 100%;
2350
+ display: flex;
2351
+ flex-direction: column;
2352
+ align-items: center;
2353
+ gap: 20px;
2354
+ }
2355
+
2356
+ .onboard-lang-prompt {
2357
+ font-size: 15px;
2358
+ color: var(--color-text-secondary);
2359
+ text-align: center;
2360
+ }
2361
+
2362
+ .onboard-lang-btns {
2363
+ display: flex;
2364
+ gap: 12px;
2365
+ justify-content: center;
2366
+ }
2367
+
2368
+ .onboard-lang-btn {
2369
+ background: var(--color-bg-secondary);
2370
+ border: 2px solid var(--color-border-primary);
2371
+ border-radius: 8px;
2372
+ color: var(--color-text-primary);
2373
+ font-size: 15px;
2374
+ font-weight: 600;
2375
+ padding: 12px 32px;
2376
+ cursor: pointer;
2377
+ transition: border-color .15s, background .15s, color .15s;
2378
+ }
2379
+
2380
+ .onboard-lang-btn:hover {
2381
+ border-color: var(--color-accent-primary);
2382
+ }
2383
+
2384
+ .onboard-lang-btn.active {
2385
+ background: var(--color-accent-primary);
2386
+ border-color: var(--color-accent-primary);
2387
+ color: #fff;
2388
+ }
2389
+
2390
+ /* ── Settings language buttons ── */
2391
+ .settings-lang-btns {
2392
+ display: flex;
2393
+ gap: 8px;
2394
+ }
2395
+
2396
+ .settings-lang-btn {
2397
+ background: var(--color-bg-secondary);
2398
+ border: 2px solid var(--color-border-primary);
2399
+ border-radius: 6px;
2400
+ color: var(--color-text-primary);
2401
+ font-size: 13px;
2402
+ font-weight: 600;
2403
+ padding: 6px 18px;
2404
+ cursor: pointer;
2405
+ transition: border-color .15s, background .15s, color .15s;
2406
+ }
2407
+
2408
+ .settings-lang-btn:hover {
2409
+ border-color: var(--color-accent-primary);
2410
+ }
2411
+
2412
+ .settings-lang-btn.active {
2413
+ background: var(--color-accent-primary);
2414
+ border-color: var(--color-accent-primary);
2415
+ color: #fff;
2416
+ }
2417
+
2347
2418
  /* ── Phase containers ── */
2348
2419
  #onboard-phase-key,
2349
2420
  #onboard-phase-soul {
@@ -276,10 +276,15 @@ WS.onEvent(ev => {
276
276
  Sessions.setAll(ev.sessions || []);
277
277
  Sessions.renderList();
278
278
 
279
- // Restore URL hash once on initial connect; ignore subsequent session_list events
279
+ // Restore URL hash once on initial connect; ignore subsequent session_list events.
280
+ // Skip if we are already on a session view (e.g. onboard flow navigated there
281
+ // before WS connected) — restoreFromHash would wrongly redirect to "welcome"
282
+ // because there is no hash set during onboarding.
280
283
  if (!_initialRestoreDone) {
281
284
  _initialRestoreDone = true;
282
- Router.restoreFromHash();
285
+ if (Router.current !== "session") {
286
+ Router.restoreFromHash();
287
+ }
283
288
  } else {
284
289
  // If active session was deleted, go to welcome
285
290
  if (Sessions.activeId && !Sessions.find(Sessions.activeId)) {
@@ -361,7 +366,7 @@ WS.onEvent(ev => {
361
366
 
362
367
  case "progress":
363
368
  if (ev.session_id !== Sessions.activeId) break;
364
- if (ev.status === "start") Sessions.showProgress(ev.message || "Thinking…");
369
+ if (ev.status === "start") Sessions.showProgress(ev.message || I18n.t("chat.thinking"));
365
370
  else Sessions.clearProgress();
366
371
  break;
367
372
 
@@ -369,7 +374,7 @@ WS.onEvent(ev => {
369
374
  if (ev.session_id !== Sessions.activeId) break;
370
375
  Sessions.clearProgress();
371
376
  Sessions.collapseToolGroup();
372
- Sessions.appendInfo(`✓ Done ${ev.iterations} iteration(s), $${(ev.cost || 0).toFixed(4)}`);
377
+ Sessions.appendInfo(`✓ ${I18n.t("chat.done", { n: ev.iterations, cost: (ev.cost || 0).toFixed(4) })}`);
373
378
  break;
374
379
 
375
380
  case "request_confirmation":
@@ -381,7 +386,7 @@ WS.onEvent(ev => {
381
386
  if (ev.session_id !== Sessions.activeId) break;
382
387
  Sessions.clearProgress();
383
388
  Sessions.collapseToolGroup();
384
- Sessions.appendInfo("Interrupted.");
389
+ Sessions.appendInfo(I18n.t("chat.interrupted"));
385
390
  break;
386
391
 
387
392
  // ── Info / errors ──────────────────────────────────────────────────
@@ -705,7 +710,7 @@ const SkillAC = (() => {
705
710
  // Header label
706
711
  const header = document.createElement("div");
707
712
  header.className = "skill-ac-header";
708
- header.textContent = "Skills";
713
+ header.textContent = I18n.t("sidebar.skills");
709
714
  list.appendChild(header);
710
715
 
711
716
  _items.forEach((skill, idx) => {
@@ -49,8 +49,8 @@ const Brand = (() => {
49
49
  if (brandName) {
50
50
  const title = $("brand-title");
51
51
  const sub = $("brand-subtitle");
52
- if (title) title.textContent = "Activate " + brandName;
53
- if (sub) sub.textContent = "Enter your license key to get started.";
52
+ if (title) title.textContent = I18n.t("brand.activate.title", { name: brandName });
53
+ if (sub) sub.textContent = I18n.t("brand.activate.subtitle");
54
54
  }
55
55
  Router.navigate("brand");
56
56
  _bindActivationPanel();
@@ -69,18 +69,18 @@ const Brand = (() => {
69
69
  const key = $("brand-license-key").value.trim();
70
70
 
71
71
  if (!key) {
72
- _setResult(false, "Please enter your license key.");
72
+ _setResult(false, I18n.t("settings.brand.enterKey"));
73
73
  return;
74
74
  }
75
75
 
76
76
  // In brand-test mode accept any non-empty key so developers can test without a real license.
77
77
  if (!_testMode && !/^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}$/.test(key)) {
78
- _setResult(false, "Invalid format. Expected: XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX");
78
+ _setResult(false, I18n.t("settings.brand.invalidFormat"));
79
79
  return;
80
80
  }
81
81
 
82
82
  btn.disabled = true;
83
- btn.textContent = "Activating...";
83
+ btn.textContent = I18n.t("settings.brand.btn.activating");
84
84
  _setResult(null, "");
85
85
 
86
86
  try {
@@ -92,18 +92,18 @@ const Brand = (() => {
92
92
  const data = await res.json();
93
93
 
94
94
  if (data.ok) {
95
- _setResult(true, "License activated successfully!");
95
+ _setResult(true, I18n.t("brand.activate.success"));
96
96
  if (data.brand_name) _applyBrandName(data.brand_name);
97
97
  setTimeout(_bootUI, 800);
98
98
  } else {
99
- _setResult(false, data.error || "Activation failed. Please try again.");
99
+ _setResult(false, data.error || I18n.t("settings.brand.activationFailed"));
100
100
  btn.disabled = false;
101
- btn.textContent = "Activate";
101
+ btn.textContent = I18n.t("settings.brand.btn.activate");
102
102
  }
103
103
  } catch (e) {
104
- _setResult(false, "Network error: " + e.message);
104
+ _setResult(false, I18n.t("settings.brand.networkError") + e.message);
105
105
  btn.disabled = false;
106
- btn.textContent = "Activate";
106
+ btn.textContent = I18n.t("settings.brand.btn.activate");
107
107
  }
108
108
  }
109
109
 
@@ -124,8 +124,8 @@ const Brand = (() => {
124
124
  const nodes = {
125
125
  "page-title": name,
126
126
  "sidebar-logo": name,
127
- "onboard-title": "Welcome to " + name,
128
- "welcome-title": "Welcome to " + name
127
+ "onboard-title": I18n.t("onboard.welcome", { name }),
128
+ "welcome-title": I18n.t("onboard.welcome", { name })
129
129
  };
130
130
  Object.entries(nodes).forEach(([id, text]) => {
131
131
  const el = $(id);
@@ -8,25 +8,27 @@
8
8
 
9
9
  const Channels = (() => {
10
10
 
11
- // Platform display metadata
12
- const PLATFORM_META = {
13
- feishu: {
14
- logo: "飞",
15
- logoClass: "channel-logo-feishu",
16
- name: "Feishu / Lark",
17
- desc: "Connect via Feishu open platform WebSocket long connection",
18
- setupCmd: "/channel-setup setup feishu",
19
- testCmd: "/channel-setup doctor",
20
- },
21
- wecom: {
22
- logo: "微",
23
- logoClass: "channel-logo-wecom",
24
- name: "WeCom (企业微信)",
25
- desc: "Connect via WeCom intelligent robot WebSocket",
26
- setupCmd: "/channel-setup setup wecom",
27
- testCmd: "/channel-setup doctor",
28
- },
29
- };
11
+ // Platform display metadata (use accessor to pick up runtime language)
12
+ function PLATFORM_META() {
13
+ return {
14
+ feishu: {
15
+ logo: "",
16
+ logoClass: "channel-logo-feishu",
17
+ name: "Feishu / Lark",
18
+ desc: I18n.t("channels.feishu.desc"),
19
+ setupCmd: "/channel-setup setup feishu",
20
+ testCmd: "/channel-setup doctor",
21
+ },
22
+ wecom: {
23
+ logo: "",
24
+ logoClass: "channel-logo-wecom",
25
+ name: "WeCom (企业微信)",
26
+ desc: I18n.t("channels.wecom.desc"),
27
+ setupCmd: "/channel-setup setup wecom",
28
+ testCmd: "/channel-setup doctor",
29
+ },
30
+ };
31
+ }
30
32
 
31
33
  // ── Public API ──────────────────────────────────────────────────────────────
32
34
 
@@ -39,14 +41,14 @@ const Channels = (() => {
39
41
  async function _load() {
40
42
  const container = $("channels-list");
41
43
  if (!container) return;
42
- container.innerHTML = `<div class="channel-loading">Loading…</div>`;
44
+ container.innerHTML = `<div class="channel-loading">${I18n.t("channels.loading")}</div>`;
43
45
 
44
46
  try {
45
47
  const res = await fetch("/api/channels");
46
48
  const data = await res.json();
47
49
  _render(data.channels || []);
48
50
  } catch (e) {
49
- container.innerHTML = `<div class="channel-error">Failed to load channels: ${_esc(e.message)}</div>`;
51
+ container.innerHTML = `<div class="channel-error">${I18n.t("channels.loadError", { msg: _esc(e.message) })}</div>`;
50
52
  }
51
53
  }
52
54
 
@@ -58,15 +60,15 @@ const Channels = (() => {
58
60
  container.innerHTML = "";
59
61
 
60
62
  // Merge server data with display metadata, show all known platforms
61
- const platformIds = Object.keys(PLATFORM_META);
63
+ const meta = PLATFORM_META();
64
+ const platformIds = Object.keys(meta);
62
65
  platformIds.forEach(pid => {
63
66
  const serverData = channels.find(c => c.platform == pid) || {};
64
- container.appendChild(_renderCard(pid, serverData));
67
+ container.appendChild(_renderCard(pid, serverData, meta[pid]));
65
68
  });
66
69
  }
67
70
 
68
- function _renderCard(platform, data) {
69
- const meta = PLATFORM_META[platform];
71
+ function _renderCard(platform, data, meta) {
70
72
  const enabled = !!data.enabled;
71
73
  const running = !!data.running;
72
74
 
@@ -97,13 +99,13 @@ const Channels = (() => {
97
99
  <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
98
100
  <polyline points="22 4 12 14.01 9 11.01"/>
99
101
  </svg>
100
- Run E2E Test
102
+ ${I18n.t("channels.btn.test")}
101
103
  </button>
102
104
  <button class="btn-channel-configure btn-primary" id="btn-configure-${_esc(platform)}">
103
105
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
104
106
  <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
105
107
  </svg>
106
- ${enabled ? "Reconfigure" : "Set Up with Agent"}
108
+ ${enabled ? I18n.t("channels.btn.reconfigure") : I18n.t("channels.btn.setup")}
107
109
  </button>
108
110
  </div>
109
111
  </div>
@@ -119,32 +121,32 @@ const Channels = (() => {
119
121
  // ── Badge & status hint helpers ───────────────────────────────────────────────
120
122
 
121
123
  function _badgeHtml(enabled, running) {
122
- if (running) return `<span class="badge-running">● Running</span>`;
123
- if (enabled) return `<span class="badge-enabled">● Enabled</span>`;
124
- return `<span class="badge-disabled">○ Not configured</span>`;
124
+ if (running) return `<span class="badge-running">● ${I18n.t("channels.badge.running")}</span>`;
125
+ if (enabled) return `<span class="badge-enabled">● ${I18n.t("channels.badge.enabled")}</span>`;
126
+ return `<span class="badge-disabled">○ ${I18n.t("channels.badge.notConfigured")}</span>`;
125
127
  }
126
128
 
127
129
  function _statusHint(enabled, running) {
128
130
  if (running) {
129
- return `<p class="channel-status-hint hint-ok">✓ Adapter is running and accepting messages from users</p>`;
131
+ return `<p class="channel-status-hint hint-ok">✓ ${I18n.t("channels.hint.running")}</p>`;
130
132
  }
131
133
  if (enabled) {
132
- return `<p class="channel-status-hint hint-warn">⚠ Enabled but not running — the adapter may have failed to connect. Run E2E Test to diagnose.</p>`;
134
+ return `<p class="channel-status-hint hint-warn">⚠ ${I18n.t("channels.hint.enabledNotRunning")}</p>`;
133
135
  }
134
- return `<p class="channel-status-hint hint-idle">Not configured yet. Click "Set Up with Agent" to get started.</p>`;
136
+ return `<p class="channel-status-hint hint-idle">${I18n.t("channels.hint.notConfigured")}</p>`;
135
137
  }
136
138
 
137
139
  // ── Actions ───────────────────────────────────────────────────────────────────
138
140
 
139
141
  // Run E2E test: open a session and send /channel-setup doctor
140
142
  async function _runTest(platform) {
141
- const meta = PLATFORM_META[platform];
143
+ const meta = PLATFORM_META()[platform];
142
144
  await _sendToAgent(meta.testCmd, `Channel E2E Test — ${meta.name}`);
143
145
  }
144
146
 
145
147
  // Open setup: open a session and send /channel-setup setup <platform>
146
148
  async function _openSetup(platform) {
147
- const meta = PLATFORM_META[platform];
149
+ const meta = PLATFORM_META()[platform];
148
150
  await _sendToAgent(meta.setupCmd, `Channel Setup — ${meta.name}`);
149
151
  }
150
152
 
@@ -165,9 +167,9 @@ const Channels = (() => {
165
167
  body: JSON.stringify({ name }),
166
168
  });
167
169
  const data = await res.json();
168
- if (!res.ok) throw new Error(data.error || "unknown");
170
+ if (!res.ok) throw new Error(data.error || I18n.t("channels.sessionError"));
169
171
  const session = data.session;
170
- if (!session) throw new Error("No session returned");
172
+ if (!session) throw new Error(I18n.t("channels.noSession"));
171
173
 
172
174
  // Register in Sessions, refresh sidebar, queue command, then navigate
173
175
  Sessions.add(session);