made-bridge 0.0.0-stage → 0.2.0

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/builtin/plugins/README.md +22 -0
  4. package/builtin/themes/README.md +64 -0
  5. package/builtin/themes/sunset/theme.json +19 -0
  6. package/config.example.json +20 -0
  7. package/demo-project/README.md +5 -0
  8. package/demo-project/index.mjs +14 -0
  9. package/package.json +48 -4
  10. package/public/app.js +1028 -0
  11. package/public/assistants.html +67 -0
  12. package/public/bean-icon.png +0 -0
  13. package/public/i18n-messages.js +967 -0
  14. package/public/i18n-static.js +615 -0
  15. package/public/i18n.js +50 -0
  16. package/public/index.html +149 -0
  17. package/public/made-icon.png +0 -0
  18. package/public/plugins.html +64 -0
  19. package/public/plugins.js +335 -0
  20. package/public/provider-icon.js +77 -0
  21. package/public/styles.css +445 -0
  22. package/public/tasks.html +85 -0
  23. package/src/agent-icons.mjs +138 -0
  24. package/src/agents/codex.mjs +94 -0
  25. package/src/agents/cursor.mjs +31 -0
  26. package/src/agents/events.mjs +92 -0
  27. package/src/agents/index.mjs +7 -0
  28. package/src/agents/qoder.mjs +34 -0
  29. package/src/agents/shared.mjs +191 -0
  30. package/src/agents/workbuddy.mjs +121 -0
  31. package/src/board-link.mjs +266 -0
  32. package/src/config.mjs +196 -0
  33. package/src/doctor.mjs +146 -0
  34. package/src/i18n.mjs +257 -0
  35. package/src/installed.mjs +291 -0
  36. package/src/jobs.mjs +369 -0
  37. package/src/language-settings.mjs +38 -0
  38. package/src/model-settings.mjs +63 -0
  39. package/src/openai-key.mjs +47 -0
  40. package/src/oray.mjs +243 -0
  41. package/src/pairing.mjs +203 -0
  42. package/src/plugin-package.mjs +132 -0
  43. package/src/plugin-runtime.mjs +169 -0
  44. package/src/plugins/contracts.d.ts +184 -0
  45. package/src/plugins/enhance.mjs +156 -0
  46. package/src/plugins/registry.mjs +146 -0
  47. package/src/providers.mjs +75 -0
  48. package/src/server.mjs +939 -0
  49. package/src/sessions.mjs +134 -0
  50. package/src/speech/index.mjs +10 -0
  51. package/src/speech/openai.mjs +48 -0
  52. package/src/speech/whisper.mjs +59 -0
  53. package/src/speech-settings.mjs +209 -0
  54. package/src/transcribe.mjs +85 -0
  55. package/src/tunnel.mjs +531 -0
  56. package/src/usb-receiver.mjs +664 -0
  57. package/src/voice-settings.mjs +69 -0
