cli-jaw 1.2.9 β†’ 1.3.0-preview.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 (40) hide show
  1. package/README.md +42 -19
  2. package/dist/lib/mcp-sync.js +3 -1
  3. package/dist/lib/mcp-sync.js.map +1 -1
  4. package/dist/lib/quota-copilot.js +93 -0
  5. package/dist/lib/quota-copilot.js.map +1 -1
  6. package/dist/lib/stt.js +95 -0
  7. package/dist/lib/stt.js.map +1 -0
  8. package/dist/lib/token-keepalive.js +34 -0
  9. package/dist/lib/token-keepalive.js.map +1 -0
  10. package/dist/server.js +68 -1
  11. package/dist/server.js.map +1 -1
  12. package/dist/src/prompt/builder.js +82 -264
  13. package/dist/src/prompt/builder.js.map +1 -1
  14. package/dist/src/prompt/template-loader.js +46 -0
  15. package/dist/src/prompt/template-loader.js.map +1 -0
  16. package/dist/src/prompt/templates/a1-system.md +106 -0
  17. package/dist/src/prompt/templates/a2-default.md +17 -0
  18. package/dist/src/prompt/templates/employee.md +37 -0
  19. package/dist/src/prompt/templates/heartbeat-default.md +4 -0
  20. package/dist/src/prompt/templates/heartbeat-jobs.md +4 -0
  21. package/dist/src/prompt/templates/orchestration.md +64 -0
  22. package/dist/src/prompt/templates/skills.md +18 -0
  23. package/dist/src/prompt/templates/vision-click.md +3 -0
  24. package/dist/src/prompt/templates/worker-context.md +8 -0
  25. package/dist/src/telegram/bot.js +2 -0
  26. package/dist/src/telegram/bot.js.map +1 -1
  27. package/dist/src/telegram/voice.js +25 -0
  28. package/dist/src/telegram/voice.js.map +1 -0
  29. package/package.json +2 -2
  30. package/public/css/chat.css +38 -0
  31. package/public/dist/bundle.js +59 -58
  32. package/public/dist/bundle.js.map +4 -4
  33. package/public/index.html +41 -0
  34. package/public/js/features/chat.ts +25 -0
  35. package/public/js/features/settings.ts +123 -2
  36. package/public/js/features/voice-recorder.ts +150 -0
  37. package/public/js/main.ts +13 -1
  38. package/public/js/state.ts +2 -0
  39. package/public/locales/en.json +15 -0
  40. package/public/locales/ko.json +15 -0
package/public/index.html CHANGED
@@ -89,6 +89,7 @@
89
89
  </div>
90
90
  <button class="sidebar-hb-btn" id="btnClearChat">/clear</button>
91
91
  <button class="sidebar-hb-btn" id="hbSidebarBtn">πŸ’“ Heartbeat (0)</button>
92
+ <button class="sidebar-hb-btn" data-action="openTemplates">πŸ“ ν”„λ‘¬ν”„νŠΈ ν…œν”Œλ¦Ώ</button>
92
93
  <button class="sidebar-hb-btn" id="langToggle" title="ν•œκ΅­μ–΄ ⇄ English">🌐 ν•œκ΅­μ–΄</button>
93
94
  </div>
94
95
  </nav>
@@ -136,6 +137,8 @@
136
137
  style="display:none"></div>
137
138
  <button class="btn-attach" data-i18n-title="btn.attach" title="파일 첨뢀">πŸ“Ž</button>
138
139
  <input type="file" id="fileInput" hidden multiple>
140
+ <button class="btn-voice" id="btnVoice" data-i18n-title="voice.start" title="μŒμ„± λ…ΉμŒ">🎀</button>
141
+ <span class="voice-timer" id="voiceTimer" style="display:none">00:00</span>
139
142
  <textarea class="chat-input" id="chatInput" rows="1" data-i18n-placeholder="input.placeholder"
140
143
  placeholder="λ©”μ‹œμ§€ μž…λ ₯..." aria-label="Chat message input" aria-haspopup="listbox" aria-expanded="false"
