diff --git a/.gitignore b/.gitignore index a362518ec3..8f338a361f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ environments/benchmarks/evals/ # Web UI build output hermes_cli/web_dist/ apps/desktop/dist/ +apps/desktop/*.tsbuildinfo # Web UI assets — synced from @nous-research/ui at build time via # `npm run sync-assets` (see web/package.json). diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9f86e89c8c..e898c95f31 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -1,4 +1,16 @@ -const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require('electron') +const { + app, + BrowserWindow, + Menu, + Notification, + clipboard, + dialog, + ipcMain, + nativeImage, + session, + shell, + systemPreferences +} = require('electron') const crypto = require('node:crypto') const fs = require('node:fs') const http = require('node:http') @@ -391,6 +403,18 @@ function installContextMenu(window) { }) } +function installMediaPermissions() { + session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => { + if (permission === 'media' && details?.mediaTypes?.includes('audio')) { + callback(true) + + return + } + + callback(false) + }) +} + async function startHermes() { if (connectionPromise) return connectionPromise @@ -488,6 +512,14 @@ function createWindow() { ipcMain.handle('hermes:connection', async () => startHermes()) +ipcMain.handle('hermes:requestMicrophoneAccess', async () => { + if (!IS_MAC || typeof systemPreferences.askForMediaAccess !== 'function') { + return true + } + + return systemPreferences.askForMediaAccess('microphone') +}) + ipcMain.handle('hermes:api', async (_event, request) => { const connection = await startHermes() return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, { @@ -539,6 +571,7 @@ ipcMain.handle('hermes:openExternal', (_event, url) => shell.openExternal(url)) app.whenReady().then(() => { Menu.setApplicationMenu(buildApplicationMenu()) + installMediaPermissions() createWindow() startHermes().catch(error => rememberLog(error.stack || error.message)) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 98279b2f41..201673a8a4 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getConnection: () => ipcRenderer.invoke('hermes:connection'), api: request => ipcRenderer.invoke('hermes:api', request), notify: payload => ipcRenderer.invoke('hermes:notify', payload), + requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'), readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath), selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 8da8311294..2dabfaca5c 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -19,17 +19,19 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "leva": "^0.10.1", + "liquid-glass-react": "^1.1.1", "lucide-react": "^0.577.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", "react-shiki": "^0.9.3", "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", - "tw-shimmer": "^0.4.11" + "tw-shimmer": "^0.4.11", + "web-haptics": "^0.0.6" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -5370,15 +5372,6 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, - "node_modules/@stitches/react": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz", - "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16.3.0" - } - }, "node_modules/@streamdown/code": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", @@ -6519,24 +6512,6 @@ "d3-transition": "^3.0.1" } }, - "node_modules/@use-gesture/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", - "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", - "license": "MIT" - }, - "node_modules/@use-gesture/react": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", - "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", - "license": "MIT", - "dependencies": { - "@use-gesture/core": "10.3.1" - }, - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -6919,15 +6894,6 @@ "node": ">=12" } }, - "node_modules/assign-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", - "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/assistant-cloud": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz", @@ -6983,15 +6949,6 @@ "dev": true, "license": "MIT" }, - "node_modules/attr-accept": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", - "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7442,12 +7399,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -7525,6 +7476,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", @@ -9100,27 +9064,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extend-shallow/node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -9203,18 +9146,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-selector": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.5.0.tgz", - "integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -9290,15 +9221,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -9498,15 +9420,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-value": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", - "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -10219,18 +10132,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -10365,18 +10266,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -10543,15 +10432,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -10602,6 +10482,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -10829,46 +10710,6 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, - "node_modules/leva": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", - "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-portal": "^1.1.4", - "@radix-ui/react-tooltip": "^1.1.8", - "@stitches/react": "^1.2.8", - "@use-gesture/react": "^10.2.5", - "colord": "^2.9.2", - "dequal": "^2.0.2", - "merge-value": "^1.0.0", - "react-colorful": "^5.5.1", - "react-dropzone": "^12.0.0", - "v8n": "^1.3.3", - "zustand": "^3.6.9" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, - "node_modules/leva/node_modules/zustand": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", - "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", - "license": "MIT", - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -11132,6 +10973,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/liquid-glass-react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/liquid-glass-react/-/liquid-glass-react-1.1.1.tgz", + "integrity": "sha512-pKzaktaMAEztd93wpWcz2Z5Z9qdLJUNJdMX+n00Ca4XsnrLTQ5xJzm/+GQXZUeuFXe/PQ8ziVMZO6531PyaFJw==", + "license": "MIT", + "workspaces": [ + "liquid-glass" + ], + "peerDependencies": { + "react": ">=19", + "react-dom": ">=19" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11182,6 +11036,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -11574,21 +11429,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/merge-value": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/merge-value/-/merge-value-1.0.0.tgz", - "integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==", - "license": "MIT", - "dependencies": { - "get-value": "^2.0.6", - "is-extendable": "^1.0.0", - "mixin-deep": "^1.2.0", - "set-value": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mermaid": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", @@ -12252,19 +12092,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mixin-deep": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", - "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", - "license": "MIT", - "dependencies": { - "for-in": "^1.0.2", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -12376,6 +12203,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12858,6 +12686,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -13061,16 +12890,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-colorful": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", - "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/react-dom": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", @@ -13083,27 +12902,11 @@ "react": "^19.2.5" } }, - "node_modules/react-dropzone": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.1.0.tgz", - "integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==", - "license": "MIT", - "dependencies": { - "attr-accept": "^2.2.2", - "file-selector": "^0.5.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "react": ">= 16.8" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/react-remove-scroll": { @@ -13153,6 +12956,44 @@ } } }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-shiki": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/react-shiki/-/react-shiki-0.9.3.tgz", @@ -13694,6 +13535,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -13743,30 +13590,6 @@ "node": ">= 0.4" } }, - "node_modules/set-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", - "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "is-extendable": "^0.1.1", - "is-plain-object": "^2.0.3", - "split-string": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/set-value/node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13924,31 +13747,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/split-string": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", - "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/split-string/node_modules/extend-shallow": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", - "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", - "license": "MIT", - "dependencies": { - "assign-symbols": "^1.0.0", - "is-extendable": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -14841,12 +14639,6 @@ "uuid": "dist/esm/bin/uuid" } }, - "node_modules/v8n": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz", - "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==", - "license": "MIT" - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -15138,6 +14930,32 @@ "node": ">=20.0.0" } }, + "node_modules/web-haptics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz", + "integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index acd1ce3200..7a2672a007 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,17 +32,19 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "leva": "^0.10.1", + "liquid-glass-react": "^1.1.1", "lucide-react": "^0.577.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-router-dom": "^7.14.2", "react-shiki": "^0.9.3", "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", - "tw-shimmer": "^0.4.11" + "tw-shimmer": "^0.4.11", + "web-haptics": "^0.0.6" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 13b8bbcd5a..eb2122fed1 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -1,27 +1,780 @@ -import { Layers3 } from 'lucide-react' -import type * as React from 'react' +import { + Copy, + Download, + ExternalLink, + FileImage, + FileText, + FolderOpen, + Layers3, + Link2, + RefreshCw, + Search, + X +} from 'lucide-react' +import type { ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' -import { titlebarHeaderClass } from '../shell/titlebar' +import { PageLoader } from '@/components/page-loader' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { getSessionMessages, listSessions } from '@/hermes' +import { sessionTitle } from '@/lib/chat-runtime' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { SessionInfo, SessionMessage } from '@/types/hermes' -export function ArtifactsView(props: React.ComponentProps<'section'>) { +import { sessionRoute } from '../routes' +import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar' + +type ArtifactKind = 'image' | 'file' | 'link' + +interface ArtifactRecord { + id: string + kind: ArtifactKind + value: string + href: string + label: string + sessionId: string + sessionTitle: string + timestamp: number +} + +const MARKDOWN_IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g +const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g +const URL_RE = /https?:\/\/[^\s<>"')]+/g +const PATH_RE = /(^|[\s("'`])((?:\/|~\/|\.\.?\/)[^\s"'`<>]+(?:\.[a-z0-9]{1,8})?)/gi +const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:\?.*)?$/i +const FILE_EXT_RE = /\.(?:png|jpe?g|gif|webp|svg|bmp|pdf|txt|json|md|csv|zip|tar|gz|mp3|wav|mp4|mov)(?:\?.*)?$/i +const KEY_HINT_RE = /(path|file|url|image|artifact|output|download|result|target)/i + +const imageActionButtonClass = + 'absolute right-2 top-2 grid size-8 place-items-center rounded-full border border-border/70 bg-background/80 text-muted-foreground opacity-0 shadow-sm backdrop-blur transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 disabled:opacity-50' + +const ARTIFACT_TIME_FMT = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short' +}) + +function normalizeValue(value: string): string { + return value.trim().replace(/[),.;]+$/, '') +} + +function parseMaybeJson(value: string): unknown { + if (!value.trim()) { + return null + } + + try { + return JSON.parse(value) + } catch { + return null + } +} + +function looksLikePathOrUrl(value: string): boolean { return ( -
void): void { + if (typeof value === 'string') { + collector(value, keyPath) + + return + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => collectStringValues(entry, `${keyPath}.${index}`, collector)) + + return + } + + if (!value || typeof value !== 'object') { + return + } + + for (const [key, child] of Object.entries(value as Record)) { + collectStringValues(child, keyPath ? `${keyPath}.${key}` : key, collector) + } +} + +function collectArtifactsFromText(text: string, pushValue: (value: string) => void): void { + for (const match of text.matchAll(MARKDOWN_IMAGE_RE)) { + pushValue(match[2] || '') + } + + for (const match of text.matchAll(MARKDOWN_LINK_RE)) { + const start = match.index ?? 0 + + if (start > 0 && text[start - 1] === '!') { + continue + } + + const value = match[2] || '' + + if (looksLikeArtifact(value)) { + pushValue(value) + } + } + + for (const match of text.matchAll(URL_RE)) { + const value = match[0] || '' + + if (looksLikeArtifact(value)) { + pushValue(value) + } + } + + for (const match of text.matchAll(PATH_RE)) { + pushValue(match[2] || '') + } +} + +function collectArtifactsFromMessage(message: SessionMessage, pushValue: (value: string) => void): void { + const text = messageText(message) + + if (text) { + collectArtifactsFromText(text, pushValue) + } + + if (message.role !== 'tool' && !Array.isArray(message.tool_calls)) { + return + } + + if (Array.isArray(message.tool_calls)) { + for (const call of message.tool_calls) { + collectStringValues(call, 'tool_call', (value, keyPath) => { + const normalized = normalizeValue(value) + + if (!normalized) { + return + } + + if (KEY_HINT_RE.test(keyPath) && (looksLikePathOrUrl(normalized) || FILE_EXT_RE.test(normalized))) { + pushValue(normalized) + } + }) + } + } + + const parsed = parseMaybeJson(text) + + if (parsed !== null) { + collectStringValues(parsed, 'tool_result', (value, keyPath) => { + const normalized = normalizeValue(value) + + if (!normalized) { + return + } + + if ((KEY_HINT_RE.test(keyPath) || looksLikePathOrUrl(normalized)) && looksLikeArtifact(normalized)) { + pushValue(normalized) + } + }) + } +} + +function collectArtifactsForSession(session: SessionInfo, messages: SessionMessage[]): ArtifactRecord[] { + const found = new Map() + const title = sessionTitle(session) + + for (const message of messages) { + if (message.role !== 'assistant' && message.role !== 'tool') { + continue + } + + collectArtifactsFromMessage(message, candidate => { + const value = normalizeValue(candidate) + + if (!value || !looksLikeArtifact(value)) { + return + } + + const key = `${session.id}:${value}` + + if (found.has(key)) { + return + } + + found.set(key, { + id: key, + kind: artifactKind(value), + value, + href: artifactHref(value), + label: artifactLabel(value), + sessionId: session.id, + sessionTitle: title, + timestamp: message.timestamp || session.last_active || session.started_at || Date.now() + }) + }) + } + + return Array.from(found.values()) +} + +function formatArtifactTime(timestamp: number): string { + return ARTIFACT_TIME_FMT.format(new Date(timestamp)) +} + +interface ArtifactsViewProps extends React.ComponentProps<'section'> { + setTitlebarActions?: (actions: ReactNode | null) => void +} + +export function ArtifactsView({ setTitlebarActions, ...props }: ArtifactsViewProps) { + const navigate = useNavigate() + const [artifacts, setArtifacts] = useState(null) + const [query, setQuery] = useState('') + const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all') + const [refreshing, setRefreshing] = useState(false) + const [savingArtifactId, setSavingArtifactId] = useState(null) + const [failedImageIds, setFailedImageIds] = useState>(() => new Set()) + const [lightboxArtifact, setLightboxArtifact] = useState(null) + + const refreshArtifacts = useCallback(async () => { + setRefreshing(true) + + try { + const sessions = (await listSessions(30)).sessions + const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id))) + const nextArtifacts: ArtifactRecord[] = [] + + results.forEach((result, index) => { + if (result.status !== 'fulfilled') { + return + } + + const session = sessions[index] + nextArtifacts.push(...collectArtifactsForSession(session, result.value.messages)) + }) + + setArtifacts(nextArtifacts.sort((a, b) => b.timestamp - a.timestamp)) + } catch (err) { + notifyError(err, 'Artifacts failed to load') + setArtifacts([]) + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refreshArtifacts() + }, [refreshArtifacts]) + + useEffect(() => { + if (!setTitlebarActions) { + return + } + + setTitlebarActions( + + ) + + return () => setTitlebarActions(null) + }, [refreshArtifacts, refreshing, setTitlebarActions]) + + const visibleArtifacts = useMemo(() => { + if (!artifacts) { + return [] + } + + const q = query.trim().toLowerCase() + + return artifacts.filter(artifact => { + if (kindFilter !== 'all' && artifact.kind !== kindFilter) { + return false + } + + if (!q) { + return true + } + + return ( + artifact.label.toLowerCase().includes(q) || + artifact.value.toLowerCase().includes(q) || + artifact.sessionTitle.toLowerCase().includes(q) + ) + }) + }, [artifacts, kindFilter, query]) + + const counts = useMemo(() => { + const all = artifacts || [] + + return { + all: all.length, + image: all.filter(artifact => artifact.kind === 'image').length, + file: all.filter(artifact => artifact.kind === 'file').length, + link: all.filter(artifact => artifact.kind === 'link').length + } + }, [artifacts]) + + const copyArtifact = useCallback(async (value: string) => { + try { + if (window.hermesDesktop?.writeClipboard) { + await window.hermesDesktop.writeClipboard(value) + } else if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value) + } + + notify({ + kind: 'success', + title: 'Copied', + message: value + }) + } catch (err) { + notifyError(err, 'Copy failed') + } + }, []) + + const openArtifact = useCallback(async (href: string) => { + try { + if (window.hermesDesktop?.openExternal) { + await window.hermesDesktop.openExternal(href) + } else { + window.open(href, '_blank', 'noopener,noreferrer') + } + } catch (err) { + notifyError(err, 'Open failed') + } + }, []) + + const saveImageArtifact = useCallback(async (artifact: ArtifactRecord) => { + if (artifact.kind !== 'image') { + return + } + + setSavingArtifactId(artifact.id) + + try { + if (!window.hermesDesktop?.saveImageFromUrl) { + throw new Error('Image saving is unavailable in this build.') + } + + const saved = await window.hermesDesktop.saveImageFromUrl(artifact.href) + + if (saved) { + notify({ + kind: 'success', + title: 'Image saved', + message: artifact.label + }) + } + } catch (err) { + notifyError(err, 'Save failed') + } finally { + setSavingArtifactId(null) + } + }, []) + + const markImageFailed = useCallback((id: string) => { + setFailedImageIds(current => { + if (current.has(id)) { + return current + } + + return new Set(current).add(id) + }) + }, []) + + const imageLightbox = lightboxArtifact ? ( + !open && setLightboxArtifact(null)} open> + +
+ {lightboxArtifact.label} setLightboxArtifact(null)} + src={lightboxArtifact.href} + /> + +
+
+
+ ) : null + + return ( + <> +
+
+

Artifacts

+ {counts.all} found +
+ +
+
+
+ setKindFilter('all')} + /> + setKindFilter('image')} + /> + setKindFilter('file')} + /> + setKindFilter('link')} + /> +
+
+ + setQuery(event.target.value)} + placeholder="Search artifacts..." + value={query} + /> + {query && ( + + )} +
+
+
+
+ + {!artifacts ? ( + + ) : visibleArtifacts.length === 0 ? ( +
+
+
No artifacts found
+
+ Generated images and file outputs will appear here as sessions produce them. +
+
+
+ ) : ( +
+
+ {visibleArtifacts.map(artifact => ( + navigate(sessionRoute(sessionId))} + onSaveImage={saveImageArtifact} + onZoom={setLightboxArtifact} + saving={savingArtifactId === artifact.id} + /> + ))} +
+
+ )} +
+
+ {imageLightbox} + + ) +} + +function FilterButton({ + active, + icon: Icon, + label, + onClick +}: { + active: boolean + icon: typeof Layers3 + label: string + onClick: () => void +}) { + return ( + + ) +} + +interface ArtifactCardProps { + artifact: ArtifactRecord + failedImage: boolean + onCopy: (value: string) => void | Promise + onImageError: (id: string) => void + onOpen: (href: string) => void | Promise + onOpenChat: (sessionId: string) => void + onSaveImage: (artifact: ArtifactRecord) => void | Promise + onZoom: (artifact: ArtifactRecord) => void + saving: boolean +} + +function ArtifactCard({ + artifact, + failedImage, + onCopy, + onImageError, + onOpen, + onOpenChat, + onSaveImage, + onZoom, + saving +}: ArtifactCardProps) { + const image = artifact.kind === 'image' + + if (!image) { + const Icon = artifact.kind === 'file' ? FileText : Link2 + + return ( +
+
+ +
+ +
+
+ {artifact.kind} +
+
{artifact.label}
+
{artifact.value}
+
+ {artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)} +
+
+ +
+ + + +
+
+ ) + } + + return ( +
-
-

Artifacts

-
-
-
- -

Artifacts view is ready

-

- Generated files and visual outputs now have a dedicated route and view module instead of being folded into - App.tsx. -

+ {image && ( + + )} + +
+
+
+ {image ? ( + + ) : artifact.kind === 'file' ? ( + + ) : ( + + )} + {artifact.kind} +
+
{artifact.label}
+
{artifact.value}
+
+ +
+ {artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)} +
+ +
+
-
+ ) } diff --git a/apps/desktop/src/app/chat/composer/attachments.tsx b/apps/desktop/src/app/chat/composer/attachments.tsx new file mode 100644 index 0000000000..a1cae43226 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/attachments.tsx @@ -0,0 +1,53 @@ +import { X } from 'lucide-react' + +import type { ComposerAttachment } from '@/store/composer' + +import { ATTACHMENT_ICON } from './constants' + +export function AttachmentList({ + attachments, + onRemove +}: { + attachments: ComposerAttachment[] + onRemove?: (id: string) => void +}) { + return ( +
+ {attachments.map(a => ( + + ))} +
+ ) +} + +function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) { + const Icon = ATTACHMENT_ICON[attachment.kind] + + return ( +
+ {attachment.previewUrl ? ( + + ) : ( + + + + )} + + {attachment.label} + {attachment.detail && ( + {attachment.detail} + )} + + {onRemove && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/app/chat/composer/constants.ts b/apps/desktop/src/app/chat/composer/constants.ts new file mode 100644 index 0000000000..60b0d1d1e1 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/constants.ts @@ -0,0 +1,115 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import type { Unstable_IconComponent } from '@assistant-ui/react' +import { FileText, FolderOpen, ImageIcon, Link, type LucideIcon } from 'lucide-react' +import type { CSSProperties } from 'react' + +import { cn } from '@/lib/utils' +import type { ComposerAttachment } from '@/store/composer' + +export const STACK_AT = 500 +export const NARROW_VIEWPORT = '(max-width: 680px)' +export const EXPAND_HEIGHT_PX = 42 + +export const SHELL = + 'absolute bottom-0 left-1/2 z-30 w-[min(calc(100%_-_1rem),clamp(26rem,61.8%,56rem))] max-w-full -translate-x-1/2' + +export const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full' + +export const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground') + +export const COMPOSER_BACKDROP_STYLE = { + backdropFilter: 'blur(.5rem) saturate(1.18)', + WebkitBackdropFilter: 'blur(.5rem) saturate(1.18)' +} satisfies CSSProperties + +export const ATTACHMENT_ICON: Record = { + folder: FolderOpen, + url: Link, + image: ImageIcon, + file: FileText +} + +export const DIRECTIVE_ICONS: Record = { + file: FileText, + folder: FolderOpen, + image: ImageIcon, + url: Link +} + +export const DIRECTIVE_POPOVER_CLASS = + 'absolute bottom-24 left-1/2 z-50 w-[min(calc(100vw-1.5rem),28rem)] max-h-[min(28rem,calc(100vh-8rem))] -translate-x-1/2 overflow-y-auto overscroll-contain rounded-2xl border border-border/70 bg-popover p-1.5 text-popover-foreground shadow-2xl' + +export const PROMPT_SNIPPETS = [ + { + label: 'Code review', + text: 'Please review this for bugs, regressions, and missing tests.' + }, + { + label: 'Implementation plan', + text: 'Please make a concise implementation plan before changing code.' + }, + { + label: 'Explain this', + text: 'Please explain how this works and point me to the key files.' + } +] + +export const ASK_PLACEHOLDERS = [ + 'Hey friend, what can I help with?', + "What's on your mind? I'm here with you.", + 'Need a hand? We can take it one step at a time.', + 'Want to walk through this bug together?', + "Share what you're working on and we'll figure it out.", + "Tell me where you're stuck and I'll stay with you.", + 'Duck mode: gentle debugging, together.' +] + +export const REF_ITEMS: Unstable_TriggerItem[] = [ + { + id: 'file:', + type: 'file', + label: 'File', + description: 'Attach a file path', + metadata: { icon: 'file' } + }, + { + id: 'folder:', + type: 'folder', + label: 'Folder', + description: 'Attach a folder path', + metadata: { icon: 'folder' } + }, + { + id: 'url:', + type: 'url', + label: 'URL', + description: 'Attach a web page', + metadata: { icon: 'url' } + }, + { + id: 'image:', + type: 'image', + label: 'Image', + description: 'Attach an image path', + metadata: { icon: 'image' } + } +] + +export const EDGE_NEWLINES_RE = /^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g +export const DEFAULT_MAX_RECORDING_SECONDS = 120 + +// Conversation-mode VAD tuning — mirrors `tools.voice_mode` defaults so the +// browser pipeline feels like the CLI continuous loop. +export const CONVERSATION_SPEECH_LEVEL = 0.075 +export const CONVERSATION_POST_SPEECH_SILENCE_MS = 1_250 +export const CONVERSATION_IDLE_SILENCE_MS = 12_000 +export const CONVERSATION_MAX_TURN_SECONDS = 60 + +export const VOICE_MIME_TYPES = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/mp4', + 'audio/ogg;codecs=opus', + 'audio/ogg', + 'audio/wav' +] diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx new file mode 100644 index 0000000000..96851f9ac3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -0,0 +1,136 @@ +import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +import { GHOST_ICON_BTN, PROMPT_SNIPPETS } from './constants' +import type { ChatBarState, ContextSuggestion } from './types' + +export function ContextMenu({ + state, + onAddContextRef, + onInsertText, + onOpenUrlDialog, + onPasteClipboardImage, + onPickFiles, + onPickFolders, + onPickImages +}: { + state: ChatBarState + onAddContextRef?: (refText: string, label?: string, detail?: string) => void + onInsertText: (text: string) => void + onOpenUrlDialog: () => void + onPasteClipboardImage?: () => void + onPickFiles?: () => void + onPickFolders?: () => void + onPickImages?: () => void +}) { + const choose = (item: ContextSuggestion) => + onAddContextRef ? onAddContextRef(item.text, item.display, item.meta) : onInsertText(item.text) + + const suggestions = state.tools.suggestions?.slice(0, 8) ?? [] + + return ( + + + + + + Add context + + Files + + + Folders + + + Images + + + Image from clipboard + + + URL + + + + + + + + Suggested files + + + {suggestions.length === 0 ? ( + + No suggestions + + ) : ( + suggestions.map(item => ( + choose(item)}> + + {item.display} + {item.meta && {item.meta}} + + )) + )} + + + + + + + Prompt snippets + + + {PROMPT_SNIPPETS.map(snippet => ( + onInsertText(snippet.text)}> + {snippet.label} + + ))} + + + + + ) +} + +export function ContextMenuItem({ + children, + disabled, + icon: Icon, + onSelect +}: { + children: string + disabled?: boolean + icon: LucideIcon + onSelect?: () => void +}) { + return ( + + + {children} + + ) +} diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx new file mode 100644 index 0000000000..56cf5a8a9d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -0,0 +1,223 @@ +import { ArrowUp, AudioLines, Loader2, Mic, MicOff, Square } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' + +import { GHOST_ICON_BTN, ICON_BTN } from './constants' +import type { ConversationStatus } from './hooks/use-voice-conversation' +import type { ChatBarState, VoiceStatus } from './types' + +interface ConversationProps { + active: boolean + level: number + muted: boolean + status: ConversationStatus + onEnd: () => void + onStart: () => void + onToggleMute: () => void +} + +export function ComposerControls({ + busy, + canSubmit, + conversation, + disabled, + hasComposerPayload, + state, + voiceStatus, + onDictate +}: { + busy: boolean + canSubmit: boolean + conversation: ConversationProps + disabled: boolean + hasComposerPayload: boolean + state: ChatBarState + voiceStatus: VoiceStatus + onDictate: () => void +}) { + if (conversation.active) { + return + } + + const showVoicePrimary = !busy && !hasComposerPayload + + return ( +
+ + {showVoicePrimary ? ( + + ) : ( + + )} +
+ ) +} + +function ConversationPill({ + disabled, + level, + muted, + onEnd, + onToggleMute, + status +}: ConversationProps & { disabled: boolean }) { + const speaking = status === 'speaking' + const listening = status === 'listening' && !muted + + const label = + status === 'speaking' + ? 'Speaking' + : status === 'transcribing' + ? 'Transcribing' + : status === 'thinking' + ? 'Thinking' + : muted + ? 'Muted' + : 'Listening' + + return ( +
+ + + + {label} + +
+ ) +} + +function ConversationIndicator({ + level, + listening, + speaking +}: { + level: number + listening: boolean + speaking: boolean +}) { + if (speaking) { + return + } + + const bars = [0.55, 0.85, 1, 0.85, 0.55] + const normalized = Math.max(0, Math.min(level, 1)) + + return ( +