uwonbot 1.1.0 → 1.1.1

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 CHANGED
@@ -13,7 +13,7 @@ showBanner();
13
13
  program
14
14
  .name('uwonbot')
15
15
  .description('Uwonbot AI Assistant — Your AI controls your computer')
16
- .version('1.1.0');
16
+ .version('1.1.1');
17
17
 
18
18
  program
19
19
  .command('login')
@@ -44,19 +44,21 @@ program
44
44
  .command('chat [assistantName]')
45
45
  .description('Start chatting with an AI assistant')
46
46
  .option('-n, --name <name>', 'Assistant name to launch directly')
47
+ .option('-v, --voice', 'Enable hands-free voice input mode')
47
48
  .action(async (assistantName, opts) => {
48
49
  const config = getConfig();
49
50
  if (!config.get('uid')) {
50
51
  console.log('\n ⚠️ Please log in first: uwonbot login\n');
51
52
  process.exit(1);
52
53
  }
54
+ const chatOpts = { voice: opts.voice || false };
53
55
  const targetName = opts.name || assistantName;
54
56
  if (targetName) {
55
- await startChat(targetName);
57
+ await startChat(targetName, null, null, chatOpts);
56
58
  } else {
57
59
  const assistant = await selectAssistant();
58
60
  if (assistant) {
59
- await startChat(null, assistant);
61
+ await startChat(null, assistant, null, chatOpts);
60
62
  } else {
61
63
  const defaultBot = {
62
64
  name: 'Uwonbot',
@@ -66,7 +68,7 @@ program
66
68
  voiceStyle: 'male',
67
69
  isDefaultBot: true,
68
70
  };
69
- await startChat(null, defaultBot);
71
+ await startChat(null, defaultBot, null, chatOpts);
70
72
  }
71
73
  }
72
74
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uwonbot",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Uwonbot AI Assistant CLI — Your AI controls your computer",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/chat.js CHANGED
@@ -8,6 +8,7 @@ import { getConfig } from './config.js';
8
8
  import { sendToBrain } from './brain.js';
9
9
  import { showMiniBar } from './banner.js';
10
10
  import { printOrb, animateOrb } from './terminalOrb.js';
11
+ import VoiceInput from './voiceInput.js';
11
12
  import {
12
13
  hasRegisteredDevices,
13
14
  createCLISession,
@@ -110,7 +111,7 @@ async function requireBiometricAuth(uid) {
110
111
  return false;
111
112
  }
112
113
 
113
- export async function startChat(assistantName, assistant, initialCommand) {
114
+ export async function startChat(assistantName, assistant, initialCommand, options = {}) {
114
115
  const config = getConfig();
115
116
  const uid = config.get('uid');
116
117
  if (!uid) {
@@ -155,24 +156,72 @@ export async function startChat(assistantName, assistant, initialCommand) {
155
156
 
156
157
  await bootSequence(assistant, brainLabel, brainColor);
157
158
 
159
+ const voiceMode = options.voice || false;
160
+
158
161
  console.log('');
159
162
  console.log(chalk.gray(' ─────────────────────────────────────────'));
163
+ if (voiceMode) {
164
+ console.log(chalk.cyan(' 🎙 음성 모드 활성화 — 말하면 자동 인식됩니다'));
165
+ console.log(chalk.gray(' 텍스트 입력도 가능합니다'));
166
+ }
160
167
  console.log(chalk.gray(' "exit" 종료 | "clear" 대화 초기화'));
161
168
  console.log(chalk.gray(' ─────────────────────────────────────────'));
162
169
  console.log('');
163
170
 
164
171
  const messages = [];
165
-
166
- if (initialCommand) {
167
- await processMessage(initialCommand, messages, assistant, brainColor);
168
- }
172
+ let voiceInput = null;
173
+ let processingVoice = false;
169
174
 
170
175
  const rl = readline.createInterface({
171
176
  input: process.stdin,
172
177
  output: process.stdout,
173
- prompt: chalk.hex('#2563eb')(' You > '),
178
+ prompt: chalk.hex(brainColor)(' You > '),
174
179
  });
175
180
 
181
+ if (voiceMode) {
182
+ const apiKey = assistant.apiKey || process.env.GEMINI_API_KEY || '';
183
+ if (!apiKey) {
184
+ console.log(chalk.yellow(' ⚠ 음성 모드에는 API 키가 필요합니다 (음성→텍스트 변환에 Gemini 사용)'));
185
+ console.log(chalk.gray(' 텍스트 입력으로 진행합니다.\n'));
186
+ } else {
187
+ voiceInput = new VoiceInput(apiKey);
188
+ const ok = await voiceInput.start({
189
+ onListening: () => {
190
+ if (!processingVoice) {
191
+ process.stdout.write(chalk.gray(' 🎙 듣고 있습니다...') + '\r');
192
+ }
193
+ },
194
+ onSpeechStart: () => {
195
+ process.stdout.write('\x1b[2K\r');
196
+ process.stdout.write(chalk.cyan(' 🔴 음성 감지 중...') + '\r');
197
+ },
198
+ onSpeechEnd: () => {
199
+ process.stdout.write('\x1b[2K\r');
200
+ process.stdout.write(chalk.gray(' ⏳ 음성 인식 중...') + '\r');
201
+ },
202
+ onTranscript: async (text) => {
203
+ if (processingVoice) return;
204
+ processingVoice = true;
205
+ process.stdout.write('\x1b[2K\r');
206
+ console.log(chalk.hex(brainColor)(` You (voice) > `) + chalk.white(text));
207
+ rl.pause();
208
+ await processMessage(text, messages, assistant, brainColor);
209
+ processingVoice = false;
210
+ rl.resume();
211
+ rl.prompt();
212
+ },
213
+ });
214
+ if (!ok) {
215
+ voiceInput = null;
216
+ console.log(chalk.gray(' 텍스트 입력으로 진행합니다.\n'));
217
+ }
218
+ }
219
+ }
220
+
221
+ if (initialCommand) {
222
+ await processMessage(initialCommand, messages, assistant, brainColor);
223
+ }
224
+
176
225
  rl.prompt();
177
226
 
178
227
  rl.on('line', async (line) => {
@@ -183,7 +232,8 @@ export async function startChat(assistantName, assistant, initialCommand) {
183
232
  }
184
233
 
185
234
  if (input.toLowerCase() === 'exit' || input.toLowerCase() === 'quit') {
186
- console.log(chalk.gray('\n Goodbye! 👋\n'));
235
+ if (voiceInput) voiceInput.stop();
236
+ console.log(chalk.gray('\n Goodbye!\n'));
187
237
  rl.close();
188
238
  process.exit(0);
189
239
  }
@@ -195,17 +245,42 @@ export async function startChat(assistantName, assistant, initialCommand) {
195
245
  return;
196
246
  }
197
247
 
198
- if (input.toLowerCase() === 'tools') {
199
- console.log('');
200
- console.log(chalk.white.bold(' Available Tools:'));
201
- console.log(chalk.gray(' ────────────────'));
202
- console.log(' 📂 read_file, write_file, list_directory, create_directory');
203
- console.log(' 🗑️ delete_file, move_file, search_files');
204
- console.log(' ⚙️ run_shell, install_package');
205
- console.log(' 🌐 open_url, open_application');
206
- console.log(' 📋 get_clipboard, set_clipboard');
207
- console.log(' 💻 system_info');
208
- console.log('');
248
+ if (input.toLowerCase() === 'voice on') {
249
+ if (!voiceInput) {
250
+ const apiKey = assistant.apiKey || process.env.GEMINI_API_KEY || '';
251
+ if (apiKey) {
252
+ voiceInput = new VoiceInput(apiKey);
253
+ await voiceInput.start({
254
+ onListening: () => {},
255
+ onSpeechStart: () => process.stdout.write(chalk.cyan('\r 🔴 음성 감지 중...') + '\r'),
256
+ onSpeechEnd: () => process.stdout.write(chalk.gray('\r 인식 중...') + '\r'),
257
+ onTranscript: async (text) => {
258
+ if (processingVoice) return;
259
+ processingVoice = true;
260
+ process.stdout.write('\x1b[2K\r');
261
+ console.log(chalk.hex(brainColor)(` You (voice) > `) + chalk.white(text));
262
+ rl.pause();
263
+ await processMessage(text, messages, assistant, brainColor);
264
+ processingVoice = false;
265
+ rl.resume();
266
+ rl.prompt();
267
+ },
268
+ });
269
+ console.log(chalk.green(' 🎙 음성 모드 활성화'));
270
+ } else {
271
+ console.log(chalk.yellow(' ⚠ 음성 모드에는 API 키가 필요합니다.'));
272
+ }
273
+ }
274
+ rl.prompt();
275
+ return;
276
+ }
277
+
278
+ if (input.toLowerCase() === 'voice off') {
279
+ if (voiceInput) {
280
+ voiceInput.stop();
281
+ voiceInput = null;
282
+ console.log(chalk.gray(' 🔇 음성 모드 비활성화'));
283
+ }
209
284
  rl.prompt();
210
285
  return;
211
286
  }
@@ -217,6 +292,7 @@ export async function startChat(assistantName, assistant, initialCommand) {
217
292
  });
218
293
 
219
294
  rl.on('close', () => {
295
+ if (voiceInput) voiceInput.stop();
220
296
  process.exit(0);
221
297
  });
222
298
  }
@@ -0,0 +1,185 @@
1
+ import chalk from 'chalk';
2
+ import fetch from 'node-fetch';
3
+
4
+ const SILENCE_THRESHOLD = 0.02;
5
+ const SILENCE_DURATION_MS = 1200;
6
+ const MIN_SPEECH_MS = 400;
7
+ const SAMPLE_RATE = 16000;
8
+ const BITS_PER_SAMPLE = 16;
9
+ const CHANNELS = 1;
10
+
11
+ function createWavHeader(dataLength) {
12
+ const header = Buffer.alloc(44);
13
+ header.write('RIFF', 0);
14
+ header.writeUInt32LE(36 + dataLength, 4);
15
+ header.write('WAVE', 8);
16
+ header.write('fmt ', 12);
17
+ header.writeUInt32LE(16, 16);
18
+ header.writeUInt16LE(1, 20);
19
+ header.writeUInt16LE(CHANNELS, 22);
20
+ header.writeUInt32LE(SAMPLE_RATE, 24);
21
+ header.writeUInt32LE(SAMPLE_RATE * CHANNELS * (BITS_PER_SAMPLE / 8), 28);
22
+ header.writeUInt16LE(CHANNELS * (BITS_PER_SAMPLE / 8), 32);
23
+ header.writeUInt16LE(BITS_PER_SAMPLE, 34);
24
+ header.write('data', 36);
25
+ header.writeUInt32LE(dataLength, 40);
26
+ return header;
27
+ }
28
+
29
+ function getAmplitude(buf) {
30
+ let max = 0;
31
+ for (let i = 0; i < buf.length - 1; i += 2) {
32
+ const sample = buf.readInt16LE(i);
33
+ const abs = Math.abs(sample) / 32768;
34
+ if (abs > max) max = abs;
35
+ }
36
+ return max;
37
+ }
38
+
39
+ async function transcribeWithGemini(wavBuffer, apiKey) {
40
+ const base64Audio = wavBuffer.toString('base64');
41
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${encodeURIComponent(apiKey)}`;
42
+
43
+ const body = {
44
+ contents: [{
45
+ parts: [
46
+ { text: 'Transcribe the following audio exactly as spoken. Output ONLY the transcribed text, nothing else. If the audio is in Korean, output Korean. If in English, output English. If mixed, output mixed.' },
47
+ { inlineData: { mimeType: 'audio/wav', data: base64Audio } },
48
+ ],
49
+ }],
50
+ generationConfig: { maxOutputTokens: 512, temperature: 0.1 },
51
+ };
52
+
53
+ const res = await fetch(url, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify(body),
57
+ });
58
+
59
+ if (!res.ok) {
60
+ const err = await res.json().catch(() => ({}));
61
+ throw new Error(err?.error?.message || `Transcription failed (${res.status})`);
62
+ }
63
+
64
+ const data = await res.json();
65
+ const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
66
+ return text?.trim() || '';
67
+ }
68
+
69
+ export default class VoiceInput {
70
+ constructor(apiKey) {
71
+ this.apiKey = apiKey;
72
+ this.mic = null;
73
+ this.micInstance = null;
74
+ this.running = false;
75
+ this.onTranscript = null;
76
+ this.onListening = null;
77
+ this.onSpeechStart = null;
78
+ this.onSpeechEnd = null;
79
+ }
80
+
81
+ async start({ onTranscript, onListening, onSpeechStart, onSpeechEnd }) {
82
+ this.onTranscript = onTranscript;
83
+ this.onListening = onListening;
84
+ this.onSpeechStart = onSpeechStart;
85
+ this.onSpeechEnd = onSpeechEnd;
86
+
87
+ let micModule;
88
+ try {
89
+ micModule = await import('mic');
90
+ this.mic = micModule.default || micModule;
91
+ } catch {
92
+ console.log(chalk.yellow('\n ⚠ mic 모듈이 없습니다. 음성 모드를 사용하려면:'));
93
+ console.log(chalk.gray(' npm install -g mic'));
94
+ console.log(chalk.gray(' brew install sox (macOS)\n'));
95
+ return false;
96
+ }
97
+
98
+ this.running = true;
99
+ this._listen();
100
+ return true;
101
+ }
102
+
103
+ _listen() {
104
+ const micInstance = this.mic({
105
+ rate: String(SAMPLE_RATE),
106
+ channels: String(CHANNELS),
107
+ bitwidth: String(BITS_PER_SAMPLE),
108
+ encoding: 'signed-integer',
109
+ endian: 'little',
110
+ device: 'default',
111
+ });
112
+
113
+ this.micInstance = micInstance;
114
+ const stream = micInstance.getAudioStream();
115
+
116
+ let speechChunks = [];
117
+ let isSpeaking = false;
118
+ let silenceStart = null;
119
+ let speechStart = null;
120
+
121
+ this.onListening?.();
122
+
123
+ stream.on('data', (buf) => {
124
+ if (!this.running) return;
125
+ const amp = getAmplitude(buf);
126
+
127
+ if (amp > SILENCE_THRESHOLD) {
128
+ if (!isSpeaking) {
129
+ isSpeaking = true;
130
+ speechStart = Date.now();
131
+ speechChunks = [];
132
+ this.onSpeechStart?.();
133
+ }
134
+ silenceStart = null;
135
+ speechChunks.push(Buffer.from(buf));
136
+ } else if (isSpeaking) {
137
+ speechChunks.push(Buffer.from(buf));
138
+ if (!silenceStart) silenceStart = Date.now();
139
+
140
+ if (Date.now() - silenceStart >= SILENCE_DURATION_MS) {
141
+ const duration = Date.now() - speechStart;
142
+ isSpeaking = false;
143
+ silenceStart = null;
144
+ this.onSpeechEnd?.();
145
+
146
+ if (duration >= MIN_SPEECH_MS && speechChunks.length > 0) {
147
+ this._processAudio(speechChunks);
148
+ }
149
+ speechChunks = [];
150
+ }
151
+ }
152
+ });
153
+
154
+ stream.on('error', (err) => {
155
+ if (this.running) {
156
+ console.log(chalk.red(` 마이크 오류: ${err.message}`));
157
+ }
158
+ });
159
+
160
+ micInstance.start();
161
+ }
162
+
163
+ async _processAudio(chunks) {
164
+ const pcmData = Buffer.concat(chunks);
165
+ const wavHeader = createWavHeader(pcmData.length);
166
+ const wavBuffer = Buffer.concat([wavHeader, pcmData]);
167
+
168
+ try {
169
+ const text = await transcribeWithGemini(wavBuffer, this.apiKey);
170
+ if (text) {
171
+ this.onTranscript?.(text);
172
+ }
173
+ } catch (err) {
174
+ console.log(chalk.red(` 음성 인식 오류: ${err.message}`));
175
+ }
176
+ }
177
+
178
+ stop() {
179
+ this.running = false;
180
+ if (this.micInstance) {
181
+ try { this.micInstance.stop(); } catch {}
182
+ this.micInstance = null;
183
+ }
184
+ }
185
+ }