141
144
  aria-controls="cmdDropdown" aria-autocomplete="list" aria-activedescendant=""></textarea>
@@ -484,6 +487,44 @@
484
487
  <button id="mobileMenuRight" aria-label="Open settings">βš™οΈ Settings</button>
485
488
  </div>
486
489
 
490
+ <!-- Template Editor Modal (Node Map + Editor 2-view) -->
491
+ <div class="modal-overlay" id="templateModal" role="dialog" aria-modal="true" aria-label="Prompt templates">
492
+ <div class="modal-box" style="width:640px;max-width:90vw;max-height:85vh;display:flex;flex-direction:column">
493
+ <div class="modal-header">
494
+ <span id="templateModalTitle">πŸ“ ν”„λ‘¬ν”„νŠΈ ꡬ쑰</span>
495
+ <button data-action="closeTemplate"
496
+ style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:16px">βœ•</button>
497
+ </div>
498
+ <!-- View 1: Node Map -->
499
+ <div id="templateTreeView" style="flex:1;overflow-y:auto;padding:12px 16px">
500
+ <div id="templateTree"></div>
501
+ <div style="padding:8px 0;font-size:11px;color:var(--text-dim)">
502
+ πŸ”’ ν΄λ¦­ν•˜λ©΄ ν”„λ‘¬ν”„νŠΈ λ‚΄μš© 확인 Β· ⚠️ 개발자 λͺ¨λ“œμ—μ„œ νŽΈμ§‘
503
+ </div>
504
+ </div>
505
+ <!-- View 2: Editor (hidden by default) -->
506
+ <div id="templateEditorView" style="display:none;flex-direction:column">
507
+ <div style="display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:1px solid var(--border)">
508
+ <button id="templateBack"
509
+ style="background:none;border:1px solid var(--border);color:var(--text-dim);padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px">←
510
+ λ’€λ‘œ</button>
511
+ <span id="templateEditorLabel" style="flex:1;font-size:12px;color:var(--text-dim)"></span>
512
+ <button id="templateDevToggle"
513
+ style="background:var(--bg);border:1px solid var(--border);color:var(--text-dim);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">πŸ”§
514
+ 개발자 λͺ¨λ“œ</button>
515
+ </div>
516
+ <textarea class="modal-textarea" id="templateEditor" readonly
517
+ style="flex:1;min-height:350px;font-family:var(--font-mono);font-size:12px"></textarea>
518
+ <div class="modal-footer">
519
+ <span id="templateVars"
520
+ style="color:var(--text-dim);font-size:10px;margin-right:auto"></span>
521
+ <button class="btn-save" data-action="saveTemplate" style="display:none"
522
+ id="templateSaveBtn">πŸ’Ύ μ €μž₯</button>
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+
487
528
  <script type="module" src="/dist/bundle.js"></script>
488
529
  </body>
489
530
 
@@ -251,3 +251,28 @@ export function initDragDrop(): void {
251
251
  }
252
252
  });
253
253
  }
254
+
255
+ /** Upload recorded voice blob and send to /api/voice for STT */
256
+ export async function sendVoiceToServer(blob: Blob, ext: string, mime: string): Promise<void> {
257
+ addMessage('user', '🎀 [μŒμ„± λ©”μ‹œμ§€]');
258
+ try {
259
+ const res = await fetch('/api/voice', {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': mime,
263
+ 'X-Voice-Ext': ext,
264
+ },
265
+ body: blob,
266
+ });
267
+ if (!res.ok) {
268
+ const data = await res.json().catch(() => ({}));
269
+ throw new Error(data.error || `HTTP ${res.status}`);
270
+ }
271
+ const result = await res.json().catch(() => null);
272
+ if (result?.text) {
273
+ addSystemMsg(`🎀 STT (${result.engine}, ${result.elapsed?.toFixed(1)}s): "${result.text.slice(0, 100)}"`, '', 'info');
274
+ }
275
+ } catch (err) {
276
+ addSystemMsg(t('voice.sttFail', { msg: (err as Error).message }), '', 'error');
277
+ }
278
+ }
@@ -495,7 +495,6 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
495
495
  if (parts.length) accountLine = `<div style="font-size:10px;color:var(--text-dim);margin:2px 0 4px 16px">${escapeHtml(parts.join(' Β· '))}</div>`;
