shellbase 0.5.0 → 0.7.0
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/README.md +46 -0
- package/dist/agent.js +121 -0
- package/dist/cli.js +0 -0
- package/dist/images.js +76 -0
- package/dist/stt.js +65 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,6 +87,8 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
|
|
|
87
87
|
| 세션 갈아타기 | 터미널 화면 위쪽의 **기기 이름(▾)** 탭 → 원하는 세션 선택 |
|
|
88
88
|
| 세션 끄기 | 목록의 **전원 아이콘**, 또는 터미널 화면의 기기 이름(▾) → **이 세션 끄기** |
|
|
89
89
|
| 화면만 닫기 | `← 목록` 으로 나가기 — 컴퓨터의 세션은 계속 살아있어요 |
|
|
90
|
+
| 파일 열어보기·고치기 | **`📁 폴더 찾기`** → 목록에서 **파일**을 탭하면 에디터가 열려요 (수정 후 `저장`) |
|
|
91
|
+
| 세션 이름 바꾸기 | 기기 이름(▾) → 세션 옆 연필 버튼 |
|
|
90
92
|
|
|
91
93
|
- **끄기는 진짜로 꺼요.** 컴퓨터에서 돌아가던 그 터미널이 종료되고 목록에서도 자동으로 사라져요
|
|
92
94
|
(`Ctrl+C` 를 누른 것과 같아요). 프로세스가 죽었는데 목록에만 남아있는 항목은 휴지통 아이콘으로
|
|
@@ -97,6 +99,50 @@ shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나
|
|
|
97
99
|
종료돼요(그 대신 다음 실행 때 자동 복구됩니다). 세션을 하나만 끄고 싶으면 폰에서 그 세션의 `✕` 를
|
|
98
100
|
쓰세요 — 나머지 세션은 그대로 유지돼요.
|
|
99
101
|
|
|
102
|
+
### 파일 보기·편집
|
|
103
|
+
|
|
104
|
+
`📁 폴더 찾기` 목록에는 폴더와 함께 **파일**도 나와요. 파일을 탭하면 편집기가 열리고, 고친 뒤 `저장` 을
|
|
105
|
+
누르면 그 컴퓨터의 파일이 실제로 바뀝니다. **기기에 따라 편집기가 다르게 열려요:**
|
|
106
|
+
|
|
107
|
+
- **노트북·데스크톱(넓은 화면 + 마우스)**: VS Code 와 같은 **모나코** — 찾기·바꾸기(Ctrl+F), 커서 여러 개,
|
|
108
|
+
줄 접기, 미니맵, `Ctrl+S` 저장이 그대로 동작해요. 파일을 처음 열 때만 내려받습니다.
|
|
109
|
+
- **폰**: 가벼운 편집기(CodeMirror) — 터치 우선이라 좁은 화면에서 편하고, `Tab` 들여쓰기와 `Ctrl+S` 저장도 됩니다.
|
|
110
|
+
- 모나코에서 **JS/TS 타입 기반 자동완성은 빠져 있어요** (그 기능 파일 하나가 6.6MB 라 웹 스토리지 한도를
|
|
111
|
+
넘습니다). 색칠·단축키·검색은 모두 정상이에요.
|
|
112
|
+
|
|
113
|
+
- 512KB 까지, **글자 파일만** 열려요 (사진·실행파일 같은 이진 파일은 자동으로 걸러집니다)
|
|
114
|
+
- 저장은 같은 폴더의 임시 파일에 먼저 쓴 뒤 교체해서, 저장 도중 끊겨도 원본이 깨지지 않아요
|
|
115
|
+
- 파일 내용은 터미널 화면과 똑같이 세션 키로 암호화해서 주고받아요
|
|
116
|
+
- 에디터는 **파일을 처음 열 때만 따로 내려받아요** — 터미널만 쓰면 그만큼 안 받습니다
|
|
117
|
+
|
|
118
|
+
### 폰에서 사진·스크린샷 첨부 (0.7.0+)
|
|
119
|
+
|
|
120
|
+
폰 화면의 **클립(📎) 버튼**(PC 는 상단 바)으로 사진을 고르면, 사진이 **이 컴퓨터의
|
|
121
|
+
`~/.shellbase/images/`** 에 파일로 저장되고 그 **파일 경로가 터미널에 자동으로 입력**됩니다.
|
|
122
|
+
이어서 "이 화면 좀 봐줘" 처럼 쓰고 엔터를 누르면 Claude Code 가 그 사진을 읽어요.
|
|
123
|
+
|
|
124
|
+
- 폰에서는 「사진 찍기 / 사진 보관함」이 함께 떠요 — 방금 찍은 **스크린샷은 보관함**에 있습니다
|
|
125
|
+
- 스크린샷을 **복사해서 붙여넣기**(Ctrl+V) 해도 첨부돼요
|
|
126
|
+
- 사진은 보내기 전에 폰에서 **긴 변 1568px 로 줄여서** 올라가요(전송이 빠르고 인식 정확도는 그대로)
|
|
127
|
+
- 저장된 사진은 **7일이 지나면 자동으로 지워져요**, 파일 권한은 본인만 읽기(600)
|
|
128
|
+
- 사진도 터미널 화면과 똑같이 **세션 키로 암호화**해서 전송돼요
|
|
129
|
+
|
|
130
|
+
### 폰에서 마이크로 말하기 (음성 → 글자)
|
|
131
|
+
|
|
132
|
+
폰 화면 오른쪽 아래 **마이크 버튼**을 누르고 말하면, 녹음이 **이 컴퓨터로** 전송돼서 컴퓨터에 떠 있는
|
|
133
|
+
음성인식 서버가 글자로 바꿔줍니다. 기본값은 **말한 뒤 바로 보내기(엔터까지 자동)** 라서, 「완료」만
|
|
134
|
+
누르면 그대로 실행돼요 (Claude Code 에게 말로 시킬 때 편합니다). 확인하고 보내고 싶으면 명령창의
|
|
135
|
+
`말한 뒤 바로 보내기 끄기` 로 예전 방식(받아쓴 글자를 보여주고 `보내기 ↵` 를 누르는 방식)으로 바꿀 수 있어요.
|
|
136
|
+
|
|
137
|
+
- **녹음은 다른 회사 서버로 나가지 않아요** — 폰 → (암호화된 실시간 채널) → 내 컴퓨터 → 내 음성인식 서버
|
|
138
|
+
- 음성인식 서버는 시작할 때 자동으로 찾아요: `SHELLBASE_STT_URL` → `http://127.0.0.1:5005`
|
|
139
|
+
→ `http://172.17.0.1:5005`(도커 안에서 본 호스트) 순서
|
|
140
|
+
- 주소가 다르면 `SHELLBASE_STT_URL=http://127.0.0.1:9000` 처럼 지정하고 다시 시작하세요.
|
|
141
|
+
인식 언어는 `SHELLBASE_STT_LANGS=ko,en` 으로 바꿀 수 있어요 (기본 `ko`)
|
|
142
|
+
- 서버가 없으면 마이크 기능만 꺼지고 나머지는 그대로 동작해요 (시작할 때 로그로 알려줍니다)
|
|
143
|
+
- 필요한 서버 형식: `GET /health` 와 `POST /transcribe_json` (`{audio_base64, languages}` → `{ok, text}`) —
|
|
144
|
+
whisper 계열 서버면 대개 이 형태입니다
|
|
145
|
+
|
|
100
146
|
> ⚠️ **도커로 돌릴 때, 마지막 세션을 폰에서 끄면 도커가 다시 띄워요.** 세션이 0개가 되면 프로세스가
|
|
101
147
|
> 종료되는데, `--restart unless-stopped` 는 정상 종료(exit 0)에도 컨테이너를 자동 재시작하기 때문이에요
|
|
102
148
|
> (실제로 확인한 동작). 즉 마지막 세션 끄기는 **사실상 재시작**이 됩니다. 세션이 여러 개일 때 하나만
|
package/dist/agent.js
CHANGED
|
@@ -12,7 +12,9 @@ import { promptApproval } from './prompt.js';
|
|
|
12
12
|
import { touchRecentDir } from './recent-dirs.js';
|
|
13
13
|
import { listDirs } from './browse.js';
|
|
14
14
|
import { readTextFile, writeTextFile, chunkContent } from './files.js';
|
|
15
|
+
import { AUDIO_CHUNK_TTL_MS, MAX_AUDIO_BASE64, STT_SETUP_HINT, findSttServer, transcribe, } from './stt.js';
|
|
15
16
|
import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
17
|
+
import { IMAGE_CHUNK_TTL_MS, saveImage } from './images.js';
|
|
16
18
|
const HEARTBEAT_MS = 20_000;
|
|
17
19
|
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
18
20
|
const SCROLLBACK_MAX = 16_000;
|
|
@@ -77,6 +79,12 @@ export async function runAgent(options) {
|
|
|
77
79
|
const sessions = new Map();
|
|
78
80
|
// 폰에서 저장 중인 파일 조각 모음 (경로별)
|
|
79
81
|
const writeBuffers = new Map();
|
|
82
|
+
// 폰에서 온 녹음 조각 모음 (파일 저장과 같은 방식)
|
|
83
|
+
const audioBuffers = new Map();
|
|
84
|
+
// 폰에서 온 사진 조각 모음 (녹음과 같은 방식)
|
|
85
|
+
const imageBuffers = new Map();
|
|
86
|
+
// 이 컴퓨터에서 찾은 음성인식 서버 주소 — 시작할 때 한 번 찾고, 없으면 요청이 올 때 다시 찾는다
|
|
87
|
+
let sttUrl = null;
|
|
80
88
|
function defaultName(cwd) {
|
|
81
89
|
return cwd === os.homedir() ? hostLabel : `${hostLabel} · ${path.basename(cwd)}`;
|
|
82
90
|
}
|
|
@@ -409,6 +417,112 @@ export async function runAgent(options) {
|
|
|
409
417
|
console.log(`📱 폰에서 파일을 저장했어요: ${filePath}`);
|
|
410
418
|
return;
|
|
411
419
|
}
|
|
420
|
+
// 폰에서 마이크로 말한 녹음 — 조각이 다 모이면 이 컴퓨터의 음성인식 서버로 넘겨 글자로 돌려준다.
|
|
421
|
+
// 녹음은 인터넷의 다른 음성인식 서비스로 나가지 않고 이 컴퓨터 안에서만 처리된다.
|
|
422
|
+
if (frame.kind === 'stt_audio') {
|
|
423
|
+
const control = readControl(frame.data, session.frameKey);
|
|
424
|
+
if (!control ||
|
|
425
|
+
typeof control.id !== 'string' ||
|
|
426
|
+
typeof control.chunk !== 'string' ||
|
|
427
|
+
typeof control.index !== 'number' ||
|
|
428
|
+
typeof control.total !== 'number') {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const recordId = control.id;
|
|
432
|
+
const key = `${session.deviceId}:${recordId}`;
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
for (const [bufferKey, buffer] of audioBuffers) {
|
|
435
|
+
if (now - buffer.at > AUDIO_CHUNK_TTL_MS)
|
|
436
|
+
audioBuffers.delete(bufferKey);
|
|
437
|
+
}
|
|
438
|
+
const buffer = control.index === 0
|
|
439
|
+
? { parts: [], total: control.total, at: now }
|
|
440
|
+
: (audioBuffers.get(key) ?? { parts: [], total: control.total, at: now });
|
|
441
|
+
buffer.parts[control.index] = control.chunk;
|
|
442
|
+
buffer.at = now;
|
|
443
|
+
audioBuffers.set(key, buffer);
|
|
444
|
+
if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
|
|
445
|
+
return;
|
|
446
|
+
audioBuffers.delete(key);
|
|
447
|
+
const audioBase64 = buffer.parts.join('');
|
|
448
|
+
const fail = (reason) => send({
|
|
449
|
+
kind: 'stt_error',
|
|
450
|
+
to: session.deviceId,
|
|
451
|
+
data: encryptFrame(JSON.stringify({ id: recordId, reason }), session.frameKey),
|
|
452
|
+
});
|
|
453
|
+
if (audioBase64.length > MAX_AUDIO_BASE64) {
|
|
454
|
+
void fail('녹음이 너무 길어요. 조금 짧게 나눠서 말해주세요.');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
void (async () => {
|
|
458
|
+
if (!sttUrl)
|
|
459
|
+
sttUrl = await findSttServer();
|
|
460
|
+
if (!sttUrl) {
|
|
461
|
+
await fail(STT_SETUP_HINT);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const result = await transcribe(sttUrl, audioBase64);
|
|
465
|
+
if (!result.ok) {
|
|
466
|
+
// 서버가 꺼졌을 수도 있으니 다음 요청 때 다시 찾도록 주소를 비운다
|
|
467
|
+
sttUrl = null;
|
|
468
|
+
await fail(result.reason);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
console.log(`📱 폰에서 말한 내용을 받아썼어요: "${result.text}"`);
|
|
472
|
+
await send({
|
|
473
|
+
kind: 'stt_text',
|
|
474
|
+
to: session.deviceId,
|
|
475
|
+
data: encryptFrame(JSON.stringify({ id: recordId, text: result.text }), session.frameKey),
|
|
476
|
+
});
|
|
477
|
+
})();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// 폰에서 올린 사진 — 조각이 다 모이면 ~/.shellbase/images 에 파일로 저장하고 경로를 돌려준다.
|
|
481
|
+
// 폰은 그 경로를 터미널에 넣어서 Claude Code 가 그림을 읽게 한다.
|
|
482
|
+
if (frame.kind === 'image_upload') {
|
|
483
|
+
const control = readControl(frame.data, session.frameKey);
|
|
484
|
+
if (!control ||
|
|
485
|
+
typeof control.id !== 'string' ||
|
|
486
|
+
typeof control.mime !== 'string' ||
|
|
487
|
+
typeof control.chunk !== 'string' ||
|
|
488
|
+
typeof control.index !== 'number' ||
|
|
489
|
+
typeof control.total !== 'number') {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const uploadId = control.id;
|
|
493
|
+
const mime = control.mime;
|
|
494
|
+
const key = `${session.deviceId}:${uploadId}`;
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
for (const [bufferKey, buffer] of imageBuffers) {
|
|
497
|
+
if (now - buffer.at > IMAGE_CHUNK_TTL_MS)
|
|
498
|
+
imageBuffers.delete(bufferKey);
|
|
499
|
+
}
|
|
500
|
+
const buffer = control.index === 0
|
|
501
|
+
? { parts: [], total: control.total, at: now }
|
|
502
|
+
: (imageBuffers.get(key) ?? { parts: [], total: control.total, at: now });
|
|
503
|
+
buffer.parts[control.index] = control.chunk;
|
|
504
|
+
buffer.at = now;
|
|
505
|
+
imageBuffers.set(key, buffer);
|
|
506
|
+
if (buffer.parts.filter((part) => typeof part === 'string').length < buffer.total)
|
|
507
|
+
return;
|
|
508
|
+
imageBuffers.delete(key);
|
|
509
|
+
const result = saveImage(buffer.parts.join(''), mime);
|
|
510
|
+
if (!result.ok) {
|
|
511
|
+
void send({
|
|
512
|
+
kind: 'image_error',
|
|
513
|
+
to: session.deviceId,
|
|
514
|
+
data: encryptFrame(JSON.stringify({ id: uploadId, reason: result.reason }), session.frameKey),
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
console.log(`📱 폰에서 사진을 받았어요: ${result.path}`);
|
|
519
|
+
void send({
|
|
520
|
+
kind: 'image_saved',
|
|
521
|
+
to: session.deviceId,
|
|
522
|
+
data: encryptFrame(JSON.stringify({ id: uploadId, path: result.path }), session.frameKey),
|
|
523
|
+
});
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
412
526
|
if (frame.kind === 'list_dirs') {
|
|
413
527
|
const control = readControl(frame.data, session.frameKey);
|
|
414
528
|
if (!control)
|
|
@@ -465,6 +579,13 @@ export async function runAgent(options) {
|
|
|
465
579
|
heartbeatDevice(cb, session.rowId).catch((err) => console.error(`하트비트 실패(${session.name}):`, err.message));
|
|
466
580
|
}
|
|
467
581
|
}, HEARTBEAT_MS);
|
|
582
|
+
// 음성인식 서버가 이 컴퓨터에 떠 있으면 폰에서 마이크 버튼을 쓸 수 있다 (없어도 나머지 기능은 그대로)
|
|
583
|
+
void findSttServer().then((url) => {
|
|
584
|
+
sttUrl = url;
|
|
585
|
+
console.log(url
|
|
586
|
+
? `🎤 음성인식 서버를 찾았어요: ${url} — 폰에서 마이크로 말할 수 있어요.`
|
|
587
|
+
: '🎤 음성인식 서버가 없어요 — 폰 마이크 기능은 꺼진 상태예요 (SHELLBASE_STT_URL 로 주소를 지정할 수 있어요).');
|
|
588
|
+
});
|
|
468
589
|
startSleepGuard();
|
|
469
590
|
process.on('SIGINT', () => void shutdownProcess());
|
|
470
591
|
process.on('SIGTERM', () => void shutdownProcess());
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/images.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// 폰에서 찍거나 고른 사진을 이 컴퓨터에 파일로 내려놓는 부분.
|
|
2
|
+
// Claude Code 는 "파일 경로"만 프롬프트에 있으면 그림을 직접 읽을 수 있어서,
|
|
3
|
+
// 여기 저장한 뒤 경로를 폰으로 돌려주면 폰에서 그 경로를 터미널에 넣어 쓰게 된다.
|
|
4
|
+
import { mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
export const IMAGE_DIR = join(homedir(), '.shellbase', 'images');
|
|
8
|
+
// 조각(chunk)이 다 모이기를 기다리는 시간 — 사진은 녹음보다 조각이 많아서 넉넉하게
|
|
9
|
+
export const IMAGE_CHUNK_TTL_MS = 180_000;
|
|
10
|
+
// base64 글자 수 상한 (약 6MB 원본). 폰에서 이미 줄여서 보내지만 안전장치로 둔다.
|
|
11
|
+
export const MAX_IMAGE_BASE64 = 8 * 1024 * 1024;
|
|
12
|
+
// 올린 지 오래된 사진은 저장할 때마다 조용히 치운다 (디스크가 계속 차지 않게)
|
|
13
|
+
const KEEP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
14
|
+
const EXT_BY_TYPE = {
|
|
15
|
+
'image/png': 'png',
|
|
16
|
+
'image/jpeg': 'jpg',
|
|
17
|
+
'image/jpg': 'jpg',
|
|
18
|
+
'image/webp': 'webp',
|
|
19
|
+
'image/gif': 'gif',
|
|
20
|
+
};
|
|
21
|
+
function pruneOld() {
|
|
22
|
+
try {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const name of readdirSync(IMAGE_DIR)) {
|
|
25
|
+
const full = join(IMAGE_DIR, name);
|
|
26
|
+
try {
|
|
27
|
+
if (now - statSync(full).mtimeMs > KEEP_MS)
|
|
28
|
+
unlinkSync(full);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// 지우다 실패해도 사진 저장 자체를 막지는 않는다
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// 폴더가 아직 없으면 지울 것도 없다
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 두 자리로 맞춘 시간 문자열 (파일 이름이 시간순으로 정렬되게)
|
|
40
|
+
function stamp(date) {
|
|
41
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
42
|
+
return (`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
|
|
43
|
+
`-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`);
|
|
44
|
+
}
|
|
45
|
+
export function saveImage(base64, mime) {
|
|
46
|
+
if (base64.length > MAX_IMAGE_BASE64) {
|
|
47
|
+
return { ok: false, reason: '사진이 너무 커요. 조금 작은 사진으로 다시 올려주세요.' };
|
|
48
|
+
}
|
|
49
|
+
const ext = EXT_BY_TYPE[mime.toLowerCase()];
|
|
50
|
+
if (!ext)
|
|
51
|
+
return { ok: false, reason: '이 형식의 사진은 아직 못 받아요 (png·jpg·webp·gif 만).' };
|
|
52
|
+
let bytes;
|
|
53
|
+
try {
|
|
54
|
+
bytes = Buffer.from(base64, 'base64');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { ok: false, reason: '사진이 오는 도중에 깨졌어요. 다시 시도해주세요.' };
|
|
58
|
+
}
|
|
59
|
+
if (bytes.length === 0)
|
|
60
|
+
return { ok: false, reason: '사진이 비어 있어요. 다시 골라주세요.' };
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(IMAGE_DIR, { recursive: true });
|
|
63
|
+
pruneOld();
|
|
64
|
+
// 같은 초에 여러 장을 올려도 안 겹치도록 뒤에 짧은 임의 글자를 붙인다
|
|
65
|
+
const suffix = Math.random().toString(36).slice(2, 6);
|
|
66
|
+
const path = join(IMAGE_DIR, `${stamp(new Date())}-${suffix}.${ext}`);
|
|
67
|
+
writeFileSync(path, bytes, { mode: 0o600 });
|
|
68
|
+
return { ok: true, path };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
reason: `사진을 저장하지 못했어요: ${err instanceof Error ? err.message : String(err)}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/stt.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 폰에서 보낸 녹음을 "이 컴퓨터에 떠 있는 음성인식 서버"로 넘겨 글자로 바꾼다.
|
|
2
|
+
// 녹음이 컴퓨터 밖으로 나가지 않는 게 핵심 — 폰 → (암호화된 실시간 채널) → 이 컴퓨터 → 로컬 whisper.
|
|
3
|
+
// 실시간 프레임은 한 번에 20KB 남짓만 보낼 수 있어서 녹음도 파일과 같은 방식으로 조각내 받는다
|
|
4
|
+
export const AUDIO_CHUNK_TTL_MS = 60_000;
|
|
5
|
+
// 대략 1~2분 분량 (base64 로 부풀린 크기 기준)
|
|
6
|
+
export const MAX_AUDIO_BASE64 = 2 * 1024 * 1024;
|
|
7
|
+
// 서버를 찾는 순서: 직접 지정한 주소 → 이 컴퓨터 → 도커 안에서 본 호스트
|
|
8
|
+
function candidates() {
|
|
9
|
+
const list = [];
|
|
10
|
+
const configured = process.env.SHELLBASE_STT_URL?.trim();
|
|
11
|
+
if (configured)
|
|
12
|
+
list.push(configured.replace(/\/$/, ''));
|
|
13
|
+
list.push('http://127.0.0.1:5005', 'http://172.17.0.1:5005', 'http://host.docker.internal:5005');
|
|
14
|
+
return list;
|
|
15
|
+
}
|
|
16
|
+
async function isAlive(baseUrl) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1500) });
|
|
19
|
+
return res.ok;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// 시작할 때 한 번 찾아두고, 못 찾으면 요청이 올 때 다시 한 번 찾아본다(그 사이에 켰을 수도 있으니)
|
|
26
|
+
export async function findSttServer() {
|
|
27
|
+
for (const url of candidates()) {
|
|
28
|
+
if (await isAlive(url))
|
|
29
|
+
return url;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
export async function transcribe(baseUrl, audioBase64) {
|
|
34
|
+
const languages = (process.env.SHELLBASE_STT_LANGS ?? 'ko')
|
|
35
|
+
.split(',')
|
|
36
|
+
.map((lang) => lang.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${baseUrl}/transcribe_json`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ audio_base64: audioBase64, languages }),
|
|
43
|
+
signal: AbortSignal.timeout(60_000),
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
return { ok: false, reason: `음성인식 서버가 오류를 돌려줬어요 (HTTP ${res.status}).` };
|
|
47
|
+
}
|
|
48
|
+
const body = (await res.json());
|
|
49
|
+
if (body.ok === false) {
|
|
50
|
+
return { ok: false, reason: body.error ?? '음성을 알아듣지 못했어요.' };
|
|
51
|
+
}
|
|
52
|
+
const text = (body.text ?? '').trim();
|
|
53
|
+
if (!text)
|
|
54
|
+
return { ok: false, reason: '말소리가 들리지 않았어요. 다시 한 번 말해주세요.' };
|
|
55
|
+
return { ok: true, text };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const reason = err.name === 'TimeoutError'
|
|
59
|
+
? '음성인식이 너무 오래 걸려서 멈췄어요.'
|
|
60
|
+
: `음성인식 서버에 연결하지 못했어요: ${err.message}`;
|
|
61
|
+
return { ok: false, reason };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export const STT_SETUP_HINT = '이 컴퓨터에서 음성인식 서버를 찾지 못했어요. whisper 같은 음성인식 서버를 켜고, 주소가 다르면 ' +
|
|
65
|
+
'SHELLBASE_STT_URL=http://127.0.0.1:5005 처럼 지정한 뒤 에이전트를 다시 시작해주세요.';
|