cli-jaw 1.3.0-preview.2 → 1.3.0-preview.3

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/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,23 +376,48 @@
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="vortex">Vortex</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
- <div class="settings-row sub-row">
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
+ <!-- Vortex -->
415
+ <div class="settings-row sub-row stt-vortex" style="display:none">
416
+ <label data-i18n="stt.vortexConfig">Vortex Config (JSON)</label>
417
+ <textarea id="sttVortexJson" rows="4" placeholder='{"endpoint":"...","token":"..."}'
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">
@@ -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,11 +190,33 @@ 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 vortexJson = document.getElementById('sttVortexJson') 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';
202
+ if (openaiBaseUrl) openaiBaseUrl.value = sttConfig.openaiBaseUrl || '';
203
+ if (openaiKey) openaiKey.placeholder = sttConfig.openaiKeySet ? '••••••••' : 'sk-...';
204
+ if (openaiModel) openaiModel.value = sttConfig.openaiModel || '';
205
+ if (vortexJson) vortexJson.value = sttConfig.vortexConfig || '';
206
+
207
+ function toggleProviderFields() {
208
+ const v = engine?.value || 'auto';
209
+ const showGemini = v === 'auto' || v === 'gemini';
210
+ const showOpenai = v === 'openai';
211
+ const showVortex = v === 'vortex';
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-vortex').forEach(el => (el as HTMLElement).style.display = showVortex ? '' : 'none');
216
+ document.querySelectorAll('.stt-whisper').forEach(el => (el as HTMLElement).style.display = showWhisper ? '' : 'none');
217
+ }
218
+ toggleProviderFields();
219
+ engine?.addEventListener('change', toggleProviderFields);
198
220
 
199
221
  const btn = document.getElementById('sttSave');
200
222
  if (btn && !btn.dataset.bound) {
@@ -205,13 +227,24 @@ function initSttSettings(sttConfig: Record<string, any>): void {
205
227
  engine: engine?.value || 'auto',
206
228
  geminiModel: geminiModel?.value || 'gemini-2.5-flash-lite',
207
229
  whisperModel: whisperModel?.value || '',
230
+ openaiBaseUrl: openaiBaseUrl?.value || '',
231
+ openaiModel: openaiModel?.value || '',
232
+ vortexConfig: vortexJson?.value || '',
208
233
  },
209
234
  };
210
235
  if (geminiKey?.value) patch.stt.geminiApiKey = geminiKey.value;
211
- await apiJson('/api/settings', 'PUT', patch);
212
- if (geminiKey) {
213
- geminiKey.value = '';
214
- geminiKey.placeholder = '••••••••';
236
+ if (openaiKey?.value) patch.stt.openaiApiKey = openaiKey.value;
237
+ console.log('[stt] saving:', { engine: patch.stt.engine, hasGeminiKey: !!patch.stt.geminiApiKey, hasOpenaiKey: !!patch.stt.openaiApiKey });
238
+ try {
239
+ await apiJson('/api/settings', 'PUT', patch);
240
+ btn.textContent = t('stt.saved');
241
+ setTimeout(() => btn.textContent = t('stt.save'), 2000);
242
+ if (geminiKey) { geminiKey.value = ''; geminiKey.placeholder = '••••••••'; }
243
+ if (openaiKey) { openaiKey.value = ''; openaiKey.placeholder = '••••••••'; }
244
+ } catch (e) {
245
+ console.error('[stt] save failed:', e);
246
+ btn.textContent = '❌ Save failed';
247
+ setTimeout(() => btn.textContent = t('stt.save'), 3000);
215
248
  }
216
249
  });
217
250
  }
@@ -553,13 +586,25 @@ function renderCliStatus(data: { cliStatus: Record<string, { available: boolean
553
586
  windowsHtml = q.windows.map(w => {
554
587
  const pct = Math.round(w.percent);
555
588
  const barColor = pct > 80 ? '#ef4444' : pct > 50 ? '#fbbf24' : '#38bdf8';
589
+ const shortLabel = w.label.replace('-hour', 'h').replace('-day', 'd').replace(' Sonnet', '').replace(' Opus', '');
590
+ let resetStr = '';
591
+ if (w.resetsAt) {
592
+ const d = new Date(typeof w.resetsAt === 'number' ? w.resetsAt * 1000 : w.resetsAt);
593
+ const now = new Date();
594
+ if (d.toDateString() === now.toDateString()) {
595
+ resetStr = `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
596
+ } else {
597
+ resetStr = `${d.getMonth() + 1}/${d.getDate()}`;
598
+ }
599
+ }
556
600
  return `
557
- <div style="display:flex;align-items:center;gap:6px;margin-left:16px;font-size:10px;color:var(--text-dim)">
558
- <span style="width:42px">${w.label}</span>
601
+ <div style="display:flex;align-items:center;gap:4px;margin-left:16px;font-size:10px;color:var(--text-dim)">
602
+ <span style="width:18px">${shortLabel}</span>
559
603
  <div style="flex:1;height:4px;background:var(--border);border-radius:2px;overflow:hidden">
560
604
  <div style="width:${pct}%;height:100%;background:${barColor};border-radius:2px"></div>
561
605
  </div>
562
- <span style="width:28px;text-align:right">${pct}%</span>
606
+ <span style="width:24px;text-align:right">${pct}%</span>
607
+ ${resetStr ? `<span style="width:30px;text-align:right;opacity:0.6">${resetStr}</span>` : ''}
563
608
  </div>
564
609
  `;
565
610
  }).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
- if (!btn) return;
147
- btn.classList.toggle('recording', recording);
148
- btn.textContent = recording ? '⏹' : '🎤';
149
- btn.title = recording ? t('voice.stop') : t('voice.start');
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
  });
@@ -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.vortexConfig": "Vortex 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.",
@@ -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.vortexConfig": "Vortex 설정 (JSON)",
121
125
  "tg.timeout": "⏰ 시간 초과 (20분 무응답)",
122
126
  "tg.noResponse": "응답 없음",
123
127
  "tg.connected": "🦈 Jaw Agent 연결됨! 메시지를 보내면 AI 에이전트가 응답합니다.",