496
496
  }
497
497
 
498
- // Auth hint when CLI is not available OR not authenticated
499
498
  let authHint = '';
500
499
  if (!info.available || dotClass === 'warn') {
501
500
  const hint = AUTH_HINTS[name];
@@ -534,7 +533,7 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
534
533
  <div class="settings-group" style="margin-bottom:6px;padding:8px 10px">
535
534
  <div class="cli-status-row">
536
535
  <span class="cli-dot ${dotClass}"></span>
537
- <span class="cli-name" style="font-weight:600">${name}</span>
536
+ <span class="cli-name" style="font-weight:600">${name}</span>${name === 'copilot' ? `<button id="copilotKeychainBtn" style="font-size:9px;margin-left:6px;padding:1px 5px;background:var(--border);color:var(--text-dim);border:1px solid var(--text-dim);border-radius:3px;cursor:pointer;vertical-align:middle;line-height:1" title="${t('copilot.keychainHint')}">πŸ”‘</button>` : ''}
538
537
  </div>
539
538
  ${accountLine}
540
539
  ${authHint}
@@ -544,6 +543,24 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
544
543
  }
545
544
 
546
545
  if (el) el.innerHTML = html;
546
+
547
+ // Copilot keychain refresh handler β€” shows each token source result
548
+ const kcBtn = document.getElementById('copilotKeychainBtn');
549
+ if (kcBtn) {
550
+ kcBtn.addEventListener('click', async () => {
551
+ const btn = kcBtn as HTMLButtonElement;
552
+ btn.disabled = true;
553
+ btn.textContent = '⏳';
554
+ try {
555
+ const res = await api<{ ok: boolean }>('/api/copilot/refresh', { method: 'POST' });
556
+ btn.textContent = res?.ok ? 'βœ…' : '❌';
557
+ if (res?.ok) await loadCliStatus(true);
558
+ } catch {
559
+ btn.textContent = '❌';
560
+ }
561
+ setTimeout(() => { btn.textContent = 'πŸ”‘'; btn.disabled = false; }, 2000);
562
+ });
563
+ }
547
564
  }
548
565
 
549
566
  // ── Prompt Modal ──
@@ -567,3 +584,107 @@ export async function savePromptFromModal(): Promise<void> {
567
584
  await apiJson('/api/prompt', 'PUT', { content });
568
585
  document.getElementById('promptModal')?.classList.remove('open');
569
586
  }
