mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 12:48:54 +08:00
Compare commits
13 Commits
desktop-cm
...
ci/readonl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb43b4a297 | ||
|
|
b63b857331 | ||
|
|
6328d86ae9 | ||
|
|
9b1764899d | ||
|
|
0b7dd08e79 | ||
|
|
07ac185904 | ||
|
|
3acf73161f | ||
|
|
dd60c49bb8 | ||
|
|
6fe4821926 | ||
|
|
d986bb0c6d | ||
|
|
4cecb1a13a | ||
|
|
90f4b3040d | ||
|
|
3bfbb3f2a0 |
2
.github/workflows/deploy-site.yml
vendored
2
.github/workflows/deploy-site.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
|
||||
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
# (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each
|
||||
# shard would otherwise reach the session-scoped ``built_image``
|
||||
# fixture in ``tests/docker/conftest.py`` and start a 3-7min
|
||||
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
|
||||
# ``docker build`` — guaranteed to
|
||||
# die in fixture setup.
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the smoke test
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout —
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
|
||||
2
.github/workflows/docs-site-checks.yml
vendored
2
.github/workflows/docs-site-checks.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
|
||||
36
.github/workflows/tests.yml
vendored
36
.github/workflows/tests.yml
vendored
@@ -2,15 +2,15 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, ci/readonly-tests-tree-drop-exit4-retry]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,13 +30,17 @@ jobs:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
# main always writes a new suffix, but jobs pick the latest one with the same prefix
|
||||
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
|
||||
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
|
||||
# If there are no exact matches, the action searches for partial matches of the restore keys.
|
||||
# When the action finds a partial match, the most recent cache is restored to the path directory.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
@@ -54,7 +58,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -115,7 +119,7 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
@@ -129,7 +133,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
@@ -149,17 +153,17 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
key: test-durations-${{ github.run_id }}
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -176,7 +180,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -215,4 +219,4 @@ jobs:
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
25
.github/workflows/typecheck.yml
vendored
Normal file
25
.github/workflows/typecheck.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# .github/workflows/typecheck.yml
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package:
|
||||
[ui-tui, web, apps/bootstrap-installer, apps/desktop, apps/shared]
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
@@ -459,7 +459,7 @@ npm install # first time
|
||||
npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
|
||||
npm start # production
|
||||
npm run build # full build (hermes-ink + tsc)
|
||||
npm run type-check # typecheck only (tsc --noEmit)
|
||||
npm run typecheck # typecheck only (tsc --noEmit)
|
||||
npm run lint # eslint
|
||||
npm run fmt # prettier
|
||||
npm test # vitest
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug"
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
@@ -40,7 +41,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@@ -93,7 +93,7 @@ Run before opening a PR (lint may surface pre-existing warnings but must exit cl
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
npm run type-check
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
```
|
||||
|
||||
@@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
@@ -47,7 +46,6 @@ export default [
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-compiler': reactCompiler,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
@@ -98,7 +96,6 @@ export default [
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
|
||||
@@ -103,20 +103,19 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"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",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^16.5.0",
|
||||
|
||||
@@ -38,7 +38,6 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeCombo } from '@/lib/keybinds/combo'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
@@ -112,7 +111,8 @@ const NON_SESSION_LOAD_STEP = 10
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modKey } from '@/lib/keybinds/combo'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
@@ -134,11 +133,11 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if (event[modKey] && canOpenSessionWindow()) {
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
|
||||
@@ -91,7 +91,6 @@ import { CommandPalette } from './command-palette'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { modKey } from '@/lib/keybinds/combo'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
@@ -272,7 +271,7 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeActiveRightRailTab()
|
||||
|
||||
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
variant="secondary"
|
||||
>
|
||||
{t.rightSidebar.addToChat}
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { formatCombo, modKey } from '@/lib/keybinds/combo'
|
||||
import type { DesktopTerminalPalette } from '@/themes/types'
|
||||
|
||||
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
|
||||
@@ -98,10 +97,12 @@ export function resolveSurfaceColor(fallback: string): string {
|
||||
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
|
||||
}
|
||||
|
||||
export const addSelectionShortcutLabel = formatCombo('mod+l')
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
const mod = event[modKey]
|
||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||
|
||||
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
import {
|
||||
$bindings,
|
||||
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatFakeCombo(key)}
|
||||
{formatCombo(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -51,6 +50,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
||||
return <ApprovalBar request={request} />
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.approval
|
||||
@@ -126,7 +127,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -519,7 +518,7 @@ export const en: Translations = {
|
||||
loading: 'Loading archived sessions…',
|
||||
archivedTitle: 'Archived sessions',
|
||||
archivedIntro:
|
||||
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
|
||||
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
|
||||
emptyArchivedTitle: 'Nothing archived',
|
||||
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
||||
unarchive: 'Unarchive',
|
||||
@@ -530,7 +529,7 @@ export const en: Translations = {
|
||||
defaultDirTitle: 'Default project directory',
|
||||
defaultDirDesc:
|
||||
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
|
||||
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
|
||||
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
|
||||
defaultsTo: label => `Defaults to ${label}.`,
|
||||
change: 'Change',
|
||||
choose: 'Choose',
|
||||
@@ -1678,7 +1677,7 @@ export const en: Translations = {
|
||||
loadingQuestion: 'Loading question…',
|
||||
other: 'Other (type your answer)',
|
||||
placeholder: 'Type your answer…',
|
||||
shortcut: `${formatCombo('mod+enter')} to send`,
|
||||
shortcut: '⌘/Ctrl + Enter to send',
|
||||
back: 'Back',
|
||||
skip: 'Skip',
|
||||
send: 'Send'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -643,7 +642,7 @@ export const ja = defineLocale({
|
||||
loading: 'アーカイブ済みセッションを読み込み中…',
|
||||
archivedTitle: 'アーカイブ済みセッション',
|
||||
archivedIntro:
|
||||
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
|
||||
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
|
||||
emptyArchivedTitle: 'アーカイブがありません',
|
||||
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
|
||||
unarchive: 'アーカイブを解除',
|
||||
@@ -1812,7 +1811,7 @@ export const ja = defineLocale({
|
||||
loadingQuestion: '質問を読み込み中…',
|
||||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
shortcut: `${formatCombo('mod+enter')} で送信`,
|
||||
shortcut: '⌘/Ctrl + Enter で送信',
|
||||
back: '戻る',
|
||||
skip: 'スキップ',
|
||||
send: '送信'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -628,7 +627,7 @@ export const zhHant = defineLocale({
|
||||
loading: '正在載入已封存工作階段…',
|
||||
archivedTitle: '已封存工作階段',
|
||||
archivedIntro:
|
||||
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
|
||||
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
||||
emptyArchivedTitle: '暫無封存',
|
||||
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
||||
unarchive: '取消封存',
|
||||
@@ -1773,7 +1772,7 @@ export const zhHant = defineLocale({
|
||||
loadingQuestion: '正在載入問題…',
|
||||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
shortcut: `${formatCombo('mod+enter')} 傳送`,
|
||||
shortcut: '⌘/Ctrl + Enter 傳送',
|
||||
back: '返回',
|
||||
skip: '略過',
|
||||
send: '傳送'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -713,7 +712,7 @@ export const zh: Translations = {
|
||||
sessions: {
|
||||
loading: '正在加载已归档会话…',
|
||||
archivedTitle: '已归档会话',
|
||||
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
|
||||
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
|
||||
emptyArchivedTitle: '暂无归档',
|
||||
emptyArchivedDesc: '归档一个对话后会显示在这里。',
|
||||
unarchive: '取消归档',
|
||||
@@ -1857,7 +1856,7 @@ export const zh: Translations = {
|
||||
loadingQuestion: '正在加载问题…',
|
||||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
shortcut: `${formatCombo('mod+enter')} 发送`,
|
||||
shortcut: '⌘/Ctrl + Enter 发送',
|
||||
back: '返回',
|
||||
skip: '跳过',
|
||||
send: '发送'
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||
// add a hotkey, add a row here and a handler there — nothing else.
|
||||
|
||||
import type { Combo, FakeCombo } from "./combo";
|
||||
|
||||
|
||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||
|
||||
// The self-referential opener — bound + dispatched like any action, but shown in
|
||||
@@ -30,16 +27,15 @@ export interface KeybindActionMeta {
|
||||
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
||||
export const PROFILE_SLOT_COUNT = 18
|
||||
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
|
||||
const slot = i+1
|
||||
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
|
||||
function comboForSlot(slot: number): string {
|
||||
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
|
||||
}
|
||||
|
||||
return ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [combo]
|
||||
})
|
||||
})
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [comboForSlot(i + 1)]
|
||||
}))
|
||||
|
||||
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
|
||||
// `mod` keeps one binding cross-platform; on macOS this shadows the system
|
||||
@@ -108,12 +104,10 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
|
||||
return ACTION_BY_ID.get(id)
|
||||
}
|
||||
|
||||
export type KeybindBindings = Record<string, Combo[]>
|
||||
export type KeybindBindings = Record<string, string[]>
|
||||
|
||||
export function defaultBindings(): KeybindBindings {
|
||||
return Object.fromEntries<string, Combo[]>(
|
||||
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
|
||||
)
|
||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
||||
}
|
||||
|
||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||
@@ -123,7 +117,7 @@ export function defaultBindings(): KeybindBindings {
|
||||
export interface KeybindReadonly {
|
||||
id: string
|
||||
category: KeybindCategory
|
||||
keys: readonly FakeCombo[]
|
||||
keys: readonly string[]
|
||||
}
|
||||
|
||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
|
||||
// folds `ctrl` → `mod`.
|
||||
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
|
||||
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
// event.code → canonical base token. Letters/digits map to their lowercase
|
||||
// character; everything else uses an explicit name so combos read cleanly.
|
||||
const CODE_TO_KEY = {
|
||||
const CODE_TO_KEY: Record<string, string> = {
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
@@ -37,50 +35,8 @@ const CODE_TO_KEY = {
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right'
|
||||
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
|
||||
|
||||
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
|
||||
|
||||
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
|
||||
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
|
||||
|
||||
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
|
||||
|
||||
|
||||
type FKey =
|
||||
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
|
||||
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
|
||||
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
|
||||
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
|
||||
|
||||
type BaseKey = Alpha | Digit | FKey | SpecialKey
|
||||
|
||||
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
|
||||
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
|
||||
|
||||
function baseKeyFromCode(code: KeyCode): BaseKey | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase() as Alpha
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5) as Digit
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest as Digit : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase() as FKey
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
|
||||
}
|
||||
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
@@ -92,20 +48,42 @@ const MODIFIER_CODES = new Set([
|
||||
'ShiftRight'
|
||||
])
|
||||
|
||||
function baseKeyFromCode(code: string): string | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase()
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5)
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase()
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code] ?? null
|
||||
}
|
||||
|
||||
// Returns the canonical combo for a keydown, or null while only modifiers are
|
||||
// held (so capture mode keeps waiting for a real key).
|
||||
export function comboFromEvent(event: KeyboardEvent): Combo | null {
|
||||
export function comboFromEvent(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = baseKeyFromCode(event.code as KeyCode)
|
||||
const base = baseKeyFromCode(event.code)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts: Combo[] = []
|
||||
const parts: string[] = []
|
||||
|
||||
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
|
||||
// Control IS the accelerator, so it folds into `mod`.
|
||||
@@ -127,7 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): Combo | null {
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join('+') as Combo
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
|
||||
@@ -137,14 +115,7 @@ export function canonicalizeCombo(combo: string): string {
|
||||
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
|
||||
}
|
||||
|
||||
const MOD_LABELS = {
|
||||
mod: IS_MAC ? '⌘' : 'Ctrl',
|
||||
ctrl: IS_MAC ? '⌃' : 'Ctrl',
|
||||
alt: IS_MAC ? '⌥' : 'Alt',
|
||||
shift: IS_MAC ? '⇧' : 'Shift'
|
||||
} as const
|
||||
|
||||
const FANCY_KEY_LABELS = {
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
enter: '↵',
|
||||
escape: 'Esc',
|
||||
backspace: '⌫',
|
||||
@@ -153,47 +124,39 @@ const FANCY_KEY_LABELS = {
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→',
|
||||
} as const
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
...MOD_LABELS,
|
||||
...FANCY_KEY_LABELS
|
||||
right: '→'
|
||||
}
|
||||
|
||||
function labelForToken(token: string): string {
|
||||
if (TOKEN_LABELS[token]) {
|
||||
return TOKEN_LABELS[token]
|
||||
function labelForBase(base: string): string {
|
||||
if (TOKEN_LABELS[base]) {
|
||||
return TOKEN_LABELS[base]
|
||||
}
|
||||
|
||||
if (/^f\d{1,2}$/.test(token)) {
|
||||
return token.toUpperCase()
|
||||
if (/^f\d{1,2}$/.test(base)) {
|
||||
return base.toUpperCase()
|
||||
}
|
||||
|
||||
return token.length === 1 ? token.toUpperCase() : token
|
||||
return base.length === 1 ? base.toUpperCase() : base
|
||||
}
|
||||
|
||||
//
|
||||
function labelForMod(mod: string): string {
|
||||
if (mod === 'mod') {
|
||||
return IS_MAC ? '⌘' : 'Ctrl'
|
||||
}
|
||||
|
||||
type ModKey = keyof typeof MOD_LABELS
|
||||
if (mod === 'ctrl') {
|
||||
return IS_MAC ? '⌃' : 'Ctrl'
|
||||
}
|
||||
|
||||
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
|
||||
if (mod === 'alt') {
|
||||
return IS_MAC ? '⌥' : 'Alt'
|
||||
}
|
||||
|
||||
type ModPrefixedCombo<Suffix extends string> =
|
||||
| `${ModPrefix}${Suffix}`
|
||||
| ModKey
|
||||
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
|
||||
| 'ctrl+tab' | 'ctrl+shift+tab'
|
||||
| `ctrl+${Digit}`
|
||||
if (mod === 'shift') {
|
||||
return IS_MAC ? '⇧' : 'Shift'
|
||||
}
|
||||
|
||||
export type Combo = ModPrefixedCombo<BaseKey>
|
||||
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
|
||||
|
||||
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
|
||||
export function normalizeCombo(combo: Combo): string[] {
|
||||
const parts = combo.split('+')
|
||||
|
||||
return parts.map(p => labelForToken(p.trim()))
|
||||
return mod
|
||||
}
|
||||
|
||||
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
|
||||
@@ -202,18 +165,14 @@ export function comboTokens(combo: string): string[] {
|
||||
const parts = combo.split('+')
|
||||
const base = parts.pop() ?? ''
|
||||
|
||||
return [...parts.map(labelForToken), labelForToken(base)]
|
||||
return [...parts.map(labelForMod), labelForBase(base)]
|
||||
}
|
||||
|
||||
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: Combo): string {
|
||||
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
|
||||
}
|
||||
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: string): string {
|
||||
const tokens = comboTokens(combo)
|
||||
|
||||
|
||||
// like `formatCombo` but allows any input like `@`
|
||||
export function formatFakeCombo(combo: FakeCombo): string {
|
||||
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
|
||||
return IS_MAC ? tokens.join('') : tokens.join('+')
|
||||
}
|
||||
|
||||
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
|
||||
@@ -231,6 +190,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
|
||||
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
|
||||
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
|
||||
export function comboAllowedInInput(combo: Combo): boolean {
|
||||
export function comboAllowedInInput(combo: string): boolean {
|
||||
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type KeybindBindings
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { canonicalizeCombo } from '@/lib/keybinds/combo'
|
||||
import type { Combo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual, persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.keybinds'
|
||||
@@ -29,7 +28,7 @@ function loadBindings(): KeybindBindings {
|
||||
const value = parsed[id]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -79,7 +78,7 @@ export const $comboIndex = computed($bindings, bindings => {
|
||||
return index
|
||||
})
|
||||
|
||||
export function setBinding(actionId: string, combos: Combo[]): void {
|
||||
export function setBinding(actionId: string, combos: string[]): void {
|
||||
if (!keybindAction(actionId)) {
|
||||
return
|
||||
}
|
||||
@@ -102,7 +101,7 @@ export function resetAllBindings(): void {
|
||||
}
|
||||
|
||||
// Other actions that already use `combo` (excluding `actionId` itself).
|
||||
export function conflictsFor(actionId: string, combo: Combo): string[] {
|
||||
export function conflictsFor(actionId: string, combo: string): string[] {
|
||||
const bindings = $bindings.get()
|
||||
|
||||
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
|
||||
144
docs/design/profile-builder.md
Normal file
144
docs/design/profile-builder.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
|
||||
|
||||
Status: design proposal (not yet implemented)
|
||||
Author: drafted for Teknium
|
||||
Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
|
||||
|
||||
## Why this, not the CLI wizard
|
||||
|
||||
PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
|
||||
The decision is to **not** build the profile-creation experience in the CLI.
|
||||
The dashboard already owns mature, separate pages for every element a profile
|
||||
needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
|
||||
right home for a full-featured builder, and it can reuse everything that
|
||||
already exists.
|
||||
|
||||
A profile = a full `~/.hermes/profiles/<name>/` directory with its own:
|
||||
- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
|
||||
- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
|
||||
- `.env` — secrets
|
||||
- `SOUL.md` / `USER.md` — identity
|
||||
|
||||
So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
|
||||
change needed. The gap is purely UX: creation today is a thin modal
|
||||
(name + clone + model + description), and you can only compose skills/MCPs
|
||||
*after* the profile exists, by visiting other pages and remembering to scope
|
||||
them.
|
||||
|
||||
## What already exists (reuse, don't rebuild)
|
||||
|
||||
| Element | Existing page | Existing API | Profile-scopable? |
|
||||
|---|---|---|---|
|
||||
| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
|
||||
| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
|
||||
| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
|
||||
| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
|
||||
| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
|
||||
|
||||
## Two architectural seams found while grounding this design
|
||||
|
||||
These are load-bearing — they change the implementation, not just the polish.
|
||||
|
||||
### Seam #1 — hub-skill install cannot use the HERMES_HOME override
|
||||
|
||||
`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
|
||||
import time**. The context-local `set_hermes_home_override()` swap (which makes
|
||||
`_write_profile_model` and the MCP write land in the target profile) does NOT
|
||||
retroactively rebind that already-imported module global. So a data-layer wrap
|
||||
of hub install would write into the dashboard's *own* active profile, not the
|
||||
new one.
|
||||
|
||||
The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
|
||||
runs `python -m hermes_cli.main <subcommand>`, and `_apply_profile_override()`
|
||||
re-reads `sys.argv` at import in the fresh child. Prepend `-p <profile>`:
|
||||
|
||||
```python
|
||||
_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
|
||||
```
|
||||
|
||||
A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
|
||||
from the start, so `SKILLS_DIR` resolves to `<profile>/skills/`. Correct by
|
||||
construction.
|
||||
|
||||
### Seam #2 — hub installs are async, so create cannot be fully atomic
|
||||
|
||||
Built-in/optional skill enabling and MCP writes are **synchronous config ops**
|
||||
and can be part of the create call. Hub installs are long-running git fetches
|
||||
spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
|
||||
create flow is:
|
||||
|
||||
1. `create_profile()` — make the dir (synchronous)
|
||||
2. write model (synchronous, HERMES_HOME override)
|
||||
3. write selected MCP servers (synchronous, HERMES_HOME override)
|
||||
4. seed/enable selected built-in + optional skills (synchronous)
|
||||
5. spawn `hermes -p <profile> skills install <id>` per hub skill (async, returns PIDs)
|
||||
|
||||
Steps 1–4 commit before the response; step 5 returns a list of action PIDs the
|
||||
UI polls (same pattern as today's SkillsPage hub install). The builder's
|
||||
"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
|
||||
final screen shows live install progress for the hub skills.
|
||||
|
||||
## Proposed backend change (small, follows existing patterns)
|
||||
|
||||
Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
|
||||
|
||||
```python
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from_default: bool = False
|
||||
clone_all: bool = False
|
||||
no_skills: bool = False
|
||||
description: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
# NEW — all optional, all best-effort post-create (profile already exists)
|
||||
mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
|
||||
builtin_skills: List[str] = [] # synchronous enable/seed
|
||||
hub_skills: List[str] = [] # async spawn, returns PIDs
|
||||
```
|
||||
|
||||
The endpoint already does best-effort post-create steps (`seed_profile_skills`,
|
||||
`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
|
||||
spawn) in the same style — a failure in any of them must not 500 the create,
|
||||
since the profile dir already exists and the user can fix it from the relevant
|
||||
page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
|
||||
the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
|
||||
|
||||
## Proposed frontend — dedicated builder page `/profiles/new`
|
||||
|
||||
A full page (not the cramped modal), stepped, each step reusing the existing
|
||||
page's component + API, targeted at the new profile:
|
||||
|
||||
```
|
||||
① Identity Name + Description (+ optional clone-from existing profile)
|
||||
② Model Provider + model picker (reuse ModelsPage picker)
|
||||
③ Skills Tabs: Built-in · Optional · Hub-search
|
||||
multi-select; "Start from default bundle" preset button
|
||||
④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
|
||||
⑤ Review Blueprint preview → Create
|
||||
→ progress screen for async hub installs
|
||||
```
|
||||
|
||||
Nothing writes to disk until ⑤.
|
||||
|
||||
## Open product decisions (need Teknium)
|
||||
|
||||
1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
|
||||
today. In the builder, should the skill step **replace** the bundle (pick
|
||||
exactly what you want; offer a "start from default bundle" preset) or
|
||||
**augment** it? Recommendation: replace + preset button.
|
||||
|
||||
2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
|
||||
SOUL editing, multi-agent fleets later) vs a bigger create modal on
|
||||
ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
|
||||
more options."
|
||||
|
||||
## Verification plan (when built)
|
||||
|
||||
- Backend E2E with isolated HERMES_HOME: POST a full create body
|
||||
(name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
|
||||
profile dir has the model in config.yaml, both MCP servers in config.yaml,
|
||||
the builtin skills enabled, and a spawned PID for the hub skill. Negative:
|
||||
a bad MCP entry must not 500 the create.
|
||||
- `cd web && npm run build` (no JS test suite in web/).
|
||||
- Targeted: `pytest tests/<web_server profile tests> -k profile_create`.
|
||||
@@ -20,9 +20,11 @@ import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -657,6 +659,21 @@ class AudioTranscriptionRequest(BaseModel):
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ManagedFileUpload(BaseModel):
|
||||
path: str
|
||||
data_url: str
|
||||
overwrite: bool = True
|
||||
|
||||
|
||||
class ManagedDirectoryCreate(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class ManagedFileDelete(BaseModel):
|
||||
path: str
|
||||
recursive: bool = False
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
@@ -819,6 +836,16 @@ _MEDIA_CONTENT_TYPES = {
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
||||
_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT"
|
||||
_MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024
|
||||
_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedFilesPolicy:
|
||||
default_path: Path
|
||||
locked_root: Path | None
|
||||
can_change_path: bool
|
||||
|
||||
|
||||
def _media_serve_roots() -> list[Path]:
|
||||
@@ -874,6 +901,297 @@ async def get_media(path: str):
|
||||
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
|
||||
|
||||
|
||||
def _canonical_path(path: Path, *, require_exists: bool = False) -> Path:
|
||||
try:
|
||||
return path.expanduser().resolve(strict=require_exists)
|
||||
except FileNotFoundError:
|
||||
if require_exists:
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
raise
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
|
||||
def _ensure_managed_root(raw_path: str | Path) -> Path:
|
||||
root = Path(raw_path).expanduser()
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
resolved = root.resolve()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}")
|
||||
if not resolved.is_dir():
|
||||
raise HTTPException(status_code=500, detail="Managed files root is not a directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def _path_is_under(root: Path, target: Path) -> bool:
|
||||
return target == root or root in target.parents
|
||||
|
||||
|
||||
def _path_text(raw_path: str | None) -> str:
|
||||
text = str(raw_path or "").strip()
|
||||
if "\x00" in text:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
return text
|
||||
|
||||
|
||||
def _local_dashboard_request(request: Request) -> bool:
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
return False
|
||||
host = (request.url.hostname or "").lower()
|
||||
client_host = (request.client.host if request.client else "").lower()
|
||||
local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"}
|
||||
return host in local_hosts or client_host in local_hosts
|
||||
|
||||
|
||||
def _default_hermes_root_is_opt_data() -> bool:
|
||||
raw = os.environ.get("HERMES_HOME", "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
root = get_default_hermes_root().expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError):
|
||||
root = Path(raw).expanduser().resolve(strict=False)
|
||||
return root == _HOSTED_MANAGED_FILES_ROOT
|
||||
|
||||
|
||||
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
||||
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
||||
if raw_forced_root:
|
||||
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data():
|
||||
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
home = _canonical_path(Path.home())
|
||||
return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True)
|
||||
|
||||
|
||||
def _resolve_managed_path(
|
||||
raw_path: str | None,
|
||||
request: Request,
|
||||
*,
|
||||
for_write: bool = False,
|
||||
) -> tuple[ManagedFilesPolicy, Path, str]:
|
||||
policy = _managed_files_policy(request)
|
||||
text = _path_text(raw_path)
|
||||
root = policy.locked_root
|
||||
|
||||
if root is not None and (not text or text in {".", "/"}):
|
||||
candidate = root
|
||||
elif not text:
|
||||
candidate = policy.default_path
|
||||
else:
|
||||
candidate = Path(text).expanduser()
|
||||
if root is not None and not candidate.is_absolute():
|
||||
if any(part == ".." for part in candidate.parts):
|
||||
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
|
||||
candidate = root / candidate
|
||||
elif not candidate.is_absolute():
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
if ".." in candidate.parts:
|
||||
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
|
||||
|
||||
if for_write and not candidate.exists():
|
||||
parent = _canonical_path(candidate.parent)
|
||||
resolved = parent / candidate.name
|
||||
else:
|
||||
resolved = _canonical_path(candidate, require_exists=not for_write)
|
||||
|
||||
if root is not None and not _path_is_under(root, resolved):
|
||||
raise HTTPException(status_code=403, detail="Path outside managed files root")
|
||||
|
||||
return policy, resolved, str(resolved)
|
||||
|
||||
|
||||
def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]:
|
||||
locked_root = str(policy.locked_root) if policy.locked_root is not None else None
|
||||
return {
|
||||
"root": locked_root,
|
||||
"locked_root": locked_root,
|
||||
"can_change_path": policy.can_change_path,
|
||||
}
|
||||
|
||||
|
||||
def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved):
|
||||
raise HTTPException(status_code=403, detail="Path outside managed files root")
|
||||
|
||||
try:
|
||||
st = resolved.stat()
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}")
|
||||
|
||||
is_dir = resolved.is_dir()
|
||||
mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream")
|
||||
return {
|
||||
"name": target.name or resolved.name or str(resolved),
|
||||
"path": str(resolved),
|
||||
"is_directory": is_dir,
|
||||
"size": None if is_dir else st.st_size,
|
||||
"mtime": st.st_mtime,
|
||||
"mime_type": mime_type,
|
||||
}
|
||||
|
||||
|
||||
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
|
||||
text = (data_url or "").strip()
|
||||
if not text.startswith("data:") or "," not in text:
|
||||
raise HTTPException(status_code=400, detail="Upload payload must be a data URL")
|
||||
header, encoded = text.split(",", 1)
|
||||
mime_type = header[5:].split(";", 1)[0] or "application/octet-stream"
|
||||
if ";base64" not in header:
|
||||
raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded")
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Upload payload is not valid base64")
|
||||
if len(data) > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
return data, mime_type
|
||||
|
||||
|
||||
@app.get("/api/files")
|
||||
async def list_managed_files(request: Request, path: Optional[str] = None):
|
||||
policy, target, display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
try:
|
||||
entries = [_managed_file_entry(policy, child) for child in target.iterdir()]
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Directory is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}")
|
||||
|
||||
entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower()))
|
||||
locked_root = policy.locked_root
|
||||
parent = None
|
||||
if target.parent != target and (locked_root is None or target != locked_root):
|
||||
parent = str(target.parent)
|
||||
return {
|
||||
"path": display_path,
|
||||
"parent": parent,
|
||||
"entries": entries,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/files/read")
|
||||
async def read_managed_file(request: Request, path: str):
|
||||
policy, target, display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
try:
|
||||
size = target.stat().st_size
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
|
||||
if size > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
|
||||
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
try:
|
||||
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read file: {exc}")
|
||||
|
||||
return {
|
||||
"name": target.name,
|
||||
"path": display_path,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"data_url": f"data:{mime_type};base64,{encoded}",
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/files/upload")
|
||||
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
if target.exists() and target.is_dir():
|
||||
raise HTTPException(status_code=409, detail="A directory already exists at that path")
|
||||
if target.exists() and not payload.overwrite:
|
||||
raise HTTPException(status_code=409, detail="File already exists")
|
||||
|
||||
data, _mime_type = _decode_data_url(payload.data_url)
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not writable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"entry": _managed_file_entry(policy, target),
|
||||
"path": display_path,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/files/mkdir")
|
||||
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
if target.exists() and not target.is_dir():
|
||||
raise HTTPException(status_code=409, detail="A file already exists at that path")
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Directory is not writable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"entry": _managed_file_entry(policy, target),
|
||||
"path": display_path,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/files")
|
||||
async def delete_managed_file(payload: ManagedFileDelete, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request)
|
||||
if policy.locked_root is not None and target == policy.locked_root:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the managed files root")
|
||||
if target.parent == target:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the filesystem root")
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
|
||||
try:
|
||||
if target.is_dir():
|
||||
if payload.recursive:
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.rmdir()
|
||||
else:
|
||||
target.unlink()
|
||||
except OSError as exc:
|
||||
status_code = 409 if target.is_dir() and not payload.recursive else 500
|
||||
raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}")
|
||||
|
||||
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
@@ -7422,6 +7740,21 @@ class ProfileCreate(BaseModel):
|
||||
clone_from: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
# Profile-builder additions — all optional, all applied best-effort AFTER
|
||||
# the profile directory exists, so a hiccup in any of them never 500s the
|
||||
# create (the user can fix it from the relevant dashboard page afterward).
|
||||
# MCP servers to write into the new profile's config.yaml.
|
||||
mcp_servers: List["MCPServerCreate"] = []
|
||||
# Built-in / optional skills to KEEP active. When this list is non-empty,
|
||||
# the builder uses "replace" semantics: the bundle is seeded, then every
|
||||
# seeded skill NOT in this list is added to the profile's disabled list.
|
||||
# Empty list = leave the seeded bundle untouched (legacy behaviour).
|
||||
keep_skills: List[str] = []
|
||||
# Skills-hub identifiers to install into the new profile. Installed async
|
||||
# via a subprocess scoped to the profile (`hermes -p <name> skills install`)
|
||||
# because skills_hub.SKILLS_DIR is import-time-bound and the HERMES_HOME
|
||||
# override can't redirect it. Returns spawned PIDs for the UI to poll.
|
||||
hub_skills: List[str] = []
|
||||
|
||||
|
||||
class ProfileRename(BaseModel):
|
||||
@@ -7567,6 +7900,94 @@ def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate"]) -> int:
|
||||
"""Write MCP server entries into a specific profile's config.yaml.
|
||||
|
||||
Scopes ``load_config``/``save_config`` to ``profile_dir`` via the
|
||||
context-local HERMES_HOME override (same mechanism as
|
||||
``_write_profile_model``) so the entries land in the target profile's
|
||||
config rather than the dashboard process's active profile.
|
||||
|
||||
Mirrors the per-server shape the ``POST /api/mcp/servers`` endpoint builds,
|
||||
but batched so the whole profile-create write is a single config save.
|
||||
Returns the number of servers written.
|
||||
"""
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
|
||||
written = 0
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
mcp = cfg.setdefault("mcp_servers", {})
|
||||
for server in servers:
|
||||
name = (server.name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
entry: Dict[str, Any] = {}
|
||||
if server.url:
|
||||
entry["url"] = server.url
|
||||
if server.command:
|
||||
entry["command"] = server.command
|
||||
if server.args:
|
||||
entry["args"] = list(server.args)
|
||||
if server.env:
|
||||
entry["env"] = dict(server.env)
|
||||
if server.auth:
|
||||
entry["auth"] = server.auth
|
||||
if not entry:
|
||||
# Nothing usable to write (neither url nor command) — skip
|
||||
# rather than persist an empty, unusable server stanza.
|
||||
continue
|
||||
mcp[name] = entry
|
||||
written += 1
|
||||
if written:
|
||||
save_config(cfg)
|
||||
elif not mcp:
|
||||
# We created an empty mcp_servers dict but wrote nothing — don't
|
||||
# leave a stray empty key in the new profile's config.
|
||||
cfg.pop("mcp_servers", None)
|
||||
save_config(cfg)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return written
|
||||
|
||||
|
||||
def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int:
|
||||
"""Disable every installed skill in ``profile_dir`` not in ``keep``.
|
||||
|
||||
Profiles manage skill activation via a *disabled* list — all installed
|
||||
skills are active by default and users opt out. The builder's skill step
|
||||
uses "replace" semantics: the user picks exactly which seeded built-in /
|
||||
optional skills stay active, and everything else gets added to the disabled
|
||||
list. (Hub skills are installed separately via subprocess and are active on
|
||||
install.) Scoped to the profile via the HERMES_HOME override. Returns the
|
||||
number of skills newly disabled.
|
||||
"""
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
|
||||
keep_set = {s.strip() for s in keep if s and s.strip()}
|
||||
disabled_count = 0
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
installed: List[str] = []
|
||||
skills_root = profile_dir / "skills"
|
||||
if skills_root.is_dir():
|
||||
for md in skills_root.rglob("SKILL.md"):
|
||||
installed.append(md.parent.name)
|
||||
cfg = load_config()
|
||||
disabled = get_disabled_skills(cfg)
|
||||
for name in installed:
|
||||
if name not in keep_set and name not in disabled:
|
||||
disabled.add(name)
|
||||
disabled_count += 1
|
||||
if disabled_count:
|
||||
save_disabled_skills(cfg, disabled)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return disabled_count
|
||||
|
||||
|
||||
@app.get("/api/profiles")
|
||||
async def list_profiles_endpoint():
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
@@ -7633,7 +8054,55 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
except Exception:
|
||||
_log.exception("Setting model for new profile %s failed", body.name)
|
||||
|
||||
return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set}
|
||||
# Optional MCP servers. Best-effort, same rationale as model assignment.
|
||||
mcp_written = 0
|
||||
if body.mcp_servers:
|
||||
try:
|
||||
mcp_written = _write_profile_mcp_servers(path, body.mcp_servers)
|
||||
except Exception:
|
||||
_log.exception("Writing MCP servers for new profile %s failed", body.name)
|
||||
|
||||
# Optional "keep" skill selection — replace semantics. When the builder
|
||||
# sends an explicit keep list, disable every seeded skill not in it.
|
||||
# Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle).
|
||||
skills_disabled = 0
|
||||
if body.keep_skills:
|
||||
try:
|
||||
skills_disabled = _disable_unselected_skills(path, body.keep_skills)
|
||||
except Exception:
|
||||
_log.exception("Applying skill selection for new profile %s failed", body.name)
|
||||
|
||||
# Optional skills-hub installs. Spawned async, scoped to the new profile
|
||||
# via `-p <name>` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the
|
||||
# profile's HERMES_HOME at import). Returns PIDs for the UI to poll.
|
||||
hub_installs: List[Dict[str, Any]] = []
|
||||
for identifier in body.hub_skills:
|
||||
ident = (identifier or "").strip()
|
||||
if not ident:
|
||||
continue
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
["-p", body.name, "skills", "install", ident],
|
||||
"skills-install",
|
||||
)
|
||||
hub_installs.append({"identifier": ident, "pid": proc.pid})
|
||||
except Exception:
|
||||
_log.exception(
|
||||
"Spawning hub-skill install %s for new profile %s failed",
|
||||
ident,
|
||||
body.name,
|
||||
)
|
||||
hub_installs.append({"identifier": ident, "pid": None})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"name": body.name,
|
||||
"path": str(path),
|
||||
"model_set": model_set,
|
||||
"mcp_written": mcp_written,
|
||||
"skills_disabled": skills_disabled,
|
||||
"hub_installs": hub_installs,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/profiles/active")
|
||||
|
||||
@@ -21,7 +21,7 @@ let
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-cY+gM1FnTBjmld/uqt7RsqRtW9uQGs8LGokCcxu7bjQ=";
|
||||
npmDepsHash = "sha256-mVWPJLIYa4EA0iNPiSVLAPzjjnWdky2HbG5mwApy1lo=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
@@ -53,7 +53,7 @@ in
|
||||
{
|
||||
folder, # repo-relative folder with package.json, e.g. "ui-tui"
|
||||
attr, # flake package attr, e.g. "tui"
|
||||
pname, # e.g. "hermes-tui"
|
||||
...
|
||||
}:
|
||||
let
|
||||
# No sourceRoot — the workspace root (with the single package-lock.json)
|
||||
|
||||
536
package-lock.json
generated
536
package-lock.json
generated
@@ -53,10 +53,24 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
},
|
||||
"apps/bootstrap-installer/node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "hermes",
|
||||
"version": "0.15.1",
|
||||
@@ -119,20 +133,19 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"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",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^16.5.0",
|
||||
@@ -216,6 +229,152 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"apps/desktop/node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
@@ -308,6 +467,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"apps/shared": {
|
||||
"name": "@hermes/shared",
|
||||
"version": "0.0.0",
|
||||
@@ -680,19 +885,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-annotate-as-pure": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
|
||||
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
@@ -720,38 +912,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz",
|
||||
"integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||
"@babel/helper-member-expression-to-functions": "^7.28.5",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/helper-replace-supers": "^7.28.6",
|
||||
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
@@ -762,20 +922,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-member-expression-to-functions": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
|
||||
"integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.5",
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
@@ -808,19 +954,6 @@
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-optimise-call-expression": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
|
||||
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
@@ -831,38 +964,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-replace-supers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
|
||||
"integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-member-expression-to-functions": "^7.28.5",
|
||||
"@babel/helper-optimise-call-expression": "^7.27.1",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
|
||||
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
@@ -923,24 +1024,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-proposal-private-methods": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
|
||||
"integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
|
||||
"deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-create-class-features-plugin": "^7.18.6",
|
||||
"@babel/helper-plugin-utils": "^7.18.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
@@ -8335,13 +8418,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz",
|
||||
"integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/plist": {
|
||||
@@ -10141,31 +10224,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
@@ -11519,25 +11577,6 @@
|
||||
"@electron/windows-sign": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
@@ -11565,14 +11604,6 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
@@ -12030,50 +12061,6 @@
|
||||
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-compiler": {
|
||||
"version": "19.1.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz",
|
||||
"integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.22.4",
|
||||
"zod-validation-error": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-compiler/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-compiler/node_modules/zod-validation-error": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
|
||||
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||
@@ -18771,19 +18758,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/shiki": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz",
|
||||
@@ -19279,22 +19253,6 @@
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-hyperlinks": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
|
||||
@@ -19701,6 +19659,7 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19717,6 +19676,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19733,6 +19693,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19749,6 +19710,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19765,6 +19727,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19781,6 +19744,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19797,6 +19761,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19813,6 +19778,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19829,6 +19795,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19845,6 +19812,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19861,6 +19829,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19877,6 +19846,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19893,6 +19863,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19909,6 +19880,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19925,6 +19897,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19941,6 +19914,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19957,6 +19931,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19973,6 +19948,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -19989,6 +19965,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20005,6 +19982,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20021,6 +19999,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20037,6 +20016,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20053,6 +20033,7 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20069,6 +20050,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20085,6 +20067,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20101,6 +20084,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -20276,6 +20260,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -20344,9 +20329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -21433,7 +21418,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
@@ -21441,13 +21426,12 @@
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-perfectionist": "^5",
|
||||
"eslint-plugin-react": "^7",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7",
|
||||
"eslint-plugin-unused-imports": "^4",
|
||||
"globals": "^16",
|
||||
"prettier": "^3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
},
|
||||
@@ -22026,6 +22010,20 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"ui-tui/node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"ui-tui/node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
@@ -22163,7 +22161,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"three": "^0.180.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
@@ -22224,6 +22222,20 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"web/node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ edge-tts = ["edge-tts==7.2.7"]
|
||||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
|
||||
@@ -327,12 +327,8 @@ markers = [
|
||||
"integration: marks tests requiring external services (API keys, Modal, etc.)",
|
||||
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
|
||||
]
|
||||
# pytest-timeout: per-test 30s hard cap with cross-platform thread method.
|
||||
# This is the fallback inside each per-file pytest subprocess (see
|
||||
# scripts/run_tests_parallel.py). Per-file isolation gives every test
|
||||
# file a fresh Python interpreter; pytest-timeout catches Python-level
|
||||
# hangs within a file.
|
||||
addopts = "-m 'not integration' --timeout=30 --timeout-method=thread"
|
||||
# integration tests take way too long to run in the normal CI environments
|
||||
addopts = "-m 'not integration'"
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.13"
|
||||
|
||||
@@ -73,6 +73,7 @@ exec env -i \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
PYTHONHASHSEED=0 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
${EXTRA_PYTHONPATH:+PYTHONPATH="$EXTRA_PYTHONPATH"} \
|
||||
${EXTRA_PYTEST_PLUGINS:+PYTEST_PLUGINS="$EXTRA_PYTEST_PLUGINS"} \
|
||||
"$PYTHON" "$SCRIPT_DIR/run_tests_parallel.py" "$@"
|
||||
|
||||
@@ -65,17 +65,14 @@ _DEFAULT_ROOTS = ["tests"]
|
||||
# rebuild). The full pytest-shard runner can't
|
||||
# host these because the session-scoped
|
||||
# ``built_image`` fixture would do a 3-7min
|
||||
# ``docker build`` inside a 180s per-test
|
||||
# pytest-timeout cap (set by tests/docker/conftest.py),
|
||||
# ``docker build``,
|
||||
# so the build is guaranteed to die in fixture
|
||||
# setup. The dedicated job sidesteps both costs.
|
||||
_SKIP_PARTS = {"integration", "e2e", "docker"}
|
||||
|
||||
# Per-file wall-clock cap. Generous default — pytest-timeout still
|
||||
# enforces per-test caps inside each subprocess; this is just an outer
|
||||
# safety net so a single hung file can't stall the whole suite. Override
|
||||
# Per-file wall-clock cap. Override
|
||||
# via --file-timeout or HERMES_TEST_FILE_TIMEOUT.
|
||||
_DEFAULT_FILE_TIMEOUT_SECONDS = 600.0 # 10 minutes
|
||||
_DEFAULT_FILE_TIMEOUT_SECONDS = 140.0 # set by observing the slowest file at commit time was ~100s in CI and adding some leeway
|
||||
|
||||
# Duration cache: maps relative file paths to last-observed subprocess
|
||||
# wall-clock seconds. Used by ``--slice`` to distribute files across
|
||||
@@ -246,27 +243,49 @@ def _kill_tree(proc: "subprocess.Popen", pgid: int | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _spawn_pytest_once(
|
||||
cmd: List[str],
|
||||
def _run_one_file(
|
||||
file: Path,
|
||||
pytest_args: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
*,
|
||||
timeout_note: str = "per-file timeout",
|
||||
) -> Tuple[int, str]:
|
||||
"""Run one ``pytest`` subprocess to completion and return ``(rc, output)``.
|
||||
) -> Tuple[Path, int, str, dict[str, int], float]:
|
||||
"""Run ``python -m pytest <file> <pytest_args>`` in a fresh subprocess.
|
||||
|
||||
Spawns the child in its own process group / session so a hung file and
|
||||
its grandchildren (uvicorn servers, async runtimes, etc.) can be SIGKILL'd
|
||||
as a tree on timeout rather than orphaning onto PID 1. Shared by the
|
||||
primary per-file run and the exit-4 retry loop so the lifecycle/cleanup
|
||||
logic lives in exactly one place.
|
||||
Returns (file, returncode, captured_combined_output, summary_counts, subprocess_wall_seconds).
|
||||
|
||||
``summary_counts`` is the result of ``_parse_pytest_summary(output)`` —
|
||||
|
||||
pytest exit codes (https://docs.pytest.org/en/stable/reference/exit-codes.html):
|
||||
0 = all tests passed
|
||||
1 = some tests failed
|
||||
2 = test execution interrupted
|
||||
3 = internal error
|
||||
4 = pytest CLI usage error
|
||||
5 = no tests collected
|
||||
|
||||
We treat exit 5 as a pass: it just means every test in the file was
|
||||
skipped or filtered by a marker (e.g. ``-m 'not integration'`` skips
|
||||
files where every test is marked integration). That's intentional and
|
||||
not a failure mode.
|
||||
|
||||
On per-file timeout (``file_timeout`` seconds) or any other exception
|
||||
during ``communicate()``, we kill the whole process group / process
|
||||
tree so grandchildren (uvicorn servers, async runtimes, etc.) do not
|
||||
orphan onto PID 1. This outer timeout exists only to
|
||||
bound a pathologically slow or hung file as a whole.
|
||||
"""
|
||||
cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args]
|
||||
|
||||
subproc_start = time.monotonic()
|
||||
# launch the pytest process
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=repo_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
# skipping writing bytecode because we're running a bunch of parallel python processes on the same code
|
||||
env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'},
|
||||
# POSIX: place the child at the head of its own process group so
|
||||
# _kill_tree can SIGKILL the group atomically.
|
||||
# Windows: this maps to CREATE_NEW_PROCESS_GROUP in CPython 3.12+;
|
||||
@@ -309,91 +328,36 @@ def _spawn_pytest_once(
|
||||
# case it left grandchildren behind; already-dead is a no-op.
|
||||
_kill_tree(proc, pgid=pgid)
|
||||
|
||||
return rc, output
|
||||
|
||||
|
||||
# How many times to re-run a file that exits 4 ("file or directory not found")
|
||||
# while the file demonstrably exists on disk. On loaded shared CI runners the
|
||||
# planner can enumerate a file (tests counted via --collect-only) but the
|
||||
# per-file subprocess fail to stat it moments later — and a SINGLE immediate
|
||||
# retry can land in the same brief high-load window and fail again. We retry a
|
||||
# few times with a short backoff so transient I/O pressure has time to settle.
|
||||
_EXIT4_RETRY_ATTEMPTS = 3
|
||||
_EXIT4_RETRY_BACKOFF_SECONDS = 0.5
|
||||
|
||||
|
||||
def _file_present(file: Path, *, attempts: int = 3, delay: float = 0.2) -> bool:
|
||||
"""Return True if ``file`` exists, re-checking a few times.
|
||||
|
||||
``Path.exists()`` itself issues a ``stat`` that can transiently fail under
|
||||
the same load that makes pytest report "file or directory not found", so a
|
||||
single negative check is not authoritative. Only conclude the file is
|
||||
genuinely missing if it's absent across several spaced checks.
|
||||
"""
|
||||
for i in range(attempts):
|
||||
if file.exists():
|
||||
return True
|
||||
if i < attempts - 1:
|
||||
time.sleep(delay)
|
||||
return False
|
||||
|
||||
|
||||
def _run_one_file(
|
||||
file: Path,
|
||||
pytest_args: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
) -> Tuple[Path, int, str, dict[str, int], float]:
|
||||
"""Run ``python -m pytest <file> <pytest_args>`` in a fresh subprocess.
|
||||
|
||||
Returns (file, returncode, captured_combined_output, summary_counts, subprocess_wall_seconds).
|
||||
|
||||
``summary_counts`` is the result of ``_parse_pytest_summary(output)`` —
|
||||
|
||||
pytest exit codes (https://docs.pytest.org/en/stable/reference/exit-codes.html):
|
||||
0 = all tests passed
|
||||
1 = some tests failed
|
||||
2 = test execution interrupted
|
||||
3 = internal error
|
||||
4 = pytest CLI usage error
|
||||
5 = no tests collected
|
||||
|
||||
We treat exit 5 as a pass: it just means every test in the file was
|
||||
skipped or filtered by a marker (e.g. ``-m 'not integration'`` skips
|
||||
files where every test is marked integration). That's intentional and
|
||||
not a failure mode.
|
||||
|
||||
On per-file timeout (``file_timeout`` seconds) or any other exception
|
||||
during ``communicate()``, we kill the whole process group / process
|
||||
tree so grandchildren (uvicorn servers, async runtimes, etc.) do not
|
||||
orphan onto PID 1. The pytest-timeout plugin enforces per-test
|
||||
timeouts inside the subprocess; this outer timeout exists only to
|
||||
bound a pathologically slow or hung file as a whole.
|
||||
"""
|
||||
cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args]
|
||||
subproc_start = time.monotonic()
|
||||
rc, output = _spawn_pytest_once(cmd, repo_root, file_timeout)
|
||||
|
||||
# pytest exit 4 = "file or directory not found" at exec time. On loaded
|
||||
# shared CI runners we have seen the planner enumerate a file (its tests
|
||||
# counted via --collect-only) but the per-file subprocess fail to stat it
|
||||
# moments later — a transient the deterministic LPT slicer otherwise
|
||||
# reproduces on every rerun (same file set → same shard). Re-run the file a
|
||||
# few times with a short backoff so the I/O pressure has time to settle,
|
||||
# but ONLY while the file demonstrably exists on disk. A single immediate
|
||||
# retry (the old behaviour) could land in the same brief high-load window
|
||||
# and fail again; a single Path.exists() check could itself be a flaky stat
|
||||
# under that load, so we re-check existence across spaced attempts.
|
||||
# We do NOT widen the exit-5 rule: exit 4 on a file that genuinely does not
|
||||
# exist must still fail.
|
||||
attempt = 0
|
||||
while rc == 4 and attempt < _EXIT4_RETRY_ATTEMPTS and _file_present(file):
|
||||
attempt += 1
|
||||
time.sleep(_EXIT4_RETRY_BACKOFF_SECONDS * attempt)
|
||||
rc, output = _spawn_pytest_once(
|
||||
cmd, repo_root, file_timeout,
|
||||
timeout_note=f"per-file timeout on exit-4 retry {attempt}",
|
||||
)
|
||||
if rc == 4:
|
||||
# the file wasn't found.
|
||||
# this shouldn't be possible.
|
||||
# Capture filesystem forensics so a CI-only "file not found" can
|
||||
# be diagnosed from the log instead of guessed at: does the file
|
||||
# exist NOW, what does the parent dir hold, and is the git tree
|
||||
# clean?
|
||||
forensics = [f"--- file-not-found forensics for {file} ---"]
|
||||
try:
|
||||
forensics.append(f"exists={file.exists()}")
|
||||
parent = file.parent
|
||||
if parent.exists():
|
||||
names = sorted(p.name for p in parent.iterdir())
|
||||
sibling_hint = [n for n in names if file.stem[:12] in n]
|
||||
forensics.append(
|
||||
f"parent={parent} entries={len(names)} "
|
||||
f"similar={sibling_hint[:5]}"
|
||||
)
|
||||
else:
|
||||
forensics.append(f"parent={parent} MISSING")
|
||||
git_st = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
dirty = git_st.stdout.strip().splitlines()
|
||||
forensics.append(f"git_dirty_entries={len(dirty)}")
|
||||
forensics.extend(f" {line}" for line in dirty[:10])
|
||||
except Exception as exc: # noqa: BLE001 — forensics must never mask rc=4
|
||||
forensics.append(f"(forensics error: {exc})")
|
||||
output = output + "\n" + "\n".join(forensics)
|
||||
|
||||
if rc == 5:
|
||||
# No tests collected — every test in the file was filtered out.
|
||||
@@ -689,7 +653,7 @@ def main() -> int:
|
||||
help=(
|
||||
"Per-file wall-clock cap in seconds. On timeout, the pytest "
|
||||
"subprocess and its full process tree are SIGKILL'd. "
|
||||
"Default: 600 (10 min), env: HERMES_TEST_FILE_TIMEOUT."
|
||||
f"Default: {_DEFAULT_FILE_TIMEOUT_SECONDS}s ({round(_DEFAULT_FILE_TIMEOUT_SECONDS/60)} min), env: HERMES_TEST_FILE_TIMEOUT."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
|
||||
@@ -126,8 +126,8 @@ def test_cmd_update_on_git_install_does_not_print_docker_message(
|
||||
|
||||
``subprocess.run`` is mocked because the git path will otherwise shell
|
||||
out to ``git fetch upstream`` / ``git fetch origin`` — on CI runners
|
||||
with no ``upstream`` remote configured this can hang past the 30s
|
||||
pytest-timeout depending on git's network behaviour. The stub
|
||||
with no ``upstream`` remote configured this can hang past a timeout
|
||||
depending on git's network behaviour. The stub
|
||||
returns a successful CompletedProcess-shaped object with ``"0\\n"``
|
||||
stdout, which both keeps the flow shell-free AND parses cleanly as
|
||||
the "0 commits behind" rev-list output the check path later parses
|
||||
|
||||
@@ -2425,6 +2425,83 @@ class TestNewEndpoints:
|
||||
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
|
||||
assert profiles["fresh"]["skill_count"] == 1
|
||||
|
||||
def test_profiles_create_builder_fields_model_mcp_and_keep_skills(self, monkeypatch):
|
||||
"""Profile-builder create: model + MCP servers + keep-skills selection
|
||||
all land in the NEW profile's config, and hub installs are spawned
|
||||
scoped to that profile via ``-p <name>``."""
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
set_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
)
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.skills_config import get_disabled_skills
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
|
||||
|
||||
# Seed two known skills so keep-skills "replace" has something to act on.
|
||||
def fake_seed(profile_dir, quiet=False):
|
||||
for skill in ("keep-me", "drop-me"):
|
||||
d = profile_dir / "skills" / "custom" / skill
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(f"---\nname: {skill}\n---\n", encoding="utf-8")
|
||||
return {"copied": ["keep-me", "drop-me"]}
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed)
|
||||
|
||||
# Capture hub-install spawns instead of launching real subprocesses.
|
||||
spawned = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4321
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
spawned.append((list(subcommand), name))
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={
|
||||
"name": "builder",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"mcp_servers": [
|
||||
{"name": "ctx7", "url": "https://mcp.context7.com/mcp"},
|
||||
{"name": "bogus"}, # no url/command -> must be skipped, no 500
|
||||
],
|
||||
"keep_skills": ["keep-me"],
|
||||
"hub_skills": ["someuser/some-skill"],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["model_set"] is True
|
||||
assert data["mcp_written"] == 1 # bogus skipped
|
||||
assert data["skills_disabled"] == 1 # drop-me disabled, keep-me kept
|
||||
assert data["hub_installs"] == [{"identifier": "someuser/some-skill", "pid": 4321}]
|
||||
|
||||
# Hub install was scoped to the new profile.
|
||||
assert spawned == [(["-p", "builder", "skills", "install", "someuser/some-skill"], "skills-install")]
|
||||
|
||||
# Verify the writes landed in the NEW profile's config, not the root.
|
||||
prof_dir = get_hermes_home() / "profiles" / "builder"
|
||||
token = set_hermes_home_override(str(prof_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert cfg["model"]["provider"] == "openrouter"
|
||||
assert sorted((cfg.get("mcp_servers") or {}).keys()) == ["ctx7"]
|
||||
disabled = get_disabled_skills(cfg)
|
||||
assert "drop-me" in disabled
|
||||
assert "keep-me" not in disabled
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.web_server as web_server
|
||||
@@ -4468,7 +4545,7 @@ class TestPtyWebSocket:
|
||||
while time.monotonic() < deadline:
|
||||
# receive_bytes() blocks; once the child prints its winsize and
|
||||
# exits, the PTY closes and further reads raise. Without this
|
||||
# guard a missed-marker run blocks until the 30s pytest-timeout
|
||||
# guard a missed-marker run blocks until a test timeout
|
||||
# (flaky failure) instead of failing fast on the assert below.
|
||||
try:
|
||||
frame = conn.receive_bytes()
|
||||
|
||||
246
tests/hermes_cli/test_web_server_files.py
Normal file
246
tests/hermes_cli/test_web_server_files.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests for the dashboard-managed file browser API."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
def _client_with_app_state():
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = None
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
return client, prev_auth_required, prev_bound_host
|
||||
|
||||
|
||||
def _restore_app_state(prev_auth_required, prev_bound_host):
|
||||
if prev_auth_required is None:
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
else:
|
||||
web_server.app.state.auth_required = prev_auth_required
|
||||
if prev_bound_host is None:
|
||||
if hasattr(web_server.app.state, "bound_host"):
|
||||
delattr(web_server.app.state, "bound_host")
|
||||
else:
|
||||
web_server.app.state.bound_host = prev_bound_host
|
||||
|
||||
|
||||
def _close_client(client):
|
||||
close = getattr(client, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forced_files_client(monkeypatch, tmp_path):
|
||||
root = tmp_path / "data"
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
|
||||
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
yield client, root
|
||||
finally:
|
||||
_close_client(client)
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_files_client(monkeypatch, tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
yield client, home
|
||||
finally:
|
||||
_close_client(client)
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
|
||||
def test_forced_root_file_upload_list_read_delete_roundtrip(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
file_path = root / "out" / "hello.txt"
|
||||
|
||||
created = client.post(
|
||||
"/api/files/upload",
|
||||
json={
|
||||
"path": str(file_path),
|
||||
"data_url": "data:text/plain;base64,aGVsbG8=",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["entry"]["path"] == str(file_path)
|
||||
assert created.json()["locked_root"] == str(root)
|
||||
assert created.json()["can_change_path"] is False
|
||||
assert file_path.read_text() == "hello"
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(root / "out")})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["path"] == str(root / "out")
|
||||
assert listing.json()["parent"] == str(root)
|
||||
assert listing.json()["entries"] == [
|
||||
{
|
||||
"name": "hello.txt",
|
||||
"path": str(file_path),
|
||||
"is_directory": False,
|
||||
"size": 5,
|
||||
"mtime": pytest.approx(file_path.stat().st_mtime),
|
||||
"mime_type": "text/plain",
|
||||
}
|
||||
]
|
||||
|
||||
read = client.get("/api/files/read", params={"path": str(file_path)})
|
||||
assert read.status_code == 200
|
||||
assert read.json()["data_url"] == "data:text/plain;base64,aGVsbG8="
|
||||
|
||||
deleted = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(file_path)},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert not file_path.exists()
|
||||
|
||||
|
||||
def test_directory_management_requires_recursive_delete_for_nonempty_dirs(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
runs_path = root / "runs"
|
||||
checkpoints_path = runs_path / "checkpoints"
|
||||
|
||||
created = client.post("/api/files/mkdir", json={"path": str(checkpoints_path)})
|
||||
assert created.status_code == 200
|
||||
assert checkpoints_path.is_dir()
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(runs_path)})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["entries"][0]["path"] == str(checkpoints_path)
|
||||
assert listing.json()["entries"][0]["is_directory"] is True
|
||||
|
||||
non_recursive = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(runs_path), "recursive": False},
|
||||
)
|
||||
assert non_recursive.status_code == 409
|
||||
|
||||
recursive = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(runs_path), "recursive": True},
|
||||
)
|
||||
assert recursive.status_code == 200
|
||||
assert not runs_path.exists()
|
||||
|
||||
|
||||
def test_forced_root_paths_stay_under_root(forced_files_client, tmp_path):
|
||||
client, root = forced_files_client
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("do not leak")
|
||||
|
||||
traversal = client.get("/api/files", params={"path": "../outside"})
|
||||
assert traversal.status_code == 400
|
||||
|
||||
outside_absolute = client.get("/api/files", params={"path": str(outside)})
|
||||
assert outside_absolute.status_code == 403
|
||||
|
||||
root_delete = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(root), "recursive": True},
|
||||
)
|
||||
assert root_delete.status_code == 400
|
||||
|
||||
root.mkdir(exist_ok=True)
|
||||
link = root / "escape"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("filesystem does not allow directory symlinks")
|
||||
|
||||
escaped = client.get("/api/files", params={"path": str(link)})
|
||||
assert escaped.status_code == 403
|
||||
|
||||
|
||||
def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_client, tmp_path):
|
||||
client, home = local_files_client
|
||||
(home / "home.txt").write_text("home")
|
||||
|
||||
default_listing = client.get("/api/files")
|
||||
assert default_listing.status_code == 200
|
||||
assert default_listing.json()["path"] == str(home)
|
||||
assert default_listing.json()["locked_root"] is None
|
||||
assert default_listing.json()["can_change_path"] is True
|
||||
assert default_listing.json()["entries"][0]["path"] == str(home / "home.txt")
|
||||
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
(other / "other.txt").write_text("other")
|
||||
|
||||
other_listing = client.get("/api/files", params={"path": str(other)})
|
||||
assert other_listing.status_code == 200
|
||||
assert other_listing.json()["path"] == str(other)
|
||||
assert other_listing.json()["parent"] == str(tmp_path)
|
||||
assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt")
|
||||
|
||||
|
||||
def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
|
||||
client, home = local_files_client
|
||||
folder = home / "workspace"
|
||||
file_path = folder / "note.txt"
|
||||
|
||||
created_folder = client.post("/api/files/mkdir", json={"path": str(folder)})
|
||||
assert created_folder.status_code == 200
|
||||
assert created_folder.json()["locked_root"] is None
|
||||
assert created_folder.json()["can_change_path"] is True
|
||||
assert folder.is_dir()
|
||||
|
||||
uploaded = client.post(
|
||||
"/api/files/upload",
|
||||
json={
|
||||
"path": str(file_path),
|
||||
"data_url": "data:text/plain;base64,bG9jYWw=",
|
||||
},
|
||||
)
|
||||
assert uploaded.status_code == 200
|
||||
assert file_path.read_text() == "local"
|
||||
|
||||
read = client.get("/api/files/read", params={"path": str(file_path)})
|
||||
assert read.status_code == 200
|
||||
assert read.json()["data_url"] == "data:text/plain;base64,bG9jYWw="
|
||||
|
||||
deleted = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(folder), "recursive": True},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert not folder.exists()
|
||||
|
||||
|
||||
def test_hosted_policy_locks_to_opt_data(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", "/opt/data")
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
request = SimpleNamespace(
|
||||
app=web_server.app,
|
||||
client=SimpleNamespace(host="127.0.0.1"),
|
||||
url=SimpleNamespace(hostname="127.0.0.1"),
|
||||
)
|
||||
policy = web_server._managed_files_policy(request, create_root=False)
|
||||
finally:
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
client.close()
|
||||
|
||||
assert str(policy.locked_root) == "/opt/data"
|
||||
assert policy.can_change_path is False
|
||||
@@ -185,111 +185,3 @@ def test_grandchild_leak_is_killed_by_runner(tmp_path: Path) -> None:
|
||||
f"diag={diag!r} test_pid={test_pid} test_pgid={test_pgid}; "
|
||||
f"runner output:\n{proc.stdout}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# exit-4 retry loop (transient "file or directory not found" on loaded runners)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import importlib.util as _importlib_util # noqa: E402
|
||||
|
||||
|
||||
def _load_runner_module():
|
||||
"""Import scripts/run_tests_parallel.py as a module for in-process tests."""
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
path = repo_root / "scripts" / "run_tests_parallel.py"
|
||||
spec = _importlib_util.spec_from_file_location("_rtp_under_test", path)
|
||||
mod = _importlib_util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_exit4_retry_recovers_when_file_exists(tmp_path, monkeypatch):
|
||||
"""A file that exits 4 transiently then passes must be retried and recover.
|
||||
|
||||
Simulates the loaded-CI transient: the per-file pytest subprocess reports
|
||||
"file or directory not found" (exit 4) on the first attempts even though
|
||||
the file is on disk, then succeeds. The runner must retry and report pass.
|
||||
"""
|
||||
rtp = _load_runner_module()
|
||||
f = tmp_path / "test_transient.py"
|
||||
f.write_text("def test_ok():\n assert True\n")
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_spawn(cmd, repo_root, file_timeout, *, timeout_note="per-file timeout"):
|
||||
calls["n"] += 1
|
||||
# First two attempts: transient exit-4. Third: success.
|
||||
if calls["n"] < 3:
|
||||
return 4, "ERROR: file or directory not found\nno tests ran in 0.00s"
|
||||
return 0, "1 passed"
|
||||
|
||||
monkeypatch.setattr(rtp, "_spawn_pytest_once", fake_spawn)
|
||||
monkeypatch.setattr(rtp, "_EXIT4_RETRY_BACKOFF_SECONDS", 0.0) # no real sleep
|
||||
|
||||
file, rc, output, summary, _wall = rtp._run_one_file(f, [], tmp_path, 30.0)
|
||||
assert rc == 0, f"expected recovery to pass, got rc={rc}, output={output!r}"
|
||||
assert calls["n"] == 3, f"expected 3 attempts (1 + 2 retries), got {calls['n']}"
|
||||
|
||||
|
||||
def test_exit4_no_retry_when_file_genuinely_missing(tmp_path, monkeypatch):
|
||||
"""Exit 4 on a file that does NOT exist must fail fast without retrying.
|
||||
|
||||
Guards the narrowing: we only retry while the file is present on disk, so a
|
||||
real typo / deleted file surfaces immediately instead of looping.
|
||||
"""
|
||||
rtp = _load_runner_module()
|
||||
missing = tmp_path / "test_does_not_exist.py" # never created
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_spawn(cmd, repo_root, file_timeout, *, timeout_note="per-file timeout"):
|
||||
calls["n"] += 1
|
||||
return 4, "ERROR: file or directory not found"
|
||||
|
||||
monkeypatch.setattr(rtp, "_spawn_pytest_once", fake_spawn)
|
||||
monkeypatch.setattr(rtp, "_EXIT4_RETRY_BACKOFF_SECONDS", 0.0)
|
||||
|
||||
file, rc, output, summary, _wall = rtp._run_one_file(missing, [], tmp_path, 30.0)
|
||||
assert rc == 4, f"genuinely-missing file should keep rc=4, got {rc}"
|
||||
assert calls["n"] == 1, f"missing file must NOT be retried, got {calls['n']} calls"
|
||||
|
||||
|
||||
def test_exit4_retry_gives_up_after_max_attempts(tmp_path, monkeypatch):
|
||||
"""If the transient never clears, we stop after the bounded attempt count."""
|
||||
rtp = _load_runner_module()
|
||||
f = tmp_path / "test_persistent_transient.py"
|
||||
f.write_text("def test_ok():\n assert True\n")
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_spawn(cmd, repo_root, file_timeout, *, timeout_note="per-file timeout"):
|
||||
calls["n"] += 1
|
||||
return 4, "ERROR: file or directory not found"
|
||||
|
||||
monkeypatch.setattr(rtp, "_spawn_pytest_once", fake_spawn)
|
||||
monkeypatch.setattr(rtp, "_EXIT4_RETRY_BACKOFF_SECONDS", 0.0)
|
||||
|
||||
file, rc, output, summary, _wall = rtp._run_one_file(f, [], tmp_path, 30.0)
|
||||
assert rc == 4
|
||||
# 1 initial + _EXIT4_RETRY_ATTEMPTS retries.
|
||||
assert calls["n"] == 1 + rtp._EXIT4_RETRY_ATTEMPTS
|
||||
|
||||
|
||||
def test_file_present_tolerates_transient_negative(tmp_path, monkeypatch):
|
||||
"""_file_present must not conclude 'missing' on a single flaky stat."""
|
||||
rtp = _load_runner_module()
|
||||
f = tmp_path / "test_flaky_stat.py"
|
||||
f.write_text("x = 1\n")
|
||||
|
||||
seq = iter([False, False, True]) # first two stats flake, third succeeds
|
||||
monkeypatch.setattr(rtp.Path, "exists", lambda self: next(seq))
|
||||
assert rtp._file_present(f, attempts=3, delay=0.0) is True
|
||||
|
||||
|
||||
def test_file_present_reports_truly_missing(tmp_path, monkeypatch):
|
||||
"""_file_present returns False when the file is absent across all checks."""
|
||||
rtp = _load_runner_module()
|
||||
f = tmp_path / "nope.py"
|
||||
monkeypatch.setattr(rtp.Path, "exists", lambda self: False)
|
||||
assert rtp._file_present(f, attempts=3, delay=0.0) is False
|
||||
|
||||
@@ -3,7 +3,6 @@ import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
@@ -44,7 +43,6 @@ export default [
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-compiler': reactCompiler,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
@@ -55,7 +53,6 @@ export default [
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'padding-line-between-statements': [
|
||||
1,
|
||||
{ blankLine: 'always', next: ['block-like', 'block', 'return', 'if', 'class', 'continue', 'debugger', 'break', 'multiline-const', 'multiline-let'], prev: '*' },
|
||||
@@ -92,7 +89,6 @@ export default [
|
||||
'no-constant-condition': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-redeclare': 'off',
|
||||
'react-compiler/react-compiler': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx",
|
||||
"start": "tsx src/entry.tsx",
|
||||
"build": "node scripts/build.mjs",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"lint": "eslint src/ packages/",
|
||||
"lint:fix": "eslint src/ packages/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
@@ -34,13 +34,12 @@
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-perfectionist": "^5",
|
||||
"eslint-plugin-react": "^7",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7",
|
||||
"eslint-plugin-unused-imports": "^4",
|
||||
"globals": "^16",
|
||||
"prettier": "^3",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,12 +79,8 @@ const asWireText = (raw: unknown): string | null => {
|
||||
return raw
|
||||
}
|
||||
|
||||
if (raw instanceof ArrayBuffer) {
|
||||
return _wireDecoder.decode(raw)
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(raw)) {
|
||||
return _wireDecoder.decode(raw)
|
||||
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
|
||||
return _wireDecoder.decode(raw as ArrayBufferLike)
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs =
|
||||
return null
|
||||
}
|
||||
|
||||
const timeout = new Promise<undefined>(resolve => setTimeout(resolve, timeoutMs))
|
||||
const timeout = new Promise<void>(resolve => setTimeout(resolve, timeoutMs))
|
||||
|
||||
const query = querier.send<OscResponse>({
|
||||
request: buildOsc52ClipboardQuery(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@hermes/ink": ["src/types/hermes-ink.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
14
uv.lock
generated
14
uv.lock
generated
@@ -1461,7 +1461,6 @@ dev = [
|
||||
{ name = "mcp" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "starlette" },
|
||||
@@ -1663,7 +1662,6 @@ requires-dist = [
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
|
||||
@@ -3175,18 +3173,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -p . --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.18.2",
|
||||
@@ -45,7 +46,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"three": "^0.180.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Globe,
|
||||
Heart,
|
||||
@@ -68,12 +69,14 @@ import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import DocsPage from "@/pages/DocsPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import FilesPage from "@/pages/FilesPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import ModelsPage from "@/pages/ModelsPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import ProfilesPage from "@/pages/ProfilesPage";
|
||||
import ProfileBuilderPage from "@/pages/ProfileBuilderPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import PluginsPage from "@/pages/PluginsPage";
|
||||
import McpPage from "@/pages/McpPage";
|
||||
@@ -124,6 +127,7 @@ const CHAT_NAV_ITEM: NavItem = {
|
||||
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
"/files": FilesPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/models": ModelsPage,
|
||||
"/logs": LogsPage,
|
||||
@@ -136,6 +140,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/webhooks": WebhooksPage,
|
||||
"/system": SystemPage,
|
||||
"/profiles": ProfilesPage,
|
||||
"/profiles/new": ProfileBuilderPage,
|
||||
"/config": ConfigPage,
|
||||
"/env": EnvPage,
|
||||
"/docs": DocsPage,
|
||||
@@ -156,6 +161,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
||||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{
|
||||
path: "/analytics",
|
||||
labelKey: "analytics",
|
||||
@@ -194,6 +200,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
||||
Clock,
|
||||
Cpu,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
|
||||
@@ -325,6 +325,32 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ older_than_days, source }),
|
||||
}),
|
||||
listFiles: (path?: string) => {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return fetchJSON<ManagedFilesResponse>(`/api/files${query}`);
|
||||
},
|
||||
readFile: (path: string) =>
|
||||
fetchJSON<ManagedFileReadResponse>(
|
||||
`/api/files/read?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
|
||||
}),
|
||||
createDirectory: (path: string) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
deleteFile: (path: string, recursive = false) =>
|
||||
fetchJSON<{ ok: boolean; path: string }>("/api/files", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, recursive }),
|
||||
}),
|
||||
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.file) qs.set("file", params.file);
|
||||
@@ -439,8 +465,19 @@ export const api = {
|
||||
description?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mcp_servers?: McpServerCreate[];
|
||||
keep_skills?: string[];
|
||||
hub_skills?: string[];
|
||||
}) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
|
||||
fetchJSON<{
|
||||
ok: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
model_set?: boolean;
|
||||
mcp_written?: number;
|
||||
skills_disabled?: number;
|
||||
hub_installs?: Array<{ identifier: string; pid: number | null }>;
|
||||
}>("/api/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
@@ -1504,6 +1541,44 @@ export interface LogsResponse {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface ManagedFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number | null;
|
||||
mtime: number;
|
||||
mime_type: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedFilesResponse {
|
||||
root: string | null;
|
||||
path: string;
|
||||
parent: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
entries: ManagedFileEntry[];
|
||||
}
|
||||
|
||||
export interface ManagedFileReadResponse {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
data_url: string;
|
||||
root: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFileWriteResponse {
|
||||
ok: boolean;
|
||||
path: string;
|
||||
entry: ManagedFileEntry;
|
||||
root: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyEntry {
|
||||
day: string;
|
||||
input_tokens: number;
|
||||
|
||||
538
web/src/pages/FilesPage.tsx
Normal file
538
web/src/pages/FilesPage.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
FileIcon,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
const cleanName = name.trim().replace(/^[\\/]+/, "");
|
||||
if (!cleanName) return base;
|
||||
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
|
||||
if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`;
|
||||
return `${base}${separator}${cleanName}`;
|
||||
}
|
||||
|
||||
function formatBytes(size: number | null): string {
|
||||
if (size === null) return "-";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: globalThis.File): 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 file"));
|
||||
});
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDataUrl(dataUrl: string, name: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = name || "download";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function displayPath(path: string | null | undefined): string {
|
||||
return path?.trim() || "Files";
|
||||
}
|
||||
|
||||
function transferHasFiles(event: ReactDragEvent<HTMLElement>): boolean {
|
||||
return Array.from(event.dataTransfer.types).includes("Files");
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [listing, setListing] = useState<ManagedFilesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [draggingFiles, setDraggingFiles] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<ManagedFileEntry | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activePath = listing?.path ?? currentPath ?? "";
|
||||
const canChangePath = listing?.can_change_path ?? false;
|
||||
const canUpload = Boolean(activePath) && !uploading;
|
||||
const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath);
|
||||
|
||||
const load = useCallback(
|
||||
async (path = currentPath) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.listFiles(path);
|
||||
setListing(result);
|
||||
setCurrentPath(result.path);
|
||||
setPathInput(result.path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Existing dashboard data pages fetch from effects; keep this local and explicit
|
||||
// until the shared lint profile is updated for async page loaders.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void load(currentPath);
|
||||
}, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setAfterTitle(
|
||||
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
|
||||
{headerPath}
|
||||
</Badge>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
aria-label="Refresh files"
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [headerPath, load, loading, setAfterTitle, setEnd]);
|
||||
|
||||
const openDirectory = (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) {
|
||||
setCurrentPath(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPath = async () => {
|
||||
const nextPath = pathInput.trim();
|
||||
if (!nextPath) {
|
||||
showToast("Path required", "error");
|
||||
return;
|
||||
}
|
||||
await load(nextPath);
|
||||
};
|
||||
|
||||
const createDirectory = async () => {
|
||||
const name = folderName.trim();
|
||||
if (!activePath) {
|
||||
showToast("Directory unavailable", "error");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
showToast("Folder name required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createDirectory(joinPath(activePath, name));
|
||||
setFolderName("");
|
||||
setCreateDialogOpen(false);
|
||||
showToast("Folder created", "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const dataUrl = await readAsDataUrl(file);
|
||||
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
|
||||
}
|
||||
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Upload failed: ${e}`, "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setDraggingFiles(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDraggingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setDraggingFiles(false);
|
||||
void uploadFiles(event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const downloadFile = async (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) return;
|
||||
try {
|
||||
const file = await api.readFile(entry.path);
|
||||
downloadDataUrl(file.data_url, file.name);
|
||||
} catch (e) {
|
||||
showToast(`Download failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteFile(pendingDelete.path, pendingDelete.is_directory);
|
||||
showToast("Deleted", "success");
|
||||
setPendingDelete(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<Toast toast={toast} />
|
||||
<PluginSlot name="files:top" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadFiles(event.currentTarget.files)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
{canChangePath ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void goToPath();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={pathInput}
|
||||
onChange={(event) => setPathInput(event.target.value)}
|
||||
aria-label="Path"
|
||||
placeholder="Path"
|
||||
className="h-9 min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<Button type="submit" size="sm" outlined className="uppercase">
|
||||
Go
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
|
||||
{activePath}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!canUpload}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={uploading ? <Spinner /> : <Upload />}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={!activePath}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={<FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canUpload && fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={!canUpload}
|
||||
aria-label="Upload files"
|
||||
className={`flex min-h-20 w-full min-w-0 items-center justify-between gap-4 border border-dashed px-4 py-3 text-left transition ${
|
||||
draggingFiles
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-background/20 text-text-secondary hover:border-text-tertiary hover:bg-background/35"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-border bg-background/45 text-text-tertiary">
|
||||
{uploading ? <Spinner /> : <Upload className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold uppercase tracking-[0.08em] text-foreground">
|
||||
{uploading ? "Uploading" : draggingFiles ? "Release to upload" : "Drop files here"}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-text-secondary" title={activePath}>
|
||||
{activePath || "Loading"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary sm:block">
|
||||
Choose files
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
{error && (
|
||||
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPath(listing.parent ?? undefined)}
|
||||
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
|
||||
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
..
|
||||
</span>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && !listing ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<Spinner />
|
||||
Loading files...
|
||||
</div>
|
||||
) : listing && listing.entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
|
||||
) : (
|
||||
listing?.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
|
||||
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-warning" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
|
||||
</span>
|
||||
<span className="flex justify-end gap-1">
|
||||
{entry.is_directory ? (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => openDirectory(entry)}
|
||||
aria-label={`Open ${entry.name}`}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void downloadFile(entry)}
|
||||
aria-label={`Download ${entry.name}`}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(entry)}
|
||||
aria-label={`Delete ${entry.name}`}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PluginSlot name="files:bottom" />
|
||||
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (creating) return;
|
||||
setCreateDialogOpen(open);
|
||||
if (!open) setFolderName("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Target: {activePath || "Loading"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void createDirectory();
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
outlined
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
setFolderName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void createDirectory()}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : <FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={Boolean(pendingDelete)}
|
||||
loading={deleting}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"}
|
||||
description={
|
||||
pendingDelete?.is_directory
|
||||
? "This removes the folder and everything inside it."
|
||||
: "This removes the file."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
611
web/src/pages/ProfileBuilderPage.tsx
Normal file
611
web/src/pages/ProfileBuilderPage.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`).
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
type StepId = "identity" | "model" | "skills" | "mcp" | "review";
|
||||
|
||||
const STEPS: { id: StepId; label: string }[] = [
|
||||
{ id: "identity", label: "Identity" },
|
||||
{ id: "model", label: "Model" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCPs" },
|
||||
{ id: "review", label: "Review" },
|
||||
];
|
||||
|
||||
interface ModelChoice {
|
||||
provider: string;
|
||||
model: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-native, full-featured profile builder.
|
||||
*
|
||||
* Composes the same elements the standalone Models / Skills / MCP pages
|
||||
* manage — Name, Description, Model+Provider, Skills (built-in/optional +
|
||||
* hub), MCP servers — into one stepped create flow. Nothing is written to
|
||||
* disk until "Create profile" on the final step; the single POST /api/profiles
|
||||
* call commits model + MCPs + skill selection synchronously and spawns any
|
||||
* hub-skill installs (which the success toast reports as in-progress).
|
||||
*
|
||||
* Skills use REPLACE semantics: the default bundle is seeded server-side, then
|
||||
* every seeded skill the user did NOT keep is disabled. The "Start from full
|
||||
* bundle" toggle keeps everything (sends no keep list).
|
||||
*/
|
||||
export default function ProfileBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
const [step, setStep] = useState<StepId>("identity");
|
||||
|
||||
// ── Step 1: identity ──────────────────────────────────────────────
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// ── Step 2: model ─────────────────────────────────────────────────
|
||||
const [modelChoices, setModelChoices] = useState<ModelChoice[] | null>(null);
|
||||
const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}`
|
||||
const [modelFilter, setModelFilter] = useState("");
|
||||
const modelLoading = useRef(false);
|
||||
|
||||
// ── Step 3: skills ────────────────────────────────────────────────
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null);
|
||||
// keepAll = true: don't send a keep list (full bundle stays active).
|
||||
const [keepAll, setKeepAll] = useState(true);
|
||||
const [keptSkills, setKeptSkills] = useState<Set<string>>(new Set());
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const skillsLoading = useRef(false);
|
||||
// Hub search
|
||||
const [hubQuery, setHubQuery] = useState("");
|
||||
const [hubResults, setHubResults] = useState<SkillHubResult[]>([]);
|
||||
const [hubSearching, setHubSearching] = useState(false);
|
||||
const [hubSkills, setHubSkills] = useState<SkillHubResult[]>([]);
|
||||
|
||||
// ── Step 4: MCPs ──────────────────────────────────────────────────
|
||||
const [mcpServers, setMcpServers] = useState<McpServerCreate[]>([]);
|
||||
const [mcpDraft, setMcpDraft] = useState<{
|
||||
name: string;
|
||||
url: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}>({ name: "", url: "", command: "", args: "" });
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const nameValid = PROFILE_NAME_RE.test(name.trim());
|
||||
|
||||
// Lazy-load model choices when the model step is first shown.
|
||||
const loadModels = useCallback(() => {
|
||||
if (modelChoices !== null || modelLoading.current) return;
|
||||
modelLoading.current = true;
|
||||
api
|
||||
.getModelOptions()
|
||||
.then((res) => {
|
||||
const flat: ModelChoice[] = [];
|
||||
for (const prov of res.providers ?? []) {
|
||||
for (const m of prov.models ?? []) {
|
||||
flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` });
|
||||
}
|
||||
}
|
||||
setModelChoices(flat);
|
||||
})
|
||||
.catch(() => setModelChoices([]))
|
||||
.finally(() => {
|
||||
modelLoading.current = false;
|
||||
});
|
||||
}, [modelChoices]);
|
||||
|
||||
const loadSkills = useCallback(() => {
|
||||
if (skills !== null || skillsLoading.current) return;
|
||||
skillsLoading.current = true;
|
||||
api
|
||||
.getSkills()
|
||||
.then((res) => {
|
||||
setSkills(res);
|
||||
// Default keep = all currently-enabled skills (matches the seeded set).
|
||||
setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name)));
|
||||
})
|
||||
.catch(() => setSkills([]))
|
||||
.finally(() => {
|
||||
skillsLoading.current = false;
|
||||
});
|
||||
}, [skills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "model") loadModels();
|
||||
if (step === "skills") loadSkills();
|
||||
}, [step, loadModels, loadSkills]);
|
||||
|
||||
const runHubSearch = useCallback(() => {
|
||||
const q = hubQuery.trim();
|
||||
if (!q) return;
|
||||
setHubSearching(true);
|
||||
api
|
||||
.searchSkillsHub(q, "all", 20)
|
||||
.then((res) => setHubResults(res.results ?? []))
|
||||
.catch(() => setHubResults([]))
|
||||
.finally(() => setHubSearching(false));
|
||||
}, [hubQuery]);
|
||||
|
||||
const toggleKeep = (skillName: string) => {
|
||||
setKeptSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(skillName)) next.delete(skillName);
|
||||
else next.add(skillName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addHubSkill = (r: SkillHubResult) => {
|
||||
setHubSkills((prev) =>
|
||||
prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r],
|
||||
);
|
||||
};
|
||||
const removeHubSkill = (identifier: string) =>
|
||||
setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier));
|
||||
|
||||
const addMcpDraft = () => {
|
||||
const n = mcpDraft.name.trim();
|
||||
if (!n) {
|
||||
showToast("MCP server needs a name", "error");
|
||||
return;
|
||||
}
|
||||
if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) {
|
||||
showToast("Give the MCP server a URL or a command", "error");
|
||||
return;
|
||||
}
|
||||
const entry: McpServerCreate = { name: n };
|
||||
if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim();
|
||||
if (mcpDraft.command.trim()) {
|
||||
entry.command = mcpDraft.command.trim();
|
||||
const args = mcpDraft.args.trim();
|
||||
if (args) entry.args = args.split(/\s+/);
|
||||
}
|
||||
setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]);
|
||||
setMcpDraft({ name: "", url: "", command: "", args: "" });
|
||||
};
|
||||
const removeMcp = (n: string) =>
|
||||
setMcpServers((prev) => prev.filter((s) => s.name !== n));
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!modelChoices) return [];
|
||||
const f = modelFilter.trim().toLowerCase();
|
||||
if (!f) return modelChoices;
|
||||
return modelChoices.filter((c) => c.label.toLowerCase().includes(f));
|
||||
}, [modelChoices, modelFilter]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) return [];
|
||||
const f = skillFilter.trim().toLowerCase();
|
||||
if (!f) return skills;
|
||||
return skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(f) ||
|
||||
(s.description || "").toLowerCase().includes(f) ||
|
||||
(s.category || "").toLowerCase().includes(f),
|
||||
);
|
||||
}, [skills, skillFilter]);
|
||||
|
||||
const pickedModel = useMemo(
|
||||
() =>
|
||||
modelChoice
|
||||
? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice)
|
||||
: undefined,
|
||||
[modelChoice, modelChoices],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const n = name.trim();
|
||||
if (!PROFILE_NAME_RE.test(n)) {
|
||||
showToast("Invalid profile name (lowercase, digits, - and _)", "error");
|
||||
setStep("identity");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await api.createProfile({
|
||||
name: n,
|
||||
clone_from_default: false,
|
||||
description: description.trim() || undefined,
|
||||
provider: pickedModel?.provider,
|
||||
model: pickedModel?.model,
|
||||
mcp_servers: mcpServers.length ? mcpServers : undefined,
|
||||
keep_skills: keepAll ? undefined : Array.from(keptSkills),
|
||||
hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined,
|
||||
});
|
||||
const pending = (res.hub_installs ?? []).filter((h) => h.pid).length;
|
||||
showToast(
|
||||
pending
|
||||
? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing`
|
||||
: `Profile "${n}" created`,
|
||||
"success",
|
||||
);
|
||||
navigate("/profiles");
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === step);
|
||||
const canAdvance = step !== "identity" || nameValid;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<H2>New profile</H2>
|
||||
<Button ghost onClick={() => navigate("/profiles")}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
// Identity must be valid before jumping ahead.
|
||||
disabled={i > 0 && !nameValid}
|
||||
onClick={() => setStep(s.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
s.id === step
|
||||
? "bg-primary text-primary-foreground"
|
||||
: i <= stepIndex
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground",
|
||||
i > 0 && !nameValid && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{i + 1}. {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
{step === "identity" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-name">Profile name</Label>
|
||||
<Input
|
||||
id="pb-name"
|
||||
placeholder="coder"
|
||||
value={name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
{name && !nameValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Lowercase letters, digits, hyphens and underscores; must start with a letter or digit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="pb-desc"
|
||||
placeholder="What this agent profile is for"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "model" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick the model+provider for this profile. Skip to use the default.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter models…"
|
||||
value={modelFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setModelFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{modelChoices === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading models…</p>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setModelChoice("")}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === "" ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
Use default (set later)
|
||||
</button>
|
||||
{filteredModels.map((c) => {
|
||||
const key = `${c.provider}\u0000${c.model}`;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setModelChoice(key)}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === key ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "skills" && (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={keepAll}
|
||||
onCheckedChange={(v) => setKeepAll(Boolean(v))}
|
||||
/>
|
||||
Start from the full default skill bundle (recommended)
|
||||
</label>
|
||||
{!keepAll && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter skills…"
|
||||
value={skillFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSkillFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{skills === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading skills…</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{filteredSkills.map((s) => (
|
||||
<label
|
||||
key={s.name}
|
||||
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={keptSkills.has(s.name)}
|
||||
onCheckedChange={() => toggleKeep(s.name)}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.category && (
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{s.category}
|
||||
</Badge>
|
||||
)}
|
||||
{s.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills hub */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label>Add from the skills hub</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search the hub (e.g. linear, hyperliquid)…"
|
||||
value={hubQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setHubQuery(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") runHubSearch();
|
||||
}}
|
||||
/>
|
||||
<Button outlined onClick={runHubSearch} disabled={hubSearching}>
|
||||
{hubSearching ? "Searching…" : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
{hubResults.length > 0 && (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{hubResults.map((r) => (
|
||||
<div
|
||||
key={r.identifier}
|
||||
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{r.source}
|
||||
</Badge>
|
||||
{r.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="sm" ghost onClick={() => addHubSkill(r)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hubSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{hubSkills.map((r) => (
|
||||
<Badge key={r.identifier} className="gap-1">
|
||||
{r.name}
|
||||
<button
|
||||
className="ml-1 text-xs"
|
||||
onClick={() => removeHubSkill(r.identifier)}
|
||||
aria-label={`Remove ${r.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "mcp" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="Server name"
|
||||
value={mcpDraft.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL (https://…/mcp)"
|
||||
value={mcpDraft.url}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Command (e.g. npx)"
|
||||
value={mcpDraft.command}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, command: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Args (space-separated)"
|
||||
value={mcpDraft.args}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, args: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button outlined onClick={addMcpDraft}>
|
||||
Add server
|
||||
</Button>
|
||||
{mcpServers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex items-center justify-between rounded bg-muted px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span>
|
||||
<span className="font-medium">{s.name}</span>{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.url || `${s.command} ${(s.args || []).join(" ")}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="text-xs text-destructive"
|
||||
onClick={() => removeMcp(s.name)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<ReviewRow label="Name" value={name.trim() || "—"} />
|
||||
<ReviewRow label="Description" value={description.trim() || "—"} />
|
||||
<ReviewRow
|
||||
label="Model"
|
||||
value={pickedModel ? pickedModel.label : "Default (set later)"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Skills"
|
||||
value={
|
||||
keepAll
|
||||
? "Full default bundle"
|
||||
: `${keptSkills.size} built-in/optional kept` +
|
||||
(hubSkills.length ? ` + ${hubSkills.length} hub` : "")
|
||||
}
|
||||
/>
|
||||
{!keepAll && hubSkills.length > 0 && (
|
||||
<p className="pl-24 text-xs text-muted-foreground">
|
||||
Hub: {hubSkills.map((s) => s.name).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{keepAll && hubSkills.length > 0 && (
|
||||
<ReviewRow
|
||||
label="Hub skills"
|
||||
value={hubSkills.map((s) => s.name).join(", ")}
|
||||
/>
|
||||
)}
|
||||
<ReviewRow
|
||||
label="MCP servers"
|
||||
value={mcpServers.length ? mcpServers.map((s) => s.name).join(", ") : "None"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
ghost
|
||||
disabled={stepIndex === 0}
|
||||
onClick={() => setStep(STEPS[Math.max(0, stepIndex - 1)].id)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{step === "review" ? (
|
||||
<Button onClick={handleCreate} disabled={creating || !nameValid}>
|
||||
{creating ? "Creating…" : "Create profile"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canAdvance}
|
||||
onClick={() => setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Toast toast={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="flex-1 break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
Check,
|
||||
@@ -246,6 +247,7 @@ export default function ProfilesPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Locale strings with English fallbacks. The enriched keys are optional in
|
||||
// the i18n type so untranslated locales don't break the build — they render
|
||||
@@ -722,21 +724,31 @@ export default function ProfilesPage() {
|
||||
: base;
|
||||
})();
|
||||
|
||||
// Put "Create" button in page header
|
||||
// Put "Build" (full builder) + "Create" (quick modal) buttons in header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => navigate("/profiles/new")}
|
||||
>
|
||||
Build
|
||||
</Button>
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
}, [setEnd, t.common.create, loading, navigate]);
|
||||
|
||||
const cloning = cloneAll || cloneFromDefault;
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
82
website/package-lock.json
generated
82
website/package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"@docusaurus/module-type-aliases": "3.9.2",
|
||||
"@docusaurus/tsconfig": "3.9.2",
|
||||
"@docusaurus/types": "3.9.2",
|
||||
"typescript": "~5.6.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0"
|
||||
@@ -7023,9 +7023,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -7036,7 +7036,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -7109,9 +7109,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -9834,14 +9834,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -9860,7 +9860,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -9907,9 +9907,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/range-parser": {
|
||||
@@ -14512,9 +14512,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15151,9 +15151,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -15222,9 +15222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -15241,7 +15241,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -16836,9 +16836,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -18098,9 +18098,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -18926,9 +18926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -18946,9 +18946,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.23.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.23.0.tgz",
|
||||
"integrity": "sha512-HVMxHKZKi+eL2mrUZDzDkKW3XvCjynhbtpSq20xQp4ePDFeSFuAfnvM0GIwZIv8fiKHjXFQ5WjxhCt15KRNj+g==",
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -19718,9 +19718,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint:diagrams": "ascii-guard lint --exclude-code-blocks docs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,7 +32,7 @@
|
||||
"@docusaurus/module-type-aliases": "3.9.2",
|
||||
"@docusaurus/tsconfig": "3.9.2",
|
||||
"@docusaurus/types": "3.9.2",
|
||||
"typescript": "~5.6.2"
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.5",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
},
|
||||
"exclude": [".docusaurus", "build"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user