cli-jaw 1.3.0-preview.2 → 1.3.0-preview.4
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/dist/lib/stt.js +101 -1
- package/dist/lib/stt.js.map +1 -1
- package/dist/server.js +10 -2
- package/dist/server.js.map +1 -1
- package/dist/src/core/config.js +4 -0
- package/dist/src/core/config.js.map +1 -1
- package/package.json +1 -1
- package/public/css/chat.css +16 -0
- package/public/dist/bundle.js +58 -57
- package/public/dist/bundle.js.map +3 -3
- package/public/index.html +29 -5
- package/public/js/features/settings.ts +68 -22
- package/public/js/features/voice-recorder.ts +28 -4
- package/public/js/main.ts +8 -2
- package/public/locales/en.json +4 -0
- package/public/locales/ko.json +4 -0
package/public/index.html
CHANGED
|
@@ -139,6 +139,7 @@
|
|
|
139
139
|
<input type="file" id="fileInput" hidden multiple>
|
|
140
140
|
<button class="btn-voice" id="btnVoice" data-i18n-title="voice.start" title="음성 녹음">🎤</button>
|
|
141
141
|
<span class="voice-timer" id="voiceTimer" style="display:none">00:00</span>
|
|
142
|
+
<button class="btn-voice-cancel" id="btnVoiceCancel" style="display:none" title="취소">✕</button>
|
|
142
143
|
<textarea class="chat-input" id="chatInput" rows="1" data-i18n-placeholder="input.placeholder"
|
|
143
144
|
placeholder="메시지 입력..." aria-label="Chat message input" aria-haspopup="listbox" aria-expanded="false"
|
|
144
145
|
aria-controls="cmdDropdown" aria-autocomplete="list" aria-activedescendant=""></textarea>
|
|
@@ -375,29 +376,52 @@
|
|
|
375
376
|
<select id="sttEngine">
|
|
376
377
|
<option value="auto">Auto (Gemini → Whisper)</option>
|
|
377
378
|
<option value="gemini">Gemini only</option>
|
|
379
|
+
<option value="openai">OpenAI Compatible</option>
|
|
380
|
+
<option value="vertex">Vertex AI</option>
|
|
378
381
|
<option value="whisper">Whisper only (local)</option>
|
|
379
382
|
</select>
|
|
380
383
|
</div>
|
|
381
|
-
<div class="settings-row sub-row">
|
|
384
|
+
<div class="settings-row sub-row stt-gemini">
|
|
382
385
|
<label data-i18n="stt.geminiKey">Gemini API Key</label>
|
|
383
386
|
<input type="password" id="sttGeminiKey" placeholder="AIza..."
|
|
384
387
|
style="width:100%;font-size:11px;padding:4px">
|
|
385
388
|
</div>
|
|
386
|
-
<div class="settings-row sub-row">
|
|
389
|
+
<div class="settings-row sub-row stt-gemini">
|
|
387
390
|
<label data-i18n="stt.geminiModel">Gemini 모델</label>
|
|
388
391
|
<select id="sttGeminiModel">
|
|
392
|
+
<option value="gemini-3.1-flash-lite">gemini-3.1-flash-lite (fastest)</option>
|
|
389
393
|
<option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite (fast)</option>
|
|
390
394
|
<option value="gemini-2.5-flash">gemini-2.5-flash (balanced)</option>
|
|
391
395
|
<option value="gemini-2.5-pro">gemini-2.5-pro (accurate)</option>
|
|
392
396
|
</select>
|
|
393
397
|
</div>
|
|
394
|
-
|
|
398
|
+
<!-- OpenAI Compatible -->
|
|
399
|
+
<div class="settings-row sub-row stt-openai" style="display:none">
|
|
400
|
+
<label data-i18n="stt.openaiBaseUrl">Base URL</label>
|
|
401
|
+
<input type="text" id="sttOpenaiBaseUrl" placeholder="https://api.groq.com/openai"
|
|
402
|
+
style="width:100%;font-size:11px;padding:4px">
|
|
403
|
+
</div>
|
|
404
|
+
<div class="settings-row sub-row stt-openai" style="display:none">
|
|
405
|
+
<label data-i18n="stt.openaiKey">API Key</label>
|
|
406
|
+
<input type="password" id="sttOpenaiKey" placeholder="sk-..."
|
|
407
|
+
style="width:100%;font-size:11px;padding:4px">
|
|
408
|
+
</div>
|
|
409
|
+
<div class="settings-row sub-row stt-openai" style="display:none">
|
|
410
|
+
<label data-i18n="stt.openaiModel">Model</label>
|
|
411
|
+
<input type="text" id="sttOpenaiModel" placeholder="whisper-large-v3"
|
|
412
|
+
style="width:100%;font-size:11px;padding:4px">
|
|
413
|
+
</div>
|
|
414
|
+
<!-- Vertex AI -->
|
|
415
|
+
<div class="settings-row sub-row stt-vertex" style="display:none">
|
|
416
|
+
<label data-i18n="stt.vertexConfig">Vertex AI Config (JSON)</label>
|
|
417
|
+
<textarea id="sttVertexJson" rows="4" placeholder='{"project_id":"...","location":"us-central1","credentials":{...}}'
|
|
418
|
+
style="width:100%;font-size:11px;padding:4px;font-family:var(--font-mono)"></textarea>
|
|
419
|
+
</div>
|
|
420
|
+
<div class="settings-row sub-row stt-whisper">
|
|
395
421
|
<label data-i18n="stt.whisperModel">Whisper 모델</label>
|
|
396
422
|
<input type="text" id="sttWhisperModel" placeholder="mlx-community/..."
|
|
397
423
|
style="width:100%;font-size:11px;padding:4px">
|
|
398
424
|
</div>
|
|
399
|
-
<button class="btn-clear" style="width:100%;margin:4px 0;padding:6px;font-size:11px"
|
|
400
|
-
id="sttSave" data-i18n="stt.save">💾 STT 설정 저장</button>
|
|
401
425
|
<small style="font-size:9px;color:var(--text-dim)" data-i18n="stt.shortcutHint">
|
|
402
426
|
⌨️ Ctrl+Shift+Space (⌘+Shift+Space on Mac)</small>
|
|
403
427
|
</div>
|
|
@@ -8,7 +8,7 @@ import { api, apiJson, apiFire } from '../api.js';
|
|
|
8
8
|
|
|
9
9
|
interface PerCliConfig { model?: string; effort?: string; }
|
|
10
10
|
interface TelegramConfig { enabled?: boolean; token?: string; allowedChatIds?: number[]; forwardAll?: boolean; }
|
|
11
|
-
interface QuotaWindow { label: string; percent: number; }
|
|
11
|
+
interface QuotaWindow { label: string; percent: number; resetsAt?: string | number | null; }
|
|
12
12
|
interface QuotaEntry {
|
|
13
13
|
account?: { email?: string; type?: string; plan?: string; tier?: string };
|
|
14
14
|
windows?: QuotaWindow[];
|
|
@@ -190,31 +190,65 @@ function initSttSettings(sttConfig: Record<string, any>): void {
|
|
|
190
190
|
const geminiKey = document.getElementById('sttGeminiKey') as HTMLInputElement | null;
|
|
191
191
|
const geminiModel = document.getElementById('sttGeminiModel') as HTMLSelectElement | null;
|
|
192
192
|
const whisperModel = document.getElementById('sttWhisperModel') as HTMLInputElement | null;
|
|
193
|
+
const openaiBaseUrl = document.getElementById('sttOpenaiBaseUrl') as HTMLInputElement | null;
|
|
194
|
+
const openaiKey = document.getElementById('sttOpenaiKey') as HTMLInputElement | null;
|
|
195
|
+
const openaiModel = document.getElementById('sttOpenaiModel') as HTMLInputElement | null;
|
|
196
|
+
const vertexJson = document.getElementById('sttVertexJson') as HTMLTextAreaElement | null;
|
|
193
197
|
|
|
194
198
|
if (engine) engine.value = sttConfig.engine || 'auto';
|
|
195
199
|
if (geminiKey) geminiKey.placeholder = sttConfig.geminiKeySet ? '••••••••' : 'AIza...';
|
|
196
200
|
if (geminiModel) geminiModel.value = sttConfig.geminiModel || 'gemini-2.5-flash-lite';
|
|
197
201
|
if (whisperModel) whisperModel.value = sttConfig.whisperModel || 'mlx-community/whisper-large-v3-turbo';
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
if (openaiBaseUrl) openaiBaseUrl.value = sttConfig.openaiBaseUrl || '';
|
|
203
|
+
if (openaiKey) openaiKey.placeholder = sttConfig.openaiKeySet ? '••••••••' : 'sk-...';
|
|
204
|
+
if (openaiModel) openaiModel.value = sttConfig.openaiModel || '';
|
|
205
|
+
if (vertexJson) vertexJson.value = sttConfig.vertexConfig || '';
|
|
206
|
+
|
|
207
|
+
function toggleProviderFields() {
|
|
208
|
+
const v = engine?.value || 'auto';
|
|
209
|
+
const showGemini = v === 'auto' || v === 'gemini';
|
|
210
|
+
const showOpenai = v === 'openai';
|
|
211
|
+
const showVertex = v === 'vertex';
|
|
212
|
+
const showWhisper = v === 'auto' || v === 'whisper';
|
|
213
|
+
document.querySelectorAll('.stt-gemini').forEach(el => (el as HTMLElement).style.display = showGemini ? '' : 'none');
|
|
214
|
+
document.querySelectorAll('.stt-openai').forEach(el => (el as HTMLElement).style.display = showOpenai ? '' : 'none');
|
|
215
|
+
document.querySelectorAll('.stt-vertex').forEach(el => (el as HTMLElement).style.display = showVertex ? '' : 'none');
|
|
216
|
+
document.querySelectorAll('.stt-whisper').forEach(el => (el as HTMLElement).style.display = showWhisper ? '' : 'none');
|
|
217
|
+
}
|
|
218
|
+
toggleProviderFields();
|
|
219
|
+
|
|
220
|
+
async function saveStt() {
|
|
221
|
+
const patch: Record<string, any> = {
|
|
222
|
+
stt: {
|
|
223
|
+
engine: engine?.value || 'auto',
|
|
224
|
+
geminiModel: geminiModel?.value || 'gemini-2.5-flash-lite',
|
|
225
|
+
whisperModel: whisperModel?.value || '',
|
|
226
|
+
openaiBaseUrl: openaiBaseUrl?.value || '',
|
|
227
|
+
openaiModel: openaiModel?.value || '',
|
|
228
|
+
vertexConfig: vertexJson?.value || '',
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
if (geminiKey?.value) patch.stt.geminiApiKey = geminiKey.value;
|
|
232
|
+
if (openaiKey?.value) patch.stt.openaiApiKey = openaiKey.value;
|
|
233
|
+
console.log('[stt] saving:', { engine: patch.stt.engine, hasGeminiKey: !!patch.stt.geminiApiKey, hasOpenaiKey: !!patch.stt.openaiApiKey });
|
|
234
|
+
try {
|
|
211
235
|
await apiJson('/api/settings', 'PUT', patch);
|
|
212
|
-
if (geminiKey) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
236
|
+
if (geminiKey?.value) { geminiKey.value = ''; geminiKey.placeholder = '••••••••'; }
|
|
237
|
+
if (openaiKey?.value) { openaiKey.value = ''; openaiKey.placeholder = '••••••••'; }
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error('[stt] save failed:', e);
|
|
240
|
+
}
|
|
217
241
|
}
|
|
242
|
+
|
|
243
|
+
// Auto-save on change (selects) and blur (text/password inputs)
|
|
244
|
+
engine?.addEventListener('change', () => { toggleProviderFields(); saveStt(); });
|
|
245
|
+
geminiModel?.addEventListener('change', saveStt);
|
|
246
|
+
geminiKey?.addEventListener('blur', () => { if (geminiKey.value) saveStt(); });
|
|
247
|
+
openaiKey?.addEventListener('blur', () => { if (openaiKey.value) saveStt(); });
|
|
248
|
+
openaiBaseUrl?.addEventListener('blur', saveStt);
|
|
249
|
+
openaiModel?.addEventListener('blur', saveStt);
|
|
250
|
+
whisperModel?.addEventListener('blur', saveStt);
|
|
251
|
+
vertexJson?.addEventListener('blur', saveStt);
|
|
218
252
|
}
|
|
219
253
|
|
|
220
254
|
interface McpData { servers: Record<string, { command: string; args?: string[] }>; }
|
|
@@ -553,13 +587,25 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
|
|
|
553
587
|
windowsHtml = q.windows.map(w => {
|
|
554
588
|
const pct = Math.round(w.percent);
|
|
555
589
|
const barColor = pct > 80 ? '#ef4444' : pct > 50 ? '#fbbf24' : '#38bdf8';
|
|
590
|
+
const shortLabel = w.label.replace('-hour', 'h').replace('-day', 'd').replace(' Sonnet', '').replace(' Opus', '');
|
|
591
|
+
let resetStr = '';
|
|
592
|
+
if (w.resetsAt) {
|
|
593
|
+
const d = new Date(typeof w.resetsAt === 'number' ? w.resetsAt * 1000 : w.resetsAt);
|
|
594
|
+
const now = new Date();
|
|
595
|
+
if (d.toDateString() === now.toDateString()) {
|
|
596
|
+
resetStr = `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
597
|
+
} else {
|
|
598
|
+
resetStr = `${d.getMonth() + 1}/${d.getDate()}`;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
556
601
|
return `
|
|
557
|
-
<div style="display:flex;align-items:center;gap:
|
|
558
|
-
<span style="width:
|
|
602
|
+
<div style="display:flex;align-items:center;gap:4px;margin-left:16px;font-size:10px;color:var(--text-dim)">
|
|
603
|
+
<span style="width:18px">${shortLabel}</span>
|
|
559
604
|
<div style="flex:1;height:4px;background:var(--border);border-radius:2px;overflow:hidden">
|
|
560
605
|
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:2px"></div>
|
|
561
606
|
</div>
|
|
562
|
-
<span style="width:
|
|
607
|
+
<span style="width:24px;text-align:right">${pct}%</span>
|
|
608
|
+
${resetStr ? `<span style="width:30px;text-align:right;opacity:0.6">${resetStr}</span>` : ''}
|
|
563
609
|
</div>
|
|
564
610
|
`;
|
|
565
611
|
}).join('');
|
|
@@ -5,6 +5,8 @@ import { addSystemMsg } from '../ui.js';
|
|
|
5
5
|
import { t } from './i18n.js';
|
|
6
6
|
import { sendVoiceToServer } from './chat.js';
|
|
7
7
|
|
|
8
|
+
let cancelled = false;
|
|
9
|
+
|
|
8
10
|
let mediaRecorder: MediaRecorder | null = null;
|
|
9
11
|
let chunks: Blob[] = [];
|
|
10
12
|
let recordingStream: MediaStream | null = null;
|
|
@@ -75,6 +77,12 @@ export async function startRecording(): Promise<void> {
|
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
mediaRecorder.onstop = async () => {
|
|
80
|
+
if (cancelled) {
|
|
81
|
+
chunks = [];
|
|
82
|
+
releaseStream();
|
|
83
|
+
cancelled = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
78
86
|
const actualMime = mediaRecorder?.mimeType || mimeType || 'audio/webm';
|
|
79
87
|
const ext = actualMime.includes('mp4') ? '.m4a'
|
|
80
88
|
: actualMime.includes('ogg') ? '.ogg'
|
|
@@ -113,6 +121,17 @@ export function stopRecording(): void {
|
|
|
113
121
|
updateRecordingUI(false);
|
|
114
122
|
}
|
|
115
123
|
|
|
124
|
+
export function cancelRecording(): void {
|
|
125
|
+
if (!state.isRecording || !mediaRecorder) return;
|
|
126
|
+
cancelled = true;
|
|
127
|
+
if (mediaRecorder.state === 'recording') {
|
|
128
|
+
mediaRecorder.stop();
|
|
129
|
+
}
|
|
130
|
+
state.isRecording = false;
|
|
131
|
+
stopTimer();
|
|
132
|
+
updateRecordingUI(false);
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
export function toggleRecording(): void {
|
|
117
136
|
if (state.isRecording) stopRecording();
|
|
118
137
|
else startRecording();
|
|
@@ -143,8 +162,13 @@ function stopTimer(): void {
|
|
|
143
162
|
|
|
144
163
|
function updateRecordingUI(recording: boolean): void {
|
|
145
164
|
const btn = document.getElementById('btnVoice');
|
|
146
|
-
|
|
147
|
-
btn
|
|
148
|
-
|
|
149
|
-
|
|
165
|
+
const cancelBtn = document.getElementById('btnVoiceCancel');
|
|
166
|
+
if (btn) {
|
|
167
|
+
btn.classList.toggle('recording', recording);
|
|
168
|
+
btn.textContent = recording ? '⏹' : '🎤';
|
|
169
|
+
btn.title = recording ? t('voice.stop') : t('voice.start');
|
|
170
|
+
}
|
|
171
|
+
if (cancelBtn) {
|
|
172
|
+
cancelBtn.style.display = recording ? 'inline-block' : 'none';
|
|
173
|
+
}
|
|
150
174
|
}
|
package/public/js/main.ts
CHANGED
|
@@ -44,7 +44,7 @@ import { initAppName } from './features/appname.js';
|
|
|
44
44
|
import { initSidebar, toggleLeft, toggleRight } from './features/sidebar.js';
|
|
45
45
|
import { initTheme } from './features/theme.js';
|
|
46
46
|
import { initI18n, setLang, getLang, t } from './features/i18n.js';
|
|
47
|
-
import { toggleRecording } from './features/voice-recorder.js';
|
|
47
|
+
import { toggleRecording, cancelRecording } from './features/voice-recorder.js';
|
|
48
48
|
|
|
49
49
|
// ── Chat Actions ──
|
|
50
50
|
document.getElementById('btnSend')?.addEventListener('click', sendMessage);
|
|
@@ -76,6 +76,7 @@ document.querySelector('.btn-attach')?.addEventListener('click', () => {
|
|
|
76
76
|
(document.getElementById('fileInput') as HTMLInputElement | null)?.click();
|
|
77
77
|
});
|
|
78
78
|
document.getElementById('btnVoice')?.addEventListener('click', () => toggleRecording());
|
|
79
|
+
document.getElementById('btnVoiceCancel')?.addEventListener('click', () => cancelRecording());
|
|
79
80
|
|
|
80
81
|
// ── Left Sidebar ──
|
|
81
82
|
document.getElementById('memorySidebarBtn')?.addEventListener('click', openMemoryModal);
|
|
@@ -196,7 +197,7 @@ document.querySelector('#promptModal .modal-box')?.addEventListener('click', (e)
|
|
|
196
197
|
document.querySelector('[data-action="closePrompt"]')?.addEventListener('click', () => closePromptModal());
|
|
197
198
|
document.querySelector('[data-action="cancelPrompt"]')?.addEventListener('click', () => closePromptModal());
|
|
198
199
|
document.querySelector('[data-action="savePrompt"]')?.addEventListener('click', savePromptFromModal);
|
|
199
|
-
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePromptModal(); });
|
|
200
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !state.isRecording) closePromptModal(); });
|
|
200
201
|
|
|
201
202
|
// ── Template Modal ──
|
|
202
203
|
document.querySelector('[data-action="openTemplates"]')?.addEventListener('click', openTemplateModal);
|
|
@@ -283,6 +284,11 @@ void bootstrap().catch((err: unknown) => {
|
|
|
283
284
|
// ── Keyboard: Escape closes modals ──────────────────
|
|
284
285
|
document.addEventListener('keydown', (e) => {
|
|
285
286
|
if (e.key === 'Escape') {
|
|
287
|
+
if (state.isRecording) {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
cancelRecording();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
286
292
|
document.querySelectorAll('.modal-overlay.open').forEach(m => {
|
|
287
293
|
m.classList.remove('open');
|
|
288
294
|
});
|
package/public/locales/en.json
CHANGED
|
@@ -118,6 +118,10 @@
|
|
|
118
118
|
"stt.save": "💾 Save STT Settings",
|
|
119
119
|
"stt.saved": "✅ Saved",
|
|
120
120
|
"stt.shortcutHint": "⌨️ Ctrl+Shift+Space (⌘+Shift+Space on Mac)",
|
|
121
|
+
"stt.openaiBaseUrl": "Base URL",
|
|
122
|
+
"stt.openaiKey": "API Key",
|
|
123
|
+
"stt.openaiModel": "Model",
|
|
124
|
+
"stt.vertexConfig": "Vertex AI Config (JSON)",
|
|
121
125
|
"tg.timeout": "⏰ Timeout (20 min no response)",
|
|
122
126
|
"tg.noResponse": "No response",
|
|
123
127
|
"tg.connected": "🦈 Jaw Agent connected! Send a message and the AI agent will respond.",
|
package/public/locales/ko.json
CHANGED
|
@@ -118,6 +118,10 @@
|
|
|
118
118
|
"stt.save": "💾 STT 설정 저장",
|
|
119
119
|
"stt.saved": "✅ 저장됨",
|
|
120
120
|
"stt.shortcutHint": "⌨️ Ctrl+Shift+Space (Mac: ⌘+Shift+Space)",
|
|
121
|
+
"stt.openaiBaseUrl": "Base URL",
|
|
122
|
+
"stt.openaiKey": "API Key",
|
|
123
|
+
"stt.openaiModel": "모델",
|
|
124
|
+
"stt.vertexConfig": "Vertex AI 설정 (JSON)",
|
|
121
125
|
"tg.timeout": "⏰ 시간 초과 (20분 무응답)",
|
|
122
126
|
"tg.noResponse": "응답 없음",
|
|
123
127
|
"tg.connected": "🦈 Jaw Agent 연결됨! 메시지를 보내면 AI 에이전트가 응답합니다.",
|