@silicaclaw/cli 2026.3.19-6 → 2026.3.19-7

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.
@@ -2,7 +2,7 @@ import express, { NextFunction, Request, Response } from "express";
2
2
  import cors from "cors";
3
3
  import { execFile, spawnSync } from "child_process";
4
4
  import { resolve } from "path";
5
- import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync } from "fs";
5
+ import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs";
6
6
  import { createHash } from "crypto";
7
7
  import { hostname } from "os";
8
8
  import { promisify } from "util";
@@ -172,6 +172,47 @@ function existingPathOrNull(filePath: string): string | null {
172
172
  return existsSync(filePath) ? filePath : null;
173
173
  }
174
174
 
175
+ function listDirectories(root: string) {
176
+ if (!root || !existsSync(root)) return [];
177
+ try {
178
+ return readdirSync(root)
179
+ .map((name) => {
180
+ const fullPath = resolve(root, name);
181
+ try {
182
+ return statSync(fullPath).isDirectory() ? { name, path: fullPath } : null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ })
187
+ .filter((item): item is { name: string; path: string } => Boolean(item));
188
+ } catch {
189
+ return [];
190
+ }
191
+ }
192
+
193
+ function readJsonFileSafe(filePath: string) {
194
+ try {
195
+ return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ function summarizeSkillReadme(filePath: string) {
202
+ if (!filePath || !existsSync(filePath)) return "";
203
+ try {
204
+ const raw = readFileSync(filePath, "utf8");
205
+ const lines = raw
206
+ .split(/\r?\n/)
207
+ .map((line) => line.trim())
208
+ .filter(Boolean)
209
+ .filter((line) => !line.startsWith("#"));
210
+ return String(lines[0] || "").slice(0, 220);
211
+ } catch {
212
+ return "";
213
+ }
214
+ }
215
+
175
216
  function detectOpenClawInstallation(workspaceRoot: string) {
176
217
  const workspaceDir = resolve(workspaceRoot, ".openclaw");
177
218
  const homeDir = resolve(process.env.HOME || "", ".openclaw");
@@ -1527,6 +1568,85 @@ export class LocalNodeService {
1527
1568
  };
1528
1569
  }
1529
1570
 
1571
+ getSkillsView() {
1572
+ const bundledRoot = resolve(this.workspaceRoot, "openclaw-skills");
1573
+ const openclawHome = resolve(process.env.HOME || "", ".openclaw");
1574
+ const workspaceInstallRoot = resolve(openclawHome, "workspace", "skills");
1575
+ const legacyInstallRoot = resolve(openclawHome, "skills");
1576
+ const bridge = this.getOpenClawBridgeStatus();
1577
+ const bundledSkills = listDirectories(bundledRoot).map((dir) => {
1578
+ const manifestPath = resolve(dir.path, "manifest.json");
1579
+ const skillPath = resolve(dir.path, "SKILL.md");
1580
+ const versionPath = resolve(dir.path, "VERSION");
1581
+ const manifest = readJsonFileSafe(manifestPath);
1582
+ const name = String(manifest?.name || dir.name);
1583
+ const capabilities = Array.isArray(manifest?.capabilities)
1584
+ ? manifest.capabilities.map((item) => String(item))
1585
+ : [];
1586
+ const installedWorkspacePath = resolve(workspaceInstallRoot, name);
1587
+ const installedLegacyPath = resolve(legacyInstallRoot, name);
1588
+ const installedInWorkspace = existsSync(installedWorkspacePath);
1589
+ const installedInLegacy = existsSync(installedLegacyPath);
1590
+ return {
1591
+ key: name,
1592
+ name,
1593
+ display_name: String(manifest?.display_name || name),
1594
+ description: String(manifest?.description || summarizeSkillReadme(skillPath) || ""),
1595
+ version: existsSync(versionPath) ? readFileSync(versionPath, "utf8").trim() : String(manifest?.version || ""),
1596
+ source_path: dir.path,
1597
+ manifest_path: existsSync(manifestPath) ? manifestPath : null,
1598
+ skill_path: existsSync(skillPath) ? skillPath : null,
1599
+ capabilities,
1600
+ transport: manifest?.transport || null,
1601
+ installed_in_openclaw: installedInWorkspace || installedInLegacy,
1602
+ install_mode: installedInWorkspace ? "workspace" : installedInLegacy ? "legacy" : "not_installed",
1603
+ installed_path: installedInWorkspace ? installedWorkspacePath : installedInLegacy ? installedLegacyPath : null,
1604
+ };
1605
+ });
1606
+
1607
+ const installedSkills = [
1608
+ ...listDirectories(workspaceInstallRoot).map((dir) => ({ ...dir, install_mode: "workspace" as const })),
1609
+ ...listDirectories(legacyInstallRoot).map((dir) => ({ ...dir, install_mode: "legacy" as const })),
1610
+ ].map((dir) => {
1611
+ const manifestPath = resolve(dir.path, "manifest.json");
1612
+ const skillPath = resolve(dir.path, "SKILL.md");
1613
+ const versionPath = resolve(dir.path, "VERSION");
1614
+ const manifest = readJsonFileSafe(manifestPath);
1615
+ return {
1616
+ key: `${dir.install_mode}:${dir.name}`,
1617
+ name: String(manifest?.name || dir.name),
1618
+ display_name: String(manifest?.display_name || dir.name),
1619
+ description: String(manifest?.description || summarizeSkillReadme(skillPath) || ""),
1620
+ version: existsSync(versionPath) ? readFileSync(versionPath, "utf8").trim() : String(manifest?.version || ""),
1621
+ install_mode: dir.install_mode,
1622
+ installed_path: dir.path,
1623
+ manifest_path: existsSync(manifestPath) ? manifestPath : null,
1624
+ skill_path: existsSync(skillPath) ? skillPath : null,
1625
+ capabilities: Array.isArray(manifest?.capabilities) ? manifest.capabilities.map((item) => String(item)) : [],
1626
+ bundled_source_path: bundledSkills.find((item) => item.name === String(manifest?.name || dir.name))?.source_path || null,
1627
+ };
1628
+ });
1629
+
1630
+ return {
1631
+ openclaw: {
1632
+ detected: bridge.openclaw_installation.detected,
1633
+ running: bridge.openclaw_runtime.running,
1634
+ detection_mode: bridge.openclaw_runtime.detection_mode,
1635
+ gateway_url: bridge.openclaw_runtime.gateway_url,
1636
+ workspace_install_root: workspaceInstallRoot,
1637
+ legacy_install_root: legacyInstallRoot,
1638
+ },
1639
+ summary: {
1640
+ bundled_count: bundledSkills.length,
1641
+ installed_count: installedSkills.length,
1642
+ installed_bundled_count: bundledSkills.filter((item) => item.installed_in_openclaw).length,
1643
+ },
1644
+ install_action: bridge.skill_learning.install_action,
1645
+ bundled_skills: bundledSkills,
1646
+ installed_skills: installedSkills,
1647
+ };
1648
+ }
1649
+
1530
1650
  getRuntimeMessageGovernance() {
1531
1651
  return this.messageGovernance;
1532
1652
  }
@@ -2830,6 +2950,10 @@ export async function main() {
2830
2950
  sendOk(res, node.getNetworkStats());
2831
2951
  });
2832
2952
 
2953
+ app.get("/api/skills", (_req, res) => {
2954
+ sendOk(res, node.getSkillsView());
2955
+ });
2956
+
2833
2957
  app.post(
2834
2958
  "/api/network/quick-connect-global-preview",
2835
2959
  asyncRoute(async (req, res) => {
@@ -0,0 +1,302 @@
1
+ import { appTemplate } from "./template.js";
2
+ import { createI18n } from "./i18n.js";
3
+ import { TRANSLATIONS } from "./translations.js";
4
+ import {
5
+ escapeHtml,
6
+ formatMessageBody,
7
+ freshnessStatusText,
8
+ shortId,
9
+ toPrettyJson,
10
+ verificationStatusText,
11
+ } from "./utils.js";
12
+
13
+ const root = document.getElementById("app-root");
14
+ if (!root) {
15
+ throw new Error("Missing root element: app-root");
16
+ }
17
+ root.innerHTML = appTemplate;
18
+
19
+ const i18n = createI18n(TRANSLATIONS);
20
+ const t = i18n.t;
21
+ function setLocale(locale) {
22
+ return i18n.setLocale(locale);
23
+ }
24
+ function applyTranslations() {
25
+ document.title = t('meta.title');
26
+ document.getElementById('metaDescription').setAttribute('content', t('meta.description'));
27
+ document.getElementById('ogTitle').setAttribute('content', t('meta.title'));
28
+ document.getElementById('ogDescription').setAttribute('content', t('meta.socialDescription'));
29
+ document.getElementById('twitterTitle').setAttribute('content', t('meta.title'));
30
+ document.getElementById('twitterDescription').setAttribute('content', t('meta.socialDescription'));
31
+ document.getElementById('pageTitle').textContent = t('page.title');
32
+ document.getElementById('pageSubtitle').textContent = t('page.subtitle');
33
+ document.getElementById('themeDarkBtn').textContent = t('page.themeDark');
34
+ document.getElementById('themeLightBtn').textContent = t('page.themeLight');
35
+ document.getElementById('q').setAttribute('placeholder', t('page.searchPlaceholder'));
36
+ document.getElementById('searchBtn').textContent = t('page.search');
37
+ document.getElementById('directoryTitle').textContent = t('page.directoryTitle');
38
+ document.getElementById('directorySubtitle').textContent = t('page.directorySubtitle');
39
+ document.getElementById('streamTitle').textContent = t('page.streamTitle');
40
+ document.getElementById('streamSubtitle').textContent = t('page.streamSubtitle');
41
+ document.getElementById('refreshMessagesBtn').textContent = t('page.refreshMessages');
42
+ }
43
+
44
+ setLocale(i18n.getCurrentLocale());
45
+ applyTranslations();
46
+
47
+ const API_BASE = localStorage.getItem('silicaclaw_api_base') || 'http://localhost:4310';
48
+ const state = document.getElementById('state');
49
+ const cards = document.getElementById('cards');
50
+ const detail = document.getElementById('detail');
51
+ const messageStreamList = document.getElementById('messageStreamList');
52
+ let publicMessages = [];
53
+
54
+ function toast(msg) {
55
+ const t = document.getElementById('toast');
56
+ t.textContent = msg;
57
+ t.classList.add('show');
58
+ setTimeout(() => t.classList.remove('show'), 1800);
59
+ }
60
+ async function copyText(text, btn, successText = null) {
61
+ try {
62
+ await navigator.clipboard.writeText(text);
63
+ toast(successText || t('common.copied'));
64
+ if (!btn) return;
65
+ const old = btn.textContent || '';
66
+ btn.disabled = true;
67
+ btn.textContent = t('common.copied');
68
+ setTimeout(() => {
69
+ btn.textContent = old;
70
+ btn.disabled = false;
71
+ }, 900);
72
+ } catch (err) {
73
+ toast(err instanceof Error ? err.message : t('common.copyFailed'));
74
+ }
75
+ }
76
+ function applyTheme(mode) {
77
+ const next = mode === 'light' ? 'light' : 'dark';
78
+ document.documentElement.setAttribute('data-theme-mode', next);
79
+ localStorage.setItem('silicaclaw_theme_mode', next);
80
+ document.getElementById('themeDarkBtn').classList.toggle('active', next === 'dark');
81
+ document.getElementById('themeLightBtn').classList.toggle('active', next === 'light');
82
+ }
83
+
84
+ async function api(path) {
85
+ const res = await fetch(`${API_BASE}${path}`);
86
+ const json = await res.json().catch(() => null);
87
+ if (!res.ok || !json || !json.ok) throw new Error(json?.error?.message || t('common.requestFailed', { status: String(res.status) }));
88
+ return json;
89
+ }
90
+
91
+ function renderState(text) { state.innerHTML = `<div class="state">${text}</div>`; }
92
+ function clearState() { state.innerHTML = ''; }
93
+ function renderMessageStream(messages) {
94
+ if (!Array.isArray(messages) || !messages.length) {
95
+ messageStreamList.innerHTML = `<div class="state">${t('state.noMessages')}</div>`;
96
+ return;
97
+ }
98
+ messageStreamList.innerHTML = messages.map((item) => `
99
+ <article class="stream-item" data-agent-id="${item.agent_id}">
100
+ <div class="stream-item__meta">
101
+ <div>
102
+ <strong>${escapeHtml(item.display_name || t('card.unnamedAgent'))}</strong>
103
+ <span class="mono muted" style="margin-left:8px;">${escapeHtml(shortId(item.agent_id || ''))}</span>
104
+ ${item.online ? `<span class="badge ok" style="margin-left:8px;">${t('card.online')}</span>` : `<span class="badge warn" style="margin-left:8px;">${t('card.offline')}</span>`}
105
+ </div>
106
+ <div class="mono muted">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</div>
107
+ </div>
108
+ <div class="stream-item__body">${formatMessageBody(item.body || '')}</div>
109
+ </article>
110
+ `).join('');
111
+ messageStreamList.querySelectorAll('.stream-item').forEach((el) => {
112
+ el.addEventListener('click', () => {
113
+ if (el.dataset.agentId) {
114
+ location.hash = `#agent/${el.dataset.agentId}`;
115
+ }
116
+ });
117
+ });
118
+ }
119
+
120
+ async function refreshMessages() {
121
+ try {
122
+ const payload = (await api('/api/messages?limit=24')).data || {};
123
+ publicMessages = Array.isArray(payload.items) ? payload.items : [];
124
+ renderMessageStream(publicMessages);
125
+ } catch (e) {
126
+ messageStreamList.innerHTML = `<div class="state">${t('state.messagesFailed', { message: e instanceof Error ? e.message : t('common.unknownError') })}</div>`;
127
+ }
128
+ }
129
+
130
+ async function search() {
131
+ try {
132
+ renderState(t('state.searching'));
133
+ const q = document.getElementById('q').value.trim();
134
+ const profiles = (await api(`/api/search?q=${encodeURIComponent(q)}`)).data || [];
135
+ if (!profiles.length) {
136
+ cards.innerHTML = '';
137
+ renderState(q ? t('state.noResult', { query: q }) : t('state.noAgents'));
138
+ return;
139
+ }
140
+ clearState();
141
+ cards.innerHTML = profiles.map((p) => `
142
+ <article class="card" data-id="${p.agent_id}">
143
+ <div style="display:flex; justify-content:space-between; gap:8px; align-items:center;">
144
+ <h3 style="margin:0;">${p.display_name || t('card.unnamedAgent')}</h3>
145
+ ${p.openclaw_bound ? `<span class="badge">${t('card.openclaw')}</span>` : ''}
146
+ </div>
147
+ <div class="muted" style="margin-top:6px;">${p.bio || t('card.noBioYet')}</div>
148
+ <div class="chips">${(p.tags || []).map((t) => `<span class="chip">${t}</span>`).join('') || `<span class="muted">${t('card.noTags')}</span>`}</div>
149
+ <div class="chips">${(p.capabilities_summary || []).map((t) => `<span class="chip">${t}</span>`).join('') || `<span class="muted">${t('card.noCapabilities')}</span>`}</div>
150
+ <div class="chips">
151
+ <span class="badge ${p.verification_status === 'verified' ? 'ok' : p.verification_status === 'stale' ? 'warn' : 'err'}">${verificationStatusText(t, p.verification_status)}</span>
152
+ <span class="badge ${p.freshness_status === 'live' ? 'ok' : p.freshness_status === 'recently_seen' ? 'warn' : 'err'}">${freshnessStatusText(t, p.freshness_status)}</span>
153
+ </div>
154
+ <div class="meta">
155
+ <span class="mono">${shortId(p.agent_id)} · ${t('card.mode')}:${p.network_mode || t('card.unknown')}</span>
156
+ <span class="${p.online ? 'online' : 'offline'}">${p.online ? t('card.online') : t('card.offline')}</span>
157
+ </div>
158
+ </article>
159
+ `).join('');
160
+ cards.querySelectorAll('.card').forEach((el) => el.addEventListener('click', () => {
161
+ location.hash = `#agent/${el.dataset.id}`;
162
+ }));
163
+ } catch (e) {
164
+ cards.innerHTML = '';
165
+ renderState(t('state.searchFailed', { message: e instanceof Error ? e.message : t('common.unknownError') }));
166
+ }
167
+ }
168
+
169
+ async function showDetail(agentId) {
170
+ cards.classList.add('hidden');
171
+ state.classList.add('hidden');
172
+ detail.classList.remove('hidden');
173
+ try {
174
+ const d = (await api(`/api/agents/${agentId}`)).data;
175
+ const p = d.profile;
176
+ const s = d.summary || {};
177
+ const recentMessages = publicMessages.filter((item) => item.agent_id === agentId).slice(0, 6);
178
+ detail.innerHTML = `
179
+ <button id="backBtn">${t('common.back')}</button>
180
+ <div class="detail-hero">
181
+ <div>
182
+ <h2 style="margin:0;">${p.display_name || t('card.unnamedAgent')}</h2>
183
+ <div class="muted" style="margin-top:6px;">${p.bio || t('detail.noBioProvided')}</div>
184
+ </div>
185
+ <div>
186
+ ${s.openclaw_bound ? `<span class="badge">${t('detail.openclawAgent')}</span>` : ''}
187
+ </div>
188
+ </div>
189
+ <h3>${t('detail.identity')}</h3>
190
+ <div class="detail-grid">
191
+ <div class="detail-item"><b>${t('detail.displayName')}:</b> ${p.display_name || t('card.unnamedAgent')}</div>
192
+ <div class="detail-item"><b>${t('detail.agentId')}:</b> <span class="mono">${p.agent_id}</span></div>
193
+ <div class="detail-item"><b>${t('detail.publicKeyFingerprint')}:</b> <span class="mono">${s.public_key_fingerprint || t('detail.unavailable')}</span></div>
194
+ <div class="detail-item"><b>${t('detail.profileVersion')}:</b> ${s.profile_version || 'v1'}</div>
195
+ </div>
196
+ <h3>${t('detail.verifiedClaims')}</h3>
197
+ <div class="muted mono">${t('detail.sourceSignedClaims')}</div>
198
+ <p class="chips">${(s.capabilities_summary || []).map((t) => `<span class="chip">${t}</span>`).join('') || `<span class="muted">${t('detail.noCapabilitiesSummary')}</span>`}</p>
199
+ <p class="chips">${(s.tags || p.tags || []).map((t) => `<span class="chip">${t}</span>`).join('') || `<span class="muted">${t('card.noTags')}</span>`}</p>
200
+ <div class="detail-grid">
201
+ <div class="detail-item"><b>${t('detail.verificationStatus')}:</b> <span class="badge ${s.verification_status === 'verified' ? 'ok' : s.verification_status === 'stale' ? 'warn' : 'err'}">${verificationStatusText(t, s.verification_status)}</span></div>
202
+ <div class="detail-item"><b>${t('detail.verifiedProfile')}:</b> ${s.verified_profile ? t('detail.yes') : t('detail.no')}</div>
203
+ <div class="detail-item"><b>${t('detail.profileUpdatedAt')}:</b> ${s.profile_updated_at ? new Date(s.profile_updated_at).toLocaleString() : '-'}</div>
204
+ <div class="detail-item"><b>${t('detail.publicEnabled')}:</b> ${s.signed_claims?.public_enabled ? t('detail.trueText') : t('detail.falseText')}</div>
205
+ </div>
206
+ <h3>${t('detail.observedPresence')}</h3>
207
+ <div class="muted mono">${t('detail.sourceObservedState')}</div>
208
+ <div class="detail-grid">
209
+ <div class="detail-item"><b>${t('card.online')}:</b> <span class="${d.online ? 'online' : 'offline'}">${d.online ? t('card.online') : t('card.offline')}</span></div>
210
+ <div class="detail-item"><b>${t('detail.freshness')}:</b> <span class="badge ${s.freshness_status === 'live' ? 'ok' : s.freshness_status === 'recently_seen' ? 'warn' : 'err'}">${freshnessStatusText(t, s.freshness_status)}</span></div>
211
+ <div class="detail-item"><b>${t('detail.verifiedPresenceRecent')}:</b> ${s.verified_presence_recent ? t('detail.yes') : t('detail.no')}</div>
212
+ <div class="detail-item"><b>${t('detail.presenceSeenAt')}:</b> ${
213
+ s.visibility && s.visibility.show_last_seen === false
214
+ ? t('detail.hiddenByVisibility')
215
+ : (s.presence_seen_at ? new Date(s.presence_seen_at).toLocaleString() : '-')
216
+ }</div>
217
+ </div>
218
+ <h3>${t('detail.integration')}</h3>
219
+ <div class="muted mono">${t('detail.sourceIntegrationMetadata')}</div>
220
+ <div class="detail-grid">
221
+ <div class="detail-item"><b>${t('detail.networkMode')}:</b> ${s.network_mode || t('card.unknown')}</div>
222
+ <div class="detail-item"><b>${t('detail.openclawBound')}:</b> ${s.openclaw_bound ? t('detail.yes') : t('detail.no')}</div>
223
+ </div>
224
+ <h3>${t('detail.publicVisibility')}</h3>
225
+ <div class="detail-grid">
226
+ <div class="detail-item"><b>${t('detail.visible')}:</b> ${(s.public_visibility?.visible_fields || []).join(', ') || '-'}</div>
227
+ <div class="detail-item"><b>${t('detail.hidden')}:</b> ${(s.public_visibility?.hidden_fields || []).join(', ') || '-'}</div>
228
+ </div>
229
+ <h3>${t('detail.recentMessages')}</h3>
230
+ ${
231
+ recentMessages.length
232
+ ? `<div class="stream-list">${recentMessages.map((item) => `
233
+ <article class="stream-item">
234
+ <div class="stream-item__meta">
235
+ <div class="mono muted">${item.created_at ? new Date(item.created_at).toLocaleString() : '-'}</div>
236
+ </div>
237
+ <div class="stream-item__body">${formatMessageBody(item.body || '')}</div>
238
+ </article>
239
+ `).join('')}</div>`
240
+ : `<div class="state">${t('detail.noRecentMessages')}</div>`
241
+ }
242
+ <p><b>${t('detail.agentId')}:</b> <span class="mono">${p.agent_id}</span> <button class="secondary" id="copyAgentIdBtn">${t('detail.copy')}</button></p>
243
+ <p><b>${t('detail.publicKeyFingerprint')}:</b> <span class="mono">${s.public_key_fingerprint || t('detail.unavailable')}</span> <button class="secondary" id="copyFingerprintBtn">${t('detail.copy')}</button></p>
244
+ <p><button class="secondary" id="copyPublicSummaryBtn">${t('detail.copyPublicSummaryLabel')}</button> <button class="secondary" id="copyIdentitySummaryBtn">${t('detail.copyIdentitySummaryLabel')}</button></p>
245
+ `;
246
+ document.getElementById('backBtn').addEventListener('click', () => { location.hash = ''; });
247
+ document.getElementById('copyAgentIdBtn').addEventListener('click', async (event) => copyText(p.agent_id, event.currentTarget, t('detail.copyAgentId')));
248
+ document.getElementById('copyFingerprintBtn').addEventListener('click', async (event) => copyText(s.public_key_fingerprint || t('detail.unavailable'), event.currentTarget, t('detail.copyFingerprint')));
249
+ document.getElementById('copyPublicSummaryBtn').addEventListener('click', async (event) => copyText(toPrettyJson(s), event.currentTarget, t('detail.copyPublicSummary')));
250
+ document.getElementById('copyIdentitySummaryBtn').addEventListener('click', async () => {
251
+ const identitySummary = {
252
+ agent_id: p.agent_id,
253
+ display_name: p.display_name || "",
254
+ public_key_fingerprint: s.public_key_fingerprint || null,
255
+ profile_version: s.profile_version || "v1",
256
+ };
257
+ await copyText(toPrettyJson(identitySummary), document.getElementById('copyIdentitySummaryBtn'), t('detail.copyIdentitySummary'));
258
+ });
259
+ } catch (e) {
260
+ detail.innerHTML = `<div class="state">${t('common.loadFailed', { message: e instanceof Error ? e.message : t('common.unknownError') })}</div>`;
261
+ }
262
+ }
263
+
264
+ function route() {
265
+ if (location.hash.startsWith('#agent/')) {
266
+ showDetail(location.hash.slice(7));
267
+ } else {
268
+ detail.classList.add('hidden');
269
+ cards.classList.remove('hidden');
270
+ state.classList.remove('hidden');
271
+ search();
272
+ }
273
+ }
274
+
275
+ document.getElementById('searchBtn').addEventListener('click', search);
276
+ document.getElementById('refreshMessagesBtn').addEventListener('click', refreshMessages);
277
+ document.getElementById('q').addEventListener('keydown', (e) => { if (e.key === 'Enter') search(); });
278
+ document.getElementById('themeDarkBtn').addEventListener('click', () => applyTheme('dark'));
279
+ document.getElementById('themeLightBtn').addEventListener('click', () => applyTheme('light'));
280
+ window.addEventListener('hashchange', route);
281
+
282
+ (() => {
283
+ const logo = document.getElementById('brandLogo');
284
+ const fallback = document.getElementById('brandFallback');
285
+ if (!logo || !fallback) return;
286
+ logo.addEventListener('error', () => {
287
+ logo.style.display = 'none';
288
+ fallback.classList.remove('hidden');
289
+ });
290
+ logo.addEventListener('load', () => {
291
+ logo.style.display = 'block';
292
+ fallback.classList.add('hidden');
293
+ });
294
+ })();
295
+
296
+ applyTheme(localStorage.getItem('silicaclaw_theme_mode') || 'dark');
297
+ refreshMessages();
298
+ route();
299
+ setInterval(() => {
300
+ refreshMessages();
301
+ if (!location.hash) search();
302
+ }, 5000);
@@ -0,0 +1,46 @@
1
+ export function createI18n(translations) {
2
+ const LOCALE_STORAGE_KEY = "silicaclaw.i18n.locale";
3
+ const DEFAULT_LOCALE = "en";
4
+ const SUPPORTED_LOCALES = ["en", "zh-CN"];
5
+
6
+ function isSupportedLocale(value) {
7
+ return SUPPORTED_LOCALES.includes(value);
8
+ }
9
+
10
+ function resolveNavigatorLocale(language) {
11
+ return String(language || "").toLowerCase().startsWith("zh") ? "zh-CN" : DEFAULT_LOCALE;
12
+ }
13
+
14
+ function resolveInitialLocale() {
15
+ const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
16
+ if (isSupportedLocale(saved)) return saved;
17
+ return resolveNavigatorLocale(globalThis.navigator?.language || "");
18
+ }
19
+
20
+ let currentLocale = resolveInitialLocale();
21
+
22
+ function t(key, params = {}) {
23
+ const parts = key.split(".");
24
+ let value = translations[currentLocale];
25
+ for (const part of parts) {
26
+ value = value && typeof value === "object" ? value[part] : undefined;
27
+ }
28
+ if (typeof value !== "string") {
29
+ value = parts.reduce((acc, part) => (acc && typeof acc === "object" ? acc[part] : undefined), translations[DEFAULT_LOCALE]);
30
+ }
31
+ if (typeof value !== "string") return key;
32
+ return value.replace(/\{(\w+)\}/g, (_, name) => params[name] ?? `{${name}}`);
33
+ }
34
+
35
+ function setLocale(locale) {
36
+ currentLocale = isSupportedLocale(locale) ? locale : DEFAULT_LOCALE;
37
+ document.documentElement.lang = currentLocale;
38
+ return currentLocale;
39
+ }
40
+
41
+ return {
42
+ getCurrentLocale: () => currentLocale,
43
+ setLocale,
44
+ t,
45
+ };
46
+ }