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.
- package/README.md +42 -19
- package/dist/lib/mcp-sync.js +3 -1
- package/dist/lib/mcp-sync.js.map +1 -1
- package/dist/lib/quota-copilot.js +93 -0
- package/dist/lib/quota-copilot.js.map +1 -1
- package/dist/lib/stt.js +95 -0
- package/dist/lib/stt.js.map +1 -0
- package/dist/lib/token-keepalive.js +34 -0
- package/dist/lib/token-keepalive.js.map +1 -0
- package/dist/server.js +68 -1
- package/dist/server.js.map +1 -1
- package/dist/src/prompt/builder.js +82 -264
- package/dist/src/prompt/builder.js.map +1 -1
- package/dist/src/prompt/template-loader.js +46 -0
- package/dist/src/prompt/template-loader.js.map +1 -0
- package/dist/src/prompt/templates/a1-system.md +106 -0
- package/dist/src/prompt/templates/a2-default.md +17 -0
- package/dist/src/prompt/templates/employee.md +37 -0
- package/dist/src/prompt/templates/heartbeat-default.md +4 -0
- package/dist/src/prompt/templates/heartbeat-jobs.md +4 -0
- package/dist/src/prompt/templates/orchestration.md +64 -0
- package/dist/src/prompt/templates/skills.md +18 -0
- package/dist/src/prompt/templates/vision-click.md +3 -0
- package/dist/src/prompt/templates/worker-context.md +8 -0
- package/dist/src/telegram/bot.js +2 -0
- package/dist/src/telegram/bot.js.map +1 -1
- package/dist/src/telegram/voice.js +25 -0
- package/dist/src/telegram/voice.js.map +1 -0
- package/package.json +2 -2
- package/public/css/chat.css +38 -0
- package/public/dist/bundle.js +59 -58
- package/public/dist/bundle.js.map +4 -4
- package/public/index.html +41 -0
- package/public/js/features/chat.ts +25 -0
- package/public/js/features/settings.ts +123 -2
- package/public/js/features/voice-recorder.ts +150 -0
- package/public/js/main.ts +13 -1
- package/public/js/state.ts +2 -0
- package/public/locales/en.json +15 -0
- 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());
|
package/public/js/state.ts
CHANGED
|
@@ -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
|
};
|
package/public/locales/en.json
CHANGED
|
@@ -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.",
|
package/public/locales/ko.json
CHANGED
|
@@ -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 μμ΄μ νΈκ° μλ΅ν©λλ€.",
|