uwonbot 1.1.6 → 1.1.8
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/bin/uwonbot.js +1 -1
- package/package.json +1 -1
- package/src/agent.js +30 -26
- package/src/chat.js +50 -21
- package/src/terminalTTS.js +99 -0
- package/src/voiceInput.js +8 -1
package/bin/uwonbot.js
CHANGED
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -323,36 +323,26 @@ async function handleCommand(msg) {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
async function openWebAssistant(assistantId) {
|
|
326
|
-
const
|
|
326
|
+
const chatUrl = `${WEB_APP_URL}/assistant/live?id=${assistantId}`;
|
|
327
327
|
try {
|
|
328
328
|
if (platform === 'darwin') {
|
|
329
|
-
await execAsync(`open -na "Google Chrome" --args --new-window
|
|
329
|
+
await execAsync(`open -na "Google Chrome" --args --new-window --window-size=380,520 --window-position=1000,200 "${chatUrl}" 2>/dev/null || open "${chatUrl}"`);
|
|
330
330
|
} else if (platform === 'win32') {
|
|
331
|
-
await execAsync(`start chrome --new-window "${
|
|
331
|
+
await execAsync(`start chrome --new-window --window-size=380,520 --window-position=1000,200 "${chatUrl}" 2>nul || start "" "${chatUrl}"`);
|
|
332
332
|
} else {
|
|
333
|
-
await execAsync(`google-chrome --new-window "${
|
|
333
|
+
await execAsync(`google-chrome --new-window "${chatUrl}" 2>/dev/null || xdg-open "${chatUrl}"`);
|
|
334
334
|
}
|
|
335
335
|
return true;
|
|
336
336
|
} catch {
|
|
337
|
-
try { await execAsync(`open "${
|
|
337
|
+
try { await execAsync(`open "${chatUrl}"`); return true; } catch { return false; }
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
async function activateAllAssistants(assistants) {
|
|
342
342
|
const opened = [];
|
|
343
343
|
for (const a of assistants) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
await openTerminalWithChat(a.name);
|
|
347
|
-
opened.push({ name: a.name, mode: 'terminal' });
|
|
348
|
-
} else if (mode === 'web') {
|
|
349
|
-
await openWebAssistant(a.id);
|
|
350
|
-
opened.push({ name: a.name, mode: 'web' });
|
|
351
|
-
} else {
|
|
352
|
-
await openTerminalWithChat(a.name);
|
|
353
|
-
await openWebAssistant(a.id);
|
|
354
|
-
opened.push({ name: a.name, mode: 'both' });
|
|
355
|
-
}
|
|
344
|
+
await openWebAssistant(a.id);
|
|
345
|
+
opened.push({ name: a.name, mode: 'web' });
|
|
356
346
|
await new Promise(r => setTimeout(r, 500));
|
|
357
347
|
}
|
|
358
348
|
return opened;
|
|
@@ -449,17 +439,23 @@ export async function startAgent(port = 9876, options = {}) {
|
|
|
449
439
|
console.log(chalk.bold.cyan(' 👏 박수 감지! 비서 활성화 중...'));
|
|
450
440
|
|
|
451
441
|
if (userAssistants.length === 0) {
|
|
452
|
-
console.log(chalk.gray(' → 기본 Uwonbot 실행'));
|
|
453
|
-
|
|
442
|
+
console.log(chalk.gray(' → 기본 Uwonbot 웹 실행'));
|
|
443
|
+
try {
|
|
444
|
+
if (platform === 'darwin') {
|
|
445
|
+
await execAsync(`open "${WEB_APP_URL}/assistant"`);
|
|
446
|
+
} else if (platform === 'win32') {
|
|
447
|
+
await execAsync(`start "" "${WEB_APP_URL}/assistant"`);
|
|
448
|
+
} else {
|
|
449
|
+
await execAsync(`xdg-open "${WEB_APP_URL}/assistant"`);
|
|
450
|
+
}
|
|
451
|
+
} catch {}
|
|
454
452
|
return;
|
|
455
453
|
}
|
|
456
454
|
|
|
457
455
|
if (userAssistants.length === 1) {
|
|
458
456
|
const a = userAssistants[0];
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (mode === 'terminal' || mode === 'both') await openTerminalWithChat(a.name);
|
|
462
|
-
if (mode === 'web' || mode === 'both') await openWebAssistant(a.id);
|
|
457
|
+
console.log(chalk.green(` → ${a.name} 웹 실행`));
|
|
458
|
+
await openWebAssistant(a.id);
|
|
463
459
|
return;
|
|
464
460
|
}
|
|
465
461
|
|
|
@@ -488,7 +484,11 @@ export async function startAgent(port = 9876, options = {}) {
|
|
|
488
484
|
console.log(chalk.green(` ✓ Client connected from ${origin}`));
|
|
489
485
|
|
|
490
486
|
ws.on('message', async (data) => {
|
|
491
|
-
const
|
|
487
|
+
const raw = data.toString();
|
|
488
|
+
let _reqId;
|
|
489
|
+
try { _reqId = JSON.parse(raw)._reqId; } catch {}
|
|
490
|
+
const result = await handleCommand(raw);
|
|
491
|
+
if (_reqId) result._reqId = _reqId;
|
|
492
492
|
ws.send(JSON.stringify(result));
|
|
493
493
|
});
|
|
494
494
|
|
|
@@ -508,10 +508,14 @@ export async function startAgent(port = 9876, options = {}) {
|
|
|
508
508
|
const retry = new WebSocketServer({ port });
|
|
509
509
|
retry.on('connection', (ws, req) => {
|
|
510
510
|
ws.on('message', async (data) => {
|
|
511
|
-
const
|
|
511
|
+
const raw = data.toString();
|
|
512
|
+
let _reqId;
|
|
513
|
+
try { _reqId = JSON.parse(raw)._reqId; } catch {}
|
|
514
|
+
const result = await handleCommand(raw);
|
|
515
|
+
if (_reqId) result._reqId = _reqId;
|
|
512
516
|
ws.send(JSON.stringify(result));
|
|
513
517
|
});
|
|
514
|
-
ws.send(JSON.stringify({ type: 'welcome', agent: 'uwonbot', version: '1.1.
|
|
518
|
+
ws.send(JSON.stringify({ type: 'welcome', agent: 'uwonbot', version: '1.1.8', uid }));
|
|
515
519
|
});
|
|
516
520
|
console.log(chalk.green(` ✓ 재시도 성공 — ws://localhost:${port}`));
|
|
517
521
|
} catch {
|
package/src/chat.js
CHANGED
|
@@ -7,7 +7,8 @@ import open from 'open';
|
|
|
7
7
|
import { getConfig } from './config.js';
|
|
8
8
|
import { sendToBrain } from './brain.js';
|
|
9
9
|
import { showMiniBar } from './banner.js';
|
|
10
|
-
|
|
10
|
+
// terminalOrb is used by agent.js for display; chat.js shows text-only
|
|
11
|
+
import { speak } from './terminalTTS.js';
|
|
11
12
|
import VoiceInput from './voiceInput.js';
|
|
12
13
|
import {
|
|
13
14
|
fetchAssistants,
|
|
@@ -177,19 +178,19 @@ export async function startChat(assistantName, assistant, initialCommand, option
|
|
|
177
178
|
|
|
178
179
|
const colorsArr = assistant.colors;
|
|
179
180
|
const orbColorHex = (Array.isArray(colorsArr) && colorsArr[0]) || assistant.orbColor || brainColor;
|
|
180
|
-
const orbRgb = hexToRgb(orbColorHex);
|
|
181
181
|
|
|
182
182
|
console.clear();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
|
|
184
|
+
const c = chalk.hex(brainColor);
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(c(' ●') + chalk.bold.white(` ${assistant.name}`));
|
|
187
|
+
console.log(chalk.gray(` ${brainLabel} | ${assistant.voiceLang || 'ko-KR'}`));
|
|
188
|
+
console.log('');
|
|
189
189
|
|
|
190
190
|
await bootSequence(assistant, brainLabel, brainColor);
|
|
191
191
|
|
|
192
|
-
const
|
|
192
|
+
const hasApiKey = !!(assistant.apiKey || process.env.GEMINI_API_KEY);
|
|
193
|
+
const voiceMode = options.voice !== undefined ? options.voice : hasApiKey;
|
|
193
194
|
|
|
194
195
|
console.log('');
|
|
195
196
|
console.log(chalk.gray(' ─────────────────────────────────────────'));
|
|
@@ -221,22 +222,27 @@ export async function startChat(assistantName, assistant, initialCommand, option
|
|
|
221
222
|
const ok = await voiceInput.start({
|
|
222
223
|
onListening: () => {
|
|
223
224
|
if (!processingVoice) {
|
|
224
|
-
process.stdout.write(chalk.gray(' 🎙 듣고 있습니다...
|
|
225
|
+
process.stdout.write('\x1b[2K\r' + chalk.gray(' 🎙 듣고 있습니다... (말하세요)'));
|
|
225
226
|
}
|
|
226
227
|
},
|
|
227
228
|
onSpeechStart: () => {
|
|
228
229
|
process.stdout.write('\x1b[2K\r');
|
|
229
|
-
process.stdout.write(chalk.cyan(' 🔴 음성 감지 중...') + '\r');
|
|
230
230
|
},
|
|
231
231
|
onSpeechEnd: () => {
|
|
232
|
-
process.stdout.write('\x1b[2K\r');
|
|
233
|
-
|
|
232
|
+
process.stdout.write('\x1b[2K\r' + chalk.yellow(' ⏳ 음성 인식 중...'));
|
|
233
|
+
},
|
|
234
|
+
onAmplitude: (amp, isSpeaking) => {
|
|
235
|
+
if (processingVoice) return;
|
|
236
|
+
if (isSpeaking) {
|
|
237
|
+
const bar = renderVoiceBar(amp);
|
|
238
|
+
process.stdout.write('\x1b[2K\r' + chalk.cyan(' 🔴 ') + chalk.hex(brainColor)(bar) + chalk.gray(' 말하는 중...'));
|
|
239
|
+
}
|
|
234
240
|
},
|
|
235
241
|
onTranscript: async (text) => {
|
|
236
242
|
if (processingVoice) return;
|
|
237
243
|
processingVoice = true;
|
|
238
244
|
process.stdout.write('\x1b[2K\r');
|
|
239
|
-
console.log(chalk.hex(brainColor)(` You
|
|
245
|
+
console.log(chalk.hex(brainColor)(` 🗣 You > `) + chalk.white(text));
|
|
240
246
|
rl.pause();
|
|
241
247
|
await processMessage(text, messages, assistant, brainColor);
|
|
242
248
|
processingVoice = false;
|
|
@@ -285,13 +291,18 @@ export async function startChat(assistantName, assistant, initialCommand, option
|
|
|
285
291
|
voiceInput = new VoiceInput(apiKey);
|
|
286
292
|
await voiceInput.start({
|
|
287
293
|
onListening: () => {},
|
|
288
|
-
onSpeechStart: () =>
|
|
289
|
-
onSpeechEnd: () => process.stdout.write(chalk.
|
|
294
|
+
onSpeechStart: () => {},
|
|
295
|
+
onSpeechEnd: () => process.stdout.write('\x1b[2K\r' + chalk.yellow(' ⏳ 인식 중...')),
|
|
296
|
+
onAmplitude: (amp, isSpeaking) => {
|
|
297
|
+
if (processingVoice || !isSpeaking) return;
|
|
298
|
+
const bar = renderVoiceBar(amp);
|
|
299
|
+
process.stdout.write('\x1b[2K\r' + chalk.cyan(' 🔴 ') + chalk.hex(brainColor)(bar));
|
|
300
|
+
},
|
|
290
301
|
onTranscript: async (text) => {
|
|
291
302
|
if (processingVoice) return;
|
|
292
303
|
processingVoice = true;
|
|
293
304
|
process.stdout.write('\x1b[2K\r');
|
|
294
|
-
console.log(chalk.hex(brainColor)(` You
|
|
305
|
+
console.log(chalk.hex(brainColor)(` 🗣 You > `) + chalk.white(text));
|
|
295
306
|
rl.pause();
|
|
296
307
|
await processMessage(text, messages, assistant, brainColor);
|
|
297
308
|
processingVoice = false;
|
|
@@ -330,6 +341,13 @@ export async function startChat(assistantName, assistant, initialCommand, option
|
|
|
330
341
|
});
|
|
331
342
|
}
|
|
332
343
|
|
|
344
|
+
function renderVoiceBar(amplitude) {
|
|
345
|
+
const barLen = Math.round(amplitude * 40);
|
|
346
|
+
const bar = '█'.repeat(Math.min(barLen, 40));
|
|
347
|
+
const empty = '░'.repeat(40 - Math.min(barLen, 40));
|
|
348
|
+
return bar + empty;
|
|
349
|
+
}
|
|
350
|
+
|
|
333
351
|
async function processMessage(input, messages, assistant, brainColor) {
|
|
334
352
|
const spinner = ora({
|
|
335
353
|
text: chalk.gray('Thinking...'),
|
|
@@ -337,6 +355,9 @@ async function processMessage(input, messages, assistant, brainColor) {
|
|
|
337
355
|
color: 'blue',
|
|
338
356
|
}).start();
|
|
339
357
|
|
|
358
|
+
const vLang = assistant.voiceLang || 'ko-KR';
|
|
359
|
+
const vGender = assistant.voiceGender || assistant.voiceStyle || 'male';
|
|
360
|
+
|
|
340
361
|
try {
|
|
341
362
|
const reply = await sendToBrain(assistant, messages, input);
|
|
342
363
|
spinner.stop();
|
|
@@ -348,12 +369,20 @@ async function processMessage(input, messages, assistant, brainColor) {
|
|
|
348
369
|
console.log(chalk.white(` ${line}`));
|
|
349
370
|
}
|
|
350
371
|
console.log('');
|
|
372
|
+
|
|
373
|
+
speak(reply, { lang: vLang, gender: vGender }).catch(() => {});
|
|
351
374
|
} catch (err) {
|
|
352
375
|
spinner.stop();
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
376
|
+
const rawMsg = err.message || '';
|
|
377
|
+
let friendlyMsg = rawMsg;
|
|
378
|
+
if (rawMsg.includes('API_KEY_INVALID') || rawMsg.includes('API key not valid')) {
|
|
379
|
+
friendlyMsg = 'API 키가 유효하지 않습니다. 비서 설정에서 올바른 API 키를 확인해주세요.';
|
|
380
|
+
} else if (rawMsg.includes('No API key')) {
|
|
381
|
+
friendlyMsg = 'API 키가 설정되지 않았습니다. 웹에서 비서의 API 키를 설정해주세요.';
|
|
382
|
+
}
|
|
383
|
+
console.log(chalk.red(`\n ❌ ${friendlyMsg}\n`));
|
|
384
|
+
if (rawMsg.includes('API key') || rawMsg.includes('api key') || rawMsg.includes('No API key')) {
|
|
385
|
+
console.log(chalk.gray(' API 키 설정: https://chartapp-653e1.web.app/assistant/create\n'));
|
|
357
386
|
}
|
|
358
387
|
}
|
|
359
388
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import fetch from 'node-fetch';
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const platform = process.platform;
|
|
10
|
+
|
|
11
|
+
const GOOGLE_TTS_FUNCTION = 'https://us-central1-chartapp-653e1.cloudfunctions.net/googleTTS';
|
|
12
|
+
|
|
13
|
+
async function speakWithGoogleTTS(text, lang = 'ko-KR', gender = 'male') {
|
|
14
|
+
try {
|
|
15
|
+
const res = await fetch(GOOGLE_TTS_FUNCTION, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify({ text, lang, gender }),
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) throw new Error(`TTS API ${res.status}`);
|
|
21
|
+
const data = await res.json();
|
|
22
|
+
if (!data.audio) throw new Error('No audio');
|
|
23
|
+
|
|
24
|
+
const tmpFile = join(tmpdir(), `uwonbot_tts_${Date.now()}.mp3`);
|
|
25
|
+
writeFileSync(tmpFile, Buffer.from(data.audio, 'base64'));
|
|
26
|
+
|
|
27
|
+
if (platform === 'darwin') {
|
|
28
|
+
await execAsync(`afplay "${tmpFile}"`);
|
|
29
|
+
} else if (platform === 'win32') {
|
|
30
|
+
await execAsync(`powershell -c "(New-Object Media.SoundPlayer '${tmpFile}').PlaySync()"`);
|
|
31
|
+
} else {
|
|
32
|
+
try {
|
|
33
|
+
await execAsync(`mpg123 "${tmpFile}" 2>/dev/null || ffplay -nodisp -autoexit "${tmpFile}" 2>/dev/null || aplay "${tmpFile}" 2>/dev/null`);
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function speakWithSay(text, lang = 'ko-KR', gender = 'male') {
|
|
45
|
+
if (platform !== 'darwin') return false;
|
|
46
|
+
|
|
47
|
+
let voice;
|
|
48
|
+
if (lang.startsWith('ko')) {
|
|
49
|
+
voice = gender === 'female' ? 'Yuna' : 'Jian';
|
|
50
|
+
} else {
|
|
51
|
+
voice = gender === 'female' ? 'Samantha' : 'Daniel';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await execAsync(`say -v "${voice}" "${text.replace(/"/g, '\\"')}"`);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
try {
|
|
59
|
+
await execAsync(`say "${text.replace(/"/g, '\\"')}"`);
|
|
60
|
+
return true;
|
|
61
|
+
} catch { return false; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function speakWithEspeak(text, lang = 'ko-KR') {
|
|
66
|
+
if (platform === 'darwin') return false;
|
|
67
|
+
const espeakLang = lang.startsWith('ko') ? 'ko' : 'en';
|
|
68
|
+
try {
|
|
69
|
+
await execAsync(`espeak -v ${espeakLang} "${text.replace(/"/g, '\\"')}" 2>/dev/null`);
|
|
70
|
+
return true;
|
|
71
|
+
} catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function speak(text, options = {}) {
|
|
75
|
+
const lang = options.lang || 'ko-KR';
|
|
76
|
+
const gender = options.gender || 'male';
|
|
77
|
+
|
|
78
|
+
if (!text || !text.trim()) return;
|
|
79
|
+
|
|
80
|
+
const ok = await speakWithGoogleTTS(text, lang, gender);
|
|
81
|
+
if (ok) return;
|
|
82
|
+
|
|
83
|
+
if (platform === 'darwin') {
|
|
84
|
+
await speakWithSay(text, lang, gender);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await speakWithEspeak(text, lang);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function speakStreaming(text, options = {}) {
|
|
92
|
+
const sentences = text.match(/[^.!?。!?\n]+[.!?。!?\n]?/g) || [text];
|
|
93
|
+
for (const s of sentences) {
|
|
94
|
+
const t = s.trim();
|
|
95
|
+
if (t) await speak(t, options);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default { speak, speakStreaming };
|
package/src/voiceInput.js
CHANGED
|
@@ -78,11 +78,12 @@ export default class VoiceInput {
|
|
|
78
78
|
this.onSpeechEnd = null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
async start({ onTranscript, onListening, onSpeechStart, onSpeechEnd }) {
|
|
81
|
+
async start({ onTranscript, onListening, onSpeechStart, onSpeechEnd, onAmplitude }) {
|
|
82
82
|
this.onTranscript = onTranscript;
|
|
83
83
|
this.onListening = onListening;
|
|
84
84
|
this.onSpeechStart = onSpeechStart;
|
|
85
85
|
this.onSpeechEnd = onSpeechEnd;
|
|
86
|
+
this.onAmplitude = onAmplitude;
|
|
86
87
|
|
|
87
88
|
let micModule;
|
|
88
89
|
try {
|
|
@@ -120,10 +121,14 @@ export default class VoiceInput {
|
|
|
120
121
|
|
|
121
122
|
this.onListening?.();
|
|
122
123
|
|
|
124
|
+
let speechDuration = 0;
|
|
125
|
+
|
|
123
126
|
stream.on('data', (buf) => {
|
|
124
127
|
if (!this.running) return;
|
|
125
128
|
const amp = getAmplitude(buf);
|
|
126
129
|
|
|
130
|
+
this.onAmplitude?.(amp, isSpeaking);
|
|
131
|
+
|
|
127
132
|
if (amp > SILENCE_THRESHOLD) {
|
|
128
133
|
if (!isSpeaking) {
|
|
129
134
|
isSpeaking = true;
|
|
@@ -133,6 +138,7 @@ export default class VoiceInput {
|
|
|
133
138
|
}
|
|
134
139
|
silenceStart = null;
|
|
135
140
|
speechChunks.push(Buffer.from(buf));
|
|
141
|
+
speechDuration = Date.now() - speechStart;
|
|
136
142
|
} else if (isSpeaking) {
|
|
137
143
|
speechChunks.push(Buffer.from(buf));
|
|
138
144
|
if (!silenceStart) silenceStart = Date.now();
|
|
@@ -141,6 +147,7 @@ export default class VoiceInput {
|
|
|
141
147
|
const duration = Date.now() - speechStart;
|
|
142
148
|
isSpeaking = false;
|
|
143
149
|
silenceStart = null;
|
|
150
|
+
speechDuration = 0;
|
|
144
151
|
this.onSpeechEnd?.();
|
|
145
152
|
|
|
146
153
|
if (duration >= MIN_SPEECH_MS && speechChunks.length > 0) {
|