mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08:00
feat: add install readme et al
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,7 +54,9 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
|
||||
122
apps/desktop/README.md
Normal file
122
apps/desktop/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Hermes Desktop
|
||||
|
||||
Native Electron shell for Hermes. It packages the desktop renderer, a bundled Hermes source payload, and installer targets for macOS and Windows.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm run dev` runs Vite plus Electron against the local repo checkout. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run pack # unpacked app at release/mac-<arch>/Hermes.app
|
||||
npm run dist:mac # macOS DMG + zip
|
||||
npm run dist:mac:dmg # DMG only
|
||||
npm run dist:mac:zip # zip only
|
||||
npm run dist:win # NSIS + MSI
|
||||
```
|
||||
|
||||
Before packaging, `stage:hermes` copies the Python Hermes payload into `build/hermes-agent`. Electron Builder then ships it as `Contents/Resources/hermes-agent`.
|
||||
|
||||
## Icons
|
||||
|
||||
Desktop icons live in `assets/`:
|
||||
|
||||
- `assets/icon.icns`
|
||||
- `assets/icon.ico`
|
||||
- `assets/icon.png`
|
||||
|
||||
The builder config points at `assets/icon`. Replace these files directly if the app icon changes.
|
||||
|
||||
## Testing Install Paths
|
||||
|
||||
Use the package-local test scripts from this directory:
|
||||
|
||||
```bash
|
||||
npm run test:desktop:all
|
||||
npm run test:desktop:existing
|
||||
npm run test:desktop:fresh
|
||||
npm run test:desktop:dmg
|
||||
```
|
||||
|
||||
`test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the user’s real `~/.hermes` config.
|
||||
|
||||
`test:desktop:fresh` builds the packaged app, deletes the bundled desktop runtime, sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, and launches the app through the bundled payload path. Use this repeatedly to test first-run bootstrap.
|
||||
|
||||
`test:desktop:dmg` builds and opens the DMG.
|
||||
|
||||
For fast reruns without rebuilding:
|
||||
|
||||
```bash
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:existing
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:dmg
|
||||
```
|
||||
|
||||
## Installing Locally
|
||||
|
||||
```bash
|
||||
npm run dist:mac:dmg
|
||||
open release/Hermes-0.0.0-arm64.dmg
|
||||
```
|
||||
|
||||
Drag `Hermes` to Applications. If testing repeated installs, replace the existing app.
|
||||
|
||||
## Runtime Bootstrap
|
||||
|
||||
Packaged desktop startup resolves Hermes in this order:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT`
|
||||
2. existing `hermes` CLI, unless `HERMES_DESKTOP_IGNORE_EXISTING=1`
|
||||
3. bundled `Contents/Resources/hermes-agent`
|
||||
4. dev repo source
|
||||
5. installed `python -m hermes_cli.main`
|
||||
|
||||
When the bundled path is used, Electron creates or reuses:
|
||||
|
||||
```text
|
||||
~/Library/Application Support/Hermes/hermes-runtime
|
||||
```
|
||||
|
||||
The runtime is validated before use. If required dashboard imports are missing, it reinstalls the desktop runtime dependencies and retries.
|
||||
|
||||
## Debugging
|
||||
|
||||
Desktop boot logs are written to:
|
||||
|
||||
```text
|
||||
~/Library/Application Support/Hermes/desktop.log
|
||||
```
|
||||
|
||||
If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context.
|
||||
|
||||
To reset bundled runtime state:
|
||||
|
||||
```bash
|
||||
rm -rf "$HOME/Library/Application Support/Hermes/hermes-runtime"
|
||||
```
|
||||
|
||||
To reset stale macOS microphone permission prompts:
|
||||
|
||||
```bash
|
||||
tccutil reset Microphone com.github.Electron
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Run before handing off installer changes:
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
```
|
||||
|
||||
Current lint may report existing warnings, but it should exit with no errors.
|
||||
BIN
apps/desktop/assets/icon.icns
Normal file
BIN
apps/desktop/assets/icon.icns
Normal file
Binary file not shown.
BIN
apps/desktop/assets/icon.ico
Normal file
BIN
apps/desktop/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/desktop/assets/icon.png
Normal file
BIN
apps/desktop/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 674 KiB |
@@ -23,9 +23,40 @@ const { spawn } = require('node:child_process')
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../..')
|
||||
const DESKTOP_ROOT = path.resolve(__dirname, '..')
|
||||
const IS_PACKAGED = app.isPackaged
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const APP_ROOT = app.getAppPath()
|
||||
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
||||
const BUNDLED_HERMES_ROOT = path.join(process.resourcesPath, 'hermes-agent')
|
||||
const BUNDLED_VENV_ROOT = path.join(app.getPath('userData'), 'hermes-runtime')
|
||||
const BUNDLED_VENV_MARKER = path.join(BUNDLED_VENV_ROOT, '.hermes-desktop-runtime.json')
|
||||
const DESKTOP_LOG_PATH = path.join(app.getPath('userData'), 'desktop.log')
|
||||
const RUNTIME_SCHEMA_VERSION = 3
|
||||
const BUNDLED_RUNTIME_REQUIREMENTS = [
|
||||
'openai>=2.21.0,<3',
|
||||
'anthropic>=0.39.0,<1',
|
||||
'python-dotenv>=1.2.1,<2',
|
||||
'fire>=0.7.1,<1',
|
||||
'httpx[socks]>=0.28.1,<1',
|
||||
'rich>=14.3.3,<15',
|
||||
'tenacity>=9.1.4,<10',
|
||||
'pyyaml>=6.0.2,<7',
|
||||
'requests>=2.32.0,<3',
|
||||
'jinja2>=3.1.5,<4',
|
||||
'pydantic>=2.12.5,<3',
|
||||
'prompt_toolkit>=3.0.52,<4',
|
||||
'exa-py>=2.9.0,<3',
|
||||
'firecrawl-py>=4.16.0,<5',
|
||||
'parallel-web>=0.4.2,<1',
|
||||
'fal-client>=0.13.1,<1',
|
||||
'croniter>=6.0.0,<7',
|
||||
'edge-tts>=7.2.7,<8',
|
||||
'PyJWT[crypto]>=2.12.0,<3',
|
||||
'fastapi>=0.104.0,<1',
|
||||
'uvicorn[standard]>=0.24.0,<1',
|
||||
IS_WINDOWS ? 'pywinpty>=2.0.0,<3' : 'ptyprocess>=0.7.0,<1'
|
||||
]
|
||||
const APP_NAME = 'Hermes'
|
||||
const TITLEBAR_HEIGHT = 34
|
||||
const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
|
||||
@@ -33,9 +64,17 @@ const WINDOW_BUTTON_POSITION = {
|
||||
x: 24,
|
||||
y: TITLEBAR_HEIGHT / 2 - MACOS_TRAFFIC_LIGHTS_HEIGHT / 2
|
||||
}
|
||||
const APP_ICON_PATH = path.join(DESKTOP_ROOT, 'public', 'apple-touch-icon.png')
|
||||
const APP_ICON_PATHS = [
|
||||
path.join(APP_ROOT, 'public', 'apple-touch-icon.png'),
|
||||
path.join(APP_ROOT, 'dist', 'apple-touch-icon.png'),
|
||||
path.join(unpackedPathFor(APP_ROOT), 'dist', 'apple-touch-icon.png')
|
||||
]
|
||||
|
||||
app.setName(APP_NAME)
|
||||
app.setAboutPanelOptions({
|
||||
applicationName: APP_NAME,
|
||||
copyright: 'Copyright © 2026 Nous Research'
|
||||
})
|
||||
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
@@ -45,15 +84,324 @@ const hermesLog = []
|
||||
function rememberLog(chunk) {
|
||||
const text = String(chunk || '').trim()
|
||||
if (!text) return
|
||||
hermesLog.push(...text.split(/\r?\n/).map(line => `[hermes] ${line}`))
|
||||
const lines = text.split(/\r?\n/).map(line => `[hermes] ${line}`)
|
||||
hermesLog.push(...lines)
|
||||
if (hermesLog.length > 300) {
|
||||
hermesLog.splice(0, hermesLog.length - 300)
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
fs.appendFileSync(DESKTOP_LOG_PATH, `${lines.join('\n')}\n`)
|
||||
} catch {
|
||||
// Logging must never block app startup.
|
||||
}
|
||||
}
|
||||
|
||||
function findPython() {
|
||||
const local = [path.join(REPO_ROOT, '.venv', 'bin', 'python'), path.join(REPO_ROOT, 'venv', 'bin', 'python')]
|
||||
return local.find(candidate => fs.existsSync(candidate)) || 'python3'
|
||||
function fileExists(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function directoryExists(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function unpackedPathFor(filePath) {
|
||||
return filePath.replace(/app\.asar(?=$|[\\/])/, 'app.asar.unpacked')
|
||||
}
|
||||
|
||||
function findOnPath(command) {
|
||||
if (!command) return null
|
||||
|
||||
if (path.isAbsolute(command) || command.includes(path.sep) || (IS_WINDOWS && command.includes('/'))) {
|
||||
return fileExists(command) ? command : null
|
||||
}
|
||||
|
||||
const pathEntries = String(process.env.PATH || '')
|
||||
.split(path.delimiter)
|
||||
.filter(Boolean)
|
||||
const extensions = IS_WINDOWS
|
||||
? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
|
||||
: ['']
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = path.join(entry, `${command}${extension}`)
|
||||
if (fileExists(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isCommandScript(command) {
|
||||
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
|
||||
}
|
||||
|
||||
function isHermesSourceRoot(root) {
|
||||
return directoryExists(root) && fileExists(path.join(root, 'hermes_cli', 'main.py'))
|
||||
}
|
||||
|
||||
function findPythonForRoot(root) {
|
||||
const override = process.env.HERMES_DESKTOP_PYTHON
|
||||
if (override && fileExists(override)) return override
|
||||
|
||||
const relativePaths = IS_WINDOWS
|
||||
? [path.join('.venv', 'Scripts', 'python.exe'), path.join('venv', 'Scripts', 'python.exe')]
|
||||
: [path.join('.venv', 'bin', 'python'), path.join('venv', 'bin', 'python')]
|
||||
|
||||
for (const relativePath of relativePaths) {
|
||||
const candidate = path.join(root, relativePath)
|
||||
if (fileExists(candidate)) return candidate
|
||||
}
|
||||
|
||||
return findSystemPython()
|
||||
}
|
||||
|
||||
function findSystemPython() {
|
||||
const commands = IS_WINDOWS ? ['python.exe', 'py.exe', 'python'] : ['python3', 'python']
|
||||
|
||||
for (const command of commands) {
|
||||
const candidate = findOnPath(command)
|
||||
if (candidate) return candidate
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getVenvPython(venvRoot) {
|
||||
return path.join(venvRoot, IS_WINDOWS ? path.join('Scripts', 'python.exe') : path.join('bin', 'python'))
|
||||
}
|
||||
|
||||
function runProcess(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env || process.env,
|
||||
shell: Boolean(options.shell),
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
child.stderr.on('data', rememberLog)
|
||||
child.once('error', reject)
|
||||
child.once('exit', code => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`${path.basename(command)} exited with code ${code}: ${recentHermesLog()}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function recentHermesLog() {
|
||||
return hermesLog.slice(-20).join('\n')
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWebDist() {
|
||||
const override = process.env.HERMES_DESKTOP_WEB_DIST
|
||||
if (override && directoryExists(path.resolve(override))) return path.resolve(override)
|
||||
|
||||
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
|
||||
if (directoryExists(unpackedDist)) return unpackedDist
|
||||
|
||||
return path.join(APP_ROOT, 'dist')
|
||||
}
|
||||
|
||||
function resolveRendererIndex() {
|
||||
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
|
||||
return candidates.find(fileExists) || candidates[0]
|
||||
}
|
||||
|
||||
function resolveHermesCwd() {
|
||||
const candidates = [
|
||||
process.env.HERMES_DESKTOP_CWD,
|
||||
process.env.INIT_CWD,
|
||||
process.cwd(),
|
||||
!IS_PACKAGED ? SOURCE_REPO_ROOT : null,
|
||||
app.getPath('home')
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const resolved = path.resolve(String(candidate))
|
||||
if (directoryExists(resolved)) return resolved
|
||||
}
|
||||
|
||||
return app.getPath('home')
|
||||
}
|
||||
|
||||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
|
||||
return {
|
||||
kind: 'python',
|
||||
label,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
function createBundledBackend(root, dashboardArgs) {
|
||||
const python = getVenvPython(BUNDLED_VENV_ROOT)
|
||||
|
||||
return {
|
||||
kind: 'python',
|
||||
label: 'bundled Hermes',
|
||||
command: fileExists(python) ? python : findSystemPython(),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: {
|
||||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
root,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHermesBackend(dashboardArgs) {
|
||||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||||
if (overrideRoot && isHermesSourceRoot(overrideRoot)) {
|
||||
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
if (process.env.HERMES_DESKTOP_IGNORE_EXISTING !== '1') {
|
||||
const hermesCommand = process.env.HERMES_DESKTOP_HERMES
|
||||
? findOnPath(process.env.HERMES_DESKTOP_HERMES) || process.env.HERMES_DESKTOP_HERMES
|
||||
: findOnPath('hermes')
|
||||
if (hermesCommand) {
|
||||
return {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: dashboardArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
shell: isCommandScript(hermesCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_PACKAGED && isHermesSourceRoot(BUNDLED_HERMES_ROOT)) {
|
||||
const backend = createBundledBackend(BUNDLED_HERMES_ROOT, dashboardArgs)
|
||||
if (backend.command) return backend
|
||||
}
|
||||
|
||||
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
const python = findSystemPython()
|
||||
if (python) {
|
||||
return {
|
||||
kind: 'python',
|
||||
label: `installed hermes_cli module via ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not find Hermes. Install the Hermes CLI or set HERMES_DESKTOP_HERMES_ROOT.')
|
||||
}
|
||||
|
||||
async function ensureBundledRuntime(backend) {
|
||||
if (!backend.bootstrap) return backend
|
||||
|
||||
const sourceVersion = readJson(path.join(backend.root, 'package.json'))?.version || app.getVersion()
|
||||
const marker = readJson(BUNDLED_VENV_MARKER)
|
||||
const venvPython = getVenvPython(BUNDLED_VENV_ROOT)
|
||||
|
||||
const runtimeReady =
|
||||
fileExists(venvPython) &&
|
||||
marker?.sourceVersion === sourceVersion &&
|
||||
marker?.runtimeSchemaVersion === RUNTIME_SCHEMA_VERSION &&
|
||||
(await hasBundledRuntimeImports(venvPython))
|
||||
|
||||
if (runtimeReady) {
|
||||
backend.command = venvPython
|
||||
backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}`
|
||||
return backend
|
||||
}
|
||||
|
||||
const systemPython = findSystemPython()
|
||||
if (!systemPython) {
|
||||
throw new Error('Python 3.11+ is required to bootstrap the bundled Hermes runtime.')
|
||||
}
|
||||
|
||||
rememberLog(`Preparing bundled Hermes runtime in ${BUNDLED_VENV_ROOT}`)
|
||||
fs.mkdirSync(BUNDLED_VENV_ROOT, { recursive: true })
|
||||
|
||||
if (!fileExists(venvPython)) {
|
||||
await runProcess(systemPython, ['-m', 'venv', BUNDLED_VENV_ROOT])
|
||||
}
|
||||
|
||||
await runProcess(venvPython, [
|
||||
'-m',
|
||||
'pip',
|
||||
'install',
|
||||
'--disable-pip-version-check',
|
||||
'--no-warn-script-location',
|
||||
'--upgrade',
|
||||
...BUNDLED_RUNTIME_REQUIREMENTS
|
||||
])
|
||||
|
||||
await runProcess(venvPython, ['-c', 'import fastapi, uvicorn, ptyprocess'])
|
||||
|
||||
fs.writeFileSync(
|
||||
BUNDLED_VENV_MARKER,
|
||||
JSON.stringify(
|
||||
{
|
||||
runtimeSchemaVersion: RUNTIME_SCHEMA_VERSION,
|
||||
sourceVersion,
|
||||
installedAt: new Date().toISOString()
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
backend.command = venvPython
|
||||
backend.label = `${backend.label} runtime at ${BUNDLED_VENV_ROOT}`
|
||||
return backend
|
||||
}
|
||||
|
||||
async function hasBundledRuntimeImports(python) {
|
||||
try {
|
||||
await runProcess(python, ['-c', 'import fastapi, uvicorn, ptyprocess'])
|
||||
return true
|
||||
} catch {
|
||||
rememberLog('Bundled Hermes runtime is missing required dashboard dependencies; reinstalling.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isPortAvailable(port) {
|
||||
@@ -226,22 +574,15 @@ function getWindowButtonPosition() {
|
||||
return mainWindow?.getWindowButtonPosition?.() || WINDOW_BUTTON_POSITION
|
||||
}
|
||||
|
||||
function getAppIconPath() {
|
||||
return fs.existsSync(APP_ICON_PATH) ? APP_ICON_PATH : undefined
|
||||
function sendBackendExit(payload) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const { webContents } = mainWindow
|
||||
if (!webContents || webContents.isDestroyed()) return
|
||||
webContents.send('hermes:backend-exit', payload)
|
||||
}
|
||||
|
||||
function resolveHermesCwd() {
|
||||
const candidates = [process.env.HERMES_DESKTOP_CWD, process.env.INIT_CWD, process.cwd(), REPO_ROOT]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
const resolved = path.resolve(String(candidate))
|
||||
try {
|
||||
if (fs.statSync(resolved).isDirectory()) return resolved
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
return REPO_ROOT
|
||||
function getAppIconPath() {
|
||||
return APP_ICON_PATHS.find(fileExists)
|
||||
}
|
||||
|
||||
function buildApplicationMenu() {
|
||||
@@ -421,43 +762,57 @@ async function startHermes() {
|
||||
connectionPromise = (async () => {
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const python = findPython()
|
||||
const args = [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--tui',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(port)
|
||||
]
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--tui', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureBundledRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
hermesProcess = spawn(python, args, {
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
hermesProcess = spawn(backend.command, backend.args, {
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONPATH: [REPO_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter),
|
||||
...backend.env,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
HERMES_DASHBOARD_TUI: '1',
|
||||
HERMES_WEB_DIST: path.join(REPO_ROOT, 'apps', 'desktop', 'dist')
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
let backendReady = false
|
||||
let rejectBackendStart = null
|
||||
const backendStartFailed = new Promise((_resolve, reject) => {
|
||||
rejectBackendStart = reject
|
||||
})
|
||||
hermesProcess.once('error', error => {
|
||||
rememberLog(`Hermes backend failed to start: ${error.message}`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
sendBackendExit({ code: null, signal: null, error: error.message })
|
||||
rejectBackendStart?.(error)
|
||||
})
|
||||
hermesProcess.once('exit', (code, signal) => {
|
||||
rememberLog(`Hermes dashboard exited (${signal || code})`)
|
||||
hermesProcess = null
|
||||
connectionPromise = null
|
||||
mainWindow?.webContents.send('hermes:backend-exit', { code, signal })
|
||||
sendBackendExit({ code, signal })
|
||||
if (!backendReady) {
|
||||
rejectBackendStart?.(
|
||||
new Error(
|
||||
`Hermes dashboard exited before it became ready (${signal || code}). Log: ${DESKTOP_LOG_PATH}\n${recentHermesLog()}`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await waitForHermes(baseUrl, token)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
@@ -506,7 +861,7 @@ function createWindow() {
|
||||
if (DEV_SERVER) {
|
||||
mainWindow.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
mainWindow.loadURL(pathToFileURL(path.join(__dirname, '..', 'dist', 'index.html')).toString())
|
||||
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2238
apps/desktop/package-lock.json
generated
2238
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@hermes-agent/desktop",
|
||||
"name": "hermes",
|
||||
"productName": "Hermes",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Native desktop shell for Hermes Agent.",
|
||||
"author": "Nous Research",
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
@@ -12,6 +13,20 @@
|
||||
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "tsc -b && vite build",
|
||||
"stage:hermes": "node scripts/stage-hermes-payload.mjs",
|
||||
"pack": "npm run build && npm run stage:hermes && electron-builder --dir",
|
||||
"dist": "npm run build && npm run stage:hermes && electron-builder",
|
||||
"dist:mac": "npm run build && npm run stage:hermes && electron-builder --mac",
|
||||
"dist:mac:dmg": "npm run build && npm run stage:hermes && electron-builder --mac dmg",
|
||||
"dist:mac:zip": "npm run build && npm run stage:hermes && electron-builder --mac zip",
|
||||
"dist:win": "npm run build && npm run stage:hermes && electron-builder --win",
|
||||
"dist:win:msi": "npm run build && npm run stage:hermes && electron-builder --win msi",
|
||||
"dist:win:nsis": "npm run build && npm run stage:hermes && electron-builder --win nsis",
|
||||
"test:desktop": "node scripts/test-desktop.mjs",
|
||||
"test:desktop:all": "node scripts/test-desktop.mjs all",
|
||||
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -59,6 +74,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.9.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
@@ -72,5 +88,82 @@
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
"artifactName": "Hermes-${version}-${arch}.${ext}",
|
||||
"icon": "assets/icon",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"assets/**",
|
||||
"electron/**",
|
||||
"public/**",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/hermes-agent",
|
||||
"to": "hermes-agent"
|
||||
}
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"dist/**"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"extendInfo": {
|
||||
"CFBundleDisplayName": "Hermes",
|
||||
"CFBundleExecutable": "Hermes",
|
||||
"CFBundleName": "Hermes",
|
||||
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
|
||||
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
|
||||
},
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Install Hermes",
|
||||
"backgroundColor": "#f5f5f7",
|
||||
"iconSize": 96,
|
||||
"window": {
|
||||
"width": 560,
|
||||
"height": 360
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"x": 160,
|
||||
"y": 170,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"x": 400,
|
||||
"y": 170,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"legalTrademarks": "Hermes",
|
||||
"target": [
|
||||
"nsis",
|
||||
"msi"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"perMachine": false,
|
||||
"shortcutName": "Hermes",
|
||||
"uninstallDisplayName": "Hermes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
apps/desktop/scripts/stage-hermes-payload.mjs
Normal file
109
apps/desktop/scripts/stage-hermes-payload.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '../..')
|
||||
const OUT_ROOT = path.join(DESKTOP_ROOT, 'build', 'hermes-agent')
|
||||
|
||||
const ROOT_FILES = [
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
'pyproject.toml',
|
||||
'run_agent.py',
|
||||
'model_tools.py',
|
||||
'toolsets.py',
|
||||
'batch_runner.py',
|
||||
'trajectory_compressor.py',
|
||||
'toolset_distributions.py',
|
||||
'cli.py',
|
||||
'hermes_constants.py',
|
||||
'hermes_logging.py',
|
||||
'hermes_state.py',
|
||||
'hermes_time.py',
|
||||
'rl_cli.py',
|
||||
'utils.py'
|
||||
]
|
||||
|
||||
const ROOT_DIRS = [
|
||||
'acp_adapter',
|
||||
'agent',
|
||||
'cron',
|
||||
'gateway',
|
||||
'hermes_cli',
|
||||
'plugins',
|
||||
'scripts',
|
||||
'skills',
|
||||
'tools',
|
||||
'tui_gateway'
|
||||
]
|
||||
|
||||
const TUI_FILES = ['package.json', 'package-lock.json']
|
||||
const TUI_DIRS = ['dist', 'packages/hermes-ink/dist']
|
||||
|
||||
const EXCLUDED_NAMES = new Set([
|
||||
'.DS_Store',
|
||||
'.git',
|
||||
'.mypy_cache',
|
||||
'.pytest_cache',
|
||||
'.ruff_cache',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'node_modules',
|
||||
'release',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function keep(entry) {
|
||||
return !EXCLUDED_NAMES.has(entry.name) && !entry.name.endsWith('.pyc') && !entry.name.endsWith('.pyo')
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await fs.access(target)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFileIfPresent(relativePath) {
|
||||
const from = path.join(REPO_ROOT, relativePath)
|
||||
if (!(await exists(from))) return
|
||||
|
||||
const to = path.join(OUT_ROOT, relativePath)
|
||||
await fs.mkdir(path.dirname(to), { recursive: true })
|
||||
await fs.copyFile(from, to)
|
||||
}
|
||||
|
||||
async function copyDirIfPresent(relativePath) {
|
||||
const from = path.join(REPO_ROOT, relativePath)
|
||||
if (!(await exists(from))) return
|
||||
|
||||
const to = path.join(OUT_ROOT, relativePath)
|
||||
await fs.cp(from, to, {
|
||||
recursive: true,
|
||||
filter: source => keep({ name: path.basename(source) })
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.rm(OUT_ROOT, { force: true, recursive: true })
|
||||
await fs.mkdir(OUT_ROOT, { recursive: true })
|
||||
|
||||
await Promise.all(ROOT_FILES.map(copyFileIfPresent))
|
||||
|
||||
for (const dir of ROOT_DIRS) {
|
||||
await copyDirIfPresent(dir)
|
||||
}
|
||||
|
||||
for (const file of TUI_FILES) {
|
||||
await copyFileIfPresent(path.join('ui-tui', file))
|
||||
}
|
||||
|
||||
for (const dir of TUI_DIRS) {
|
||||
await copyDirIfPresent(path.join('ui-tui', dir))
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
171
apps/desktop/scripts/test-desktop.mjs
Normal file
171
apps/desktop/scripts/test-desktop.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
|
||||
const MODE = process.argv[2] || 'help'
|
||||
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
const APP_PATH = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
|
||||
const APP_BIN = path.join(APP_PATH, 'Contents', 'MacOS', 'Hermes')
|
||||
const DMG_PATH = path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
||||
const USER_DATA = path.join(os.homedir(), 'Library', 'Application Support', 'Hermes')
|
||||
const RUNTIME_ROOT = path.join(USER_DATA, 'hermes-runtime')
|
||||
|
||||
function die(message) {
|
||||
console.error(`\n${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd || DESKTOP_ROOT,
|
||||
env: options.env || process.env,
|
||||
shell: Boolean(options.shell),
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
die(`${command} ${args.join(' ')} failed`)
|
||||
}
|
||||
}
|
||||
|
||||
function output(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
|
||||
return result.status === 0 ? result.stdout.trim() : ''
|
||||
}
|
||||
|
||||
function exists(target) {
|
||||
return fs.existsSync(target)
|
||||
}
|
||||
|
||||
function ensureMac() {
|
||||
if (process.platform !== 'darwin') {
|
||||
die('Desktop launch tests are macOS-only from this script.')
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePackagedApp() {
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP_BIN)) {
|
||||
return
|
||||
}
|
||||
|
||||
run('npm', ['run', 'pack'])
|
||||
}
|
||||
|
||||
function ensureDmg() {
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(DMG_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
run('npm', ['run', 'dist:mac:dmg'])
|
||||
}
|
||||
|
||||
function openApp() {
|
||||
if (!exists(APP_PATH)) {
|
||||
die(`Missing packaged app: ${APP_PATH}`)
|
||||
}
|
||||
|
||||
run('open', ['-n', APP_PATH])
|
||||
}
|
||||
|
||||
function openDmg() {
|
||||
if (!exists(DMG_PATH)) {
|
||||
die(`Missing DMG: ${DMG_PATH}`)
|
||||
}
|
||||
|
||||
run('open', [DMG_PATH])
|
||||
}
|
||||
|
||||
function launchFresh() {
|
||||
if (!exists(APP_BIN)) {
|
||||
die(`Missing app executable: ${APP_BIN}`)
|
||||
}
|
||||
|
||||
fs.rmSync(RUNTIME_ROOT, { force: true, recursive: true })
|
||||
|
||||
const python = output('which', ['python3'])
|
||||
if (!python) {
|
||||
die('python3 is required for fresh bundled-runtime bootstrap.')
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_TEST_MODE: 'fresh-bundled-runtime'
|
||||
}
|
||||
delete env.HERMES_DESKTOP_HERMES
|
||||
delete env.HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const child = spawn(APP_BIN, [], {
|
||||
cwd: os.homedir(),
|
||||
detached: true,
|
||||
env,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.unref()
|
||||
}
|
||||
|
||||
function validateBundle() {
|
||||
const required = [
|
||||
APP_BIN,
|
||||
path.join(APP_PATH, 'Contents', 'Resources', 'hermes-agent', 'hermes_cli', 'main.py'),
|
||||
path.join(APP_PATH, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
]
|
||||
|
||||
for (const target of required) {
|
||||
if (!exists(target)) {
|
||||
die(`Missing packaged payload file: ${target}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printArtifacts() {
|
||||
console.log('\nDesktop artifacts:')
|
||||
console.log(` app: ${APP_PATH}`)
|
||||
console.log(` dmg: ${DMG_PATH}`)
|
||||
console.log(` runtime: ${RUNTIME_ROOT}`)
|
||||
}
|
||||
|
||||
function help() {
|
||||
console.log(`Usage:
|
||||
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
|
||||
npm run test:desktop:fresh # build packaged app, delete bundled runtime, hide existing Hermes, launch
|
||||
npm run test:desktop:dmg # build DMG and open it
|
||||
npm run test:desktop:all # build DMG, validate app payload, print paths
|
||||
|
||||
Fast rerun:
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
`)
|
||||
}
|
||||
|
||||
ensureMac()
|
||||
|
||||
if (MODE === 'existing') {
|
||||
ensurePackagedApp()
|
||||
validateBundle()
|
||||
openApp()
|
||||
printArtifacts()
|
||||
} else if (MODE === 'fresh') {
|
||||
ensurePackagedApp()
|
||||
validateBundle()
|
||||
launchFresh()
|
||||
printArtifacts()
|
||||
} else if (MODE === 'dmg') {
|
||||
ensureDmg()
|
||||
openDmg()
|
||||
printArtifacts()
|
||||
} else if (MODE === 'all') {
|
||||
ensureDmg()
|
||||
validateBundle()
|
||||
printArtifacts()
|
||||
} else {
|
||||
help()
|
||||
}
|
||||
@@ -105,7 +105,13 @@ function artifactKind(value: string): ArtifactKind {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../') || value.startsWith('~/') || value.startsWith('file://')) {
|
||||
if (
|
||||
value.startsWith('/') ||
|
||||
value.startsWith('./') ||
|
||||
value.startsWith('../') ||
|
||||
value.startsWith('~/') ||
|
||||
value.startsWith('file://')
|
||||
) {
|
||||
return 'file'
|
||||
}
|
||||
|
||||
@@ -113,7 +119,12 @@ function artifactKind(value: string): ArtifactKind {
|
||||
}
|
||||
|
||||
function artifactHref(value: string): string {
|
||||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://') || value.startsWith('data:')) {
|
||||
if (
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('file://') ||
|
||||
value.startsWith('data:')
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -153,7 +164,11 @@ function messageText(message: SessionMessage): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function collectStringValues(value: unknown, keyPath: string, collector: (value: string, keyPath: string) => void): void {
|
||||
function collectStringValues(
|
||||
value: unknown,
|
||||
keyPath: string,
|
||||
collector: (value: string, keyPath: string) => void
|
||||
): void {
|
||||
if (typeof value === 'string') {
|
||||
collector(value, keyPath)
|
||||
|
||||
|
||||
@@ -46,13 +46,7 @@ export function ComposerCompletionDrawer({
|
||||
)
|
||||
}
|
||||
|
||||
export function CompletionDrawerEmpty({
|
||||
children,
|
||||
title
|
||||
}: {
|
||||
children?: ReactNode
|
||||
title: string
|
||||
}) {
|
||||
export function CompletionDrawerEmpty({ children, title }: { children?: ReactNode; title: string }) {
|
||||
return (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">
|
||||
<p>{title}</p>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Clipboard, FileText, FolderOpen, ImageIcon, Link, type LucideIcon, MessageSquareText, Plus } from 'lucide-react'
|
||||
import {
|
||||
Clipboard,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ImageIcon,
|
||||
Link,
|
||||
type LucideIcon,
|
||||
MessageSquareText,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
||||
@@ -178,13 +178,7 @@ function ConversationIndicator({
|
||||
{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}%` }}
|
||||
/>
|
||||
)
|
||||
return <span className="w-0.5 rounded-full bg-current" key={index} style={{ height: `${height * 100}%` }} />
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
@@ -204,11 +198,7 @@ function DictationButton({
|
||||
const active = state.active || status !== 'idle'
|
||||
|
||||
const aria =
|
||||
status === 'recording'
|
||||
? 'Stop dictation'
|
||||
: status === 'transcribing'
|
||||
? 'Transcribing dictation'
|
||||
: 'Voice dictation'
|
||||
status === 'recording' ? 'Stop dictation' : status === 'transcribing' ? 'Transcribing dictation' : 'Voice dictation'
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive, type Unstable_MentionDirective } from '@assistant-ui/react'
|
||||
|
||||
import {
|
||||
ComposerCompletionDrawer,
|
||||
CompletionDrawerEmpty,
|
||||
COMPLETION_DRAWER_ROW_CLASS
|
||||
} from './completion-drawer'
|
||||
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
|
||||
|
||||
export function DirectivePopover({
|
||||
adapter,
|
||||
@@ -44,11 +40,7 @@ function DirectiveRow({ index, item }: { index: number; item: Unstable_TriggerIt
|
||||
const description = metadata?.meta || item.description
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem
|
||||
className={COMPLETION_DRAWER_ROW_CLASS}
|
||||
index={index}
|
||||
item={item}
|
||||
>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem className={COMPLETION_DRAWER_ROW_CLASS} index={index} item={item}>
|
||||
<span className="shrink-0 truncate font-mono font-medium leading-5 text-foreground">{display}</span>
|
||||
{description && <span className="min-w-0 truncate leading-5 text-muted-foreground/80">{description}</span>}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
|
||||
@@ -59,7 +59,9 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
|
||||
return (
|
||||
<div className="flex min-w-0 items-baseline gap-2 rounded-md px-2.5 py-1 text-xs">
|
||||
<span
|
||||
className={mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'}
|
||||
className={
|
||||
mono ? 'shrink-0 truncate font-mono font-medium text-foreground/85' : 'shrink-0 truncate text-foreground/85'
|
||||
}
|
||||
>
|
||||
{keyLabel}
|
||||
</span>
|
||||
|
||||
@@ -181,6 +181,7 @@ export function useMicRecorder(): { handle: MicRecorderHandle; level: number; re
|
||||
['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/wav'].find(
|
||||
type => MediaRecorder.isTypeSupported(type)
|
||||
) ?? ''
|
||||
|
||||
let recorder: MediaRecorder
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,9 +38,10 @@ function commandText(value: string): string {
|
||||
}
|
||||
|
||||
/** Live `/` completions backed by the gateway's `complete.slash` RPC. */
|
||||
export function useSlashCompletions(options: {
|
||||
gateway: HermesGateway | null
|
||||
}): { adapter: Unstable_TriggerAdapter; loading: boolean } {
|
||||
export function useSlashCompletions(options: { gateway: HermesGateway | null }): {
|
||||
adapter: Unstable_TriggerAdapter
|
||||
loading: boolean
|
||||
} {
|
||||
const { gateway } = options
|
||||
const enabled = Boolean(gateway)
|
||||
|
||||
|
||||
@@ -104,7 +104,11 @@ export function useVoiceConversation({
|
||||
}
|
||||
|
||||
if (!force && buffer.length > 220) {
|
||||
const softBoundary = Math.max(buffer.lastIndexOf(', ', 180), buffer.lastIndexOf('; ', 180), buffer.lastIndexOf(': ', 180))
|
||||
const softBoundary = Math.max(
|
||||
buffer.lastIndexOf(', ', 180),
|
||||
buffer.lastIndexOf('; ', 180),
|
||||
buffer.lastIndexOf(': ', 180)
|
||||
)
|
||||
|
||||
if (softBoundary > 80) {
|
||||
const chunk = buffer.slice(0, softBoundary + 1).trim()
|
||||
@@ -123,33 +127,21 @@ export function useVoiceConversation({
|
||||
return buffer
|
||||
}
|
||||
|
||||
const handleTurn = useCallback(async (forceTranscribe = false) => {
|
||||
if (turnClosingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
turnClosingRef.current = true
|
||||
clearTurnTimeout()
|
||||
setStatus('transcribing')
|
||||
|
||||
try {
|
||||
const result = await handle.stop()
|
||||
|
||||
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
|
||||
const handleTurn = useCallback(
|
||||
async (forceTranscribe = false) => {
|
||||
if (turnClosingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const transcript = (await onTranscribeAudio(result.audio)).trim()
|
||||
turnClosingRef.current = true
|
||||
clearTurnTimeout()
|
||||
setStatus('transcribing')
|
||||
|
||||
if (!transcript) {
|
||||
if (enabledRef.current) {
|
||||
try {
|
||||
const result = await handle.stop()
|
||||
|
||||
if (!result || (!result.heardSpeech && !forceTranscribe) || !onTranscribeAudio) {
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current && statusRef.current !== 'speaking') {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
@@ -158,23 +150,38 @@ export function useVoiceConversation({
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
resetSpeechBuffer()
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice transcription failed')
|
||||
try {
|
||||
const transcript = (await onTranscribeAudio(result.audio)).trim()
|
||||
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
|
||||
pendingStartRef.current = true
|
||||
if (!transcript) {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
resetSpeechBuffer()
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice transcription failed')
|
||||
|
||||
if (enabledRef.current && !mutedRef.current && !busyRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
} finally {
|
||||
turnClosingRef.current = false
|
||||
}
|
||||
} finally {
|
||||
turnClosingRef.current = false
|
||||
}
|
||||
}, [handle, onSubmit, onTranscribeAudio])
|
||||
},
|
||||
[handle, onSubmit, onTranscribeAudio]
|
||||
)
|
||||
|
||||
const startListening = useCallback(async () => {
|
||||
pendingStartRef.current = false
|
||||
@@ -210,25 +217,22 @@ export function useVoiceConversation({
|
||||
}
|
||||
}, [handle, handleTurn, onFatalError])
|
||||
|
||||
const speak = useCallback(
|
||||
async (text: string) => {
|
||||
setStatus('speaking')
|
||||
const speak = useCallback(async (text: string) => {
|
||||
setStatus('speaking')
|
||||
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice playback failed')
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
}
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, 'Voice playback failed')
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!onTranscribeAudio) {
|
||||
|
||||
@@ -86,10 +86,7 @@ export function useVoiceRecorder({
|
||||
startedAtRef.current = Date.now()
|
||||
setElapsedSeconds(0)
|
||||
setVoiceStatus('recording')
|
||||
intervalRef.current = window.setInterval(
|
||||
() => setElapsedSeconds((Date.now() - startedAtRef.current) / 1000),
|
||||
250
|
||||
)
|
||||
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) {
|
||||
|
||||
@@ -378,10 +378,7 @@ export function ChatBar({
|
||||
loading={at.loading}
|
||||
/>
|
||||
<SlashPopover adapter={slash.adapter} loading={slash.loading} />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{ background: glassTweaks.fadeBackground }}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0" style={{ background: glassTweaks.fadeBackground }} />
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -430,9 +427,7 @@ export function ChatBar({
|
||||
>
|
||||
<VoiceActivity state={voiceActivityState} />
|
||||
<VoicePlaybackActivity />
|
||||
{attachments.length > 0 && (
|
||||
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
|
||||
)}
|
||||
{attachments.length > 0 && <AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />}
|
||||
{stacked ? (
|
||||
<>
|
||||
{input}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Unstable_DirectiveFormatter, Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
|
||||
import {
|
||||
ComposerCompletionDrawer,
|
||||
CompletionDrawerEmpty,
|
||||
COMPLETION_DRAWER_ROW_CLASS
|
||||
} from './completion-drawer'
|
||||
import { COMPLETION_DRAWER_ROW_CLASS, CompletionDrawerEmpty, ComposerCompletionDrawer } from './completion-drawer'
|
||||
|
||||
const slashFormatter: Unstable_DirectiveFormatter = {
|
||||
serialize(item: Unstable_TriggerItem): string {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
display: string
|
||||
@@ -28,7 +30,7 @@ export interface ChatBarProps {
|
||||
focusKey?: string | null
|
||||
maxRecordingSeconds?: number
|
||||
state: ChatBarState
|
||||
gateway?: import('@/hermes').HermesGateway | null
|
||||
gateway?: HermesGateway | null
|
||||
sessionId?: string | null
|
||||
cwd?: string | null
|
||||
onCancel: () => void
|
||||
|
||||
@@ -2,7 +2,14 @@ import { Globe } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const URL_HINT = /^https?:\/\//i
|
||||
|
||||
@@ -162,11 +162,7 @@ function PlaybackWaveform({ audioElement }: { audioElement: HTMLAudioElement | n
|
||||
return <canvas aria-hidden="true" className="block h-4 w-[88px]" ref={canvasRef} />
|
||||
}
|
||||
|
||||
export function VoiceActivity({
|
||||
state
|
||||
}: {
|
||||
state: VoiceActivityState
|
||||
}) {
|
||||
export function VoiceActivity({ state }: { state: VoiceActivityState }) {
|
||||
if (state.status === 'idle') {
|
||||
return null
|
||||
}
|
||||
@@ -194,7 +190,9 @@ export function VoiceActivity({
|
||||
|
||||
<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>
|
||||
<span className="font-mono text-[0.6875rem] text-muted-foreground/85">
|
||||
{formatElapsed(state.elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<VoiceLevelBars active={recording} level={state.level} />
|
||||
|
||||
@@ -2,11 +2,7 @@ import { useCallback } from 'react'
|
||||
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
import {
|
||||
addComposerAttachment,
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { addComposerAttachment, type ComposerAttachment, removeComposerAttachment } from '@/store/composer'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import type { ImageAttachResponse, ImageDetachResponse } from '../../types'
|
||||
@@ -92,6 +88,7 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
session_id: activeSessionId,
|
||||
path
|
||||
})
|
||||
|
||||
const attachedPath = result.path || path
|
||||
|
||||
if (result.attached) {
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
$gatewayState
|
||||
} from '@/store/session'
|
||||
|
||||
interface ChatRightRailProps
|
||||
extends Pick<React.ComponentProps<typeof SessionInspector>, 'onBrowseCwd' | 'onChangeCwd'> {
|
||||
interface ChatRightRailProps extends Pick<
|
||||
React.ComponentProps<typeof SessionInspector>,
|
||||
'onBrowseCwd' | 'onChangeCwd'
|
||||
> {
|
||||
onOpenModelPicker: () => void
|
||||
onSelectPersonality: (name: string) => void
|
||||
}
|
||||
|
||||
@@ -74,12 +74,16 @@ export function ChatSidebar({
|
||||
const sessionsLoading = useStore($sessionsLoading)
|
||||
const workingSessionIds = useStore($workingSessionIds)
|
||||
|
||||
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
|
||||
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])
|
||||
return bTime - aTime
|
||||
}),
|
||||
[sessions]
|
||||
)
|
||||
|
||||
const sessionsById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions])
|
||||
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 're
|
||||
|
||||
import type { ModelOptionsResponse, SessionRuntimeInfo } from '@/types/hermes'
|
||||
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import {
|
||||
getGlobalModelInfo,
|
||||
getHermesConfig,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
listSessions,
|
||||
setGlobalModel
|
||||
} from '../hermes'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { toChatMessages } from '../lib/chat-messages'
|
||||
import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromConfig } from '../lib/chat-runtime'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
|
||||
@@ -48,13 +48,7 @@ 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 { 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'
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
interface GatewayBootOptions {
|
||||
handleGatewayEvent: (event: RpcEvent) => void
|
||||
onConnectionReady: (connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null) => void
|
||||
onConnectionReady: (
|
||||
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
|
||||
) => void
|
||||
onGatewayReady: (gateway: HermesGateway | null) => void
|
||||
refreshHermesConfig: () => Promise<void>
|
||||
refreshSessions: () => Promise<void>
|
||||
@@ -87,11 +89,5 @@ export function useGatewayBoot({
|
||||
onConnectionReady(null)
|
||||
onGatewayReady(null)
|
||||
}
|
||||
}, [
|
||||
handleGatewayEvent,
|
||||
onConnectionReady,
|
||||
onGatewayReady,
|
||||
refreshHermesConfig,
|
||||
refreshSessions
|
||||
])
|
||||
}, [handleGatewayEvent, onConnectionReady, onGatewayReady, refreshHermesConfig, refreshSessions])
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import type { 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)
|
||||
|
||||
|
||||
@@ -56,9 +56,7 @@ interface CommandsCatalogResponse {
|
||||
}
|
||||
|
||||
function renderCommandsCatalog(catalog: CommandsCatalogResponse): string {
|
||||
const sections = catalog.categories?.length
|
||||
? catalog.categories
|
||||
: [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
|
||||
const sections = catalog.categories?.length ? catalog.categories : [{ name: 'Commands', pairs: catalog.pairs ?? [] }]
|
||||
|
||||
const body = sections
|
||||
.filter(section => section.pairs.length > 0)
|
||||
@@ -120,12 +118,15 @@ export function usePromptActions({
|
||||
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?' : '')
|
||||
@@ -241,6 +242,7 @@ export function usePromptActions({
|
||||
session_id: sessionId,
|
||||
command: command.replace(/^\/+/, '')
|
||||
})
|
||||
|
||||
const body = result?.output || `/${name}: no output`
|
||||
renderSlashOutput(result?.warning ? `warning: ${result.warning}\n${body}` : body)
|
||||
|
||||
@@ -407,6 +409,7 @@ export function usePromptActions({
|
||||
|
||||
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')
|
||||
@@ -428,6 +431,7 @@ export function usePromptActions({
|
||||
parentId && messages[parentIndex]?.role === 'assistant'
|
||||
? messages[parentIndex]
|
||||
: messages.slice(absoluteUserIndex + 1).find(message => message.role === 'assistant')
|
||||
|
||||
const branchGroupId = targetAssistant?.branchGroupId ?? branchGroupForUser(userMessage)
|
||||
|
||||
clearNotifications()
|
||||
@@ -435,6 +439,7 @@ export function usePromptActions({
|
||||
const nextUserIndex = state.messages.findIndex(
|
||||
(message, index) => index > absoluteUserIndex && message.role === 'user'
|
||||
)
|
||||
|
||||
const end = nextUserIndex < 0 ? state.messages.length : nextUserIndex
|
||||
|
||||
return {
|
||||
|
||||
@@ -280,110 +280,121 @@ export function useSessionActions({
|
||||
]
|
||||
)
|
||||
|
||||
const branchCurrentSession = useCallback(async (messageId?: string): Promise<boolean> => {
|
||||
const sourceSessionId = activeSessionIdRef.current
|
||||
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) {
|
||||
if (!sourceSessionId) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Nothing to branch',
|
||||
message: 'This message has no text to branch from.'
|
||||
message: 'Start or resume a chat before branching.'
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
clearNotifications()
|
||||
if (busyRef.current) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Session busy',
|
||||
message: 'Stop the current turn before branching this chat.'
|
||||
})
|
||||
|
||||
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)
|
||||
return false
|
||||
}
|
||||
|
||||
if (branched.info?.provider) {
|
||||
setCurrentProvider(branched.info.provider)
|
||||
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
|
||||
}
|
||||
|
||||
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])
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const removeSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
|
||||
@@ -73,7 +73,11 @@ function ConfigField({
|
||||
<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)'}
|
||||
{option
|
||||
? (optionLabels?.[option] ?? prettyName(option))
|
||||
: schemaKey === 'display.personality'
|
||||
? 'None'
|
||||
: '(none)'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -227,6 +231,7 @@ export function ConfigSettings({
|
||||
void (async () => {
|
||||
try {
|
||||
await saveHermesConfig(config)
|
||||
|
||||
if (saveVersionRef.current === v) {
|
||||
onConfigSaved?.()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { Brain, Lock, type LucideIcon, MessageCircle, Mic, Monitor, Moon, Palette, Sparkles, Sun, Wrench } from 'lucide-react'
|
||||
import {
|
||||
Brain,
|
||||
Lock,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { ThemeMode } from '@/themes/context'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c
|
||||
|
||||
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) => {
|
||||
export const withoutKey = <T>(record: Record<string, T>, key: string) => {
|
||||
const next = { ...record }
|
||||
delete next[key]
|
||||
|
||||
|
||||
@@ -9,7 +9,15 @@ 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 {
|
||||
asText,
|
||||
includesQuery,
|
||||
prettyName,
|
||||
providerGroup,
|
||||
providerPriority,
|
||||
redactedValue,
|
||||
withoutKey
|
||||
} from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
@@ -218,6 +226,7 @@ export function KeysSettings({ query }: SearchProps) {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
|
||||
@@ -34,11 +34,7 @@ function filteredSkills(skills: SkillInfo[], query: string, category: string | n
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
includesQuery(skill.name, q) ||
|
||||
includesQuery(skill.description, q) ||
|
||||
includesQuery(skill.category, q)
|
||||
)
|
||||
return includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q)
|
||||
})
|
||||
.sort((a, b) => asText(a.name).localeCompare(asText(b.name)))
|
||||
}
|
||||
@@ -177,14 +173,21 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
>
|
||||
<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>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabledSkills}/{totalSkills} enabled
|
||||
</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">
|
||||
<ModeButton active={mode === 'skills'} icon={Brain} onClick={() => setMode('skills')} text="Skills" />
|
||||
<ModeButton active={mode === 'toolsets'} icon={Wrench} onClick={() => setMode('toolsets')} text="Toolsets" />
|
||||
<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" />
|
||||
@@ -246,7 +249,10 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
</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="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">
|
||||
@@ -272,7 +278,9 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
<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="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)
|
||||
@@ -289,7 +297,9 @@ export function SkillsView({ setTitlebarActions, ...props }: SkillsViewProps) {
|
||||
</StatusPill>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{asText(toolset.description) || 'No description.'}</p>
|
||||
<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 => (
|
||||
|
||||
@@ -32,12 +32,7 @@ const ICONS: Record<HermesRefType, ComponentType<{ className?: string }>> = {
|
||||
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
|
||||
|
||||
const HERMES_DIRECTIVE_RE = new RegExp(
|
||||
'@(file|folder|url|image|tool):(' +
|
||||
'`[^`\\n]+`' +
|
||||
'|"[^"\\n]+"' +
|
||||
"|'[^'\\n]+'" +
|
||||
'|\\S+' +
|
||||
')',
|
||||
'@(file|folder|url|image|tool):(' + '`[^`\\n]+`' + '|"[^"\\n]+"' + "|'[^'\\n]+'" + '|\\S+' + ')',
|
||||
'g'
|
||||
)
|
||||
|
||||
@@ -59,7 +54,7 @@ function unwrapRefValue(raw: string): string {
|
||||
}
|
||||
|
||||
function needsQuoting(value: string): boolean {
|
||||
return /[\s()\[\]{}<>"'`]/.test(value)
|
||||
return /[\s()[\]{}<>"'`]/.test(value)
|
||||
}
|
||||
|
||||
export function formatRefValue(value: string): string {
|
||||
|
||||
@@ -21,15 +21,7 @@ import {
|
||||
Volume2Icon,
|
||||
VolumeXIcon
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { type FC, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
|
||||
@@ -256,7 +248,9 @@ export const Thread: FC<{
|
||||
scrollToBottomOnThreadSwitch
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3" ref={contentRef}>
|
||||
<ThreadPrimitive.Messages>{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}</ThreadPrimitive.Messages>
|
||||
<ThreadPrimitive.Messages>
|
||||
{() => <ThreadMessage onBranchInNewChat={onBranchInNewChat} />}
|
||||
</ThreadPrimitive.Messages>
|
||||
{loading === 'response' && <ResponseLoadingIndicator />}
|
||||
{loading === 'working' && <WorkingIndicator />}
|
||||
</div>
|
||||
@@ -384,7 +378,12 @@ const WorkingIndicator: FC = () => {
|
||||
|
||||
return (
|
||||
<StatusRow label="Hermes is still working">
|
||||
<Loader className="size-4 text-muted-foreground/60" label="Still working" strokeScale={0.65} type="spiral-search" />
|
||||
<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>
|
||||
|
||||
@@ -176,4 +176,3 @@ export const ToolFallback = ({ toolName, args, result }: ToolCallMessagePartProp
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,9 @@ function NotificationDetail({ detail }: { detail: string }) {
|
||||
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>
|
||||
<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}
|
||||
|
||||
@@ -52,7 +52,10 @@ interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||
type?: LoaderType
|
||||
}
|
||||
|
||||
interface BaseCurveOptions extends Pick<LoaderCurve, 'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'> {
|
||||
interface BaseCurveOptions extends Pick<
|
||||
LoaderCurve,
|
||||
'durationMs' | 'particleCount' | 'pulseDurationMs' | 'strokeWidth' | 'trailSpan'
|
||||
> {
|
||||
point?: LoaderCurve['point']
|
||||
rotate?: boolean
|
||||
rotationDurationMs?: number
|
||||
@@ -206,8 +209,7 @@ const LOADER_CURVES: Record<LoaderType, LoaderCurve> = {
|
||||
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 butterfly = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.sin(t / 12) ** 5
|
||||
|
||||
const scale = 4.6 + detailScale * 0.45
|
||||
|
||||
@@ -520,7 +522,9 @@ function buildPath(config: LoaderCurve, detailScale: number, steps: number) {
|
||||
}
|
||||
|
||||
function detailScaleFor(time: number, config: LoaderCurve, phaseOffset: number) {
|
||||
const pulseProgress = ((time + phaseOffset * config.pulseDurationMs) % config.pulseDurationMs) / config.pulseDurationMs
|
||||
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
|
||||
@@ -548,5 +552,7 @@ function rotationFor(time: number, config: LoaderCurve, phaseOffset: number) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return -(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
|
||||
return (
|
||||
-(((time + phaseOffset * config.rotationDurationMs) % config.rotationDurationMs) / config.rotationDurationMs) * 360
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,7 +437,9 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
||||
})
|
||||
flushPendingTools(messages.length)
|
||||
|
||||
return withUniqueToolCallIds(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,5 +1,4 @@
|
||||
const EMOJI_RE =
|
||||
/(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu
|
||||
const EMOJI_RE = /(?:[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]|[\u{FE0F}\u{200D}]|[\u{E0020}-\u{E007F}])+/gu
|
||||
|
||||
const FENCED_CODE_RE = /```[\s\S]*?(?:```|$)/g
|
||||
const INLINE_CODE_RE = /`([^`]+)`/g
|
||||
|
||||
@@ -38,10 +38,7 @@ const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
|
||||
const INJECTED_FONT_URLS = new Set<string>()
|
||||
const SKIN_THEME_LIST = BUILTIN_THEME_LIST.filter(t => t.name !== 'nous-light')
|
||||
|
||||
function effectiveMode(
|
||||
mode: ThemeMode,
|
||||
systemDark = matchesQuery('(prefers-color-scheme: dark)')
|
||||
): 'light' | 'dark' {
|
||||
function effectiveMode(mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' {
|
||||
return mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user