agentgui 1.0.192 → 1.0.194
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/lib/speech.js +7 -95
- package/package.json +1 -1
- package/server.js +30 -0
- package/static/index.html +100 -1
- package/static/js/conversations.js +98 -0
package/lib/speech.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import http from 'http';
|
|
5
4
|
import { fileURLToPath } from 'url';
|
|
6
5
|
|
|
7
6
|
const require = createRequire(import.meta.url);
|
|
@@ -12,64 +11,6 @@ const serverSTT = require('webtalk/server-stt');
|
|
|
12
11
|
const serverTTS = require('webtalk/server-tts');
|
|
13
12
|
|
|
14
13
|
const EXTRA_VOICE_DIRS = [path.join(ROOT, 'voices')];
|
|
15
|
-
const TTS_PORT = 8787;
|
|
16
|
-
|
|
17
|
-
const TTS_CACHE_MAX = 10 * 1024 * 1024;
|
|
18
|
-
let cacheBytes = 0;
|
|
19
|
-
const cache = new Map();
|
|
20
|
-
const inflight = new Map();
|
|
21
|
-
|
|
22
|
-
function resolveVoice(voiceId) {
|
|
23
|
-
if (!voiceId || voiceId === 'default') return null;
|
|
24
|
-
return serverTTS.findVoiceFile(voiceId, EXTRA_VOICE_DIRS);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function cachePut(key, buf) {
|
|
28
|
-
if (cache.has(key)) { cacheBytes -= cache.get(key).length; cache.delete(key); }
|
|
29
|
-
while (cacheBytes + buf.length > TTS_CACHE_MAX && cache.size > 0) {
|
|
30
|
-
const oldest = cache.keys().next().value;
|
|
31
|
-
cacheBytes -= cache.get(oldest).length;
|
|
32
|
-
cache.delete(oldest);
|
|
33
|
-
}
|
|
34
|
-
cache.set(key, buf);
|
|
35
|
-
cacheBytes += buf.length;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sendToPocket(text, voicePath) {
|
|
39
|
-
return new Promise((resolve, reject) => {
|
|
40
|
-
const boundary = '----PocketTTS' + Date.now();
|
|
41
|
-
const parts = [];
|
|
42
|
-
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="text"\r\n\r\n${text}\r\n`);
|
|
43
|
-
if (voicePath) {
|
|
44
|
-
const data = fs.readFileSync(voicePath);
|
|
45
|
-
const name = path.basename(voicePath);
|
|
46
|
-
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="voice_wav"; filename="${name}"\r\nContent-Type: audio/wav\r\n\r\n`);
|
|
47
|
-
parts.push(data);
|
|
48
|
-
parts.push('\r\n');
|
|
49
|
-
}
|
|
50
|
-
parts.push(`--${boundary}--\r\n`);
|
|
51
|
-
const body = Buffer.concat(parts.map(p => Buffer.isBuffer(p) ? p : Buffer.from(p)));
|
|
52
|
-
const req = http.request({
|
|
53
|
-
hostname: '127.0.0.1', port: TTS_PORT, path: '/tts', method: 'POST',
|
|
54
|
-
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length },
|
|
55
|
-
timeout: 60000,
|
|
56
|
-
}, res => {
|
|
57
|
-
if (res.statusCode !== 200) {
|
|
58
|
-
let e = '';
|
|
59
|
-
res.on('data', d => e += d);
|
|
60
|
-
res.on('end', () => reject(new Error(`pocket-tts HTTP ${res.statusCode}: ${e}`)));
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
const chunks = [];
|
|
64
|
-
res.on('data', d => chunks.push(d));
|
|
65
|
-
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
66
|
-
});
|
|
67
|
-
req.on('error', reject);
|
|
68
|
-
req.on('timeout', () => { req.destroy(); reject(new Error('pocket-tts timeout')); });
|
|
69
|
-
req.write(body);
|
|
70
|
-
req.end();
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
14
|
|
|
74
15
|
function transcribe(audioBuffer) {
|
|
75
16
|
return serverSTT.transcribe(audioBuffer);
|
|
@@ -79,37 +20,12 @@ function getSTT() {
|
|
|
79
20
|
return serverSTT.getSTT();
|
|
80
21
|
}
|
|
81
22
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (!status.ready) throw new Error('pocket-tts not healthy');
|
|
85
|
-
const key = (voiceId || 'default') + ':' + text;
|
|
86
|
-
const cached = cache.get(key);
|
|
87
|
-
if (cached) { cache.delete(key); cache.set(key, cached); return cached; }
|
|
88
|
-
const existing = inflight.get(key);
|
|
89
|
-
if (existing) return existing;
|
|
90
|
-
const promise = (async () => {
|
|
91
|
-
const voicePath = resolveVoice(voiceId);
|
|
92
|
-
const wav = await sendToPocket(text, voicePath);
|
|
93
|
-
if (!wav || wav.length <= 44) throw new Error('pocket-tts returned empty audio');
|
|
94
|
-
cachePut(key, wav);
|
|
95
|
-
return wav;
|
|
96
|
-
})();
|
|
97
|
-
inflight.set(key, promise);
|
|
98
|
-
try { return await promise; } finally { inflight.delete(key); }
|
|
23
|
+
function synthesize(text, voiceId) {
|
|
24
|
+
return serverTTS.synthesize(text, voiceId, EXTRA_VOICE_DIRS);
|
|
99
25
|
}
|
|
100
26
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (!status.ready) throw new Error('pocket-tts not healthy');
|
|
104
|
-
const sentences = splitSentences(text);
|
|
105
|
-
for (const sentence of sentences) {
|
|
106
|
-
const key = (voiceId || 'default') + ':' + sentence;
|
|
107
|
-
const cached = cache.get(key);
|
|
108
|
-
if (cached) { cache.delete(key); cache.set(key, cached); yield cached; continue; }
|
|
109
|
-
const voicePath = resolveVoice(voiceId);
|
|
110
|
-
const wav = await sendToPocket(sentence, voicePath);
|
|
111
|
-
if (wav && wav.length > 44) { cachePut(key, wav); yield wav; }
|
|
112
|
-
}
|
|
27
|
+
function synthesizeStream(text, voiceId) {
|
|
28
|
+
return serverTTS.synthesizeStream(text, voiceId, EXTRA_VOICE_DIRS);
|
|
113
29
|
}
|
|
114
30
|
|
|
115
31
|
function getVoices() {
|
|
@@ -142,19 +58,15 @@ function preloadTTS() {
|
|
|
142
58
|
}
|
|
143
59
|
|
|
144
60
|
function ttsCacheKey(text, voiceId) {
|
|
145
|
-
return (voiceId
|
|
61
|
+
return serverTTS.ttsCacheKey(text, voiceId);
|
|
146
62
|
}
|
|
147
63
|
|
|
148
64
|
function ttsCacheGet(key) {
|
|
149
|
-
|
|
150
|
-
if (cached) { cache.delete(key); cache.set(key, cached); }
|
|
151
|
-
return cached || null;
|
|
65
|
+
return serverTTS.ttsCacheGet(key);
|
|
152
66
|
}
|
|
153
67
|
|
|
154
68
|
function splitSentences(text) {
|
|
155
|
-
|
|
156
|
-
if (!raw) return [text];
|
|
157
|
-
return raw.map(s => s.trim()).filter(s => s.length > 0);
|
|
69
|
+
return serverTTS.splitSentences(text);
|
|
158
70
|
}
|
|
159
71
|
|
|
160
72
|
export { transcribe, synthesize, synthesizeStream, getSTT, getStatus, getVoices, preloadTTS, ttsCacheKey, ttsCacheGet, splitSentences };
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -666,6 +666,36 @@ const server = http.createServer(async (req, res) => {
|
|
|
666
666
|
return;
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
if (pathOnly === '/api/clone' && req.method === 'POST') {
|
|
670
|
+
const body = await parseBody(req);
|
|
671
|
+
const repo = (body.repo || '').trim();
|
|
672
|
+
if (!repo || !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) {
|
|
673
|
+
sendJSON(req, res, 400, { error: 'Invalid repo format. Use org/repo or user/repo' });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const cloneDir = STARTUP_CWD || os.homedir();
|
|
677
|
+
const repoName = repo.split('/')[1];
|
|
678
|
+
const targetPath = path.join(cloneDir, repoName);
|
|
679
|
+
if (fs.existsSync(targetPath)) {
|
|
680
|
+
sendJSON(req, res, 409, { error: `Directory already exists: ${repoName}`, path: targetPath });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
execSync(`git clone https://github.com/${repo}.git`, {
|
|
685
|
+
cwd: cloneDir,
|
|
686
|
+
encoding: 'utf-8',
|
|
687
|
+
timeout: 120000,
|
|
688
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
689
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
690
|
+
});
|
|
691
|
+
sendJSON(req, res, 200, { ok: true, repo, path: targetPath, name: repoName });
|
|
692
|
+
} catch (err) {
|
|
693
|
+
const stderr = err.stderr || err.message || 'Clone failed';
|
|
694
|
+
sendJSON(req, res, 500, { error: stderr.trim() });
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
669
699
|
if (pathOnly === '/api/folders' && req.method === 'POST') {
|
|
670
700
|
const body = await parseBody(req);
|
|
671
701
|
const folderPath = body.path || STARTUP_CWD;
|
package/static/index.html
CHANGED
|
@@ -118,6 +118,97 @@
|
|
|
118
118
|
|
|
119
119
|
.sidebar-new-btn:hover { background-color: var(--color-primary-dark); }
|
|
120
120
|
|
|
121
|
+
.sidebar-header-actions {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 0.375rem;
|
|
124
|
+
align-items: center;
|
|
125
|
+
flex-shrink: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.sidebar-clone-btn {
|
|
129
|
+
padding: 0.375rem 0.625rem;
|
|
130
|
+
font-size: 0.75rem;
|
|
131
|
+
background-color: var(--color-bg-primary);
|
|
132
|
+
color: var(--color-text-primary);
|
|
133
|
+
border: 1px solid var(--color-border);
|
|
134
|
+
border-radius: 0.375rem;
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
transition: all 0.2s;
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
flex-shrink: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.sidebar-clone-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
|
142
|
+
|
|
143
|
+
.clone-input-bar {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 0.375rem;
|
|
147
|
+
padding: 0.375rem 0.75rem;
|
|
148
|
+
background: var(--color-bg-primary);
|
|
149
|
+
border-bottom: 1px solid var(--color-border);
|
|
150
|
+
flex-shrink: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.clone-input {
|
|
154
|
+
flex: 1;
|
|
155
|
+
min-width: 0;
|
|
156
|
+
padding: 0.375rem 0.5rem;
|
|
157
|
+
font-size: 0.8rem;
|
|
158
|
+
font-family: 'Monaco','Menlo','Ubuntu Mono', monospace;
|
|
159
|
+
border: 1px solid var(--color-border);
|
|
160
|
+
border-radius: 0.25rem;
|
|
161
|
+
background: var(--color-bg-secondary);
|
|
162
|
+
color: var(--color-text-primary);
|
|
163
|
+
outline: none;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.clone-input:focus { border-color: var(--color-primary); }
|
|
167
|
+
|
|
168
|
+
.clone-go-btn {
|
|
169
|
+
padding: 0.375rem 0.625rem;
|
|
170
|
+
font-size: 0.75rem;
|
|
171
|
+
font-weight: 600;
|
|
172
|
+
background: var(--color-primary);
|
|
173
|
+
color: white;
|
|
174
|
+
border: none;
|
|
175
|
+
border-radius: 0.25rem;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
flex-shrink: 0;
|
|
178
|
+
transition: background-color 0.15s;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.clone-go-btn:hover { background-color: var(--color-primary-dark); }
|
|
182
|
+
.clone-go-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
183
|
+
|
|
184
|
+
.clone-cancel-btn {
|
|
185
|
+
padding: 0.25rem 0.5rem;
|
|
186
|
+
font-size: 1rem;
|
|
187
|
+
background: none;
|
|
188
|
+
border: none;
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
color: var(--color-text-secondary);
|
|
191
|
+
flex-shrink: 0;
|
|
192
|
+
line-height: 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.clone-cancel-btn:hover { color: var(--color-text-primary); }
|
|
196
|
+
|
|
197
|
+
.clone-status {
|
|
198
|
+
padding: 0.375rem 0.75rem;
|
|
199
|
+
font-size: 0.75rem;
|
|
200
|
+
background: var(--color-bg-primary);
|
|
201
|
+
border-bottom: 1px solid var(--color-border);
|
|
202
|
+
flex-shrink: 0;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: 0.5rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.clone-status.cloning { color: var(--color-primary); }
|
|
209
|
+
.clone-status.clone-error { color: var(--color-error); }
|
|
210
|
+
.clone-status.clone-success { color: var(--color-success); }
|
|
211
|
+
|
|
121
212
|
.sidebar-list {
|
|
122
213
|
flex: 1;
|
|
123
214
|
overflow-y: auto;
|
|
@@ -2079,7 +2170,15 @@
|
|
|
2079
2170
|
<aside class="sidebar" data-sidebar>
|
|
2080
2171
|
<div class="sidebar-header">
|
|
2081
2172
|
<h2>History</h2>
|
|
2082
|
-
<
|
|
2173
|
+
<div class="sidebar-header-actions">
|
|
2174
|
+
<button id="cloneRepoBtn" class="sidebar-clone-btn" data-clone-repo title="Clone a GitHub repo">Clone</button>
|
|
2175
|
+
<button id="newConversationBtn" class="sidebar-new-btn" data-new-conversation title="Start new conversation">+ New</button>
|
|
2176
|
+
</div>
|
|
2177
|
+
</div>
|
|
2178
|
+
<div class="clone-input-bar" id="cloneInputBar" style="display:none;">
|
|
2179
|
+
<input type="text" class="clone-input" id="cloneRepoInput" placeholder="org/repo" autocomplete="off" spellcheck="false">
|
|
2180
|
+
<button class="clone-go-btn" id="cloneGoBtn" title="Clone">Go</button>
|
|
2181
|
+
<button class="clone-cancel-btn" id="cloneCancelBtn" title="Cancel">×</button>
|
|
2083
2182
|
</div>
|
|
2084
2183
|
<ul class="sidebar-list" data-conversation-list>
|
|
2085
2184
|
<li class="sidebar-empty" data-conversation-empty>No conversations yet</li>
|
|
@@ -35,6 +35,7 @@ class ConversationManager {
|
|
|
35
35
|
this.loadConversations();
|
|
36
36
|
this.setupWebSocketListener();
|
|
37
37
|
this.setupFolderBrowser();
|
|
38
|
+
this.setupCloneUI();
|
|
38
39
|
|
|
39
40
|
setInterval(() => this.loadConversations(), 30000);
|
|
40
41
|
}
|
|
@@ -210,6 +211,103 @@ class ConversationManager {
|
|
|
210
211
|
}));
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
setupCloneUI() {
|
|
215
|
+
this.cloneBtn = document.getElementById('cloneRepoBtn');
|
|
216
|
+
this.cloneBar = document.getElementById('cloneInputBar');
|
|
217
|
+
this.cloneInput = document.getElementById('cloneRepoInput');
|
|
218
|
+
this.cloneGoBtn = document.getElementById('cloneGoBtn');
|
|
219
|
+
this.cloneCancelBtn = document.getElementById('cloneCancelBtn');
|
|
220
|
+
|
|
221
|
+
if (!this.cloneBtn || !this.cloneBar) return;
|
|
222
|
+
|
|
223
|
+
this.cloneBtn.addEventListener('click', () => this.toggleCloneBar());
|
|
224
|
+
|
|
225
|
+
this.cloneCancelBtn?.addEventListener('click', () => this.hideCloneBar());
|
|
226
|
+
|
|
227
|
+
this.cloneGoBtn?.addEventListener('click', () => this.performClone());
|
|
228
|
+
|
|
229
|
+
this.cloneInput?.addEventListener('keydown', (e) => {
|
|
230
|
+
if (e.key === 'Enter') this.performClone();
|
|
231
|
+
if (e.key === 'Escape') this.hideCloneBar();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
toggleCloneBar() {
|
|
236
|
+
if (!this.cloneBar) return;
|
|
237
|
+
const visible = this.cloneBar.style.display !== 'none';
|
|
238
|
+
if (visible) {
|
|
239
|
+
this.hideCloneBar();
|
|
240
|
+
} else {
|
|
241
|
+
this.cloneBar.style.display = 'flex';
|
|
242
|
+
this.cloneInput.value = '';
|
|
243
|
+
this.cloneInput.focus();
|
|
244
|
+
this.removeCloneStatus();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
hideCloneBar() {
|
|
249
|
+
if (this.cloneBar) this.cloneBar.style.display = 'none';
|
|
250
|
+
this.removeCloneStatus();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
removeCloneStatus() {
|
|
254
|
+
const existing = this.sidebarEl?.querySelector('.clone-status');
|
|
255
|
+
if (existing) existing.remove();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
showCloneStatus(message, type) {
|
|
259
|
+
this.removeCloneStatus();
|
|
260
|
+
const statusEl = document.createElement('div');
|
|
261
|
+
statusEl.className = `clone-status ${type}`;
|
|
262
|
+
statusEl.textContent = message;
|
|
263
|
+
if (this.cloneBar && this.cloneBar.parentNode) {
|
|
264
|
+
this.cloneBar.parentNode.insertBefore(statusEl, this.cloneBar.nextSibling);
|
|
265
|
+
}
|
|
266
|
+
if (type === 'clone-success' || type === 'clone-error') {
|
|
267
|
+
setTimeout(() => statusEl.remove(), 5000);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async performClone() {
|
|
272
|
+
const repo = (this.cloneInput?.value || '').trim();
|
|
273
|
+
if (!repo) return;
|
|
274
|
+
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo)) {
|
|
275
|
+
this.showCloneStatus('Invalid format. Use org/repo', 'clone-error');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.cloneGoBtn.disabled = true;
|
|
280
|
+
this.cloneInput.disabled = true;
|
|
281
|
+
this.showCloneStatus(`Cloning ${repo}...`, 'cloning');
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch((window.__BASE_URL || '') + '/api/clone', {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: { 'Content-Type': 'application/json' },
|
|
287
|
+
body: JSON.stringify({ repo })
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const data = await res.json();
|
|
291
|
+
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
this.showCloneStatus(data.error || 'Clone failed', 'clone-error');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.showCloneStatus(`Cloned ${data.name}`, 'clone-success');
|
|
298
|
+
this.hideCloneBar();
|
|
299
|
+
|
|
300
|
+
window.dispatchEvent(new CustomEvent('create-new-conversation', {
|
|
301
|
+
detail: { workingDirectory: data.path, title: data.name }
|
|
302
|
+
}));
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this.showCloneStatus('Network error: ' + err.message, 'clone-error');
|
|
305
|
+
} finally {
|
|
306
|
+
if (this.cloneGoBtn) this.cloneGoBtn.disabled = false;
|
|
307
|
+
if (this.cloneInput) this.cloneInput.disabled = false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
213
311
|
async loadConversations() {
|
|
214
312
|
try {
|
|
215
313
|
const res = await fetch((window.__BASE_URL || '') + '/api/conversations');
|