v.2.5.0
This commit is contained in:
@@ -71,7 +71,17 @@
|
||||
"Bash(pkill *)",
|
||||
"Bash(grep -rn \"getUserWorkspace\\\\|userWorkspace\\\\|users/\\\\${username}\" /home/kim/homeclaw/src/config/config.ts)",
|
||||
"Bash(grep *)",
|
||||
"Bash(dd if=/home/kim/homeclaw/.smallclaw/users/cherry/workspace/.smallclaw/vault/vault.enc bs=1 skip=620 count=80)"
|
||||
"Bash(dd if=/home/kim/homeclaw/.smallclaw/users/cherry/workspace/.smallclaw/vault/vault.enc bs=1 skip=620 count=80)",
|
||||
"Bash(claude plugin *)",
|
||||
"Bash(claude mcp *)",
|
||||
"Bash(/home/kim/.opencode/bin/opencode --help)",
|
||||
"Bash(/home/kim/.opencode/bin/opencode mcp *)",
|
||||
"Bash(dpkg -l)",
|
||||
"Bash(ffmpeg -version)",
|
||||
"Bash(whisper-cli --help)",
|
||||
"Bash(piper --version)",
|
||||
"Read(//usr/local/bin/**)",
|
||||
"Bash(whisper-cli *)"
|
||||
]
|
||||
},
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||||
|
||||
@@ -61,12 +61,202 @@
|
||||
"role": "assistant",
|
||||
"content": "모든 계정의 메일 목록입니다.\n\n**applecherry**\n1. YouTube PIP 모드 업데이트 (4/30)\n2. YouTube Premium 결제 수단 업데이트 요청 (25/12/2)\n3. YouTube Premium 가입 축하 (25/9/3)\n\n**daum**\n1. OpenRouter Team: per-request model routing (5/4)\n2. Unsplash Team: A special invitation (5/4)\n3. 국세청: 김상규연합치과 안내 (5/4)\n4. KICC: 쿠팡 신용카드 결제 내역 (5/4)\n5. 쿠팡: 와우 멤버십 월회비 결제 (5/4)\n6. Pexels: Find free content (5/4)\n7. Elicit: Search over 125M papers (5/3)\n8. Unsplash: Beautiful photos (5/3)\n9. Google Play: 주문 영수증 (5/3)\n10. eBay: $5 coupon for Sang-Gyoo (5/2)\n\n**evangel** 계정에는 메일이 없습니다. 더 자세히 읽어볼 메일이 있을까요?",
|
||||
"timestamp": 1777909390918
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "3. 국세청: 김상규연합치과 안내 (5/4)",
|
||||
"timestamp": 1777912695595
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "국세청에서 발송한 전자세금계산서 발급 안내 메일입니다. 연합세무회계사무소에서 김상규연합치과로 2026년 5월 4일에 발급되었으며, 상세 내용은 첨부된 HTML 파일을 통해 확인하실 수 있습니다. 첨부파일을 열어드릴까요?",
|
||||
"timestamp": 1777912724499
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "응",
|
||||
"timestamp": 1777912738445
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "국세청에서 발송한 전자세금계산서 보안 메일입니다. 첨부된 HTML 파일은 암호화되어 있어, 내용을 확인하시려면 **사업자등록번호 10자리** 또는 **주민등록번호 13자리**를 입력해야 합니다.\n\n비밀번호를 알려주시면 제가 내용을 확인해 드릴 수 있습니다. 어떻게 도와드릴까요?",
|
||||
"timestamp": 1777912764291
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "6410191690218",
|
||||
"timestamp": 1777912817154
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Tool failed: ERROR: Navigation failed: page.goto: net::ERR_NAME_NOT_RESOLVED at ~~https://file///C:[REDACTED-HE].html~~ ⚠️ (링크 끊김)\nCall log:\n\u001b[2m - navigating to \"~~https://file/~~ ⚠️ (링크 끊김)",
|
||||
"timestamp": 1777912862467
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "다운로드 링크 만들어줘",
|
||||
"timestamp": 1777912888566
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "해당 메일의 첨부파일(`NTS_eTaxInvoice.html`)을 확인했습니다. 아래 링크를 클릭하여 파일을 다운로드하거나 브라우저에서 여실 수 있습니다.\n\n[국세청 전자세금계산서 첨부파일 열기](file:///C:[REDACTED-HE].html)\n\n*참고: 보안 메일이므로 열기 위해 입력하셨던 번호가 다시 필요할 수 있습니다.*",
|
||||
"timestamp": 1777912902497
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "오늘 날씨 어때?",
|
||||
"timestamp": 1777942331781
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "오늘 서울은 맑고 기온은 17°C입니다. 습도는 26%로 낮아 쾌적한 날씨입니다.",
|
||||
"timestamp": 1777942339455
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "오늘 bbc cnn abc 뉴스 500자로 정리.",
|
||||
"timestamp": 1777942451691
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "2026년 5월 5일 주요 뉴스 요약입니다.\n\n**BBC:** 중동 긴장이 지속되는 가운데 이스라엘의 레바논 남부 공습으로 77명이 사망했습니다. 또한 컨트리 스타 돌리 파튼(80)이 무대 복귀를 위해 시간이 필요하다는 소식과 함께 싱가포르발 국제 뉴스(Newsday)를 비중 있게 다루고 있습니다.\n\n**CNN:** 트럼프 대통령의 호르무즈 해협 선박 유도 작전이 중동 정세의 핵심 변수로 떠올랐습니다. 미국과 이란이 평화안을 교환하며 긍정적인 논의를 이어가고 있으나, 최근 이란 보트 8척 파괴 사건으로 휴전 유지 여부가 불투명해졌습니다. 한편 미국 내 평균 가스 가격은 4달러로 상승했습니다.\n\n**ABC:** 호주 중앙은행(RBA)이 인플레이션 억제를 위해 올해 세 번째 금리 인상을 단행할 것으로 예상되어 시장이 긴장하고 있습니다. 문화계에서는 영화 '잇 엔즈 위드 어스' 제작 과정의 갈등으로 법적 다툼을 벌이던 블레이크 라이블리와 저스틴 발도니가 합의에 도달했다는 소식이 전해졌습니다.",
|
||||
"timestamp": 1777942476862
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "새로온 메일 있니?",
|
||||
"timestamp": 1777942760588
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "오늘 새로 도착한 메일은 총 2건입니다.\n\n* **applecherry:** Ai2에서 보낸 \"What’s next for Ai2\" 뉴스레터 (오전 1:02)\n* **daum:** Unsplash 파트너십 관련 \"Unsplash API <> homeclaw\" 메일 (오전 3:00)\n\n내용을 확인해 드릴까요?",
|
||||
"timestamp": 1777942771367
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "응",
|
||||
"timestamp": 1777942821837
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "새로 온 메일 2건의 요약입니다.\n\n1. **Ai2 뉴스레터 (오전 1:02):** 임시 CEO 피터 클라크와의 인터뷰 내용입니다. AI의 급격한 발전 속에서도 장기적인 연구와 '오픈 소스' 원칙을 지키겠다는 의지를 강조하며, 차세대 오픈 모델 개발 프로젝트(OMAI)에 대한 업데이트를 예고했습니다.\n2. **Unsplash 파트너십 (오전 3:00):** 귀하의 앱 'homeclaw'의 API 프로덕션 승인을 위한 가이드라인 준수 요청입니다. 사진작가 출처 표기(Attribution)와 다운로드 트리거(Triggering Downloads) 구현이 확인되지 않아, 이를 수정한 스크린샷이나 영상을 제출해달라는 내용입니다.\n\nUnsplash 승인을 위해 필요한 작업을 도와드릴까요?",
|
||||
"timestamp": 1777942837018
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "이메일 체크",
|
||||
"timestamp": 1777945046464
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "최근 도착한 메일은 Ai2의 뉴스레터(오늘 오전 1:02)와 YouTube Premium 관련 안내 메일들입니다. Ai2 메일은 임시 CEO 피터 클라크와의 인터뷰 내용을 담고 있습니다. 상세 내용이 궁금한 메일이 있으신가요?",
|
||||
"timestamp": 1777945059614
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "다음 에밀 확인",
|
||||
"timestamp": 1777945084058
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "다음(Daum) 계정의 최근 메일 목록입니다.\n\n1. **Pixabay:** 가입 환영 및 시작 가이드 (오전 10:11)\n2. **Unsplash:** homeclaw API 파트너십 관련 (오전 3:00)\n3. **OpenRouter:** 요청별 모델 라우팅 기능 안내 (어제 오후 11:07)\n4. **Unsplash:** 특별 초대장 (어제 오후 9:31)\n5. **국세청:** 김상규연합치과 관련 안내 (어제 오후 4:56)\n\n어떤 메일을 읽어드릴까요?",
|
||||
"timestamp": 1777945091766
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "1. Pixabay: 가입 환영 및 시작 가이드 (오전 10:11)",
|
||||
"timestamp": 1777945123447
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Pixabay 가입 환영 메일입니다. 수백만 개의 사진, 비디오, 음악 등을 다운로드할 수 있으며, 프로필 설정, 라이선스 규정 확인, 커뮤니티 포럼 참여 등을 안내하고 있습니다. 별도의 인증 절차 없이 바로 서비스를 이용하실 수 있습니다.",
|
||||
"timestamp": 1777945140736
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "그랜드 스타렉스 캠핑카 개조 사진 찾을 수 있을까?",
|
||||
"timestamp": 1777945339762
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "그랜드 스타렉스 캠핑카 개조 사진을 찾아보았습니다. 내부 침상, 수납장, 싱크대 등 다양한 개조 사례를 확인하실 수 있습니다.\n\n:** 12인승을 5인승으로 구조 변경하는 등 다양한 실내 레이아웃 사례가 많습니다.\n* **아트원 (everydayartone.tistory.com):** 11인승을 2인승으로 개조하는 등 합법적인 구조 변경과 수납장 중심의 개조 정보가 풍부합니다.\n* **캠핑카 제작소 (m.blog.naver.com/fhrm3):** 3밴, 5밴을 활용한 실속형 개조와 낚시용 차박 침상 제작 사례를 확인할 수 있습니다.\n\n원하시는 특정 개조 스타일(침상 위주, 풀옵션 등)이 있다면 더 자세히 찾아드릴까요?",
|
||||
"timestamp": 1777945526418
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "pixabay 검색.",
|
||||
"timestamp": 1777945680405
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Pixabay에서 '그랜드 스타렉스'와 같은 특정 국산 모델의 개조 사진은 찾기 어려워, 일반적인 캠핑카 내부 개조(Camping car interior) 이미지들을 찾아보았습니다. 침상과 조명, 수납 공간 등 개조 아이디어를 참고해 보세요.",
|
||||
"timestamp": 1777945808638
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "캠핑카 개조 전문 업체 구미 근처 검색",
|
||||
"timestamp": 1777949705661
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "구미 인근의 캠핑카 개조 전문 업체들입니다.\n\n* **네이처하우스 (칠곡군 석적읍):** 맞춤 제작, 리모델링, 전 차종 구조 변경 전문입니다.\n* **원 캠핑카 (구미시):** 인산철 배터리, 무시동 히터, 바닥 난방 등 전기 및 설비 작업에 강점이 있습니다.\n* **남구미캠핑카 (구미시):** 시티밴 등 다양한 차량의 풀옵션 제작 및 튜닝을 전문으로 합니다.\n\n방문 전 전화로 스타렉스 개조 가능 여부와 견적을 먼저 문의해 보시는 것을 추천드립니다.",
|
||||
"timestamp": 1777949723744
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "구조변경 검사 대행업체",
|
||||
"timestamp": 1777949804606
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "구미 인근에서 캠핑카 구조변경 및 검사 대행을 전문으로 하는 업체입니다.\n\n* **남구미캠핑카 (칠곡 북삼):** 캠핑카 제작과 함께 전 차종 구조변경 및 튜닝 검사 대행을 전문으로 합니다. (054-977-8825)\n* **네이처하우스 (칠곡 석적):** 맞춤 제작뿐만 아니라 전 차종 구조변경 대행 서비스를 제공합니다. (010-3810-1130)\n* **오토튜닝샵 (김해/경남권):** 구미에서 거리가 조금 있으나, 캠핑카 승차정원 변경 및 자동차 검사 대행을 전문적으로 처리하는 대형 업체입니다.\n\n대행 시에는 **도면 작성 및 승인 절차**가 포함되므로, 현재 차량의 개조 상태를 먼저 상담받으시는 것이 좋습니다.",
|
||||
"timestamp": 1777949820699
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "그랜드 스타렉스 캠핑카 구조변경 평면도 많은 곳",
|
||||
"timestamp": 1777949911022
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "그랜드 스타렉스 캠핑카 구조변경 평면도와 설계 자료가 많은 곳들입니다.\n\n* **아로에의 캠핑과 인생 (aloecamping.tistory.com):** '더뉴 그랜드 스타렉스' 기반의 상세 설계 도면과 수치 데이터를 공유하고 있어 자작 캠핑카 유저들에게 인기가 높습니다.\n* **네이버 블로그 'mg5694':** 3인 탑승, 2인 취침 기준의 실측 도면과 구조변경 완료 사례를 PDF나 이미지 파일로 제공합니다.\n* **캠핑제국 (유튜브/카페):** 다양한 업체의 스타렉스 개조 평면도와 내부 레이아웃을 영상과 사진으로 가장 많이 볼 수 있는 커뮤니티입니다.\n\n특히 **'아로에'** 블로그는 캐드 도면 수준의 상세 수치를 제공하므로 구조변경 서류 준비 시 참고하기 가장 좋습니다.",
|
||||
"timestamp": 1777949931146
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "링크 만들어줘",
|
||||
"timestamp": 1777949962891
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "그랜드 스타렉스 캠핑카 구조변경 평면도와 설계 자료를 확인할 수 있는 주요 링크입니다.\n\n1. **아로에의 캠핑과 인생 (상세 수치 및 도면)**\n * [스타렉스 캠핑카 구조변경 설계와 도면 다운로드](https://aloecamping.tistory.com/59)\n * [캠핑카 도면 자료실 (그림 파일)](https://aloecamping.tistory.com/66)\n\n2. **mg5694 네이버 블로그 (자작 실측 도면)**\n * [스타렉스 캠핑카 DIY 설계 도면 (구조변경 완료 사례)](https://blog.naver.com/mg5694/221870477927)\n\n3. **현대자동차 공식 카탈로그 (순정 레이아웃 참고용)**\n * [그랜드 스타렉스 특장(캠핑카) 카탈로그 PDF](https://www.hyundai.[REDACTED-HE].pdf)\n\n4. **캠핑제국 (다양한 업체 레이아웃 영상)**\n * [캠핑제국 유튜브 채널 - 스타렉스 개조 사례](https://www.youtube.com/@campingempire)\n\n특히 **아로에** 블로그의 자료가 실측 수치가 포함되어 있어 구조변경 서류 작성 시 가장 유용합니다.",
|
||||
"timestamp": 1777949985454
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "telegram에서 음성입력하려면 어떻게 하지?",
|
||||
"timestamp": 1777967980684
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "채팅창 오른쪽의 **마이크 아이콘**을 길게 누르면 음성 메시지가 전송됩니다. 텍스트로 변환해 입력하고 싶다면 채팅창을 클릭한 후 **Windows 키 + H**를 눌러 윈도우 음성 받아쓰기 기능을 이용하세요.",
|
||||
"timestamp": 1777967988064
|
||||
}
|
||||
],
|
||||
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
|
||||
"createdAt": 1777909116851,
|
||||
"lastActiveAt": 1777909390918,
|
||||
"lastActiveAt": 1777967988064,
|
||||
"pendingMemoryFlush": false,
|
||||
"pendingCompaction": false,
|
||||
"contextTokenEstimate": 375
|
||||
"contextTokenEstimate": 3195
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -279,3 +279,19 @@ TS 한국교통안전공단 승인용 도면에는 반드시 **mm 단위의 치
|
||||
2. Unsplash Team: A special invitation (5/4)
|
||||
3. 국세청: 김상규연합치과 안내 (5/4)
|
||||
4. KICC: 쿠팡 신용카드 결제 내역 (5/4
|
||||
[01:38:33] **User**: 3. 국세청: 김상규연합치과 안내 (5/4)
|
||||
[01:38:44] **SmallClaw**: 국세청에서 발송한 전자세금계산서 발급 안내 메일입니다. 연합세무회계사무소에서 김상규연합치과로 2026년 5월 4일에 발급되었으며, 상세 내용은 첨부된 HTML 파일을 통해 확인하실 수 있습니다. 첨부파일을 열어드릴까요?
|
||||
[01:39:08] **User**: 응
|
||||
[01:39:24] **SmallClaw**: 국세청에서 발송한 전자세금계산서 보안 메일입니다. 첨부된 HTML 파일은 암호화되어 있어, 내용을 확인하시려면 **사업자등록번호 10자리** 또는 **주민등록번호 13자리**를 입력해야 합니다.
|
||||
|
||||
비밀번호를 알려주시면 제가 내용을 확인해 드릴 수 있습니다. 어떻게 도와드릴까요?
|
||||
[01:40:26] **User**: 6410191690218
|
||||
[01:41:02] **SmallClaw**: Tool failed: ERROR: Navigation failed: page.goto: net::ERR_NAME_NOT_RESOLVED at https://file///C:/Users/Sang-Gyoo/SmallClaw/attachments/uid-55253/NTS_eTaxInvoice.html
|
||||
Call log:
|
||||
[2m - navigating to "https://file/
|
||||
[01:41:37] **User**: 다운로드 링크 만들어줘
|
||||
[01:41:42] **SmallClaw**: 해당 메일의 첨부파일(`NTS_eTaxInvoice.html`)을 확인했습니다. 아래 링크를 클릭하여 파일을 다운로드하거나 브라우저에서 여실 수 있습니다.
|
||||
|
||||
[국세청 전자세금계산서 첨부파일 열기](file:///C:/Users/Sang-Gyoo/SmallClaw/attachments/uid-55253/NTS_eTaxInvoice.html)
|
||||
|
||||
*참고: 보안 메일이므로 열기 위해 입력하셨던 번호가 다시 필요할 수 있습니다.*
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
[09:52:15] **User**: 오늘 날씨 어때?
|
||||
[09:52:19] **SmallClaw**: 오늘 서울은 맑고 기온은 17°C입니다. 습도는 26%로 낮아 쾌적한 날씨입니다.
|
||||
[09:54:18] **User**: 오늘 bbc cnn abc 뉴스 500자로 정리.
|
||||
[09:54:36] **SmallClaw**: 2026년 5월 5일 주요 뉴스 요약입니다.
|
||||
|
||||
**BBC:** 중동 긴장이 지속되는 가운데 이스라엘의 레바논 남부 공습으로 77명이 사망했습니다. 또한 컨트리 스타 돌리 파튼(80)이 무대 복귀를 위해 시간이 필요하다는 소식과 함께 싱가포르발 국제 뉴스(Newsday)를 비중 있게 다루고 있습니다.
|
||||
|
||||
**CNN:** 트럼프 대통령의 호르무즈 해협 선박 유도 작전이 중동 정세의 핵심 변수로 떠올랐습니다. 미국과 이란이 평화안을 교환하며 긍정적인 논의를 이어가고 있으나, 최근 이란 보트 8척 파괴 사건으로 휴전 유지 여부가 불투명해졌습
|
||||
[09:59:26] **User**: 새로온 메일 있니?
|
||||
[09:59:31] **SmallClaw**: 오늘 새로 도착한 메일은 총 2건입니다.
|
||||
|
||||
* **applecherry:** Ai2에서 보낸 "What’s next for Ai2" 뉴스레터 (오전 1:02)
|
||||
* **daum:** Unsplash 파트너십 관련 "Unsplash API <> homeclaw" 메일 (오전 3:00)
|
||||
|
||||
내용을 확인해 드릴까요?
|
||||
[10:00:28] **User**: 응
|
||||
[10:00:37] **SmallClaw**: 새로 온 메일 2건의 요약입니다.
|
||||
|
||||
1. **Ai2 뉴스레터 (오전 1:02):** 임시 CEO 피터 클라크와의 인터뷰 내용입니다. AI의 급격한 발전 속에서도 장기적인 연구와 '오픈 소스' 원칙을 지키겠다는 의지를 강조하며, 차세대 오픈 모델 개발 프로젝트(OMAI)에 대한 업데이트를 예고했습니다.
|
||||
2. **Unsplash 파트너십 (오전 3:00):** 귀하의 앱 'homeclaw'의 API 프로덕션 승인을 위한 가이드라인 준수 요청입니다. 사진작가 출처 표기(Attribution)와 다운로드 트리거(Triggering Do
|
||||
[10:37:30] **User**: 이메일 체크
|
||||
[10:37:39] **SmallClaw**: 최근 도착한 메일은 Ai2의 뉴스레터(오늘 오전 1:02)와 YouTube Premium 관련 안내 메일들입니다. Ai2 메일은 임시 CEO 피터 클라크와의 인터뷰 내용을 담고 있습니다. 상세 내용이 궁금한 메일이 있으신가요?
|
||||
[10:38:07] **User**: 다음 에밀 확인
|
||||
[10:38:11] **SmallClaw**: 다음(Daum) 계정의 최근 메일 목록입니다.
|
||||
|
||||
1. **Pixabay:** 가입 환영 및 시작 가이드 (오전 10:11)
|
||||
2. **Unsplash:** homeclaw API 파트너십 관련 (오전 3:00)
|
||||
3. **OpenRouter:** 요청별 모델 라우팅 기능 안내 (어제 오후 11:07)
|
||||
4. **Unsplash:** 특별 초대장 (어제 오후 9:31)
|
||||
5. **국세청:** 김상규연합치과 관련 안내 (어제 오후 4:56)
|
||||
|
||||
어떤 메일을 읽어드릴까요?
|
||||
[10:38:47] **User**: 1. Pixabay: 가입 환영 및 시작 가이드 (오전 10:11)
|
||||
[10:39:00] **SmallClaw**: Pixabay 가입 환영 메일입니다. 수백만 개의 사진, 비디오, 음악 등을 다운로드할 수 있으며, 프로필 설정, 라이선스 규정 확인, 커뮤니티 포럼 참여 등을 안내하고 있습니다. 별도의 인증 절차 없이 바로 서비스를 이용하실 수 있습니다.
|
||||
[10:42:23] **User**: 그랜드 스타렉스 캠핑카 개조 사진 찾을 수 있을까?
|
||||
[10:44:28] **SmallClaw**: 그랜드 스타렉스 캠핑카 개조 사진을 찾아보았습니다. 내부 침상, 수납장, 싱크대 등 다양한 개조 사례를 확인하실 수 있습니다.
|
||||
|
||||
:** 12인승을 5인승으로 구조 변경하는 등 다양한 실내 레이아웃 사례가 많습니다.
|
||||
* **아트원 (everydayartone.tistory.com):** 11인승을 2인승으로 개조하는 등 합법적인 구조 변경과 수납장 중심의 개조 정보가 풍부합니다.
|
||||
* **캠핑카 제작소 (m.blog.naver.com/fhrm3):** 3밴, 5밴을 활용한 실속형 개조와 낚시용 차박
|
||||
[10:48:40] **User**: pixabay 검색.
|
||||
[10:50:08] **SmallClaw**: Pixabay에서 '그랜드 스타렉스'와 같은 특정 국산 모델의 개조 사진은 찾기 어려워, 일반적인 캠핑카 내부 개조(Camping car interior) 이미지들을 찾아보았습니다. 침상과 조명, 수납 공간 등 개조 아이디어를 참고해 보세요.
|
||||
[11:55:15] **User**: 캠핑카 개조 전문 업체 구미 근처 검색
|
||||
[11:55:23] **SmallClaw**: 구미 인근의 캠핑카 개조 전문 업체들입니다.
|
||||
|
||||
* **네이처하우스 (칠곡군 석적읍):** 맞춤 제작, 리모델링, 전 차종 구조 변경 전문입니다.
|
||||
* **원 캠핑카 (구미시):** 인산철 배터리, 무시동 히터, 바닥 난방 등 전기 및 설비 작업에 강점이 있습니다.
|
||||
* **남구미캠핑카 (구미시):** 시티밴 등 다양한 차량의 풀옵션 제작 및 튜닝을 전문으로 합니다.
|
||||
|
||||
방문 전 전화로 스타렉스 개조 가능 여부와 견적을 먼저 문의해 보시는 것을 추천드립니다.
|
||||
[11:56:54] **User**: 구조변경 검사 대행업체
|
||||
[11:57:00] **SmallClaw**: 구미 인근에서 캠핑카 구조변경 및 검사 대행을 전문으로 하는 업체입니다.
|
||||
|
||||
* **남구미캠핑카 (칠곡 북삼):** 캠핑카 제작과 함께 전 차종 구조변경 및 튜닝 검사 대행을 전문으로 합니다. (054-977-8825)
|
||||
* **네이처하우스 (칠곡 석적):** 맞춤 제작뿐만 아니라 전 차종 구조변경 대행 서비스를 제공합니다. (010-3810-1130)
|
||||
* **오토튜닝샵 (김해/경남권):** 구미에서 거리가 조금 있으나, 캠핑카 승차정원 변경 및 자동차 검사 대행을 전문적으로 처리하는 대형 업체입니다.
|
||||
|
||||
대행 시에는 **도면
|
||||
[11:58:44] **User**: 그랜드 스타렉스 캠핑카 구조변경 평면도 많은 곳
|
||||
[11:58:51] **SmallClaw**: 그랜드 스타렉스 캠핑카 구조변경 평면도와 설계 자료가 많은 곳들입니다.
|
||||
|
||||
* **아로에의 캠핑과 인생 (aloecamping.tistory.com):** '더뉴 그랜드 스타렉스' 기반의 상세 설계 도면과 수치 데이터를 공유하고 있어 자작 캠핑카 유저들에게 인기가 높습니다.
|
||||
* **네이버 블로그 'mg5694':** 3인 탑승, 2인 취침 기준의 실측 도면과 구조변경 완료 사례를 PDF나 이미지 파일로 제공합니다.
|
||||
* **캠핑제국 (유튜브/카페):** 다양한 업체의 스타렉스 개조 평면도와 내부 레이아웃을 영상과 사진으로 가
|
||||
[11:59:31] **User**: 링크 만들어줘
|
||||
[11:59:44] **SmallClaw**: 그랜드 스타렉스 캠핑카 구조변경 평면도와 설계 자료를 확인할 수 있는 주요 링크입니다.
|
||||
|
||||
1. **아로에의 캠핑과 인생 (상세 수치 및 도면)**
|
||||
* [스타렉스 캠핑카 구조변경 설계와 도면 다운로드](https://aloecamping.tistory.com/59)
|
||||
* [캠핑카 도면 자료실 (그림 파일)](https://aloecamping.tistory.com/66)
|
||||
|
||||
2. **mg5694 네이버 블로그 (자작 실측 도면)**
|
||||
* [스타렉스 캠핑카 DIY 설계 도면 (구조변경 완료 사례)](htt
|
||||
[16:59:43] **User**: telegram에서 음성입력하려면 어떻게 하지?
|
||||
[16:59:48] **SmallClaw**: 채팅창 오른쪽의 **마이크 아이콘**을 길게 누르면 음성 메시지가 전송됩니다. 텍스트로 변환해 입력하고 싶다면 채팅창을 클릭한 후 **Windows 키 + H**를 눌러 윈도우 음성 받아쓰기 기능을 이용하세요.
|
||||
+48
-2
@@ -21,11 +21,12 @@ RUN npm run build
|
||||
# ── Stage 2: Runtime ────────────────────────────────────────
|
||||
FROM node:20-slim AS runtime
|
||||
|
||||
# System deps: Playwright/Chromium + Tesseract OCR
|
||||
# System deps: Playwright/Chromium + Tesseract OCR + ffmpeg (for voice)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
ffmpeg \
|
||||
fonts-liberation \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
@@ -65,6 +66,46 @@ RUN npm ci --omit=dev
|
||||
# Install Playwright browser binaries
|
||||
RUN npx playwright install chromium --with-deps 2>/dev/null || true
|
||||
|
||||
# Install Piper TTS binary + Korean voice model (from neurlang — rhasspy repo returns 404 for Korean)
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then PIPER_ARCH="x86_64"; \
|
||||
elif [ "$ARCH" = "arm64" ]; then PIPER_ARCH="aarch64"; \
|
||||
elif [ "$ARCH" = "armhf" ]; then PIPER_ARCH="armv7l"; \
|
||||
else PIPER_ARCH="$ARCH"; fi && \
|
||||
cd /tmp && \
|
||||
curl -L "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_${PIPER_ARCH}.tar.gz" -o piper.tar.gz && \
|
||||
tar xzf piper.tar.gz && \
|
||||
cp piper/piper /usr/local/bin/piper && \
|
||||
chmod +x /usr/local/bin/piper && \
|
||||
cp piper/libespeak-ng.so* /usr/local/lib/ && \
|
||||
cp piper/libonnxruntime.so* /usr/local/lib/ && \
|
||||
cp piper/libpiper_phonemize.so* /usr/local/lib/ && \
|
||||
cp piper/espeak-ng /usr/local/lib/ && \
|
||||
cp -r piper/espeak-ng-data /usr/local/lib/ && \
|
||||
ldconfig && \
|
||||
rm -rf piper piper.tar.gz && \
|
||||
mkdir -p /usr/local/share/piper-voices && \
|
||||
curl -L "https://huggingface.co/neurlang/piper-onnx-kss-korean/resolve/main/piper-kss-korean.onnx" \
|
||||
-o /usr/local/share/piper-voices/ko_KR-kss-medium.onnx && \
|
||||
curl -L "https://huggingface.co/neurlang/piper-onnx-kss-korean/raw/main/piper-kss-korean.onnx.json" \
|
||||
-o /usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json
|
||||
|
||||
# Install whisper.cpp (build from source — no pre-built Linux binary)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends cmake git build-essential && \
|
||||
cd /tmp && \
|
||||
git clone https://github.com/ggml-org/whisper.cpp.git && \
|
||||
cd whisper.cpp && \
|
||||
cmake -B build && \
|
||||
cmake --build build --config Release -j$(nproc) && \
|
||||
cp build/bin/whisper-cli /usr/local/bin/whisper-cli && \
|
||||
rm -rf /tmp/whisper.cpp && \
|
||||
apt-get remove -y git build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /usr/local/share/whisper-models && \
|
||||
curl -L "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" \
|
||||
-o /usr/local/share/whisper-models/ggml-base.bin
|
||||
|
||||
# Compiled app from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
@@ -105,7 +146,12 @@ ENV NODE_ENV=production \
|
||||
OPENAI_MODEL=gpt-4o \
|
||||
\
|
||||
# OpenAI Codex OAuth (tokens live in mounted ~/.localclaw volume)
|
||||
CODEX_MODEL=gpt-5.3-codex
|
||||
CODEX_MODEL=gpt-5.3-codex \
|
||||
\
|
||||
# Voice (Piper TTS + Whisper STT model paths)
|
||||
PIPER_MODEL_PATH=/usr/local/share/piper-voices/ko_KR-kss-medium.onnx \
|
||||
PIPER_CONFIG_PATH=/usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json \
|
||||
WHISPER_MODEL_PATH=/usr/local/share/whisper-models/ggml-base.bin
|
||||
|
||||
EXPOSE 18789
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# ============================================================
|
||||
# SmallClaw Voice – Dockerfile.voice
|
||||
# ============================================================
|
||||
# Voice-enabled build with ffmpeg, whisper.cpp (STT), and Piper (TTS).
|
||||
# Use this when deploying with voice support on a capable server.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f Dockerfile.voice -t smallclaw-voice .
|
||||
#
|
||||
# Run:
|
||||
# docker run -d --name smallclaw-voice \
|
||||
# -p 18789:18789 \
|
||||
# -v smallclaw_data:/data \
|
||||
# -v smallclaw_workspace:/data/workspace \
|
||||
# -v ~/.localclaw:/root/.localclaw \
|
||||
# -e OLLAMA_HOST=http://host.docker.internal:11434 \
|
||||
# smallclaw-voice
|
||||
#
|
||||
# Or with docker compose (see docker-compose.voice.yml):
|
||||
# docker compose -f docker-compose.voice.yml up -d
|
||||
# ============================================================
|
||||
|
||||
# ── Stage 1: Builder ────────────────────────────────────────
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Runtime ────────────────────────────────────────
|
||||
FROM node:20-slim AS runtime
|
||||
|
||||
# System deps: Playwright/Chromium + Tesseract OCR + ffmpeg (for voice audio conversion)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
ffmpeg \
|
||||
fonts-liberation \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libexpat1 \
|
||||
libgbm1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
xdg-utils \
|
||||
tesseract-ocr \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Production deps only
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Install Playwright browser binaries
|
||||
RUN npx playwright install chromium --with-deps 2>/dev/null || true
|
||||
|
||||
# ── Voice: Piper TTS ────────────────────────────────────────
|
||||
# Install Piper binary + shared libs + Korean voice model
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then PIPER_ARCH="x86_64"; \
|
||||
elif [ "$ARCH" = "arm64" ]; then PIPER_ARCH="aarch64"; \
|
||||
elif [ "$ARCH" = "armhf" ]; then PIPER_ARCH="armv7l"; \
|
||||
else PIPER_ARCH="$ARCH"; fi && \
|
||||
cd /tmp && \
|
||||
curl -L "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_${PIPER_ARCH}.tar.gz" -o piper.tar.gz && \
|
||||
tar xzf piper.tar.gz && \
|
||||
cp piper/piper /usr/local/bin/piper && \
|
||||
chmod +x /usr/local/bin/piper && \
|
||||
cp piper/libespeak-ng.so* /usr/local/lib/ && \
|
||||
cp piper/libonnxruntime.so* /usr/local/lib/ && \
|
||||
cp piper/libpiper_phonemize.so* /usr/local/lib/ && \
|
||||
cp piper/espeak-ng /usr/local/lib/ && \
|
||||
cp -r piper/espeak-ng-data /usr/local/lib/ && \
|
||||
ldconfig && \
|
||||
rm -rf piper piper.tar.gz && \
|
||||
mkdir -p /usr/local/share/piper-voices && \
|
||||
curl -L "https://huggingface.co/neurlang/piper-onnx-kss-korean/resolve/main/piper-kss-korean.onnx" \
|
||||
-o /usr/local/share/piper-voices/ko_KR-kss-medium.onnx && \
|
||||
curl -L "https://huggingface.co/neurlang/piper-onnx-kss-korean/raw/main/piper-kss-korean.onnx.json" \
|
||||
-o /usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json
|
||||
|
||||
# ── Voice: whisper.cpp (STT) ────────────────────────────────
|
||||
# Build from source (no pre-built Linux binary available)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends cmake git build-essential && \
|
||||
cd /tmp && \
|
||||
git clone https://github.com/ggml-org/whisper.cpp.git && \
|
||||
cd whisper.cpp && \
|
||||
cmake -B build && \
|
||||
cmake --build build --config Release -j$(nproc) && \
|
||||
cp build/bin/whisper-cli /usr/local/bin/whisper-cli && \
|
||||
rm -rf /tmp/whisper.cpp && \
|
||||
apt-get remove -y git build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /usr/local/share/whisper-models && \
|
||||
curl -L "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" \
|
||||
-o /usr/local/share/whisper-models/ggml-base.bin
|
||||
|
||||
# ── Voice: espeak-ng-data symlink ────────────────────────────
|
||||
# Piper expects espeak-ng-data at /usr/share/espeak-ng-data
|
||||
RUN ln -sf /usr/local/lib/espeak-ng-data /usr/share/espeak-ng-data
|
||||
|
||||
# ── Voice: Telegram DNS fix ───────────────────────────────────
|
||||
# Some networks block 149.154.166.110; use working IP instead
|
||||
RUN echo "149.154.167.220 api.telegram.org" >> /etc/hosts
|
||||
|
||||
# ── App ──────────────────────────────────────────────────────
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Static web UI
|
||||
COPY web-ui/ ./web-ui/
|
||||
|
||||
# Data directories (overridden by volumes in compose)
|
||||
RUN mkdir -p /data/workspace /data/logs /root/.localclaw
|
||||
|
||||
# ── Environment defaults ──────────────────────────────────────
|
||||
ENV NODE_ENV=production \
|
||||
DOCKER_CONTAINER=true \
|
||||
SMALLCLAW_DATA_DIR=/data \
|
||||
SMALLCLAW_WORKSPACE_DIR=/data/workspace \
|
||||
GATEWAY_PORT=18789 \
|
||||
GATEWAY_HOST=0.0.0.0 \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright \
|
||||
\
|
||||
SMALLCLAW_PROVIDER=ollama \
|
||||
\
|
||||
OLLAMA_HOST=http://ollama:11434 \
|
||||
\
|
||||
LM_STUDIO_ENDPOINT=http://host.docker.internal:1234 \
|
||||
LM_STUDIO_API_KEY="" \
|
||||
LM_STUDIO_MODEL="" \
|
||||
\
|
||||
LLAMA_CPP_ENDPOINT=http://host.docker.internal:8080 \
|
||||
LLAMA_CPP_MODEL="" \
|
||||
\
|
||||
OPENAI_API_KEY="" \
|
||||
OPENAI_MODEL=gpt-4o \
|
||||
\
|
||||
CODEX_MODEL=gpt-5.3-codex \
|
||||
\
|
||||
PIPER_MODEL_PATH=/usr/local/share/piper-voices/ko_KR-kss-medium.onnx \
|
||||
PIPER_CONFIG_PATH=/usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json \
|
||||
WHISPER_MODEL_PATH=/usr/local/share/whisper-models/ggml-base.bin
|
||||
|
||||
EXPOSE 18789
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:18789/health || exit 1
|
||||
|
||||
CMD ["node", "dist/cli/index.js", "gateway"]
|
||||
@@ -0,0 +1,129 @@
|
||||
version: "3.9"
|
||||
|
||||
# ============================================================
|
||||
# SmallClaw Voice – docker-compose.voice.yml
|
||||
# ============================================================
|
||||
# Voice-enabled deployment with ffmpeg, whisper.cpp (STT), Piper (TTS).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.voice.yml up -d
|
||||
# docker compose -f docker-compose.voice.yml logs -f
|
||||
# docker compose -f docker-compose.voice.yml down
|
||||
#
|
||||
# Config: mount your config.json with voice.enabled=true
|
||||
# or set voice config via environment variables below.
|
||||
# ============================================================
|
||||
|
||||
services:
|
||||
|
||||
# ── Ollama ────────────────────────────────────────────────
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: smallclaw-ollama
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
environment:
|
||||
- OLLAMA_HOST=0.0.0.0
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
interval: 20s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ── Model pull (one-shot init, Ollama only) ──────────────
|
||||
model-init:
|
||||
image: ollama/ollama:latest
|
||||
container_name: smallclaw-model-init
|
||||
profiles:
|
||||
- ollama
|
||||
depends_on:
|
||||
ollama:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
environment:
|
||||
- OLLAMA_HOST=http://ollama:11434
|
||||
- DEFAULT_MODEL=${SMALLCLAW_DEFAULT_MODEL:-qwen3:4b}
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
echo '>>> Pulling model: '$$DEFAULT_MODEL;
|
||||
ollama pull $$DEFAULT_MODEL;
|
||||
echo '>>> Done.';
|
||||
"
|
||||
restart: "no"
|
||||
|
||||
# ── SmallClaw Gateway (Voice-enabled) ────────────────────
|
||||
smallclaw:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.voice
|
||||
container_name: smallclaw-voice
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-18789}:18789"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# Telegram API DNS fix — use working IP
|
||||
- "api.telegram.org:149.154.167.220"
|
||||
|
||||
volumes:
|
||||
- smallclaw_data:/data
|
||||
- smallclaw_workspace:/data/workspace
|
||||
- ${LOCALCLAW_CONFIG_DIR:-~/.localclaw}:/root/.localclaw
|
||||
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DOCKER_CONTAINER=true
|
||||
- GATEWAY_PORT=18789
|
||||
- GATEWAY_HOST=0.0.0.0
|
||||
- SMALLCLAW_DATA_DIR=/data
|
||||
- SMALLCLAW_WORKSPACE_DIR=/data/workspace
|
||||
|
||||
# Provider
|
||||
- SMALLCLAW_PROVIDER=${SMALLCLAW_PROVIDER:-ollama}
|
||||
|
||||
# Ollama
|
||||
- OLLAMA_HOST=${OLLAMA_HOST:-http://ollama:11434}
|
||||
|
||||
# LM Studio
|
||||
- LM_STUDIO_ENDPOINT=${LM_STUDIO_ENDPOINT:-http://host.docker.internal:1234}
|
||||
- LM_STUDIO_API_KEY=${LM_STUDIO_API_KEY:-}
|
||||
- LM_STUDIO_MODEL=${LM_STUDIO_MODEL:-}
|
||||
|
||||
# llama.cpp
|
||||
- LLAMA_CPP_ENDPOINT=${LLAMA_CPP_ENDPOINT:-http://host.docker.internal:8080}
|
||||
- LLAMA_CPP_MODEL=${LLAMA_CPP_MODEL:-}
|
||||
|
||||
# OpenAI
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
|
||||
|
||||
# OpenAI Codex
|
||||
- CODEX_MODEL=${CODEX_MODEL:-gpt-5.3-codex}
|
||||
|
||||
# Voice paths (built into image)
|
||||
- PIPER_MODEL_PATH=/usr/local/share/piper-voices/ko_KR-kss-medium.onnx
|
||||
- PIPER_CONFIG_PATH=/usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json
|
||||
- WHISPER_MODEL_PATH=/usr/local/share/whisper-models/ggml-base.bin
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:18789/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
ollama_data:
|
||||
driver: local
|
||||
smallclaw_data:
|
||||
driver: local
|
||||
smallclaw_workspace:
|
||||
driver: local
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# Fix Telegram API connectivity issue
|
||||
# Node.js resolves api.telegram.org to 149.154.166.110 which is blocked
|
||||
# 149.154.167.220 works
|
||||
echo "Adding api.telegram.org to /etc/hosts..."
|
||||
echo "149.154.167.220 api.telegram.org" | sudo tee -a /etc/hosts
|
||||
echo "Done. Test with: node -e \"fetch('https://api.telegram.org/').then(r=>console.log('OK:',r.status)).catch(e=>console.error(e.message))\""
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Fix remaining voice dependency issues
|
||||
# Run: bash scripts/fix-voice-deps.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Fixing voice dependencies ==="
|
||||
|
||||
# 1. Fix whisper-cli — remove broken placeholder and install real binary
|
||||
echo "[1/3] Fixing whisper-cli..."
|
||||
echo " Building whisper.cpp from source (this takes a few minutes)..."
|
||||
sudo apt install -y cmake git build-essential
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
git clone https://github.com/ggml-org/whisper.cpp.git "$TMPDIR/whisper.cpp"
|
||||
cd "$TMPDIR/whisper.cpp"
|
||||
cmake -B build
|
||||
cmake --build build --config Release -j$(nproc)
|
||||
sudo cp build/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||
cd -
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo " whisper-cli installed: $(whisper-cli --help 2>&1 | head -1)"
|
||||
|
||||
# 2. Fix piper shared libraries
|
||||
echo ""
|
||||
echo "[2/3] Installing piper shared libraries..."
|
||||
sudo cp /tmp/piper/libespeak-ng.so* /usr/local/lib/
|
||||
sudo cp /tmp/piper/libonnxruntime.so* /usr/local/lib/
|
||||
sudo cp /tmp/piper/libpiper_phonemize.so* /usr/local/lib/
|
||||
sudo cp /tmp/piper/espeak-ng /usr/local/lib/
|
||||
sudo cp -r /tmp/piper/espeak-ng-data /usr/local/lib/
|
||||
sudo ldconfig
|
||||
|
||||
echo " Libraries installed and ldconfig run"
|
||||
|
||||
# 3. Verify piper works
|
||||
echo ""
|
||||
echo "[3/3] Verifying piper..."
|
||||
echo "안녕하세요" | piper --model /usr/local/share/piper-voices/ko_KR-kss-medium.onnx --output_file /tmp/test_piper.wav && \
|
||||
echo " Piper TTS OK ($(stat -c%s /tmp/test_piper.wav) bytes)" && \
|
||||
rm -f /tmp/test_piper.wav || \
|
||||
echo " Piper TTS test failed"
|
||||
|
||||
echo ""
|
||||
echo "=== Done! Verify with: ==="
|
||||
echo " whisper-cli --help"
|
||||
echo " echo 'hello' | piper --model /usr/local/share/piper-voices/ko_KR-kss-medium.onnx --output_file /tmp/test.wav"
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
# SmallClaw Voice Dependencies Installer (fixed)
|
||||
# Run: bash scripts/install-voice-deps.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== SmallClaw Voice Dependencies Installer ==="
|
||||
echo ""
|
||||
|
||||
# 1. ffmpeg
|
||||
echo "[1/5] Installing ffmpeg..."
|
||||
if command -v ffmpeg &>/dev/null; then
|
||||
echo " ffmpeg already installed: $(ffmpeg -version 2>&1 | head -1)"
|
||||
else
|
||||
sudo apt update && sudo apt install -y ffmpeg
|
||||
echo " ffmpeg installed: $(ffmpeg -version 2>&1 | head -1)"
|
||||
fi
|
||||
|
||||
# 2. whisper.cpp — build from source
|
||||
echo ""
|
||||
echo "[2/5] Installing whisper-cli..."
|
||||
WHISPER_BIN="/usr/local/bin/whisper-cli"
|
||||
if [ -x "$WHISPER_BIN" ]; then
|
||||
echo " whisper-cli already installed"
|
||||
else
|
||||
echo " Building whisper.cpp from source (requires cmake, git, build-essential)..."
|
||||
sudo apt install -y cmake git build-essential
|
||||
TMPDIR=$(mktemp -d)
|
||||
git clone https://github.com/ggml-org/whisper.cpp.git "$TMPDIR/whisper.cpp"
|
||||
cd "$TMPDIR/whisper.cpp"
|
||||
cmake -B build
|
||||
cmake --build build --config Release -j$(nproc)
|
||||
sudo cp build/bin/whisper-cli "$WHISPER_BIN"
|
||||
cd -
|
||||
rm -rf "$TMPDIR"
|
||||
echo " whisper-cli installed to $WHISPER_BIN"
|
||||
fi
|
||||
|
||||
# 3. Whisper model (ggml-medium.bin for better Korean)
|
||||
echo ""
|
||||
echo "[3/5] Downloading Whisper model (ggml-medium.bin ~1.5GB)..."
|
||||
WHISPER_MODEL_DIR="/usr/local/share/whisper-models"
|
||||
sudo mkdir -p "$WHISPER_MODEL_DIR"
|
||||
WHISPER_MODEL="$WHISPER_MODEL_DIR/ggml-medium.bin"
|
||||
if [ -f "$WHISPER_MODEL" ] && [ "$(stat -c%s "$WHISPER_MODEL" 2>/dev/null || stat -f%z "$WHISPER_MODEL" 2>/dev/null)" -gt 1000000 ]; then
|
||||
echo " Model already exists: $WHISPER_MODEL"
|
||||
else
|
||||
echo " Downloading... (this may take a few minutes)"
|
||||
curl -L "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin" \
|
||||
-o /tmp/ggml-medium.bin && \
|
||||
sudo mv /tmp/ggml-medium.bin "$WHISPER_MODEL"
|
||||
echo " Model saved to $WHISPER_MODEL"
|
||||
fi
|
||||
|
||||
# 4. Piper TTS — use architecture-specific filename
|
||||
echo ""
|
||||
echo "[4/5] Installing Piper TTS..."
|
||||
PIPER_BIN="/usr/local/bin/piper"
|
||||
if [ -x "$PIPER_BIN" ]; then
|
||||
echo " piper already installed"
|
||||
else
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
if [ "$ARCH" = "amd64" ]; then
|
||||
PIPER_ARCH="x86_64"
|
||||
elif [ "$ARCH" = "arm64" ]; then
|
||||
PIPER_ARCH="aarch64"
|
||||
elif [ "$ARCH" = "armhf" ]; then
|
||||
PIPER_ARCH="armv7l"
|
||||
else
|
||||
PIPER_ARCH="$ARCH"
|
||||
fi
|
||||
PIPER_URL="https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_${PIPER_ARCH}.tar.gz"
|
||||
echo " Downloading piper for $PIPER_ARCH from $PIPER_URL..."
|
||||
cd /tmp
|
||||
curl -L "$PIPER_URL" -o piper.tar.gz
|
||||
tar xzf piper.tar.gz
|
||||
sudo cp piper/piper "$PIPER_BIN"
|
||||
sudo chmod +x "$PIPER_BIN"
|
||||
# Install shared libraries required by piper
|
||||
sudo cp piper/libespeak-ng.so* /usr/local/lib/
|
||||
sudo cp piper/libonnxruntime.so* /usr/local/lib/
|
||||
sudo cp piper/libpiper_phonemize.so* /usr/local/lib/
|
||||
sudo cp piper/espeak-ng /usr/local/lib/
|
||||
sudo cp -r piper/espeak-ng-data /usr/local/lib/
|
||||
sudo ldconfig
|
||||
rm -rf piper piper.tar.gz
|
||||
echo " piper installed to $PIPER_BIN"
|
||||
fi
|
||||
|
||||
# 5. Piper Korean voice model (from neurlang — rhasspy repo returns 404 for Korean)
|
||||
echo ""
|
||||
echo "[5/5] Downloading Piper Korean voice model..."
|
||||
PIPER_VOICES_DIR="/usr/local/share/piper-voices"
|
||||
sudo mkdir -p "$PIPER_VOICES_DIR"
|
||||
ONNX_FILE="$PIPER_VOICES_DIR/ko_KR-kss-medium.onnx"
|
||||
ONNX_JSON="$PIPER_VOICES_DIR/ko_KR-kss-medium.onnx.json"
|
||||
if [ -f "$ONNX_FILE" ] && [ "$(stat -c%s "$ONNX_FILE" 2>/dev/null || stat -f%z "$ONNX_FILE" 2>/dev/null)" -gt 1000000 ]; then
|
||||
echo " Korean voice model already exists"
|
||||
else
|
||||
echo " Downloading ko_KR-kss-medium model (~63MB)..."
|
||||
curl -L "https://huggingface.co/neurlang/piper-onnx-kss-korean/resolve/main/piper-kss-korean.onnx" \
|
||||
-o /tmp/ko_KR-kss-medium.onnx && \
|
||||
curl -L "https://huggingface.co/neurlang/piper-onnx-kss-korean/raw/main/piper-kss-korean.onnx.json" \
|
||||
-o /tmp/ko_KR-kss-medium.onnx.json && \
|
||||
sudo mv /tmp/ko_KR-kss-medium.onnx "$ONNX_FILE" && \
|
||||
sudo mv /tmp/ko_KR-kss-medium.onnx.json "$ONNX_JSON"
|
||||
echo " Korean voice model saved"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Installation complete! ==="
|
||||
echo ""
|
||||
echo "Verify installations:"
|
||||
echo " ffmpeg -version"
|
||||
echo " whisper-cli --help"
|
||||
echo " piper --version"
|
||||
echo ""
|
||||
echo "Add to your config.json to enable voice:"
|
||||
echo ""
|
||||
echo ' "voice": {'
|
||||
echo ' "enabled": true,'
|
||||
echo ' "stt": {'
|
||||
echo ' "model": "ggml-medium.bin",'
|
||||
echo ' "modelPath": "/usr/local/share/whisper-models/ggml-medium.bin",'
|
||||
echo ' "language": "ko"'
|
||||
echo ' },'
|
||||
echo ' "tts": {'
|
||||
echo ' "model": "ko_KR-kss-medium",'
|
||||
echo ' "modelPath": "/usr/local/share/piper-voices/ko_KR-kss-medium.onnx",'
|
||||
echo ' "configPath": "/usr/local/share/piper-voices/ko_KR-kss-medium.onnx.json"'
|
||||
echo ' }'
|
||||
echo ' }'
|
||||
@@ -162,6 +162,7 @@ export const DEFAULT_CONFIG: SmallClawConfig = {
|
||||
skin: '',
|
||||
unsplash_key: process.env.UNSPLASH_ACCESS_KEY ? `env:UNSPLASH_ACCESS_KEY` : '',
|
||||
pexels_key: process.env.PEXELS_API_KEY ? `env:PEXELS_API_KEY` : '',
|
||||
pixabay_key: process.env.PIXABAY_API_KEY ? `env:PIXABAY_API_KEY` : '',
|
||||
},
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
@@ -261,6 +262,18 @@ export const DEFAULT_CONFIG: SmallClawConfig = {
|
||||
token: '',
|
||||
path: '/hooks',
|
||||
},
|
||||
voice: {
|
||||
enabled: false,
|
||||
stt: {
|
||||
provider: 'whisper_cpp' as const,
|
||||
model: 'ggml-base.bin',
|
||||
language: 'ko',
|
||||
},
|
||||
tts: {
|
||||
provider: 'piper' as const,
|
||||
model: 'ko_KR-kss-medium',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeLegacyPathsInConfig(loaded: any): any {
|
||||
@@ -297,6 +310,7 @@ const SECRET_FIELD_MAP: Array<[string[], string]> = [
|
||||
[['hooks', 'token'], 'hooks.token'],
|
||||
[['ppt', 'unsplash_key'], 'ppt.unsplash_key'],
|
||||
[['ppt', 'pexels_key'], 'ppt.pexels_key'],
|
||||
[['ppt', 'pixabay_key'], 'ppt.pixabay_key'],
|
||||
];
|
||||
|
||||
function deepGet(obj: any, keys: string[]): string | undefined {
|
||||
@@ -387,12 +401,26 @@ export class ConfigManager {
|
||||
},
|
||||
};
|
||||
|
||||
const mergedVoice = {
|
||||
...(DEFAULT_CONFIG.voice || {}),
|
||||
...(loaded.voice || {}),
|
||||
stt: {
|
||||
...((DEFAULT_CONFIG.voice as any)?.stt || {}),
|
||||
...((loaded.voice as any)?.stt || {}),
|
||||
},
|
||||
tts: {
|
||||
...((DEFAULT_CONFIG.voice as any)?.tts || {}),
|
||||
...((loaded.voice as any)?.tts || {}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...loaded,
|
||||
llm: mergedLlm,
|
||||
channels: mergedChannels as any,
|
||||
telegram: (mergedChannels as any).telegram,
|
||||
voice: mergedVoice as any,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+109
-54
@@ -568,6 +568,7 @@ type TelegramChannelConfig = {
|
||||
* so messages from a known Telegram user run under that user's
|
||||
* workspace/persona instead of the global one. */
|
||||
userMap: Record<string, string>;
|
||||
voiceEnabled?: boolean;
|
||||
};
|
||||
|
||||
type DiscordChannelConfig = {
|
||||
@@ -620,6 +621,7 @@ function normalizeTelegramConfig(raw: any): TelegramChannelConfig {
|
||||
allowedUserIds: Array.isArray(raw?.allowedUserIds) ? raw.allowedUserIds.map(Number).filter((n: number) => Number.isFinite(n) && n > 0) : [],
|
||||
streamMode: raw?.streamMode === 'partial' ? 'partial' : 'full',
|
||||
userMap,
|
||||
voiceEnabled: raw?.voiceEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2313,18 +2315,33 @@ async function webFetch(url: string): Promise<string> {
|
||||
}
|
||||
|
||||
|
||||
// UTM tag identifying homeclaw on Unsplash/Pexels referral links (per their guidelines).
|
||||
const IMG_ATTRIBUTION_UTM = 'utm_source=homeclaw&utm_medium=referral';
|
||||
|
||||
/**
|
||||
* Per Unsplash API guidelines: GET /photos/:id/download must be hit when the
|
||||
* photo is actually used (displayed in our chat output). Fire-and-forget.
|
||||
*/
|
||||
function triggerUnsplashDownload(photoId: string, accessKey: string): void {
|
||||
fetch(`https://api.unsplash.com/photos/${encodeURIComponent(photoId)}/download`, {
|
||||
headers: { Authorization: `Client-ID ${accessKey}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function imageSearch(query: string, count: number = 3): Promise<string> {
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
const pexelsKey = process.env.PEXELS_API_KEY || getConfig().resolveSecret(cfg.ppt?.pexels_key) || '';
|
||||
const unsplashKey = process.env.UNSPLASH_ACCESS_KEY || getConfig().resolveSecret(cfg.ppt?.unsplash_key) || '';
|
||||
if (!pexelsKey && !unsplashKey) {
|
||||
return 'No image API keys configured (PEXELS_API_KEY / UNSPLASH_ACCESS_KEY).';
|
||||
const pixabayKey = process.env.PIXABAY_API_KEY || getConfig().resolveSecret(cfg.ppt?.pixabay_key) || '';
|
||||
if (!pexelsKey && !unsplashKey && !pixabayKey) {
|
||||
return 'No image API keys configured (PEXELS_API_KEY / UNSPLASH_ACCESS_KEY / PIXABAY_API_KEY).';
|
||||
}
|
||||
const need = Math.min(Math.max(count, 1), 6);
|
||||
type ImgResult = { url: string; description: string; credit: string; source: string };
|
||||
type ImgResult = { url: string; description: string; attribution: string };
|
||||
|
||||
// Fetch Pexels and Unsplash in parallel
|
||||
const [pexelsRes, unsplashRes] = await Promise.allSettled([
|
||||
// Fetch Pexels, Unsplash, and Pixabay in parallel
|
||||
const [pexelsRes, unsplashRes, pixabayRes] = await Promise.allSettled([
|
||||
pexelsKey
|
||||
? fetch(
|
||||
`https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${need}&orientation=landscape`,
|
||||
@@ -2337,6 +2354,12 @@ async function imageSearch(query: string, count: number = 3): Promise<string> {
|
||||
{ headers: { Authorization: `Client-ID ${unsplashKey}` }, signal: AbortSignal.timeout(8000) }
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
pixabayKey
|
||||
? fetch(
|
||||
`https://pixabay.com/api/?key=${encodeURIComponent(pixabayKey)}&q=${encodeURIComponent(query)}&per_page=${Math.max(need, 3)}&image_type=photo&orientation=horizontal&safesearch=true`,
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const results: ImgResult[] = [];
|
||||
@@ -2346,25 +2369,55 @@ async function imageSearch(query: string, count: number = 3): Promise<string> {
|
||||
const d = await pexelsRes.value.json() as any;
|
||||
for (const p of (d.photos || []).slice(0, need)) {
|
||||
const url = p.src?.large2x || p.src?.large || p.src?.original;
|
||||
if (url) results.push({ url, description: p.alt || query, credit: p.photographer || '', source: 'Pexels' });
|
||||
if (!url) continue;
|
||||
const name = p.photographer || 'Pexels contributor';
|
||||
const profile = p.photographer_url;
|
||||
const author = profile ? `[${name}](${profile})` : name;
|
||||
const attribution = `📷 Photo by ${author} on [Pexels](https://www.pexels.com/?${IMG_ATTRIBUTION_UTM})`;
|
||||
results.push({ url, description: p.alt || query, attribution });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const remaining = need - results.length;
|
||||
let remaining = need - results.length;
|
||||
if (remaining > 0 && pixabayRes.status === 'fulfilled' && pixabayRes.value?.ok) {
|
||||
try {
|
||||
const d = await pixabayRes.value.json() as any;
|
||||
for (const p of (d.hits || []).slice(0, remaining)) {
|
||||
const url = p.largeImageURL || p.webformatURL;
|
||||
if (!url) continue;
|
||||
const name = p.user || 'Pixabay contributor';
|
||||
const page = p.pageURL;
|
||||
const author = page ? `[${name}](${page})` : name;
|
||||
const attribution = `📷 Image by ${author} on [Pixabay](https://pixabay.com/)`;
|
||||
results.push({ url, description: p.tags || query, attribution });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
remaining = need - results.length;
|
||||
if (remaining > 0 && unsplashRes.status === 'fulfilled' && unsplashRes.value?.ok) {
|
||||
try {
|
||||
const d = await unsplashRes.value.json() as any;
|
||||
for (const p of (d.results || []).slice(0, remaining)) {
|
||||
const url = p.urls?.regular || p.urls?.full;
|
||||
if (url) results.push({ url, description: p.alt_description || p.description || query, credit: p.user?.name || '', source: 'Unsplash' });
|
||||
if (!url) continue;
|
||||
const name = p.user?.name || p.user?.username || 'Unsplash contributor';
|
||||
const username = p.user?.username;
|
||||
const author = username
|
||||
? `[${name}](https://unsplash.com/@${username}?${IMG_ATTRIBUTION_UTM})`
|
||||
: name;
|
||||
const attribution = `📷 Photo by ${author} on [Unsplash](https://unsplash.com/?${IMG_ATTRIBUTION_UTM})`;
|
||||
results.push({ url, description: p.alt_description || p.description || query, attribution });
|
||||
// Fire Unsplash download trigger — we are about to display this photo to the user.
|
||||
if (p.id && unsplashKey) triggerUnsplashDownload(p.id, unsplashKey);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!results.length) return `No images found for "${query}".`;
|
||||
return results
|
||||
.map(r => `\n*📷 ${r.credit ? r.credit + ' — ' : ''}${r.source}*`)
|
||||
.map(r => `\n*${r.attribution}*`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
@@ -2640,55 +2693,57 @@ async function executeTool(name: string, args: any, workspacePath: string, sessi
|
||||
case 'search_images': {
|
||||
const query = String(args.query || '');
|
||||
const count = Math.min(Math.max(Number(args.count) || 3, 1), 6);
|
||||
const isKorean = /[가-]|[ᄀ-ᇿ]|[-]|한국|korea|seoul|busan|korean/i.test(query);
|
||||
|
||||
if (isKorean) {
|
||||
const tmpSessionId = `img-${Date.now()}`;
|
||||
try {
|
||||
const searchUrl = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
|
||||
let result = await browserOpen(tmpSessionId, searchUrl);
|
||||
if (result.startsWith('ERROR:')) {
|
||||
return { name, args, result: `Browser image search failed: ${result}`, error: true };
|
||||
}
|
||||
await browserWait(tmpSessionId, 2500);
|
||||
result = await browserGetImages(tmpSessionId, { max_images: count + 4, download: false });
|
||||
|
||||
if (result.startsWith('ERROR:') || result.includes('Found 0 images')) {
|
||||
return { name, args, result: `No images found for "${query}".`, error: true };
|
||||
}
|
||||
|
||||
// Parse image URLs from browserGetImages text output
|
||||
const urls: string[] = [];
|
||||
const regex = /^\s*-\s*\[[^\]]+\]\s+(https?:\/\/\S+)/gm;
|
||||
let m;
|
||||
while ((m = regex.exec(result)) !== null) {
|
||||
if (!urls.includes(m[1])) urls.push(m[1]);
|
||||
}
|
||||
|
||||
if (!urls.length) {
|
||||
return { name, args, result: `No images found for "${query}".`, error: true };
|
||||
}
|
||||
|
||||
const markdown = urls.slice(0, count)
|
||||
.map((url, i) => ``)
|
||||
.join('\n\n');
|
||||
return { name, args, result: markdown, error: false, isImage: true };
|
||||
} catch (err: any) {
|
||||
return { name, args, result: `Browser image search error: ${err.message}`, error: true };
|
||||
} finally {
|
||||
try { await browserClose(tmpSessionId); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Try the API path first (Pexels → Pixabay → Unsplash, fast).
|
||||
// Browser scrape only runs as a last-resort fallback below.
|
||||
const raw = await imageSearch(query, count);
|
||||
if (raw.startsWith('No image API') || raw.startsWith('No images found')) {
|
||||
return { name, args, result: raw, error: true };
|
||||
const apiHasResults = !raw.startsWith('No image API') && !raw.startsWith('No images found');
|
||||
if (apiHasResults) {
|
||||
const filtered = await filterImageMarkdown(raw);
|
||||
if (filtered.liveCount > 0) {
|
||||
return { name, args, result: filtered.text, error: false, isImage: true };
|
||||
}
|
||||
console.log(`[search_images] APIs returned ${filtered.deadCount} dead URLs for "${query}", falling back to browser`);
|
||||
} else {
|
||||
console.log(`[search_images] APIs found nothing for "${query}", falling back to browser`);
|
||||
}
|
||||
const filtered = await filterImageMarkdown(raw);
|
||||
if (filtered.liveCount === 0) {
|
||||
return { name, args, result: `No live images found for "${args.query}" (all ${filtered.deadCount} candidates were dead URLs).`, error: true };
|
||||
|
||||
// 2) Browser fallback — Google Images via Playwright. Slow (cold start
|
||||
// + page load), so only used when APIs failed to produce live results.
|
||||
const tmpSessionId = `img-${Date.now()}`;
|
||||
try {
|
||||
const searchUrl = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
|
||||
let result = await browserOpen(tmpSessionId, searchUrl);
|
||||
if (result.startsWith('ERROR:')) {
|
||||
return { name, args, result: `No images found for "${query}".`, error: true };
|
||||
}
|
||||
await browserWait(tmpSessionId, 2500);
|
||||
result = await browserGetImages(tmpSessionId, { max_images: count + 4, download: false });
|
||||
|
||||
if (result.startsWith('ERROR:') || result.includes('Found 0 images')) {
|
||||
return { name, args, result: `No images found for "${query}".`, error: true };
|
||||
}
|
||||
|
||||
const urls: string[] = [];
|
||||
const regex = /^\s*-\s*\[[^\]]+\]\s+(https?:\/\/\S+)/gm;
|
||||
let m;
|
||||
while ((m = regex.exec(result)) !== null) {
|
||||
if (m[1].includes('gstatic.com/images') || m[1].includes('/favicon')) continue;
|
||||
if (!urls.includes(m[1])) urls.push(m[1]);
|
||||
}
|
||||
if (!urls.length) {
|
||||
return { name, args, result: `No images found for "${query}".`, error: true };
|
||||
}
|
||||
|
||||
const markdown = urls.slice(0, count)
|
||||
.map((url, i) => ``)
|
||||
.join('\n\n');
|
||||
return { name, args, result: markdown, error: false, isImage: true };
|
||||
} catch (err: any) {
|
||||
return { name, args, result: `Browser image search error: ${err.message}`, error: true };
|
||||
} finally {
|
||||
try { await browserClose(tmpSessionId); } catch {}
|
||||
}
|
||||
return { name, args, result: filtered.text, error: false, isImage: true };
|
||||
}
|
||||
|
||||
case 'web_fetch': {
|
||||
|
||||
@@ -31,6 +31,7 @@ interface TelegramConfig {
|
||||
streamMode: 'full' | 'partial';
|
||||
/** Telegram user/chat id (string) → SmallClaw username. Optional. */
|
||||
userMap?: Record<string, string>;
|
||||
voiceEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface TelegramDeps {
|
||||
@@ -63,6 +64,13 @@ interface TelegramUpdate {
|
||||
from: { id: number; first_name: string; username?: string };
|
||||
chat: { id: number; type: string };
|
||||
text?: string;
|
||||
voice?: {
|
||||
file_id: string;
|
||||
file_unique_id: string;
|
||||
duration: number;
|
||||
mime_type?: string;
|
||||
file_size?: number;
|
||||
};
|
||||
date: number;
|
||||
};
|
||||
callback_query?: {
|
||||
@@ -95,6 +103,7 @@ export class TelegramChannel {
|
||||
private botInfo: { id: number; first_name: string; username: string } | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
private workspaceRoot: string = process.cwd();
|
||||
private voiceSessions: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(config: TelegramConfig, deps: TelegramDeps) {
|
||||
this.config = config;
|
||||
@@ -118,6 +127,17 @@ export class TelegramChannel {
|
||||
return data.result;
|
||||
}
|
||||
|
||||
private async getFile(fileId: string): Promise<{ file_path: string; file_size?: number }> {
|
||||
return this.apiCall('getFile', { file_id: fileId });
|
||||
}
|
||||
|
||||
private async downloadFile(filePath: string): Promise<Buffer> {
|
||||
const url = `https://api.telegram.org/file/bot${this.config.botToken}/${filePath}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`Failed to download file: ${resp.status} ${resp.statusText}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
}
|
||||
|
||||
// ─── Public Methods ──────────────────────────────────────────────────────────
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -238,6 +258,10 @@ export class TelegramChannel {
|
||||
this.handleIncomingMessage(update.message).catch(err =>
|
||||
console.error('[Telegram] Message handling error:', err.message)
|
||||
);
|
||||
} else if (update.message?.voice) {
|
||||
this.handleVoiceMessage(update.message).catch(err =>
|
||||
console.error('[Telegram] Voice message handling error:', err.message)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -537,7 +561,7 @@ export class TelegramChannel {
|
||||
|
||||
// ── Built-in commands ──────────────────────────────────────────────────────
|
||||
if (text === '/start') {
|
||||
await this.sendMessage(chatId, `🦞 <b>SmallClaw connected!</b>\n\nYour Telegram user ID: <code>${userId}</code>\n\nJust send me a message and I'll respond using your local LLM.\n\n<b>Commands:</b>\n/status — check connection\n/clear — reset chat history\n/browse — browse workspace files\n/download <path> — download a file\n\n<b>Self-Repair:</b>\n/repairs — list pending repair proposals\n/repair <id> — show full details of a repair\n/approve <id> — apply a repair, rebuild & restart\n/reject <id> — discard a repair`);
|
||||
await this.sendMessage(chatId, `🦞 <b>SmallClaw connected!</b>\n\nYour Telegram user ID: <code>${userId}</code>\n\nJust send me a message and I'll respond using your local LLM.\n\n<b>Commands:</b>\n/status — check connection\n/clear — reset chat history\n/voice — toggle voice responses\n/browse — browse workspace files\n/download <path> — download a file\n\n<b>Self-Repair:</b>\n/repairs — list pending repair proposals\n/repair <id> — show full details of a repair\n/approve <id> — apply a repair, rebuild & restart\n/reject <id> — discard a repair`);
|
||||
return;
|
||||
}
|
||||
if (text === '/status') {
|
||||
@@ -554,6 +578,26 @@ export class TelegramChannel {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── /voice — toggle voice responses ────────────────────────────────────────
|
||||
if (text === '/voice') {
|
||||
const voiceConfig = (getConfig().getConfig() as any)?.voice;
|
||||
if (!voiceConfig?.enabled && !this.config.voiceEnabled) {
|
||||
await this.sendMessage(chatId, '🦞 Voice features are not enabled in config.');
|
||||
return;
|
||||
}
|
||||
const mappedUsername = this.config.userMap?.[String(userId)] || undefined;
|
||||
const sessionId = mappedUsername
|
||||
? `telegram_${mappedUsername}_${userId}`
|
||||
: `telegram_${userId}`;
|
||||
const current = this.voiceSessions.get(sessionId) || false;
|
||||
const next = !current;
|
||||
this.voiceSessions.set(sessionId, next);
|
||||
await this.sendMessage(chatId, next
|
||||
? '🦞 Voice responses ON. I will reply with voice messages.'
|
||||
: '🦞 Voice responses OFF. I will reply with text.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── /repairs — list pending self-repair proposals ───────────────────────────
|
||||
if (text === '/repairs') {
|
||||
const pending = listPendingRepairs();
|
||||
@@ -670,6 +714,25 @@ export class TelegramChannel {
|
||||
|
||||
await this.sendMessage(chatId, responseText);
|
||||
|
||||
// Check voice toggle — if enabled, also send as voice message
|
||||
const voiceConfig = (getConfig().getConfig() as any)?.voice;
|
||||
const sessionVoiceMode = this.voiceSessions.get(sessionId);
|
||||
if (sessionVoiceMode && voiceConfig?.enabled && responseText.length > 0) {
|
||||
try {
|
||||
const { synthesizeSpeech } = await import('../tools/tts.js');
|
||||
const audioBuffer = await synthesizeSpeech(responseText);
|
||||
const formData = new FormData();
|
||||
formData.append('chat_id', String(chatId));
|
||||
formData.append('voice', new Blob([new Uint8Array(audioBuffer)]), 'voice.ogg');
|
||||
if (responseText.length > 1024) {
|
||||
formData.append('caption', responseText.slice(0, 1024));
|
||||
}
|
||||
await fetch(`${this.apiBase}/sendVoice`, { method: 'POST', body: formData });
|
||||
} catch (ttsErr: any) {
|
||||
console.error(`[Telegram] TTS failed for text reply: ${ttsErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to web UI that a Telegram message was processed
|
||||
this.deps.broadcast({
|
||||
type: 'telegram_message',
|
||||
@@ -706,4 +769,115 @@ export class TelegramChannel {
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Voice Message Support ──────────────────────────────────────────────────
|
||||
|
||||
private async sendVoice(chatId: number, audioBuffer: Buffer, caption?: string): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('chat_id', String(chatId));
|
||||
formData.append('voice', new Blob([new Uint8Array(audioBuffer)]), 'voice.ogg');
|
||||
if (caption) {
|
||||
formData.append('caption', caption.slice(0, 1024));
|
||||
}
|
||||
|
||||
const resp = await fetch(`${this.apiBase}/sendVoice`, { method: 'POST', body: formData });
|
||||
const data: any = await resp.json();
|
||||
if (!data.ok) throw new Error(data.description || 'sendVoice failed');
|
||||
}
|
||||
|
||||
private async sendVoiceResponse(chatId: number, text: string): Promise<void> {
|
||||
try {
|
||||
const { synthesizeSpeech } = await import('../tools/tts.js');
|
||||
const audioBuffer = await synthesizeSpeech(text);
|
||||
const caption = text.length > 1024 ? text.slice(0, 1024) : undefined;
|
||||
await this.sendVoice(chatId, audioBuffer, caption);
|
||||
} catch (err: any) {
|
||||
console.error(`[Telegram] TTS failed, falling back to text: ${err.message}`);
|
||||
await this.sendMessage(chatId, text);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleVoiceMessage(msg: NonNullable<TelegramUpdate['message']>): Promise<void> {
|
||||
const voiceConfig = (getConfig().getConfig() as any)?.voice;
|
||||
if (!voiceConfig?.enabled && !this.config.voiceEnabled) {
|
||||
await this.sendMessage(msg.chat.id, 'Voice messages are not enabled. Send /voice to toggle.');
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = msg.from.id;
|
||||
const chatId = msg.chat.id;
|
||||
const userName = msg.from.first_name || msg.from.username || 'Unknown';
|
||||
|
||||
// Allowlist check
|
||||
if (this.config.allowedUserIds.length > 0 && !this.config.allowedUserIds.includes(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const voice = msg.voice!;
|
||||
const maxDuration = voiceConfig?.maxVoiceDurationSeconds || 120;
|
||||
if (voice.duration > maxDuration) {
|
||||
await this.sendMessage(chatId, `Voice message too long (${voice.duration}s). Maximum: ${maxDuration}s.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.deps.getIsModelBusy()) {
|
||||
await this.sendMessage(chatId, 'Listening... but I\'m currently busy. Try again in a moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Telegram] Voice message from ${userName} (${userId}): ${voice.duration}s`);
|
||||
|
||||
try {
|
||||
await this.apiCall('sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => {});
|
||||
|
||||
const fileInfo = await this.getFile(voice.file_id);
|
||||
const audioBuffer = await this.downloadFile(fileInfo.file_path);
|
||||
|
||||
const { transcribeAudio } = await import('../tools/stt.js');
|
||||
const { text } = await transcribeAudio(audioBuffer, 'ogg');
|
||||
|
||||
if (!text || !text.trim()) {
|
||||
await this.sendMessage(chatId, 'Could not transcribe the voice message. It may be silent or unclear.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendMessage(chatId, `[Voice] ${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`);
|
||||
|
||||
const mappedUsername = this.config.userMap?.[String(userId)] || undefined;
|
||||
const sessionId = mappedUsername
|
||||
? `telegram_${mappedUsername}_${userId}`
|
||||
: `telegram_${userId}`;
|
||||
const events: Array<{ type: string; data: any }> = [];
|
||||
const sendSSE = (type: string, data: any) => { events.push({ type, data }); };
|
||||
|
||||
const telegramContext = 'CONTEXT: You are responding via Telegram. The user sent a voice message that was transcribed as follows. Respond as if they typed this.';
|
||||
const result = await this.deps.handleChat(
|
||||
text, sessionId, sendSSE, undefined, undefined, telegramContext, undefined, undefined, mappedUsername
|
||||
);
|
||||
const responseText = result.text || 'No response generated.';
|
||||
|
||||
this.deps.addMessage(sessionId, { role: 'user', content: `[Voice] ${text}`, timestamp: Date.now() }, { disableMemoryFlushCheck: true }, mappedUsername);
|
||||
this.deps.addMessage(sessionId, { role: 'assistant', content: responseText, timestamp: Date.now() }, { disableMemoryFlushCheck: true }, mappedUsername);
|
||||
|
||||
const sessionVoiceMode = this.voiceSessions.get(sessionId);
|
||||
if (sessionVoiceMode && responseText.length > 0) {
|
||||
await this.sendVoiceResponse(chatId, responseText);
|
||||
} else {
|
||||
await this.sendMessage(chatId, responseText);
|
||||
}
|
||||
|
||||
this.deps.broadcast({
|
||||
type: 'telegram_message',
|
||||
from: userName,
|
||||
userId,
|
||||
text: `[Voice] ${text.slice(0, 100)}`,
|
||||
response: responseText.slice(0, 200),
|
||||
});
|
||||
|
||||
console.log(`[Telegram] Replied to voice from ${userName}: ${responseText.slice(0, 80)}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[Telegram] Voice handling error:`, err.message);
|
||||
await this.sendMessage(chatId, `Voice transcription failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { getConfig } from '../config/config.js';
|
||||
import { ToolResult } from '../types.js';
|
||||
import { transcribeAudio } from './stt.js';
|
||||
|
||||
const AUDIO_EXTS = new Set(['.ogg', '.mp3', '.wav', '.flac', '.m4a', '.wma', '.opus']);
|
||||
|
||||
export const audioTranscribeTool = {
|
||||
name: 'audio_transcribe',
|
||||
description: 'Transcribe an audio file to text using Whisper. Supports OGG, MP3, WAV, FLAC, M4A, WMA, and OPUS formats. Returns the transcribed text.',
|
||||
schema: {
|
||||
path: 'Path to the audio file — absolute or relative to workspace',
|
||||
language: 'Language hint: "ko" (Korean), "en" (English), "auto" (default)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to the audio file' },
|
||||
language: { type: 'string', description: 'Language code: "ko" | "en" | "auto" (default: "auto")' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const filePath = String(args?.path || '').trim();
|
||||
if (!filePath) return { success: false, error: 'path is required' };
|
||||
|
||||
const workspacePath = getConfig().getConfig()?.workspace?.path || process.cwd();
|
||||
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(workspacePath, filePath);
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { success: false, error: `File not found: ${resolved}` };
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (!AUDIO_EXTS.has(ext)) {
|
||||
return { success: false, error: `Unsupported audio format "${ext}". Supported: ${[...AUDIO_EXTS].join(', ')}` };
|
||||
}
|
||||
|
||||
const voiceConfig = (getConfig().getConfig() as any)?.voice;
|
||||
if (!voiceConfig?.enabled) {
|
||||
return { success: false, error: 'Voice/STT is not enabled in config (voice.enabled)' };
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
const sizeMB = (stat.size / 1024 / 1024).toFixed(2);
|
||||
const language = String(args?.language || 'auto').replace(/[^a-zA-Z]/g, '');
|
||||
|
||||
try {
|
||||
const audioBuffer = fs.readFileSync(resolved);
|
||||
const format = ext.slice(1);
|
||||
const { text, language: detectedLang } = await transcribeAudio(audioBuffer, format, language === 'auto' ? undefined : language);
|
||||
|
||||
const lines: string[] = [
|
||||
`File: ${path.basename(resolved)}`,
|
||||
`Size: ${sizeMB} MB | Format: ${format.toUpperCase()} | Lang: ${detectedLang || language}`,
|
||||
'',
|
||||
'[Transcription]',
|
||||
text || '(no speech detected)',
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: lines.join('\n'),
|
||||
data: {
|
||||
path: resolved,
|
||||
size_bytes: stat.size,
|
||||
transcription: text || '',
|
||||
language: detectedLang,
|
||||
},
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Transcription failed: ${err.message}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
+176
-26
@@ -263,7 +263,17 @@ async function downloadImageByUrl(url: string, dest: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchUnsplash(query: string, accessKey: string): Promise<string | null> {
|
||||
// UTM tag identifying homeclaw on Unsplash/Pexels referral links (per their guidelines).
|
||||
const ATTRIBUTION_UTM = 'utm_source=homeclaw&utm_medium=referral';
|
||||
|
||||
type ImageHit = {
|
||||
url: string;
|
||||
source: 'Unsplash' | 'Pexels' | 'Pixabay' | 'Browser';
|
||||
credit: string; // human-readable line for slide notes / chat
|
||||
photoId?: string; // present for Unsplash (needed for download trigger)
|
||||
};
|
||||
|
||||
async function searchUnsplash(query: string, accessKey: string): Promise<ImageHit | null> {
|
||||
const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=3&orientation=landscape`;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -281,14 +291,36 @@ async function searchUnsplash(query: string, accessKey: string): Promise<string
|
||||
const photo = data?.results?.[0];
|
||||
const imageUrl = photo?.urls?.regular || photo?.urls?.full || null;
|
||||
console.log(`[pptx] Unsplash: ${data?.results?.length ?? 0} results for "${query}", url=${imageUrl?.slice(0, 80) ?? 'none'}`);
|
||||
return imageUrl;
|
||||
if (!imageUrl) return null;
|
||||
const name = photo?.user?.name || photo?.user?.username || 'Unsplash contributor';
|
||||
const username = photo?.user?.username;
|
||||
const credit = username
|
||||
? `Photo by ${name} (https://unsplash.com/@${username}?${ATTRIBUTION_UTM}) on Unsplash (https://unsplash.com/?${ATTRIBUTION_UTM})`
|
||||
: `Photo by ${name} on Unsplash (https://unsplash.com/?${ATTRIBUTION_UTM})`;
|
||||
return { url: imageUrl, source: 'Unsplash', credit, photoId: photo?.id };
|
||||
} catch (e: any) {
|
||||
console.log(`[pptx] Unsplash error for "${query}": ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchPexels(query: string, apiKey: string): Promise<string | null> {
|
||||
/**
|
||||
* Per Unsplash API guidelines: GET /photos/:id/download must be hit when the
|
||||
* photo is actually used (embedded in a deliverable, displayed to the user).
|
||||
* Fire-and-forget — failure must not block the user-facing flow.
|
||||
*/
|
||||
function triggerUnsplashDownload(photoId: string, accessKey: string): void {
|
||||
const url = `https://api.unsplash.com/photos/${encodeURIComponent(photoId)}/download`;
|
||||
fetch(url, {
|
||||
headers: { 'Authorization': `Client-ID ${accessKey}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).then(
|
||||
r => console.log(`[pptx] Unsplash download trigger: ${photoId} -> ${r.status}`),
|
||||
e => console.log(`[pptx] Unsplash download trigger error for ${photoId}: ${e.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function searchPexels(query: string, apiKey: string): Promise<ImageHit | null> {
|
||||
const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=3&orientation=landscape`;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -306,13 +338,101 @@ async function searchPexels(query: string, apiKey: string): Promise<string | nul
|
||||
const photo = data?.photos?.[0];
|
||||
const imageUrl = photo?.src?.large2x || photo?.src?.large || photo?.src?.original || null;
|
||||
console.log(`[pptx] Pexels: ${data?.photos?.length ?? 0} results for "${query}", url=${imageUrl?.slice(0, 80) ?? 'none'}`);
|
||||
return imageUrl;
|
||||
if (!imageUrl) return null;
|
||||
const name = photo?.photographer || 'Pexels contributor';
|
||||
const profileUrl = photo?.photographer_url;
|
||||
const credit = profileUrl
|
||||
? `Photo by ${name} (${profileUrl}) on Pexels (https://www.pexels.com/?${ATTRIBUTION_UTM})`
|
||||
: `Photo by ${name} on Pexels (https://www.pexels.com/?${ATTRIBUTION_UTM})`;
|
||||
return { url: imageUrl, source: 'Pexels', credit };
|
||||
} catch (e: any) {
|
||||
console.log(`[pptx] Pexels error for "${query}": ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchPixabay(query: string, apiKey: string): Promise<ImageHit | null> {
|
||||
const url = `https://pixabay.com/api/?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}&per_page=3&image_type=photo&orientation=horizontal&safesearch=true`;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
const resp = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (!resp.ok) {
|
||||
console.log(`[pptx] Pixabay: HTTP ${resp.status} for "${query}"`);
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json() as any;
|
||||
const photo = data?.hits?.[0];
|
||||
const imageUrl = photo?.largeImageURL || photo?.webformatURL || null;
|
||||
console.log(`[pptx] Pixabay: ${data?.hits?.length ?? 0} results for "${query}", url=${imageUrl?.slice(0, 80) ?? 'none'}`);
|
||||
if (!imageUrl) return null;
|
||||
const name = photo?.user || 'Pixabay contributor';
|
||||
const pageUrl = photo?.pageURL;
|
||||
const credit = pageUrl
|
||||
? `Image by ${name} on Pixabay (${pageUrl})`
|
||||
: `Image by ${name} on Pixabay`;
|
||||
return { url: imageUrl, source: 'Pixabay', credit };
|
||||
} catch (e: any) {
|
||||
console.log(`[pptx] Pixabay error for "${query}": ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-driven fallback used when all API providers (Pexels/Pixabay/Unsplash)
|
||||
* fail or are unconfigured. Scrapes Google Images via the existing Playwright
|
||||
* browser session helpers — same pattern already used by `search_images` for
|
||||
* Korean queries. Lazy-imported so pptx.ts doesn't pull Playwright at boot.
|
||||
*/
|
||||
async function searchBrowser(query: string): Promise<ImageHit | null> {
|
||||
let bt: typeof import('../gateway/browser-tools.js');
|
||||
try {
|
||||
bt = await import('../gateway/browser-tools.js');
|
||||
} catch (e: any) {
|
||||
console.log(`[pptx] Browser fallback: import failed: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sid = `pptx-img-${Date.now()}`;
|
||||
const searchUrl = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
|
||||
try {
|
||||
const opened = await bt.browserOpen(sid, searchUrl);
|
||||
if (opened.startsWith('ERROR:')) {
|
||||
console.log(`[pptx] Browser fallback: open failed: ${opened.slice(0, 120)}`);
|
||||
return null;
|
||||
}
|
||||
await bt.browserWait(sid, 2500);
|
||||
const result = await bt.browserGetImages(sid, { max_images: 12, download: false });
|
||||
if (result.startsWith('ERROR:') || result.includes('Found 0 images')) {
|
||||
console.log(`[pptx] Browser fallback: no images for "${query}"`);
|
||||
return null;
|
||||
}
|
||||
// Parse " - [type] https://..." lines from browserGetImages text output.
|
||||
const regex = /^\s*-\s*\[[^\]]+\]\s+(https?:\/\/\S+)/gm;
|
||||
const urls: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = regex.exec(result)) !== null) {
|
||||
// Skip Google's UI sprites/favicons; prefer real content URLs.
|
||||
if (m[1].includes('gstatic.com/images') || m[1].includes('/favicon')) continue;
|
||||
if (!urls.includes(m[1])) urls.push(m[1]);
|
||||
}
|
||||
const url = urls[0];
|
||||
if (!url) return null;
|
||||
console.log(`[pptx] Browser fallback: picked ${url.slice(0, 80)}`);
|
||||
return {
|
||||
url,
|
||||
source: 'Browser',
|
||||
credit: `Image via web search for "${query}"`,
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.log(`[pptx] Browser fallback error for "${query}": ${e.message}`);
|
||||
return null;
|
||||
} finally {
|
||||
try { await bt.browserClose(sid); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAndDownloadImage(
|
||||
query: string,
|
||||
projectDir: string,
|
||||
@@ -320,7 +440,8 @@ async function searchAndDownloadImage(
|
||||
sendSSE?: SSESender,
|
||||
unsplashKey?: string,
|
||||
pexelsKey?: string,
|
||||
): Promise<string | null> {
|
||||
pixabayKey?: string,
|
||||
): Promise<{ filename: string; credit: string } | null> {
|
||||
console.log(`[pptx] searchAndDownloadImage: slide ${slideIndex + 1}, query="${query}"`);
|
||||
sendSSE?.('info', { message: `🖼 Slide ${slideIndex + 1}: searching for "${query}"...` });
|
||||
|
||||
@@ -330,41 +451,66 @@ async function searchAndDownloadImage(
|
||||
// Strategy 1: Pexels (200 req/hour — primary)
|
||||
if (pexelsKey) {
|
||||
sendSSE?.('info', { message: `🔍 Slide ${slideIndex + 1}: searching Pexels...` });
|
||||
const imageUrl = await searchPexels(query, pexelsKey);
|
||||
if (imageUrl) {
|
||||
const hit = await searchPexels(query, pexelsKey);
|
||||
if (hit) {
|
||||
sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from Pexels...` });
|
||||
if (await downloadImageByUrl(imageUrl, dest)) {
|
||||
if (await downloadImageByUrl(hit.url, dest)) {
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Pexels OK`);
|
||||
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image ready (Pexels)` });
|
||||
return fname;
|
||||
return { filename: fname, credit: hit.credit };
|
||||
}
|
||||
}
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Pexels failed for "${query}"`);
|
||||
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: Pexels failed` });
|
||||
}
|
||||
|
||||
// Strategy 2: Unsplash (50 req/hour — fallback)
|
||||
// Strategy 2: Pixabay (200 req/min — secondary)
|
||||
if (pixabayKey) {
|
||||
sendSSE?.('info', { message: `🔍 Slide ${slideIndex + 1}: searching Pixabay...` });
|
||||
const hit = await searchPixabay(query, pixabayKey);
|
||||
if (hit) {
|
||||
sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from Pixabay...` });
|
||||
if (await downloadImageByUrl(hit.url, dest)) {
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Pixabay OK`);
|
||||
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image ready (Pixabay)` });
|
||||
return { filename: fname, credit: hit.credit };
|
||||
}
|
||||
}
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Pixabay failed for "${query}"`);
|
||||
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: Pixabay failed` });
|
||||
}
|
||||
|
||||
// Strategy 3: Unsplash (50 req/hour — fallback)
|
||||
if (unsplashKey) {
|
||||
sendSSE?.('info', { message: `🔍 Slide ${slideIndex + 1}: searching Unsplash...` });
|
||||
const imageUrl = await searchUnsplash(query, unsplashKey);
|
||||
if (imageUrl) {
|
||||
const hit = await searchUnsplash(query, unsplashKey);
|
||||
if (hit) {
|
||||
sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from Unsplash...` });
|
||||
if (await downloadImageByUrl(imageUrl, dest)) {
|
||||
if (await downloadImageByUrl(hit.url, dest)) {
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Unsplash OK`);
|
||||
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image ready (Unsplash)` });
|
||||
return fname;
|
||||
if (hit.photoId) triggerUnsplashDownload(hit.photoId, unsplashKey);
|
||||
return { filename: fname, credit: hit.credit };
|
||||
}
|
||||
}
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Unsplash failed for "${query}"`);
|
||||
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: Unsplash failed` });
|
||||
}
|
||||
|
||||
if (!unsplashKey && !pexelsKey) {
|
||||
sendSSE?.('info', { message: `⚠️ Slide ${slideIndex + 1}: no image API keys configured (set UNSPLASH_ACCESS_KEY or PEXELS_API_KEY)` });
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: no API keys for image search`);
|
||||
} else {
|
||||
sendSSE?.('info', { message: `❌ Slide ${slideIndex + 1}: all image sources failed for "${query}"` });
|
||||
// Strategy 4: Browser fallback (Google Images via Playwright) — last resort,
|
||||
// works even with no API keys configured.
|
||||
sendSSE?.('info', { message: `🌐 Slide ${slideIndex + 1}: trying browser fallback...` });
|
||||
const browserHit = await searchBrowser(query);
|
||||
if (browserHit) {
|
||||
sendSSE?.('info', { message: `📥 Slide ${slideIndex + 1}: downloading from web search...` });
|
||||
if (await downloadImageByUrl(browserHit.url, dest)) {
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Browser fallback OK`);
|
||||
sendSSE?.('info', { message: `✅ Slide ${slideIndex + 1}: image ready (web search)` });
|
||||
return { filename: fname, credit: browserHit.credit };
|
||||
}
|
||||
}
|
||||
console.log(`[pptx] slide ${slideIndex + 1}: Browser fallback failed for "${query}"`);
|
||||
sendSSE?.('info', { message: `❌ Slide ${slideIndex + 1}: all image sources failed for "${query}"` });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -461,6 +607,7 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
// Resolve image API keys: env var > config (vault-resolved)
|
||||
const unsplashKey = process.env.UNSPLASH_ACCESS_KEY || configMgr.resolveSecret(config.ppt?.unsplash_key) || '';
|
||||
const pexelsKey = process.env.PEXELS_API_KEY || configMgr.resolveSecret(config.ppt?.pexels_key) || '';
|
||||
const pixabayKey = process.env.PIXABAY_API_KEY || configMgr.resolveSecret(config.ppt?.pixabay_key) || '';
|
||||
|
||||
// Fix deprecated/defunct image URLs before downloading
|
||||
for (const slide of spec.slides) {
|
||||
@@ -485,10 +632,11 @@ export const pptxTool: import('./registry.js').Tool = {
|
||||
|
||||
// image_search takes priority
|
||||
if (slide.image_search && !slide.image_path) {
|
||||
const localPath = await searchAndDownloadImage(slide.image_search, projectDir, i, sendSSE, unsplashKey, pexelsKey);
|
||||
console.log(`[pptx] slide ${i + 1} image_search="${slide.image_search}" -> "${localPath}"`);
|
||||
if (localPath) {
|
||||
slide.image_path = localPath;
|
||||
const match = await searchAndDownloadImage(slide.image_search, projectDir, i, sendSSE, unsplashKey, pexelsKey, pixabayKey);
|
||||
console.log(`[pptx] slide ${i + 1} image_search="${slide.image_search}" -> "${match?.filename}"`);
|
||||
if (match) {
|
||||
slide.image_path = match.filename;
|
||||
slide.notes = slide.notes ? `${slide.notes}\n\n${match.credit}` : match.credit;
|
||||
}
|
||||
delete slide.image_search;
|
||||
// image_search overrides image_url
|
||||
@@ -605,6 +753,7 @@ export const editPptxTool: import('./registry.js').Tool = {
|
||||
// Resolve image API keys
|
||||
const unsplashKey = process.env.UNSPLASH_ACCESS_KEY || configMgr.resolveSecret(config.ppt?.unsplash_key) || '';
|
||||
const pexelsKey = process.env.PEXELS_API_KEY || configMgr.resolveSecret(config.ppt?.pexels_key) || '';
|
||||
const pixabayKey = process.env.PIXABAY_API_KEY || configMgr.resolveSecret(config.ppt?.pixabay_key) || '';
|
||||
|
||||
// Fix deprecated/defunct image URLs before downloading
|
||||
for (const slide of spec.slides) {
|
||||
@@ -622,9 +771,10 @@ export const editPptxTool: import('./registry.js').Tool = {
|
||||
|
||||
// image_search takes priority
|
||||
if (slide.image_search && !slide.image_path) {
|
||||
const localPath = await searchAndDownloadImage(slide.image_search, editProjectDir, i, sendSSE, unsplashKey, pexelsKey);
|
||||
if (localPath) {
|
||||
slide.image_path = localPath;
|
||||
const match = await searchAndDownloadImage(slide.image_search, editProjectDir, i, sendSSE, unsplashKey, pexelsKey, pixabayKey);
|
||||
if (match) {
|
||||
slide.image_path = match.filename;
|
||||
slide.notes = slide.notes ? `${slide.notes}\n\n${match.credit}` : match.credit;
|
||||
}
|
||||
delete slide.image_search;
|
||||
if (slide.image_url) delete slide.image_url;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { pubmedSearchTool, pubmedFetchTool, pubmedFulltextTool } from './pubmed.
|
||||
import { openalexSearchTool, semanticSearchTool } from './scholar.js';
|
||||
import { pdfReadTool } from './pdf.js';
|
||||
import { imageReadTool } from './image.js';
|
||||
import { audioTranscribeTool } from './audio-transcribe.js';
|
||||
import { pythonEvalTool } from './python.js';
|
||||
import { sqliteTool } from './sqlite.js';
|
||||
import { weatherSearchTool } from './weather.js';
|
||||
@@ -56,6 +57,7 @@ const TOOL_PROFILE_TOOL_NAMES: Record<Exclude<ToolProfile, 'full'>, ReadonlySet<
|
||||
'memory_write',
|
||||
'pdf_read',
|
||||
'image_read',
|
||||
'audio_transcribe',
|
||||
'python_eval',
|
||||
'sqlite_query',
|
||||
]),
|
||||
@@ -184,6 +186,7 @@ class ToolRegistry {
|
||||
// Document & data tools
|
||||
this.registerSafe(pdfReadTool);
|
||||
this.registerSafe(imageReadTool);
|
||||
this.registerSafe(audioTranscribeTool);
|
||||
this.registerSafe(pythonEvalTool);
|
||||
this.registerSafe(sqliteTool);
|
||||
// Weather tool
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
interface STTConfig {
|
||||
whisperPath: string;
|
||||
modelPath: string;
|
||||
language: string;
|
||||
ffmpegPath: string;
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
function getSTTConfig(): STTConfig {
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
const voice = cfg?.voice;
|
||||
return {
|
||||
whisperPath: voice?.stt?.whisperPath || process.env.WHISPER_CLI_PATH || 'whisper-cli',
|
||||
modelPath: voice?.stt?.modelPath || process.env.WHISPER_MODEL_PATH || '',
|
||||
language: voice?.stt?.language || 'ko',
|
||||
ffmpegPath: voice?.ffmpegPath || 'ffmpeg',
|
||||
tempDir: voice?.tempDir || os.tmpdir(),
|
||||
};
|
||||
}
|
||||
|
||||
async function convertAudioToWav(
|
||||
inputBuffer: Buffer,
|
||||
sourceFormat: string,
|
||||
config: STTConfig
|
||||
): Promise<string> {
|
||||
const tempDir = config.tempDir;
|
||||
const inputPath = path.join(tempDir, `stt_input_${Date.now()}.${sourceFormat}`);
|
||||
const outputPath = path.join(tempDir, `stt_output_${Date.now()}.wav`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(inputPath, inputBuffer);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ffmpeg = spawn(config.ffmpegPath, [
|
||||
'-y', '-i', inputPath,
|
||||
'-ar', '16000', '-ac', '1', '-f', 'wav',
|
||||
outputPath,
|
||||
]);
|
||||
let stderr = '';
|
||||
ffmpeg.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ffmpeg exited ${code}: ${stderr}`));
|
||||
});
|
||||
ffmpeg.on('error', reject);
|
||||
});
|
||||
|
||||
return outputPath;
|
||||
} finally {
|
||||
try { fs.unlinkSync(inputPath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcribeAudio(
|
||||
audioBuffer: Buffer,
|
||||
sourceFormat: string = 'ogg',
|
||||
languageOverride?: string
|
||||
): Promise<{ text: string; language?: string }> {
|
||||
const config = getSTTConfig();
|
||||
|
||||
if (!config.modelPath) {
|
||||
throw new Error('STT model path not configured. Set voice.stt.modelPath or WHISPER_MODEL_PATH env var.');
|
||||
}
|
||||
|
||||
const wavPath = await convertAudioToWav(audioBuffer, sourceFormat, config);
|
||||
const language = languageOverride || config.language;
|
||||
|
||||
try {
|
||||
// whisper-cli: capture transcription from stdout
|
||||
const args = [
|
||||
'-m', config.modelPath,
|
||||
'-f', wavPath,
|
||||
'-l', language,
|
||||
'--no-timestamps',
|
||||
'-nt', // no print timestamps
|
||||
];
|
||||
|
||||
const result = await new Promise<{ text: string; language?: string }>((resolve, reject) => {
|
||||
const whisper = spawn(config.whisperPath, args);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
whisper.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
||||
whisper.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
whisper.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`whisper-cli exited ${code}: ${stderr.slice(0, 500)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse stdout: whisper-cli outputs transcription lines like:
|
||||
// [00:00:00.000 --> 00:00:02.000] transcribed text
|
||||
// With --no-timestamps, lines are just the text
|
||||
const lines = stdout.split('\n')
|
||||
.map(line => line.replace(/^\[\d{2}:\d{2}:\d{2}\.\d{3}\s*-->\s*\d{2}:\d{2}:\d{2}\.\d{3}\]\s*/, '').trim())
|
||||
.filter(line => line.length > 0 && !line.startsWith('[') && !line.startsWith('whisper_'));
|
||||
|
||||
const text = lines.join(' ').trim();
|
||||
resolve({ text, language });
|
||||
});
|
||||
whisper.on('error', reject);
|
||||
});
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
try { fs.unlinkSync(wavPath); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import { getConfig } from '../config/config.js';
|
||||
|
||||
interface TTSConfig {
|
||||
piperPath: string;
|
||||
modelPath: string;
|
||||
configPath?: string;
|
||||
ffmpegPath: string;
|
||||
tempDir: string;
|
||||
}
|
||||
|
||||
function getTTSConfig(): TTSConfig | null {
|
||||
const cfg = getConfig().getConfig() as any;
|
||||
const voice = cfg?.voice;
|
||||
if (!voice?.tts) return null;
|
||||
|
||||
return {
|
||||
piperPath: voice.tts.piperPath || 'piper',
|
||||
modelPath: voice.tts.modelPath || process.env.PIPER_MODEL_PATH || '',
|
||||
configPath: voice.tts.configPath || process.env.PIPER_CONFIG_PATH,
|
||||
ffmpegPath: voice.ffmpegPath || 'ffmpeg',
|
||||
tempDir: voice.tempDir || os.tmpdir(),
|
||||
};
|
||||
}
|
||||
|
||||
export function isTTSAvailable(): boolean {
|
||||
return getTTSConfig() !== null;
|
||||
}
|
||||
|
||||
export async function synthesizeSpeech(text: string): Promise<Buffer> {
|
||||
const config = getTTSConfig();
|
||||
if (!config) throw new Error('TTS not configured');
|
||||
|
||||
const truncatedText = text.slice(0, 4000);
|
||||
const tempDir = config.tempDir;
|
||||
const wavPath = path.join(tempDir, `tts_output_${Date.now()}.wav`);
|
||||
const oggPath = path.join(tempDir, `tts_output_${Date.now()}.ogg`);
|
||||
|
||||
try {
|
||||
// Generate WAV via Piper
|
||||
const piperArgs = ['--model', config.modelPath, '--output_file', wavPath];
|
||||
if (config.configPath) {
|
||||
piperArgs.push('--config', config.configPath);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const piper = spawn(config.piperPath, piperArgs);
|
||||
let stderr = '';
|
||||
piper.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
piper.stdin.write(truncatedText);
|
||||
piper.stdin.end();
|
||||
piper.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Piper exited ${code}: ${stderr}`));
|
||||
});
|
||||
piper.on('error', reject);
|
||||
});
|
||||
|
||||
// Convert WAV to OGG/Opus for Telegram sendVoice
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ffmpeg = spawn(config.ffmpegPath, [
|
||||
'-y', '-i', wavPath,
|
||||
'-c:a', 'libopus', '-b:a', '48k', '-vbr', 'on',
|
||||
'-compression_level', '10',
|
||||
oggPath,
|
||||
]);
|
||||
let stderr = '';
|
||||
ffmpeg.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`ffmpeg exited ${code}: ${stderr}`));
|
||||
});
|
||||
ffmpeg.on('error', reject);
|
||||
});
|
||||
|
||||
return fs.readFileSync(oggPath);
|
||||
} finally {
|
||||
try { fs.unlinkSync(wavPath); } catch {}
|
||||
try { fs.unlinkSync(oggPath); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,7 @@ export interface SmallClawConfig {
|
||||
skin?: string;
|
||||
unsplash_key?: string;
|
||||
pexels_key?: string;
|
||||
pixabay_key?: string;
|
||||
};
|
||||
heartbeat: {
|
||||
enabled: boolean;
|
||||
@@ -349,6 +350,26 @@ export interface SmallClawConfig {
|
||||
searxng_url?: string;
|
||||
search_rigor?: string;
|
||||
};
|
||||
voice?: {
|
||||
enabled: boolean;
|
||||
stt: {
|
||||
provider: 'whisper_cpp';
|
||||
model: string;
|
||||
modelPath?: string;
|
||||
whisperPath?: string;
|
||||
language?: string;
|
||||
};
|
||||
tts: {
|
||||
provider: 'piper';
|
||||
model: string;
|
||||
piperPath?: string;
|
||||
modelPath?: string;
|
||||
configPath?: string;
|
||||
};
|
||||
ffmpegPath?: string;
|
||||
maxVoiceDurationSeconds?: number;
|
||||
tempDir?: string;
|
||||
};
|
||||
pubmed?: {
|
||||
api_key?: string;
|
||||
};
|
||||
|
||||
@@ -14,4 +14,10 @@ PEXELS_API_KEY=y7Fm3aGM9MlS9nq8ttsMEowcaCFRibFvxpp6ABg97ZupAoYNHWKSU5Z7
|
||||
|
||||
|
||||
|
||||
y7Fm3aGM9MlS9nq8ttsMEowcaCFRibFvxpp6ABg97ZupAoYNHWKSU5Z7 -- pexels
|
||||
y7Fm3aGM9MlS9nq8ttsMEowcaCFRibFvxpp6ABg97ZupAoYNHWKSU5Z7 -- pexels
|
||||
|
||||
|
||||
PIXABAY_API_KEY=55719198-56307131aa2d21b72ec3efd1f
|
||||
|
||||
|
||||
55719198-56307131aa2d21b72ec3efd1f -- pixabay
|
||||
|
||||
Reference in New Issue
Block a user