package/public/app.js ADDED
@@ -0,0 +1,1028 @@
1
+ import { renderProviderIcon, clearProviderIcon } from './provider-icon.js';
2
+ import { t, m, getLocale, setLocale, supportedLocales, applyStaticTranslations, setUiMessage, refreshUiMessages, formatDateTime } from './i18n.js';
3
+
4
+ (() => {
5
+ 'use strict';
6
+ const $ = (id) => document.getElementById(id);
7
+ const taskPage = document.body.dataset.page === 'tasks';
8
+ const assistantsPage = document.body.dataset.page === 'assistants';
9
+ const state = { jobs: [], sessions: [], providers: [], projects: [], connection: {}, voice: {}, models: {}, pairing: { open: false, pending: [], paired: [] }, tunnel: { status: 'off' }, receiver: { status: 'disabled' }, receiverPasswordVisible: false, token: null, lastSyncedAt: null, loaded: false, error: '', busy: new Set(), pairBusy: false, pairWindowBusy: false, tunnelBusy: false, dismissedPairing: new Set(), selectedSessionId: null, previewProvider: '', previewSessionId: null, sessionFormOpen: false, legacySessionsOpen: false, renamingSession: false, refreshing: false };
10
+ if (taskPage) {
11
+ try { state.selectedSessionId = sessionStorage.getItem('vibe-selected-session'); } catch { /* Selection works without storage. */ }
12
+ const params = new URLSearchParams(window.location.search);
13
+ state.sessionFormOpen = params.get('newSession') === '1';
14
+ state.newSessionProvider = params.get('provider') || '';
15
+ }
16
+ if ($('boardScreen')) {
17
+ try {
18
+ const saved = JSON.parse(sessionStorage.getItem('vibe-preview-job') || 'null');
19
+ if (saved && typeof saved.provider === 'string') {
20
+ state.previewProvider = saved.provider;
21
+ state.previewSessionId = typeof saved.sessionId === 'string' ? saved.sessionId : null;
22
+ }
23
+ sessionStorage.removeItem('vibe-preview-job');
24
+ } catch { /* The preview works without a saved selection. */ }
25
+ }
26
+ const statusMap = () => ({
27
+ waiting_confirmation: { label: t('app.001'), css: 'status-waiting', mark: '?' },
28
+ queued: { label: t('app.002'), css: 'status-active', mark: '→' },
29
+ running: { label: t('app.003'), css: 'status-active', mark: '↗' },
30
+ completed: { label: t('app.004'), css: 'status-completed', mark: '✓' },
31
+ handed_off: { label: t('app.005'), css: 'status-handed', mark: '↗' },
32
+ failed: { label: t('app.006'), css: 'status-failed', mark: '!' },
33
+ cancelled: { label: t('app.007'), css: 'status-cancelled', mark: '×' }
34
+ });
35
+ const text = (tag, className, value) => { const node = document.createElement(tag); if (className) node.className = className; node.textContent = value ?? ''; return node; };
36
+ const safeString = (value) => value == null ? '' : String(value);
37
+ const defaultDeviceNames = new Set(['码得', '码达', 'made', '码豆', 'ESP32-S3 Round Display', 'ESP32-S3 \u5706\u5c4f']);
38
+ const deviceDisplayName = (value, fallback = t('app.008')) => {
39
+ const name = safeString(value);
40
+ return defaultDeviceNames.has(name) ? t('app.008') : name || fallback;
41
+ };
42
+ const jobStatus = (job) => job.provider === 'codex' && job.retryPending ?
43
+ { label: t('app.009'), css: 'status-waiting', mark: '?' } :
44
+ statusMap()[job.status] || { label: safeString(job.status) || t('app.010'), css: 'status-cancelled', mark: '·' };
45
+ function userFacingJobError(job) {
46
+ if (job.provider === 'codex' && job.retryPending) return t('app.011');
47
+ const clean = safeString(job.error).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\r/g, '\n');
48
+ return clean.split('\n').map((line) => line.trim()).find((line) => line && !/^at\s+/.test(line))?.slice(0, 240) || '';
49
+ }
50
+ const providerById = (id) => state.providers.find((item) => item.id === id);
51
+ const unscopedProvider = (id) => providerById(id)?.capabilities?.session === 'external_unscoped';
52
+ const providerName = (id) => state.providers.find((item) => item.id === id)?.label || safeString(id) || t('app.012');
53
+ const projectName = (id) => state.projects.find((item) => item.id === id)?.label || safeString(id) || t('app.013');
54
+ const projectPath = (id) => state.projects.find((item) => item.id === id)?.path || '';
55
+ const sessionById = (id) => state.sessions.find((item) => item.id === id);
56
+ const selectedSession = () => sessionById(state.selectedSessionId);
57
+ const jobsForSession = (id) => state.jobs.filter((job) => job.vibeSessionId === id);
58
+ const latestJobForSession = (id) => jobsForSession(id).sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
59
+ const sessionContextLabel = (session) => unscopedProvider(session.provider) ?
60
+ t('app.014') :
61
+ session.contextMode === 'native' ? t('app.015') :
62
+ t('app.016');
63
+ const sessionId = (job) => /^[A-Za-z0-9_-]{6,128}$/.test(safeString(job.sessionId)) ? job.sessionId : '';
64
+ const canConfirm = (job) => job.status === 'waiting_confirmation';
65
+ const canCancel = (job) => ['waiting_confirmation', 'queued', 'running'].includes(job.status);
66
+ const timeText = (value) => { const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat(getLocale(), { hour: '2-digit', minute: '2-digit' }).format(date); };
67
+ const setFormMessage = (message, success = false) => { setUiMessage($('formMessage'), message); $('formMessage').classList.toggle('success', success); };
68
+ let toastTimer;
69
+ function showToast(message, error = false) { const toast = $('toast'); setUiMessage(toast, message); toast.classList.toggle('error', error); toast.classList.add('visible'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('visible'), 4200); }
70
+
71
+ async function request(url, options = {}) {
72
+ const response = await fetch(url, { cache: 'no-store', ...options });
73
+ let data;
74
+ try { data = await response.json(); } catch { data = {}; }
75
+ if (!response.ok) throw new Error(data.error || data.message || t('app.017', { v0: (response.status) }));
76
+ return data;
77
+ }
78
+ async function sessionToken() {
79
+ if (state.token) return state.token;
80
+ const session = await request('/api/session');
81
+ if (!session.token) throw new Error(t('app.018'));
82
+ state.token = session.token;
83
+ return state.token;
84
+ }
85
+ async function post(url, body) {
86
+ const token = await sessionToken();
87
+ try {
88
+ return await request(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Vibe-Token': token }, body: JSON.stringify(body || {}) });
89
+ } catch (error) {
90
+ if (String(error.message).includes('401') || String(error.message).includes('403')) state.token = null;
91
+ throw error;
92
+ }
93
+ }
94
+ let languageBusy = false;
95
+ let languageEpoch = 0;
96
+ async function refresh() {
97
+ if (state.refreshing) return;
98
+ state.refreshing = true;
99
+ const refreshEpoch = languageEpoch;
100
+ try {
101
+ const data = await request('/api/state');
102
+ state.jobs = Array.isArray(data.jobs) ? data.jobs : [];
103
+ state.sessions = Array.isArray(data.sessions) ? data.sessions : [];
104
+ if (state.selectedSessionId && !state.sessions.some((item) => item.id === state.selectedSessionId)) state.selectedSessionId = null;
105
+ if (!state.selectedSessionId) state.selectedSessionId = state.sessions.find((item) => !item.legacy)?.id || null;
106
+ if (!state.loaded && sessionById(state.selectedSessionId)?.legacy) state.legacySessionsOpen = true;
107
+ if (taskPage && state.selectedSessionId) {
108
+ try { sessionStorage.setItem('vibe-selected-session', state.selectedSessionId); } catch { /* Volatile selection is fine. */ }
109
+ }
110
+ state.providers = Array.isArray(data.providers) ? data.providers : [];
111
+ state.projects = Array.isArray(data.projects) ? data.projects : [];
112
+ state.connection = data.connection && typeof data.connection === 'object' ? data.connection : {};
113
+ state.voice = data.voice && typeof data.voice === 'object' ? data.voice : {};
114
+ state.models = data.models && typeof data.models === 'object' ? data.models : {};
115
+ state.pairing = data.pairing && typeof data.pairing === 'object' ? data.pairing : { open: false, pending: [], paired: [] };
116
+ state.tunnel = data.tunnel && typeof data.tunnel === 'object' ? data.tunnel : { status: 'off' };
117
+ state.receiver = data.receiver && typeof data.receiver === 'object' ? data.receiver : { status: 'disabled' };
118
+ state.loaded = true;
119
+ state.error = '';
120
+ if (!languageBusy && refreshEpoch === languageEpoch && supportedLocales.includes(data.locale)) applyLanguage(data.locale);
121
+ state.lastSyncedAt = Date.now();
122
+ } catch (error) { state.error = error.message || t('app.020'); }
123
+ finally { state.refreshing = false; render(); }
124
+ }
125
+ async function act(job, action) {
126
+ if (state.busy.has(job.id)) return;
127
+ state.busy.add(job.id); render();
128
+ try {
129
+ await post(`/api/jobs/${encodeURIComponent(job.id)}/${action}`);
130
+ showToast(action === 'confirm' ?
131
+ job.retryPending ? t('app.021') : t('app.022') :
132
+ t('app.023'));
133
+ await refresh();
134
+ } catch (error) { showToast(error.message || t('app.024'), true); }
135
+ finally { state.busy.delete(job.id); render(); }
136
+ }
137
+ function actionButton(job, action, compact = false) {
138
+ const isConfirm = action === 'confirm';
139
+ const label = isConfirm ? (job.retryPending ? t('app.025') : t('app.026')) : t('app.027');
140
+ const button = text('button', compact ? `board-button ${isConfirm ? 'confirm' : ''}` : `small-button ${isConfirm ? 'confirm' : ''}`, compact ? (isConfirm ? t('app.028') : t('app.029')) : label);
141
+ button.type = 'button'; button.disabled = state.busy.has(job.id);
142
+ button.addEventListener('click', () => act(job, action));
143
+ return button;
144
+ }
145
+ async function copyText(value, label) {
146
+ try {
147
+ if (!navigator.clipboard?.writeText) throw new Error(t('app.030'));
148
+ await navigator.clipboard.writeText(value);
149
+ showToast(t('app.031', { v0: (label) }));
150
+ } catch { showToast(t('app.032'), true); }
151
+ }
152
+ function copyLine(value, label) {
153
+ const line = text('div', 'handoff-line');
154
+ const code = text('code', '', value);
155
+ const button = text('button', 'small-button', t('app.033', { v0: (label) })); button.type = 'button';
156
+ button.addEventListener('click', () => copyText(value, label));
157
+ line.append(code, button);
158
+ return line;
159
+ }
160
+ function handoffPanel(job) {
161
+ const id = sessionId(job);
162
+ if (!id || !['codex', 'cursor'].includes(job.provider)) return null;
163
+ const panel = text('div', 'job-handoff');
164
+ if (job.provider === 'codex') {
165
+ const link = text('a', 'small-button handoff-open', t('app.034'));
166
+ link.href = `codex://threads/${encodeURIComponent(id)}`;
167
+ panel.append(link, copyLine(`codex resume --include-non-interactive ${id}`, t('app.035')),
168
+ text('span', 'handoff-note', t('app.036')));
169
+ } else {
170
+ panel.append(copyLine(`cursor-agent --resume ${id}`, t('app.035')));
171
+ const path = projectPath(job.projectId);
172
+ if (path) panel.append(copyLine(path, t('app.037')));
173
+ panel.append(text('span', 'handoff-note', t('app.038')));
174
+ }
175
+ return panel;
176
+ }
177
+ function renderSelects() {
178
+ if (!$('provider')) return;
179
+ const provider = $('provider'), project = $('project');
180
+ const currentProvider = provider.value, currentProject = project.value;
181
+ provider.replaceChildren(); project.replaceChildren();
182
+ const available = state.providers.filter((item) => item.available);
183
+ if (!available.length) provider.append(new Option(state.loaded ? t('app.039') : t('app.040'), ''));
184
+ else available.forEach((item) => provider.append(new Option(item.label || item.id, item.id)));
185
+ if (!state.projects.length) project.append(new Option(state.loaded ? t('app.041') : t('app.040'), ''));
186
+ else state.projects.forEach((item) => project.append(new Option(item.label || item.id, item.id)));
187
+ if (state.newSessionProvider && [...provider.options].some((option) => option.value === state.newSessionProvider)) {
188
+ provider.value = state.newSessionProvider;
189
+ state.newSessionProvider = '';
190
+ } else if ([...provider.options].some((option) => option.value === currentProvider)) provider.value = currentProvider;
191
+ if ([...project.options].some((option) => option.value === currentProject)) project.value = currentProject;
192
+ $('createSessionButton').disabled = !available.length || !state.projects.length || !!state.error || state.busy.has('new-session');
193
+ $('createButton').disabled = !selectedSession() || !!state.error || state.busy.has('new-turn');
194
+ const notice = $('sessionContextNotice');
195
+ if (notice) notice.textContent = unscopedProvider(provider.value) ?
196
+ t('app.042', { v0: (providerName(provider.value)) }) :
197
+ providerById(provider.value)?.capabilities?.session === 'native' ? t('app.043') : t('app.044');
198
+ }
199
+ function appendTaskCard(list, session, historical = false) {
200
+ const button = text('button', `session-item ${historical ? 'legacy-session-item' : ''} ${session.id === state.selectedSessionId ? 'active' : ''}`);
201
+ button.type = 'button';
202
+ button.setAttribute('aria-current', session.id === state.selectedSessionId ? 'true' : 'false');
203
+ button.append(text('strong', 'session-item-title', session.title || t('app.045')));
204
+ const latest = latestJobForSession(session.id);
205
+ button.append(text('span', 'session-item-meta', `${providerName(session.provider)} · ${projectName(session.projectId)}`));
206
+ button.append(text('span', 'session-item-updated', t('app.048', { v0: (latest ? jobStatus(latest).label : t('app.046')), v1: (timeText(session.updatedAt) || t('app.047')) })));
207
+ button.addEventListener('click', () => {
208
+ state.selectedSessionId = session.id;
209
+ state.renamingSession = false;
210
+ try { sessionStorage.setItem('vibe-selected-session', session.id); } catch { /* Volatile selection is fine. */ }
211
+ render();
212
+ });
213
+ list.append(button);
214
+ }
215
+ function renderSessions() {
216
+ if (!$('sessionList')) return;
217
+ const current = state.sessions.filter((session) => !session.legacy);
218
+ const historical = state.sessions.filter((session) => session.legacy);
219
+ const open = state.sessionFormOpen || (state.loaded && !current.length);
220
+ $('sessionForm').hidden = !open;
221
+ $('newSessionButton').hidden = state.loaded && !current.length;
222
+ $('newSessionButton').setAttribute('aria-expanded', String(open));
223
+ $('newSessionButton').textContent = open ? t('app.049') : t('app.050');
224
+ const list = $('sessionList'); list.replaceChildren();
225
+ if (state.error) {
226
+ list.append(text('p', 'session-list-empty', t('app.051')));
227
+ } else if (!current.length) {
228
+ list.append(text('p', 'session-list-empty', state.loaded ?
229
+ historical.length ? t('app.052') : t('app.053') :
230
+ t('app.054')));
231
+ } else {
232
+ current.forEach((session) => appendTaskCard(list, session));
233
+ }
234
+ const legacySection = $('legacySessions');
235
+ const legacyList = $('legacySessionList');
236
+ legacySection.hidden = !historical.length || !!state.error;
237
+ $('legacySessionToggle').textContent = t('app.055', { v0: (historical.length) });
238
+ $('legacySessionToggle').setAttribute('aria-expanded', String(state.legacySessionsOpen));
239
+ legacyList.hidden = !state.legacySessionsOpen;
240
+ legacyList.replaceChildren();
241
+ if (state.legacySessionsOpen) historical.forEach((session) => appendTaskCard(legacyList, session, true));
242
+ const selected = selectedSession();
243
+ $('currentSessionTitle').textContent = selected?.title || t('app.056');
244
+ $('currentSessionMeta').textContent = selected ?
245
+ `${providerName(selected.provider)} · ${projectName(selected.projectId)} · ${sessionContextLabel(selected)}` :
246
+ t('app.057');
247
+ $('currentSessionWarning').hidden = !unscopedProvider(selected?.provider);
248
+ $('currentSessionWarning').textContent = unscopedProvider(selected?.provider) ?
249
+ t('app.058', { v0: (providerName(selected.provider)) }) : '';
250
+ $('renameSessionButton').hidden = !selected;
251
+ $('renameSessionForm').hidden = !selected || !state.renamingSession;
252
+ $('renameSessionButton').disabled = !!state.error || state.busy.has('rename-session');
253
+ $('renameSessionSubmit').disabled = !!state.error || state.busy.has('rename-session');
254
+ }
255
+ async function createSession() {
256
+ const provider = $('provider').value;
257
+ const projectId = $('project').value;
258
+ if (!provider || !projectId) {
259
+ setUiMessage($('sessionFormMessage'), m('app.059'));
260
+ return;
261
+ }
262
+ if (state.busy.has('new-session')) return;
263
+ state.busy.add('new-session'); renderSelects();
264
+ setUiMessage($('sessionFormMessage'), m('app.060'));
265
+ try {
266
+ const session = await post('/api/sessions', { provider, projectId });
267
+ state.selectedSessionId = session.id;
268
+ state.sessionFormOpen = false;
269
+ state.newSessionProvider = '';
270
+ setUiMessage($('sessionFormMessage'), '');
271
+ try { sessionStorage.setItem('vibe-selected-session', session.id); } catch { /* Volatile selection is fine. */ }
272
+ showToast(t('app.061'));
273
+ await refresh();
274
+ $('instruction').focus();
275
+ } catch (error) {
276
+ setUiMessage($('sessionFormMessage'), error.message || m('app.062'));
277
+ showToast(error.message || t('app.062'), true);
278
+ } finally { state.busy.delete('new-session'); render(); }
279
+ }
280
+ async function renameSession() {
281
+ const session = selectedSession();
282
+ const title = $('renameSessionTitle').value.trim();
283
+ if (!session || !title || state.busy.has('rename-session')) return;
284
+ state.busy.add('rename-session'); renderSessions();
285
+ setUiMessage($('renameSessionMessage'), m('app.063'));
286
+ try {
287
+ await post(`/api/sessions/${encodeURIComponent(session.id)}/rename`, { title });
288
+ state.renamingSession = false;
289
+ setUiMessage($('renameSessionMessage'), '');
290
+ showToast(t('app.064'));
291
+ await refresh();
292
+ } catch (error) {
293
+ setUiMessage($('renameSessionMessage'), error.message || m('app.065'));
294
+ showToast(error.message || t('app.065'), true);
295
+ } finally { state.busy.delete('rename-session'); render(); }
296
+ }
297
+ function renderJobs() {
298
+ if (!$('jobList')) return;
299
+ const list = $('jobList'); list.replaceChildren();
300
+ if (state.error) {
301
+ const empty = text('div', 'empty'); empty.append(text('div', 'empty-icon', '!'), text('strong', '', t('app.066')), text('span', '', state.error)); list.append(empty); return;
302
+ }
303
+ const session = selectedSession();
304
+ if (!session) {
305
+ const empty = text('div', 'empty'); empty.append(text('div', 'empty-icon', '+'), text('strong', '', t('app.067')), text('span', '', t('app.068')));
306
+ list.append(empty); return;
307
+ }
308
+ const job = latestJobForSession(session.id);
309
+ if (!job) {
310
+ const empty = text('div', 'empty'); empty.append(text('div', 'empty-icon', '⌁'), text('strong', '', t('app.069')), text('span', '', t('app.070'))); list.append(empty); return;
311
+ }
312
+ const info = jobStatus(job);
313
+ const card = text('article', 'job-card task-detail-card');
314
+ const top = text('div', 'job-top');
315
+ top.append(text('div', 'job-meta', t('app.071', { v0: (timeText(job.createdAt) || t('app.047')) })), text('span', `job-status ${info.css}`, info.label));
316
+ card.append(top, text('div', 'job-instruction', t('app.073', { v0: (job.instruction || t('app.072')) })));
317
+ const answer = userFacingJobError(job) || (job.status === 'running' ? job.progress : '') || job.result ||
318
+ (job.status === 'waiting_confirmation' ? t('app.074') :
319
+ job.status === 'queued' ? t('app.075') : t('app.076'));
320
+ card.append(text('p', `job-summary turn-answer ${job.error && !job.retryPending ? 'error' : ''}`, t('app.077', { v0: (providerName(job.provider)), v1: (safeString(answer)) })));
321
+ if (job.provider === 'codex' && job.retryPending) {
322
+ card.append(text('p', 'job-retry-notice', t('app.078')));
323
+ }
324
+ const handoff = handoffPanel(job); if (handoff) card.append(handoff);
325
+ const actions = text('div', 'job-actions');
326
+ if (canConfirm(job)) actions.append(actionButton(job, 'confirm'));
327
+ if (canCancel(job)) actions.append(actionButton(job, 'cancel'));
328
+ if (actions.children.length) { const bottom = text('div', 'job-bottom'); bottom.append(actions); card.append(bottom); }
329
+ list.append(card);
330
+ }
331
+ const boardEncoder = new TextEncoder();
332
+ function boardCleanText(value) {
333
+ return safeString(value)
334
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
335
+ .replace(/\r\n?/g, '\n')
336
+ .replace(/\t/g, ' ')
337
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ')
338
+ .replace(/[\p{Extended_Pictographic}\p{Emoji_Modifier}\uFE0E\uFE0F\u200D]/gu, '');
339
+ }
340
+ function boardDeviceText(value, maxBytes) {
341
+ const clean = boardCleanText(value);
342
+ let content = '', bytes = 0;
343
+ for (const character of clean) {
344
+ const size = boardEncoder.encode(character).length;
345
+ if (bytes + size > maxBytes) break;
346
+ content += character;
347
+ bytes += size;
348
+ }
349
+ return { content, truncated: bytes < boardEncoder.encode(clean).length };
350
+ }
351
+ function boardResultPreview(value, maxBytes) {
352
+ const full = boardCleanText(value);
353
+ if (boardEncoder.encode(full).length <= maxBytes) return { content: full, truncated: false };
354
+ const separator = '\n…\n';
355
+ const prefix = boardDeviceText(full, Math.floor(maxBytes / 3)).content;
356
+ const remaining = maxBytes - boardEncoder.encode(prefix).length - boardEncoder.encode(separator).length;
357
+ const suffix = [];
358
+ let bytes = 0;
359
+ for (const character of Array.from(full).reverse()) {
360
+ const size = boardEncoder.encode(character).length;
361
+ if (bytes + size > remaining) break;
362
+ suffix.push(character);
363
+ bytes += size;
364
+ }
365
+ return { content: prefix + separator + suffix.reverse().join(''), truncated: true };
366
+ }
367
+ let boardRenderedJobKey = null;
368
+ function previewSessions() {
369
+ return state.sessions.filter((session) => session.provider === state.previewProvider && !session.legacy);
370
+ }
371
+ function previewSession() {
372
+ const sessions = previewSessions();
373
+ return sessions.find((session) => session.id === state.previewSessionId) || sessions[0];
374
+ }
375
+ function renderBoard() {
376
+ if (!$('boardScreen')) return;
377
+ const provider = state.error ? null : providerById(state.previewProvider) || state.providers[0];
378
+ $('boardNewSession').hidden = !provider;
379
+ $('boardNewSession').disabled = !provider;
380
+ $('boardAnimal').hidden = !provider;
381
+ if (!provider) {
382
+ state.previewProvider = '';
383
+ state.previewSessionId = null;
384
+ boardRenderedJobKey = null;
385
+ clearProviderIcon($('boardAnimal'));
386
+ $('boardProviderName').textContent = state.loaded && !state.error ? t('app.079') : t('app.040');
387
+ $('boardPage').textContent = '—';
388
+ $('boardStatus').textContent = state.loaded && !state.error ? t('app.080') : t('app.081');
389
+ $('boardStatus').title = '';
390
+ $('boardTitle').textContent = '';
391
+ $('boardTitle').title = '';
392
+ $('boardResult').textContent = '';
393
+ $('boardResult').scrollTop = 0;
394
+ return;
395
+ }
396
+ state.previewProvider = provider.id;
397
+ const page = state.providers.indexOf(provider);
398
+ const available = provider.available;
399
+ const sessions = previewSessions();
400
+ const session = previewSession();
401
+ state.previewSessionId = session?.id || null;
402
+ const job = session ? latestJobForSession(session.id) : null;
403
+ const renderedJobKey = `${provider.id}:${session?.id || ''}:${job?.id || ''}:${job?.status || ''}`;
404
+ const resetAnswerScroll = renderedJobKey !== boardRenderedJobKey;
405
+ boardRenderedJobKey = renderedJobKey;
406
+ const answerNode = $('boardResult');
407
+ const showAnswer = (value) => {
408
+ const content = safeString(value);
409
+ const previousScroll = answerNode.scrollTop;
410
+ if (answerNode.textContent !== content) answerNode.textContent = content;
411
+ answerNode.scrollTop = resetAnswerScroll ? 0 : previousScroll;
412
+ };
413
+ renderProviderIcon($('boardAnimal'), provider.icon);
414
+ $('boardProviderName').textContent = provider.label;
415
+ $('boardPage').textContent = `${page + 1} / ${state.providers.length}`;
416
+ $('boardStatus').textContent = session ? t('app.082', { v0: (sessions.indexOf(session) + 1), v1: (sessions.length), v2: (session.title || t('app.045')) }) : t('app.083');
417
+ $('boardStatus').title = session?.title || t('app.083');
418
+ if (!job) {
419
+ const title = !session ? t('app.084') : t('app.085');
420
+ $('boardTitle').textContent = title;
421
+ $('boardTitle').title = title;
422
+ showAnswer(state.error ? t('app.086') :
423
+ available === false ? t('app.087') :
424
+ !state.voice.available ? t('app.088') :
425
+ !session ? t('app.089') :
426
+ t('app.090'));
427
+ return;
428
+ }
429
+ const instruction = boardDeviceText(job.instruction, 1200);
430
+ const title = t('app.091', { v0: (jobStatus(job).label), v1: (instruction.content || t('app.072')) });
431
+ $('boardTitle').textContent = title;
432
+ $('boardTitle').title = t('app.073', { v0: (job.instruction || t('app.072')) });
433
+ const result = boardResultPreview(job.result, 1200);
434
+ const deviceResult = result.content + ((result.truncated || job.resultTruncated) ? t('app.092') : '');
435
+ const pendingNotice = (instruction.truncated || job.instructionTruncated) ? t('app.093') :
436
+ boardDeviceText(userFacingJobError(job), 96).content || t('app.094');
437
+ const pendingInstruction = `${pendingNotice}\n${instruction.content || t('app.095')}`;
438
+ showAnswer((job.status === 'waiting_confirmation' ? pendingInstruction : '') ||
439
+ boardDeviceText(userFacingJobError(job), 96).content ||
440
+ (job.status === 'running' ? boardDeviceText(job.progress, 160).content : '') ||
441
+ (job.status === 'queued' ? t('app.096') : '') ||
442
+ deviceResult || t('app.097'));
443
+ }
444
+ let boardPointerStart = null;
445
+ function changeBoardProvider(direction) {
446
+ if (!state.providers.length) return;
447
+ const current = Math.max(0, state.providers.findIndex((item) => item.id === state.previewProvider));
448
+ state.previewProvider = state.providers[(current + direction + state.providers.length) % state.providers.length].id;
449
+ state.previewSessionId = null;
450
+ renderBoard();
451
+ }
452
+ function changeBoardSession(direction) {
453
+ const sessions = previewSessions();
454
+ if (sessions.length < 2) return;
455
+ const current = Math.max(0, sessions.findIndex((session) => session.id === state.previewSessionId));
456
+ state.previewSessionId = sessions[(current + direction + sessions.length) % sessions.length].id;
457
+ renderBoard();
458
+ }
459
+ function renderConnection() {
460
+ if (state.lastSyncedAt) $('lastUpdated').textContent = t('app.019', { v0: formatDateTime(state.lastSyncedAt, { hour: '2-digit', minute: '2-digit', second: '2-digit' }) });
461
+ const header = $('headerConnection'); header.classList.toggle('online', !state.error && state.loaded); header.querySelector('span:last-child').textContent = state.error ? t('app.098') : state.loaded ? t('app.099') : t('app.100');
462
+ if (!$('connectionRows')) return;
463
+ const rows = $('connectionRows'); rows.replaceChildren();
464
+ const entries = [
465
+ { label: t('app.101'), value: state.error ? t('app.102') : state.loaded ? t('app.103') : t('app.104'), css: state.error ? 'warn' : state.loaded ? 'ok' : 'off', on: !state.error && state.loaded },
466
+ { label: t('app.105'), value: state.voice.available ? t('app.106') : state.voice.reason || t('app.107'), css: state.voice.available ? 'ok' : 'warn', on: state.voice.available },
467
+ { label: t('app.108'), value: state.connection.deviceApi === 'enabled' ? t('app.109') : t('app.110'), css: state.connection.deviceApi === 'enabled' ? 'ok' : 'off', on: state.connection.deviceApi === 'enabled' },
468
+ { label: t('app.111'), value: t('app.112', { v0: ((state.pairing.paired || []).filter((item) => item.online).length), v1: ((state.pairing.paired || []).length) }), css: state.pairing.paired?.some((item) => item.online) ? 'ok' : 'off', on: state.pairing.paired?.some((item) => item.online) }
469
+ ];
470
+ if (state.receiver.status === 'connected') entries.splice(3, 0, { label: state.receiver.mode === 'direct' ? t('app.113') : t('app.114'), value: t('app.115'), css: 'ok', on: true });
471
+ else if (state.receiver.status === 'waiting') entries.splice(3, 0, { label: t('app.116'), value: t('app.117'), css: 'off', on: false });
472
+ entries.forEach((item) => { const row = text('div', 'info-row'); const name = text('span', 'info-name'); const dot = text('span', `status-dot ${item.on ? 'on' : ''}`); name.append(dot, document.createTextNode(item.label)); row.append(name, text('span', `info-value ${item.css}`, item.value)); rows.append(row); });
473
+ }
474
+ function renderReceiver() {
475
+ if (!$('receiverStatus')) return;
476
+ const receiver = state.receiver || {};
477
+ const connected = receiver.status === 'connected';
478
+ const direct = receiver.mode === 'direct';
479
+ const status = $('receiverStatus');
480
+ status.classList.toggle('open', connected);
481
+ if (state.error) status.textContent = t('app.118');
482
+ else if (connected && direct) status.textContent = t('app.119', { v0: (receiver.port ? ` · ${receiver.port}` : '') });
483
+ else if (connected) status.textContent = t('app.120', { v0: (receiver.port ? ` · ${receiver.port}` : '') });
484
+ else if (receiver.status === 'waiting') status.textContent = direct ? t('app.121') : t('app.122');
485
+ else if (receiver.status === 'disconnected') status.textContent = receiver.error ? t('app.123', { v0: (receiver.error) }) : t('app.124');
486
+ else status.textContent = t('app.125');
487
+ const details = $('receiverDetails');
488
+ details.hidden = direct || !connected || !receiver.ssid || !receiver.password;
489
+ const directDetails = $('receiverDirectDetails');
490
+ if (directDetails) {
491
+ directDetails.hidden = !direct || !connected;
492
+ $('receiverDeviceName').textContent = deviceDisplayName(receiver.deviceName);
493
+ $('receiverDeviceId').textContent = receiver.deviceId || '';
494
+ }
495
+ const help = $('receiverHelp');
496
+ if (help) help.textContent = direct ? t('app.126') : t('app.127');
497
+ if (details.hidden) return;
498
+ $('receiverSsid').textContent = receiver.ssid;
499
+ $('receiverPassword').textContent = state.receiverPasswordVisible ? receiver.password : '••••••••••••';
500
+ $('receiverRevealPassword').textContent = state.receiverPasswordVisible ? t('app.128') : t('app.129');
501
+ $('receiverRevealPassword').setAttribute('aria-pressed', String(state.receiverPasswordVisible));
502
+ }
503
+ let modelSettingsBusy = false;
504
+ const modelDrafts = new Map();
505
+ let modelFieldsSignature = '';
506
+ const modelInputId = (id) => ['codex', 'cursor', 'qoder'].includes(id) ? `${id}Model` : `model-${id}`;
507
+ function renderModelSettings() {
508
+ if (!$('modelSettingsForm') || !state.loaded) return;
509
+ const providers = state.providers.filter((provider) => provider.capabilities?.model);
510
+ const signature = JSON.stringify([getLocale(), providers.map(({ id, label, model }) => ({ id, label, model }))]);
511
+ if (signature !== modelFieldsSignature) {
512
+ const fields = $('modelPluginFields'); fields.replaceChildren();
513
+ providers.forEach((provider) => {
514
+ const wrapper = text('div');
515
+ const id = modelInputId(provider.id);
516
+ const label = text('label', 'field-label', provider.label); label.htmlFor = id;
517
+ const input = text('input', 'secret-input');
518
+ input.id = id; input.type = 'text'; input.spellcheck = false; input.autocapitalize = 'off';
519
+ input.required = Boolean(provider.model?.required);
520
+ input.placeholder = provider.model?.default || t('app.130', { v0: (provider.label) });
521
+ input.dataset.modelProvider = provider.id;
522
+ input.addEventListener('input', () => modelDrafts.set(provider.id, input.value));
523
+ wrapper.append(label, input); fields.append(wrapper);
524
+ });
525
+ if (!providers.length) fields.append(text('p', 'panel-intro', t('app.131')));
526
+ modelFieldsSignature = signature;
527
+ }
528
+ providers.forEach((provider) => {
529
+ const input = $(modelInputId(provider.id));
530
+ if (!input) return;
531
+ if (document.activeElement !== input) input.value = modelDrafts.get(provider.id) ?? state.models[provider.id] ?? provider.model?.default ?? '';
532
+ input.disabled = modelSettingsBusy || !!state.error;
533
+ });
534
+ const unsupported = state.providers.filter((provider) => !provider.capabilities?.model).map((provider) => provider.label);
535
+ $('modelPluginHelp').textContent = t('app.134', { v0: (unsupported.length ? t('app.133', { v0: (unsupported.join(t('app.132'))) }) : '') });
536
+ $('saveModels').disabled = !providers.length || modelSettingsBusy || !!state.error;
537
+ }
538
+ async function saveModels() {
539
+ if (modelSettingsBusy) return;
540
+ const models = Object.fromEntries(state.providers.filter((provider) => provider.capabilities?.model)
541
+ .map((provider) => [provider.id, $(modelInputId(provider.id)).value.trim()]));
542
+ modelSettingsBusy = true;
543
+ renderModelSettings();
544
+ setUiMessage($('modelSettingsMessage'), m('app.063'));
545
+ try {
546
+ const response = await post('/api/settings/models', models);
547
+ state.models = response.models || models;
548
+ modelDrafts.clear();
549
+ setUiMessage($('modelSettingsMessage'), m('app.135'));
550
+ showToast(t('app.135'));
551
+ await refresh();
552
+ } catch (error) {
553
+ setUiMessage($('modelSettingsMessage'), error.message || m('app.136'));
554
+ showToast(error.message || t('app.136'), true);
555
+ } finally { modelSettingsBusy = false; renderModelSettings(); }
556
+ }
557
+ let voiceSettingsBusy = false;
558
+ let voiceFieldsTouched = false;
559
+ function renderVoiceSettings() {
560
+ if (!$('voiceSettingsForm') || !state.loaded) return;
561
+ if (!voiceFieldsTouched && !voiceSettingsBusy) {
562
+ $('voiceEndpoint').value = state.voice.url || 'https://api.openai.com/v1/audio/transcriptions';
563
+ $('voiceModel').value = state.voice.model || 'gpt-transcribe';
564
+ }
565
+ const source = state.voice.keySource || 'none';
566
+ const compatible = (state.voice.pluginId || state.voice.mode) === 'openai';
567
+ $('voiceKeyStatus').textContent = state.error ? t('app.137') : !compatible ? t('app.138') :
568
+ source === 'none' ? t('app.139') :
569
+ source === 'environment' ? t('app.140') : t('app.141');
570
+ for (const id of ['voiceEndpoint', 'voiceModel', 'openaiKey', 'saveOpenaiKey']) $(id).disabled = voiceSettingsBusy || !!state.error;
571
+ $('openaiKey').required = source === 'none';
572
+ $('clearOpenaiKey').disabled = voiceSettingsBusy || !!state.error || source === 'none';
573
+ }
574
+ function setVoiceSettingsMessage(message, success = false) {
575
+ const node = $('voiceSettingsMessage');
576
+ setUiMessage(node, message);
577
+ node.classList.toggle('success', success);
578
+ }
579
+ function openVoiceSettings() {
580
+ $('voiceSettings').scrollIntoView({ behavior: 'smooth', block: 'center' });
581
+ setTimeout(() => $('voiceEndpoint').focus({ preventScroll: true }), 350);
582
+ }
583
+ async function saveVoiceSettings(clearKey = false) {
584
+ if (voiceSettingsBusy) return;
585
+ const settings = {url: $('voiceEndpoint').value.trim(), model: $('voiceModel').value.trim(),
586
+ apiKey: clearKey ? '' : $('openaiKey').value.trim(), clearKey};
587
+ voiceSettingsBusy = true; renderVoiceSettings(); setVoiceSettingsMessage(m('app.063'));
588
+ try {
589
+ const result = await post('/api/settings/voice', settings);
590
+ if (result.voice) state.voice = result.voice;
591
+ $('openaiKey').value = '';
592
+ voiceFieldsTouched = false;
593
+ const message = clearKey ? (result.voice?.keySource === 'environment' ? m('app.142') : m('app.143')) : m('app.144');
594
+ setVoiceSettingsMessage(message, true); showToast(message);
595
+ await refresh();
596
+ } catch (error) {
597
+ setVoiceSettingsMessage(error.message || m('app.145'));
598
+ showToast(error.message || t('app.145'), true);
599
+ } finally { voiceSettingsBusy = false; renderVoiceSettings(); }
600
+ }
601
+ function pairingTime(value) {
602
+ const timestamp = typeof value === 'number' && value < 1e12 ? value * 1000 : new Date(value).getTime();
603
+ return Number.isFinite(timestamp) ? timestamp : null;
604
+ }
605
+ function pairingRemaining(value) {
606
+ const until = pairingTime(value);
607
+ if (!until) return '';
608
+ const seconds = Math.max(0, Math.ceil((until - Date.now()) / 1000));
609
+ return t('app.146', { v0: (Math.floor(seconds / 60)), v1: (String(seconds % 60).padStart(2, '0')) });
610
+ }
611
+ function currentPairingRequest() {
612
+ const pending = Array.isArray(state.pairing.pending) ? state.pairing.pending : [];
613
+ return pending.find((item) => item && item.deviceId && item.nonce && !state.dismissedPairing.has(item.nonce));
614
+ }
615
+ function renderPairing() {
616
+ const pairing = state.pairing || {};
617
+ const paired = Array.isArray(pairing.paired) ? pairing.paired : [];
618
+ const pending = Array.isArray(pairing.pending) ? pairing.pending : [];
619
+ const openButton = $('pairingOpenButton');
620
+ if (openButton) {
621
+ const closeButton = $('pairingCloseButton');
622
+ $('pairingShowButton').hidden = !pending.length || !!currentPairingRequest();
623
+ openButton.hidden = !!pairing.open;
624
+ openButton.disabled = state.pairWindowBusy || !!state.error;
625
+ closeButton.hidden = !pairing.open;
626
+ closeButton.disabled = state.pairWindowBusy || !!state.error;
627
+ const status = $('pairingState');
628
+ status.classList.toggle('open', !!pairing.open && !state.error);
629
+ if (state.error) status.textContent = t('app.147');
630
+ else if (pending.length) status.textContent = t('app.148', { v0: (pending.length) });
631
+ else if (pairing.open) status.textContent = t('app.150', { v0: (pairing.openUntil ? t('app.149', { v0: (pairingRemaining(pairing.openUntil)) }) : '') });
632
+ else status.textContent = paired.length ? t('app.151') : t('app.152');
633
+ const list = $('pairedDevices'); list.replaceChildren();
634
+ paired.forEach((device) => {
635
+ const row = text('div', 'pairing-device');
636
+ const details = text('div', 'pairing-device-text');
637
+ const name = deviceDisplayName(device.deviceName, t('app.153', { v0: (safeString(device.deviceId).slice(0, 8)) }));
638
+ const seen = pairingTime(device.lastSeen);
639
+ details.append(text('span', 'pairing-device-name', name), text('span', 'pairing-device-meta', `${device.online ? t('app.103') : t('app.102')}${seen ? t('app.154', { v0: (formatDateTime(seen)) }) : ''}`));
640
+ const remove = text('button', 'small-button', t('app.155'));
641
+ remove.type = 'button'; remove.title = t('app.156', { v0: (name) });
642
+ remove.disabled = state.busy.has(`pair:${device.deviceId}`);
643
+ remove.addEventListener('click', () => removePairing(device));
644
+ row.append(text('span', `status-dot ${device.online ? 'on' : ''}`), details, remove); list.append(row);
645
+ });
646
+ }
647
+ const request = currentPairingRequest();
648
+ const overlay = $('pairingOverlay');
649
+ const wasHidden = overlay.hidden;
650
+ overlay.hidden = !request;
651
+ document.title = request ? t('app.157') : taskPage ? t('app.158') : assistantsPage ? t('app.159') : t('app.160');
652
+ if (!request) return;
653
+ $('pairingDialogTitle').textContent = t('app.161', { v0: (deviceDisplayName(request.deviceName)) });
654
+ $('pairingEndpoint').textContent = request.endpoint === 'usb.vibe.local:8788' ? t('app.162') : t('app.164', { v0: (safeString(request.endpoint) || t('app.163')) });
655
+ $('pairingCode').textContent = safeString(request.code).padStart(6, '0');
656
+ $('pairingRemaining').textContent = t('app.166', { v0: (pairingRemaining(request.expiresAt) || t('app.165')) });
657
+ $('pairingConfirmButton').disabled = state.pairBusy;
658
+ $('pairingRejectButton').disabled = state.pairBusy;
659
+ $('pairingDismissButton').disabled = state.pairBusy;
660
+ if (wasHidden) $('pairingRejectButton').focus();
661
+ }
662
+ async function setPairingWindow(open) {
663
+ if (state.pairWindowBusy) return;
664
+ state.pairWindowBusy = true; renderPairing();
665
+ try {
666
+ await post(open ? '/api/pair/open' : '/api/pair/close');
667
+ showToast(open ? t('app.167') : t('app.168'));
668
+ await refresh();
669
+ } catch (error) { showToast(error.message || t('app.169'), true); }
670
+ finally { state.pairWindowBusy = false; renderPairing(); }
671
+ }
672
+ async function decidePairing(action) {
673
+ const request = currentPairingRequest();
674
+ if (!request || state.pairBusy) return;
675
+ state.pairBusy = true; renderPairing();
676
+ try {
677
+ await post(`/api/pair/${encodeURIComponent(request.deviceId)}/${action}`, { nonce: request.nonce });
678
+ showToast(action === 'confirm' ? t('app.170') : t('app.171'));
679
+ await refresh();
680
+ } catch (error) { showToast(error.message || t('app.172'), true); }
681
+ finally { state.pairBusy = false; renderPairing(); }
682
+ }
683
+ async function removePairing(device) {
684
+ const id = safeString(device.deviceId);
685
+ if (!id || state.busy.has(`pair:${id}`)) return;
686
+ const name = deviceDisplayName(device.deviceName, t('app.173'));
687
+ if (!window.confirm(t('app.174', { v0: (name) }))) return;
688
+ state.busy.add(`pair:${id}`); renderPairing();
689
+ try {
690
+ await post(`/api/pair/${encodeURIComponent(id)}/remove`);
691
+ showToast(t('app.175'));
692
+ await refresh();
693
+ } catch (error) { showToast(error.message || t('app.176'), true); }
694
+ finally { state.busy.delete(`pair:${id}`); renderPairing(); }
695
+ }
696
+ // Fixed, public vendor entry points. No local state or credentials are added to these URLs.
697
+ const tunnelOfficialEntries = {
698
+ quick: { name: 'static.060', links: [
699
+ ['tunnel.official.download', 'https://developers.cloudflare.com/tunnel/downloads/'],
700
+ ['tunnel.official.guide', 'https://developers.cloudflare.com/tunnel/get-started/#quick-tunnels-development'],
701
+ ] },
702
+ named: { name: 'static.061', links: [
703
+ ['tunnel.official.signup', 'https://dash.cloudflare.com/sign-up'],
704
+ ['tunnel.official.console', 'https://dash.cloudflare.com/'],
705
+ ['tunnel.official.download', 'https://developers.cloudflare.com/tunnel/downloads/'],
706
+ ['tunnel.official.guide', 'https://developers.cloudflare.com/tunnel/get-started/'],
707
+ ] },
708
+ ngrok: { name: 'static.063', links: [
709
+ ['tunnel.official.signup', 'https://dashboard.ngrok.com/signup'],
710
+ ['tunnel.official.authtoken', 'https://dashboard.ngrok.com/get-started/your-authtoken'],
711
+ ['tunnel.official.download', 'https://ngrok.com/download'],
712
+ ['tunnel.official.guide', 'https://ngrok.com/docs/share-localhost/quickstart'],
713
+ ] },
714
+ oray: { name: 'static.064', links: [
715
+ ['tunnel.official.websiteSignup', 'https://hsk.oray.com/'],
716
+ ['tunnel.official.console', 'https://console.hsk.oray.com/'],
717
+ ['tunnel.official.download', 'https://hsk.oray.com/download'],
718
+ ] },
719
+ tailscale: { name: 'static.062', links: [
720
+ ['tunnel.official.signup', 'https://login.tailscale.com/start'],
721
+ ['tunnel.official.console', 'https://console.tailscale.com/'],
722
+ ['tunnel.official.download', 'https://tailscale.com/download'],
723
+ ['tunnel.official.guide', 'https://tailscale.com/docs/features/tailscale-funnel'],
724
+ ] },
725
+ };
726
+ let tunnelOfficialKey = '';
727
+ function renderTunnelOfficialLinks(mode) {
728
+ const container = $('tunnelOfficialLinks');
729
+ if (!container) return;
730
+ const signature = `${getLocale()}:${mode}`;
731
+ if (signature === tunnelOfficialKey) return;
732
+ const service = tunnelOfficialEntries[mode] || tunnelOfficialEntries.quick;
733
+ $('tunnelServiceName').textContent = t(service.name);
734
+ container.replaceChildren(...service.links.map(([label, url]) => {
735
+ const link = text('a', 'tunnel-official-link', t(label));
736
+ link.href = url;
737
+ link.target = '_blank';
738
+ link.rel = 'noopener noreferrer';
739
+ link.title = t('tunnel.official.newTab');
740
+ return link;
741
+ }));
742
+ tunnelOfficialKey = signature;
743
+ }
744
+ let tunnelFieldsTouched = false;
745
+ function renderTunnel() {
746
+ if (!$('tunnelStatus')) return;
747
+ const tunnel = state.tunnel || {};
748
+ const active = tunnel.status === 'starting' || tunnel.status === 'online' ||
749
+ tunnel.running || tunnel.cleanupAvailable;
750
+ if (!tunnelFieldsTouched && !state.tunnelBusy) {
751
+ $('tunnelMode').value = ['named', 'tailscale', 'ngrok', 'oray'].includes(tunnel.mode) ? tunnel.mode : 'quick';
752
+ $('tunnelHostname').value = tunnel.configuredUrl || '';
753
+ $('tunnelNgrokHostname').value = tunnel.ngrokConfiguredUrl || '';
754
+ $('tunnelOrayHostname').value = tunnel.mode === 'oray' ? (tunnel.configuredUrl || '') : '';
755
+ }
756
+ const mode = $('tunnelMode').value;
757
+ renderTunnelOfficialLinks(mode);
758
+ const named = mode === 'named';
759
+ $('tunnelNamedFields').hidden = !named;
760
+ $('tunnelNgrokFields').hidden = mode !== 'ngrok';
761
+ $('tunnelOrayFields').hidden = mode !== 'oray';
762
+ $('tunnelModeHelp').textContent = mode === 'tailscale' ?
763
+ t('app.177') :
764
+ mode === 'ngrok' ? t('app.178') :
765
+ mode === 'oray' ? t('app.179') :
766
+ mode === 'named' ? t('app.180') :
767
+ t('app.181');
768
+ $('tunnelTokenStatus').textContent = tunnel.tokenConfigured ? t('app.182') : t('app.183');
769
+ $('tunnelNgrokTokenStatus').textContent = tunnel.ngrokTokenConfigured ? t('app.184') : t('app.185');
770
+ $('tunnelOrayKeyStatus').textContent = tunnel.apiKeyConfigured ? t('app.186') : t('app.187');
771
+ for (const id of ['tunnelMode', 'tunnelHostname', 'tunnelToken', 'tunnelNgrokHostname', 'tunnelNgrokToken', 'tunnelOrayHostname', 'tunnelOrayKey', 'tunnelSaveButton']) {
772
+ $(id).disabled = active || state.tunnelBusy || !!state.error;
773
+ }
774
+ $('tunnelStartButton').hidden = active;
775
+ $('tunnelStopButton').hidden = !active;
776
+ $('tunnelStopButton').textContent = mode === 'oray' ?
777
+ (tunnel.mappingEnabled ? t('app.188') : t('app.189')) :
778
+ tunnel.cleanupAvailable && !tunnel.running ? t('app.190') : t('app.191');
779
+ $('tunnelStartButton').disabled = state.tunnelBusy || !!state.error || tunnelFieldsTouched;
780
+ $('tunnelStopButton').disabled = state.tunnelBusy || !!state.error;
781
+ const status = $('tunnelStatus');
782
+ status.classList.toggle('open', tunnel.status === 'online');
783
+ status.textContent = state.error ? t('app.192') :
784
+ tunnelFieldsTouched && !active ? t('app.193') :
785
+ tunnel.status === 'online' ? (tunnel.mode === 'named' ?
786
+ t('app.194') :
787
+ t('app.195')) :
788
+ tunnel.status === 'starting' ? (tunnel.mode === 'tailscale' ?
789
+ t('app.196') :
790
+ tunnel.mode === 'ngrok' ? t('app.197') :
791
+ tunnel.mode === 'oray' ? t('app.198') :
792
+ t('app.199')) :
793
+ tunnel.status === 'stopping' ? t('app.200') :
794
+ tunnel.status === 'error' ? (tunnel.error || t('app.201')) :
795
+ t('app.202');
796
+ const address = typeof tunnel.url === 'string' && /^https:\/\/[a-z0-9.-]+$/i.test(tunnel.url) ? tunnel.url : '';
797
+ $('tunnelAddressRow').hidden = !address;
798
+ $('tunnelAddress').textContent = address;
799
+ $('tunnelCopyButton').disabled = !address;
800
+ }
801
+ async function setTunnel(enabled) {
802
+ if (state.tunnelBusy) return;
803
+ state.tunnelBusy = true; renderTunnel();
804
+ try {
805
+ await post(enabled ? '/api/tunnel/start' : '/api/tunnel/stop');
806
+ await refresh();
807
+ showToast(enabled ? (state.tunnel?.status === 'online' ? t('app.203') :
808
+ t('app.204')) :
809
+ state.tunnel?.status === 'off' ? t('app.205') : t('app.206'));
810
+ } catch (error) { showToast(error.message || t('app.207'), true); }
811
+ finally { state.tunnelBusy = false; renderTunnel(); }
812
+ }
813
+ async function saveTunnelSettings() {
814
+ if (state.tunnelBusy) return;
815
+ const mode = $('tunnelMode').value;
816
+ const url = $(mode === 'ngrok' ? 'tunnelNgrokHostname' : mode === 'oray' ? 'tunnelOrayHostname' : 'tunnelHostname').value.trim();
817
+ const token = $(mode === 'ngrok' ? 'tunnelNgrokToken' : mode === 'oray' ? 'tunnelOrayKey' : 'tunnelToken').value.trim();
818
+ state.tunnelBusy = true; renderTunnel();
819
+ try {
820
+ await post('/api/tunnel/settings', { mode, url, token });
821
+ $('tunnelToken').value = '';
822
+ $('tunnelNgrokToken').value = '';
823
+ $('tunnelOrayKey').value = '';
824
+ tunnelFieldsTouched = false;
825
+ showToast(t('app.208'));
826
+ await refresh();
827
+ } catch (error) { showToast(error.message || t('app.209'), true); }
828
+ finally { state.tunnelBusy = false; renderTunnel(); }
829
+ }
830
+ const providerCards = new Map();
831
+ function renderProviders() {
832
+ if (!$('providerGrid')) return;
833
+ const grid = $('providerGrid');
834
+ if (!state.providers.length) {
835
+ providerCards.clear();
836
+ grid.replaceChildren(text('span', 'provider-desc', state.error ? t('app.210') : state.loaded ? t('app.211') : t('app.212')));
837
+ return;
838
+ }
839
+ const activeIds = new Set(state.providers.map((item) => item.id));
840
+ for (const id of providerCards.keys()) if (!activeIds.has(id)) providerCards.delete(id);
841
+ const cards = state.providers.map((item) => {
842
+ let entry = providerCards.get(item.id);
843
+ if (!entry) {
844
+ const card = text('div', 'provider');
845
+ const top = text('div', 'provider-top');
846
+ const mark = text('span', 'provider-mark');
847
+ const dot = text('span', 'status-dot');
848
+ const name = text('span', 'provider-name');
849
+ const description = text('span', 'provider-desc');
850
+ top.append(mark, dot);
851
+ card.append(top, name, description);
852
+ entry = { card, mark, dot, name, description };
853
+ providerCards.set(item.id, entry);
854
+ }
855
+ entry.mark.classList.toggle('off', !item.available);
856
+ entry.dot.classList.toggle('on', Boolean(item.available));
857
+ renderProviderIcon(entry.mark, item.icon);
858
+ entry.name.textContent = item.label || item.id;
859
+ entry.description.textContent = item.available ? (item.mode === 'dispatch_only' ? t('app.213') : t('app.214')) : (item.reason || t('app.215'));
860
+ return entry.card;
861
+ });
862
+ if (cards.length !== grid.children.length || cards.some((card, index) => grid.children[index] !== card)) grid.replaceChildren(...cards);
863
+ }
864
+ function render() { renderSelects(); renderSessions(); renderJobs(); renderBoard(); renderPairing(); renderTunnel(); renderReceiver(); renderConnection(); renderModelSettings(); renderVoiceSettings(); renderProviders(); }
865
+ function applyLanguage(locale) {
866
+ const changed = setLocale(locale);
867
+ if (!changed && document.documentElement.lang === locale) {
868
+ if (!languageBusy) $('languageSelect').value = locale;
869
+ return;
870
+ }
871
+ const active = document.activeElement;
872
+ const draft = [...document.querySelectorAll('input[id], textarea[id], select[id]')]
873
+ .filter((node) => node.id !== 'languageSelect')
874
+ .map((node) => ({ id: node.id, value: node.value, checked: node.checked, start: node.selectionStart, end: node.selectionEnd }));
875
+ applyStaticTranslations();
876
+ refreshUiMessages();
877
+ $('toast').classList.remove('visible');
878
+ render();
879
+ for (const field of draft) {
880
+ const node = $(field.id);
881
+ if (!node) continue;
882
+ node.value = field.value;
883
+ if (typeof field.checked === 'boolean') node.checked = field.checked;
884
+ if (active?.id === field.id) {
885
+ node.focus({ preventScroll: true });
886
+ if (typeof field.start === 'number' && typeof field.end === 'number') {
887
+ try { node.setSelectionRange(field.start, field.end); } catch { /* Some input types have no selection API. */ }
888
+ }
889
+ }
890
+ }
891
+ $('languageSelect').value = locale;
892
+ }
893
+ $('languageSelect').addEventListener('change', async (event) => {
894
+ const requested = event.target.value;
895
+ if (languageBusy || !supportedLocales.includes(requested)) return;
896
+ languageBusy = true;
897
+ languageEpoch++;
898
+ event.target.disabled = true;
899
+ try {
900
+ const response = await post('/api/settings/language', { locale: requested });
901
+ languageEpoch++;
902
+ applyLanguage(supportedLocales.includes(response.locale) ? response.locale : requested);
903
+ showToast(t('language.saved'));
904
+ } catch (error) {
905
+ $('languageSelect').value = getLocale();
906
+ showToast(error.message || t('language.failed'), true);
907
+ } finally {
908
+ languageBusy = false;
909
+ $('languageSelect').disabled = false;
910
+ await refresh();
911
+ }
912
+ });
913
+ $('refreshButton').addEventListener('click', refresh);
914
+ $('pairingOpenButton')?.addEventListener('click', () => setPairingWindow(true));
915
+ $('pairingCloseButton')?.addEventListener('click', () => setPairingWindow(false));
916
+ $('pairingShowButton')?.addEventListener('click', () => { state.dismissedPairing.clear(); renderPairing(); });
917
+ $('pairingConfirmButton').addEventListener('click', () => decidePairing('confirm'));
918
+ $('pairingRejectButton').addEventListener('click', () => decidePairing('reject'));
919
+ $('pairingDismissButton').addEventListener('click', () => { const request = currentPairingRequest(); if (request) state.dismissedPairing.add(request.nonce); renderPairing(); });
920
+ $('receiverRevealPassword')?.addEventListener('click', () => { state.receiverPasswordVisible = !state.receiverPasswordVisible; renderReceiver(); });
921
+ $('receiverCopySsid')?.addEventListener('click', () => copyText(state.receiver.ssid || '', t('app.216')));
922
+ $('receiverCopyPassword')?.addEventListener('click', () => copyText(state.receiver.password || '', t('app.217')));
923
+ $('receiverCopyAddress')?.addEventListener('click', () => copyText($('receiverAddress').textContent, t('app.218')));
924
+ $('tunnelStartButton')?.addEventListener('click', () => setTunnel(true));
925
+ $('tunnelStopButton')?.addEventListener('click', () => setTunnel(false));
926
+ $('tunnelCopyButton')?.addEventListener('click', () => copyText($('tunnelAddress').textContent, t('app.219')));
927
+ $('tunnelMode')?.addEventListener('change', () => { tunnelFieldsTouched = true; renderTunnel(); });
928
+ $('tunnelHostname')?.addEventListener('input', () => { tunnelFieldsTouched = true; renderTunnel(); });
929
+ $('tunnelToken')?.addEventListener('input', () => { tunnelFieldsTouched = true; renderTunnel(); });
930
+ $('tunnelNgrokHostname')?.addEventListener('input', () => { tunnelFieldsTouched = true; renderTunnel(); });
931
+ $('tunnelNgrokToken')?.addEventListener('input', () => { tunnelFieldsTouched = true; renderTunnel(); });
932
+ $('tunnelOrayHostname')?.addEventListener('input', () => { tunnelFieldsTouched = true; renderTunnel(); });
933
+ $('tunnelOrayKey')?.addEventListener('input', () => { tunnelFieldsTouched = true; renderTunnel(); });
934
+ $('tunnelSettingsForm')?.addEventListener('submit', (event) => { event.preventDefault(); saveTunnelSettings(); });
935
+ $('newSessionButton')?.addEventListener('click', () => {
936
+ state.sessionFormOpen = !state.sessionFormOpen;
937
+ renderSessions();
938
+ if (state.sessionFormOpen) $('provider').focus();
939
+ });
940
+ $('legacySessionToggle')?.addEventListener('click', () => {
941
+ state.legacySessionsOpen = !state.legacySessionsOpen;
942
+ renderSessions();
943
+ });
944
+ $('sessionForm')?.addEventListener('submit', (event) => { event.preventDefault(); createSession(); });
945
+ $('renameSessionButton')?.addEventListener('click', () => {
946
+ const session = selectedSession();
947
+ if (!session) return;
948
+ state.renamingSession = true;
949
+ $('renameSessionTitle').value = session.title || '';
950
+ setUiMessage($('renameSessionMessage'), '');
951
+ renderSessions();
952
+ $('renameSessionTitle').focus();
953
+ });
954
+ $('renameSessionForm')?.addEventListener('submit', (event) => { event.preventDefault(); renameSession(); });
955
+ $('renameSessionCancel')?.addEventListener('click', () => {
956
+ state.renamingSession = false;
957
+ setUiMessage($('renameSessionMessage'), '');
958
+ renderSessions();
959
+ });
960
+ $('boardNewSession')?.addEventListener('click', () => {
961
+ if (!providerById(state.previewProvider) || state.error) return;
962
+ window.location.assign(`/tasks.html?newSession=1&provider=${encodeURIComponent(state.previewProvider)}`);
963
+ });
964
+ $('boardScreen')?.addEventListener('pointerdown', (event) => {
965
+ if (event.target.closest('#boardNewSession')) return;
966
+ boardPointerStart = {
967
+ x: event.clientX, y: event.clientY,
968
+ inAnswer: Boolean(event.target.closest('#boardResult')),
969
+ inHeader: event.clientY - $('boardScreen').getBoundingClientRect().top < $('boardScreen').getBoundingClientRect().height * .53,
970
+ answerScrollTop: $('boardResult').scrollTop,
971
+ };
972
+ });
973
+ $('boardScreen')?.addEventListener('pointermove', (event) => {
974
+ if (!boardPointerStart?.inAnswer) return;
975
+ const dx = event.clientX - boardPointerStart.x, dy = event.clientY - boardPointerStart.y;
976
+ if (Math.abs(dy) > Math.abs(dx)) $('boardResult').scrollTop = boardPointerStart.answerScrollTop - dy;
977
+ });
978
+ $('boardScreen')?.addEventListener('pointerup', (event) => {
979
+ if (!boardPointerStart) return;
980
+ const { inAnswer, inHeader } = boardPointerStart;
981
+ const dx = event.clientX - boardPointerStart.x, dy = event.clientY - boardPointerStart.y;
982
+ boardPointerStart = null;
983
+ if (Math.abs(dx) >= 35 && Math.abs(dx) >= Math.abs(dy) * 1.25) changeBoardProvider(dx < 0 ? 1 : -1);
984
+ else if (inHeader && !inAnswer && Math.abs(dy) >= 35 && Math.abs(dy) >= Math.abs(dx) * 1.25) changeBoardSession(dy < 0 ? 1 : -1);
985
+ });
986
+ $('boardScreen')?.addEventListener('pointercancel', () => { boardPointerStart = null; });
987
+ $('boardScreen')?.addEventListener('keydown', (event) => {
988
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
989
+ event.preventDefault();
990
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') changeBoardProvider(event.key === 'ArrowRight' ? 1 : -1);
991
+ else changeBoardSession(event.key === 'ArrowDown' ? 1 : -1);
992
+ });
993
+ $('modelSettingsForm')?.addEventListener('submit', (event) => { event.preventDefault(); saveModels(); });
994
+ $('provider')?.addEventListener('change', (event) => {
995
+ if (state.providers.some((item) => item.id === event.target.value)) { state.previewProvider = event.target.value; state.previewSessionId = null; renderBoard(); renderSelects(); }
996
+ });
997
+ $('voiceSettingsForm')?.addEventListener('submit', (event) => { event.preventDefault(); saveVoiceSettings(); });
998
+ for (const id of ['voiceEndpoint', 'voiceModel', 'openaiKey']) $(id)?.addEventListener('input', () => { voiceFieldsTouched = true; });
999
+ document.querySelectorAll('[data-voice-preset]').forEach((button) => button.addEventListener('click', () => {
1000
+ const siliconflow = button.dataset.voicePreset === 'siliconflow';
1001
+ $('voiceEndpoint').value = siliconflow ? 'https://api.siliconflow.cn/v1/audio/transcriptions' : 'https://api.openai.com/v1/audio/transcriptions';
1002
+ $('voiceModel').value = siliconflow ? 'FunAudioLLM/SenseVoiceSmall' : 'gpt-transcribe';
1003
+ voiceFieldsTouched = true;
1004
+ setVoiceSettingsMessage(m('app.220'));
1005
+ }));
1006
+ $('clearOpenaiKey')?.addEventListener('click', () => saveVoiceSettings(true));
1007
+ $('jobForm')?.addEventListener('submit', async (event) => {
1008
+ event.preventDefault();
1009
+ const instruction = $('instruction').value.trim(), session = selectedSession();
1010
+ if (!instruction || !session) { setFormMessage(m('app.221')); return; }
1011
+ if (state.busy.has('new-turn')) return;
1012
+ state.busy.add('new-turn'); renderSelects(); setFormMessage(m('app.222'), true);
1013
+ try {
1014
+ await post('/api/jobs', { sessionId: session.id, instruction });
1015
+ state.previewProvider = session.provider;
1016
+ state.previewSessionId = session.id;
1017
+ $('instruction').value = '';
1018
+ setFormMessage(m('app.223'), true);
1019
+ showToast(t('app.224'));
1020
+ await refresh();
1021
+ }
1022
+ catch (error) { setFormMessage(error.message || m('app.225')); showToast(error.message || t('app.225'), true); }
1023
+ finally { state.busy.delete('new-turn'); render(); }
1024
+ });
1025
+ applyStaticTranslations();
1026
+ $('languageSelect').value = getLocale();
1027
+ render(); refresh(); setInterval(refresh, 4000);
1028
+ })();