@thevinci/web 1.0.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 +197 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +4887 -0
- package/bin/cli.test.js +64 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +528 -0
- package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
- package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
- package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
- package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
- package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
- package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
- package/dist/assets/es-4o9ciP61.js +15 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-DLTDToSP.css +1 -0
- package/dist/assets/index-DgiFEKGN.js +1 -0
- package/dist/assets/ko-B20imCHE.js +15 -0
- package/dist/assets/main-BV3KOtdA.css +1 -0
- package/dist/assets/main-CDKJj0sH.js +226 -0
- package/dist/assets/main-LC-PSNVM.js +2 -0
- package/dist/assets/miniChat-CQUiG_cr.js +2 -0
- package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
- package/dist/assets/pl-DQJ7LSzj.js +15 -0
- package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
- package/dist/assets/uk-BNFxOlO4.js +15 -0
- package/dist/assets/vendor--DBfsbEis.css +1 -0
- package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
- package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
- package/dist/assets/worker-CbT6TVo7.js +155 -0
- package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +528 -0
- package/dist/index.html +607 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.png +0 -0
- package/dist/logo-dark-512x512.svg +528 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.png +0 -0
- package/dist/logo-light-512x512.svg +528 -0
- package/dist/mini-chat.html +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +21 -0
- package/dist/sw.js +1 -0
- package/package.json +118 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +528 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +528 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.png +0 -0
- package/public/logo-dark-512x512.svg +528 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.png +0 -0
- package/public/logo-light-512x512.svg +528 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +21 -0
- package/server/TERMINAL_WS_PROTOCOL.md +48 -0
- package/server/index.d.ts +39 -0
- package/server/index.js +1311 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/event-stream/DOCUMENTATION.md +61 -0
- package/server/lib/event-stream/directory-ws-bridge.js +185 -0
- package/server/lib/event-stream/global-hub.js +158 -0
- package/server/lib/event-stream/global-hub.test.js +140 -0
- package/server/lib/event-stream/global-ws-bridge.js +206 -0
- package/server/lib/event-stream/index.js +25 -0
- package/server/lib/event-stream/protocol.js +131 -0
- package/server/lib/event-stream/protocol.test.js +182 -0
- package/server/lib/event-stream/runtime.js +180 -0
- package/server/lib/event-stream/runtime.test.js +512 -0
- package/server/lib/event-stream/upstream-reader.js +226 -0
- package/server/lib/event-stream/upstream-reader.test.js +276 -0
- package/server/lib/fs/DOCUMENTATION.md +36 -0
- package/server/lib/fs/routes.js +1040 -0
- package/server/lib/fs/search.js +238 -0
- package/server/lib/git/DOCUMENTATION.md +152 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +112 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/routes.js +972 -0
- package/server/lib/git/service.js +3432 -0
- package/server/lib/git/service.test.js +39 -0
- package/server/lib/github/DOCUMENTATION.md +171 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +519 -0
- package/server/lib/github/repo/fork-detection.js +102 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/github/routes.js +1560 -0
- package/server/lib/magic-prompts/routes.js +63 -0
- package/server/lib/magic-prompts/runtime.js +119 -0
- package/server/lib/notifications/DOCUMENTATION.md +122 -0
- package/server/lib/notifications/emitter-runtime.js +102 -0
- package/server/lib/notifications/index.js +4 -0
- package/server/lib/notifications/message.js +52 -0
- package/server/lib/notifications/message.test.js +34 -0
- package/server/lib/notifications/push-runtime.js +304 -0
- package/server/lib/notifications/routes.js +315 -0
- package/server/lib/notifications/runtime.js +566 -0
- package/server/lib/notifications/template-runtime.js +349 -0
- package/server/lib/notifications/template-runtime.test.js +26 -0
- package/server/lib/opencode/DOCUMENTATION.md +362 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth-state-runtime.js +88 -0
- package/server/lib/opencode/auth.js +83 -0
- package/server/lib/opencode/bootstrap-runtime.js +131 -0
- package/server/lib/opencode/cli-entry-runtime.js +43 -0
- package/server/lib/opencode/cli-options.js +128 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/config-entity-routes.js +370 -0
- package/server/lib/opencode/core-routes.js +500 -0
- package/server/lib/opencode/core-routes.test.js +26 -0
- package/server/lib/opencode/env-config.js +74 -0
- package/server/lib/opencode/env-keys.js +68 -0
- package/server/lib/opencode/env-runtime.js +1162 -0
- package/server/lib/opencode/env-runtime.test.js +116 -0
- package/server/lib/opencode/feature-routes-runtime.js +244 -0
- package/server/lib/opencode/hmr-state-runtime.js +85 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/lifecycle.js +1019 -0
- package/server/lib/opencode/lifecycle.test.js +240 -0
- package/server/lib/opencode/mcp.js +278 -0
- package/server/lib/opencode/network-runtime.js +104 -0
- package/server/lib/opencode/network-runtime.test.js +37 -0
- package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
- package/server/lib/opencode/path-utils.js +100 -0
- package/server/lib/opencode/path-utils.test.js +71 -0
- package/server/lib/opencode/project-directory-runtime.js +124 -0
- package/server/lib/opencode/project-icon-routes.js +399 -0
- package/server/lib/opencode/project-icon-routes.test.js +107 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/proxy.js +445 -0
- package/server/lib/opencode/pwa-manifest-routes.js +257 -0
- package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
- package/server/lib/opencode/routes.js +541 -0
- package/server/lib/opencode/server-startup-runtime.js +156 -0
- package/server/lib/opencode/server-utils-runtime.js +168 -0
- package/server/lib/opencode/server-utils-runtime.test.js +135 -0
- package/server/lib/opencode/session-runtime.js +356 -0
- package/server/lib/opencode/session-runtime.test.js +151 -0
- package/server/lib/opencode/settings-helpers.js +770 -0
- package/server/lib/opencode/settings-helpers.test.js +109 -0
- package/server/lib/opencode/settings-normalization-runtime.js +428 -0
- package/server/lib/opencode/settings-runtime.js +826 -0
- package/server/lib/opencode/settings-runtime.test.js +85 -0
- package/server/lib/opencode/shared.js +615 -0
- package/server/lib/opencode/shutdown-runtime.js +139 -0
- package/server/lib/opencode/shutdown-runtime.test.js +58 -0
- package/server/lib/opencode/skill-routes.js +701 -0
- package/server/lib/opencode/skills.js +548 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
- package/server/lib/opencode/static-routes-runtime.js +65 -0
- package/server/lib/opencode/theme-runtime.js +167 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
- package/server/lib/opencode/vinci-routes.js +76 -0
- package/server/lib/opencode/watcher.js +115 -0
- package/server/lib/opencode/watcher.test.js +239 -0
- package/server/lib/preview/proxy-runtime.js +1333 -0
- package/server/lib/preview/proxy-runtime.test.js +144 -0
- package/server/lib/projects/project-config.js +567 -0
- package/server/lib/projects/project-config.test.js +175 -0
- package/server/lib/projects/project-id.js +13 -0
- package/server/lib/quota/DOCUMENTATION.md +58 -0
- package/server/lib/quota/index.js +25 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +168 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
- package/server/lib/quota/providers/zhipuai.js +114 -0
- package/server/lib/quota/routes.js +27 -0
- package/server/lib/quota/utils/auth.js +50 -0
- package/server/lib/quota/utils/formatters.js +85 -0
- package/server/lib/quota/utils/formatters.test.js +54 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
- package/server/lib/scheduled-tasks/routes.js +235 -0
- package/server/lib/scheduled-tasks/runtime.js +773 -0
- package/server/lib/scheduled-tasks/runtime.test.js +100 -0
- package/server/lib/security/request-security.js +115 -0
- package/server/lib/session-folders/routes.js +63 -0
- package/server/lib/session-folders/routes.test.js +102 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +87 -0
- package/server/lib/terminal/DOCUMENTATION.md +76 -0
- package/server/lib/terminal/index.js +31 -0
- package/server/lib/terminal/output-replay-buffer.js +78 -0
- package/server/lib/terminal/output-replay-buffer.test.js +75 -0
- package/server/lib/terminal/runtime.js +850 -0
- package/server/lib/terminal/runtime.test.js +96 -0
- package/server/lib/terminal/terminal-ws-protocol.js +68 -0
- package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
- package/server/lib/text/DOCUMENTATION.md +35 -0
- package/server/lib/text/summarization.js +138 -0
- package/server/lib/text/summarization.test.js +34 -0
- package/server/lib/tts/DOCUMENTATION.md +146 -0
- package/server/lib/tts/base-url.js +62 -0
- package/server/lib/tts/capability-runtime.js +31 -0
- package/server/lib/tts/index.js +19 -0
- package/server/lib/tts/routes.js +261 -0
- package/server/lib/tts/routes.test.js +53 -0
- package/server/lib/tts/service.js +178 -0
- package/server/lib/tts/stt.js +75 -0
- package/server/lib/tunnels/DOCUMENTATION.md +18 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/managed-config.js +201 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/routes.js +605 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
- package/server/lib/ui-auth/ui-auth.js +673 -0
- package/server/lib/ui-auth/ui-passkeys.js +545 -0
- package/server/opencode-proxy.test.js +151 -0
- package/server/proxy-headers.js +61 -0
- package/server/proxy-headers.test.js +58 -0
- package/server/sse-routes.test.js +152 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const LOCAL_BASE_URL_HOSTS = new Set([
|
|
2
|
+
'localhost',
|
|
3
|
+
'127.0.0.1',
|
|
4
|
+
'::1',
|
|
5
|
+
'host.docker.internal',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const isEnvFlagEnabled = (value) => {
|
|
9
|
+
if (value === true || value === 1) return true;
|
|
10
|
+
if (typeof value !== 'string') return false;
|
|
11
|
+
const normalized = value.trim().toLowerCase();
|
|
12
|
+
return normalized === '1' || normalized === 'true';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const normalizeHostname = (hostname) => {
|
|
16
|
+
if (typeof hostname !== 'string') return '';
|
|
17
|
+
const trimmed = hostname.trim().toLowerCase();
|
|
18
|
+
if (!trimmed) return '';
|
|
19
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
20
|
+
return trimmed.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
return trimmed;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isAllowedLocalHost = (hostname) => {
|
|
26
|
+
const normalized = normalizeHostname(hostname);
|
|
27
|
+
return LOCAL_BASE_URL_HOSTS.has(normalized);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const normalizeCustomOpenAIBaseURL = (value) => {
|
|
31
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
32
|
+
return { value: undefined };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = new URL(value.trim());
|
|
38
|
+
} catch {
|
|
39
|
+
return { error: 'Custom server URL is invalid' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
43
|
+
return { error: 'Custom server URL must use http or https' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (parsed.username || parsed.password) {
|
|
47
|
+
return { error: 'Custom server URL must not include credentials' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const allowRemote = isEnvFlagEnabled(process.env.VINCI_ALLOW_REMOTE_OPENAI_COMPAT_URLS);
|
|
51
|
+
if (!allowRemote && !isAllowedLocalHost(parsed.hostname)) {
|
|
52
|
+
return {
|
|
53
|
+
error: 'Remote custom server URLs are disabled. Set VINCI_ALLOW_REMOTE_OPENAI_COMPAT_URLS=true to allow this host.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
parsed.hash = '';
|
|
58
|
+
parsed.search = '';
|
|
59
|
+
const pathname = parsed.pathname.replace(/\/+$/, '');
|
|
60
|
+
const normalizedPath = pathname.length > 0 ? pathname : '';
|
|
61
|
+
return { value: `${parsed.protocol}//${parsed.host}${normalizedPath}` };
|
|
62
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const detectSayTtsCapability = async (processLike) => {
|
|
2
|
+
let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' };
|
|
3
|
+
|
|
4
|
+
if (processLike.platform === 'darwin') {
|
|
5
|
+
try {
|
|
6
|
+
const { exec } = await import('child_process');
|
|
7
|
+
const { promisify } = await import('util');
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const { stdout } = await execAsync('say -v "?"');
|
|
10
|
+
const voices = stdout.split('\n')
|
|
11
|
+
.filter((line) => line.trim())
|
|
12
|
+
.map((line) => {
|
|
13
|
+
const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/);
|
|
14
|
+
if (match) {
|
|
15
|
+
return { name: match[1].trim(), locale: match[2] };
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
})
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
sayTTSCapability = { available: true, voices };
|
|
21
|
+
console.log(`macOS Say TTS available with ${voices.length} voices`);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
sayTTSCapability = { available: false, voices: [], reason: 'say command not available' };
|
|
24
|
+
console.log('macOS Say TTS not available:', error.message);
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return sayTTSCapability;
|
|
31
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS Module Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Public export surface for the Text-to-Speech domain module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
ttsService,
|
|
9
|
+
TTSService,
|
|
10
|
+
TTS_VOICES,
|
|
11
|
+
} from './service.js';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
summarizeText,
|
|
15
|
+
sanitizeForTTS,
|
|
16
|
+
sanitizeForNote,
|
|
17
|
+
} from '../text/summarization.js';
|
|
18
|
+
|
|
19
|
+
export { transcribeAudio } from './stt.js';
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
|
3
|
+
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
|
|
4
|
+
|
|
5
|
+
export function registerTtsRoutes(app, { sayTTSCapability }) {
|
|
6
|
+
let ttsModulePromise = null;
|
|
7
|
+
const getTtsModule = async () => {
|
|
8
|
+
if (!ttsModulePromise) {
|
|
9
|
+
ttsModulePromise = import('./index.js');
|
|
10
|
+
}
|
|
11
|
+
return ttsModulePromise;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
app.post('/api/voice/token', async (req, res) => {
|
|
15
|
+
console.log('[Voice] Token request received:', {
|
|
16
|
+
contentType: req.headers['content-type'] || null,
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
const openaiApiKey = process.env.OPENAI_API_KEY;
|
|
20
|
+
console.log('[Voice] OpenAI API Key present:', !!openaiApiKey);
|
|
21
|
+
|
|
22
|
+
if (!openaiApiKey) {
|
|
23
|
+
return res.status(503).json({
|
|
24
|
+
allowed: false,
|
|
25
|
+
error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.'
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Return success - OpenAI TTS is available
|
|
30
|
+
res.json({
|
|
31
|
+
allowed: true,
|
|
32
|
+
provider: 'openai',
|
|
33
|
+
message: 'OpenAI TTS is available'
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('[Voice] Token generation error:', error);
|
|
37
|
+
res.status(500).json({
|
|
38
|
+
allowed: false,
|
|
39
|
+
error: 'Voice service error'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Server-side TTS endpoint - streams audio from OpenAI TTS API
|
|
45
|
+
app.post('/api/tts/speak', async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, providerId, modelId, apiKey, baseURL } = req.body || {};
|
|
48
|
+
|
|
49
|
+
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
|
|
50
|
+
if (normalizedBaseURLResult.error) {
|
|
51
|
+
return res.status(400).json({ error: normalizedBaseURLResult.error });
|
|
52
|
+
}
|
|
53
|
+
const normalizedBaseURL = normalizedBaseURLResult.value;
|
|
54
|
+
|
|
55
|
+
console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey, hasBaseURL: !!baseURL });
|
|
56
|
+
|
|
57
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
58
|
+
return res.status(400).json({ error: 'Text is required' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Dynamically import the TTS service (ESM)
|
|
62
|
+
const { ttsService } = await getTtsModule();
|
|
63
|
+
|
|
64
|
+
// Check availability - server-configured key, client-provided key, or custom server URL
|
|
65
|
+
const hasServerKey = ttsService.isAvailable();
|
|
66
|
+
const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
|
|
67
|
+
const hasCustomBaseURL = typeof normalizedBaseURL === 'string' && normalizedBaseURL.length > 0;
|
|
68
|
+
|
|
69
|
+
if (!hasServerKey && !hasClientKey && !hasCustomBaseURL) {
|
|
70
|
+
return res.status(503).json({
|
|
71
|
+
error: 'TTS service not available. Please configure OpenAI in OpenCode, provide an API key, or set a custom server URL in settings.'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let textToSpeak = text.trim();
|
|
76
|
+
|
|
77
|
+
// Historical summarize request fields are intentionally ignored. The
|
|
78
|
+
// model-backed summarization provider is retired.
|
|
79
|
+
|
|
80
|
+
const result = await ttsService.generateSpeechStream({
|
|
81
|
+
text: textToSpeak,
|
|
82
|
+
voice,
|
|
83
|
+
model,
|
|
84
|
+
speed,
|
|
85
|
+
instructions,
|
|
86
|
+
apiKey: hasClientKey ? apiKey.trim() : undefined,
|
|
87
|
+
baseURL: hasCustomBaseURL ? normalizedBaseURL : undefined,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
res.setHeader('Content-Type', result.contentType);
|
|
91
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
92
|
+
res.setHeader('Content-Length', result.buffer.length);
|
|
93
|
+
res.send(result.buffer);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error('[TTS] Error:', error);
|
|
96
|
+
if (!res.headersSent) {
|
|
97
|
+
const { model: m, voice: v, baseURL: b } = req.body || {};
|
|
98
|
+
res.status(500).json({
|
|
99
|
+
error: error instanceof Error ? error.message : 'TTS generation failed',
|
|
100
|
+
detail: { model: m, voice: v, hasBaseURL: !!b },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
app.post('/api/text/summarize', async (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const { text, threshold = 200, maxLength = 500, mode } = req.body || {};
|
|
109
|
+
|
|
110
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
111
|
+
return res.status(400).json({ error: 'Text is required' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const result = await summarizeText({
|
|
115
|
+
text,
|
|
116
|
+
threshold,
|
|
117
|
+
maxLength,
|
|
118
|
+
mode: typeof mode === 'string' ? mode : 'tts',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return res.json(result);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('[Summarize] Error:', error);
|
|
124
|
+
const sanitized = typeof req.body?.mode === 'string' && req.body.mode === 'note'
|
|
125
|
+
? sanitizeForNote(req.body?.text || '')
|
|
126
|
+
: sanitizeForTTS(req.body?.text || '');
|
|
127
|
+
return res.json({ summary: sanitized, summarized: false, reason: error.message });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
// TTS status endpoint
|
|
133
|
+
app.get('/api/tts/status', async (_req, res) => {
|
|
134
|
+
try {
|
|
135
|
+
const { ttsService } = await getTtsModule();
|
|
136
|
+
res.json({
|
|
137
|
+
available: ttsService.isAvailable(),
|
|
138
|
+
voices: [
|
|
139
|
+
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
|
|
140
|
+
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
|
|
141
|
+
]
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
res.status(500).json({ error: 'Failed to check TTS status' });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// macOS 'say' command TTS status endpoint - returns cached capability from startup
|
|
149
|
+
app.get('/api/tts/say/status', (_req, res) => {
|
|
150
|
+
res.json(sayTTSCapability);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// macOS 'say' command TTS speak endpoint
|
|
154
|
+
app.post('/api/tts/say/speak', async (req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const { text, voice = 'Samantha', rate = 200 } = req.body || {};
|
|
157
|
+
|
|
158
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
159
|
+
return res.status(400).json({ error: 'Text is required' });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if we're on macOS
|
|
163
|
+
if (process.platform !== 'darwin') {
|
|
164
|
+
return res.status(503).json({ error: 'macOS say command not available on this platform' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { exec } = await import('child_process');
|
|
168
|
+
const { promisify } = await import('util');
|
|
169
|
+
const fs = await import('fs');
|
|
170
|
+
const os = await import('os');
|
|
171
|
+
const path = await import('path');
|
|
172
|
+
const execAsync = promisify(exec);
|
|
173
|
+
|
|
174
|
+
// Create temp file for audio output (use m4a for browser compatibility)
|
|
175
|
+
const tempDir = os.tmpdir();
|
|
176
|
+
const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`);
|
|
177
|
+
|
|
178
|
+
// Escape text for shell - escape both single quotes and double quotes
|
|
179
|
+
const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"');
|
|
180
|
+
|
|
181
|
+
// Generate audio file using 'say' command
|
|
182
|
+
// -o outputs to file, -r sets rate (words per minute)
|
|
183
|
+
// --data-format=aac outputs as m4a which browsers can decode
|
|
184
|
+
const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`;
|
|
185
|
+
console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate });
|
|
186
|
+
|
|
187
|
+
await execAsync(cmd);
|
|
188
|
+
|
|
189
|
+
// Read the generated audio file
|
|
190
|
+
const audioBuffer = await fs.promises.readFile(tempFile);
|
|
191
|
+
|
|
192
|
+
// Clean up temp file
|
|
193
|
+
fs.promises.unlink(tempFile).catch(() => {});
|
|
194
|
+
|
|
195
|
+
// Send audio response
|
|
196
|
+
res.setHeader('Content-Type', 'audio/mp4');
|
|
197
|
+
res.setHeader('Content-Length', audioBuffer.length);
|
|
198
|
+
res.send(audioBuffer);
|
|
199
|
+
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error('[TTS-Say] Error:', error);
|
|
202
|
+
res.status(500).json({
|
|
203
|
+
error: error instanceof Error ? error.message : 'Say command failed'
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Server-side STT: receive raw audio, proxy to OpenAI-compatible transcription endpoint
|
|
209
|
+
app.post(
|
|
210
|
+
'/api/stt/transcribe',
|
|
211
|
+
express.raw({ type: (req) => (req.headers['content-type'] || '').startsWith('audio/'), limit: '20mb' }),
|
|
212
|
+
async (req, res) => {
|
|
213
|
+
try {
|
|
214
|
+
const { transcribeAudio } = await import('./stt.js');
|
|
215
|
+
|
|
216
|
+
const mimeType = (req.headers['content-type'] || 'audio/webm').split(',')[0].trim();
|
|
217
|
+
const baseURL = typeof req.headers['x-base-url'] === 'string' ? req.headers['x-base-url'].trim() : '';
|
|
218
|
+
const model = typeof req.headers['x-model'] === 'string' && req.headers['x-model'].trim().length > 0
|
|
219
|
+
? req.headers['x-model'].trim()
|
|
220
|
+
: 'deepdml/faster-whisper-large-v3-turbo-ct2';
|
|
221
|
+
const language = typeof req.headers['x-language'] === 'string' && req.headers['x-language'].trim().length > 0
|
|
222
|
+
? req.headers['x-language'].trim()
|
|
223
|
+
: undefined;
|
|
224
|
+
|
|
225
|
+
if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
226
|
+
return res.status(400).json({ error: 'Audio data is required' });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!baseURL) {
|
|
230
|
+
return res.status(400).json({ error: 'X-Base-URL header is required' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
console.log('[STT] Transcribing audio:', {
|
|
234
|
+
bytes: req.body.length,
|
|
235
|
+
mimeType,
|
|
236
|
+
model,
|
|
237
|
+
baseURL,
|
|
238
|
+
language,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const transcript = await transcribeAudio({
|
|
242
|
+
audioBuffer: req.body,
|
|
243
|
+
mimeType,
|
|
244
|
+
model,
|
|
245
|
+
baseURL,
|
|
246
|
+
language,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
console.log('[STT] Transcript:', transcript?.slice(0, 120));
|
|
250
|
+
res.json({ transcript: transcript ?? '' });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.error('[STT] Error:', error);
|
|
253
|
+
if (!res.headersSent) {
|
|
254
|
+
res.status(500).json({
|
|
255
|
+
error: error instanceof Error ? error.message : 'Transcription failed',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
|
|
5
|
+
import { registerTtsRoutes } from './routes.js';
|
|
6
|
+
|
|
7
|
+
const createApp = () => {
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
registerTtsRoutes(app, {
|
|
11
|
+
resolveZenModel: async () => 'gpt-5-nano',
|
|
12
|
+
sayTTSCapability: null,
|
|
13
|
+
});
|
|
14
|
+
return app;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('tts routes', () => {
|
|
18
|
+
it('returns local note fallback while model summarization is retired', async () => {
|
|
19
|
+
const response = await request(createApp())
|
|
20
|
+
.post('/api/text/summarize')
|
|
21
|
+
.send({
|
|
22
|
+
text: 'First sentence. Second sentence with the useful insight.',
|
|
23
|
+
threshold: 0,
|
|
24
|
+
maxLength: 100,
|
|
25
|
+
mode: 'note',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(response.status).toBe(200);
|
|
29
|
+
expect(response.body).toMatchObject({
|
|
30
|
+
summary: 'First sentence.',
|
|
31
|
+
summarized: false,
|
|
32
|
+
reason: 'Model summarization provider unavailable',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps notification fallback behavior without calling zen', async () => {
|
|
37
|
+
const response = await request(createApp())
|
|
38
|
+
.post('/api/text/summarize')
|
|
39
|
+
.send({
|
|
40
|
+
text: 'Notification text that should fall back cleanly.',
|
|
41
|
+
threshold: 0,
|
|
42
|
+
maxLength: 100,
|
|
43
|
+
mode: 'notification',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(response.status).toBe(200);
|
|
47
|
+
expect(response.body).toMatchObject({
|
|
48
|
+
summary: 'Notification text that should fall back cleanly.',
|
|
49
|
+
summarized: false,
|
|
50
|
+
reason: 'Model summarization provider unavailable',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Text-to-Speech Service
|
|
3
|
+
*
|
|
4
|
+
* Uses OpenAI's TTS API to generate audio on the server and stream it to clients.
|
|
5
|
+
* This bypasses mobile Safari's audio context restrictions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import OpenAI from 'openai';
|
|
9
|
+
import { readAuthFile } from '../opencode/auth.js';
|
|
10
|
+
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
|
11
|
+
|
|
12
|
+
// Voice options from OpenAI
|
|
13
|
+
export const TTS_VOICES = [
|
|
14
|
+
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
|
|
15
|
+
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function getOpenAIApiKey() {
|
|
19
|
+
// First check environment variable
|
|
20
|
+
const envKey = process.env.OPENAI_API_KEY;
|
|
21
|
+
if (envKey) {
|
|
22
|
+
return envKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Then check opencode auth file (same as usage tracker)
|
|
26
|
+
try {
|
|
27
|
+
const auth = readAuthFile();
|
|
28
|
+
// Check for openai, codex, or chatgpt aliases
|
|
29
|
+
const openaiAuth = auth.openai || auth.codex || auth.chatgpt;
|
|
30
|
+
if (openaiAuth) {
|
|
31
|
+
// Handle both string format (just the token) and object format
|
|
32
|
+
if (typeof openaiAuth === 'string') {
|
|
33
|
+
return openaiAuth;
|
|
34
|
+
}
|
|
35
|
+
// Try access token first (OAuth), then regular token
|
|
36
|
+
if (openaiAuth.access) {
|
|
37
|
+
return openaiAuth.access;
|
|
38
|
+
}
|
|
39
|
+
if (openaiAuth.token) {
|
|
40
|
+
return openaiAuth.token;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.warn('[TTSService] Failed to read auth file:', error.message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class TTSService {
|
|
51
|
+
constructor() {
|
|
52
|
+
this._client = null;
|
|
53
|
+
this._lastApiKey = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_getClient() {
|
|
57
|
+
const apiKey = getOpenAIApiKey();
|
|
58
|
+
|
|
59
|
+
// If API key changed or client doesn't exist, create new client
|
|
60
|
+
if (apiKey && (!this._client || this._lastApiKey !== apiKey)) {
|
|
61
|
+
this._client = new OpenAI({ apiKey });
|
|
62
|
+
this._lastApiKey = apiKey;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return this._client;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isAvailable() {
|
|
69
|
+
return this._getClient() !== null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate speech and return as a stream
|
|
74
|
+
*/
|
|
75
|
+
async generateSpeechStream(options) {
|
|
76
|
+
const {
|
|
77
|
+
text,
|
|
78
|
+
voice = 'coral',
|
|
79
|
+
model = 'gpt-4o-mini-tts',
|
|
80
|
+
speed = 1.0,
|
|
81
|
+
instructions,
|
|
82
|
+
apiKey,
|
|
83
|
+
baseURL,
|
|
84
|
+
} = options;
|
|
85
|
+
|
|
86
|
+
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
|
|
87
|
+
if (normalizedBaseURLResult.error) {
|
|
88
|
+
throw new Error(normalizedBaseURLResult.error);
|
|
89
|
+
}
|
|
90
|
+
const normalizedBaseURL = normalizedBaseURLResult.value;
|
|
91
|
+
|
|
92
|
+
// Use provided API key / baseURL or fall back to configured key
|
|
93
|
+
let client;
|
|
94
|
+
if (normalizedBaseURL || apiKey) {
|
|
95
|
+
const clientOpts = {};
|
|
96
|
+
if (apiKey) clientOpts.apiKey = apiKey;
|
|
97
|
+
if (!apiKey) clientOpts.apiKey = 'not-required';
|
|
98
|
+
if (normalizedBaseURL) clientOpts.baseURL = normalizedBaseURL;
|
|
99
|
+
client = new OpenAI(clientOpts);
|
|
100
|
+
} else {
|
|
101
|
+
client = this._getClient();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!client) {
|
|
105
|
+
throw new Error('TTS service not available. Configure OpenAI in OpenCode, provide an API key, or set a custom server URL in settings.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!text.trim()) {
|
|
109
|
+
throw new Error('Text is required for TTS');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// OpenAI-compatible servers (custom baseURL) may not support `instructions`
|
|
114
|
+
// or `response_format`, but do support `speed`. Send the safe subset.
|
|
115
|
+
const speechParams = normalizedBaseURL
|
|
116
|
+
? { model, voice, input: text, speed }
|
|
117
|
+
: {
|
|
118
|
+
model,
|
|
119
|
+
voice,
|
|
120
|
+
input: text,
|
|
121
|
+
speed,
|
|
122
|
+
...(instructions && { instructions }),
|
|
123
|
+
response_format: 'mp3',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
console.log('[TTSService] Generating speech — model:', model, 'voice:', voice, 'baseURL:', normalizedBaseURL ?? '(openai)');
|
|
127
|
+
const response = await client.audio.speech.create(speechParams);
|
|
128
|
+
|
|
129
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
130
|
+
return {
|
|
131
|
+
buffer: Buffer.from(arrayBuffer),
|
|
132
|
+
contentType: 'audio/mpeg',
|
|
133
|
+
};
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('[TTSService] Error generating speech:', error);
|
|
136
|
+
throw new Error(`Failed to generate speech: ${error.message || 'Unknown error'}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate speech and return as a buffer (for caching)
|
|
142
|
+
*/
|
|
143
|
+
async generateSpeechBuffer(options) {
|
|
144
|
+
const client = this._getClient();
|
|
145
|
+
if (!client) {
|
|
146
|
+
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable or configure OpenAI in OpenCode.');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const {
|
|
150
|
+
text,
|
|
151
|
+
voice = 'coral',
|
|
152
|
+
model = 'gpt-4o-mini-tts',
|
|
153
|
+
speed = 1.0,
|
|
154
|
+
instructions
|
|
155
|
+
} = options;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const response = await client.audio.speech.create({
|
|
159
|
+
model,
|
|
160
|
+
voice,
|
|
161
|
+
input: text,
|
|
162
|
+
speed,
|
|
163
|
+
...(instructions && { instructions }),
|
|
164
|
+
response_format: 'mp3',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
168
|
+
return Buffer.from(arrayBuffer);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('[TTSService] Error generating speech buffer:', error);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Export singleton instance
|
|
177
|
+
export const ttsService = new TTSService();
|
|
178
|
+
export { TTSService };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Speech-to-Text Service
|
|
3
|
+
*
|
|
4
|
+
* Proxies audio to any OpenAI-compatible transcription endpoint
|
|
5
|
+
* (e.g. faster-whisper, whisper.cpp) using the OpenAI Node SDK.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import OpenAI, { toFile } from 'openai';
|
|
9
|
+
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Transcribe an audio buffer via an OpenAI-compatible /v1/audio/transcriptions endpoint.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {Buffer} opts.audioBuffer - Raw audio bytes
|
|
16
|
+
* @param {string} opts.mimeType - MIME type of the audio (e.g. 'audio/webm')
|
|
17
|
+
* @param {string} opts.model - Model name accepted by the remote server
|
|
18
|
+
* @param {string} [opts.baseURL] - Base URL of the compatible server (including /v1)
|
|
19
|
+
* @param {string} [opts.language] - Optional BCP-47 language hint (e.g. 'en')
|
|
20
|
+
* @returns {Promise<string>} Transcribed text
|
|
21
|
+
*/
|
|
22
|
+
export async function transcribeAudio({ audioBuffer, mimeType, model, baseURL, language }) {
|
|
23
|
+
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
|
|
24
|
+
if (normalizedBaseURLResult.error) {
|
|
25
|
+
throw new Error(normalizedBaseURLResult.error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const normalizedBaseURL = normalizedBaseURLResult.value;
|
|
29
|
+
if (!normalizedBaseURL) {
|
|
30
|
+
throw new Error('Custom server URL is required');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const clientOpts = {
|
|
34
|
+
apiKey: process.env.OPENAI_API_KEY || 'not-required',
|
|
35
|
+
};
|
|
36
|
+
clientOpts.baseURL = normalizedBaseURL;
|
|
37
|
+
|
|
38
|
+
const client = new OpenAI(clientOpts);
|
|
39
|
+
|
|
40
|
+
// Derive a sensible filename extension from the MIME type so the server
|
|
41
|
+
// can infer the codec when it isn't explicit in the stream header.
|
|
42
|
+
const ext = mimeTypeToExt(mimeType);
|
|
43
|
+
const filename = `audio.${ext}`;
|
|
44
|
+
|
|
45
|
+
const file = await toFile(audioBuffer, filename, { type: mimeType });
|
|
46
|
+
|
|
47
|
+
const result = await client.audio.transcriptions.create({
|
|
48
|
+
file,
|
|
49
|
+
model,
|
|
50
|
+
response_format: 'json',
|
|
51
|
+
...(language ? { language } : {}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return result.text ?? '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map a MIME type to a file extension understood by Whisper servers.
|
|
59
|
+
* @param {string} mimeType
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function mimeTypeToExt(mimeType) {
|
|
63
|
+
const type = (mimeType || '').split(';')[0].trim().toLowerCase();
|
|
64
|
+
const map = {
|
|
65
|
+
'audio/webm': 'webm',
|
|
66
|
+
'audio/ogg': 'ogg',
|
|
67
|
+
'audio/wav': 'wav',
|
|
68
|
+
'audio/wave': 'wav',
|
|
69
|
+
'audio/mpeg': 'mp3',
|
|
70
|
+
'audio/mp4': 'mp4',
|
|
71
|
+
'audio/mp3': 'mp3',
|
|
72
|
+
'audio/flac': 'flac',
|
|
73
|
+
};
|
|
74
|
+
return map[type] ?? 'webm';
|
|
75
|
+
}
|