uwonbot 1.1.0 → 1.1.2
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 +32 -4
- package/package.json +1 -1
- package/src/autostart.js +179 -0
- package/src/brain.js +6 -0
- package/src/chat.js +94 -18
- package/src/setup.js +14 -0
- package/src/voiceInput.js +185 -0
package/bin/uwonbot.js
CHANGED
|
@@ -7,13 +7,14 @@ import { startChat } from '../src/chat.js';
|
|
|
7
7
|
import { getConfig } from '../src/config.js';
|
|
8
8
|
import { startAgent } from '../src/agent.js';
|
|
9
9
|
import { runSetupWizard } from '../src/setup.js';
|
|
10
|
+
import { enableAutostart, disableAutostart, isAutostartEnabled } from '../src/autostart.js';
|
|
10
11
|
|
|
11
12
|
showBanner();
|
|
12
13
|
|
|
13
14
|
program
|
|
14
15
|
.name('uwonbot')
|
|
15
16
|
.description('Uwonbot AI Assistant — Your AI controls your computer')
|
|
16
|
-
.version('1.1.
|
|
17
|
+
.version('1.1.2');
|
|
17
18
|
|
|
18
19
|
program
|
|
19
20
|
.command('login')
|
|
@@ -44,19 +45,21 @@ program
|
|
|
44
45
|
.command('chat [assistantName]')
|
|
45
46
|
.description('Start chatting with an AI assistant')
|
|
46
47
|
.option('-n, --name <name>', 'Assistant name to launch directly')
|
|
48
|
+
.option('-v, --voice', 'Enable hands-free voice input mode')
|
|
47
49
|
.action(async (assistantName, opts) => {
|
|
48
50
|
const config = getConfig();
|
|
49
51
|
if (!config.get('uid')) {
|
|
50
52
|
console.log('\n ⚠️ Please log in first: uwonbot login\n');
|
|
51
53
|
process.exit(1);
|
|
52
54
|
}
|
|
55
|
+
const chatOpts = { voice: opts.voice || false };
|
|
53
56
|
const targetName = opts.name || assistantName;
|
|
54
57
|
if (targetName) {
|
|
55
|
-
await startChat(targetName);
|
|
58
|
+
await startChat(targetName, null, null, chatOpts);
|
|
56
59
|
} else {
|
|
57
60
|
const assistant = await selectAssistant();
|
|
58
61
|
if (assistant) {
|
|
59
|
-
await startChat(null, assistant);
|
|
62
|
+
await startChat(null, assistant, null, chatOpts);
|
|
60
63
|
} else {
|
|
61
64
|
const defaultBot = {
|
|
62
65
|
name: 'Uwonbot',
|
|
@@ -66,7 +69,7 @@ program
|
|
|
66
69
|
voiceStyle: 'male',
|
|
67
70
|
isDefaultBot: true,
|
|
68
71
|
};
|
|
69
|
-
await startChat(null, defaultBot);
|
|
72
|
+
await startChat(null, defaultBot, null, chatOpts);
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
});
|
|
@@ -93,6 +96,31 @@ program
|
|
|
93
96
|
await runSetupWizard();
|
|
94
97
|
});
|
|
95
98
|
|
|
99
|
+
program
|
|
100
|
+
.command('autostart')
|
|
101
|
+
.description('Enable/disable uwonbot agent auto-start on boot')
|
|
102
|
+
.option('--on', 'Enable auto-start')
|
|
103
|
+
.option('--off', 'Disable auto-start')
|
|
104
|
+
.action(async (opts) => {
|
|
105
|
+
const chalk = (await import('chalk')).default;
|
|
106
|
+
if (opts.on) {
|
|
107
|
+
enableAutostart();
|
|
108
|
+
} else if (opts.off) {
|
|
109
|
+
disableAutostart();
|
|
110
|
+
} else {
|
|
111
|
+
const enabled = isAutostartEnabled();
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(chalk.white.bold(' uwonbot agent 자동 시작'));
|
|
114
|
+
console.log(chalk.gray(' ─────────────────────────'));
|
|
115
|
+
console.log(` 상태: ${enabled ? chalk.green('활성화') : chalk.gray('비활성화')}`);
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(chalk.gray(' 사용법:'));
|
|
118
|
+
console.log(chalk.white(' uwonbot autostart --on ') + chalk.gray('자동 시작 활성화'));
|
|
119
|
+
console.log(chalk.white(' uwonbot autostart --off ') + chalk.gray('자동 시작 비활성화'));
|
|
120
|
+
console.log('');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
96
124
|
program
|
|
97
125
|
.command('upgrade')
|
|
98
126
|
.description('Upgrade uwonbot to the latest version')
|
package/package.json
CHANGED
package/src/autostart.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
|
|
7
|
+
const LABEL = 'com.uwonbot.agent';
|
|
8
|
+
const platform = process.platform;
|
|
9
|
+
|
|
10
|
+
function getUwonbotPath() {
|
|
11
|
+
try {
|
|
12
|
+
const p = execSync('which uwonbot', { encoding: 'utf8' }).trim();
|
|
13
|
+
if (p) return p;
|
|
14
|
+
} catch {}
|
|
15
|
+
return 'uwonbot';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getMacPlistPath() {
|
|
19
|
+
const dir = join(homedir(), 'Library', 'LaunchAgents');
|
|
20
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
21
|
+
return join(dir, `${LABEL}.plist`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getLinuxServicePath() {
|
|
25
|
+
const dir = join(homedir(), '.config', 'systemd', 'user');
|
|
26
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
27
|
+
return join(dir, 'uwonbot-agent.service');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function enableAutostart() {
|
|
31
|
+
const uwonbotPath = getUwonbotPath();
|
|
32
|
+
|
|
33
|
+
if (platform === 'darwin') {
|
|
34
|
+
const plistPath = getMacPlistPath();
|
|
35
|
+
const logPath = join(homedir(), '.uwonbot', 'agent.log');
|
|
36
|
+
const logDir = join(homedir(), '.uwonbot');
|
|
37
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key>
|
|
44
|
+
<string>${LABEL}</string>
|
|
45
|
+
<key>ProgramArguments</key>
|
|
46
|
+
<array>
|
|
47
|
+
<string>${uwonbotPath}</string>
|
|
48
|
+
<string>agent</string>
|
|
49
|
+
</array>
|
|
50
|
+
<key>RunAtLoad</key>
|
|
51
|
+
<true/>
|
|
52
|
+
<key>KeepAlive</key>
|
|
53
|
+
<true/>
|
|
54
|
+
<key>StandardOutPath</key>
|
|
55
|
+
<string>${logPath}</string>
|
|
56
|
+
<key>StandardErrorPath</key>
|
|
57
|
+
<string>${logPath}</string>
|
|
58
|
+
<key>EnvironmentVariables</key>
|
|
59
|
+
<dict>
|
|
60
|
+
<key>PATH</key>
|
|
61
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${process.env.PATH || ''}</string>
|
|
62
|
+
</dict>
|
|
63
|
+
</dict>
|
|
64
|
+
</plist>`;
|
|
65
|
+
|
|
66
|
+
writeFileSync(plistPath, plist);
|
|
67
|
+
try {
|
|
68
|
+
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'ignore' });
|
|
69
|
+
} catch {}
|
|
70
|
+
try {
|
|
71
|
+
execSync(`launchctl load -w "${plistPath}"`);
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
console.log(chalk.green(' ✓ uwonbot agent 자동 시작이 등록되었습니다'));
|
|
75
|
+
console.log(chalk.gray(` ${plistPath}`));
|
|
76
|
+
console.log(chalk.gray(` 로그: ${logPath}`));
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (platform === 'linux') {
|
|
81
|
+
const servicePath = getLinuxServicePath();
|
|
82
|
+
const service = `[Unit]
|
|
83
|
+
Description=Uwonbot Agent
|
|
84
|
+
After=network.target
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
ExecStart=${uwonbotPath} agent
|
|
88
|
+
Restart=always
|
|
89
|
+
RestartSec=5
|
|
90
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
91
|
+
|
|
92
|
+
[Install]
|
|
93
|
+
WantedBy=default.target`;
|
|
94
|
+
|
|
95
|
+
writeFileSync(servicePath, service);
|
|
96
|
+
try {
|
|
97
|
+
execSync('systemctl --user daemon-reload');
|
|
98
|
+
execSync('systemctl --user enable uwonbot-agent.service');
|
|
99
|
+
execSync('systemctl --user start uwonbot-agent.service');
|
|
100
|
+
} catch {}
|
|
101
|
+
|
|
102
|
+
console.log(chalk.green(' ✓ uwonbot agent 자동 시작이 등록되었습니다 (systemd)'));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (platform === 'win32') {
|
|
107
|
+
const vbsPath = join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'uwonbot-agent.vbs');
|
|
108
|
+
const vbs = `Set WshShell = CreateObject("WScript.Shell")
|
|
109
|
+
WshShell.Run """${uwonbotPath}"" agent", 0, False`;
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(vbsPath, vbs);
|
|
112
|
+
console.log(chalk.green(' ✓ uwonbot agent 자동 시작이 등록되었습니다 (Windows Startup)'));
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
console.log(chalk.yellow(' ⚠ Windows 자동 시작 등록에 실패했습니다.'));
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(chalk.yellow(` ⚠ 이 OS(${platform})에서는 자동 시작을 지원하지 않습니다.`));
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function disableAutostart() {
|
|
125
|
+
if (platform === 'darwin') {
|
|
126
|
+
const plistPath = getMacPlistPath();
|
|
127
|
+
if (existsSync(plistPath)) {
|
|
128
|
+
try {
|
|
129
|
+
execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'ignore' });
|
|
130
|
+
} catch {}
|
|
131
|
+
unlinkSync(plistPath);
|
|
132
|
+
console.log(chalk.green(' ✓ uwonbot agent 자동 시작이 해제되었습니다'));
|
|
133
|
+
} else {
|
|
134
|
+
console.log(chalk.gray(' 자동 시작이 등록되어 있지 않습니다.'));
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (platform === 'linux') {
|
|
140
|
+
const servicePath = getLinuxServicePath();
|
|
141
|
+
try {
|
|
142
|
+
execSync('systemctl --user stop uwonbot-agent.service', { stdio: 'ignore' });
|
|
143
|
+
execSync('systemctl --user disable uwonbot-agent.service', { stdio: 'ignore' });
|
|
144
|
+
} catch {}
|
|
145
|
+
if (existsSync(servicePath)) {
|
|
146
|
+
unlinkSync(servicePath);
|
|
147
|
+
try { execSync('systemctl --user daemon-reload'); } catch {}
|
|
148
|
+
}
|
|
149
|
+
console.log(chalk.green(' ✓ uwonbot agent 자동 시작이 해제되었습니다'));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (platform === 'win32') {
|
|
154
|
+
const vbsPath = join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'uwonbot-agent.vbs');
|
|
155
|
+
if (existsSync(vbsPath)) {
|
|
156
|
+
unlinkSync(vbsPath);
|
|
157
|
+
console.log(chalk.green(' ✓ uwonbot agent 자동 시작이 해제되었습니다'));
|
|
158
|
+
} else {
|
|
159
|
+
console.log(chalk.gray(' 자동 시작이 등록되어 있지 않습니다.'));
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(chalk.yellow(` ⚠ 이 OS(${platform})에서는 자동 시작을 지원하지 않습니다.`));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isAutostartEnabled() {
|
|
168
|
+
if (platform === 'darwin') {
|
|
169
|
+
return existsSync(getMacPlistPath());
|
|
170
|
+
}
|
|
171
|
+
if (platform === 'linux') {
|
|
172
|
+
return existsSync(getLinuxServicePath());
|
|
173
|
+
}
|
|
174
|
+
if (platform === 'win32') {
|
|
175
|
+
const vbsPath = join(homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'uwonbot-agent.vbs');
|
|
176
|
+
return existsSync(vbsPath);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
package/src/brain.js
CHANGED
|
@@ -6,7 +6,13 @@ const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
|
6
6
|
|
|
7
7
|
function buildSystemPrompt(assistant) {
|
|
8
8
|
const base = assistant.systemPrompt || `You are ${assistant.name || 'Uwonbot'}, an AI assistant.`;
|
|
9
|
+
const lang = assistant.voiceLang || 'ko-KR';
|
|
10
|
+
const langRule = lang.startsWith('ko')
|
|
11
|
+
? `\n[LANGUAGE - STRICTLY ENFORCED]\nYou MUST respond ONLY in Korean (한국어). Never use English or other languages unless the user explicitly uses English words.\nAlways use polite speech (존댓말).`
|
|
12
|
+
: `\n[LANGUAGE - STRICTLY ENFORCED]\nYou MUST respond ONLY in English. Never use Korean or other languages.\nAlways address the user as "sir" or "ma'am".`;
|
|
13
|
+
|
|
9
14
|
return `${base}
|
|
15
|
+
${langRule}
|
|
10
16
|
|
|
11
17
|
You have full access to the user's computer through tools. You can:
|
|
12
18
|
- Read, write, create, delete, and move files
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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() === '
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
}
|
package/src/setup.js
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import { getConfig } from './config.js';
|
|
5
|
+
import { enableAutostart, isAutostartEnabled } from './autostart.js';
|
|
5
6
|
|
|
6
7
|
function checkSoxInstalled() {
|
|
7
8
|
try {
|
|
@@ -43,6 +44,19 @@ export async function runSetupWizard() {
|
|
|
43
44
|
|
|
44
45
|
config.set('agentAutoStart', enableAgent);
|
|
45
46
|
|
|
47
|
+
if (enableAgent && !isAutostartEnabled()) {
|
|
48
|
+
console.log('');
|
|
49
|
+
const { bootStart } = await inquirer.prompt([{
|
|
50
|
+
type: 'confirm',
|
|
51
|
+
name: 'bootStart',
|
|
52
|
+
message: '컴퓨터 시작 시 에이전트를 자동 실행하시겠습니까?',
|
|
53
|
+
default: true,
|
|
54
|
+
}]);
|
|
55
|
+
if (bootStart) {
|
|
56
|
+
enableAutostart();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
if (enableAgent) {
|
|
47
61
|
console.log('');
|
|
48
62
|
console.log(chalk.bold.white(' 2️⃣ 박수 감지 (Clap Detection)'));
|
|
@@ -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
|
+
}
|