@agent-link/server 0.1.160 → 0.1.162

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.
@@ -25,6 +25,8 @@ export function createConnection(deps) {
25
25
  // Multi-session parallel
26
26
  currentConversationId, processingConversations, conversationCache,
27
27
  switchConversation,
28
+ // i18n
29
+ t,
28
30
  } = deps;
29
31
 
30
32
  // Dequeue callback — set after creation to resolve circular dependency
@@ -163,7 +165,7 @@ export function createConnection(deps) {
163
165
  const sid = getSessionId();
164
166
  if (!sid) {
165
167
  status.value = 'No Session';
166
- error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
168
+ error.value = t('error.noSessionId');
167
169
  return;
168
170
  }
169
171
  sessionId.value = sid;
@@ -194,9 +196,9 @@ export function createConnection(deps) {
194
196
  return;
195
197
  }
196
198
  if (parsed.type === 'auth_failed') {
197
- authError.value = parsed.message || 'Incorrect password.';
199
+ authError.value = parsed.message || t('error.incorrectPassword');
198
200
  authAttempts.value = parsed.attemptsRemaining != null
199
- ? `${parsed.attemptsRemaining} attempt${parsed.attemptsRemaining !== 1 ? 's' : ''} remaining`
201
+ ? t('error.attemptsRemaining', { n: parsed.attemptsRemaining })
200
202
  : null;
201
203
  authPassword.value = '';
202
204
  return;
@@ -204,7 +206,7 @@ export function createConnection(deps) {
204
206
  if (parsed.type === 'auth_locked') {
205
207
  authLocked.value = true;
206
208
  authRequired.value = false;
207
- authError.value = parsed.message || 'Too many failed attempts.';
209
+ authError.value = parsed.message || t('error.tooManyAttempts');
208
210
  status.value = 'Locked';
209
211
  return;
210
212
  }
@@ -283,7 +285,7 @@ export function createConnection(deps) {
283
285
  wsSend({ type: 'query_active_conversations' });
284
286
  } else {
285
287
  status.value = 'Waiting';
286
- error.value = 'Agent is not connected yet.';
288
+ error.value = t('error.agentNotConnected');
287
289
  }
288
290
  } else if (msg.type === 'pong') {
289
291
  if (typeof msg.ts === 'number') {
@@ -294,7 +296,7 @@ export function createConnection(deps) {
294
296
  status.value = 'Waiting';
295
297
  agentName.value = '';
296
298
  hostname.value = '';
297
- error.value = 'Agent disconnected. Waiting for reconnect...';
299
+ error.value = t('error.agentDisconnected');
298
300
  isProcessing.value = false;
299
301
  isCompacting.value = false;
300
302
  queuedMessages.value = [];
@@ -423,7 +425,7 @@ export function createConnection(deps) {
423
425
  isCompacting.value = true;
424
426
  messages.value.push({
425
427
  id: streaming.nextId(), role: 'system',
426
- content: 'Context compacting...', isCompactStart: true,
428
+ content: t('system.contextCompacting'), isCompactStart: true,
427
429
  timestamp: new Date(),
428
430
  });
429
431
  scrollToBottom();
@@ -432,7 +434,7 @@ export function createConnection(deps) {
432
434
  // Update the start message to show completed
433
435
  const startMsg = [...messages.value].reverse().find(m => m.isCompactStart && !m.compactDone);
434
436
  if (startMsg) {
435
- startMsg.content = 'Context compacted';
437
+ startMsg.content = t('system.contextCompacted');
436
438
  startMsg.compactDone = true;
437
439
  }
438
440
  scrollToBottom();
@@ -451,7 +453,7 @@ export function createConnection(deps) {
451
453
  needsResume.value = true;
452
454
  messages.value.push({
453
455
  id: streaming.nextId(), role: 'system',
454
- content: 'Generation stopped.', timestamp: new Date(),
456
+ content: t('system.generationStopped'), timestamp: new Date(),
455
457
  });
456
458
  scrollToBottom();
457
459
  }
@@ -507,20 +509,20 @@ export function createConnection(deps) {
507
509
  isProcessing.value = true;
508
510
  messages.value.push({
509
511
  id: streaming.nextId(), role: 'system',
510
- content: 'Context compacting...', isCompactStart: true,
512
+ content: t('system.contextCompacting'), isCompactStart: true,
511
513
  timestamp: new Date(),
512
514
  });
513
515
  } else if (msg.isProcessing) {
514
516
  isProcessing.value = true;
515
517
  messages.value.push({
516
518
  id: streaming.nextId(), role: 'system',
517
- content: 'Agent is processing...',
519
+ content: t('system.agentProcessing'),
518
520
  timestamp: new Date(),
519
521
  });
520
522
  } else {
521
523
  messages.value.push({
522
524
  id: streaming.nextId(), role: 'system',
523
- content: 'Session restored. You can continue the conversation.',
525
+ content: t('system.sessionRestored'),
524
526
  timestamp: new Date(),
525
527
  });
526
528
  }
@@ -564,7 +566,7 @@ export function createConnection(deps) {
564
566
  }
565
567
  messages.value.push({
566
568
  id: streaming.nextId(), role: 'system',
567
- content: 'Working directory changed to: ' + msg.workDir,
569
+ content: t('system.workdirChanged', { dir: msg.workDir }),
568
570
  timestamp: new Date(),
569
571
  });
570
572
  // Clear old history immediately so UI doesn't show stale data
@@ -601,13 +603,13 @@ export function createConnection(deps) {
601
603
  function scheduleReconnect(scheduleHighlight) {
602
604
  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
603
605
  status.value = 'Disconnected';
604
- error.value = 'Unable to reconnect. Please refresh the page.';
606
+ error.value = t('error.unableToReconnect');
605
607
  return;
606
608
  }
607
609
  const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
608
610
  reconnectAttempts++;
609
611
  status.value = 'Reconnecting...';
610
- error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
612
+ error.value = t('error.connectionLost', { n: reconnectAttempts });
611
613
  if (reconnectTimer) clearTimeout(reconnectTimer);
612
614
  reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
613
615
  }
@@ -0,0 +1,101 @@
1
+ // ── Lightweight i18n module ─────────────────────────────────────────────────
2
+ const { ref, computed } = Vue;
3
+
4
+ /**
5
+ * Creates i18n functionality: t() translator, locale switching, persistence.
6
+ * Locale data is loaded dynamically from /locales/<lang>.json.
7
+ *
8
+ * @returns {{ t: Function, locale: import('vue').Ref<string>, setLocale: Function }}
9
+ */
10
+ export function createI18n() {
11
+ const STORAGE_KEY = 'agentlink-language';
12
+ const SUPPORTED = ['en', 'zh'];
13
+ const DEFAULT_LOCALE = 'en';
14
+
15
+ // Detect initial locale
16
+ function detectLocale() {
17
+ // 1. Explicit user choice
18
+ const stored = localStorage.getItem(STORAGE_KEY);
19
+ if (stored && SUPPORTED.includes(stored)) return stored;
20
+
21
+ // 2. Browser preference
22
+ const nav = (navigator.language || '').toLowerCase();
23
+ if (nav.startsWith('zh')) return 'zh';
24
+
25
+ // 3. Default
26
+ return DEFAULT_LOCALE;
27
+ }
28
+
29
+ const locale = ref(detectLocale());
30
+ const _messages = ref({});
31
+ let _loadedLocale = null;
32
+
33
+ // Load locale JSON
34
+ async function loadMessages(lang) {
35
+ if (_loadedLocale === lang && Object.keys(_messages.value).length > 0) return;
36
+ try {
37
+ const resp = await fetch(`/locales/${lang}.json`);
38
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
39
+ _messages.value = await resp.json();
40
+ _loadedLocale = lang;
41
+ } catch (e) {
42
+ console.warn(`[i18n] Failed to load locale "${lang}":`, e);
43
+ // Fallback: try loading English
44
+ if (lang !== DEFAULT_LOCALE) {
45
+ try {
46
+ const resp = await fetch(`/locales/${DEFAULT_LOCALE}.json`);
47
+ if (resp.ok) {
48
+ _messages.value = await resp.json();
49
+ _loadedLocale = DEFAULT_LOCALE;
50
+ }
51
+ } catch { /* give up */ }
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Translate a key, with optional parameter substitution.
58
+ * Returns the key itself if no translation is found (fallback).
59
+ *
60
+ * @param {string} key - Dot-notation key, e.g. "button.send"
61
+ * @param {object} [params] - Substitution params, e.g. { n: 5 }
62
+ * @returns {string}
63
+ */
64
+ function t(key, params) {
65
+ let str = _messages.value[key];
66
+ if (str === undefined) return key;
67
+ if (params) {
68
+ for (const [k, v] of Object.entries(params)) {
69
+ str = str.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
70
+ }
71
+ }
72
+ return str;
73
+ }
74
+
75
+ /**
76
+ * Switch locale, persist choice, and reload strings.
77
+ * @param {string} lang
78
+ */
79
+ async function setLocale(lang) {
80
+ if (!SUPPORTED.includes(lang)) return;
81
+ locale.value = lang;
82
+ localStorage.setItem(STORAGE_KEY, lang);
83
+ await loadMessages(lang);
84
+ }
85
+
86
+ /**
87
+ * Toggle between supported locales (EN ↔ 中).
88
+ */
89
+ async function toggleLocale() {
90
+ const next = locale.value === 'en' ? 'zh' : 'en';
91
+ await setLocale(next);
92
+ }
93
+
94
+ // The display label for the language switcher button
95
+ const localeLabel = computed(() => locale.value === 'en' ? 'EN' : '中');
96
+
97
+ // Load initial messages
98
+ loadMessages(locale.value);
99
+
100
+ return { t, locale, setLocale, toggleLocale, localeLabel };
101
+ }
@@ -8,15 +8,15 @@ export function isContextSummary(text) {
8
8
  return typeof text === 'string' && text.trimStart().startsWith(CONTEXT_SUMMARY_PREFIX);
9
9
  }
10
10
 
11
- export function formatRelativeTime(ts) {
11
+ export function formatRelativeTime(ts, t) {
12
12
  const diff = Date.now() - ts;
13
13
  const mins = Math.floor(diff / 60000);
14
- if (mins < 1) return 'just now';
15
- if (mins < 60) return `${mins}m ago`;
14
+ if (mins < 1) return t ? t('time.justNow') : 'just now';
15
+ if (mins < 60) return t ? t('time.minutesAgo', { n: mins }) : `${mins}m ago`;
16
16
  const hours = Math.floor(mins / 60);
17
- if (hours < 24) return `${hours}h ago`;
17
+ if (hours < 24) return t ? t('time.hoursAgo', { n: hours }) : `${hours}h ago`;
18
18
  const days = Math.floor(hours / 24);
19
- if (days < 30) return `${days}d ago`;
19
+ if (days < 30) return t ? t('time.daysAgo', { n: days }) : `${days}d ago`;
20
20
  return new Date(ts).toLocaleDateString();
21
21
  }
22
22
 
@@ -53,7 +53,7 @@ export function toggleTool(msg) {
53
53
  msg.expanded = !msg.expanded;
54
54
  }
55
55
 
56
- export function getToolSummary(msg) {
56
+ export function getToolSummary(msg, t) {
57
57
  const name = msg.toolName;
58
58
  const input = msg.toolInput;
59
59
  try {
@@ -65,8 +65,8 @@ export function getToolSummary(msg) {
65
65
  if (name === 'Glob' && obj.pattern) return obj.pattern;
66
66
  if (name === 'Grep' && obj.pattern) return obj.pattern;
67
67
  if (name === 'TodoWrite' && obj.todos) {
68
- const done = obj.todos.filter(t => t.status === 'completed').length;
69
- return `${done}/${obj.todos.length} done`;
68
+ const doneCount = obj.todos.filter(td => td.status === 'completed').length;
69
+ return t ? t('tool.done', { done: doneCount, total: obj.todos.length }) : `${doneCount}/${obj.todos.length} done`;
70
70
  }
71
71
  if (name === 'Task' && obj.description) return obj.description;
72
72
  if (name === 'Agent' && obj.description) return obj.description;
@@ -81,7 +81,7 @@ export function isEditTool(msg) {
81
81
  return msg.role === 'tool' && msg.toolName === 'Edit' && msg.toolInput;
82
82
  }
83
83
 
84
- export function getFormattedToolInput(msg) {
84
+ export function getFormattedToolInput(msg, t) {
85
85
  if (!msg.toolInput) return null;
86
86
  try {
87
87
  const obj = JSON.parse(msg.toolInput);
@@ -91,18 +91,22 @@ export function getFormattedToolInput(msg) {
91
91
  if (name === 'Read' && obj.file_path) {
92
92
  let detail = esc(obj.file_path);
93
93
  if (obj.offset && obj.limit) {
94
- detail += ` <span class="tool-input-meta">lines ${obj.offset}\u2013${obj.offset + obj.limit - 1}</span>`;
94
+ const meta = t ? t('tool.lines', { start: obj.offset, end: obj.offset + obj.limit - 1 }) : `lines ${obj.offset}\u2013${obj.offset + obj.limit - 1}`;
95
+ detail += ` <span class="tool-input-meta">${meta}</span>`;
95
96
  } else if (obj.offset) {
96
- detail += ` <span class="tool-input-meta">from line ${obj.offset}</span>`;
97
+ const meta = t ? t('tool.fromLine', { offset: obj.offset }) : `from line ${obj.offset}`;
98
+ detail += ` <span class="tool-input-meta">${meta}</span>`;
97
99
  } else if (obj.limit) {
98
- detail += ` <span class="tool-input-meta">first ${obj.limit} lines</span>`;
100
+ const meta = t ? t('tool.firstLines', { limit: obj.limit }) : `first ${obj.limit} lines`;
101
+ detail += ` <span class="tool-input-meta">${meta}</span>`;
99
102
  }
100
103
  return detail;
101
104
  }
102
105
 
103
106
  if (name === 'Write' && obj.file_path) {
104
107
  const lines = (obj.content || '').split('\n').length;
105
- return esc(obj.file_path) + ` <span class="tool-input-meta">${lines} lines</span>`;
108
+ const lineCount = t ? t('tool.lineCount', { n: lines }) : `${lines} lines`;
109
+ return esc(obj.file_path) + ` <span class="tool-input-meta">${lineCount}</span>`;
106
110
  }
107
111
 
108
112
  if (name === 'Bash' && obj.command) {
@@ -113,13 +117,13 @@ export function getFormattedToolInput(msg) {
113
117
 
114
118
  if (name === 'Glob' && obj.pattern) {
115
119
  let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
116
- if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
120
+ if (obj.path) html += ' <span class="tool-input-meta">' + (t ? t('tool.inPath', { path: esc(obj.path) }) : 'in ' + esc(obj.path)) + '</span>';
117
121
  return html;
118
122
  }
119
123
 
120
124
  if (name === 'Grep' && obj.pattern) {
121
125
  let html = '<code class="tool-input-cmd">' + esc(obj.pattern) + '</code>';
122
- if (obj.path) html += ' <span class="tool-input-meta">in ' + esc(obj.path) + '</span>';
126
+ if (obj.path) html += ' <span class="tool-input-meta">' + (t ? t('tool.inPath', { path: esc(obj.path) }) : 'in ' + esc(obj.path)) + '</span>';
123
127
  return html;
124
128
  }
125
129
 
@@ -139,10 +143,13 @@ export function getFormattedToolInput(msg) {
139
143
 
140
144
  if (name === 'Task' || name === 'Agent') {
141
145
  let html = '';
142
- if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">Description</span> ' + esc(obj.description) + '</div>';
143
- if (obj.subagent_type) html += '<div class="task-field"><span class="tool-input-meta">Agent</span> <code class="tool-input-cmd">' + esc(obj.subagent_type) + '</code></div>';
146
+ const descLabel = t ? t('tool.description') : 'Description';
147
+ const agentLabel = t ? t('tool.agent') : 'Agent';
148
+ const promptLabel = t ? t('tool.prompt') : 'Prompt';
149
+ if (obj.description) html += '<div class="task-field"><span class="tool-input-meta">' + descLabel + '</span> ' + esc(obj.description) + '</div>';
150
+ if (obj.subagent_type) html += '<div class="task-field"><span class="tool-input-meta">' + agentLabel + '</span> <code class="tool-input-cmd">' + esc(obj.subagent_type) + '</code></div>';
144
151
  if (obj.prompt) {
145
- html += '<div class="task-field"><span class="tool-input-meta">Prompt</span></div><div class="task-prompt">' + esc(obj.prompt) + '</div>';
152
+ html += '<div class="task-field"><span class="tool-input-meta">' + promptLabel + '</span></div><div class="task-prompt">' + esc(obj.prompt) + '</div>';
146
153
  }
147
154
  if (html) return html;
148
155
  }
@@ -161,7 +168,7 @@ export function getFormattedToolInput(msg) {
161
168
  return null;
162
169
  }
163
170
 
164
- export function getEditDiffHtml(msg) {
171
+ export function getEditDiffHtml(msg, t) {
165
172
  try {
166
173
  const obj = JSON.parse(msg.toolInput);
167
174
  if (!obj.old_string && !obj.new_string) return null;
@@ -171,7 +178,7 @@ export function getEditDiffHtml(msg) {
171
178
  const newLines = (obj.new_string || '').split('\n');
172
179
  let html = '';
173
180
  if (filePath) {
174
- html += '<div class="diff-file">' + esc(filePath) + (obj.replace_all ? ' <span class="diff-replace-all">(replace all)</span>' : '') + '</div>';
181
+ html += '<div class="diff-file">' + esc(filePath) + (obj.replace_all ? ' <span class="diff-replace-all">' + (t ? t('tool.replaceAll') : '(replace all)') + '</span>' : '') + '</div>';
175
182
  }
176
183
  html += '<div class="diff-lines">';
177
184
  for (const line of oldLines) {
@@ -35,6 +35,8 @@ export function createSidebar(deps) {
35
35
  // Multi-session parallel
36
36
  currentConversationId, conversationCache, processingConversations,
37
37
  switchConversation,
38
+ // i18n
39
+ t,
38
40
  } = deps;
39
41
 
40
42
  // Late-binding callback: called when user switches to a normal chat session
@@ -130,7 +132,7 @@ export function createSidebar(deps) {
130
132
  switchConversation(newConvId);
131
133
  messages.value.push({
132
134
  id: streaming.nextId(), role: 'system',
133
- content: 'New conversation started.',
135
+ content: t('system.newConversation'),
134
136
  timestamp: new Date(),
135
137
  });
136
138
  return;
@@ -365,18 +367,25 @@ export function createSidebar(deps) {
365
367
  const yesterdayStart = todayStart - 86400000;
366
368
  const weekStart = todayStart - 6 * 86400000;
367
369
 
370
+ const GROUP_KEYS = {
371
+ today: 'session.today',
372
+ yesterday: 'session.yesterday',
373
+ thisWeek: 'session.thisWeek',
374
+ earlier: 'session.earlier',
375
+ };
376
+
368
377
  const groups = {};
369
378
  for (const s of historySessions.value) {
370
- let label;
371
- if (s.lastModified >= todayStart) label = 'Today';
372
- else if (s.lastModified >= yesterdayStart) label = 'Yesterday';
373
- else if (s.lastModified >= weekStart) label = 'This week';
374
- else label = 'Earlier';
375
- if (!groups[label]) groups[label] = [];
376
- groups[label].push(s);
379
+ let key;
380
+ if (s.lastModified >= todayStart) key = 'today';
381
+ else if (s.lastModified >= yesterdayStart) key = 'yesterday';
382
+ else if (s.lastModified >= weekStart) key = 'thisWeek';
383
+ else key = 'earlier';
384
+ if (!groups[key]) groups[key] = [];
385
+ groups[key].push(s);
377
386
  }
378
- const order = ['Today', 'Yesterday', 'This week', 'Earlier'];
379
- return order.filter(k => groups[k]).map(k => ({ label: k, sessions: groups[k] }));
387
+ const order = ['today', 'yesterday', 'thisWeek', 'earlier'];
388
+ return order.filter(k => groups[k]).map(k => ({ label: t(GROUP_KEYS[k]), sessions: groups[k] }));
380
389
  });
381
390
 
382
391
  return {