587
+
588
+ // ── Template Modal (Node Map + Editor) ──
589
+
590
+ interface TemplateInfo { id: string; filename: string; content: string; }
591
+ interface TreeNode { id: string; label: string; emoji: string; children: string[]; }
592
+ let _templates: TemplateInfo[] = [];
593
+ let _devMode = false;
594
+
595
+ export async function openTemplateModal(): Promise<void> {
596
+ const data = await api<{ templates: TemplateInfo[]; tree: TreeNode[] }>('/api/prompt-templates');
597
+ if (!data) return;
598
+ _templates = data.templates;
599
+ _devMode = false;
600
+ renderTree(data.tree);
601
+ showTemplateView('tree');
602
+ document.getElementById('templateModal')?.classList.add('open');
603
+ }
604
+
605
+ function renderTree(tree: TreeNode[]): void {
606
+ const container = document.getElementById('templateTree');
607
+ if (!container) return;
608
+ container.innerHTML = '';
609
+ for (const group of tree) {
610
+ const main = document.createElement('div');
611
+ main.style.cssText = 'background:var(--bg);border:1px solid var(--accent);border-radius:6px;padding:8px 10px;margin:8px 0 4px;font-size:12px;color:var(--accent);font-weight:600';
612
+ main.textContent = `${group.emoji} ${group.label}`;
613
+ container.appendChild(main);
614
+ for (const childId of group.children) {
615
+ const tmpl = _templates.find(t => t.id === childId);
616
+ if (!tmpl) continue;
617
+ const node = document.createElement('div');
618
+ node.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:6px 10px;margin:2px 0 2px 24px;font-size:12px;cursor:pointer;transition:border-color .15s';
619
+ node.textContent = `πŸ“„ ${tmpl.filename}`;
620
+ node.addEventListener('mouseenter', () => { node.style.borderColor = 'var(--accent2)'; });
621
+ node.addEventListener('mouseleave', () => { node.style.borderColor = 'var(--border)'; });
622
+ node.addEventListener('click', () => { openTemplateEditor(tmpl); });
623
+ container.appendChild(node);
624
+ }
625
+ }
626
+ }
627
+
628
+ function openTemplateEditor(tmpl: TemplateInfo): void {
629
+ const editor = document.getElementById('templateEditor') as HTMLTextAreaElement;
630
+ editor.value = tmpl.content;
631
+ editor.dataset.templateId = tmpl.id;
632
+ editor.readOnly = true;
633
+ _devMode = false;
634
+ const label = document.getElementById('templateEditorLabel');
635
+ if (label) label.textContent = `πŸ“„ ${tmpl.filename}`;
636
+ const vars = tmpl.content.match(/\{\{[A-Z_]+\}\}/g);
637
+ const varsEl = document.getElementById('templateVars');
638
+ if (varsEl) varsEl.textContent = vars ? `vars: ${[...new Set(vars)].join(', ')}` : 'no variables';
639
+ const saveBtn = document.getElementById('templateSaveBtn');
640
+ if (saveBtn) saveBtn.style.display = 'none';
641
+ const toggle = document.getElementById('templateDevToggle');
642
+ if (toggle) { toggle.style.color = 'var(--text-dim)'; toggle.style.borderColor = 'var(--border)'; toggle.textContent = 'πŸ”§ 개발자 λͺ¨λ“œ'; }
643
+ const title = document.getElementById('templateModalTitle');
644
+ if (title) title.textContent = `πŸ“„ ${tmpl.filename}`;
645
+ showTemplateView('editor');
646
+ }
647
+
648
+ export function toggleDevMode(): void {
649
+ if (!_devMode) {
650
+ if (!confirm('⚠️ ν”„λ‘¬ν”„νŠΈλ₯Ό 직접 μˆ˜μ •ν•˜λ©΄ μ˜ˆμƒμΉ˜ λͺ»ν•œ λ™μž‘μ΄ λ°œμƒν•  수 μžˆμŠ΅λ‹ˆλ‹€.\nκ³„μ†ν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?')) return;
651
+ }
652
+ _devMode = !_devMode;
653
+ const editor = document.getElementById('templateEditor') as HTMLTextAreaElement;
654
+ editor.readOnly = !_devMode;
655
+ const saveBtn = document.getElementById('templateSaveBtn');
656
+ if (saveBtn) saveBtn.style.display = _devMode ? '' : 'none';
657
+ const toggle = document.getElementById('templateDevToggle');
658
+ if (toggle) {
659
+ toggle.style.color = _devMode ? 'var(--stop-btn)' : 'var(--text-dim)';
660
+ toggle.style.borderColor = _devMode ? 'var(--stop-btn)' : 'var(--border)';
661
+ toggle.textContent = _devMode ? 'πŸ”“ 개발자 λͺ¨λ“œ ON' : 'πŸ”§ 개발자 λͺ¨λ“œ';
662
+ }
663
+ }
664
+
665
+ export async function saveTemplateFromModal(): Promise<void> {
666
+ const editor = document.getElementById('templateEditor') as HTMLTextAreaElement;
667
+ const id = editor.dataset.templateId;
668
+ if (!id) return;
669
+ await apiJson(`/api/prompt-templates/${id}`, 'PUT', { content: editor.value });
670
+ const label = document.getElementById('templateEditorLabel');
671
+ if (label) { label.textContent = 'βœ… μ €μž₯ + ν•«λ¦¬λ‘œλ“œ μ™„λ£Œ!'; setTimeout(() => { label.textContent = `πŸ“„ ${id}.md`; }, 2000); }
672
+ const t = _templates.find(x => x.id === id);
673
+ if (t) t.content = editor.value;
674
+ }
675
+
676
+ function showTemplateView(view: 'tree' | 'editor'): void {
677
+ const treeView = document.getElementById('templateTreeView');
678
+ const editorView = document.getElementById('templateEditorView');
679
+ if (treeView) treeView.style.display = view === 'tree' ? '' : 'none';
680
+ if (editorView) editorView.style.display = view === 'editor' ? 'flex' : 'none';
681
+ const title = document.getElementById('templateModalTitle');
682
+ if (title && view === 'tree') title.textContent = 'πŸ“ ν”„λ‘¬ν”„νŠΈ ꡬ쑰';
683
+ }
684
+
685
+ export function templateGoBack(): void { showTemplateView('tree'); }
686
+
687
+ export function closeTemplateModal(e?: Event): void {
688
+ if (e && e.target !== e.currentTarget) return;
689
+ document.getElementById('templateModal')?.classList.remove('open');
690
+ }
@@ -0,0 +1,150 @@
1
+ // ── Voice Recorder Feature ──
2
+ // Cross-platform: Chrome/Firefox/Edge (webm/opus), Safari (mp4/aac)
3
+ import { state } from '../state.js';
4
+ import { addSystemMsg } from '../ui.js';
5
+ import { t } from './i18n.js';
6
+ import { sendVoiceToServer } from './chat.js';
7
+
8
+ let mediaRecorder: MediaRecorder | null = null;
9
+ let chunks: Blob[] = [];
10
+ let recordingStream: MediaStream | null = null;
11
+ let timerInterval: ReturnType<typeof setInterval> | null = null;
12
+ let startTime = 0;
13
+
14
+ /** Pick best supported MIME type for cross-platform */
15
+ function pickMime(): string {
16
+ if (typeof MediaRecorder === 'undefined') return '';
17
+ const candidates = [
18
+ 'audio/webm;codecs=opus', // Chrome, Firefox, Edge
19
+ 'audio/mp4', // Safari (macOS/iOS)
20
+ 'audio/ogg;codecs=opus', // Firefox fallback
21
+ ];
22
+ for (const m of candidates) {
23
+ if (MediaRecorder.isTypeSupported(m)) return m;
24
+ }
25
+ return ''; // browser default
26
+ }
27
+
28
+ /** Classify getUserMedia errors into user-friendly messages */
29
+ function classifyMicError(err: unknown): string {
30
+ const e = err as DOMException;
31
+ switch (e.name) {
32
+ case 'NotAllowedError':
33
+ return t('voice.micDenied');
34
+ case 'NotFoundError':
35
+ return t('voice.micNotFound');
36
+ case 'NotReadableError':
37
+ case 'AbortError':
38
+ return t('voice.micBusy');
39
+ default:
40
+ if (e instanceof TypeError || !navigator.mediaDevices) {
41
+ return t('voice.httpsRequired');
42
+ }
43
+ return t('voice.micDenied');
44
+ }
45
+ }
46
+
47
+ export async function startRecording(): Promise<void> {
48
+ if (state.isRecording) return;
49
+
50
+ // Feature detection
51
+ if (typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
52
+ addSystemMsg(t('voice.unsupported'), '', 'error');
53
+ return;
54
+ }
55
+
56
+ try {
57
+ recordingStream = await navigator.mediaDevices.getUserMedia({ audio: true });
58
+ } catch (err) {
59
+ addSystemMsg(classifyMicError(err), '', 'error');
60
+ return;
61
+ }
62
+
63
+ const mimeType = pickMime();
64
+ const options: MediaRecorderOptions = mimeType ? { mimeType } : {};
65
+ mediaRecorder = new MediaRecorder(recordingStream, options);
66
+ chunks = [];
67
+
68
+ mediaRecorder.ondataavailable = (e) => {
69
+ if (e.data.size > 0) chunks.push(e.data);
70
+ };
71
+
72
+ mediaRecorder.onerror = () => {
73
+ stopRecording();
74
+ addSystemMsg(t('voice.interrupted'), '', 'error');
75
+ };
76
+
77
+ mediaRecorder.onstop = async () => {
78
+ const actualMime = mediaRecorder?.mimeType || mimeType || 'audio/webm';
79
+ const ext = actualMime.includes('mp4') ? '.m4a'
80
+ : actualMime.includes('ogg') ? '.ogg'
81
+ : '.webm';
82
+ const blob = new Blob(chunks, { type: actualMime });
83
+ chunks = [];
84
+ releaseStream();
85
+
86
+ if (blob.size > 20 * 1024 * 1024) {
87
+ addSystemMsg(t('voice.tooLarge'), '', 'error');
88
+ return;
89
+ }
90
+ if (blob.size < 1000) {
91
+ addSystemMsg(t('voice.tooShort'), '', 'error');
92
+ return;
93
+ }
94
+
95
+ await sendVoiceToServer(blob, ext, actualMime);
96
+ };
97
+
98
+ // iOS Safari: no timeslice support β†’ call start() without args
99
+ mediaRecorder.start();
100
+ state.isRecording = true;
101
+ startTime = Date.now();
102
+ updateRecordingUI(true);
103
+ startTimer();
104
+ }
105
+
106
+ export function stopRecording(): void {
107
+ if (!state.isRecording || !mediaRecorder) return;
108
+ if (mediaRecorder.state === 'recording') {
109
+ mediaRecorder.stop();
110
+ }
111
+ state.isRecording = false;
112
+ stopTimer();
113
+ updateRecordingUI(false);
114
+ }
115
+
116
+ export function toggleRecording(): void {
117
+ if (state.isRecording) stopRecording();
118
+ else startRecording();
119
+ }
120
+
121
+ function releaseStream(): void {
122
+ recordingStream?.getTracks().forEach(track => track.stop());
123
+ recordingStream = null;
124
+ }
125
+
126
+ function startTimer(): void {
127
+ const el = document.getElementById('voiceTimer');
128
+ if (!el) return;
129
+ el.style.display = 'inline';
130
+ timerInterval = setInterval(() => {
131
+ const sec = Math.floor((Date.now() - startTime) / 1000);
132
+ const m = String(Math.floor(sec / 60)).padStart(2, '0');
133
+ const s = String(sec % 60).padStart(2, '0');
134
+ el.textContent = `${m}:${s}`;
135
+ }, 500);
136
+ }
137
+
138
+ function stopTimer(): void {
139
+ if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
140
+ const el = document.getElementById('voiceTimer');
141
+ if (el) { el.style.display = 'none'; el.textContent = '00:00'; }
142
+ }
143
+
144
+ function updateRecordingUI(recording: boolean): void {
145
+ const btn = document.getElementById('btnVoice');
146
+ if (!btn) return;
147
+ btn.classList.toggle('recording', recording);
148
+ btn.textContent = recording ? '⏹' : '🎀';
149
+ btn.title = recording ? t('voice.stop') : t('voice.start');
150
+ }
package/public/js/main.ts CHANGED
@@ -22,7 +22,8 @@ import {
22
22
  loadSettings, setPerm, handleModelSelect, applyCustomModel, onCliChange,
23
23
  saveActiveCliSettings, savePerCli, updateSettings, openPromptModal,
24
24
  closePromptModal, savePromptFromModal, syncMcpServers, installMcpGlobal,
25
- loadCliStatus, setTelegram, setForwardAll, saveTelegramSettings, saveFallbackOrder
25
+ loadCliStatus, setTelegram, setForwardAll, saveTelegramSettings, saveFallbackOrder,
26
+ openTemplateModal, saveTemplateFromModal, closeTemplateModal, templateGoBack, toggleDevMode
26
27
  } from './features/settings.js';
