feat(desktop): polish chat voice and loading states
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Generated
+105
-287
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('file://') ||
|
||||
value.startsWith('data:image/') ||
|
||||
value.startsWith('/') ||
|
||||
value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
value.startsWith('~/')
|
||||
)
|
||||
}
|
||||
|
||||
function looksLikeArtifact(value: string): boolean {
|
||||
if (value.startsWith('data:image/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (looksLikePathOrUrl(value) && (IMAGE_EXT_RE.test(value) || FILE_EXT_RE.test(value))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return value.startsWith('/') && value.includes('.')
|
||||
}
|
||||
|
||||
function artifactKind(value: string): ArtifactKind {
|
||||
if (value.startsWith('data:image/') || IMAGE_EXT_RE.test(value)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') || value.startsWith('file://')) {
|
||||
return 'file'
|
||||
}
|
||||
|
||||
return 'link'
|
||||
}
|
||||
|
||||
function artifactHref(value: string): string {
|
||||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || value.startsWith('data:')) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
return `file://${encodeURI(value)}`
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function artifactLabel(value: string): string {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
const item = url.pathname.split('/').filter(Boolean).pop()
|
||||
|
||||
return item || value
|
||||
} catch {
|
||||
const parts = value.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
return parts.pop() || value
|
||||
}
|
||||
}
|
||||
|
||||
function messageText(message: SessionMessage): string {
|
||||
if (typeof message.content === 'string' && message.content.trim()) {
|
||||
return message.content
|
||||
}
|
||||
|
||||
if (typeof message.text === 'string' && message.text.trim()) {
|
||||
return message.text
|
||||
}
|
||||
|
||||
if (typeof message.context === 'string' && message.context.trim()) {
|
||||
return message.context
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function collectStringValues(value: unknown, keyPath: string, collector: (value: string, keyPath: string) => 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<string, unknown>)) {
|
||||
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<string, ArtifactRecord>()
|
||||
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<ArtifactRecord[] | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [kindFilter, setKindFilter] = useState<'all' | ArtifactKind>('all')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingArtifactId, setSavingArtifactId] = useState<string | null>(null)
|
||||
const [failedImageIds, setFailedImageIds] = useState<Set<string>>(() => new Set())
|
||||
const [lightboxArtifact, setLightboxArtifact] = useState<ArtifactRecord | null>(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(
|
||||
<button
|
||||
aria-label={refreshing ? 'Refreshing artifacts' : 'Refresh artifacts'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshArtifacts()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
|
||||
</button>
|
||||
)
|
||||
|
||||
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 ? (
|
||||
<Dialog onOpenChange={open => !open && setLightboxArtifact(null)} open>
|
||||
<DialogContent
|
||||
className="grid max-h-[calc(100vh-2rem)] w-auto max-w-[calc(100vw-2rem)] place-items-center overflow-visible border-0 bg-transparent p-0 shadow-none"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="group/lightbox relative max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-auto">
|
||||
<img
|
||||
alt={lightboxArtifact.label}
|
||||
className="block max-h-[calc(100vh-2rem)] max-w-full cursor-zoom-out select-auto rounded-lg object-contain shadow-2xl"
|
||||
onClick={() => setLightboxArtifact(null)}
|
||||
src={lightboxArtifact.href}
|
||||
/>
|
||||
<button
|
||||
aria-label={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/lightbox:opacity-100')}
|
||||
disabled={savingArtifactId === lightboxArtifact.id}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void saveImageArtifact(lightboxArtifact)
|
||||
}}
|
||||
title={savingArtifactId === lightboxArtifact.id ? 'Saving image' : 'Download image'}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', savingArtifactId === lightboxArtifact.id && 'animate-pulse')} />
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
<span className="text-xs text-muted-foreground">{counts.all} found</span>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterButton
|
||||
active={kindFilter === 'all'}
|
||||
icon={Layers3}
|
||||
label={`All (${counts.all})`}
|
||||
onClick={() => setKindFilter('all')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'image'}
|
||||
icon={FileImage}
|
||||
label={`Images (${counts.image})`}
|
||||
onClick={() => setKindFilter('image')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'file'}
|
||||
icon={FileText}
|
||||
label={`Files (${counts.file})`}
|
||||
onClick={() => setKindFilter('file')}
|
||||
/>
|
||||
<FilterButton
|
||||
active={kindFilter === 'link'}
|
||||
icon={Link2}
|
||||
label={`Links (${counts.link})`}
|
||||
onClick={() => setKindFilter('link')}
|
||||
/>
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search artifacts..."
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!artifacts ? (
|
||||
<PageLoader label="Indexing recent session artifacts" />
|
||||
) : visibleArtifacts.length === 0 ? (
|
||||
<div className="grid h-full place-items-center px-6 text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">No artifacts found</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Generated images and file outputs will appear here as sessions produce them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto p-3">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] items-start gap-3">
|
||||
{visibleArtifacts.map(artifact => (
|
||||
<ArtifactCard
|
||||
artifact={artifact}
|
||||
failedImage={failedImageIds.has(artifact.id)}
|
||||
key={artifact.id}
|
||||
onCopy={copyArtifact}
|
||||
onImageError={markImageFailed}
|
||||
onOpen={openArtifact}
|
||||
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
|
||||
onSaveImage={saveImageArtifact}
|
||||
onZoom={setLightboxArtifact}
|
||||
saving={savingArtifactId === artifact.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{imageLightbox}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
active: boolean
|
||||
icon: typeof Layers3
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ArtifactCardProps {
|
||||
artifact: ArtifactRecord
|
||||
failedImage: boolean
|
||||
onCopy: (value: string) => void | Promise<void>
|
||||
onImageError: (id: string) => void
|
||||
onOpen: (href: string) => void | Promise<void>
|
||||
onOpenChat: (sessionId: string) => void
|
||||
onSaveImage: (artifact: ArtifactRecord) => void | Promise<void>
|
||||
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 (
|
||||
<article className="group/artifact grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2 rounded-xl border border-border/50 bg-background/70 p-3 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]">
|
||||
<div className="mt-0.5 grid size-8 place-items-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-sm font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[0.68rem] text-muted-foreground/80">{artifact.value}</div>
|
||||
<div className="mt-2 truncate text-[0.68rem] text-muted-foreground">
|
||||
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 opacity-70 transition-opacity group-hover/artifact:opacity-100">
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onOpen(artifact.href)}
|
||||
size="icon-xs"
|
||||
title="Open"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(artifact.value)}
|
||||
size="icon-xs"
|
||||
title="Copy"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenChat(artifact.sessionId)}
|
||||
size="icon-xs"
|
||||
title="Open chat"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'group/artifact overflow-hidden rounded-xl border border-border/50 bg-background/70 shadow-[0_0.1875rem_0.75rem_color-mix(in_srgb,black_3%,transparent)]',
|
||||
image && 'bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<header className={titlebarHeaderClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Artifacts</h2>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-8 text-center">
|
||||
<div className="max-w-md space-y-3">
|
||||
<Layers3 className="mx-auto size-8 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Artifacts view is ready</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generated files and visual outputs now have a dedicated route and view module instead of being folded into
|
||||
App.tsx.
|
||||
</p>
|
||||
{image && (
|
||||
<button
|
||||
aria-label={failedImage ? undefined : `Open ${artifact.label}`}
|
||||
className={cn(
|
||||
'relative flex h-56 w-full items-center justify-center overflow-hidden border-b border-border/50 bg-[color-mix(in_srgb,var(--dt-muted)_58%,var(--dt-background))] p-2',
|
||||
failedImage ? 'cursor-default' : 'cursor-zoom-in'
|
||||
)}
|
||||
disabled={failedImage}
|
||||
onClick={() => onZoom(artifact)}
|
||||
title={failedImage ? undefined : 'Open image'}
|
||||
type="button"
|
||||
>
|
||||
{!failedImage && (
|
||||
<>
|
||||
<img
|
||||
alt=""
|
||||
className="max-h-full max-w-full rounded-md object-contain shadow-sm"
|
||||
data-slot="artifact-media"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onError={() => onImageError(artifact.id)}
|
||||
src={artifact.href}
|
||||
/>
|
||||
<span
|
||||
aria-label={saving ? 'Saving image' : 'Download image'}
|
||||
className={cn(imageActionButtonClass, 'group-hover/artifact:opacity-100')}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void onSaveImage(artifact)
|
||||
}}
|
||||
title={saving ? 'Saving image' : 'Download image'}
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[0.68rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{image ? (
|
||||
<FileImage className="size-3.5" />
|
||||
) : artifact.kind === 'file' ? (
|
||||
<FileText className="size-3.5" />
|
||||
) : (
|
||||
<Link2 className="size-3.5" />
|
||||
)}
|
||||
{artifact.kind}
|
||||
</div>
|
||||
<div className="truncate text-sm font-medium">{artifact.label}</div>
|
||||
<div className="mt-0.5 truncate text-[0.68rem] text-muted-foreground">{artifact.value}</div>
|
||||
</div>
|
||||
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground">
|
||||
{artifact.sessionTitle} · {formatArtifactTime(artifact.timestamp)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button onClick={() => onOpenChat(artifact.sessionId)} size="sm" type="button" variant="outline">
|
||||
<FolderOpen className="size-3.5" />
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const Icon = ATTACHMENT_ICON[attachment.kind]
|
||||
|
||||
return (
|
||||
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">
|
||||
{attachment.previewUrl ? (
|
||||
<img alt="" className="size-9 rounded-xl object-cover" draggable={false} src={attachment.previewUrl} />
|
||||
) : (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-background/70 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate font-medium">{attachment.label}</span>
|
||||
{attachment.detail && (
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground">{attachment.detail}</span>
|
||||
)}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground opacity-70 transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ComposerAttachment['kind'], LucideIcon> = {
|
||||
folder: FolderOpen,
|
||||
url: Link,
|
||||
image: ImageIcon,
|
||||
file: FileText
|
||||
}
|
||||
|
||||
export const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
|
||||
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'
|
||||
]
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Add context</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folders
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Image from clipboard
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileText />
|
||||
<span>Suggested files</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{suggestions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-muted-foreground">No suggestions</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
suggestions.map(item => (
|
||||
<DropdownMenuItem key={item.text} onSelect={() => choose(item)}>
|
||||
<FileText />
|
||||
<span className="min-w-0 flex-1 truncate">{item.display}</span>
|
||||
{item.meta && <span className="max-w-28 truncate text-xs text-muted-foreground">{item.meta}</span>}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{PROMPT_SNIPPETS.map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
<span>{children}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -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 <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
|
||||
const showVoicePrimary = !busy && !hasComposerPayload
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{showVoicePrimary ? (
|
||||
<Button
|
||||
aria-label="Start voice conversation"
|
||||
className={cn(ICON_BTN, 'p-0')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
conversation.onStart()
|
||||
}}
|
||||
size="icon"
|
||||
title="Start voice conversation"
|
||||
type="button"
|
||||
>
|
||||
<AudioLines size={17} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={busy ? 'Stop' : 'Send'}
|
||||
className={cn(ICON_BTN, 'p-0')}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? <span className="block size-3 rounded-[0.1875rem] bg-current" /> : <ArrowUp size={18} />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
aria-label={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
aria-pressed={muted}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', muted && 'bg-muted text-muted-foreground')}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onToggleMute()
|
||||
}}
|
||||
size="icon"
|
||||
title={muted ? 'Unmute microphone' : 'Mute microphone'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{muted ? <MicOff size={16} /> : <Mic size={16} />}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="End voice conversation"
|
||||
className="h-8 gap-1.5 rounded-full bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onEnd()
|
||||
}}
|
||||
title="End voice conversation"
|
||||
type="button"
|
||||
>
|
||||
<ConversationIndicator level={level} listening={listening} speaking={speaking} />
|
||||
<span>End</span>
|
||||
</Button>
|
||||
<span className="sr-only" role="status">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationIndicator({
|
||||
level,
|
||||
listening,
|
||||
speaking
|
||||
}: {
|
||||
level: number
|
||||
listening: boolean
|
||||
speaking: boolean
|
||||
}) {
|
||||
if (speaking) {
|
||||
return <Loader2 className="animate-spin" size={12} />
|
||||
}
|
||||
|
||||
const bars = [0.55, 0.85, 1, 0.85, 0.55]
|
||||
const normalized = Math.max(0, Math.min(level, 1))
|
||||
|
||||
return (
|
||||
<span aria-hidden="true" className="flex h-3 items-center gap-0.5">
|
||||
{bars.map((weight, index) => {
|
||||
const height = listening ? 0.3 + Math.min(0.7, normalized * weight) : 0.3
|
||||
|
||||
return (
|
||||
<span
|
||||
className="w-0.5 rounded-full bg-current"
|
||||
key={index}
|
||||
style={{ height: `${height * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DictationButton({
|
||||
disabled,
|
||||
state,
|
||||
status,
|
||||
onToggle
|
||||
}: {
|
||||
disabled: boolean
|
||||
state: ChatBarState['voice']
|
||||
status: VoiceStatus
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording'
|
||||
? 'Stop dictation'
|
||||
: status === 'transcribing'
|
||||
? 'Transcribing dictation'
|
||||
: 'Voice dictation'
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
title={aria}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className="fill-current" size={12} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Mic size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
type Unstable_IconComponent,
|
||||
type Unstable_MentionCategory,
|
||||
type Unstable_MentionDirective
|
||||
} from '@assistant-ui/react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import { DIRECTIVE_POPOVER_CLASS, REF_ITEMS } from './constants'
|
||||
import type { ContextSuggestion } from './types'
|
||||
|
||||
export function DirectivePopover({
|
||||
adapter,
|
||||
directive,
|
||||
fallbackIcon: Fallback,
|
||||
iconMap
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
directive: Unstable_MentionDirective
|
||||
fallbackIcon: Unstable_IconComponent
|
||||
iconMap: Record<string, Unstable_IconComponent>
|
||||
}) {
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopover adapter={adapter} char="@" className={DIRECTIVE_POPOVER_CLASS}>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
{categories => (
|
||||
<div className="grid gap-1">
|
||||
{categories.map(c => (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
|
||||
categoryId={c.id}
|
||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
|
||||
key={c.id}
|
||||
>
|
||||
<span>{c.label}</span>
|
||||
<ChevronDown className="-rotate-90 size-3.5 text-muted-foreground" />
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-1">
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverBack className="mb-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
Back
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverBack>
|
||||
{items.map((item, index) => {
|
||||
const Icon = directiveIcon(item, iconMap, Fallback)
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
|
||||
index={index}
|
||||
item={item}
|
||||
key={`${item.type}:${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="grid min-w-0 flex-1 gap-0.5">
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
)
|
||||
}
|
||||
export function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] {
|
||||
const items = (suggestions ?? [])
|
||||
.map(s => {
|
||||
const match = s.text.match(/^@(file|folder|url|image):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [, type, id] = match
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
label: s.display || id,
|
||||
description: s.meta,
|
||||
metadata: { icon: type }
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
return [
|
||||
{ id: 'refs', label: 'Hermes refs', items: REF_ITEMS },
|
||||
...(items.length ? [{ id: 'context', label: 'Suggested files', items }] : [])
|
||||
]
|
||||
}
|
||||
function directiveIcon(
|
||||
item: Unstable_TriggerItem,
|
||||
iconMap: Record<string, Unstable_IconComponent>,
|
||||
fallback: Unstable_IconComponent
|
||||
): Unstable_IconComponent {
|
||||
const meta = item.metadata as Record<string, unknown> | undefined
|
||||
const key = typeof meta?.icon === 'string' ? meta.icon : item.type
|
||||
|
||||
return iconMap[key] ?? iconMap[item.type] ?? fallback
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type ComposerLiquidGlassMode = 'polar' | 'prominent' | 'shader' | 'standard'
|
||||
|
||||
export interface ComposerGlassTweakOutputs {
|
||||
fadeBackground: string
|
||||
liquid: {
|
||||
aberrationIntensity: number
|
||||
blurAmount: number
|
||||
cornerRadius: number
|
||||
displacementScale: number
|
||||
elasticity: number
|
||||
mode: ComposerLiquidGlassMode
|
||||
saturation: number
|
||||
}
|
||||
liquidKey: string
|
||||
showLibraryRims: boolean
|
||||
}
|
||||
|
||||
const COMPOSER_GLASS_TWEAKS: ComposerGlassTweakOutputs = {
|
||||
fadeBackground: 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--dt-background) 10%, transparent))',
|
||||
liquid: {
|
||||
aberrationIntensity: 0.95,
|
||||
blurAmount: 0.072,
|
||||
cornerRadius: 20,
|
||||
displacementScale: 46,
|
||||
elasticity: 0,
|
||||
mode: 'standard',
|
||||
saturation: 128
|
||||
},
|
||||
liquidKey: ['standard', '0.950', '0.072', '20', '46', '0.00', '128'].join(':'),
|
||||
showLibraryRims: false
|
||||
}
|
||||
|
||||
export function useComposerGlassTweaks(): ComposerGlassTweakOutputs {
|
||||
return COMPOSER_GLASS_TWEAKS
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { VOICE_MIME_TYPES } from '../constants'
|
||||
|
||||
type BrowserAudioContext = typeof AudioContext
|
||||
|
||||
export interface MicRecorderOptions {
|
||||
onLevel?: (level: number) => void
|
||||
onError?: (error: Error) => void
|
||||
onSilence?: () => void
|
||||
silenceLevel?: number
|
||||
silenceMs?: number
|
||||
idleSilenceMs?: number
|
||||
}
|
||||
|
||||
export interface MicRecording {
|
||||
audio: Blob
|
||||
durationMs: number
|
||||
heardSpeech: boolean
|
||||
}
|
||||
|
||||
interface MicRecorderHandle {
|
||||
start: (options?: MicRecorderOptions) => Promise<void>
|
||||
stop: () => Promise<MicRecording | null>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
function preferredVoiceMimeType(): string {
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return VOICE_MIME_TYPES.find(type => MediaRecorder.isTypeSupported(type)) || ''
|
||||
}
|
||||
|
||||
function micError(error: unknown): Error {
|
||||
const name = error instanceof DOMException ? error.name : ''
|
||||
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
||||
return new Error('Microphone permission was denied.')
|
||||
}
|
||||
|
||||
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
|
||||
return new Error('No microphone was found.')
|
||||
}
|
||||
|
||||
if (name === 'NotReadableError' || name === 'TrackStartError') {
|
||||
return new Error('Microphone is already in use by another app.')
|
||||
}
|
||||
|
||||
if (name === 'OverconstrainedError') {
|
||||
return new Error('Microphone constraints are not supported by this device.')
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error
|
||||
}
|
||||
|
||||
return new Error('Could not start microphone recording.')
|
||||
}
|
||||
|
||||
export function useMicRecorder(): { handle: MicRecorderHandle; level: number; recording: boolean } {
|
||||
const [level, setLevel] = useState(0)
|
||||
const [recording, setRecording] = useState(false)
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const startedAtRef = useRef(0)
|
||||
const heardSpeechRef = useRef(false)
|
||||
const silenceTriggeredRef = useRef(false)
|
||||
const silenceStartedAtRef = useRef<number | null>(null)
|
||||
const stopResolverRef = useRef<((recording: MicRecording | null) => void) | null>(null)
|
||||
|
||||
const cleanup = () => {
|
||||
if (animationRef.current) {
|
||||
window.cancelAnimationFrame(animationRef.current)
|
||||
animationRef.current = null
|
||||
}
|
||||
|
||||
void audioContextRef.current?.close()
|
||||
audioContextRef.current = null
|
||||
streamRef.current?.getTracks().forEach(track => track.stop())
|
||||
streamRef.current = null
|
||||
recorderRef.current = null
|
||||
setLevel(0)
|
||||
setRecording(false)
|
||||
silenceTriggeredRef.current = false
|
||||
}
|
||||
|
||||
useEffect(() => () => cleanup(), [])
|
||||
|
||||
const startMeter = (stream: MediaStream, options: MicRecorderOptions) => {
|
||||
const audioWindow = window as Window & { webkitAudioContext?: BrowserAudioContext }
|
||||
const AudioContextCtor = window.AudioContext || audioWindow.webkitAudioContext
|
||||
|
||||
if (!AudioContextCtor) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const audioContext = new AudioContextCtor()
|
||||
const analyser = audioContext.createAnalyser()
|
||||
const source = audioContext.createMediaStreamSource(stream)
|
||||
|
||||
analyser.fftSize = 256
|
||||
const data = new Uint8Array(analyser.fftSize)
|
||||
|
||||
source.connect(analyser)
|
||||
audioContextRef.current = audioContext
|
||||
|
||||
const tick = () => {
|
||||
analyser.getByteTimeDomainData(data)
|
||||
|
||||
let sum = 0
|
||||
|
||||
for (const value of data) {
|
||||
const centered = value - 128
|
||||
sum += centered * centered
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sum / data.length)
|
||||
const normalized = Math.min(1, rms / 42)
|
||||
const now = Date.now()
|
||||
|
||||
setLevel(normalized)
|
||||
options.onLevel?.(normalized)
|
||||
|
||||
const speechThreshold = options.silenceLevel ?? 0
|
||||
const silenceMs = options.silenceMs ?? 0
|
||||
const idleSilenceMs = options.idleSilenceMs ?? 0
|
||||
|
||||
if (speechThreshold > 0 && options.onSilence && !silenceTriggeredRef.current) {
|
||||
if (normalized >= speechThreshold) {
|
||||
heardSpeechRef.current = true
|
||||
silenceStartedAtRef.current = null
|
||||
} else if (heardSpeechRef.current && silenceMs > 0) {
|
||||
silenceStartedAtRef.current ??= now
|
||||
|
||||
if (now - silenceStartedAtRef.current >= silenceMs) {
|
||||
silenceTriggeredRef.current = true
|
||||
options.onSilence()
|
||||
|
||||
return
|
||||
}
|
||||
} else if (!heardSpeechRef.current && idleSilenceMs > 0 && now - startedAtRef.current >= idleSilenceMs) {
|
||||
silenceTriggeredRef.current = true
|
||||
options.onSilence()
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
} catch {
|
||||
setLevel(0)
|
||||
}
|
||||
}
|
||||
|
||||
const start: MicRecorderHandle['start'] = async (options = {}) => {
|
||||
if (recorderRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
throw new Error('This runtime does not support microphone recording.')
|
||||
}
|
||||
|
||||
const permitted = await window.hermesDesktop?.requestMicrophoneAccess?.()
|
||||
|
||||
if (permitted === false) {
|
||||
throw new Error('Microphone access denied.')
|
||||
}
|
||||
|
||||
let stream: MediaStream
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true }
|
||||
})
|
||||
} catch (error) {
|
||||
throw micError(error)
|
||||
}
|
||||
|
||||
const mimeType = preferredVoiceMimeType()
|
||||
let recorder: MediaRecorder
|
||||
|
||||
try {
|
||||
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
||||
} catch (error) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
throw micError(error)
|
||||
}
|
||||
|
||||
chunksRef.current = []
|
||||
streamRef.current = stream
|
||||
recorderRef.current = recorder
|
||||
heardSpeechRef.current = false
|
||||
silenceTriggeredRef.current = false
|
||||
silenceStartedAtRef.current = null
|
||||
startedAtRef.current = Date.now()
|
||||
|
||||
recorder.ondataavailable = event => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
recorder.onstop = () => {
|
||||
const chunks = chunksRef.current
|
||||
const recordingType = recorder.mimeType || mimeType || 'audio/webm'
|
||||
const durationMs = Date.now() - startedAtRef.current
|
||||
const heardSpeech = heardSpeechRef.current
|
||||
|
||||
chunksRef.current = []
|
||||
cleanup()
|
||||
|
||||
const resolver = stopResolverRef.current
|
||||
stopResolverRef.current = null
|
||||
|
||||
if (!chunks.length) {
|
||||
resolver?.(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolver?.({
|
||||
audio: new Blob(chunks, { type: recordingType }),
|
||||
durationMs,
|
||||
heardSpeech
|
||||
})
|
||||
}
|
||||
|
||||
recorder.onerror = event => {
|
||||
const error = micError((event as Event & { error?: unknown }).error)
|
||||
const resolver = stopResolverRef.current
|
||||
stopResolverRef.current = null
|
||||
cleanup()
|
||||
options.onError?.(error)
|
||||
resolver?.(null)
|
||||
}
|
||||
|
||||
recorder.start()
|
||||
setRecording(true)
|
||||
startMeter(stream, options)
|
||||
}
|
||||
|
||||
const stop: MicRecorderHandle['stop'] = () =>
|
||||
new Promise<MicRecording | null>(resolve => {
|
||||
const recorder = recorderRef.current
|
||||
|
||||
if (!recorder || recorder.state === 'inactive') {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stopResolverRef.current = resolve
|
||||
recorder.stop()
|
||||
})
|
||||
|
||||
const cancel: MicRecorderHandle['cancel'] = () => {
|
||||
const recorder = recorderRef.current
|
||||
const resolver = stopResolverRef.current
|
||||
stopResolverRef.current = null
|
||||
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null
|
||||
recorder.onerror = null
|
||||
recorder.onstop = null
|
||||
recorder.stop()
|
||||
}
|
||||
|
||||
cleanup()
|
||||
resolver?.(null)
|
||||
}
|
||||
|
||||
const handle: MicRecorderHandle = { start, stop, cancel }
|
||||
|
||||
return { handle, level, recording }
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { speakText } from '@/hermes'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import {
|
||||
CONVERSATION_IDLE_SILENCE_MS,
|
||||
CONVERSATION_MAX_TURN_SECONDS,
|
||||
CONVERSATION_POST_SPEECH_SILENCE_MS,
|
||||
CONVERSATION_SPEECH_LEVEL
|
||||
} from '../constants'
|
||||
|
||||
import { useMicRecorder } from './use-mic-recorder'
|
||||
|
||||
export type ConversationStatus = 'idle' | 'listening' | 'transcribing' | 'thinking' | 'speaking'
|
||||
|
||||
interface VoiceConversationOptions {
|
||||
busy: boolean
|
||||
enabled: boolean
|
||||
onFatalError?: () => void
|
||||
onSubmit: (text: string) => void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
pendingResponseText: () => string | null
|
||||
consumePendingResponse: () => void
|
||||
}
|
||||
|
||||
export function useVoiceConversation({
|
||||
busy,
|
||||
enabled,
|
||||
onFatalError,
|
||||
onSubmit,
|
||||
onTranscribeAudio,
|
||||
pendingResponseText,
|
||||
consumePendingResponse
|
||||
}: VoiceConversationOptions) {
|
||||
const { handle, level } = useMicRecorder()
|
||||
const [status, setStatus] = useState<ConversationStatus>('idle')
|
||||
const [muted, setMuted] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const turnTimeoutRef = useRef<number | null>(null)
|
||||
const pendingStartRef = useRef(false)
|
||||
const lastSpokenRef = useRef<string | null>(null)
|
||||
const enabledRef = useRef(enabled)
|
||||
const mutedRef = useRef(muted)
|
||||
const busyRef = useRef(busy)
|
||||
const statusRef = useRef<ConversationStatus>('idle')
|
||||
const wasEnabledRef = useRef(enabled)
|
||||
|
||||
useEffect(() => {
|
||||
enabledRef.current = enabled
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => {
|
||||
mutedRef.current = muted
|
||||
}, [muted])
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy
|
||||
}, [busy])
|
||||
|
||||
useEffect(() => {
|
||||
statusRef.current = status
|
||||
}, [status])
|
||||
|
||||
const clearTurnTimeout = () => {
|
||||
if (turnTimeoutRef.current) {
|
||||
window.clearTimeout(turnTimeoutRef.current)
|
||||
turnTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const stopAudio = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.src = ''
|
||||
audioRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTurn = useCallback(async () => {
|
||||
clearTurnTimeout()
|
||||
setStatus('transcribing')
|
||||
const result = await handle.stop()
|
||||
|
||||
if (!result || !result.heardSpeech || !onTranscribeAudio) {
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const transcript = (await onTranscribeAudio(result.audio)).trim()
|
||||
|
||||
if (!transcript) {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice transcription failed')
|
||||
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
}
|
||||
}, [handle, onSubmit, onTranscribeAudio])
|
||||
|
||||
const startListening = useCallback(async () => {
|
||||
pendingStartRef.current = false
|
||||
|
||||
if (!enabledRef.current || mutedRef.current || busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (statusRef.current !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handle.start({
|
||||
silenceLevel: CONVERSATION_SPEECH_LEVEL,
|
||||
silenceMs: CONVERSATION_POST_SPEECH_SILENCE_MS,
|
||||
idleSilenceMs: CONVERSATION_IDLE_SILENCE_MS,
|
||||
onError: error => {
|
||||
notifyError(error, 'Microphone failed')
|
||||
pendingStartRef.current = false
|
||||
onFatalError?.()
|
||||
},
|
||||
onSilence: () => void handleTurn()
|
||||
})
|
||||
setStatus('listening')
|
||||
turnTimeoutRef.current = window.setTimeout(
|
||||
() => void handleTurn(),
|
||||
CONVERSATION_MAX_TURN_SECONDS * 1000
|
||||
)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Could not start voice session')
|
||||
pendingStartRef.current = false
|
||||
setStatus('idle')
|
||||
onFatalError?.()
|
||||
}
|
||||
}, [handle, handleTurn, onFatalError])
|
||||
|
||||
const speak = useCallback(
|
||||
async (text: string) => {
|
||||
stopAudio()
|
||||
setStatus('speaking')
|
||||
|
||||
try {
|
||||
const response = await speakText(text)
|
||||
const audio = new Audio(response.data_url)
|
||||
audioRef.current = audio
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
audio.addEventListener('ended', () => resolve(), { once: true })
|
||||
audio.addEventListener('error', () => reject(new Error('Playback failed')), { once: true })
|
||||
void audio.play().catch(reject)
|
||||
})
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice playback failed')
|
||||
} finally {
|
||||
audioRef.current = null
|
||||
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
}
|
||||
}
|
||||
},
|
||||
[stopAudio]
|
||||
)
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!onTranscribeAudio) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Voice unavailable',
|
||||
message: 'Configure speech-to-text to use voice mode.'
|
||||
})
|
||||
onFatalError?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setMuted(false)
|
||||
lastSpokenRef.current = null
|
||||
pendingStartRef.current = true
|
||||
}, [onFatalError, onTranscribeAudio])
|
||||
|
||||
const end = useCallback(async () => {
|
||||
pendingStartRef.current = false
|
||||
clearTurnTimeout()
|
||||
stopAudio()
|
||||
handle.cancel()
|
||||
lastSpokenRef.current = null
|
||||
consumePendingResponse()
|
||||
setMuted(false)
|
||||
setStatus('idle')
|
||||
}, [consumePendingResponse, handle, stopAudio])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setMuted(value => {
|
||||
const next = !value
|
||||
|
||||
if (next) {
|
||||
clearTurnTimeout()
|
||||
handle.cancel()
|
||||
setStatus('idle')
|
||||
} else if (enabledRef.current && !busyRef.current && statusRef.current === 'idle') {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}, [handle])
|
||||
|
||||
// Drive the loop: speak any new assistant response, otherwise start listening
|
||||
// when the agent is idle and we're between turns.
|
||||
useEffect(() => {
|
||||
if (!enabled || muted) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = pendingResponseText()
|
||||
const trimmed = text?.trim() ?? ''
|
||||
|
||||
if (trimmed && trimmed !== lastSpokenRef.current && status !== 'speaking') {
|
||||
lastSpokenRef.current = trimmed
|
||||
consumePendingResponse()
|
||||
void speak(trimmed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (busy || status !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingStartRef.current) {
|
||||
void startListening()
|
||||
}
|
||||
}, [busy, consumePendingResponse, enabled, muted, pendingResponseText, speak, startListening, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !wasEnabledRef.current) {
|
||||
void start()
|
||||
}
|
||||
|
||||
if (!enabled && wasEnabledRef.current) {
|
||||
void end()
|
||||
}
|
||||
|
||||
wasEnabledRef.current = enabled
|
||||
}, [enabled, end, start])
|
||||
|
||||
return { end, level, muted, start, status, toggleMute }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { VoiceActivityState, VoiceStatus } from '../types'
|
||||
|
||||
import { useMicRecorder } from './use-mic-recorder'
|
||||
|
||||
interface VoiceRecorderOptions {
|
||||
maxRecordingSeconds: number
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
focusInput: () => void
|
||||
onTranscript: (text: string) => void
|
||||
}
|
||||
|
||||
export function useVoiceRecorder({
|
||||
maxRecordingSeconds,
|
||||
onTranscribeAudio,
|
||||
focusInput,
|
||||
onTranscript
|
||||
}: VoiceRecorderOptions) {
|
||||
const { handle, level, recording } = useMicRecorder()
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>('idle')
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||
const startedAtRef = useRef(0)
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
const clearTimers = () => {
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => () => clearTimers(), [])
|
||||
|
||||
const stop = async () => {
|
||||
clearTimers()
|
||||
const result = await handle.stop()
|
||||
|
||||
if (!result) {
|
||||
setVoiceStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!onTranscribeAudio) {
|
||||
setVoiceStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setVoiceStatus('transcribing')
|
||||
|
||||
try {
|
||||
const transcript = (await onTranscribeAudio(result.audio)).trim()
|
||||
|
||||
if (!transcript) {
|
||||
notify({ kind: 'warning', title: 'No speech detected', message: 'Try recording again.' })
|
||||
} else {
|
||||
onTranscript(transcript)
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice transcription failed')
|
||||
} finally {
|
||||
setVoiceStatus('idle')
|
||||
focusInput()
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
if (!onTranscribeAudio) {
|
||||
notify({ kind: 'warning', title: 'Voice unavailable', message: 'Voice transcription is not available yet.' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await handle.start({ onError: error => notifyError(error, 'Voice recording failed') })
|
||||
startedAtRef.current = Date.now()
|
||||
setElapsedSeconds(0)
|
||||
setVoiceStatus('recording')
|
||||
intervalRef.current = window.setInterval(
|
||||
() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000),
|
||||
250
|
||||
)
|
||||
const cap = Math.max(1, Math.min(Math.trunc(maxRecordingSeconds), 600))
|
||||
timeoutRef.current = window.setTimeout(() => void stop(), cap * 1000)
|
||||
} catch (error) {
|
||||
setVoiceStatus('idle')
|
||||
notifyError(error, 'Voice recording failed')
|
||||
}
|
||||
}
|
||||
|
||||
const dictate = () => {
|
||||
if (recording) {
|
||||
void stop()
|
||||
} else if (voiceStatus === 'idle') {
|
||||
void start()
|
||||
}
|
||||
}
|
||||
|
||||
const voiceActivityState: VoiceActivityState = {
|
||||
elapsedSeconds,
|
||||
level,
|
||||
status: voiceStatus
|
||||
}
|
||||
|
||||
return { dictate, voiceActivityState, voiceStatus }
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { ComposerPrimitive, unstable_useMentionAdapter, useAui, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import LiquidGlass from 'liquid-glass-react'
|
||||
import { FileText } from 'lucide-react'
|
||||
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments } from '@/store/composer'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
import { AttachmentList } from './attachments'
|
||||
import {
|
||||
ASK_PLACEHOLDERS,
|
||||
COMPOSER_BACKDROP_STYLE,
|
||||
DEFAULT_MAX_RECORDING_SECONDS,
|
||||
DIRECTIVE_ICONS,
|
||||
EDGE_NEWLINES_RE,
|
||||
EXPAND_HEIGHT_PX,
|
||||
NARROW_VIEWPORT,
|
||||
SHELL,
|
||||
STACK_AT
|
||||
} from './constants'
|
||||
import { ContextMenu } from './context-menu'
|
||||
import { ComposerControls } from './controls'
|
||||
import { buildMentionCategories, DirectivePopover } from './directive-popover'
|
||||
import { useComposerGlassTweaks } from './hooks/use-composer-glass-tweaks'
|
||||
import { useVoiceConversation } from './hooks/use-voice-conversation'
|
||||
import { useVoiceRecorder } from './hooks/use-voice-recorder'
|
||||
import type { ChatBarProps } from './types'
|
||||
import { UrlDialog } from './url-dialog'
|
||||
import { VoiceActivity } from './voice-activity'
|
||||
|
||||
function trimPastedEdgeNewlines(text: string): string {
|
||||
return text.replace(EDGE_NEWLINES_RE, '')
|
||||
}
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
disabled,
|
||||
focusKey,
|
||||
maxRecordingSeconds = DEFAULT_MAX_RECORDING_SECONDS,
|
||||
state,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSubmit,
|
||||
onTranscribeAudio
|
||||
}: ChatBarProps) {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const attachments = useStore($composerAttachments)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const glassShellRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
const [urlValue, setUrlValue] = useState('')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
|
||||
const [stack, setStack] = useState(false)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const [askPlaceholder] = useState(
|
||||
() => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
|
||||
)
|
||||
|
||||
const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions])
|
||||
|
||||
const mention = unstable_useMentionAdapter({
|
||||
categories: mentionCategories,
|
||||
includeModelContextTools: false,
|
||||
formatter: hermesDirectiveFormatter,
|
||||
iconMap: DIRECTIVE_ICONS,
|
||||
fallbackIcon: FileText
|
||||
})
|
||||
|
||||
const stacked = expanded || stack
|
||||
const hasComposerPayload = draft.trim().length > 0 || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
|
||||
const glassTweaks = useComposerGlassTweaks()
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [disabled, focusKey])
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus())
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return
|
||||
}
|
||||
|
||||
const wraps = (textareaRef.current?.scrollHeight ?? 0) > EXPAND_HEIGHT_PX
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(NARROW_VIEWPORT)
|
||||
|
||||
const update = () => {
|
||||
const w = composerRef.current?.getBoundingClientRect().width ?? window.innerWidth
|
||||
|
||||
setStack(mq.matches || w < STACK_AT)
|
||||
}
|
||||
|
||||
update()
|
||||
mq.addEventListener('change', update)
|
||||
const ro = new ResizeObserver(update)
|
||||
|
||||
if (composerRef.current) {
|
||||
ro.observe(composerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
mq.removeEventListener('change', update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const insertText = (text: string) => {
|
||||
const currentDraft = draftRef.current
|
||||
const sep = currentDraft && !currentDraft.endsWith('\n') ? '\n' : ''
|
||||
const nextDraft = `${currentDraft}${sep}${text}`
|
||||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
|
||||
if (!pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedText = trimPastedEdgeNewlines(pastedText)
|
||||
|
||||
if (trimmedText === pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textarea = event.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
|
||||
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
|
||||
|
||||
const cursor = start + trimmedText.length
|
||||
|
||||
aui.composer().setText(nextDraft)
|
||||
window.requestAnimationFrame(() => {
|
||||
const current = textareaRef.current
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
current.focus()
|
||||
current.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (busy) {
|
||||
triggerHaptic('cancel')
|
||||
onCancel()
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
triggerHaptic('submit')
|
||||
onSubmit(draft)
|
||||
aui.composer().setText('')
|
||||
}
|
||||
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const submitUrl = () => {
|
||||
const url = urlValue.trim()
|
||||
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (onAddUrl) {
|
||||
onAddUrl(url)
|
||||
} else {
|
||||
insertText(`@url:${url}`)
|
||||
}
|
||||
|
||||
triggerHaptic('success')
|
||||
setUrlValue('')
|
||||
setUrlOpen(false)
|
||||
}
|
||||
|
||||
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
|
||||
focusInput,
|
||||
maxRecordingSeconds,
|
||||
onTranscript: insertText,
|
||||
onTranscribeAudio
|
||||
})
|
||||
|
||||
const pendingResponseText = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages.findLast(m => m.role === 'assistant' && !m.pending && !m.hidden)
|
||||
|
||||
if (!last || last.id === lastSpokenIdRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = chatMessageText(last).trim()
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
|
||||
lastSpokenIdRef.current = last.id
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
const consumePendingResponse = () => {
|
||||
const messages = $messages.get()
|
||||
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
|
||||
|
||||
if (last) {
|
||||
lastSpokenIdRef.current = last.id
|
||||
}
|
||||
}
|
||||
|
||||
const submitVoiceTurn = (text: string) => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('submit')
|
||||
onSubmit(text)
|
||||
aui.composer().setText('')
|
||||
draftRef.current = ''
|
||||
}
|
||||
|
||||
const conversation = useVoiceConversation({
|
||||
busy,
|
||||
consumePendingResponse,
|
||||
enabled: voiceConversationActive,
|
||||
onFatalError: () => setVoiceConversationActive(false),
|
||||
onSubmit: submitVoiceTurn,
|
||||
onTranscribeAudio,
|
||||
pendingResponseText
|
||||
})
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onAddContextRef={onAddContextRef}
|
||||
onInsertText={insertText}
|
||||
onOpenUrlDialog={() => {
|
||||
triggerHaptic('open')
|
||||
setUrlOpen(true)
|
||||
}}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
state={state}
|
||||
/>
|
||||
)
|
||||
|
||||
const controls = (
|
||||
<ComposerControls
|
||||
busy={busy}
|
||||
canSubmit={canSubmit}
|
||||
conversation={{
|
||||
active: voiceConversationActive,
|
||||
level: conversation.level,
|
||||
muted: conversation.muted,
|
||||
onEnd: () => {
|
||||
setVoiceConversationActive(false)
|
||||
void conversation.end()
|
||||
},
|
||||
onStart: () => setVoiceConversationActive(true),
|
||||
onToggleMute: conversation.toggleMute,
|
||||
status: conversation.status
|
||||
}}
|
||||
disabled={disabled}
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = (
|
||||
<ComposerPrimitive.Input
|
||||
className={cn(
|
||||
'min-h-8 max-h-37.5 resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-48 flex-1'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
placeholder={disabled ? 'Starting Hermes...' : askPlaceholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
{mentionCategories.length > 0 && (
|
||||
<DirectivePopover
|
||||
adapter={mention.adapter}
|
||||
directive={mention.directive}
|
||||
fallbackIcon={mention.fallbackIcon ?? FileText}
|
||||
iconMap={mention.iconMap ?? DIRECTIVE_ICONS}
|
||||
/>
|
||||
)}
|
||||
<ComposerPrimitive.Root
|
||||
className={cn(SHELL, 'group/composer pb-8 pt-2')}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 top-0"
|
||||
style={{ background: glassTweaks.fadeBackground }}
|
||||
/>
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cn(
|
||||
'composer-liquid-shell-wrap absolute inset-0 transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-70 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-glass-frame="true"
|
||||
data-show-library-rims={glassTweaks.showLibraryRims ? 'true' : undefined}
|
||||
ref={glassShellRef}
|
||||
style={
|
||||
{
|
||||
'--composer-glass-radius': `${glassTweaks.liquid.cornerRadius}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<LiquidGlass
|
||||
aberrationIntensity={glassTweaks.liquid.aberrationIntensity}
|
||||
blurAmount={glassTweaks.liquid.blurAmount}
|
||||
className="composer-liquid-shell pointer-events-none absolute inset-0 h-full w-full"
|
||||
cornerRadius={glassTweaks.liquid.cornerRadius}
|
||||
displacementScale={glassTweaks.liquid.displacementScale}
|
||||
elasticity={glassTweaks.liquid.elasticity}
|
||||
key={glassTweaks.liquidKey}
|
||||
mode={glassTweaks.liquid.mode}
|
||||
mouseContainer={composerRef}
|
||||
padding="0"
|
||||
saturation={glassTweaks.liquid.saturation}
|
||||
style={{ position: 'absolute', top: '0', left: '0', width: '100%', height: '100%' }}
|
||||
>
|
||||
<span className="block h-full w-full" />
|
||||
</LiquidGlass>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-4 flex w-full flex-col gap-1.5 overflow-hidden border border-input/70 bg-card/72 px-2 py-1.5 shadow-composer transition-[border-color,box-shadow,opacity] duration-200 ease-out group-focus-within/composer:border-ring/35 group-focus-within/composer:shadow-composer-focus',
|
||||
scrolledUp
|
||||
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
style={{ ...COMPOSER_BACKDROP_STYLE, borderRadius: `${glassTweaks.liquid.cornerRadius}px` }}
|
||||
>
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
{stacked ? (
|
||||
<>
|
||||
{input}
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
{contextMenu}
|
||||
{controls}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full items-end gap-1.5">
|
||||
{contextMenu}
|
||||
{input}
|
||||
{controls}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
|
||||
<UrlDialog
|
||||
inputRef={urlInputRef}
|
||||
onChange={setUrlValue}
|
||||
onOpenChange={setUrlOpen}
|
||||
onSubmit={submitUrl}
|
||||
open={urlOpen}
|
||||
value={urlValue}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatBarFallback() {
|
||||
return (
|
||||
<div className={cn(SHELL, 'bg-linear-to-b from-transparent to-background/55 pb-8 pt-2')}>
|
||||
<div className="relative h-11 w-full">
|
||||
<div className="absolute inset-0 rounded-[1.25rem] bg-card/1" style={COMPOSER_BACKDROP_STYLE} />
|
||||
<div className="absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
display: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
export interface QuickModelOption {
|
||||
provider: string
|
||||
providerName: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface ChatBarState {
|
||||
model: {
|
||||
model: string
|
||||
provider: string
|
||||
canSwitch: boolean
|
||||
loading?: boolean
|
||||
quickModels?: QuickModelOption[]
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
}
|
||||
|
||||
export interface ChatBarProps {
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
focusKey?: string | null
|
||||
maxRecordingSeconds?: number
|
||||
state: ChatBarState
|
||||
onCancel: () => void
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl?: (url: string) => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
export type VoiceStatus = 'idle' | 'recording' | 'transcribing'
|
||||
|
||||
export interface VoiceActivityState {
|
||||
elapsedSeconds: number
|
||||
level: number
|
||||
status: VoiceStatus
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export function UrlDialog({
|
||||
inputRef,
|
||||
onChange,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
open,
|
||||
value
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
onChange: (value: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: () => void
|
||||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add URL Context</DialogTitle>
|
||||
<DialogDescription>
|
||||
Hermes will fetch this URL via the existing @url context resolver when you send the prompt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!value.trim()} type="submit">
|
||||
Add URL
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Loader2, Mic } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { VoiceActivityState } from './types'
|
||||
|
||||
function formatElapsed(seconds: number) {
|
||||
const safeSeconds = Math.max(0, Math.floor(seconds))
|
||||
const minutes = Math.floor(safeSeconds / 60)
|
||||
const remainingSeconds = safeSeconds % 60
|
||||
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function VoiceLevelBars({ level, active }: { active: boolean; level: number }) {
|
||||
const normalized = Math.max(0, Math.min(level, 1))
|
||||
const bars = [0.5, 0.78, 1, 0.78, 0.5]
|
||||
|
||||
return (
|
||||
<div aria-hidden="true" className="flex h-4 items-center gap-0.5">
|
||||
{bars.map((weight, index) => {
|
||||
const height = active ? 0.25 + Math.min(0.68, normalized * weight) : 0.25
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'w-0.5 rounded-full bg-current transition-[height,opacity] duration-100 ease-out',
|
||||
active ? 'opacity-80' : 'animate-pulse opacity-45'
|
||||
)}
|
||||
key={index}
|
||||
style={{ height: `${height * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VoiceActivity({
|
||||
state
|
||||
}: {
|
||||
state: VoiceActivityState
|
||||
}) {
|
||||
if (state.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
|
||||
const recording = state.status === 'recording'
|
||||
const title = recording ? 'Dictating' : 'Transcribing'
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-2 rounded-xl border border-border/55 bg-muted/55 px-2.5 text-xs text-muted-foreground',
|
||||
'shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm'
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||||
recording ? 'bg-primary/15 text-primary' : 'bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
{recording ? <Mic size={12} /> : <Loader2 className="animate-spin" size={12} />}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground/85">{title}</span>
|
||||
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">{formatElapsed(state.elapsedSeconds)}</span>
|
||||
</div>
|
||||
|
||||
<VoiceLevelBars active={recording} level={state.level} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { contextPath, attachmentId, pathLabel } from '@/lib/chat-runtime'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
removeComposerAttachment,
|
||||
type ComposerAttachment
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { ImageAttachResponse, ImageDetachResponse } from '../types'
|
||||
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
|
||||
|
||||
interface ComposerActionsOptions {
|
||||
activeSessionId: string | null
|
||||
@@ -1,17 +1,22 @@
|
||||
import { AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ExportedMessageRepository,
|
||||
type ThreadMessage,
|
||||
useExternalStoreRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { ChatBar, ChatBarFallback, type ChatBarState } from '@/components/chat-bar'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
|
||||
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import {
|
||||
$activeSessionId,
|
||||
@@ -28,10 +33,13 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
$sessions
|
||||
} from '@/store/session'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderClass } from '../shell/titlebar'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { ChatRightRail } from './right-rail'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
|
||||
@@ -42,6 +50,8 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onCancel: () => void
|
||||
onAddContextRef: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl: (url: string) => void
|
||||
onBranchInNewChat: (messageId: string) => void
|
||||
maxVoiceRecordingSeconds?: number
|
||||
onPasteClipboardImage: () => void
|
||||
onPickFiles: () => void
|
||||
onPickFolders: () => void
|
||||
@@ -54,6 +64,19 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onSelectPersonality: (name: string) => void
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
function threadLoadingState(loadingSession: boolean, busy: boolean, awaitingResponse: boolean) {
|
||||
if (loadingSession) {
|
||||
return 'session'
|
||||
}
|
||||
|
||||
if (!busy) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return awaitingResponse ? 'response' : 'working'
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
@@ -63,6 +86,8 @@ export function ChatView({
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onBranchInNewChat,
|
||||
maxVoiceRecordingSeconds,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
@@ -74,8 +99,10 @@ export function ChatView({
|
||||
onOpenModelPicker,
|
||||
onSelectPersonality,
|
||||
onThreadMessagesChange,
|
||||
onReload
|
||||
onReload,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
@@ -92,14 +119,17 @@ export function ChatView({
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const isRoutedSessionView = Boolean(routeSessionId())
|
||||
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
const showIntro =
|
||||
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
|
||||
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0
|
||||
const threadLoading = loadingSession ? 'session' : busy && awaitingResponse ? 'response' : undefined
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse)
|
||||
const showChatBar = !loadingSession
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : ''
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: () => {
|
||||
@@ -115,10 +145,12 @@ export function ChatView({
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
|
||||
const quickModels = useMemo(
|
||||
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
@@ -140,6 +172,7 @@ export function ChatView({
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
)
|
||||
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
const branchParentByGroup = new Map<string, string | null>()
|
||||
@@ -167,6 +200,7 @@ export function ChatView({
|
||||
|
||||
return ExportedMessageRepository.fromBranchableArray(items, { headId })
|
||||
}, [messages])
|
||||
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messageRepository: runtimeMessageRepository,
|
||||
isRunning: busy,
|
||||
@@ -182,7 +216,7 @@ export function ChatView({
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-transparent">
|
||||
<header className={titlebarHeaderClass}>
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<SessionActionsMenu
|
||||
@@ -213,6 +247,7 @@ export function ChatView({
|
||||
<Thread
|
||||
intro={showIntro ? { personality: introPersonality, seed: introSeed } : undefined}
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
/>
|
||||
{showChatBar && (
|
||||
<Suspense fallback={<ChatBarFallback />}>
|
||||
@@ -220,6 +255,7 @@ export function ChatView({
|
||||
busy={busy}
|
||||
disabled={!gatewayOpen}
|
||||
focusKey={activeSessionId}
|
||||
maxRecordingSeconds={maxVoiceRecordingSeconds}
|
||||
onAddContextRef={onAddContextRef}
|
||||
onAddUrl={onAddUrl}
|
||||
onCancel={onCancel}
|
||||
@@ -229,6 +265,7 @@ export function ChatView({
|
||||
onPickImages={onPickImages}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { ChevronDown, Layers3, Pin, Plus, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
setSidebarRecentsOpen,
|
||||
unpinSession
|
||||
} from '@/store/layout'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading } from '@/store/session'
|
||||
import { $selectedStoredSessionId, $sessions, $sessionsLoading, $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { type AppView, ARTIFACTS_ROUTE, SKILLS_ROUTE } from '../../routes'
|
||||
import type { SidebarNavItem } from '../../types'
|
||||
@@ -71,15 +72,17 @@ export function ChatSidebar({
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const sessions = useStore($sessions)
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
|
||||
const sortedSessions = [...sessions].sort((a, b) => {
|
||||
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => {
|
||||
const aTime = a.last_active || a.started_at || 0
|
||||
const bTime = b.last_active || b.started_at || 0
|
||||
|
||||
return bTime - aTime
|
||||
})
|
||||
}), [sessions])
|
||||
|
||||
const sessionsById = new Map(sessions.map(session => [session.id, session]))
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
const visiblePinnedIds = pinnedSessionIds.filter(id => sessionsById.has(id))
|
||||
const visiblePinnedIdSet = new Set(visiblePinnedIds)
|
||||
|
||||
@@ -94,13 +97,13 @@ export function ChatSidebar({
|
||||
return (
|
||||
<Sidebar
|
||||
className={cn(
|
||||
'relative h-screen min-w-0 overflow-hidden rounded-tr-[0.9375rem] rounded-br-[0.9375rem] border-r border-t-0 border-l-0 border-b-0 text-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
'relative h-screen min-w-0 overflow-hidden border-r border-t-0 border-b-0 border-l-0 text-foreground [backdrop-filter:blur(1.5rem)_saturate(1.08)]',
|
||||
isSidebarResizing
|
||||
? 'transition-none'
|
||||
: 'transition-[opacity,transform,border-color,box-shadow,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
: 'transition-[opacity,transform,border-color,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
sidebarOpen
|
||||
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100 shadow-(--shadow-sidebar)'
|
||||
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0 shadow-none'
|
||||
? 'translate-x-0 border-(--sidebar-edge-border) bg-[color-mix(in_srgb,var(--dt-sidebar-bg)_97%,transparent)] opacity-100'
|
||||
: 'pointer-events-none -translate-x-2 border-transparent bg-transparent opacity-0'
|
||||
)}
|
||||
collapsible="none"
|
||||
>
|
||||
@@ -153,6 +156,7 @@ export function ChatSidebar({
|
||||
<SidebarSessionRow
|
||||
isPinned
|
||||
isSelected={session.id === selectedSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => unpinSession(session.id)}
|
||||
@@ -200,6 +204,7 @@ export function ChatSidebar({
|
||||
<SidebarSessionRow
|
||||
isPinned={false}
|
||||
isSelected={session.id === selectedSessionId}
|
||||
isWorking={workingSessionIdSet.has(session.id)}
|
||||
key={session.id}
|
||||
onDelete={() => onDeleteSession(session.id)}
|
||||
onPin={() => pinSession(session.id)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SessionActionsMenuProps extends Pick<
|
||||
@@ -37,7 +38,14 @@ export function SessionActionsMenu({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} aria-label={`Actions for ${title}`} className="w-44" sideOffset={sideOffset}>
|
||||
<DropdownMenuItem className={itemClass} disabled={!onPin} onSelect={onPin}>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={!onPin}
|
||||
onSelect={() => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}}
|
||||
>
|
||||
<Pin />
|
||||
<span>{pinned ? 'Unpin' : 'Pin'}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -53,7 +61,10 @@ export function SessionActionsMenu({
|
||||
<DropdownMenuItem
|
||||
className={cn(itemClass, 'text-destructive focus:text-destructive')}
|
||||
disabled={!onDelete}
|
||||
onSelect={onDelete}
|
||||
onSelect={() => {
|
||||
triggerHaptic('warning')
|
||||
onDelete?.()
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
|
||||
@@ -4,6 +4,7 @@ import type * as React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { SessionActionsMenu } from './session-actions-menu'
|
||||
@@ -18,6 +19,7 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -27,6 +29,7 @@ export function SidebarSessionRow({
|
||||
session,
|
||||
isPinned,
|
||||
isSelected,
|
||||
isWorking,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume
|
||||
@@ -34,13 +37,22 @@ export function SidebarSessionRow({
|
||||
const title = sessionTitle(session)
|
||||
|
||||
return (
|
||||
<div className={cn(sidebarSessionRowClass, sidebarSessionFadeClass, isSelected && 'bg-accent')}>
|
||||
<div
|
||||
className={cn(
|
||||
sidebarSessionRowClass,
|
||||
sidebarSessionFadeClass,
|
||||
isSelected && 'bg-accent',
|
||||
isWorking && 'text-foreground'
|
||||
)}
|
||||
data-working={isWorking ? 'true' : undefined}
|
||||
>
|
||||
<button
|
||||
className="z-0 flex min-w-0 items-center bg-transparent py-1 pl-2 text-left"
|
||||
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-1 pl-2 text-left"
|
||||
onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onPin()
|
||||
|
||||
return
|
||||
@@ -50,6 +62,13 @@ export function SidebarSessionRow({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isWorking && (
|
||||
<span
|
||||
aria-label="Session running"
|
||||
className="relative size-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_0.625rem_color-mix(in_srgb,var(--primary)_65%,transparent)] before:absolute before:inset-0 before:rounded-full before:bg-primary before:opacity-75 before:content-[''] before:animate-ping"
|
||||
role="status"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium text-foreground/90">{title}</span>
|
||||
</button>
|
||||
<div className="relative z-2 grid w-6 place-items-center">
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
getGlobalModelInfo,
|
||||
getHermesConfig,
|
||||
getHermesConfigDefaults,
|
||||
getSessionMessages,
|
||||
type HermesGateway,
|
||||
listSessions,
|
||||
setGlobalModel
|
||||
} from '../hermes'
|
||||
import { toChatMessages } from '../lib/chat-messages'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
|
||||
import { notify, notifyError } from '../store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
$selectedStoredSessionId,
|
||||
setAvailablePersonalities,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setContextSuggestions,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setIntroPersonality,
|
||||
setMessages,
|
||||
setModelPickerOpen,
|
||||
setSessions,
|
||||
setSessionsLoading
|
||||
} from '../store/session'
|
||||
|
||||
import { ArtifactsView } from './artifacts'
|
||||
import { ChatView, SESSION_INSPECTOR_WIDTH } from './chat'
|
||||
import { useComposerActions } from './chat/hooks/use-composer-actions'
|
||||
import { ChatSidebar } from './chat/sidebar'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import {
|
||||
appViewForPath,
|
||||
isNewChatRoute,
|
||||
NEW_CHAT_ROUTE,
|
||||
routeSessionId,
|
||||
sessionRoute
|
||||
} from './routes'
|
||||
import { useMessageStream } from './session/hooks/use-message-stream'
|
||||
import { usePromptActions } from './session/hooks/use-prompt-actions'
|
||||
import { useSessionActions } from './session/hooks/use-session-actions'
|
||||
import { useSessionStateCache } from './session/hooks/use-session-state-cache'
|
||||
import { SettingsView } from './settings'
|
||||
import { AppShell } from './shell/app-shell'
|
||||
import { SkillsView } from './skills'
|
||||
import type { ContextSuggestion } from './types'
|
||||
|
||||
const DEFAULT_VOICE_RECORDING_SECONDS = 120
|
||||
|
||||
function normalizeRecordingLimit(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
|
||||
}
|
||||
|
||||
export function DesktopController() {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const busyRef = useRef(false)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const currentView = appViewForPath(location.pathname)
|
||||
const settingsOpen = currentView === 'settings'
|
||||
const chatOpen = currentView === 'chat'
|
||||
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
|
||||
const [titlebarActions, setTitlebarActions] = useState<ReactNode>(null)
|
||||
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
|
||||
const [sttEnabled, setSttEnabled] = useState(true)
|
||||
|
||||
const {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
} = useSessionStateCache({
|
||||
activeSessionId,
|
||||
busyRef,
|
||||
selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages
|
||||
})
|
||||
|
||||
const toggleSelectedPin = useCallback(() => {
|
||||
const sessionId = $selectedStoredSessionId.get()
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if ($pinnedSessionIds.get().includes(sessionId)) {
|
||||
unpinSession(sessionId)
|
||||
} else {
|
||||
pinSession(sessionId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
setSessionsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(50)
|
||||
setSessions(result.sessions)
|
||||
} finally {
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
|
||||
|
||||
const setBootGateway = useCallback(
|
||||
(gateway: HermesGateway | null) => {
|
||||
gatewayRef.current = gateway
|
||||
},
|
||||
[gatewayRef]
|
||||
)
|
||||
|
||||
const setBootConnection = useCallback(
|
||||
(connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => {
|
||||
connectionRef.current = connection
|
||||
},
|
||||
[connectionRef]
|
||||
)
|
||||
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({
|
||||
...(prev ?? {}),
|
||||
provider,
|
||||
model
|
||||
})
|
||||
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', activeSessionId || 'global'], patch)
|
||||
|
||||
if (includeGlobal) {
|
||||
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
|
||||
}
|
||||
},
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
||||
const refreshContextSuggestions = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
setContextSuggestions([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<{ items?: ContextSuggestion[] }>('complete.path', {
|
||||
session_id: activeSessionId,
|
||||
word: '@file:',
|
||||
cwd: currentCwd || undefined
|
||||
})
|
||||
|
||||
setContextSuggestions((result.items || []).filter(item => item.text))
|
||||
} catch {
|
||||
setContextSuggestions([])
|
||||
}
|
||||
}, [activeSessionId, currentCwd, requestGateway])
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
try {
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
if (typeof result.model === 'string') {
|
||||
setCurrentModel(result.model)
|
||||
}
|
||||
|
||||
if (typeof result.provider === 'string') {
|
||||
setCurrentProvider(result.provider)
|
||||
}
|
||||
} catch {
|
||||
// The delayed session.info event can still update this once the agent is ready.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeSessionCwd = useCallback(
|
||||
async (cwd: string) => {
|
||||
const trimmed = cwd.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const persistGlobal = async () => {
|
||||
await requestGateway('config.set', {
|
||||
...(activeSessionId && { session_id: activeSessionId }),
|
||||
key: 'terminal.cwd',
|
||||
value: trimmed
|
||||
})
|
||||
setCurrentCwd(trimmed)
|
||||
|
||||
if (!activeSessionId) {
|
||||
setCurrentBranch('')
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSessionId) {
|
||||
try {
|
||||
await persistGlobal()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await requestGateway<SessionRuntimeInfo>('session.cwd.set', {
|
||||
session_id: activeSessionId,
|
||||
cwd: trimmed
|
||||
})
|
||||
|
||||
setCurrentCwd(info.cwd || trimmed)
|
||||
setCurrentBranch(info.branch || '')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
if (!message.includes('unknown method')) {
|
||||
notifyError(err, 'Working directory change failed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await persistGlobal()
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Working directory saved',
|
||||
message: 'Restart the desktop backend to apply cwd changes to this active session.'
|
||||
})
|
||||
} catch (fallbackErr) {
|
||||
notifyError(fallbackErr, 'Working directory change failed')
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway]
|
||||
)
|
||||
|
||||
const browseSessionCwd = useCallback(async () => {
|
||||
const paths = await window.hermesDesktop?.selectPaths({
|
||||
title: 'Change working directory',
|
||||
defaultPath: currentCwd || undefined,
|
||||
directories: true,
|
||||
multiple: false
|
||||
})
|
||||
|
||||
if (paths?.[0]) {
|
||||
await changeSessionCwd(paths[0])
|
||||
}
|
||||
}, [changeSessionCwd, currentCwd])
|
||||
|
||||
const selectModel = useCallback(
|
||||
(selection: { provider: string; model: string; persistGlobal: boolean }) => {
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, selection.persistGlobal || !activeSessionId)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${
|
||||
selection.persistGlobal ? ' --global' : ''
|
||||
}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Model switch failed')
|
||||
}
|
||||
})()
|
||||
},
|
||||
[activeSessionId, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
const refreshHermesConfig = useCallback(async () => {
|
||||
try {
|
||||
const [config, defaults] = await Promise.all([getHermesConfig(), getHermesConfigDefaults().catch(() => ({}))])
|
||||
|
||||
const configPersonality = normalizePersonalityValue(
|
||||
typeof config.display?.personality === 'string' ? config.display.personality : ''
|
||||
)
|
||||
|
||||
setIntroPersonality(configPersonality)
|
||||
setCurrentPersonality(prev => (activeSessionIdRef.current ? prev || configPersonality : configPersonality))
|
||||
setAvailablePersonalities([
|
||||
...new Set([
|
||||
'none',
|
||||
...BUILTIN_PERSONALITIES,
|
||||
...personalityNamesFromConfig(defaults),
|
||||
...personalityNamesFromConfig(config)
|
||||
])
|
||||
])
|
||||
|
||||
const cwd = (config.terminal?.cwd ?? '').trim()
|
||||
|
||||
if (cwd && cwd !== '.') {
|
||||
setCurrentCwd(prev => prev || cwd)
|
||||
}
|
||||
|
||||
setVoiceMaxRecordingSeconds(normalizeRecordingLimit(config.voice?.max_recording_seconds))
|
||||
setSttEnabled(config.stt?.enabled !== false)
|
||||
} catch {
|
||||
// Config is nice-to-have for the empty-state copy; the chat still works.
|
||||
}
|
||||
}, [activeSessionIdRef])
|
||||
|
||||
const selectPersonality = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = (name || '').trim() || 'none'
|
||||
const normalized = normalizePersonalityValue(trimmed)
|
||||
setCurrentPersonality(normalized)
|
||||
|
||||
if (!activeSessionId) {
|
||||
setIntroPersonality(normalized)
|
||||
}
|
||||
|
||||
try {
|
||||
await (activeSessionId
|
||||
? requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/personality ${trimmed}`
|
||||
})
|
||||
: requestGateway('config.set', {
|
||||
key: 'personality',
|
||||
value: trimmed
|
||||
}))
|
||||
|
||||
if (!activeSessionId) {
|
||||
void refreshHermesConfig()
|
||||
}
|
||||
} catch (err) {
|
||||
void refreshHermesConfig()
|
||||
notifyError(err, 'Personality change failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, refreshHermesConfig, requestGateway]
|
||||
)
|
||||
|
||||
const { addContextRefAttachment, pasteClipboardImage, pickContextPaths, pickImages, removeAttachment } =
|
||||
useComposerActions({
|
||||
activeSessionId,
|
||||
currentCwd,
|
||||
requestGateway
|
||||
})
|
||||
|
||||
const hydrateFromStoredSession = useCallback(
|
||||
async (
|
||||
attempts = 1,
|
||||
storedSessionId = selectedStoredSessionIdRef.current,
|
||||
runtimeSessionId = activeSessionIdRef.current
|
||||
) => {
|
||||
if (!storedSessionId || !runtimeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < Math.max(1, attempts); index += 1) {
|
||||
try {
|
||||
const latest = await getSessionMessages(storedSessionId)
|
||||
updateSessionState(
|
||||
runtimeSessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: toChatMessages(latest.messages)
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Best-effort fallback when live stream payloads are empty.
|
||||
}
|
||||
|
||||
if (index < attempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const { handleGatewayEvent } = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
const {
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
removeSession,
|
||||
resumeSession,
|
||||
selectSidebarItem,
|
||||
startFreshSessionDraft
|
||||
} = useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'settings') {
|
||||
settingsReturnPathRef.current = `${location.pathname}${location.search}${location.hash}`
|
||||
}
|
||||
}, [currentView, location.hash, location.pathname, location.search])
|
||||
|
||||
const closeSettingsToPreviousRoute = useCallback(() => {
|
||||
navigate(settingsReturnPathRef.current || NEW_CHAT_ROUTE, { replace: true })
|
||||
}, [navigate])
|
||||
|
||||
const branchInNewChat = useCallback(
|
||||
async (messageId: string) => {
|
||||
const branched = await branchCurrentSession(messageId)
|
||||
|
||||
if (branched) {
|
||||
await refreshSessions().catch(() => undefined)
|
||||
}
|
||||
},
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent,
|
||||
onConnectionReady: setBootConnection,
|
||||
onGatewayReady: setBootGateway,
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open') {
|
||||
void refreshCurrentModel()
|
||||
void refreshSessions().catch(() => undefined)
|
||||
}
|
||||
}, [gatewayState, refreshCurrentModel, refreshSessions])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState === 'open' && activeSessionId) {
|
||||
void refreshContextSuggestions()
|
||||
}
|
||||
}, [activeSessionId, gatewayState, refreshContextSuggestions])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
if (routedSessionId) {
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(routedSessionId)
|
||||
|
||||
const alreadyActive =
|
||||
routedSessionId === selectedStoredSessionIdRef.current &&
|
||||
Boolean(cachedRuntimeId) &&
|
||||
cachedRuntimeId === activeSessionIdRef.current
|
||||
|
||||
if (!alreadyActive) {
|
||||
void resumeSession(routedSessionId, true)
|
||||
}
|
||||
} else if (isNewChatRoute(location.pathname) && (selectedStoredSessionId || activeSessionId || !freshDraftReady)) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
}, [
|
||||
activeSessionIdRef,
|
||||
activeSessionId,
|
||||
currentView,
|
||||
freshDraftReady,
|
||||
gatewayState,
|
||||
location.pathname,
|
||||
resumeSession,
|
||||
routedSessionId,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft
|
||||
])
|
||||
|
||||
const sidebar = (
|
||||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onNavigate={selectSidebarItem}
|
||||
onRefreshSessions={() => void refreshSessions()}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)
|
||||
|
||||
const overlays = (
|
||||
<>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
|
||||
{settingsOpen && (
|
||||
<SettingsView
|
||||
onClose={closeSettingsToPreviousRoute}
|
||||
onConfigSaved={() => {
|
||||
void refreshHermesConfig()
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const chatView = (
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||
onAddContextRef={addContextRefAttachment}
|
||||
onAddUrl={url => addContextRefAttachment(`@url:${url}`, url)}
|
||||
onBranchInNewChat={messageId => void branchInNewChat(messageId)}
|
||||
onBrowseCwd={() => void browseSessionCwd()}
|
||||
onCancel={() => void cancelRun()}
|
||||
onChangeCwd={cwd => void changeSessionCwd(cwd)}
|
||||
onDeleteSelectedSession={() => {
|
||||
if (selectedStoredSessionId) {
|
||||
void removeSession(selectedStoredSessionId)
|
||||
}
|
||||
}}
|
||||
onOpenModelPicker={() => setModelPickerOpen(true)}
|
||||
onPasteClipboardImage={() => void pasteClipboardImage()}
|
||||
onPickFiles={() => void pickContextPaths('file')}
|
||||
onPickFolders={() => void pickContextPaths('folder')}
|
||||
onPickImages={() => void pickImages()}
|
||||
onReload={reloadFromMessage}
|
||||
onRemoveAttachment={id => void removeAttachment(id)}
|
||||
onSelectPersonality={name => void selectPersonality(name)}
|
||||
onSubmit={text => void submitText(text)}
|
||||
onThreadMessagesChange={handleThreadMessagesChange}
|
||||
onToggleSelectedPin={toggleSelectedPin}
|
||||
onTranscribeAudio={transcribeVoiceAudio}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
inspectorWidth={SESSION_INSPECTOR_WIDTH}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
rightRailOpen={chatOpen}
|
||||
settingsOpen={settingsOpen}
|
||||
sidebar={sidebar}
|
||||
titlebarActions={titlebarActions}
|
||||
>
|
||||
<Routes>
|
||||
<Route element={chatView} index />
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route element={<SkillsView setTitlebarActions={setTitlebarActions} />} path="skills" />
|
||||
<Route element={<ArtifactsView setTitlebarActions={setTitlebarActions} />} path="artifacts" />
|
||||
<Route element={null} path="settings" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="new" />
|
||||
<Route element={<LegacySessionRedirect />} path="sessions/:sessionId" />
|
||||
<Route element={<Navigate replace to={NEW_CHAT_ROUTE} />} path="*" />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacySessionRedirect() {
|
||||
const { sessionId } = useParams()
|
||||
|
||||
return <Navigate replace to={sessionId ? sessionRoute(sessionId) : NEW_CHAT_ROUTE} />
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setConnection, setGatewayState, setSessionsLoading } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
interface GatewayBootOptions {
|
||||
handleGatewayEvent: (event: RpcEvent) => void
|
||||
onConnectionReady: (connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => void
|
||||
onGatewayReady: (gateway: HermesGateway | null) => void
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useGatewayBoot({
|
||||
handleGatewayEvent,
|
||||
onConnectionReady,
|
||||
onGatewayReady,
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
}: GatewayBootOptions) {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
setSessionsLoading(false)
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}
|
||||
|
||||
const gateway = new HermesGateway()
|
||||
onGatewayReady(gateway)
|
||||
|
||||
const offState = gateway.onState(st => void setGatewayState(st))
|
||||
const offEvent = gateway.onEvent(handleGatewayEvent)
|
||||
|
||||
const offExit = desktop.onBackendExit(() => {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Backend stopped',
|
||||
message: 'Hermes background process exited.',
|
||||
durationMs: 0
|
||||
})
|
||||
})
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
onConnectionReady(conn)
|
||||
setConnection(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await refreshSessions()
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
notifyError(err, 'Desktop boot failed')
|
||||
setSessionsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
offState()
|
||||
offEvent()
|
||||
offExit()
|
||||
gateway.close()
|
||||
onConnectionReady(null)
|
||||
onGatewayReady(null)
|
||||
}
|
||||
}, [
|
||||
handleGatewayEvent,
|
||||
onConnectionReady,
|
||||
onGatewayReady,
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewayRef = useRef<HermesGateway | null>(null)
|
||||
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
|
||||
null
|
||||
)
|
||||
const gatewayStateRef = useRef(gatewayState)
|
||||
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
gatewayStateRef.current = gatewayState
|
||||
}, [gatewayState])
|
||||
|
||||
const ensureGatewayOpen = useCallback(async () => {
|
||||
const existing = gatewayRef.current
|
||||
|
||||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (gatewayStateRef.current === 'open') {
|
||||
return existing
|
||||
}
|
||||
|
||||
if (reconnectingRef.current) {
|
||||
return reconnectingRef.current
|
||||
}
|
||||
|
||||
reconnectingRef.current = (async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
const conn = connectionRef.current || (await desktop.getConnection())
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
|
||||
try {
|
||||
await existing.connect(conn.wsUrl)
|
||||
|
||||
return existing
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
reconnectingRef.current = null
|
||||
}
|
||||
})()
|
||||
|
||||
return reconnectingRef.current
|
||||
}, [])
|
||||
|
||||
const requestGateway = useCallback(
|
||||
async <T>(method: string, params: Record<string, unknown> = {}) => {
|
||||
const gateway = gatewayRef.current
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.request<T>(method, params)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!/not connected|connection closed/i.test(message)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const recovered = await ensureGatewayOpen()
|
||||
|
||||
if (!recovered) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return recovered.request<T>(method, params)
|
||||
}
|
||||
},
|
||||
[ensureGatewayOpen]
|
||||
)
|
||||
|
||||
return { connectionRef, gatewayRef, requestGateway }
|
||||
}
|
||||
+1
-1660
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
export const SESSION_ROUTE_PREFIX = '#/sessions/'
|
||||
export const NEW_CHAT_ROUTE = '#/new'
|
||||
export const SETTINGS_ROUTE = '#/settings'
|
||||
export const SKILLS_ROUTE = '#/skills'
|
||||
export const ARTIFACTS_ROUTE = '#/artifacts'
|
||||
export const SESSION_ROUTE_PREFIX = '/'
|
||||
export const NEW_CHAT_ROUTE = '/'
|
||||
export const SETTINGS_ROUTE = '/settings'
|
||||
export const SKILLS_ROUTE = '/skills'
|
||||
export const ARTIFACTS_ROUTE = '/artifacts'
|
||||
|
||||
export type AppView = 'chat' | 'settings' | 'skills' | 'artifacts'
|
||||
|
||||
@@ -10,53 +10,42 @@ export type AppRouteId = 'new' | 'settings' | 'skills' | 'artifacts'
|
||||
|
||||
export interface AppRoute {
|
||||
id: AppRouteId
|
||||
hash: string
|
||||
path: string
|
||||
view: AppView
|
||||
}
|
||||
|
||||
export const APP_ROUTES = [
|
||||
{ id: 'new', hash: NEW_CHAT_ROUTE, view: 'chat' },
|
||||
{ id: 'settings', hash: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'skills', hash: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'artifacts', hash: ARTIFACTS_ROUTE, view: 'artifacts' }
|
||||
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
|
||||
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
|
||||
{ id: 'skills', path: SKILLS_ROUTE, view: 'skills' },
|
||||
{ id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }
|
||||
] as const satisfies readonly AppRoute[]
|
||||
|
||||
const APP_VIEW_BY_HASH = new Map<string, AppView>(APP_ROUTES.map(route => [route.hash, route.view]))
|
||||
const APP_VIEW_BY_PATH = new Map<string, AppView>(APP_ROUTES.map(route => [route.path, route.view]))
|
||||
const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => route.path))
|
||||
|
||||
export function currentRouteHash(): string {
|
||||
return window.location.hash || NEW_CHAT_ROUTE
|
||||
export function isNewChatRoute(pathname: string): boolean {
|
||||
return pathname === NEW_CHAT_ROUTE
|
||||
}
|
||||
|
||||
export function routeSessionId(hash = currentRouteHash()): string | null {
|
||||
if (!hash.startsWith(SESSION_ROUTE_PREFIX)) {
|
||||
export function routeSessionId(pathname: string): string | null {
|
||||
if (!pathname.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(pathname)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = hash.slice(SESSION_ROUTE_PREFIX.length)
|
||||
const id = pathname.slice(SESSION_ROUTE_PREFIX.length)
|
||||
|
||||
return id ? decodeURIComponent(id) : null
|
||||
return id && !id.includes('/') ? decodeURIComponent(id) : null
|
||||
}
|
||||
|
||||
export function writeRoute(hash: string, replace = false) {
|
||||
if (window.location.hash === hash) {
|
||||
return
|
||||
export function sessionRoute(sessionId: string): string {
|
||||
return `${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`
|
||||
}
|
||||
|
||||
export function appViewForPath(pathname: string): AppView {
|
||||
if (isNewChatRoute(pathname) || routeSessionId(pathname)) {
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${hash}`
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
} else {
|
||||
window.history.pushState(null, '', nextUrl)
|
||||
}
|
||||
return APP_VIEW_BY_PATH.get(pathname) ?? 'chat'
|
||||
}
|
||||
|
||||
export function writeSessionRoute(sessionId: string, replace = false) {
|
||||
writeRoute(`${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}`, replace)
|
||||
}
|
||||
|
||||
export function appViewForHash(hash = currentRouteHash()): AppView {
|
||||
return APP_VIEW_BY_HASH.get(hash) ?? 'chat'
|
||||
}
|
||||
|
||||
export const currentAppView = appViewForHash
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
|
||||
import {
|
||||
appendReasoningPart,
|
||||
appendTextPart,
|
||||
type ChatMessage,
|
||||
type ChatMessagePart,
|
||||
chatMessageText,
|
||||
type GatewayEventPayload,
|
||||
reasoningPart,
|
||||
textPart,
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { notify } from '@/store/notifications'
|
||||
import {
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
interface MessageStreamOptions {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
hydrateFromStoredSession: (
|
||||
attempts?: number,
|
||||
storedSessionId?: string | null,
|
||||
runtimeSessionId?: string | null
|
||||
) => Promise<void>
|
||||
queryClient: QueryClient
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
export function useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
refreshSessions,
|
||||
updateSessionState
|
||||
}: MessageStreamOptions) {
|
||||
// Patch the in-flight assistant message (or seed it). Centralises the
|
||||
// streamId/groupId bookkeeping every event callback would otherwise repeat.
|
||||
const mutateStream = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
transform: (parts: ChatMessagePart[], message: ChatMessage) => ChatMessagePart[],
|
||||
seed: () => ChatMessagePart[],
|
||||
opts: {
|
||||
sync?: boolean
|
||||
pending?: (message: ChatMessage) => boolean
|
||||
} = {}
|
||||
) => {
|
||||
const apply = () => {
|
||||
updateSessionState(sessionId, state => {
|
||||
// After a stop, drop any late deltas / tool events for the
|
||||
// cancelled turn so they don't keep growing the (now finalized)
|
||||
// assistant bubble or, worse, seed a brand-new bubble that
|
||||
// appears to belong to the next user message.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
}
|
||||
|
||||
const streamId = state.streamId ?? `assistant-stream-${Date.now()}`
|
||||
const groupId = state.pendingBranchGroup ?? undefined
|
||||
const prev = state.messages
|
||||
let nextMessages: ChatMessage[]
|
||||
|
||||
if (!prev.some(m => m.id === streamId)) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: streamId,
|
||||
role: 'assistant',
|
||||
parts: seed(),
|
||||
pending: true,
|
||||
branchGroupId: groupId
|
||||
}
|
||||
]
|
||||
} else {
|
||||
nextMessages = prev.map(m =>
|
||||
m.id === streamId
|
||||
? {
|
||||
...m,
|
||||
parts: transform(m.parts, m),
|
||||
pending: opts.pending ? opts.pending(m) : true
|
||||
}
|
||||
: m
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages: nextMessages,
|
||||
streamId,
|
||||
sawAssistantPayload: true,
|
||||
awaitingResponse: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
opts.sync ? flushSync(apply) : apply()
|
||||
},
|
||||
[updateSessionState]
|
||||
)
|
||||
|
||||
const appendAssistantDelta = useCallback(
|
||||
(sessionId: string, delta: string) => {
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => appendTextPart(parts, delta),
|
||||
() => [textPart(delta)],
|
||||
{ sync: true }
|
||||
)
|
||||
},
|
||||
[mutateStream]
|
||||
)
|
||||
|
||||
const appendReasoningDelta = useCallback(
|
||||
(sessionId: string, delta: string, replace = false) => {
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
|
||||
mutateStream(
|
||||
sessionId,
|
||||
(parts, message) => {
|
||||
if (replace && chatMessageText(message).trim()) {
|
||||
return parts
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
return [...parts.filter(part => part.type !== 'reasoning'), reasoningPart(delta)]
|
||||
}
|
||||
|
||||
return appendReasoningPart(parts, delta)
|
||||
},
|
||||
() => [reasoningPart(delta)],
|
||||
{ sync: true }
|
||||
)
|
||||
},
|
||||
[mutateStream]
|
||||
)
|
||||
|
||||
const upsertToolCall = useCallback(
|
||||
(sessionId: string, payload: GatewayEventPayload | undefined, phase: 'running' | 'complete') => {
|
||||
mutateStream(
|
||||
sessionId,
|
||||
parts => upsertToolPart(parts, payload, phase),
|
||||
() => upsertToolPart([], payload, phase),
|
||||
{ pending: m => phase !== 'complete' || (m.pending ?? false) }
|
||||
)
|
||||
},
|
||||
[mutateStream]
|
||||
)
|
||||
|
||||
const completeAssistantMessage = useCallback(
|
||||
(sessionId: string, text: string) => {
|
||||
let shouldHydrate = false
|
||||
|
||||
const completedState = updateSessionState(sessionId, state => {
|
||||
// Late completion from an already-cancelled turn: cancelRun has
|
||||
// already finalized the bubble and added the [interrupted] marker;
|
||||
// re-running the dedupe below would erase that marker and replace
|
||||
// the partial with the (just-cancelled) full text.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
}
|
||||
|
||||
const streamId = state.streamId
|
||||
const finalText = text.trim()
|
||||
const normalize = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
const dedupeReference = normalize(finalText)
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const kept = parts.filter(part => {
|
||||
if (part.type === 'text') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (part.type !== 'reasoning' || !dedupeReference) {
|
||||
return true
|
||||
}
|
||||
|
||||
const r = normalize(part.text)
|
||||
|
||||
return !(r && (dedupeReference.startsWith(r) || r.startsWith(dedupeReference)))
|
||||
})
|
||||
|
||||
return text ? [...kept, textPart(text)] : kept
|
||||
}
|
||||
|
||||
const completeMessage = (message: ChatMessage): ChatMessage => ({
|
||||
...message,
|
||||
parts: replaceTextPart(message.parts),
|
||||
pending: false
|
||||
})
|
||||
|
||||
const prev = state.messages
|
||||
let nextMessages = prev
|
||||
|
||||
if (streamId && prev.some(m => m.id === streamId)) {
|
||||
nextMessages = prev.map(m => (m.id === streamId ? completeMessage(m) : m))
|
||||
} else {
|
||||
const fallbackIndex = [...prev]
|
||||
.reverse()
|
||||
.findIndex(message => message.role === 'assistant' && !message.hidden)
|
||||
|
||||
if (fallbackIndex >= 0) {
|
||||
const index = prev.length - 1 - fallbackIndex
|
||||
const existing = prev[index]
|
||||
const existingText = chatMessageText(existing).trim()
|
||||
|
||||
if (existing.pending || (finalText && existingText === finalText)) {
|
||||
nextMessages = prev.map((message, messageIndex) =>
|
||||
messageIndex === index ? completeMessage(message) : message
|
||||
)
|
||||
} else if (text) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [textPart(text)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
} else if (text) {
|
||||
nextMessages = [
|
||||
...prev,
|
||||
{
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
parts: [textPart(text)],
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
shouldHydrate = !state.sawAssistantPayload || !finalText
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages: nextMessages,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
}
|
||||
})
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
|
||||
if (shouldHydrate) {
|
||||
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
if (document.hidden && sessionId === activeSessionIdRef.current) {
|
||||
void window.hermesDesktop?.notify({
|
||||
title: 'Hermes finished',
|
||||
body: text.slice(0, 140) || 'The response is ready.'
|
||||
})
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const handleGatewayEvent = useCallback(
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
const explicitSid = event.session_id || ''
|
||||
const sessionId = explicitSid || activeSessionIdRef.current
|
||||
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
|
||||
|
||||
if (event.type === 'gateway.ready') {
|
||||
return
|
||||
} else if (event.type === 'session.info') {
|
||||
// Apply session-scoped fields when the event targets the active
|
||||
// session, OR when it's a global broadcast and we have no session.
|
||||
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
|
||||
const modelChanged = typeof payload?.model === 'string'
|
||||
const providerChanged = typeof payload?.provider === 'string'
|
||||
|
||||
if (apply) {
|
||||
if (modelChanged) {
|
||||
setCurrentModel(payload!.model || '')
|
||||
}
|
||||
|
||||
if (providerChanged) {
|
||||
setCurrentProvider(payload!.provider || '')
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
setCurrentCwd(payload.cwd)
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
setCurrentBranch(payload.branch)
|
||||
}
|
||||
|
||||
if (typeof payload?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(payload.personality))
|
||||
}
|
||||
}
|
||||
|
||||
void refreshHermesConfig()
|
||||
|
||||
if (modelChanged || providerChanged) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'message.start') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamStart')
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
}))
|
||||
} else if (event.type === 'message.delta') {
|
||||
if (sessionId) {
|
||||
appendAssistantDelta(sessionId, coerceGatewayText(payload?.text))
|
||||
}
|
||||
} else if (event.type === 'thinking.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
}
|
||||
} else if (event.type === 'reasoning.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceGatewayText(payload?.text))
|
||||
}
|
||||
} else if (event.type === 'reasoning.available') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceGatewayText(payload?.text), true)
|
||||
}
|
||||
} else if (event.type === 'message.complete') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
upsertToolCall(sessionId, payload, 'running')
|
||||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
upsertToolCall(sessionId, payload, 'complete')
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
if (isActiveEvent) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Hermes error',
|
||||
message: payload?.message || 'Hermes reported an error'
|
||||
})
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
awaitingResponse: false,
|
||||
busy: false
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
appendAssistantDelta,
|
||||
appendReasoningDelta,
|
||||
activeSessionIdRef,
|
||||
completeAssistantMessage,
|
||||
queryClient,
|
||||
refreshHermesConfig,
|
||||
updateSessionState,
|
||||
upsertToolCall
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
appendAssistantDelta,
|
||||
appendReasoningDelta,
|
||||
completeAssistantMessage,
|
||||
handleGatewayEvent,
|
||||
upsertToolCall
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { ThreadMessage } from '@assistant-ui/react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import {
|
||||
attachmentDisplayText,
|
||||
INTERRUPTED_MARKER,
|
||||
parseCommandDispatch,
|
||||
parseSlashCommand,
|
||||
SLASH_COMMAND_RE
|
||||
} from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $composerAttachments, clearComposerAttachments } from '@/store/composer'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $busy, $messages, setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, SlashExecResponse } from '../../types'
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.addEventListener('load', () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error('Could not read recorded audio'))
|
||||
}
|
||||
})
|
||||
reader.addEventListener('error', () => reject(reader.error || new Error('Could not read recorded audio')))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
interface PromptActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
createBackendSessionForSend: () => Promise<string | null>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
sttEnabled: boolean
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
export function usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
}: PromptActionsOptions) {
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||
const body = text.trim()
|
||||
|
||||
if (!body) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role,
|
||||
parts: [textPart(body)]
|
||||
}
|
||||
]
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
},
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const submitPromptText = useCallback(
|
||||
async (rawText: string) => {
|
||||
const visibleText = rawText.trim()
|
||||
const attachments = $composerAttachments.get()
|
||||
const contextRefs = attachments
|
||||
.map(attachment => attachment.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
const hasImageAttachment = attachments.some(attachment => attachment.kind === 'image')
|
||||
const displayRefs = attachments.map(attachmentDisplayText).filter(Boolean).join('\n')
|
||||
const text =
|
||||
[contextRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImageAttachment ? 'What do you see in this image?' : '')
|
||||
|
||||
if (!text || busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
parts: [
|
||||
textPart(
|
||||
[displayRefs, visibleText].filter(Boolean).join('\n\n') ||
|
||||
attachments.map(attachment => attachment.label).join(', ')
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
const sessionId = activeSessionId ? activeSessionId : await createBackendSessionForSend()
|
||||
|
||||
if (!sessionId) {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Session unavailable',
|
||||
message: 'Could not create a new session'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => ({
|
||||
...state,
|
||||
messages: [...state.messages, userMessage],
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
clearComposerAttachments()
|
||||
} catch (err) {
|
||||
busyRef.current = false
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: state.messages.filter(message => message.id !== userMessage.id),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}))
|
||||
notifyError(err, 'Prompt failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, createBackendSessionForSend, requestGateway, selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const executeSlashCommand = useCallback(
|
||||
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
|
||||
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
|
||||
const command = commandText.trim()
|
||||
const { name, arg } = parseSlashCommand(command)
|
||||
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
|
||||
|
||||
if (!sessionId) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Session unavailable',
|
||||
message: 'Could not create a new session'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', text)
|
||||
|
||||
if (recordInput) {
|
||||
appendSessionTextMessage(sessionId, 'user', command)
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
renderSlashOutput('empty slash command')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SlashExecResponse>('slash.exec', {
|
||||
session_id: sessionId,
|
||||
command: command.replace(/^\/+/, '')
|
||||
})
|
||||
const body = result?.output || `/${name}: no output`
|
||||
renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Fall back to command.dispatch for skill/send/alias directives.
|
||||
}
|
||||
|
||||
try {
|
||||
const dispatch = parseCommandDispatch(
|
||||
await requestGateway<unknown>('command.dispatch', {
|
||||
session_id: sessionId,
|
||||
name,
|
||||
arg
|
||||
})
|
||||
)
|
||||
|
||||
if (!dispatch) {
|
||||
renderSlashOutput('error: invalid response: command.dispatch')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (dispatch.type === 'exec' || dispatch.type === 'plugin') {
|
||||
renderSlashOutput(dispatch.output ?? '(no output)')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (dispatch.type === 'alias') {
|
||||
await runSlash(`/${dispatch.target}${arg ? ` ${arg}` : ''}`, sessionId, false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const message = ('message' in dispatch ? dispatch.message : '')?.trim() ?? ''
|
||||
|
||||
if (!message) {
|
||||
renderSlashOutput(
|
||||
`/${name}: ${dispatch.type === 'skill' ? 'skill payload missing message' : 'empty message'}`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (dispatch.type === 'skill') {
|
||||
renderSlashOutput(`⚡ loading skill: ${dispatch.name}`)
|
||||
}
|
||||
|
||||
if (busyRef.current) {
|
||||
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await submitPromptText(message)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
|
||||
},
|
||||
[activeSessionIdRef, appendSessionTextMessage, createBackendSessionForSend, requestGateway, submitPromptText]
|
||||
)
|
||||
|
||||
const submitText = useCallback(
|
||||
async (rawText: string) => {
|
||||
const visibleText = rawText.trim()
|
||||
const attachments = $composerAttachments.get()
|
||||
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) {
|
||||
triggerHaptic('selection')
|
||||
await executeSlashCommand(visibleText)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await submitPromptText(rawText)
|
||||
},
|
||||
[executeSlashCommand, submitPromptText]
|
||||
)
|
||||
|
||||
const transcribeVoiceAudio = useCallback(
|
||||
async (audio: Blob) => {
|
||||
if (!sttEnabled) {
|
||||
throw new Error('Speech-to-text is disabled in settings.')
|
||||
}
|
||||
|
||||
const dataUrl = await blobToDataUrl(audio)
|
||||
const result = await transcribeAudio(dataUrl, audio.type)
|
||||
|
||||
return result.transcript
|
||||
},
|
||||
[sttEnabled]
|
||||
)
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(activeSessionId, state => {
|
||||
const streamId = state.streamId
|
||||
const messages = streamId
|
||||
? state.messages.map(message =>
|
||||
message.id === streamId
|
||||
? {
|
||||
...message,
|
||||
parts: chatMessageText(message).trim()
|
||||
? appendTextPart(message.parts, INTERRUPTED_MARKER)
|
||||
: [...message.parts, textPart(INTERRUPTED_MARKER.trim())],
|
||||
pending: false
|
||||
}
|
||||
: message
|
||||
)
|
||||
: state.messages
|
||||
|
||||
return {
|
||||
...state,
|
||||
messages,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: true
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: activeSessionId })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Stop failed')
|
||||
}
|
||||
}, [activeSessionId, requestGateway, updateSessionState])
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
if (!activeSessionId || $busy.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const parentIndex = parentId ? messages.findIndex(message => message.id === parentId) : messages.length - 1
|
||||
const userIndex =
|
||||
parentIndex >= 0
|
||||
? [...messages.slice(0, parentIndex + 1)].reverse().findIndex(message => message.role === 'user')
|
||||
: -1
|
||||
|
||||
if (userIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const absoluteUserIndex = parentIndex - userIndex
|
||||
const userMessage = messages[absoluteUserIndex]
|
||||
const userText = userMessage ? chatMessageText(userMessage).trim() : ''
|
||||
|
||||
if (!userText) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetAssistant =
|
||||
parentId && messages[parentIndex]?.role === 'assistant'
|
||||
? messages[parentIndex]
|
||||
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
|
||||
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
|
||||
|
||||
clearNotifications()
|
||||
updateSessionState(activeSessionId, state => {
|
||||
const nextUserIndex = state.messages.findIndex(
|
||||
(message, index) => index > absoluteUserIndex && message.role === 'user'
|
||||
)
|
||||
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: branchGroupId,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
messages: [
|
||||
...state.messages.slice(0, absoluteUserIndex + 1),
|
||||
...state.messages
|
||||
.slice(absoluteUserIndex + 1, end)
|
||||
.map(message => (message.role === 'assistant' ? { ...message, branchGroupId, hidden: true } : message))
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: activeSessionId, text: userText })
|
||||
} catch (err) {
|
||||
updateSessionState(activeSessionId, state => ({
|
||||
...state,
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}))
|
||||
notifyError(err, 'Regenerate failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
const handleThreadMessagesChange = useCallback(
|
||||
(nextMessages: readonly ThreadMessage[]) => {
|
||||
const visibleIds = new Set(nextMessages.map(message => message.id))
|
||||
const sessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
messages: state.messages.map(message =>
|
||||
message.role === 'assistant' && message.branchGroupId
|
||||
? { ...message, hidden: !visibleIds.has(message.id) }
|
||||
: message
|
||||
)
|
||||
}))
|
||||
},
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
return { cancelRun, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio }
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { chatMessageText, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$messages,
|
||||
$sessions,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setFreshDraftReady,
|
||||
setIntroSeed,
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions
|
||||
} from '@/store/session'
|
||||
import type { SessionCreateResponse, SessionResumeResponse } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../types'
|
||||
|
||||
interface SessionActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState
|
||||
navigate: NavigateFunction
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
||||
selectedStoredSessionId: string | null
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
||||
syncSessionStateToView: (sessionId: string, state: ClientSessionState) => void
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionId,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
}: SessionActionsOptions) {
|
||||
const resumeRequestRef = useRef(0)
|
||||
|
||||
const startFreshSessionDraft = useCallback(
|
||||
(replaceRoute = false) => {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
clearNotifications()
|
||||
setIntroSeed(seed => seed + 1)
|
||||
navigate(NEW_CHAT_ROUTE, { replace: replaceRoute })
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
setSelectedStoredSessionId(null)
|
||||
selectedStoredSessionIdRef.current = null
|
||||
setMessages([])
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
setFreshDraftReady(true)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (): Promise<string | null> => {
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96 })
|
||||
setActiveSessionId(created.session_id)
|
||||
activeSessionIdRef.current = created.session_id
|
||||
ensureSessionState(created.session_id, created.stored_session_id ?? null)
|
||||
|
||||
if (created.stored_session_id) {
|
||||
setSelectedStoredSessionId(created.stored_session_id)
|
||||
selectedStoredSessionIdRef.current = created.stored_session_id
|
||||
navigate(sessionRoute(created.stored_session_id), { replace: true })
|
||||
}
|
||||
|
||||
if (created.info?.model) {
|
||||
setCurrentModel(created.info.model)
|
||||
}
|
||||
|
||||
if (created.info?.provider) {
|
||||
setCurrentProvider(created.info.provider)
|
||||
}
|
||||
|
||||
if (created.info?.cwd) {
|
||||
setCurrentCwd(created.info.cwd)
|
||||
}
|
||||
|
||||
if (created.info?.branch) {
|
||||
setCurrentBranch(created.info.branch)
|
||||
}
|
||||
|
||||
if (typeof created.info?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(created.info.personality))
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
}, [activeSessionIdRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef])
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
(item: SidebarNavItem) => {
|
||||
if (item.action === 'new-session') {
|
||||
startFreshSessionDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (item.route) {
|
||||
navigate(item.route)
|
||||
}
|
||||
},
|
||||
[navigate, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
navigate(SETTINGS_ROUTE)
|
||||
}, [navigate])
|
||||
|
||||
const closeSettings = useCallback(() => {
|
||||
if (selectedStoredSessionId) {
|
||||
navigate(sessionRoute(selectedStoredSessionId))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
navigate(NEW_CHAT_ROUTE)
|
||||
}, [navigate, selectedStoredSessionId])
|
||||
|
||||
const resumeSession = useCallback(
|
||||
async (storedSessionId: string, replaceRoute = false) => {
|
||||
const requestId = resumeRequestRef.current + 1
|
||||
resumeRequestRef.current = requestId
|
||||
|
||||
const isCurrentResume = () =>
|
||||
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
|
||||
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
setFreshDraftReady(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setActiveSessionId(cachedRuntimeId)
|
||||
activeSessionIdRef.current = cachedRuntimeId
|
||||
syncSessionStateToView(cachedRuntimeId, cachedState)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setMessages([])
|
||||
|
||||
try {
|
||||
let resumeApplied = false
|
||||
|
||||
const storedMessagesPromise = getSessionMessages(storedSessionId)
|
||||
.then(storedMessages => {
|
||||
if (!resumeApplied && isCurrentResume()) {
|
||||
setMessages(toChatMessages(storedMessages.messages))
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96
|
||||
})
|
||||
|
||||
void storedMessagesPromise
|
||||
|
||||
const resumed = await resumePromise
|
||||
|
||||
resumeApplied = true
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
updateSessionState(
|
||||
resumed.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
messages: toChatMessages(resumed.messages),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
if (resumed.info?.model) {
|
||||
setCurrentModel(resumed.info.model)
|
||||
}
|
||||
|
||||
if (resumed.info?.provider) {
|
||||
setCurrentProvider(resumed.info.provider)
|
||||
}
|
||||
|
||||
if (resumed.info?.cwd) {
|
||||
setCurrentCwd(resumed.info.cwd)
|
||||
}
|
||||
|
||||
setCurrentBranch(resumed.info?.branch || '')
|
||||
|
||||
if (typeof resumed.info?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(resumed.info.personality))
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
const fallback = await getSessionMessages(storedSessionId)
|
||||
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(toChatMessages(fallback.messages))
|
||||
notifyError(err, 'Resume failed')
|
||||
} finally {
|
||||
if (isCurrentResume()) {
|
||||
busyRef.current = false
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
requestGateway,
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const branchCurrentSession = useCallback(async (messageId?: string): Promise<boolean> => {
|
||||
const sourceSessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sourceSessionId) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Nothing to branch',
|
||||
message: 'Start or resume a chat before branching.'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (busyRef.current) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Session busy',
|
||||
message: 'Stop the current turn before branching this chat.'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const currentMessages = $messages.get()
|
||||
const targetIndex = messageId ? currentMessages.findIndex(message => message.id === messageId) : -1
|
||||
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
|
||||
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
|
||||
|
||||
const branchMessages = currentMessages
|
||||
.slice(branchStart, branchEnd)
|
||||
.map(message => ({
|
||||
content: chatMessageText(message),
|
||||
source: message,
|
||||
role: message.role
|
||||
}))
|
||||
.filter(message => message.content.trim() && ['assistant', 'system', 'user'].includes(message.role))
|
||||
|
||||
if (!branchMessages.length) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Nothing to branch',
|
||||
message: 'This message has no text to branch from.'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
clearNotifications()
|
||||
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
title: 'Branch'
|
||||
})
|
||||
|
||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||
|
||||
setFreshDraftReady(false)
|
||||
ensureSessionState(branched.session_id, routedSessionId)
|
||||
setActiveSessionId(branched.session_id)
|
||||
activeSessionIdRef.current = branched.session_id
|
||||
updateSessionState(
|
||||
branched.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
messages: branchMessages.map(({ source }) => source),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}),
|
||||
routedSessionId
|
||||
)
|
||||
setSelectedStoredSessionId(routedSessionId)
|
||||
selectedStoredSessionIdRef.current = routedSessionId
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
if (branched.info?.model) {
|
||||
setCurrentModel(branched.info.model)
|
||||
}
|
||||
|
||||
if (branched.info?.provider) {
|
||||
setCurrentProvider(branched.info.provider)
|
||||
}
|
||||
|
||||
if (branched.info?.cwd) {
|
||||
setCurrentCwd(branched.info.cwd)
|
||||
}
|
||||
|
||||
setCurrentBranch(branched.info?.branch || '')
|
||||
|
||||
if (typeof branched.info?.personality === 'string') {
|
||||
setCurrentPersonality(normalizePersonalityValue(branched.info.personality))
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, 'Branch failed')
|
||||
|
||||
return false
|
||||
}
|
||||
}, [activeSessionIdRef, busyRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState])
|
||||
|
||||
const removeSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
const removed = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousMessages = $messages.get()
|
||||
const previousPinnedSessionIds = $pinnedSessionIds.get()
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinnedSessionIds.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
setSelectedStoredSessionId(null)
|
||||
selectedStoredSessionIdRef.current = null
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
try {
|
||||
if (wasSelected && activeSessionId) {
|
||||
await requestGateway('session.close', {
|
||||
session_id: activeSessionId
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
await deleteSession(storedSessionId)
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinnedSessionIds)
|
||||
|
||||
if (wasSelected) {
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
selectedStoredSessionIdRef.current = storedSessionId
|
||||
setMessages(previousMessages)
|
||||
}
|
||||
|
||||
notifyError(err, 'Delete failed')
|
||||
}
|
||||
},
|
||||
[activeSessionId, selectedStoredSessionId, selectedStoredSessionIdRef, startFreshSessionDraft, requestGateway]
|
||||
)
|
||||
|
||||
return {
|
||||
branchCurrentSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
removeSession,
|
||||
resumeSession,
|
||||
selectSidebarItem,
|
||||
startFreshSessionDraft
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -1,11 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { $busy } from '@/store/session'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $busy, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../types'
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
interface SessionStateCacheOptions {
|
||||
activeSessionId: string | null
|
||||
@@ -47,10 +47,19 @@ export function useSessionStateCache({
|
||||
|
||||
if (existing) {
|
||||
if (storedSessionId !== undefined) {
|
||||
const previousStoredSessionId = existing.storedSessionId
|
||||
existing.storedSessionId = storedSessionId
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
|
||||
if (existing.busy) {
|
||||
setSessionWorking(storedSessionId, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
|
||||
setSessionWorking(previousStoredSessionId, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +99,12 @@ export function useSessionStateCache({
|
||||
const previous = ensureSessionState(sessionId, storedSessionId)
|
||||
const next = updater({ ...previous, messages: previous.messages })
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
|
||||
setSessionWorking(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Check, Palette } from 'lucide-react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { prettyName } from './helpers'
|
||||
import { Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = BUILTIN_THEMES[name]
|
||||
|
||||
if (!t) {
|
||||
return null
|
||||
}
|
||||
|
||||
const c = t.colors
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-20 overflow-hidden rounded-xl border shadow-xs"
|
||||
style={{ backgroundColor: c.background, borderColor: c.border }}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="w-12 border-r"
|
||||
style={{
|
||||
backgroundColor: c.sidebarBackground ?? c.muted,
|
||||
borderColor: c.sidebarBorder ?? c.border
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||
<div className="h-2.5 w-16 rounded-full" style={{ backgroundColor: c.foreground }} />
|
||||
<div className="h-2 w-24 rounded-full" style={{ backgroundColor: c.mutedForeground }} />
|
||||
<div className="mt-auto flex justify-end">
|
||||
<div
|
||||
className="h-5 w-16 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: c.userBubble ?? c.muted,
|
||||
borderColor: c.userBubbleBorder ?? c.border
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const activeTheme = availableThemes.find(t => t.name === themeName)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="space-y-7">
|
||||
<div>
|
||||
<SectionHeading icon={Palette} title="Appearance" />
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
These are desktop-only display preferences. Mode controls brightness; theme controls the accent palette and
|
||||
chat surface styling.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Color Mode</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Pick a fixed mode or let Hermes follow your system setting.
|
||||
</div>
|
||||
</div>
|
||||
<Pill>{prettyName(mode)}</Pill>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{MODE_OPTIONS.map(({ id, label, description, icon: Icon }) => {
|
||||
const active = mode === id
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group rounded-xl border border-border/45 bg-background/55 p-3 text-left transition hover:border-primary/35 hover:bg-accent/45',
|
||||
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
|
||||
)}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
{active && (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-medium">{label}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-border/50 bg-card/55 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Theme</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Desktop palettes only. The selected mode is applied on top.
|
||||
</div>
|
||||
</div>
|
||||
{activeTheme && <Pill>{activeTheme.label}</Pill>}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border border-border/45 bg-background/50 p-2.5 text-left transition hover:border-primary/35 hover:bg-accent/35',
|
||||
active && 'border-primary/65 bg-primary/8 ring-2 ring-primary/25'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{theme.label}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
getElevenLabsVoices,
|
||||
getHermesConfigDefaults,
|
||||
getHermesConfigRecord,
|
||||
getHermesConfigSchema,
|
||||
saveHermesConfig
|
||||
} from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
function ConfigField({
|
||||
schemaKey,
|
||||
schema,
|
||||
value,
|
||||
enumOptions,
|
||||
optionLabels,
|
||||
onChange
|
||||
}: {
|
||||
schemaKey: string
|
||||
schema: ConfigFieldSchema
|
||||
value: unknown
|
||||
enumOptions?: string[]
|
||||
optionLabels?: Record<string, string>
|
||||
onChange: (value: unknown) => void
|
||||
}) {
|
||||
const label = FIELD_LABELS[schemaKey] ?? prettyName(schemaKey.split('.').pop() ?? schemaKey)
|
||||
const normalize = (v: string) => v.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
||||
const rawDescription = (FIELD_DESCRIPTIONS[schemaKey] ?? schema.description ?? '').trim()
|
||||
const normalizedDesc = normalize(rawDescription)
|
||||
|
||||
const description =
|
||||
rawDescription && normalizedDesc !== normalize(label) && normalizedDesc !== normalize(schemaKey)
|
||||
? rawDescription
|
||||
: undefined
|
||||
|
||||
const row = (action: ReactNode, wide = false) => (
|
||||
<ListRow action={action} description={description} title={label} wide={wide} />
|
||||
)
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
return row(
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<span className="text-xs text-muted-foreground">{value ? 'On' : 'Off'}</span>
|
||||
<Switch checked={Boolean(value)} onCheckedChange={onChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
|
||||
|
||||
if (selectOptions) {
|
||||
return row(
|
||||
<Select
|
||||
onValueChange={next => onChange(next === EMPTY_SELECT_VALUE ? '' : next)}
|
||||
value={String(value ?? '') || EMPTY_SELECT_VALUE}
|
||||
>
|
||||
<SelectTrigger className={CONTROL_TEXT}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectOptions.map(option => (
|
||||
<SelectItem key={option || EMPTY_SELECT_VALUE} value={option || EMPTY_SELECT_VALUE}>
|
||||
{option ? (optionLabels?.[option] ?? prettyName(option)) : schemaKey === 'display.personality' ? 'None' : '(none)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'number') {
|
||||
return row(
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
const n = raw === '' ? 0 : Number(raw)
|
||||
|
||||
if (!Number.isNaN(n)) {
|
||||
onChange(n)
|
||||
}
|
||||
}}
|
||||
placeholder="Not set"
|
||||
type="number"
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (schema.type === 'list') {
|
||||
return row(
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
onChange={e =>
|
||||
onChange(
|
||||
e.target.value
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
placeholder="comma-separated values"
|
||||
value={Array.isArray(value) ? value.join(', ') : String(value ?? '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return row(
|
||||
<Textarea
|
||||
className={cn('min-h-28 resize-y bg-background font-mono', CONTROL_TEXT)}
|
||||
onChange={e => {
|
||||
try {
|
||||
onChange(JSON.parse(e.target.value))
|
||||
} catch {
|
||||
/* keep last valid */
|
||||
}
|
||||
}}
|
||||
placeholder="Not set"
|
||||
spellCheck={false}
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
/>,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
const isLong = schema.type === 'text' || String(value ?? '').length > 100
|
||||
|
||||
return row(
|
||||
isLong ? (
|
||||
<Textarea
|
||||
className={cn('min-h-24 resize-y bg-background', CONTROL_TEXT)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Not set"
|
||||
value={String(value ?? '')}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Not set"
|
||||
value={String(value ?? '')}
|
||||
/>
|
||||
),
|
||||
isLong
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfigSettings({
|
||||
query,
|
||||
activeSectionId,
|
||||
onConfigSaved,
|
||||
importInputRef
|
||||
}: SearchProps & {
|
||||
activeSectionId: string
|
||||
onConfigSaved?: () => void
|
||||
importInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}) {
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [_defaults, setDefaults] = useState<HermesConfigRecord | null>(null)
|
||||
const [schema, setSchema] = useState<Record<string, ConfigFieldSchema> | null>(null)
|
||||
const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState<string[] | null>(null)
|
||||
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
|
||||
const saveVersionRef = useRef(0)
|
||||
const [saveVersion, setSaveVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([getHermesConfigRecord(), getHermesConfigDefaults(), getHermesConfigSchema()])
|
||||
.then(([c, d, s]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setConfig(c)
|
||||
setDefaults(d)
|
||||
setSchema(s.fields)
|
||||
})
|
||||
.catch(err => notifyError(err, 'Settings failed to load'))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
getElevenLabsVoices()
|
||||
.then(result => {
|
||||
if (cancelled || !result.available) {
|
||||
return
|
||||
}
|
||||
|
||||
setElevenLabsVoiceOptions(result.voices.map(voice => voice.voice_id))
|
||||
setElevenLabsVoiceLabels(Object.fromEntries(result.voices.map(voice => [voice.voice_id, voice.label])))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setElevenLabsVoiceOptions(null)
|
||||
setElevenLabsVoiceLabels({})
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config || saveVersion === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const v = saveVersion
|
||||
|
||||
const t = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await saveHermesConfig(config)
|
||||
if (saveVersionRef.current === v) {
|
||||
onConfigSaved?.()
|
||||
}
|
||||
} catch (err) {
|
||||
if (saveVersionRef.current === v) {
|
||||
notifyError(err, 'Autosave failed')
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, 550)
|
||||
|
||||
return () => window.clearTimeout(t)
|
||||
}, [config, onConfigSaved, saveVersion])
|
||||
|
||||
const updateConfig = (next: HermesConfigRecord) => {
|
||||
saveVersionRef.current += 1
|
||||
setConfig(next)
|
||||
setSaveVersion(saveVersionRef.current)
|
||||
}
|
||||
|
||||
const sectionFields = useMemo(() => {
|
||||
if (!schema) {
|
||||
return new Map<string, [string, ConfigFieldSchema][]>()
|
||||
}
|
||||
|
||||
return new Map(
|
||||
SECTIONS.map(s => [s.id, s.keys.flatMap(k => (schema[k] ? [[k, schema[k]] as [string, ConfigFieldSchema]] : []))])
|
||||
)
|
||||
}, [schema])
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
if (!schema || !q) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
|
||||
return SECTIONS.flatMap(s =>
|
||||
s.keys.flatMap(k => {
|
||||
if (seen.has(k) || !schema[k]) {
|
||||
return []
|
||||
}
|
||||
|
||||
seen.add(k)
|
||||
const label = prettyName(k.split('.').pop() ?? k)
|
||||
const item = schema[k]
|
||||
|
||||
const hit =
|
||||
k.toLowerCase().includes(q) ||
|
||||
label.toLowerCase().includes(q) ||
|
||||
includesQuery(item.category, q) ||
|
||||
includesQuery(item.description, q)
|
||||
|
||||
return hit ? [[k, item] as [string, ConfigFieldSchema]] : []
|
||||
})
|
||||
)
|
||||
}, [schema, query])
|
||||
|
||||
const fields = query.trim() ? matched : (sectionFields.get(activeSectionId) ?? [])
|
||||
|
||||
function handleImport(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = () => {
|
||||
try {
|
||||
updateConfig(JSON.parse(String(reader.result)))
|
||||
notify({ kind: 'success', title: 'Config imported', message: 'Saving…' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Invalid config JSON')
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
if (!config || !schema) {
|
||||
return <LoadingState label="Loading Hermes configuration..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{query.trim() && (
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
{fields.length} result{fields.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
)}
|
||||
{fields.length === 0 ? (
|
||||
<EmptyState description="Try a different search term or choose another section." title="No matching settings" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{fields.map(([key, field]) => (
|
||||
<ConfigField
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
: enumOptionsFor(key, getNested(config, key), config)
|
||||
}
|
||||
key={key}
|
||||
onChange={value => updateConfig(setNested(config, key, value))}
|
||||
optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined}
|
||||
schema={field}
|
||||
schemaKey={key}
|
||||
value={getNested(config, key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Brain, Lock, type LucideIcon, MessageCircle, Mic, Monitor, Moon, Palette, Sparkles, Sun, Wrench } from 'lucide-react'
|
||||
|
||||
import type { ThemeMode } from '@/themes/context'
|
||||
|
||||
import type { DesktopConfigSection } from './types'
|
||||
|
||||
interface ProviderPrefix {
|
||||
prefix: string
|
||||
name: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export const EMPTY_SELECT_VALUE = '__hermes_empty__'
|
||||
export const CONTROL_TEXT = 'text-[0.8125rem]'
|
||||
|
||||
export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
||||
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
|
||||
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
|
||||
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
|
||||
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
|
||||
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
|
||||
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
|
||||
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
|
||||
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
|
||||
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
|
||||
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
|
||||
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
|
||||
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
|
||||
]
|
||||
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
'helpful',
|
||||
'concise',
|
||||
'technical',
|
||||
'creative',
|
||||
'teacher',
|
||||
'kawaii',
|
||||
'catgirl',
|
||||
'pirate',
|
||||
'shakespeare',
|
||||
'surfer',
|
||||
'noir',
|
||||
'uwu',
|
||||
'philosopher',
|
||||
'hype'
|
||||
]
|
||||
|
||||
// Schema-side select overrides for desktop-relevant enum fields whose
|
||||
// backend schema only declares a string type.
|
||||
export const ENUM_OPTIONS: Record<string, string[]> = {
|
||||
'agent.image_input_mode': ['auto', 'native', 'text'],
|
||||
'approvals.mode': ['manual', 'smart', 'off'],
|
||||
'code_execution.mode': ['project', 'strict'],
|
||||
'context.engine': ['compressor', 'default', 'custom'],
|
||||
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
|
||||
'memory.provider': ['', 'builtin', 'honcho'],
|
||||
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
|
||||
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
model: 'Default Model',
|
||||
model_context_length: 'Context Window',
|
||||
fallback_providers: 'Fallback Models',
|
||||
toolsets: 'Enabled Toolsets',
|
||||
timezone: 'Timezone',
|
||||
'display.personality': 'Personality',
|
||||
'display.show_reasoning': 'Reasoning Blocks',
|
||||
'agent.max_turns': 'Max Agent Steps',
|
||||
'agent.image_input_mode': 'Image Attachments',
|
||||
'terminal.cwd': 'Working Directory',
|
||||
'terminal.backend': 'Execution Backend',
|
||||
'terminal.timeout': 'Command Timeout',
|
||||
'terminal.persistent_shell': 'Persistent Shell',
|
||||
'terminal.env_passthrough': 'Environment Passthrough',
|
||||
file_read_max_chars: 'File Read Limit',
|
||||
'tool_output.max_bytes': 'Terminal Output Limit',
|
||||
'tool_output.max_lines': 'File Page Limit',
|
||||
'tool_output.max_line_length': 'Line Length Limit',
|
||||
'code_execution.mode': 'Code Execution Mode',
|
||||
'approvals.mode': 'Approval Mode',
|
||||
'approvals.timeout': 'Approval Timeout',
|
||||
'approvals.mcp_reload_confirm': 'Confirm MCP Reloads',
|
||||
command_allowlist: 'Command Allowlist',
|
||||
'security.redact_secrets': 'Redact Secrets',
|
||||
'security.allow_private_urls': 'Allow Private URLs',
|
||||
'browser.allow_private_urls': 'Browser Private URLs',
|
||||
'browser.auto_local_for_private_urls': 'Local Browser For Private URLs',
|
||||
'checkpoints.enabled': 'File Checkpoints',
|
||||
'checkpoints.max_snapshots': 'Checkpoint Limit',
|
||||
'voice.record_key': 'Voice Shortcut',
|
||||
'voice.max_recording_seconds': 'Max Recording Length',
|
||||
'voice.auto_tts': 'Read Responses Aloud',
|
||||
'stt.enabled': 'Speech To Text',
|
||||
'stt.provider': 'Speech-To-Text Provider',
|
||||
'stt.local.model': 'Local Transcription Model',
|
||||
'stt.local.language': 'Transcription Language',
|
||||
'tts.provider': 'Text-To-Speech Provider',
|
||||
'tts.edge.voice': 'Edge Voice',
|
||||
'tts.openai.model': 'OpenAI TTS Model',
|
||||
'tts.openai.voice': 'OpenAI Voice',
|
||||
'tts.elevenlabs.voice_id': 'ElevenLabs Voice',
|
||||
'tts.elevenlabs.model_id': 'ElevenLabs Model',
|
||||
'memory.memory_enabled': 'Persistent Memory',
|
||||
'memory.user_profile_enabled': 'User Profile',
|
||||
'memory.memory_char_limit': 'Memory Budget',
|
||||
'memory.user_char_limit': 'Profile Budget',
|
||||
'memory.provider': 'Memory Provider',
|
||||
'context.engine': 'Context Engine',
|
||||
'compression.enabled': 'Auto-Compression',
|
||||
'compression.threshold': 'Compression Threshold',
|
||||
'compression.target_ratio': 'Compression Target',
|
||||
'compression.protect_last_n': 'Protected Recent Messages',
|
||||
'agent.api_max_retries': 'API Retries',
|
||||
'agent.service_tier': 'Service Tier',
|
||||
'agent.tool_use_enforcement': 'Tool-Use Enforcement',
|
||||
'delegation.model': 'Subagent Model',
|
||||
'delegation.provider': 'Subagent Provider',
|
||||
'delegation.max_iterations': 'Subagent Turn Limit',
|
||||
'delegation.max_concurrent_children': 'Parallel Subagents',
|
||||
'delegation.child_timeout_seconds': 'Subagent Timeout',
|
||||
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
|
||||
'auxiliary.vision.provider': 'Vision Provider',
|
||||
'auxiliary.vision.model': 'Vision Model',
|
||||
'auxiliary.compression.provider': 'Compression Provider',
|
||||
'auxiliary.compression.model': 'Compression Model',
|
||||
'auxiliary.title_generation.provider': 'Title Provider',
|
||||
'auxiliary.title_generation.model': 'Title Model'
|
||||
}
|
||||
|
||||
export const FIELD_DESCRIPTIONS: Record<string, string> = {
|
||||
model: 'Used for new chats unless you pick a different model in the composer.',
|
||||
model_context_length: "Leave at 0 to use the selected model's detected context window.",
|
||||
fallback_providers: 'Backup provider:model entries to try if the default model fails.',
|
||||
'display.personality': 'Default assistant style for new sessions.',
|
||||
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
|
||||
'display.show_reasoning': 'Show reasoning sections when the backend provides them.',
|
||||
'agent.image_input_mode': 'Controls how image attachments are sent to the model.',
|
||||
'terminal.cwd': 'Default project folder for tool and terminal work.',
|
||||
'code_execution.mode': 'How strictly code execution is scoped to the current project.',
|
||||
'terminal.persistent_shell': 'Keep shell state between commands when the backend supports it.',
|
||||
'terminal.env_passthrough': 'Environment variables to pass into tool execution.',
|
||||
file_read_max_chars: 'Maximum characters Hermes can read from one file request.',
|
||||
'approvals.mode': 'How Hermes handles commands that need explicit approval.',
|
||||
'approvals.timeout': 'How long approval prompts wait before timing out.',
|
||||
'security.redact_secrets': 'Hide detected secrets from model-visible content when possible.',
|
||||
'checkpoints.enabled': 'Create rollback snapshots before file edits.',
|
||||
'memory.memory_enabled': 'Save durable memories that can help future sessions.',
|
||||
'memory.user_profile_enabled': 'Maintain a compact profile of user preferences.',
|
||||
'context.engine': 'Strategy for managing long conversations near the context limit.',
|
||||
'compression.enabled': 'Summarize older context when conversations get large.',
|
||||
'voice.auto_tts': 'Automatically speak assistant responses.',
|
||||
'stt.enabled': 'Enable local or provider-backed speech transcription.',
|
||||
'agent.max_turns': 'Upper bound for tool-calling turns before Hermes stops a run.'
|
||||
}
|
||||
|
||||
// Curated desktop config surface: only fields a user might tune from the app.
|
||||
export const SECTIONS: DesktopConfigSection[] = [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
icon: Sparkles,
|
||||
keys: ['model', 'model_context_length', 'fallback_providers']
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
icon: MessageCircle,
|
||||
keys: ['display.personality', 'timezone', 'display.show_reasoning', 'agent.image_input_mode']
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
icon: Palette,
|
||||
keys: []
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: 'Workspace',
|
||||
icon: Monitor,
|
||||
keys: [
|
||||
'terminal.cwd',
|
||||
'code_execution.mode',
|
||||
'terminal.persistent_shell',
|
||||
'terminal.env_passthrough',
|
||||
'file_read_max_chars'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'safety',
|
||||
label: 'Safety',
|
||||
icon: Lock,
|
||||
keys: [
|
||||
'approvals.mode',
|
||||
'approvals.timeout',
|
||||
'approvals.mcp_reload_confirm',
|
||||
'command_allowlist',
|
||||
'security.redact_secrets',
|
||||
'security.allow_private_urls',
|
||||
'browser.allow_private_urls',
|
||||
'browser.auto_local_for_private_urls',
|
||||
'checkpoints.enabled'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
label: 'Memory & Context',
|
||||
icon: Brain,
|
||||
keys: [
|
||||
'memory.memory_enabled',
|
||||
'memory.user_profile_enabled',
|
||||
'memory.memory_char_limit',
|
||||
'memory.user_char_limit',
|
||||
'memory.provider',
|
||||
'context.engine',
|
||||
'compression.enabled',
|
||||
'compression.threshold',
|
||||
'compression.target_ratio',
|
||||
'compression.protect_last_n'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'voice',
|
||||
label: 'Voice',
|
||||
icon: Mic,
|
||||
keys: [
|
||||
'tts.provider',
|
||||
'stt.enabled',
|
||||
'stt.provider',
|
||||
'voice.auto_tts',
|
||||
'tts.edge.voice',
|
||||
'tts.openai.model',
|
||||
'tts.openai.voice',
|
||||
'tts.elevenlabs.voice_id',
|
||||
'tts.elevenlabs.model_id',
|
||||
'stt.local.model',
|
||||
'stt.local.language',
|
||||
'voice.record_key',
|
||||
'voice.max_recording_seconds'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
icon: Wrench,
|
||||
keys: [
|
||||
'toolsets',
|
||||
'terminal.backend',
|
||||
'terminal.timeout',
|
||||
'tool_output.max_bytes',
|
||||
'tool_output.max_lines',
|
||||
'tool_output.max_line_length',
|
||||
'checkpoints.max_snapshots',
|
||||
'agent.max_turns',
|
||||
'agent.api_max_retries',
|
||||
'agent.service_tier',
|
||||
'agent.tool_use_enforcement',
|
||||
'delegation.model',
|
||||
'delegation.provider',
|
||||
'delegation.max_iterations',
|
||||
'delegation.max_concurrent_children',
|
||||
'delegation.child_timeout_seconds',
|
||||
'delegation.reasoning_effort',
|
||||
'auxiliary.vision.provider',
|
||||
'auxiliary.vision.model',
|
||||
'auxiliary.compression.provider',
|
||||
'auxiliary.compression.model',
|
||||
'auxiliary.title_generation.provider',
|
||||
'auxiliary.title_generation.model'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export interface ModeOption {
|
||||
id: ThemeMode
|
||||
label: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
|
||||
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
|
||||
{ id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'config' | 'keys' | 'tools', string> = {
|
||||
config: 'Search settings...',
|
||||
keys: 'Search API keys...',
|
||||
tools: 'Search skills and tools...'
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { HermesConfigRecord, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { BUILTIN_PERSONALITIES, ENUM_OPTIONS, PROVIDER_GROUPS } from './constants'
|
||||
|
||||
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
|
||||
|
||||
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
|
||||
|
||||
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : [])
|
||||
|
||||
export const withoutKey = <T,>(record: Record<string, T>, key: string) => {
|
||||
const next = { ...record }
|
||||
delete next[key]
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
|
||||
|
||||
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
|
||||
|
||||
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
|
||||
|
||||
export function getNested(obj: HermesConfigRecord, path: string): unknown {
|
||||
let cur: unknown = obj
|
||||
|
||||
for (const part of path.split('.')) {
|
||||
if (cur == null || typeof cur !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
cur = (cur as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
return cur
|
||||
}
|
||||
|
||||
export function setNested(obj: HermesConfigRecord, path: string, value: unknown): HermesConfigRecord {
|
||||
const clone = structuredClone(obj)
|
||||
const parts = path.split('.')
|
||||
let cur: Record<string, unknown> = clone
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
const part = parts[i]
|
||||
|
||||
if (cur[part] == null || typeof cur[part] !== 'object') {
|
||||
cur[part] = {}
|
||||
}
|
||||
|
||||
cur = cur[part] as Record<string, unknown>
|
||||
}
|
||||
|
||||
cur[parts[parts.length - 1]] = value
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
function personalityOptions(config: HermesConfigRecord): string[] {
|
||||
const custom = getNested(config, 'agent.personalities')
|
||||
|
||||
const customNames =
|
||||
custom && typeof custom === 'object' && !Array.isArray(custom) ? Object.keys(custom as Record<string, unknown>) : []
|
||||
|
||||
return [...new Set(['', ...BUILTIN_PERSONALITIES, ...customNames])]
|
||||
}
|
||||
|
||||
export function enumOptionsFor(
|
||||
key: string,
|
||||
value: unknown,
|
||||
config: HermesConfigRecord,
|
||||
dynamicOptions?: string[]
|
||||
): string[] | undefined {
|
||||
const opts = dynamicOptions ?? (key === 'display.personality' ? personalityOptions(config) : ENUM_OPTIONS[key])
|
||||
|
||||
if (!opts) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = asText(value)
|
||||
|
||||
return current && !opts.includes(current) ? [...opts, current] : opts
|
||||
}
|
||||
@@ -1,7 +1,215 @@
|
||||
import type * as React from 'react'
|
||||
import { Download, KeyRound, Package, RotateCcw, Search, Upload, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { SettingsPage } from '@/components/settings-page'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
export function SettingsView(props: React.ComponentProps<typeof SettingsPage>) {
|
||||
return <SettingsPage {...props} />
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { NavLink } from './primitives'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useState<SettingsViewId>('config:model')
|
||||
|
||||
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
|
||||
config: '',
|
||||
keys: '',
|
||||
tools: ''
|
||||
})
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const queryKey: SettingsQueryKey = activeView.startsWith('config:') ? 'config' : (activeView as SettingsQueryKey)
|
||||
const query = queries[queryKey]
|
||||
const setQuery = (next: string) => setQueries(c => ({ ...c, [queryKey]: next }))
|
||||
|
||||
const exportConfig = async () => {
|
||||
try {
|
||||
const cfg = await getHermesConfigRecord()
|
||||
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'hermes-config.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
triggerHaptic('success')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Export failed')
|
||||
}
|
||||
}
|
||||
|
||||
const resetConfig = async () => {
|
||||
if (!window.confirm('Reset all settings to Hermes defaults?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await saveHermesConfig(await getHermesConfigDefaults())
|
||||
triggerHaptic('success')
|
||||
onConfigSaved?.()
|
||||
} catch (err) {
|
||||
notifyError(err, 'Reset failed')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
searchInputRef.current?.select()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-60 flex min-h-0 flex-col bg-background/98 p-0.75 backdrop-blur-xl">
|
||||
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(1rem+var(--titlebar-height)/2)] w-[min(36rem,calc(100vw-32rem))] min-w-80 -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/80" />
|
||||
<Input
|
||||
className="h-9 rounded-full border-transparent bg-background py-2 pl-8 pr-20 text-sm shadow-header focus-visible:bg-background"
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder={SEARCH_PLACEHOLDER[queryKey]}
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
/>
|
||||
{query ? (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 rounded-md bg-background/80 px-1.5 py-0.5 text-[0.62rem] leading-none text-muted-foreground shadow-xs">
|
||||
Cmd P
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Close settings"
|
||||
className="pointer-events-auto absolute right-3.75 top-[calc(0.1875rem+var(--titlebar-height)/2)] h-7 w-7 -translate-y-1/2 rounded-lg text-muted-foreground hover:bg-accent/70 hover:text-foreground [-webkit-app-region:no-drag]"
|
||||
onClick={() => {
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
}}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] rounded-[1.0625rem] bg-background/90 pt-(--titlebar-height) max-[760px]:grid-cols-1">
|
||||
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-muted/20 px-4 py-5">
|
||||
{SECTIONS.map(s => {
|
||||
const view = `config:${s.id}` as SettingsViewId
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
active={activeView === view && !queries.config.trim()}
|
||||
icon={s.icon}
|
||||
key={s.id}
|
||||
label={s.label}
|
||||
onClick={() => setActiveView(view)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<NavLink
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
<NavLink
|
||||
active={activeView === 'tools'}
|
||||
icon={Package}
|
||||
label="Skills & Tools"
|
||||
onClick={() => setActiveView('tools')}
|
||||
/>
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
onClick={() => void exportConfig()}
|
||||
size="icon-xs"
|
||||
title="Export config"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
size="icon-xs"
|
||||
title="Import config"
|
||||
variant="ghost"
|
||||
>
|
||||
<Upload />
|
||||
</Button>
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
triggerHaptic('warning')
|
||||
void resetConfig()
|
||||
}}
|
||||
size="icon-xs"
|
||||
title="Reset to defaults"
|
||||
variant="ghost"
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{activeView === 'config:appearance' ? (
|
||||
<AppearanceSettings />
|
||||
) : activeView.startsWith('config:') ? (
|
||||
<ConfigSettings
|
||||
activeSectionId={activeView.slice('config:'.length)}
|
||||
importInputRef={importInputRef}
|
||||
onConfigSaved={onConfigSaved}
|
||||
query={queries.config}
|
||||
/>
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings query={queries.keys} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SettingsView as SettingsPage }
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, X, Zap } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, includesQuery, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
saving: string | null
|
||||
onEdit: () => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
isRevealed: boolean
|
||||
showReveal?: boolean
|
||||
}
|
||||
|
||||
function EnvActions({
|
||||
varKey,
|
||||
info,
|
||||
saving,
|
||||
onEdit,
|
||||
onClear,
|
||||
onReveal,
|
||||
isRevealed,
|
||||
showReveal = true
|
||||
}: EnvActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="outline">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
compact = false
|
||||
}: EnvRowProps) {
|
||||
const isEditing = edits[varKey] !== undefined
|
||||
const isRevealed = revealed[varKey] !== undefined
|
||||
const value = isRevealed ? revealed[varKey] : info.redacted_value
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
|
||||
if (compact && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
showReveal={false}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{varKey}</span>
|
||||
<Pill tone={info.is_set ? 'primary' : 'muted'}>
|
||||
{info.is_set && <Check className="size-3" />}
|
||||
{info.is_set ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && info.is_set && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 font-mono text-xs',
|
||||
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
|
||||
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
|
||||
type={info.is_password ? 'password' : 'text'}
|
||||
value={edits[varKey]}
|
||||
/>
|
||||
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
|
||||
<X />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvProviderGroup({
|
||||
group,
|
||||
rowProps
|
||||
}: {
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Zap className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{group.name === 'Other' ? 'Other providers' : group.name}
|
||||
</span>
|
||||
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<EnvVarRow compact={!info.is_set} info={info} key={key} varKey={key} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeysSettings({ query }: SearchProps) {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const [showAdvanced, setShowAdvanced] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback(
|
||||
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!showAdvanced && Boolean(info.advanced)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
},
|
||||
[showAdvanced]
|
||||
)
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const entries = Object.entries(vars).filter(([key, info]) =>
|
||||
filterEnv(info, key, q, 'provider', providerGroup(key))
|
||||
)
|
||||
|
||||
const groups = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = providerGroup(entry[0])
|
||||
groups.set(name, [...(groups.get(name) ?? []), entry])
|
||||
}
|
||||
|
||||
return Array.from(groups, ([name, entries]) => ({
|
||||
name,
|
||||
priority: providerPriority(name),
|
||||
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
|
||||
hasAnySet: entries.some(([, info]) => info.is_set)
|
||||
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}, [filterEnv, query, vars])
|
||||
|
||||
const otherGroups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tool: 'Tools',
|
||||
messaging: 'Messaging',
|
||||
setting: 'Settings'
|
||||
}
|
||||
|
||||
return ['tool', 'messaging', 'setting'].flatMap(cat => {
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([key, info]) => filterEnv(info, key, q, cat))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
|
||||
})
|
||||
}, [filterEnv, query, vars])
|
||||
|
||||
function patchVar(key: string, patch: EnvPatch) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading API keys and credentials..." />
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
|
||||
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
|
||||
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
meta={`${configuredCount} of ${providerGroups.length} configured`}
|
||||
title="LLM providers"
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{providerGroups.map(group => (
|
||||
<EnvProviderGroup group={group} key={group.name} rowProps={rowProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{otherGroups.map(group => (
|
||||
<div className="mb-6" key={group.category}>
|
||||
<SectionHeading
|
||||
icon={Settings2}
|
||||
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
|
||||
title={group.label}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SettingsContent({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<section className="min-h-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 overflow-y-auto px-8 py-6 pb-24">
|
||||
<div className="mx-auto w-full max-w-5xl">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pill({ tone = 'muted', children }: { tone?: 'muted' | 'primary'; children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.66rem]',
|
||||
tone === 'primary' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: LucideIcon; title: string; meta?: string }) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 pt-3.5 text-sm font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{meta && <Pill>{meta}</Pill>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavLink({
|
||||
icon: Icon,
|
||||
label,
|
||||
active,
|
||||
onClick
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'flex min-h-8 w-full justify-start gap-2 rounded-lg px-2.5 text-left text-sm transition',
|
||||
active ? 'bg-muted text-foreground' : 'text-foreground/80 hover:bg-muted/70'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListRow({
|
||||
title,
|
||||
description,
|
||||
hint,
|
||||
action,
|
||||
below,
|
||||
wide = false
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
hint?: ReactNode
|
||||
action?: ReactNode
|
||||
below?: ReactNode
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] sm:items-center',
|
||||
wide && 'sm:grid-cols-1 sm:items-start'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
{description && <div className="mt-1 text-xs leading-5 text-muted-foreground">{description}</div>}
|
||||
{hint && <div className="mt-1 block font-mono text-[0.68rem] text-muted-foreground/45">{hint}</div>}
|
||||
{below}
|
||||
</div>
|
||||
{action && <div className={cn('min-w-0', !wide && 'sm:justify-self-end')}>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingState({ label }: { label: string }) {
|
||||
return <PageLoader label={label} />
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="grid min-h-48 place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Brain, Wrench } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, prettyName, toolNames } from './helpers'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
export function ToolsSettings({ query }: SearchProps) {
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
Promise.all([getSkills(), getToolsets()])
|
||||
.then(([s, t]) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setSkills(s)
|
||||
setToolsets(t)
|
||||
})
|
||||
.catch(err => notifyError(err, 'Capabilities failed to load'))
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return skills
|
||||
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
|
||||
.sort(
|
||||
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
|
||||
)
|
||||
}, [query, skills])
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
if (!toolsets) {
|
||||
return []
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return toolsets
|
||||
.filter(t => {
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
includesQuery(t.name, q) ||
|
||||
includesQuery(t.label, q) ||
|
||||
includesQuery(t.description, q) ||
|
||||
toolNames(t).some(n => includesQuery(n, q))
|
||||
)
|
||||
})
|
||||
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
|
||||
}, [query, toolsets])
|
||||
|
||||
const skillGroups = useMemo(() => {
|
||||
const groups = new Map<string, SkillInfo[]>()
|
||||
|
||||
for (const skill of filteredSkills) {
|
||||
const cat = asText(skill.category) || 'other'
|
||||
groups.set(cat, [...(groups.get(cat) ?? []), skill])
|
||||
}
|
||||
|
||||
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [filteredSkills])
|
||||
|
||||
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
|
||||
setSavingSkill(skill.name)
|
||||
|
||||
try {
|
||||
await toggleSkill(skill.name, enabled)
|
||||
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skills || !toolsets) {
|
||||
return <LoadingState label="Loading skills and toolsets..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="mt-4 first:mt-0" key={category}>
|
||||
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/40">
|
||||
{list.map(skill => (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={savingSkill === skill.name}
|
||||
onCheckedChange={c => void handleToggleSkill(skill, c)}
|
||||
/>
|
||||
}
|
||||
description={asText(skill.description)}
|
||||
key={asText(skill.name)}
|
||||
title={asText(skill.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Wrench}
|
||||
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
|
||||
title="Toolsets"
|
||||
/>
|
||||
<div className="divide-y divide-border/40">
|
||||
{filteredToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Pill tone={toolset.enabled ? 'primary' : 'muted'}>{toolset.enabled ? 'Enabled' : 'Disabled'}</Pill>
|
||||
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</Pill>
|
||||
</div>
|
||||
}
|
||||
below={
|
||||
tools.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{tools.slice(0, 10).map(t => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{tools.length > 10 && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
|
||||
+{tools.length - 10} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={asText(toolset.description)}
|
||||
key={asText(toolset.name) || label}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'keys' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'config' | 'keys' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
onClose: () => void
|
||||
onConfigSaved?: () => void
|
||||
}
|
||||
|
||||
export interface SearchProps {
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface ProviderGroup {
|
||||
name: string
|
||||
priority: number
|
||||
entries: [string, EnvVarInfo][]
|
||||
hasAnySet: boolean
|
||||
}
|
||||
|
||||
export interface DesktopConfigSection {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
export interface EnvRowProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
edits: Record<string, string>
|
||||
revealed: Record<string, string>
|
||||
saving: string | null
|
||||
setEdits: Dispatch<SetStateAction<Record<string, string>>>
|
||||
onSave: (key: string) => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
compact?: boolean
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent } from
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$inspectorOpen,
|
||||
@@ -24,6 +25,7 @@ interface AppShellProps {
|
||||
rightRailOpen: boolean
|
||||
settingsOpen: boolean
|
||||
sidebar: ReactNode
|
||||
titlebarActions?: ReactNode
|
||||
onOpenSettings: () => void
|
||||
overlays?: ReactNode
|
||||
}
|
||||
@@ -34,6 +36,7 @@ export function AppShell({
|
||||
rightRailOpen,
|
||||
settingsOpen,
|
||||
sidebar,
|
||||
titlebarActions,
|
||||
onOpenSettings,
|
||||
overlays
|
||||
}: AppShellProps) {
|
||||
@@ -66,6 +69,7 @@ export function AppShell({
|
||||
|
||||
const handleUp = () => {
|
||||
setSidebarResizing(false)
|
||||
triggerHaptic('crisp')
|
||||
document.body.style.cursor = previousCursor
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
window.removeEventListener('pointermove', handleMove)
|
||||
@@ -93,6 +97,7 @@ export function AppShell({
|
||||
}
|
||||
>
|
||||
<TitlebarControls
|
||||
leadingActions={titlebarActions}
|
||||
onOpenSettings={onOpenSettings}
|
||||
settingsOpen={settingsOpen}
|
||||
showInspectorToggle={rightRailOpen}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { NotebookTabs, Search, Settings, SlidersHorizontal } from 'lucide-react'
|
||||
import { NotebookTabs, Search, Settings, SlidersHorizontal, Volume2, VolumeX } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
|
||||
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
|
||||
@@ -10,13 +13,32 @@ import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
|
||||
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
|
||||
settingsOpen: boolean
|
||||
showInspectorToggle: boolean
|
||||
leadingActions?: ReactNode
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSettings }: TitlebarControlsProps) {
|
||||
export function TitlebarControls({
|
||||
settingsOpen,
|
||||
showInspectorToggle,
|
||||
leadingActions,
|
||||
onOpenSettings
|
||||
}: TitlebarControlsProps) {
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const inspectorOpen = useStore($inspectorOpen)
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
triggerHaptic('tap')
|
||||
}
|
||||
|
||||
toggleHapticsMuted()
|
||||
|
||||
if (hapticsMuted) {
|
||||
window.requestAnimationFrame(() => triggerHaptic('success'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -26,7 +48,10 @@ export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSett
|
||||
<button
|
||||
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={toggleSidebarOpen}
|
||||
onClick={() => {
|
||||
triggerHaptic('tap')
|
||||
toggleSidebarOpen()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
@@ -48,11 +73,15 @@ export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSett
|
||||
aria-label="App controls"
|
||||
className="fixed right-3 top-(--titlebar-controls-top) z-1100 grid grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
|
||||
>
|
||||
{leadingActions}
|
||||
{showInspectorToggle && (
|
||||
<button
|
||||
aria-label={inspectorOpen ? 'Hide session details' : 'Show session details'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={toggleInspectorOpen}
|
||||
onClick={() => {
|
||||
triggerHaptic('tap')
|
||||
toggleInspectorOpen()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
|
||||
type="button"
|
||||
@@ -60,10 +89,28 @@ export function TitlebarControls({ settingsOpen, showInspectorToggle, onOpenSett
|
||||
<SlidersHorizontal />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
|
||||
aria-pressed={hapticsMuted}
|
||||
className={cn(
|
||||
titlebarButtonClass,
|
||||
'grid place-items-center bg-transparent [&_svg]:size-3.5',
|
||||
hapticsMuted && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
onClick={toggleHaptics}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
|
||||
type="button"
|
||||
>
|
||||
{hapticsMuted ? <VolumeX /> : <Volume2 />}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Open settings"
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
|
||||
onClick={onOpenSettings}
|
||||
onClick={() => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
}}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
title="Settings"
|
||||
type="button"
|
||||
|
||||
@@ -15,8 +15,11 @@ const WINDOW_BUTTON_FALLBACK = {
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderClass =
|
||||
"relative z-3 flex h-(--titlebar-height) shrink-0 items-center gap-3 bg-background/70 px-3 shadow-header backdrop-blur-sm after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
|
||||
export const titlebarHeaderBaseClass =
|
||||
'relative z-3 flex h-(--titlebar-height) shrink-0 items-center gap-3 bg-background/70 px-3 backdrop-blur-sm'
|
||||
|
||||
export const titlebarHeaderShadowClass =
|
||||
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"
|
||||
|
||||
export function titlebarControlsPosition(windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined) {
|
||||
const position = windowButtonPosition || WINDOW_BUTTON_FALLBACK
|
||||
|
||||
@@ -1,27 +1,396 @@
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
import { Brain, RefreshCw, Search, Wrench, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { titlebarHeaderClass } from '../shell/titlebar'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
|
||||
import { TITLEBAR_ICON_SIZE, titlebarButtonClass, titlebarHeaderBaseClass } from '../shell/titlebar'
|
||||
|
||||
type SkillsMode = 'skills' | 'toolsets'
|
||||
|
||||
function categoryFor(skill: SkillInfo): string {
|
||||
return asText(skill.category) || 'general'
|
||||
}
|
||||
|
||||
function filteredSkills(skills: SkillInfo[], query: string, category: string | null): SkillInfo[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return skills
|
||||
.filter(skill => {
|
||||
if (category && categoryFor(skill) !== category) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
includesQuery(skill.name, q) ||
|
||||
includesQuery(skill.description, q) ||
|
||||
includesQuery(skill.category, q)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => asText(a.name).localeCompare(asText(b.name)))
|
||||
}
|
||||
|
||||
function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
|
||||
return toolsets
|
||||
.filter(toolset => {
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
includesQuery(toolset.name, q) ||
|
||||
includesQuery(toolset.label, q) ||
|
||||
includesQuery(toolset.description, q) ||
|
||||
toolNames(toolset).some(name => includesQuery(name, q))
|
||||
)
|
||||
})
|
||||
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
|
||||
}
|
||||
|
||||
interface SkillsViewProps extends React.ComponentProps<'section'> {
|
||||
setTitlebarActions?: (actions: ReactNode | null) => void
|
||||
}
|
||||
|
||||
export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
const [mode, setMode] = useState<SkillsMode>('skills')
|
||||
const [query, setQuery] = useState('')
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
|
||||
const refreshCapabilities = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const [nextSkills, nextToolsets] = await Promise.all([getSkills(), getToolsets()])
|
||||
setSkills(nextSkills)
|
||||
setToolsets(nextToolsets)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Skills failed to load')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCapabilities()
|
||||
}, [refreshCapabilities])
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTitlebarActions) {
|
||||
return
|
||||
}
|
||||
|
||||
setTitlebarActions(
|
||||
<button
|
||||
aria-label={refreshing ? 'Refreshing skills' : 'Refresh skills'}
|
||||
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
|
||||
disabled={refreshing}
|
||||
onClick={() => void refreshCapabilities()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={cn(refreshing && 'animate-spin')} size={TITLEBAR_ICON_SIZE} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return () => setTitlebarActions(null)
|
||||
}, [refreshCapabilities, refreshing, setTitlebarActions])
|
||||
|
||||
const categories = useMemo(() => {
|
||||
if (!skills) {
|
||||
return []
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
for (const skill of skills) {
|
||||
const key = categoryFor(skill)
|
||||
counts.set(key, (counts.get(key) || 0) + 1)
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
}, [skills])
|
||||
|
||||
const visibleSkills = useMemo(
|
||||
() => (skills ? filteredSkills(skills, query, mode === 'skills' ? activeCategory : null) : []),
|
||||
[activeCategory, mode, query, skills]
|
||||
)
|
||||
|
||||
const visibleToolsets = useMemo(() => (toolsets ? filteredToolsets(toolsets, query) : []), [query, toolsets])
|
||||
|
||||
const skillGroups = useMemo(() => {
|
||||
const groups = new Map<string, SkillInfo[]>()
|
||||
|
||||
for (const skill of visibleSkills) {
|
||||
const key = categoryFor(skill)
|
||||
groups.set(key, [...(groups.get(key) || []), skill])
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
}, [visibleSkills])
|
||||
|
||||
const totalSkills = skills?.length || 0
|
||||
const enabledSkills = skills?.filter(skill => skill.enabled).length || 0
|
||||
const enabledToolsets = toolsets?.filter(toolset => toolset.enabled).length || 0
|
||||
|
||||
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
|
||||
setSavingSkill(skill.name)
|
||||
|
||||
try {
|
||||
await toggleSkill(skill.name, enabled)
|
||||
setSkills(current => current?.map(row => (row.name === skill.name ? { ...row, enabled } : row)) ?? current)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Skill enabled' : 'Skill disabled',
|
||||
message: `${skill.name} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${skill.name}`)
|
||||
} finally {
|
||||
setSavingSkill(null)
|
||||
}
|
||||
}
|
||||
|
||||
export function SkillsView(props: React.ComponentProps<'section'>) {
|
||||
return (
|
||||
<section
|
||||
{...props}
|
||||
className="flex h-[calc(100vh-0.375rem)] min-w-0 flex-col overflow-hidden rounded-[0.9375rem] bg-background"
|
||||
>
|
||||
<header className={titlebarHeaderClass}>
|
||||
<header className={titlebarHeaderBaseClass}>
|
||||
<h2 className="text-base font-semibold leading-none tracking-tight">Skills</h2>
|
||||
<span className="text-xs text-muted-foreground">{enabledSkills}/{totalSkills} enabled</span>
|
||||
</header>
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-8 text-center">
|
||||
<div className="max-w-md space-y-3">
|
||||
<Sparkles className="mx-auto size-8 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Skills view is ready</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skill management already lives in Settings. This route gives it a dedicated view boundary so the real screen
|
||||
can move here without touching the app shell again.
|
||||
</p>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.0625rem] border border-border/50 bg-background/85">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
|
||||
<ModeButton active={mode === 'toolsets'} icon={Wrench} onClick={() => setMode('toolsets')} text="Toolsets" />
|
||||
<div className="ml-auto w-full max-w-sm min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 rounded-lg pl-8 pr-8 text-sm"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder={mode === 'skills' ? 'Search skills...' : 'Search toolsets...'}
|
||||
value={query}
|
||||
/>
|
||||
{query && (
|
||||
<Button
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setQuery('')}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'skills' && categories.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<CategoryButton
|
||||
active={activeCategory === null}
|
||||
count={totalSkills}
|
||||
label="All"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
/>
|
||||
{categories.map(category => (
|
||||
<CategoryButton
|
||||
active={activeCategory === category.key}
|
||||
count={category.count}
|
||||
key={category.key}
|
||||
label={prettyName(category.key)}
|
||||
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!skills || !toolsets ? (
|
||||
<PageLoader label="Loading capabilities..." />
|
||||
) : mode === 'skills' ? (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{visibleSkills.length === 0 ? (
|
||||
<EmptyState description="Try a broader search or different category." title="No skills found" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{skillGroups.map(([category, list]) => (
|
||||
<div className="space-y-1.5" key={category}>
|
||||
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{prettyName(category)}
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{list.map(skill => (
|
||||
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center" key={skill.name}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{asText(skill.description) || 'No description.'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={savingSkill === skill.name}
|
||||
onCheckedChange={checked => void handleToggleSkill(skill, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{visibleToolsets.length === 0 ? (
|
||||
<EmptyState description="Try a broader search query." title="No toolsets found" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">{enabledToolsets}/{toolsets.length} toolsets enabled</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2.5" key={toolset.name}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-sm font-medium">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
|
||||
<StatusPill active={toolset.configured}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{asText(toolset.description) || 'No description.'}</p>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tools.map(name => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.65rem] text-muted-foreground"
|
||||
key={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
text
|
||||
}: {
|
||||
active: boolean
|
||||
icon: LucideIcon
|
||||
onClick: () => void
|
||||
text: string
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryButton({
|
||||
active,
|
||||
count,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
active: boolean
|
||||
count: number
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'h-7 rounded-full px-2.5 text-[0.68rem]',
|
||||
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
<span className="ml-1 rounded-full bg-muted px-1.5 py-0 text-[0.62rem] text-muted-foreground">{count}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.64rem]',
|
||||
active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className="grid min-h-52 place-items-center text-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { formatElapsed } from './activity-timer'
|
||||
|
||||
interface ActivityTimerTextProps {
|
||||
seconds: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-muted-foreground/45 tabular-nums',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { type ComponentProps, memo, useMemo, useState } from 'react'
|
||||
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -37,6 +38,7 @@ function CodeHeader({ language, code }: { language?: string; code?: string }) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
|
||||
@@ -8,10 +8,21 @@ import {
|
||||
type ToolCallMessagePartProps,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, LoaderCircleIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
GitBranchIcon,
|
||||
MoreHorizontalIcon,
|
||||
RefreshCwIcon,
|
||||
Volume2Icon,
|
||||
VolumeXIcon
|
||||
} from 'lucide-react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { DirectiveText } from '@/components/assistant-ui/directive-text'
|
||||
import { GeneratedImageProvider, useGeneratedImageContext } from '@/components/assistant-ui/generated-image-context'
|
||||
import { ImageGenerationPlaceholder } from '@/components/assistant-ui/image-generation-placeholder'
|
||||
@@ -19,7 +30,18 @@ import { Intro, type IntroProps } from '@/components/assistant-ui/intro'
|
||||
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { speakText } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
const THINKING_FACES = [
|
||||
@@ -58,73 +80,100 @@ const THINKING_VERBS = [
|
||||
'brainstorming'
|
||||
]
|
||||
|
||||
type ThreadLoadingState = 'response' | 'session'
|
||||
type ThreadLoadingState = 'response' | 'session' | 'working'
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
messageText: string
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
}
|
||||
|
||||
const BOTTOM_DISTANCE_PX = 24
|
||||
let readAloudAudio: HTMLAudioElement | null = null
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - (el.scrollTop + el.clientHeight) <= BOTTOM_DISTANCE_PX
|
||||
}
|
||||
|
||||
function partText(part: unknown): string {
|
||||
if (typeof part === 'string') {
|
||||
return part
|
||||
}
|
||||
|
||||
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') {
|
||||
return part.text
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function messageContentText(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim()
|
||||
}
|
||||
|
||||
return Array.isArray(content) ? content.map(partText).join('').trim() : ''
|
||||
}
|
||||
|
||||
export const Thread: FC<{
|
||||
intro?: IntroProps
|
||||
loading?: ThreadLoadingState
|
||||
}> = ({ intro, loading }) => {
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const previousLoading = useRef<ThreadLoadingState | undefined>(undefined)
|
||||
onBranchInNewChat?: (messageId: string) => void
|
||||
}> = ({ intro, loading, onBranchInNewChat }) => {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const messageCount = useAuiState(s => s.thread.messages.length)
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
const lastMessageId = useAuiState(s => s.thread.messages.at(-1)?.id ?? '')
|
||||
const shouldStickToBottomRef = useRef(true)
|
||||
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget
|
||||
const nearBottom = isNearBottom(el)
|
||||
const nearBottom = isNearBottom(event.currentTarget)
|
||||
shouldStickToBottomRef.current = nearBottom
|
||||
setThreadScrolledUp(!nearBottom)
|
||||
|
||||
if (nearBottom) {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (event.deltaY < 0) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
|
||||
if (event.clientX >= rect.right - 18) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading === 'response' && previousLoading.current !== 'response') {
|
||||
setAutoScroll(true)
|
||||
}
|
||||
|
||||
previousLoading.current = loading
|
||||
}, [loading])
|
||||
|
||||
useEffect(() => {
|
||||
return () => setThreadScrolledUp(false)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
|
||||
const force = loading === 'session'
|
||||
|
||||
if (!force && !shouldStickToBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
shouldStickToBottomRef.current = true
|
||||
setThreadScrolledUp(false)
|
||||
}, [isRunning, lastMessageId, loading, messageCount])
|
||||
|
||||
return (
|
||||
<GeneratedImageProvider>
|
||||
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
|
||||
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
|
||||
|
||||
<ThreadPrimitive.Viewport
|
||||
autoScroll={autoScroll}
|
||||
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pb-32 pt-[calc(var(--vsq)*19)] scroll-smooth"
|
||||
className="h-full min-h-0 overflow-y-auto overscroll-contain px-[clamp(1rem,10%,12rem)] pt-[calc(var(--vsq)*19)] scroll-smooth"
|
||||
data-slot="aui_thread-viewport"
|
||||
onPointerDown={handlePointerDown}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
ref={viewportRef}
|
||||
scrollToBottomOnInitialize
|
||||
scrollToBottomOnRunStart
|
||||
scrollToBottomOnThreadSwitch
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<ThreadPrimitive.Messages>{() => <ThreadMessage />}</ThreadPrimitive.Messages>
|
||||
<ThreadPrimitive.Messages>{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}</ThreadPrimitive.Messages>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
{loading === 'working' && <WorkingIndicator />}
|
||||
</div>
|
||||
<ThreadPrimitive.ViewportFooter className="h-[220px] shrink-0" />
|
||||
</ThreadPrimitive.Viewport>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
</ThreadPrimitive.Root>
|
||||
@@ -138,11 +187,18 @@ const CenteredThreadSpinner: FC = () => (
|
||||
className="pointer-events-none absolute inset-0 z-1 grid place-items-center"
|
||||
role="status"
|
||||
>
|
||||
<LoaderCircleIcon aria-hidden="true" className="size-5 animate-spin text-muted-foreground/70" />
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className="size-12 text-primary/70"
|
||||
pathSteps={220}
|
||||
role="presentation"
|
||||
strokeScale={0.72}
|
||||
type="rose-curve"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ThreadMessage: FC = () => {
|
||||
const ThreadMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const role = useAuiState(s => s.message.role)
|
||||
const isEditing = useAuiState(s => s.message.composer.isEditing)
|
||||
|
||||
@@ -167,10 +223,14 @@ const ThreadMessage: FC = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AssistantMessage />
|
||||
return <AssistantMessage onBranchInNewChat={onBranchInNewChat} />
|
||||
}
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
|
||||
const messageId = useAuiState(s => s.message.id)
|
||||
const content = useAuiState(s => s.message.content)
|
||||
const messageText = messageContentText(content)
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="group flex w-full flex-col gap-2 self-start"
|
||||
@@ -195,12 +255,20 @@ const AssistantMessage: FC = () => {
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
<div className="min-h-6">
|
||||
<AssistantFooter />
|
||||
<AssistantFooter messageId={messageId} messageText={messageText} onBranchInNewChat={onBranchInNewChat} />
|
||||
</div>
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_ROW_CLASS = 'flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70'
|
||||
|
||||
const StatusRow: FC<{ children: ReactNode; label: string }> = ({ children, label }) => (
|
||||
<div aria-label={label} aria-live="polite" className={STATUS_ROW_CLASS} role="status">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const [tick, setTick] = useState(0)
|
||||
const elapsed = useElapsedSeconds()
|
||||
@@ -215,17 +283,24 @@ const ResponseLoadingIndicator: FC = () => {
|
||||
const verb = THINKING_VERBS[tick % THINKING_VERBS.length]
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Hermes is loading a response"
|
||||
aria-live="polite"
|
||||
className="flex max-w-full items-center gap-2 self-start text-sm text-muted-foreground/70"
|
||||
role="status"
|
||||
>
|
||||
<StatusRow label="Hermes is loading a response">
|
||||
<span className="shimmer shimmer-repeat-delay-0 min-w-0 truncate text-muted-foreground/55">
|
||||
{face} {verb}…
|
||||
</span>
|
||||
<ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />
|
||||
</div>
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkingIndicator: FC = () => {
|
||||
const elapsed = useElapsedSeconds()
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is still working">
|
||||
<Loader className="size-4 text-muted-foreground/60" label="Still working" strokeScale={0.65} type="spiral-search" />
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/60">Still working…</span>
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -267,7 +342,7 @@ const ThinkingDisclosure: FC<{
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,1fr)] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
@@ -279,7 +354,7 @@ const ThinkingDisclosure: FC<{
|
||||
>
|
||||
Thinking
|
||||
</span>
|
||||
{pending && <ActivityTimerBadge seconds={elapsed} tone={elapsed >= 20 ? 'warm' : 'muted'} />}
|
||||
{pending && <ActivityTimerText seconds={elapsed} />}
|
||||
</button>
|
||||
{open && <div className="ml-4 mt-1 max-w-full wrap-anywhere border-l border-border pl-3">{children}</div>}
|
||||
</div>
|
||||
@@ -301,67 +376,221 @@ const ReasoningPart: FC<{ text: string; status?: { type: string } }> = ({ text,
|
||||
</div>
|
||||
)
|
||||
|
||||
const AssistantActionBar: FC = () => (
|
||||
<div className="relative h-6 w-13 shrink-0">
|
||||
<ActionBarPrimitive.Root
|
||||
autohide="not-last"
|
||||
autohideFloat="always"
|
||||
className="absolute inset-0 flex gap-1 text-muted-foreground data-floating:opacity-0 data-floating:transition-opacity data-floating:duration-100 data-floating:group-hover:opacity-100 data-floating:focus-within:opacity-100"
|
||||
hideWhenRunning
|
||||
>
|
||||
<ActionBarPrimitive.Copy asChild copiedDuration={2000}>
|
||||
<TooltipIconButton className="group/copy" tooltip="Copy">
|
||||
<CopyIcon className="group-data-copied/copy:hidden" />
|
||||
<CheckIcon className="hidden group-data-copied/copy:block" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Copy>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
const TIME_FMT = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||
|
||||
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
month: 'short'
|
||||
})
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||
}
|
||||
|
||||
function formatMessageTimestamp(value: Date | string | number | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const dayDelta = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86_400_000)
|
||||
|
||||
if (dayDelta === 0) {
|
||||
return `Today, ${TIME_FMT.format(date)}`
|
||||
}
|
||||
|
||||
if (dayDelta === 1) {
|
||||
return `Yesterday, ${TIME_FMT.format(date)}`
|
||||
}
|
||||
|
||||
return SHORT_FMT.format(date)
|
||||
}
|
||||
|
||||
const ACTION_BAR_CLASS = cn(
|
||||
'absolute inset-0 flex gap-1 text-muted-foreground opacity-0 transition-opacity duration-100',
|
||||
'pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
'focus-within:pointer-events-auto focus-within:opacity-100'
|
||||
)
|
||||
|
||||
const AssistantFooter: FC = () => {
|
||||
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, messageText, onBranchInNewChat }) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-6 flex-col items-start gap-1">
|
||||
<BranchPickerPrimitive.Root
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
<div className="relative h-6 w-20 shrink-0">
|
||||
<ActionBarPrimitive.Root
|
||||
className={cn(ACTION_BAR_CLASS, menuOpen && 'pointer-events-auto opacity-100')}
|
||||
hideWhenRunning
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className={branchButtonClass}>
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className={branchButtonClass}>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
<AssistantActionBar />
|
||||
<CopyMessageButton text={messageText} />
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip="Refresh">
|
||||
<RefreshCwIcon />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TooltipIconButton tooltip="More actions">
|
||||
<MoreHorizontalIcon />
|
||||
</TooltipIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
|
||||
<MessageTimestamp />
|
||||
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
|
||||
<GitBranchIcon />
|
||||
Branch in new chat
|
||||
</DropdownMenuItem>
|
||||
<ReadAloudItem text={messageText} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ActionBarPrimitive.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CopyMessageButton: FC<{ text: string }> = ({ text }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = useCallback(async () => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
triggerHaptic('selection')
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
notifyError(error, 'Copy failed')
|
||||
}
|
||||
}, [text])
|
||||
|
||||
return (
|
||||
<TooltipIconButton disabled={!text} onClick={() => void copy()} tooltip={copied ? 'Copied' : 'Copy'}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</TooltipIconButton>
|
||||
)
|
||||
}
|
||||
|
||||
let currentAudio: HTMLAudioElement | null = null
|
||||
|
||||
function stopCurrentAudio() {
|
||||
if (!currentAudio) {
|
||||
return
|
||||
}
|
||||
|
||||
currentAudio.pause()
|
||||
currentAudio.src = ''
|
||||
currentAudio = null
|
||||
}
|
||||
|
||||
const ReadAloudItem: FC<{ text: string }> = ({ text }) => {
|
||||
const [reading, setReading] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
seqRef.current += 1
|
||||
stopCurrentAudio()
|
||||
setReading(false)
|
||||
}, [])
|
||||
|
||||
const read = useCallback(async () => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
stopCurrentAudio()
|
||||
const seq = ++seqRef.current
|
||||
const isCurrent = () => seq === seqRef.current
|
||||
|
||||
const finish = () => {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
|
||||
currentAudio = null
|
||||
setReading(false)
|
||||
}
|
||||
|
||||
setReading(true)
|
||||
|
||||
try {
|
||||
const { data_url } = await speakText(text)
|
||||
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio(data_url)
|
||||
currentAudio = audio
|
||||
audio.addEventListener('ended', finish, { once: true })
|
||||
audio.addEventListener('error', finish, { once: true })
|
||||
await audio.play()
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
notifyError(error, 'Read aloud failed')
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}, [text])
|
||||
|
||||
const Icon = reading ? VolumeXIcon : Volume2Icon
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
disabled={!reading && !text}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
void (reading ? stop() : read())
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
{reading ? 'Stop reading' : 'Read aloud'}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
const MessageTimestamp: FC = () => {
|
||||
const createdAt = useAuiState(s => s.message.createdAt)
|
||||
const label = formatMessageTimestamp(createdAt)
|
||||
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
}
|
||||
|
||||
const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
<div className="flex min-h-6 flex-col items-start gap-1">
|
||||
<BranchPickerPrimitive.Root
|
||||
className="inline-flex h-6 items-center gap-1 text-xs text-muted-foreground"
|
||||
hideWhenSingleBranch
|
||||
>
|
||||
<BranchPickerPrimitive.Previous className={branchButtonClass}>
|
||||
<ChevronLeftIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<span className="tabular-nums">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</span>
|
||||
<BranchPickerPrimitive.Next className={branchButtonClass}>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
<AssistantActionBar {...props} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const branchButtonClass =
|
||||
'grid size-6 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-35'
|
||||
|
||||
const ActivityTimerBadge: FC<{ seconds: number; tone?: 'muted' | 'warm' }> = ({ seconds, tone = 'muted' }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-[0.625rem] leading-none tabular-nums',
|
||||
tone === 'warm'
|
||||
? 'border-primary/20 bg-primary/8 text-primary'
|
||||
: 'border-border/70 bg-muted/40 text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const UserMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
|
||||
@@ -4,7 +4,8 @@ import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { formatElapsed, useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TOOL_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
@@ -164,7 +165,7 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
||||
{spinnerFrame}
|
||||
</span>
|
||||
) : null}
|
||||
{isPending && <ToolTimerBadge seconds={elapsed} />}
|
||||
{isPending && <ActivityTimerText seconds={elapsed} />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-4 mt-1 max-w-full whitespace-pre-wrap wrap-anywhere border-l border-border pl-3 text-xs leading-relaxed text-muted-foreground/85">
|
||||
@@ -176,15 +177,3 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
||||
)
|
||||
}
|
||||
|
||||
const ToolTimerBadge = ({ seconds }: { seconds: number }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-1.5 py-0.5 font-mono text-[0.625rem] leading-none tabular-nums',
|
||||
seconds >= 15
|
||||
? 'border-primary/20 bg-primary/8 text-primary'
|
||||
: 'border-border/70 bg-muted/40 text-muted-foreground/80'
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,829 +0,0 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import {
|
||||
ComposerPrimitive,
|
||||
type Unstable_IconComponent,
|
||||
type Unstable_MentionCategory,
|
||||
type Unstable_MentionDirective,
|
||||
unstable_useMentionAdapter,
|
||||
useAui,
|
||||
useAuiState
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ImageIcon,
|
||||
Link,
|
||||
type LucideIcon,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
Plus,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ClipboardEvent, type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
import { $composerAttachments, type ComposerAttachment } from '../store/composer'
|
||||
import { $threadScrolledUp } from '../store/thread-scroll'
|
||||
|
||||
import { hermesDirectiveFormatter } from './assistant-ui/directive-text'
|
||||
import { Button } from './ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from './ui/dropdown-menu'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
type ContextSuggestion = { text: string; display: string; meta?: string }
|
||||
|
||||
export type QuickModelOption = {
|
||||
provider: string
|
||||
providerName: string
|
||||
model: string
|
||||
}
|
||||
|
||||
export type ChatBarState = {
|
||||
model: {
|
||||
model: string
|
||||
provider: string
|
||||
canSwitch: boolean
|
||||
loading?: boolean
|
||||
quickModels?: QuickModelOption[]
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
}
|
||||
|
||||
type ChatBarProps = {
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
focusKey?: string | null
|
||||
state: ChatBarState
|
||||
onCancel: () => void
|
||||
onAddContextRef?: (refText: string, label?: string, detail?: string) => void
|
||||
onAddUrl?: (url: string) => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSubmit: (value: string) => void
|
||||
}
|
||||
|
||||
// Stacked = controls drop below the textarea.
|
||||
const STACK_AT = 500
|
||||
const NARROW_VIEWPORT = '(max-width: 680px)'
|
||||
const EXPAND_HEIGHT_PX = 42
|
||||
|
||||
const SHELL =
|
||||
'absolute bottom-0 left-1/2 z-30 w-[min(calc(100%_-_1rem),clamp(26rem,78%,56rem))] max-w-full -translate-x-1/2'
|
||||
|
||||
const ICON_BTN = 'h-8 w-8 shrink-0 rounded-full'
|
||||
|
||||
const GHOST_ICON_BTN = cn(ICON_BTN, 'text-muted-foreground hover:bg-accent hover:text-foreground')
|
||||
|
||||
const COMPOSER_BACKDROP_STYLE = {
|
||||
backdropFilter: 'blur(.5rem) saturate(1.18)',
|
||||
WebkitBackdropFilter: 'blur(.5rem) saturate(1.18)'
|
||||
} satisfies CSSProperties
|
||||
|
||||
const ATTACHMENT_ICON: Record<ComposerAttachment['kind'], LucideIcon> = {
|
||||
folder: FolderOpen,
|
||||
url: Link,
|
||||
image: ImageIcon,
|
||||
file: FileText
|
||||
}
|
||||
|
||||
const DIRECTIVE_ICONS: Record<string, Unstable_IconComponent> = {
|
||||
file: FileText,
|
||||
folder: FolderOpen,
|
||||
image: ImageIcon,
|
||||
url: Link
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
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.'
|
||||
}
|
||||
]
|
||||
|
||||
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.'
|
||||
]
|
||||
|
||||
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' }
|
||||
}
|
||||
]
|
||||
|
||||
const EDGE_NEWLINES_RE = /^[\t ]*(?:\r\n|\r|\n)+|(?:\r\n|\r|\n)+[\t ]*$/g
|
||||
|
||||
function trimPastedEdgeNewlines(text: string): string {
|
||||
return text.replace(EDGE_NEWLINES_RE, '')
|
||||
}
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
disabled,
|
||||
focusKey,
|
||||
state,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSubmit
|
||||
}: ChatBarProps) {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const attachments = useStore($composerAttachments)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
const [urlValue, setUrlValue] = useState('')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [stack, setStack] = useState(false)
|
||||
|
||||
const [askPlaceholder] = useState(
|
||||
() => ASK_PLACEHOLDERS[Math.floor(Math.random() * ASK_PLACEHOLDERS.length)] || 'Ask anything'
|
||||
)
|
||||
|
||||
const mentionCategories = useMemo(() => buildMentionCategories(state.tools.suggestions), [state.tools.suggestions])
|
||||
|
||||
const mention = unstable_useMentionAdapter({
|
||||
categories: mentionCategories,
|
||||
includeModelContextTools: false,
|
||||
formatter: hermesDirectiveFormatter,
|
||||
iconMap: DIRECTIVE_ICONS,
|
||||
fallbackIcon: FileText
|
||||
})
|
||||
|
||||
const stacked = expanded || stack
|
||||
const canSubmit = busy || draft.trim().length > 0 || attachments.length > 0
|
||||
|
||||
const focusInput = () => window.requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [disabled, focusKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus())
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return
|
||||
}
|
||||
|
||||
const wraps = (textareaRef.current?.scrollHeight ?? 0) > EXPAND_HEIGHT_PX
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(NARROW_VIEWPORT)
|
||||
|
||||
const update = () => {
|
||||
const w = composerRef.current?.getBoundingClientRect().width ?? window.innerWidth
|
||||
|
||||
setStack(mq.matches || w < STACK_AT)
|
||||
}
|
||||
|
||||
update()
|
||||
mq.addEventListener('change', update)
|
||||
const ro = new ResizeObserver(update)
|
||||
|
||||
if (composerRef.current) {
|
||||
ro.observe(composerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
mq.removeEventListener('change', update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const insertText = (text: string) => {
|
||||
const sep = draft && !draft.endsWith('\n') ? '\n' : ''
|
||||
aui.composer().setText(`${draft}${sep}${text}`)
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const pastedText = event.clipboardData.getData('text')
|
||||
|
||||
if (!pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedText = trimPastedEdgeNewlines(pastedText)
|
||||
|
||||
if (trimmedText === pastedText) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const textarea = event.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
|
||||
const nextDraft = textarea.value.slice(0, start) + trimmedText + textarea.value.slice(end)
|
||||
|
||||
const cursor = start + trimmedText.length
|
||||
|
||||
aui.composer().setText(nextDraft)
|
||||
window.requestAnimationFrame(() => {
|
||||
const current = textareaRef.current
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
current.focus()
|
||||
current.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (busy) {
|
||||
onCancel()
|
||||
} else if (draft.trim() || attachments.length > 0) {
|
||||
onSubmit(draft)
|
||||
aui.composer().setText('')
|
||||
}
|
||||
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const submitUrl = () => {
|
||||
const url = urlValue.trim()
|
||||
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
if (onAddUrl) {
|
||||
onAddUrl(url)
|
||||
} else {
|
||||
insertText(`@url:${url}`)
|
||||
}
|
||||
|
||||
setUrlValue('')
|
||||
setUrlOpen(false)
|
||||
}
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onAddContextRef={onAddContextRef}
|
||||
onInsertText={insertText}
|
||||
onOpenUrlDialog={() => setUrlOpen(true)}
|
||||
onPasteClipboardImage={onPasteClipboardImage}
|
||||
onPickFiles={onPickFiles}
|
||||
onPickFolders={onPickFolders}
|
||||
onPickImages={onPickImages}
|
||||
state={state}
|
||||
/>
|
||||
)
|
||||
|
||||
const controls = <ComposerControls busy={busy} canSubmit={canSubmit} disabled={disabled} state={state} />
|
||||
|
||||
const input = (
|
||||
<ComposerPrimitive.Input
|
||||
className={cn(
|
||||
'min-h-8 max-h-37.5 resize-none overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-48 flex-1'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onPaste={handlePaste}
|
||||
placeholder={disabled ? 'Starting Hermes...' : askPlaceholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
{mentionCategories.length > 0 && (
|
||||
<DirectivePopover
|
||||
adapter={mention.adapter}
|
||||
directive={mention.directive}
|
||||
fallbackIcon={mention.fallbackIcon ?? FileText}
|
||||
iconMap={mention.iconMap ?? DIRECTIVE_ICONS}
|
||||
/>
|
||||
)}
|
||||
<ComposerPrimitive.Root
|
||||
className={cn(SHELL, 'group/composer pb-4 pt-2')}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-0 bg-linear-to-b from-transparent to-background/55" />
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-[1.25rem] bg-card/1 transition-opacity duration-200 ease-out group-focus-within/composer:opacity-0"
|
||||
style={COMPOSER_BACKDROP_STYLE}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer transition-[opacity,background-color,border-color,box-shadow] duration-200 ease-out group-focus-within/composer:border-ring/40 group-focus-within/composer:bg-card group-focus-within/composer:opacity-100 group-focus-within/composer:shadow-composer-focus',
|
||||
scrolledUp
|
||||
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex w-full flex-col gap-1.5 overflow-hidden rounded-[1.25rem] px-2 py-1.5 transition-opacity duration-200 ease-out',
|
||||
scrolledUp
|
||||
? 'opacity-60 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
{stacked ? (
|
||||
<>
|
||||
{input}
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
{contextMenu}
|
||||
{controls}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full items-end gap-1.5">
|
||||
{contextMenu}
|
||||
{input}
|
||||
{controls}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
|
||||
<UrlDialog
|
||||
inputRef={urlInputRef}
|
||||
onChange={setUrlValue}
|
||||
onOpenChange={setUrlOpen}
|
||||
onSubmit={submitUrl}
|
||||
open={urlOpen}
|
||||
value={urlValue}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatBarFallback() {
|
||||
return (
|
||||
<div className={cn(SHELL, 'bg-linear-to-b from-transparent to-background/55 pb-4 pt-2')}>
|
||||
<div className="relative h-11 w-full">
|
||||
<div className="absolute inset-0 rounded-[1.25rem] bg-card/1" style={COMPOSER_BACKDROP_STYLE} />
|
||||
<div className="absolute inset-0 rounded-[1.25rem] border border-input/70 bg-card/72 shadow-composer" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerControls({
|
||||
busy,
|
||||
canSubmit,
|
||||
disabled,
|
||||
state
|
||||
}: {
|
||||
busy: boolean
|
||||
canSubmit: boolean
|
||||
disabled: boolean
|
||||
state: ChatBarState
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<VoiceButton state={state.voice} />
|
||||
<Button
|
||||
aria-label={busy ? 'Stop' : 'Send'}
|
||||
className={cn(ICON_BTN, 'p-0')}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
>
|
||||
{busy ? <span className="block size-3 rounded-[0.1875rem] bg-current" /> : <ArrowUp size={18} />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoiceButton({ state }: { state: ChatBarState['voice'] }) {
|
||||
const aria = state.active ? 'Voice mode active' : 'Voice input'
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={aria}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[active=true]:bg-accent data-[active=true]:text-foreground')}
|
||||
data-active={state.active}
|
||||
disabled={!state.enabled}
|
||||
size="icon"
|
||||
title={aria}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Mic size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(GHOST_ICON_BTN, 'data-[state=open]:bg-accent data-[state=open]:text-foreground')}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Add context</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folders
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Image from clipboard
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileText />
|
||||
<span>Suggested files</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{suggestions.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-muted-foreground">No suggestions</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
suggestions.map(item => (
|
||||
<DropdownMenuItem key={item.text} onSelect={() => choose(item)}>
|
||||
<FileText />
|
||||
<span className="min-w-0 flex-1 truncate">{item.display}</span>
|
||||
{item.meta && <span className="max-w-28 truncate text-xs text-muted-foreground">{item.meta}</span>}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{PROMPT_SNIPPETS.map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
children,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: LucideIcon
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
<span>{children}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentList({
|
||||
attachments,
|
||||
onRemove
|
||||
}: {
|
||||
attachments: ComposerAttachment[]
|
||||
onRemove?: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 px-1 pt-1">
|
||||
{attachments.map(a => (
|
||||
<AttachmentPill attachment={a} key={a.id} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachment; onRemove?: (id: string) => void }) {
|
||||
const Icon = ATTACHMENT_ICON[attachment.kind]
|
||||
|
||||
return (
|
||||
<div className="group/attachment flex max-w-full items-center gap-2 rounded-2xl border border-border/70 bg-muted/35 py-1 pl-1 pr-1.5 text-xs text-foreground/90">
|
||||
{attachment.previewUrl ? (
|
||||
<img alt="" className="size-9 rounded-xl object-cover" draggable={false} src={attachment.previewUrl} />
|
||||
) : (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-background/70 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="truncate font-medium">{attachment.label}</span>
|
||||
{attachment.detail && (
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground">{attachment.detail}</span>
|
||||
)}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
aria-label={`Remove ${attachment.label}`}
|
||||
className="grid size-5 shrink-0 place-items-center rounded-full text-muted-foreground opacity-70 transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100"
|
||||
onClick={() => onRemove(attachment.id)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectivePopover({
|
||||
adapter,
|
||||
directive,
|
||||
fallbackIcon: Fallback,
|
||||
iconMap
|
||||
}: {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
directive: Unstable_MentionDirective
|
||||
fallbackIcon: Unstable_IconComponent
|
||||
iconMap: Record<string, Unstable_IconComponent>
|
||||
}) {
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopover adapter={adapter} char="@" className={DIRECTIVE_POPOVER_CLASS}>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
{categories => (
|
||||
<div className="grid gap-1">
|
||||
{categories.map(c => (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
|
||||
categoryId={c.id}
|
||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
|
||||
key={c.id}
|
||||
>
|
||||
<span>{c.label}</span>
|
||||
<ChevronDown className="-rotate-90 size-3.5 text-muted-foreground" />
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
{items => (
|
||||
<div className="grid gap-1">
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverBack className="mb-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
Back
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverBack>
|
||||
{items.map((item, index) => {
|
||||
const Icon = directiveIcon(item, iconMap, Fallback)
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm hover:bg-accent data-highlighted:bg-accent"
|
||||
index={index}
|
||||
item={item}
|
||||
key={`${item.type}:${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="grid min-w-0 flex-1 gap-0.5">
|
||||
<span className="truncate font-medium">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
)
|
||||
}
|
||||
|
||||
function UrlDialog({
|
||||
inputRef,
|
||||
onChange,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
open,
|
||||
value
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
onChange: (value: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: () => void
|
||||
open: boolean
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add URL Context</DialogTitle>
|
||||
<DialogDescription>
|
||||
Hermes will fetch this URL via the existing @url context resolver when you send the prompt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!value.trim()} type="submit">
|
||||
Add URL
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function buildMentionCategories(suggestions: ContextSuggestion[] | undefined): Unstable_MentionCategory[] {
|
||||
const items = (suggestions ?? [])
|
||||
.map(s => {
|
||||
const match = s.text.match(/^@(file|folder|url|image):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [, type, id] = match
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
label: s.display || id,
|
||||
description: s.meta,
|
||||
metadata: { icon: type }
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
return [
|
||||
{ id: 'refs', label: 'Hermes refs', items: REF_ITEMS },
|
||||
...(items.length ? [{ id: 'context', label: 'Suggested files', items }] : [])
|
||||
]
|
||||
}
|
||||
|
||||
function directiveIcon(
|
||||
item: Unstable_TriggerItem,
|
||||
iconMap: Record<string, Unstable_IconComponent>,
|
||||
fallback: Unstable_IconComponent
|
||||
): Unstable_IconComponent {
|
||||
const meta = item.metadata as Record<string, unknown> | undefined
|
||||
const key = typeof meta?.icon === 'string' ? meta.icon : item.type
|
||||
|
||||
return iconMap[key] ?? iconMap[item.type] ?? fallback
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
import { useWebHaptics } from 'web-haptics/react'
|
||||
|
||||
import { registerHapticTrigger } from '@/lib/haptics'
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
export function HapticsProvider({ children }: { children: ReactNode }) {
|
||||
const muted = useStore($hapticsMuted)
|
||||
const { trigger } = useWebHaptics({ debug: true, showSwitch: false })
|
||||
|
||||
useEffect(() => {
|
||||
registerHapticTrigger(muted ? null : trigger)
|
||||
|
||||
return () => registerHapticTrigger(null)
|
||||
}, [muted, trigger])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, type LucideIcon, X } from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Copy, Info, type LucideIcon, X } from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$notifications,
|
||||
@@ -12,33 +13,21 @@ import {
|
||||
type NotificationKind
|
||||
} from '@/store/notifications'
|
||||
|
||||
const tone: Record<
|
||||
NotificationKind,
|
||||
{
|
||||
icon: LucideIcon
|
||||
variant: 'default' | 'destructive' | 'warning' | 'success'
|
||||
}
|
||||
> = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
variant: 'destructive'
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
variant: 'warning'
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
variant: 'default'
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
variant: 'success'
|
||||
}
|
||||
type ToneVariant = 'default' | 'destructive' | 'warning' | 'success'
|
||||
|
||||
const tone: Record<NotificationKind, { icon: LucideIcon; iconClass: string; variant: ToneVariant }> = {
|
||||
error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' },
|
||||
warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' },
|
||||
info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' },
|
||||
success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' }
|
||||
}
|
||||
|
||||
const STACK_SURFACE = 'pointer-events-auto border-border/80 bg-popover/95 shadow-lg shadow-black/5 backdrop-blur-md'
|
||||
const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
|
||||
|
||||
export function NotificationStack() {
|
||||
const notifications = useStore($notifications)
|
||||
const lastNotificationIdRef = useRef<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,6 +36,24 @@ export function NotificationStack() {
|
||||
}
|
||||
}, [notifications.length])
|
||||
|
||||
useEffect(() => {
|
||||
const latest = notifications[0]
|
||||
|
||||
if (!latest || latest.id === lastNotificationIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastNotificationIdRef.current = latest.id
|
||||
|
||||
if (latest.kind === 'success') {
|
||||
triggerHaptic('success')
|
||||
} else if (latest.kind === 'error') {
|
||||
triggerHaptic('error')
|
||||
} else if (latest.kind === 'warning') {
|
||||
triggerHaptic('warning')
|
||||
}
|
||||
}, [notifications])
|
||||
|
||||
if (notifications.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -61,26 +68,17 @@ export function NotificationStack() {
|
||||
role="region"
|
||||
>
|
||||
<NotificationItem notification={latest} />
|
||||
{expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)}
|
||||
{overflowCount > 0 && (
|
||||
<div className="pointer-events-auto flex min-h-8 items-center justify-between rounded-lg border border-border bg-card/80 px-3 text-xs text-muted-foreground shadow-xs">
|
||||
<button
|
||||
className="bg-transparent font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded(value => !value)}
|
||||
type="button"
|
||||
>
|
||||
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
|
||||
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
|
||||
{expanded ? 'Hide' : 'Show'} {overflowCount} more {overflowCount === 1 ? 'notification' : 'notifications'}
|
||||
</button>
|
||||
<button
|
||||
className="bg-transparent text-muted-foreground hover:text-foreground"
|
||||
onClick={clearNotifications}
|
||||
type="button"
|
||||
>
|
||||
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{expanded &&
|
||||
olderNotifications.map(notification => <NotificationItem key={notification.id} notification={notification} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,19 +86,21 @@ export function NotificationStack() {
|
||||
function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
const styles = tone[notification.kind]
|
||||
const Icon = styles.icon
|
||||
const hasDetail = Boolean(notification.detail && notification.detail !== notification.message)
|
||||
|
||||
return (
|
||||
<Alert
|
||||
aria-live={notification.kind === 'error' ? 'assertive' : 'polite'}
|
||||
className="pointer-events-auto grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5 shadow-lg"
|
||||
className={cn(STACK_SURFACE, 'grid-cols-[auto_minmax(0,1fr)_auto] pr-2.5')}
|
||||
role={notification.kind === 'error' ? 'alert' : 'status'}
|
||||
variant={styles.variant}
|
||||
variant="default"
|
||||
>
|
||||
<Icon />
|
||||
<Icon className={styles.iconClass} />
|
||||
<div className="col-start-2 min-w-0">
|
||||
{notification.title && <AlertTitle className="col-start-auto">{notification.title}</AlertTitle>}
|
||||
<AlertDescription className="col-start-auto">
|
||||
<p className="m-0">{notification.message}</p>
|
||||
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<button
|
||||
@@ -115,6 +115,39 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
async function copyDetail() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(detail)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1200)
|
||||
} catch {
|
||||
// Best effort; details remain visible even if clipboard access fails.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
Details
|
||||
</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">{detail}</pre>
|
||||
<button
|
||||
className="mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={copyDetail}
|
||||
type="button"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
{copied ? 'Copied' : 'Copy detail'}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineNotice({
|
||||
kind = 'info',
|
||||
title,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageLoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function PageLoader({
|
||||
'aria-label': ariaLabel,
|
||||
className,
|
||||
label = 'Loading',
|
||||
role = 'status',
|
||||
...props
|
||||
}: PageLoaderProps) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={cn('grid h-full place-items-center', className)}
|
||||
role={role}
|
||||
>
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className="size-10 text-primary/70"
|
||||
pathSteps={220}
|
||||
role="presentation"
|
||||
strokeScale={0.72}
|
||||
type="rose-curve"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,6 +198,12 @@ function WorkspaceSection({
|
||||
)
|
||||
}
|
||||
|
||||
function personalityOptionKey(value?: string): string {
|
||||
const key = value?.trim().toLowerCase() || 'none'
|
||||
|
||||
return key === 'default' ? 'none' : key
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
label: modelLabel,
|
||||
onOpen,
|
||||
@@ -217,12 +223,12 @@ function AgentSection({
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const merged = useMemo(
|
||||
() => [...new Set(['default', ...options, current].map(s => s?.trim().toLowerCase()).filter(Boolean))],
|
||||
() => [...new Set(['none', ...options, current].map(personalityOptionKey).filter(Boolean))],
|
||||
[current, options]
|
||||
)
|
||||
|
||||
const activeKey = (current || 'default').trim().toLowerCase()
|
||||
const personalityLabel = current ? titleize(current) : 'Default'
|
||||
const activeKey = personalityOptionKey(current)
|
||||
const personalityLabel = activeKey === 'none' ? 'None' : titleize(activeKey)
|
||||
|
||||
return (
|
||||
<section className="grid gap-1.5 py-1.5">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-overlay"
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
import { type ComponentProps, useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const LOADER_TYPES = [
|
||||
'original-thinking',
|
||||
'thinking-five',
|
||||
'thinking-nine',
|
||||
'rose-orbit',
|
||||
'rose-curve',
|
||||
'rose-two',
|
||||
'rose-three',
|
||||
'rose-four',
|
||||
'lissajous-drift',
|
||||
'lemniscate-bloom',
|
||||
'hypotrochoid-loop',
|
||||
'three-petal-spiral',
|
||||
'four-petal-spiral',
|
||||
'five-petal-spiral',
|
||||
'six-petal-spiral',
|
||||
'butterfly-phase',
|
||||
'cardioid-glow',
|
||||
'cardioid-heart',
|
||||
'heart-wave',
|
||||
'spiral-search',
|
||||
'fourier-flow'
|
||||
] as const
|
||||
|
||||
export type LoaderType = (typeof LOADER_TYPES)[number]
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface LoaderCurve {
|
||||
durationMs: number
|
||||
name: string
|
||||
particleCount: number
|
||||
point: (progress: number, detailScale: number) => Point
|
||||
pulseDurationMs: number
|
||||
rotate: boolean
|
||||
rotationDurationMs: number
|
||||
strokeWidth: number
|
||||
trailSpan: number
|
||||
}
|
||||
|
||||
interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||
label?: string
|
||||
pathSteps?: number
|
||||
strokeScale?: number
|
||||
type?: LoaderType
|
||||
}
|
||||
|
||||
interface BaseCurveOptions extends Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'> {
|
||||
point?: LoaderCurve['point']
|
||||
rotate?: boolean
|
||||
rotationDurationMs?: number
|
||||
}
|
||||
|
||||
const TWO_PI = Math.PI * 2
|
||||
|
||||
const LOADER_CURVES: Record<LoaderType, LoaderCurve> = {
|
||||
'original-thinking': thinkingCurve('Original Thinking', 7, {
|
||||
durationMs: 4600,
|
||||
particleCount: 64,
|
||||
pulseDurationMs: 4200,
|
||||
rotationDurationMs: 28000,
|
||||
trailSpan: 0.38
|
||||
}),
|
||||
'thinking-five': thinkingCurve('Thinking Five', 5, {
|
||||
durationMs: 4600,
|
||||
particleCount: 62,
|
||||
pulseDurationMs: 4200,
|
||||
rotationDurationMs: 28000,
|
||||
trailSpan: 0.38
|
||||
}),
|
||||
'thinking-nine': thinkingCurve('Thinking Nine', 9, {
|
||||
durationMs: 4700,
|
||||
particleCount: 68,
|
||||
pulseDurationMs: 4200,
|
||||
rotationDurationMs: 30000,
|
||||
trailSpan: 0.39
|
||||
}),
|
||||
'rose-orbit': {
|
||||
...baseCurve('Rose Orbit', {
|
||||
durationMs: 5200,
|
||||
particleCount: 72,
|
||||
pulseDurationMs: 4600,
|
||||
rotate: true,
|
||||
rotationDurationMs: 28000,
|
||||
strokeWidth: 5.2,
|
||||
trailSpan: 0.42
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const r = 7 - 2.7 * detailScale * Math.cos(7 * t)
|
||||
|
||||
return {
|
||||
x: 50 + Math.cos(t) * r * 3.9,
|
||||
y: 50 + Math.sin(t) * r * 3.9
|
||||
}
|
||||
}
|
||||
},
|
||||
'rose-curve': roseCurve('Rose Curve', 5, {
|
||||
durationMs: 5400,
|
||||
particleCount: 78,
|
||||
pulseDurationMs: 4600,
|
||||
strokeWidth: 4.5,
|
||||
trailSpan: 0.32
|
||||
}),
|
||||
'rose-two': roseCurve('Rose Two', 2, {
|
||||
durationMs: 5200,
|
||||
particleCount: 74,
|
||||
pulseDurationMs: 4300,
|
||||
strokeWidth: 4.6,
|
||||
trailSpan: 0.3
|
||||
}),
|
||||
'rose-three': roseCurve('Rose Three', 3, {
|
||||
durationMs: 5300,
|
||||
particleCount: 76,
|
||||
pulseDurationMs: 4400,
|
||||
strokeWidth: 4.6,
|
||||
trailSpan: 0.31
|
||||
}),
|
||||
'rose-four': roseCurve('Rose Four', 4, {
|
||||
durationMs: 5400,
|
||||
particleCount: 78,
|
||||
pulseDurationMs: 4500,
|
||||
strokeWidth: 4.6,
|
||||
trailSpan: 0.32
|
||||
}),
|
||||
'lissajous-drift': {
|
||||
...baseCurve('Lissajous Drift', {
|
||||
durationMs: 6000,
|
||||
particleCount: 68,
|
||||
pulseDurationMs: 5400,
|
||||
strokeWidth: 4.7,
|
||||
trailSpan: 0.34
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const amp = 24 + detailScale * 6
|
||||
|
||||
return {
|
||||
x: 50 + Math.sin(3 * t + 1.57) * amp,
|
||||
y: 50 + Math.sin(4 * t) * (amp * 0.92)
|
||||
}
|
||||
}
|
||||
},
|
||||
'lemniscate-bloom': {
|
||||
...baseCurve('Lemniscate Bloom', {
|
||||
durationMs: 5600,
|
||||
particleCount: 70,
|
||||
pulseDurationMs: 5000,
|
||||
rotationDurationMs: 34000,
|
||||
strokeWidth: 4.8,
|
||||
trailSpan: 0.4
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const scale = 20 + detailScale * 7
|
||||
const denom = 1 + Math.sin(t) ** 2
|
||||
|
||||
return {
|
||||
x: 50 + (scale * Math.cos(t)) / denom,
|
||||
y: 50 + (scale * Math.sin(t) * Math.cos(t)) / denom
|
||||
}
|
||||
}
|
||||
},
|
||||
'hypotrochoid-loop': {
|
||||
...baseCurve('Hypotrochoid Loop', {
|
||||
durationMs: 7600,
|
||||
particleCount: 82,
|
||||
pulseDurationMs: 6200,
|
||||
rotationDurationMs: 42000,
|
||||
strokeWidth: 4.6,
|
||||
trailSpan: 0.46
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const r = 2.7 + detailScale * 0.45
|
||||
const d = 4.8 + detailScale * 1.2
|
||||
const x = (8.2 - r) * Math.cos(t) + d * Math.cos(((8.2 - r) / r) * t)
|
||||
const y = (8.2 - r) * Math.sin(t) - d * Math.sin(((8.2 - r) / r) * t)
|
||||
|
||||
return {
|
||||
x: 50 + x * 3.05,
|
||||
y: 50 + y * 3.05
|
||||
}
|
||||
}
|
||||
},
|
||||
'three-petal-spiral': spiralPetalCurve('Three-Petal Spiral', 3, 82),
|
||||
'four-petal-spiral': spiralPetalCurve('Four-Petal Spiral', 4, 84),
|
||||
'five-petal-spiral': spiralPetalCurve('Five-Petal Spiral', 5, 85),
|
||||
'six-petal-spiral': spiralPetalCurve('Six-Petal Spiral', 6, 86),
|
||||
'butterfly-phase': {
|
||||
...baseCurve('Butterfly Phase', {
|
||||
durationMs: 9000,
|
||||
particleCount: 88,
|
||||
pulseDurationMs: 7000,
|
||||
rotationDurationMs: 50000,
|
||||
strokeWidth: 4.4,
|
||||
trailSpan: 0.32
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * Math.PI * 12
|
||||
|
||||
const butterfly =
|
||||
Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5
|
||||
|
||||
const scale = 4.6 + detailScale * 0.45
|
||||
|
||||
return {
|
||||
x: 50 + Math.sin(t) * butterfly * scale,
|
||||
y: 50 + Math.cos(t) * butterfly * scale
|
||||
}
|
||||
}
|
||||
},
|
||||
'cardioid-glow': cardioidCurve('Cardioid Glow', {
|
||||
a: 8.4,
|
||||
particleCount: 72,
|
||||
pointFor(t, r, scale) {
|
||||
return {
|
||||
x: 50 + Math.cos(t) * r * scale,
|
||||
y: 50 + Math.sin(t) * r * scale
|
||||
}
|
||||
},
|
||||
rFor(t, a) {
|
||||
return a * (1 - Math.cos(t))
|
||||
}
|
||||
}),
|
||||
'cardioid-heart': cardioidCurve('Cardioid Heart', {
|
||||
a: 8.8,
|
||||
particleCount: 74,
|
||||
pointFor(t, r, scale) {
|
||||
const baseX = Math.cos(t) * r
|
||||
const baseY = Math.sin(t) * r
|
||||
|
||||
return {
|
||||
x: 50 - baseY * scale,
|
||||
y: 50 - baseX * scale
|
||||
}
|
||||
},
|
||||
rFor(t, a) {
|
||||
return a * (1 + Math.cos(t))
|
||||
}
|
||||
}),
|
||||
'heart-wave': {
|
||||
...baseCurve('Heart Wave', {
|
||||
durationMs: 8400,
|
||||
particleCount: 104,
|
||||
pulseDurationMs: 5600,
|
||||
rotationDurationMs: 22000,
|
||||
strokeWidth: 3.9,
|
||||
trailSpan: 0.18
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const root = 3.3
|
||||
const xLimit = Math.sqrt(root)
|
||||
const x = -xLimit + progress * xLimit * 2
|
||||
const safeRoot = Math.max(0, root - x * x)
|
||||
const wave = 0.9 * Math.sqrt(safeRoot) * Math.sin(6.4 * Math.PI * x)
|
||||
const curve = Math.abs(x) ** (2 / 3)
|
||||
const y = curve + wave
|
||||
|
||||
return {
|
||||
x: 50 + x * 23.2,
|
||||
y: 18 + (1.75 - y) * (24.5 + detailScale * 1.5)
|
||||
}
|
||||
}
|
||||
},
|
||||
'spiral-search': {
|
||||
...baseCurve('Spiral Search', {
|
||||
durationMs: 7800,
|
||||
particleCount: 86,
|
||||
pulseDurationMs: 6800,
|
||||
rotationDurationMs: 44000,
|
||||
strokeWidth: 4.3,
|
||||
trailSpan: 0.28
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const angle = t * 4
|
||||
const radius = 8 + (1 - Math.cos(t)) * (8.5 + detailScale * 2.4)
|
||||
|
||||
return {
|
||||
x: 50 + Math.cos(angle) * radius,
|
||||
y: 50 + Math.sin(angle) * radius
|
||||
}
|
||||
}
|
||||
},
|
||||
'fourier-flow': {
|
||||
...baseCurve('Fourier Flow', {
|
||||
durationMs: 8400,
|
||||
particleCount: 92,
|
||||
pulseDurationMs: 6800,
|
||||
rotationDurationMs: 44000,
|
||||
strokeWidth: 4.2,
|
||||
trailSpan: 0.31
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const mix = 1 + detailScale * 0.16
|
||||
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
|
||||
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
|
||||
|
||||
return {
|
||||
x: 50 + x,
|
||||
y: 50 + y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function Loader({
|
||||
className,
|
||||
label = 'Loading',
|
||||
pathSteps = 240,
|
||||
role = 'status',
|
||||
strokeScale = 1,
|
||||
type = 'rose-curve',
|
||||
...props
|
||||
}: LoaderProps) {
|
||||
const config = LOADER_CURVES[type]
|
||||
const groupRef = useRef<SVGGElement | null>(null)
|
||||
const particleRefs = useRef<Array<SVGCircleElement | null>>([])
|
||||
const pathRef = useRef<SVGPathElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrame = 0
|
||||
const startedAt = performance.now()
|
||||
const phaseOffset = Math.random()
|
||||
particleRefs.current.length = config.particleCount
|
||||
|
||||
const render = (now: number) => {
|
||||
const time = now - startedAt
|
||||
const progress = ((time + phaseOffset * config.durationMs) % config.durationMs) / config.durationMs
|
||||
const detailScale = detailScaleFor(time, config, phaseOffset)
|
||||
const rotation = rotationFor(time, config, phaseOffset)
|
||||
|
||||
groupRef.current?.setAttribute('transform', `rotate(${rotation} 50 50)`)
|
||||
pathRef.current?.setAttribute('d', buildPath(config, detailScale, pathSteps))
|
||||
|
||||
particleRefs.current.forEach((node, index) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const particle = particleFor(config, index, progress, detailScale, strokeScale)
|
||||
node.setAttribute('cx', particle.x.toFixed(2))
|
||||
node.setAttribute('cy', particle.y.toFixed(2))
|
||||
node.setAttribute('r', particle.radius.toFixed(2))
|
||||
node.setAttribute('opacity', particle.opacity.toFixed(3))
|
||||
})
|
||||
|
||||
animationFrame = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render(performance.now())
|
||||
|
||||
return () => window.cancelAnimationFrame(animationFrame)
|
||||
}, [config, pathSteps, strokeScale])
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={props['aria-label'] ?? label}
|
||||
className={cn('inline-grid size-10 place-items-center text-primary', className)}
|
||||
role={role}
|
||||
>
|
||||
<svg aria-hidden="true" className="size-full overflow-visible" fill="none" viewBox="0 0 100 100">
|
||||
<g ref={groupRef}>
|
||||
<path
|
||||
opacity="0.1"
|
||||
ref={pathRef}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={config.strokeWidth * strokeScale}
|
||||
/>
|
||||
{Array.from({ length: config.particleCount }, (_, index) => (
|
||||
<circle
|
||||
fill="currentColor"
|
||||
key={`${type}-${index}`}
|
||||
ref={node => {
|
||||
particleRefs.current[index] = node
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function baseCurve(name: string, options: BaseCurveOptions): LoaderCurve {
|
||||
return {
|
||||
durationMs: options.durationMs,
|
||||
name,
|
||||
particleCount: options.particleCount,
|
||||
point: options.point ?? (() => ({ x: 50, y: 50 })),
|
||||
pulseDurationMs: options.pulseDurationMs,
|
||||
rotate: options.rotate ?? false,
|
||||
rotationDurationMs: options.rotationDurationMs ?? 36000,
|
||||
strokeWidth: options.strokeWidth,
|
||||
trailSpan: options.trailSpan
|
||||
}
|
||||
}
|
||||
|
||||
function thinkingCurve(
|
||||
name: string,
|
||||
petalCount: number,
|
||||
options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'rotationDurationMs' | 'trailSpan'>
|
||||
): LoaderCurve {
|
||||
return {
|
||||
...baseCurve(name, {
|
||||
...options,
|
||||
rotate: true,
|
||||
strokeWidth: 5.5
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const x = 7 * Math.cos(t) - 3 * detailScale * Math.cos(petalCount * t)
|
||||
const y = 7 * Math.sin(t) - 3 * detailScale * Math.sin(petalCount * t)
|
||||
|
||||
return {
|
||||
x: 50 + x * 3.9,
|
||||
y: 50 + y * 3.9
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function roseCurve(
|
||||
name: string,
|
||||
k: number,
|
||||
options: Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'>
|
||||
): LoaderCurve {
|
||||
return {
|
||||
...baseCurve(name, {
|
||||
...options,
|
||||
rotate: true,
|
||||
rotationDurationMs: 28000
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const a = 9.2 + detailScale * 0.6
|
||||
const r = a * (0.72 + detailScale * 0.28) * Math.cos(k * t)
|
||||
|
||||
return {
|
||||
x: 50 + Math.cos(t) * r * 3.25,
|
||||
y: 50 + Math.sin(t) * r * 3.25
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spiralPetalCurve(name: string, spiralR: number, particleCount: number): LoaderCurve {
|
||||
return {
|
||||
...baseCurve(name, {
|
||||
durationMs: 4600,
|
||||
particleCount,
|
||||
pulseDurationMs: 4200,
|
||||
rotate: true,
|
||||
rotationDurationMs: 28000,
|
||||
strokeWidth: 4.4,
|
||||
trailSpan: 0.34
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const spiralr = 1
|
||||
const d = 3 + detailScale * 0.25
|
||||
const baseX = (spiralR - spiralr) * Math.cos(t) + d * Math.cos(((spiralR - spiralr) / spiralr) * t)
|
||||
const baseY = (spiralR - spiralr) * Math.sin(t) - d * Math.sin(((spiralR - spiralr) / spiralr) * t)
|
||||
const scale = 2.2 + detailScale * 0.45
|
||||
|
||||
return {
|
||||
x: 50 + baseX * scale,
|
||||
y: 50 + baseY * scale
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cardioidCurve(
|
||||
name: string,
|
||||
options: {
|
||||
a: number
|
||||
particleCount: number
|
||||
pointFor: (t: number, r: number, scale: number) => Point
|
||||
rFor: (t: number, a: number) => number
|
||||
}
|
||||
): LoaderCurve {
|
||||
return {
|
||||
...baseCurve(name, {
|
||||
durationMs: 6200,
|
||||
particleCount: options.particleCount,
|
||||
pulseDurationMs: 5200,
|
||||
rotationDurationMs: 36000,
|
||||
strokeWidth: 4.9,
|
||||
trailSpan: 0.36
|
||||
}),
|
||||
point(progress, detailScale) {
|
||||
const t = progress * TWO_PI
|
||||
const a = options.a + detailScale * 0.8
|
||||
const r = options.rFor(t, a)
|
||||
|
||||
return options.pointFor(t, r, 2.15)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPath(config: LoaderCurve, detailScale: number, steps: number) {
|
||||
return Array.from({ length: steps + 1 }, (_, index) => {
|
||||
const point = config.point(index / steps, detailScale)
|
||||
|
||||
return `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
function detailScaleFor(time: number, config: LoaderCurve, phaseOffset: number) {
|
||||
const pulseProgress = ((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs
|
||||
const pulseAngle = pulseProgress * TWO_PI
|
||||
|
||||
return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48
|
||||
}
|
||||
|
||||
function normalizeProgress(progress: number) {
|
||||
return ((progress % 1) + 1) % 1
|
||||
}
|
||||
|
||||
function particleFor(config: LoaderCurve, index: number, progress: number, detailScale: number, strokeScale: number) {
|
||||
const tailOffset = index / (config.particleCount - 1)
|
||||
const point = config.point(normalizeProgress(progress - tailOffset * config.trailSpan), detailScale)
|
||||
const fade = (1 - tailOffset) ** 0.56
|
||||
|
||||
return {
|
||||
opacity: 0.04 + fade * 0.96,
|
||||
radius: (0.9 + fade * 2.7) * strokeScale,
|
||||
x: point.x,
|
||||
y: point.y
|
||||
}
|
||||
}
|
||||
|
||||
function rotationFor(time: number, config: LoaderCurve, phaseOffset: number) {
|
||||
if (!config.rotate) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return -(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function SheetOverlay({ className, ...props }: React.ComponentProps<typeof Sheet
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
data-slot="sheet-overlay"
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ declare global {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
readFileDataUrl: (filePath: string) => Promise<string>
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
|
||||
+37
-23
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
ConfigSchemaResponse,
|
||||
ElevenLabsVoicesResponse,
|
||||
EnvVarInfo,
|
||||
HermesConfig,
|
||||
HermesConfigRecord,
|
||||
@@ -7,15 +10,18 @@ import type {
|
||||
ModelOptionsResponse,
|
||||
PaginatedSessions,
|
||||
RpcEvent,
|
||||
SessionInfo,
|
||||
SessionMessagesResponse,
|
||||
SkillInfo,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
export type {
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
ConfigFieldSchema,
|
||||
ConfigSchemaResponse,
|
||||
ElevenLabsVoice,
|
||||
ElevenLabsVoicesResponse,
|
||||
EnvVarInfo,
|
||||
GatewayReadyPayload,
|
||||
HermesConfig,
|
||||
@@ -175,30 +181,13 @@ export class HermesGateway {
|
||||
}
|
||||
|
||||
export async function listSessions(limit = 40): Promise<PaginatedSessions> {
|
||||
const pageSize = Math.max(limit, 50)
|
||||
const collected: SessionInfo[] = []
|
||||
let offset = 0
|
||||
let total = 0
|
||||
|
||||
while (collected.length < limit) {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${pageSize}&offset=${offset}`
|
||||
})
|
||||
|
||||
total = result.total
|
||||
collected.push(...result.sessions.filter(session => session.message_count > 0))
|
||||
|
||||
offset += result.sessions.length
|
||||
|
||||
if (result.sessions.length === 0 || offset >= result.total) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=1`
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: collected.slice(0, limit),
|
||||
total,
|
||||
limit,
|
||||
...result,
|
||||
sessions: result.sessions.slice(0, limit),
|
||||
offset: 0
|
||||
}
|
||||
}
|
||||
@@ -324,3 +313,28 @@ export function setGlobalModel(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
|
||||
return window.hermesDesktop.api<AudioTranscriptionResponse>({
|
||||
path: '/api/audio/transcribe',
|
||||
method: 'POST',
|
||||
body: {
|
||||
data_url: dataUrl,
|
||||
mime_type: mimeType
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function speakText(text: string): Promise<AudioSpeakResponse> {
|
||||
return window.hermesDesktop.api<AudioSpeakResponse>({
|
||||
path: '/api/audio/speak',
|
||||
method: 'POST',
|
||||
body: { text }
|
||||
})
|
||||
}
|
||||
|
||||
export function getElevenLabsVoices(): Promise<ElevenLabsVoicesResponse> {
|
||||
return window.hermesDesktop.api<ElevenLabsVoicesResponse>({
|
||||
path: '/api/audio/elevenlabs/voices'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,6 +244,32 @@ function applyStoredToolResult(messages: ChatMessage[], toolMessage: SessionMess
|
||||
return false
|
||||
}
|
||||
|
||||
function applyStoredToolResultToParts(parts: ChatMessagePart[], toolMessage: SessionMessage): ChatMessagePart[] | null {
|
||||
const toolCallId = toolMessage.tool_call_id || undefined
|
||||
const toolName = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const content = toolMessage.content || toolMessage.text || toolMessage.context || toolMessage.name || ''
|
||||
|
||||
const partIndex = parts.findIndex(
|
||||
part =>
|
||||
part.type === 'tool-call' &&
|
||||
((toolCallId && part.toolCallId === toolCallId) || (!toolCallId && part.toolName === toolName))
|
||||
)
|
||||
|
||||
if (partIndex < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const next = [...parts]
|
||||
const existing = next[partIndex]
|
||||
next[partIndex] = {
|
||||
...existing,
|
||||
result: parseStoredToolResult(content),
|
||||
isError: false
|
||||
} as ChatMessagePart
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: number): ChatMessagePart {
|
||||
const name = toolMessage.tool_name || toolMessage.name || 'tool'
|
||||
const context = toolMessage.context || toolMessage.text || toolMessage.content || ''
|
||||
@@ -260,6 +286,42 @@ function storedToolMessagePart(toolMessage: SessionMessage, fallbackIndex: numbe
|
||||
}
|
||||
}
|
||||
|
||||
function withUniqueToolCallIds(messages: ChatMessage[]): ChatMessage[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
return messages.map(message => {
|
||||
let changed = false
|
||||
|
||||
const parts = message.parts.map((part, index) => {
|
||||
if (part.type !== 'tool-call') {
|
||||
return part
|
||||
}
|
||||
|
||||
const id = part.toolCallId || `${message.id}-tool-${index}`
|
||||
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
|
||||
if (part.toolCallId) {
|
||||
return part
|
||||
}
|
||||
|
||||
changed = true
|
||||
|
||||
return { ...part, toolCallId: id } as ChatMessagePart
|
||||
}
|
||||
|
||||
changed = true
|
||||
const uniqueId = `${id}-${message.id}-${index}`
|
||||
seen.add(uniqueId)
|
||||
|
||||
return { ...part, toolCallId: uniqueId } as ChatMessagePart
|
||||
})
|
||||
|
||||
return changed ? { ...message, parts } : message
|
||||
})
|
||||
}
|
||||
|
||||
export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
const result: ChatMessage[] = []
|
||||
let pendingToolParts: ChatMessagePart[] = []
|
||||
@@ -282,6 +344,14 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.role === 'tool') {
|
||||
const updatedPendingToolParts = applyStoredToolResultToParts(pendingToolParts, message)
|
||||
|
||||
if (updatedPendingToolParts) {
|
||||
pendingToolParts = updatedPendingToolParts
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (applyStoredToolResult(result, message)) {
|
||||
return
|
||||
}
|
||||
@@ -343,7 +413,7 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
})
|
||||
flushPendingTools(messages.length)
|
||||
|
||||
return result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text'))
|
||||
return withUniqueToolCallIds(result.filter(m => chatMessageText(m).trim() || m.parts.some(part => part.type !== 'text')))
|
||||
}
|
||||
|
||||
export function branchGroupForUser(userMessage: ChatMessage): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ThreadMessage } from '@assistant-ui/react'
|
||||
|
||||
import type { QuickModelOption } from '@/app/chat/composer/types'
|
||||
import type { ClientSessionState, CommandDispatchResponse } from '@/app/types'
|
||||
import type { QuickModelOption } from '@/components/chat-bar'
|
||||
import { type ChatMessage, type ChatMessagePart, chatMessageText, textPart } from '@/lib/chat-messages'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import type { ModelOptionsResponse, SessionInfo } from '@/types/hermes'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Routes `navigator.clipboard.writeText` through Electron IPC, since the
|
||||
// renderer's clipboard API throws "Write permission denied" whenever the
|
||||
// document loses focus (e.g. clicking a portaled Radix dropdown). The IPC
|
||||
// path runs in the main process and is unconditional.
|
||||
|
||||
export function installClipboardShim() {
|
||||
const ipc = window.hermesDesktop?.writeClipboard
|
||||
|
||||
if (!ipc || !navigator.clipboard) {
|
||||
return
|
||||
}
|
||||
|
||||
const native = navigator.clipboard.writeText?.bind(navigator.clipboard)
|
||||
|
||||
const writeText = async (text: string) => {
|
||||
try {
|
||||
await ipc(text)
|
||||
} catch {
|
||||
await native?.(text)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(navigator.clipboard, 'writeText', { configurable: true, value: writeText, writable: true })
|
||||
} catch {
|
||||
// Browser refused override; primitives keep using the native API.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { HapticInput, TriggerOptions } from 'web-haptics'
|
||||
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
export type HapticIntent =
|
||||
| 'cancel'
|
||||
| 'close'
|
||||
| 'crisp'
|
||||
| 'error'
|
||||
| 'open'
|
||||
| 'selection'
|
||||
| 'streamDone'
|
||||
| 'streamStart'
|
||||
| 'submit'
|
||||
| 'success'
|
||||
| 'tap'
|
||||
| 'warning'
|
||||
|
||||
interface HapticConfig {
|
||||
options?: TriggerOptions
|
||||
pattern: HapticInput
|
||||
}
|
||||
|
||||
const airyTap = [{ duration: 16, intensity: 0.52 }]
|
||||
|
||||
const crispTap = [{ duration: 10, intensity: 0.92 }]
|
||||
|
||||
const friendlySuccess = [
|
||||
{ duration: 28, intensity: 0.5 },
|
||||
{ delay: 42, duration: 30, intensity: 0.68 },
|
||||
{ delay: 48, duration: 38, intensity: 0.86 }
|
||||
]
|
||||
|
||||
const softArrive = [
|
||||
{ duration: 18, intensity: 0.42 },
|
||||
{ delay: 36, duration: 22, intensity: 0.66 }
|
||||
]
|
||||
|
||||
const softLeave = [
|
||||
{ duration: 22, intensity: 0.58 },
|
||||
{ delay: 32, duration: 16, intensity: 0.34 }
|
||||
]
|
||||
|
||||
const HAPTIC_INTENTS: Record<HapticIntent, HapticConfig> = {
|
||||
cancel: {
|
||||
pattern: [
|
||||
{ duration: 34, intensity: 0.72 },
|
||||
{ delay: 54, duration: 26, intensity: 0.38 }
|
||||
]
|
||||
},
|
||||
close: { pattern: softLeave },
|
||||
crisp: { pattern: crispTap },
|
||||
error: {
|
||||
pattern: [
|
||||
{ duration: 34, intensity: 0.82 },
|
||||
{ delay: 42, duration: 34, intensity: 0.72 },
|
||||
{ delay: 58, duration: 44, intensity: 0.86 }
|
||||
]
|
||||
},
|
||||
open: { pattern: softArrive },
|
||||
selection: { pattern: airyTap },
|
||||
streamDone: { pattern: friendlySuccess },
|
||||
streamStart: { pattern: [{ duration: 10, intensity: 0.32 }] },
|
||||
submit: {
|
||||
pattern: [
|
||||
{ duration: 24, intensity: 0.58 },
|
||||
{ delay: 48, duration: 36, intensity: 0.82 }
|
||||
]
|
||||
},
|
||||
success: { pattern: friendlySuccess },
|
||||
tap: {
|
||||
pattern: [
|
||||
{ duration: 14, intensity: 0.58 },
|
||||
{ delay: 30, duration: 12, intensity: 0.42 }
|
||||
]
|
||||
},
|
||||
warning: {
|
||||
pattern: [
|
||||
{ duration: 34, intensity: 0.64 },
|
||||
{ delay: 84, duration: 42, intensity: 0.5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export type HapticTrigger = (input?: HapticInput, options?: TriggerOptions) => Promise<void> | undefined
|
||||
|
||||
let registeredTrigger: HapticTrigger | null = null
|
||||
let lastSelectionAt = 0
|
||||
|
||||
export function registerHapticTrigger(trigger: HapticTrigger | null) {
|
||||
registeredTrigger = trigger
|
||||
}
|
||||
|
||||
export function triggerHaptic(intent: HapticIntent = 'selection') {
|
||||
if ($hapticsMuted.get() || !registeredTrigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = performance.now()
|
||||
|
||||
if (intent === 'selection') {
|
||||
if (now - lastSelectionAt < 50) {
|
||||
return
|
||||
}
|
||||
|
||||
lastSelectionAt = now
|
||||
}
|
||||
|
||||
const config = HAPTIC_INTENTS[intent]
|
||||
|
||||
void registeredTrigger(config.pattern, config.options)?.catch(() => undefined)
|
||||
}
|
||||
@@ -3,10 +3,15 @@ import './styles.css'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import App from './app'
|
||||
import { HapticsProvider } from './components/haptics-provider'
|
||||
import { installClipboardShim } from './lib/clipboard'
|
||||
import { ThemeProvider } from './themes/context'
|
||||
|
||||
installClipboardShim()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -20,7 +25,11 @@ createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
||||
export interface ComposerAttachment {
|
||||
id: string
|
||||
kind: 'image' | 'file' | 'folder' | 'url'
|
||||
@@ -22,7 +24,13 @@ export function clearComposerDraft() {
|
||||
}
|
||||
|
||||
export function addComposerAttachment(attachment: ComposerAttachment) {
|
||||
$composerAttachments.set(upsertAttachment($composerAttachments.get(), attachment))
|
||||
const previous = $composerAttachments.get()
|
||||
const next = upsertAttachment(previous, attachment)
|
||||
$composerAttachments.set(next)
|
||||
|
||||
if (next.length > previous.length && attachment.kind !== 'url') {
|
||||
triggerHaptic('selection')
|
||||
}
|
||||
}
|
||||
|
||||
export function removeComposerAttachment(id: string): ComposerAttachment | null {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
const HAPTICS_MUTED_STORAGE_KEY = 'hermes.desktop.hapticsMuted'
|
||||
|
||||
export const $hapticsMuted = atom(storedBoolean(HAPTICS_MUTED_STORAGE_KEY, false))
|
||||
|
||||
$hapticsMuted.subscribe(muted => persistBoolean(HAPTICS_MUTED_STORAGE_KEY, muted))
|
||||
|
||||
export function setHapticsMuted(muted: boolean) {
|
||||
$hapticsMuted.set(muted)
|
||||
}
|
||||
|
||||
export function toggleHapticsMuted() {
|
||||
$hapticsMuted.set(!$hapticsMuted.get())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface AppNotification {
|
||||
kind: NotificationKind
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
@@ -15,6 +16,7 @@ interface NotificationInput {
|
||||
kind?: NotificationKind
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
@@ -31,14 +33,51 @@ function defaultDuration(kind: NotificationKind) {
|
||||
return 5_000
|
||||
}
|
||||
|
||||
function readableErrorMessage(error: unknown, fallback: string) {
|
||||
function cleanErrorText(value: string) {
|
||||
return value.replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string) => string }[] = [
|
||||
{
|
||||
test: msg => /incorrect api key provided/i.test(msg) || /['"]code['"]\s*:\s*['"]invalid_api_key['"]/i.test(msg),
|
||||
summarize: msg => {
|
||||
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
|
||||
|
||||
return `OpenAI rejected the API key${status ? ` (${status} invalid_api_key)` : ''}.`
|
||||
}
|
||||
},
|
||||
{
|
||||
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
|
||||
summarize: () => 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.'
|
||||
},
|
||||
{
|
||||
test: msg => /method not allowed/i.test(msg),
|
||||
summarize: () => 'The desktop backend does not support that audio endpoint yet. Restart Hermes Desktop.'
|
||||
},
|
||||
{
|
||||
test: msg => /microphone permission/i.test(msg),
|
||||
summarize: () => 'Microphone permission was denied.'
|
||||
}
|
||||
]
|
||||
|
||||
function summarizeErrorMessage(message: string, fallback: string) {
|
||||
const rule = ERROR_SUMMARIES.find(r => r.test(message))
|
||||
|
||||
if (rule) {
|
||||
return rule.summarize(message)
|
||||
}
|
||||
|
||||
return message.length > 180 ? fallback : message || fallback
|
||||
}
|
||||
|
||||
function readableError(error: unknown, fallback: string): { message: string; detail?: string } {
|
||||
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
|
||||
const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw
|
||||
const cleaned = cleanErrorText(unwrapped)
|
||||
const detail = cleaned.match(/"detail"\s*:\s*"([^"]+)"/)?.[1] ?? cleaned
|
||||
const summary = summarizeErrorMessage(detail, fallback)
|
||||
|
||||
const ipcMessage = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)
|
||||
const message = ipcMessage?.[1] || raw.replace(/^Error:\s*/, '')
|
||||
const detailMatch = message.match(/"detail"\s*:\s*"([^"]+)"/)
|
||||
|
||||
return detailMatch?.[1] || message
|
||||
return { message: summary, detail: detail === summary ? undefined : detail }
|
||||
}
|
||||
|
||||
export function notify(input: NotificationInput): string {
|
||||
@@ -50,6 +89,7 @@ export function notify(input: NotificationInput): string {
|
||||
kind,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
detail: input.detail,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
@@ -70,10 +110,13 @@ export function notify(input: NotificationInput): string {
|
||||
}
|
||||
|
||||
export function notifyError(error: unknown, fallback: string): string {
|
||||
const readable = readableError(error, fallback)
|
||||
|
||||
return notify({
|
||||
kind: 'error',
|
||||
title: fallback,
|
||||
message: readableErrorMessage(error, fallback)
|
||||
message: readable.message,
|
||||
detail: readable.detail
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
import type { ContextSuggestion } from '@/app/types'
|
||||
|
||||
type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
@@ -20,6 +20,7 @@ export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsLoading = atom(true)
|
||||
export const $workingSessionIds = atom<string[]>([])
|
||||
export const $activeSessionId = atom<string | null>(null)
|
||||
export const $selectedStoredSessionId = atom<string | null>(null)
|
||||
export const $messages = atom<ChatMessage[]>([])
|
||||
@@ -41,6 +42,7 @@ export const setConnection = (next: Updater<HermesConnection | null>) => updateA
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
|
||||
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
|
||||
@@ -57,3 +59,19 @@ export const setAvailablePersonalities = (next: Updater<string[]>) => updateAtom
|
||||
export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, next)
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
setWorkingSessionIds(current => {
|
||||
const alreadyWorking = current.includes(sessionId)
|
||||
|
||||
if (working) {
|
||||
return alreadyWorking ? current : [...current, sessionId]
|
||||
}
|
||||
|
||||
return alreadyWorking ? current.filter(id => id !== sessionId) : current
|
||||
})
|
||||
}
|
||||
|
||||
@@ -177,12 +177,100 @@
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.composer-liquid-shell-wrap {
|
||||
pointer-events: none;
|
||||
border-radius: var(--composer-glass-radius, 20px);
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fragment siblings emitted by liquid-glass-react (not inside .composer-liquid-shell). */
|
||||
.composer-liquid-shell-wrap > div:not(.composer-liquid-shell) {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Liquid-glass rim spans (toggle via data-show-library-rims on shell wrap). */
|
||||
.composer-liquid-shell-wrap:not([data-show-library-rims='true']) > span {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell-wrap[data-show-library-rims='true'] > span {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell {
|
||||
z-index: 1;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > svg {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass,
|
||||
.composer-liquid-shell > :not(svg):not(.glass) {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
padding: 0 !important;
|
||||
border-radius: var(--composer-glass-radius, 20px) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > .glass__warp {
|
||||
border-radius: var(--composer-glass-radius, 20px) !important;
|
||||
}
|
||||
|
||||
.composer-liquid-shell > .glass > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font: inherit !important;
|
||||
text-shadow: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable]:not([contenteditable='false']),
|
||||
|
||||
@@ -116,6 +116,10 @@ function lightColors(seed: DesktopTheme, skinName: string): DesktopThemeColors {
|
||||
return nousLightTheme.colors
|
||||
}
|
||||
|
||||
if (skinName === 'nous') {
|
||||
return seed.colors
|
||||
}
|
||||
|
||||
const accent = seed.colors.ring || seed.colors.primary
|
||||
const soft = mix('#ffffff', accent, 0.1)
|
||||
const softer = mix('#ffffff', accent, 0.06)
|
||||
@@ -168,6 +172,32 @@ function deriveTheme(skinName: string, mode: 'light' | 'dark'): DesktopTheme {
|
||||
}
|
||||
}
|
||||
|
||||
function skinNameFromTheme(theme: DesktopTheme, mode: 'light' | 'dark'): string {
|
||||
const suffix = `-${mode}`
|
||||
|
||||
return theme.name.endsWith(suffix) ? theme.name.slice(0, -suffix.length) : theme.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the *rendered* mode for a theme, regardless of what the user has
|
||||
* toggled. A skin like Nous keeps a white background even when `mode === 'dark'`,
|
||||
* so we shouldn't apply the `.dark` class (which assumes a dark surface and
|
||||
* triggers shadow/scrollbar/form-control rules tuned for one). Decide from the
|
||||
* actual background luminance.
|
||||
*/
|
||||
function renderedModeFor(colors: DesktopThemeColors, mode: 'light' | 'dark'): 'light' | 'dark' {
|
||||
const rgb = hexToRgb(colors.background)
|
||||
|
||||
if (!rgb) {
|
||||
return mode
|
||||
}
|
||||
|
||||
const [r, g, b] = rgb.map(v => v / 255)
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
return luminance > 0.5 ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
// ─── CSS application ────────────────────────────────────────────────────────
|
||||
|
||||
function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
@@ -180,8 +210,11 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
const layout = { ...DEFAULT_LAYOUT, ...theme.layout }
|
||||
const c = theme.colors
|
||||
|
||||
root.style.setProperty('color-scheme', mode)
|
||||
root.classList.toggle('dark', mode === 'dark')
|
||||
const rendered = renderedModeFor(theme.colors, mode)
|
||||
|
||||
root.style.setProperty('color-scheme', rendered)
|
||||
root.dataset.hermesTheme = skinNameFromTheme(theme, mode)
|
||||
root.classList.toggle('dark', rendered === 'dark')
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
'--dt-background': c.background,
|
||||
|
||||
@@ -86,6 +86,48 @@ export const hermesGoldTheme: DesktopTheme = {
|
||||
}
|
||||
}
|
||||
|
||||
const NOUS_LENS_BLUE = '#0053FD'
|
||||
|
||||
/** Nous — bright white with electric blue from the NousNet identity system. */
|
||||
export const nousTheme: DesktopTheme = {
|
||||
name: 'nous',
|
||||
label: 'Nous',
|
||||
description: 'Design-system white with electric Nous blue and subtle grain',
|
||||
colors: {
|
||||
background: '#FFFFFF',
|
||||
foreground: '#17171A',
|
||||
card: '#FFFFFF',
|
||||
cardForeground: '#17171A',
|
||||
muted: `color-mix(in srgb, ${NOUS_LENS_BLUE} 5%, #FFFFFF)`,
|
||||
mutedForeground: '#666678',
|
||||
popover: '#FFFFFF',
|
||||
popoverForeground: '#17171A',
|
||||
primary: NOUS_LENS_BLUE,
|
||||
primaryForeground: '#FFFFFF',
|
||||
secondary: `color-mix(in srgb, ${NOUS_LENS_BLUE} 7%, #FFFFFF)`,
|
||||
secondaryForeground: '#242432',
|
||||
accent: `color-mix(in srgb, ${NOUS_LENS_BLUE} 10%, #FFFFFF)`,
|
||||
accentForeground: '#202030',
|
||||
border: `color-mix(in srgb, ${NOUS_LENS_BLUE} 22%, transparent)`,
|
||||
input: `color-mix(in srgb, ${NOUS_LENS_BLUE} 30%, transparent)`,
|
||||
ring: NOUS_LENS_BLUE,
|
||||
destructive: '#C72E4D',
|
||||
destructiveForeground: '#FFFFFF',
|
||||
sidebarBackground: `color-mix(in srgb, ${NOUS_LENS_BLUE} 2.5%, #FFFFFF)`,
|
||||
sidebarBorder: `color-mix(in srgb, ${NOUS_LENS_BLUE} 18%, transparent)`,
|
||||
userBubble: `color-mix(in srgb, ${NOUS_LENS_BLUE} 6%, #FFFFFF)`,
|
||||
userBubbleBorder: `color-mix(in srgb, ${NOUS_LENS_BLUE} 24%, transparent)`
|
||||
},
|
||||
typography: {
|
||||
fontSans: SYSTEM_SANS,
|
||||
fontMono: `"Courier Prime", ${SYSTEM_MONO}`,
|
||||
fontUrl: 'https://fonts.googleapis.com/css2?family=Courier+Prime:wght@400;700&display=swap'
|
||||
},
|
||||
layout: {
|
||||
radius: '0.25rem'
|
||||
}
|
||||
}
|
||||
|
||||
/** Classic Hermes dark teal. */
|
||||
export const defaultTheme: DesktopTheme = {
|
||||
name: 'default',
|
||||
@@ -314,6 +356,7 @@ export const slateTheme: DesktopTheme = {
|
||||
export const BUILTIN_THEMES: Record<string, DesktopTheme> = {
|
||||
'nous-light': nousLightTheme,
|
||||
default: defaultTheme,
|
||||
nous: nousTheme,
|
||||
gold: hermesGoldTheme,
|
||||
midnight: midnightTheme,
|
||||
ember: emberTheme,
|
||||
|
||||
@@ -10,6 +10,30 @@ export interface ConfigSchemaResponse {
|
||||
fields: Record<string, ConfigFieldSchema>
|
||||
}
|
||||
|
||||
export interface AudioTranscriptionResponse {
|
||||
ok: boolean
|
||||
provider?: string
|
||||
transcript: string
|
||||
}
|
||||
|
||||
export interface AudioSpeakResponse {
|
||||
ok: boolean
|
||||
data_url: string
|
||||
mime_type: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export interface ElevenLabsVoice {
|
||||
label: string
|
||||
name: string
|
||||
voice_id: string
|
||||
}
|
||||
|
||||
export interface ElevenLabsVoicesResponse {
|
||||
available: boolean
|
||||
voices: ElevenLabsVoice[]
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
advanced: boolean
|
||||
category: string
|
||||
@@ -36,6 +60,12 @@ export interface HermesConfig {
|
||||
terminal?: {
|
||||
cwd?: string
|
||||
}
|
||||
stt?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
voice?: {
|
||||
max_recording_seconds?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type HermesConfigRecord = Record<string, unknown>
|
||||
@@ -79,6 +109,8 @@ export interface RpcEvent<T = unknown> {
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
info?: SessionRuntimeInfo
|
||||
message_count?: number
|
||||
messages?: SessionMessage[]
|
||||
session_id: string
|
||||
stored_session_id?: string
|
||||
}
|
||||
|
||||
@@ -730,7 +730,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
"display": {
|
||||
"compact": False,
|
||||
"personality": "kawaii",
|
||||
"personality": "",
|
||||
"resume_display": "full",
|
||||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||||
# When true, `hermes --tui` auto-resumes the most recent human-
|
||||
@@ -4375,7 +4375,7 @@ def show_config():
|
||||
print()
|
||||
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
|
||||
display = config.get('display', {})
|
||||
print(f" Personality: {display.get('personality', 'kawaii')}")
|
||||
print(f" Personality: {display.get('personality') or 'none'}")
|
||||
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
|
||||
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
|
||||
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
|
||||
|
||||
+226
-3
@@ -10,6 +10,8 @@ Usage:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
@@ -18,6 +20,7 @@ import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
@@ -449,6 +452,11 @@ class EnvVarReveal(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
class AudioTranscriptionRequest(BaseModel):
|
||||
data_url: str
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ModelAssignment(BaseModel):
|
||||
"""Payload for POST /api/model/set — assign a provider/model to a slot.
|
||||
|
||||
@@ -463,6 +471,29 @@ class ModelAssignment(BaseModel):
|
||||
task: str = ""
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
"audio/m4a": ".m4a",
|
||||
"audio/mp3": ".mp3",
|
||||
"audio/mp4": ".mp4",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"audio/wave": ".wav",
|
||||
"audio/webm": ".webm",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/x-wav": ".wav",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
_MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _audio_extension_for_mime(mime_type: str) -> str:
|
||||
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
|
||||
return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm")
|
||||
|
||||
|
||||
_GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL")
|
||||
try:
|
||||
_GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3"))
|
||||
@@ -616,6 +647,197 @@ async def get_status():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/audio/transcribe")
|
||||
async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
|
||||
data_url = (payload.data_url or "").strip()
|
||||
if not data_url.startswith("data:") or "," not in data_url:
|
||||
raise HTTPException(status_code=400, detail="Invalid audio payload")
|
||||
|
||||
header, encoded = data_url.split(",", 1)
|
||||
if ";base64" not in header:
|
||||
raise HTTPException(status_code=400, detail="Audio payload must be base64 encoded")
|
||||
|
||||
mime_type = (payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm").strip()
|
||||
normalized_mime_type = mime_type.split(";", 1)[0].lower()
|
||||
if not (normalized_mime_type.startswith("audio/") or normalized_mime_type == "video/webm"):
|
||||
raise HTTPException(status_code=400, detail="Payload must be an audio recording")
|
||||
|
||||
try:
|
||||
audio_bytes = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Audio payload is not valid base64")
|
||||
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="Audio recording is empty")
|
||||
if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Audio recording is too large")
|
||||
|
||||
temp_path = ""
|
||||
try:
|
||||
suffix = _audio_extension_for_mime(mime_type)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="hermes-desktop-voice-",
|
||||
suffix=suffix,
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(audio_bytes)
|
||||
temp_path = tmp.name
|
||||
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, transcribe_audio, temp_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Desktop voice transcription failed")
|
||||
raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}")
|
||||
finally:
|
||||
if temp_path:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error") or "Transcription failed",
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"transcript": str(result.get("transcript") or "").strip(),
|
||||
"provider": result.get("provider"),
|
||||
}
|
||||
|
||||
|
||||
class TTSSpeakRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str:
|
||||
name = str(voice.get("name") or voice.get("voice_id") or "Voice").strip()
|
||||
category = str(voice.get("category") or "").strip()
|
||||
|
||||
return f"{name} ({category})" if category else name
|
||||
|
||||
|
||||
@app.get("/api/audio/elevenlabs/voices")
|
||||
async def get_elevenlabs_voices():
|
||||
"""Return ElevenLabs voices when an API key is configured.
|
||||
|
||||
The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown.
|
||||
Only non-secret voice metadata is returned; the API key stays server-side.
|
||||
"""
|
||||
api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip()
|
||||
if not api_key:
|
||||
return {"available": False, "voices": []}
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://api.elevenlabs.io/v1/voices",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"xi-api-key": api_key,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _fetch() -> Dict[str, Any]:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
payload = await loop.run_in_executor(None, _fetch)
|
||||
except Exception as exc:
|
||||
_log.warning("ElevenLabs voice list failed: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices")
|
||||
|
||||
voices = []
|
||||
for voice in payload.get("voices") or []:
|
||||
if not isinstance(voice, dict):
|
||||
continue
|
||||
|
||||
voice_id = str(voice.get("voice_id") or "").strip()
|
||||
if not voice_id:
|
||||
continue
|
||||
|
||||
voices.append({
|
||||
"voice_id": voice_id,
|
||||
"name": str(voice.get("name") or voice_id),
|
||||
"label": _elevenlabs_voice_label(voice),
|
||||
})
|
||||
|
||||
voices.sort(key=lambda item: str(item.get("label") or "").lower())
|
||||
return {"available": True, "voices": voices}
|
||||
|
||||
|
||||
@app.post("/api/audio/speak")
|
||||
async def speak_text(payload: TTSSpeakRequest):
|
||||
"""Synthesize speech and return audio as base64 data URL.
|
||||
|
||||
Used by the desktop voice-conversation mode to play back assistant
|
||||
responses without exposing the on-disk file path. Reuses the
|
||||
existing TTS provider chain (Edge / OpenAI / ElevenLabs / etc.)
|
||||
configured in ``~/.hermes/config.yaml`` under ``tts.``.
|
||||
"""
|
||||
text = (payload.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
|
||||
try:
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
loop = asyncio.get_running_loop()
|
||||
result_json = await loop.run_in_executor(None, text_to_speech_tool, text)
|
||||
except Exception as exc:
|
||||
_log.exception("Desktop voice TTS failed")
|
||||
raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}")
|
||||
|
||||
try:
|
||||
result = json.loads(result_json) if isinstance(result_json, str) else result_json
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Invalid TTS response")
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error") or "Speech synthesis failed",
|
||||
)
|
||||
|
||||
file_path = result.get("file_path")
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code=500, detail="Audio file missing")
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
mime_type = {
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".wav": "audio/wav",
|
||||
".flac": "audio/flac",
|
||||
}.get(ext, "audio/mpeg")
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as fh:
|
||||
audio_bytes = fh.read()
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read audio: {exc}")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
encoded = base64.b64encode(audio_bytes).decode("ascii")
|
||||
return {
|
||||
"ok": True,
|
||||
"data_url": f"data:{mime_type};base64,{encoded}",
|
||||
"mime_type": mime_type,
|
||||
"provider": result.get("provider"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway + update actions (invoked from the Status page).
|
||||
#
|
||||
@@ -749,13 +971,14 @@ async def get_action_status(name: str, lines: int = 200):
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def get_sessions(limit: int = 20, offset: int = 0):
|
||||
async def get_sessions(limit: int = 20, offset: int = 0, min_messages: int = 0):
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions = db.list_sessions_rich(limit=limit, offset=offset)
|
||||
total = db.session_count()
|
||||
min_message_count = max(0, min_messages)
|
||||
sessions = db.list_sessions_rich(limit=limit, offset=offset, min_message_count=min_message_count)
|
||||
total = db.session_count(min_message_count=min_message_count)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
|
||||
+18
-7
@@ -932,6 +932,7 @@ class SessionDB:
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
min_message_count: int = 0,
|
||||
project_compression_tips: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
@@ -977,6 +978,9 @@ class SessionDB:
|
||||
placeholders = ",".join("?" for _ in exclude_sources)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(exclude_sources)
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("s.message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
query = f"""
|
||||
@@ -1798,15 +1802,22 @@ class SessionDB:
|
||||
# Utility
|
||||
# =========================================================================
|
||||
|
||||
def session_count(self, source: str = None) -> int:
|
||||
def session_count(self, source: str = None, min_message_count: int = 0) -> int:
|
||||
"""Count sessions, optionally filtered by source."""
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if source:
|
||||
where_clauses.append("source = ?")
|
||||
params.append(source)
|
||||
if min_message_count > 0:
|
||||
where_clauses.append("message_count >= ?")
|
||||
params.append(min_message_count)
|
||||
|
||||
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
with self._lock:
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM sessions WHERE source = ?", (source,)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
|
||||
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions{where_sql}", params)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
def message_count(self, session_id: str = None) -> int:
|
||||
|
||||
@@ -125,6 +125,50 @@ class TestWebServerEndpoints:
|
||||
assert "hermes_home" in data
|
||||
assert "active_sessions" in data
|
||||
|
||||
def test_audio_transcription_endpoint(self, monkeypatch):
|
||||
import tools.transcription_tools as transcription_tools
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_transcribe_audio(path):
|
||||
captured["path"] = path
|
||||
return {
|
||||
"success": True,
|
||||
"transcript": "hello from voice mode",
|
||||
"provider": "test",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(transcription_tools, "transcribe_audio", fake_transcribe_audio)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/audio/transcribe",
|
||||
json={
|
||||
"data_url": "data:audio/webm;base64,aGVsbG8=",
|
||||
"mime_type": "audio/webm",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {
|
||||
"ok": True,
|
||||
"transcript": "hello from voice mode",
|
||||
"provider": "test",
|
||||
}
|
||||
assert captured["path"].endswith(".webm")
|
||||
assert not Path(captured["path"]).exists()
|
||||
|
||||
def test_audio_transcription_rejects_invalid_base64(self):
|
||||
resp = self.client.post(
|
||||
"/api/audio/transcribe",
|
||||
json={
|
||||
"data_url": "data:audio/webm;base64,not base64",
|
||||
"mime_type": "audio/webm",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "base64" in resp.json()["detail"]
|
||||
|
||||
def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch):
|
||||
import gateway.config as gateway_config
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
+40
-4
@@ -507,6 +507,14 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
||||
db = _get_db()
|
||||
if db is not None:
|
||||
db.create_session(key, source="tui", model=_resolve_model())
|
||||
seed_history = current.get("history") or []
|
||||
for msg in seed_history:
|
||||
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant", "system"):
|
||||
db.append_message(
|
||||
session_id=key,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
)
|
||||
pending_title = (current.get("pending_title") or "").strip()
|
||||
if pending_title:
|
||||
try:
|
||||
@@ -2009,6 +2017,30 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
|
||||
return messages
|
||||
|
||||
|
||||
def _coerce_seed_history(value: Any) -> list[dict]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
history = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
role = item.get("role")
|
||||
if role not in ("user", "assistant", "system"):
|
||||
continue
|
||||
|
||||
content = item.get("content")
|
||||
if content is None:
|
||||
content = item.get("text")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
|
||||
history.append({"role": role, "content": content})
|
||||
|
||||
return history
|
||||
|
||||
|
||||
# ── Methods: session ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2017,6 +2049,8 @@ def _(rid, params: dict) -> dict:
|
||||
sid = uuid.uuid4().hex[:8]
|
||||
key = _new_session_key()
|
||||
cols = int(params.get("cols", 80))
|
||||
history = _coerce_seed_history(params.get("messages"))
|
||||
title = str(params.get("title") or "").strip()
|
||||
_enable_gateway_prompts()
|
||||
|
||||
ready = threading.Event()
|
||||
@@ -2028,12 +2062,12 @@ def _(rid, params: dict) -> dict:
|
||||
"attached_images": [],
|
||||
"cols": cols,
|
||||
"edit_snapshots": {},
|
||||
"history": [],
|
||||
"history": history,
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"image_counter": 0,
|
||||
"cwd": _completion_cwd(params),
|
||||
"pending_title": None,
|
||||
"pending_title": title or None,
|
||||
"running": False,
|
||||
"session_key": key,
|
||||
"show_reasoning": _load_show_reasoning(),
|
||||
@@ -2062,6 +2096,8 @@ def _(rid, params: dict) -> dict:
|
||||
{
|
||||
"session_id": sid,
|
||||
"stored_session_id": key,
|
||||
"message_count": len(history),
|
||||
"messages": _history_to_messages(history),
|
||||
"info": {
|
||||
"model": _resolve_model(),
|
||||
"tools": {},
|
||||
@@ -3747,7 +3783,7 @@ def _(rid, params: dict) -> dict:
|
||||
pname, new_prompt = _validate_personality(str(value or ""), cfg)
|
||||
_write_config_key("display.personality", pname)
|
||||
_write_config_key("agent.system_prompt", new_prompt)
|
||||
nv = str(value or "default")
|
||||
nv = str(value or "none")
|
||||
history_reset, info = _apply_personality_to_session(
|
||||
sid_key, session, new_prompt, pname
|
||||
)
|
||||
@@ -3816,7 +3852,7 @@ def _(rid, params: dict) -> dict:
|
||||
if key == "personality":
|
||||
return _ok(
|
||||
rid,
|
||||
{"value": (_load_cfg().get("display") or {}).get("personality", "default")},
|
||||
{"value": (_load_cfg().get("display") or {}).get("personality") or "none"},
|
||||
)
|
||||
if key == "reasoning":
|
||||
cfg = _load_cfg()
|
||||
|
||||
Reference in New Issue
Block a user