27
28
  import {
28
29
  loadEmployees, addEmployee, deleteEmployee, updateEmployee,
@@ -43,6 +44,7 @@ import { initAppName } from './features/appname.js';
43
44
  import { initSidebar, toggleLeft, toggleRight } from './features/sidebar.js';
44
45
  import { initTheme } from './features/theme.js';
45
46
  import { initI18n, setLang, getLang, t } from './features/i18n.js';
47
+ import { toggleRecording } from './features/voice-recorder.js';
46
48
 
47
49
  // ── Chat Actions ──
48
50
  document.getElementById('btnSend')?.addEventListener('click', sendMessage);
@@ -73,6 +75,7 @@ document.getElementById('filePreviewList')?.addEventListener('click', (e) => {
73
75
  document.querySelector('.btn-attach')?.addEventListener('click', () => {
74
76
  (document.getElementById('fileInput') as HTMLInputElement | null)?.click();
75
77
  });
78
+ document.getElementById('btnVoice')?.addEventListener('click', () => toggleRecording());
76
79
 
77
80
  // ── Left Sidebar ──
78
81
  document.getElementById('memorySidebarBtn')?.addEventListener('click', openMemoryModal);
@@ -195,6 +198,15 @@ document.querySelector('[data-action="cancelPrompt"]')?.addEventListener('click'
195
198
  document.querySelector('[data-action="savePrompt"]')?.addEventListener('click', savePromptFromModal);
196
199
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePromptModal(); });
197
200
 
201
+ // ── Template Modal ──
202
+ document.querySelector('[data-action="openTemplates"]')?.addEventListener('click', openTemplateModal);
203
+ document.querySelector('[data-action="saveTemplate"]')?.addEventListener('click', saveTemplateFromModal);
204
+ document.querySelector('[data-action="closeTemplate"]')?.addEventListener('click', () => closeTemplateModal());
205
+ document.getElementById('templateModal')?.addEventListener('click', (e) => closeTemplateModal(e));
206
+ document.querySelector('#templateModal .modal-box')?.addEventListener('click', (e) => e.stopPropagation());
207
+ document.getElementById('templateBack')?.addEventListener('click', templateGoBack);
208
+ document.getElementById('templateDevToggle')?.addEventListener('click', toggleDevMode);
209
+
198
210
  // ── Heartbeat Modal ──
199
211
  document.getElementById('heartbeatModal')?.addEventListener('click', (e) => closeHeartbeatModal(e));
200
212
  document.querySelector('#heartbeatModal .modal-box')?.addEventListener('click', (e) => e.stopPropagation());
@@ -25,6 +25,7 @@ export interface AppState {
25
25
  heartbeatJobs: HeartbeatJob[];
26
26
  cliStatusCache: CliStatusCache | null;
27
27
  cliStatusTs: number;
28
+ isRecording: boolean;
28
29
  }
29
30
 
30
31
  export const state: AppState = {
@@ -39,4 +40,5 @@ export const state: AppState = {
39
40
  cliStatusCache: null,
40
41
  cliStatusTs: 0,
41
42
  orcState: 'IDLE',
43
+ isRecording: false,
42
44
  };
@@ -94,6 +94,21 @@
94
94
  "tg.imageCaption": "[πŸ“· Image] {caption}",
95
95
  "tg.imageFail": "❌ Image processing failed: {msg}",
96
96
  "tg.fileFail": "❌ File processing failed: {msg}",
97
+ "tg.voiceFail": "❌ Voice transcription failed: {msg}",
98
+ "tg.voiceEmpty": "πŸ€” Couldn't understand the voice message. Please try again.",
99
+ "voice.start": "Record voice",
100
+ "voice.stop": "Stop recording",
101
+ "voice.micDenied": "🎀 Microphone access denied. Please allow microphone in browser settings.",
102
+ "voice.micNotFound": "🎀 No microphone found. Please connect a microphone.",
103
+ "voice.micBusy": "🎀 Microphone is in use by another app.",
104
+ "voice.httpsRequired": "🎀 Voice recording requires HTTPS or localhost.",
105
+ "voice.unsupported": "🎀 Voice recording is not supported in this browser.",
106
+ "voice.interrupted": "⚠️ Recording was interrupted.",
107
+ "voice.tooLarge": "⚠️ Recording too large (max 20MB).",
108
+ "voice.tooShort": "⚠️ Recording too short.",
109
+ "voice.sttFail": "❌ Voice recognition failed: {msg}",
110
+ "copilot.keychain": "πŸ”‘ Keychain",
111
+ "copilot.keychainHint": "Re-read Copilot token from macOS Keychain",
97
112
  "tg.timeout": "⏰ Timeout (20 min no response)",
98
113
  "tg.noResponse": "No response",
99
114
  "tg.connected": "🦈 Jaw Agent connected! Send a message and the AI agent will respond.",
@@ -94,6 +94,21 @@
94
94
  "tg.imageCaption": "[πŸ“· 이미지] {caption}",
95
95
  "tg.imageFail": "❌ 이미지 처리 μ‹€νŒ¨: {msg}",
96
96
  "tg.fileFail": "❌ 파일 처리 μ‹€νŒ¨: {msg}",
97
+ "tg.voiceFail": "❌ μŒμ„± 인식 μ‹€νŒ¨: {msg}",
98
+ "tg.voiceEmpty": "πŸ€” μŒμ„±μ„ μΈμ‹ν•˜μ§€ λͺ»ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.",
99
+ "voice.start": "μŒμ„± λ…ΉμŒ",
100
+ "voice.stop": "λ…ΉμŒ 쀑지",
101
+ "voice.micDenied": "🎀 마이크 접근이 κ±°λΆ€λμ–΄μš”. λΈŒλΌμš°μ € μ„€μ •μ—μ„œ 마이크λ₯Ό ν—ˆμš©ν•΄ μ£Όμ„Έμš”.",
102
+ "voice.micNotFound": "🎀 마이크λ₯Ό 찾을 수 μ—†μ–΄μš”. 마이크λ₯Ό μ—°κ²°ν•΄ μ£Όμ„Έμš”.",
103
+ "voice.micBusy": "🎀 λ§ˆμ΄ν¬κ°€ λ‹€λ₯Έ μ•±μ—μ„œ μ‚¬μš© μ€‘μ΄μ—μš”.",
104
+ "voice.httpsRequired": "🎀 μŒμ„± λ…ΉμŒμ€ HTTPS λ˜λŠ” localhostμ—μ„œλ§Œ κ°€λŠ₯ν•΄μš”.",
105
+ "voice.unsupported": "🎀 이 λΈŒλΌμš°μ €μ—μ„œλŠ” μŒμ„± λ…ΉμŒμ„ μ§€μ›ν•˜μ§€ μ•Šμ•„μš”.",
106
+ "voice.interrupted": "⚠️ λ…ΉμŒμ΄ μ€‘λ‹¨λμ–΄μš”.",
107
+ "voice.tooLarge": "⚠️ λ…ΉμŒμ΄ λ„ˆλ¬΄ κΈΈμ–΄μš” (μ΅œλŒ€ 20MB).",
108
+ "voice.tooShort": "⚠️ λ…ΉμŒμ΄ λ„ˆλ¬΄ μ§§μ•„μš”.",
109
+ "voice.sttFail": "❌ μŒμ„± 인식 μ‹€νŒ¨: {msg}",
110
+ "copilot.keychain": "πŸ”‘ 킀체인",
111
+ "copilot.keychainHint": "macOS ν‚€μ²΄μΈμ—μ„œ Copilot 토큰을 λ‹€μ‹œ μ½μŠ΅λ‹ˆλ‹€",
97
112
  "tg.timeout": "⏰ μ‹œκ°„ 초과 (20λΆ„ 무응닡)",
98
113
  "tg.noResponse": "응닡 μ—†μŒ",
99
114
  "tg.connected": "🦈 Jaw Agent 연결됨! λ©”μ‹œμ§€λ₯Ό 보내면 AI μ—μ΄μ „νŠΈκ°€ μ‘λ‹΅ν•©λ‹ˆλ‹€.",