feat: 灵犀 Studio Web UI 定制版
Build / build (push) Has been cancelled
NPM Lockfile Check / npm ci --ignore-scripts (push) Has been cancelled
Playwright / e2e (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yi
2026-06-05 11:29:11 +08:00
commit 7d10320a82
643 changed files with 164406 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
node_modules
dist
hermes_data
*.log
.DS_Store
+101
View File
@@ -0,0 +1,101 @@
name: Bug Report
description: File a bug report to help us improve
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: version
attributes:
label: Hermes Web UI Version
description: What version of Hermes Web UI are you using?
placeholder: e.g., v0.5.8
validations:
required: true
- type: input
id: hermes_version
attributes:
label: Hermes Agent Version
description: What version of Hermes Agent are you using?
placeholder: e.g., v0.12.0
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What you expected to happen
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened
placeholder: |
If applicable, add screenshots to help explain your problem
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Error Messages
description: Paste any relevant logs or error messages
render: shell
- type: dropdown
id: environment
attributes:
label: Environment
description: Where are you running Hermes Web UI?
options:
- Docker
- macOS
- Linux
- Windows
- WSL
multiple: true
validations:
required: true
- type: input
id: node_version
attributes:
label: Node Version
description: What version of Node.js are you using?
placeholder: e.g., v24.14.1
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context about the problem
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Documentation
url: https://github.com/EKKOLearnAI/hermes-web-ui#readme
about: Please check the documentation first
- name: GitHub Discussions
url: https://github.com/EKKOLearnAI/hermes-web-ui/discussions
about: Use GitHub Discussions for questions that don't fit as issues
@@ -0,0 +1,76 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a new feature! Please fill out the form below.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What problem does this feature solve? What pain point does it address?
placeholder: |
I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: What would you like to see implemented?
placeholder: |
I think adding X would be great because...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to you?
options:
- Critical - blocking my usage
- High - really need this
- Medium - nice to have
- Low - would be convenient
validations:
required: true
- type: textarea
id: use_cases
attributes:
label: Use Cases
description: Describe specific use cases where this feature would be helpful
placeholder: |
1. When I do X...
2. When I need to Y...
- type: checkboxes
id: contribution
attributes:
label: Willing to Contribute?
description: Would you be willing to help implement this feature?
options:
- label: Yes, I'd like to submit a PR
required: false
- label: Yes, but I need guidance
required: false
- label: No, I don't have time
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, mockups, or examples about the feature request
+22
View File
@@ -0,0 +1,22 @@
---
name: General Issue
about: Use this for issues that don't fit into bug reports or feature requests
title: '[Question]: '
labels: ['question']
assignees: ''
---
## Please describe your issue
<!-- Provide a clear description of what you'd like to ask or discuss -->
## Context
<!-- Add any other context or screenshots about the issue -->
## Environment (if applicable)
- Hermes Web UI Version:
- Hermes Agent Version:
- Operating System:
- Node Version:
+46
View File
@@ -0,0 +1,46 @@
name: Build
on:
push:
branches:
- main
pull_request:
branches:
- main
- base
permissions:
contents: read
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: |
npm ci --ignore-scripts
npm rebuild node-pty
- name: Check repository harness
run: npm run harness:check
- name: Test with coverage
run: npm run test:coverage
- name: Build
run: npm run build
+213
View File
@@ -0,0 +1,213 @@
name: Manual Desktop Build
on:
workflow_dispatch:
inputs:
target_os:
description: "Desktop target OS"
required: true
type: choice
default: win32
options:
- win32
- darwin
- linux
target_arch:
description: "Desktop target architecture"
required: true
type: choice
default: x64
options:
- x64
- arm64
release_tag:
description: "Optional release tag to attach artifacts to"
required: false
type: string
runtime_release_tag:
description: "Optional runtime release tag embedded into the desktop app"
required: false
type: string
permissions:
contents: write
concurrency:
group: desktop-manual-${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}-${{ github.ref }}
cancel-in-progress: false
jobs:
validate:
runs-on: ubuntu-latest
outputs:
label: ${{ steps.target.outputs.label }}
runner: ${{ steps.target.outputs.runner }}
target_os: ${{ steps.target.outputs.target_os }}
target_arch: ${{ steps.target.outputs.target_arch }}
electron_target: ${{ steps.target.outputs.electron_target }}
artifact_name: ${{ steps.target.outputs.artifact_name }}
artifact_files: ${{ steps.target.outputs.artifact_files }}
steps:
- name: Select requested target
id: target
shell: bash
run: |
write_common_outputs() {
{
echo "label=$1"
echo "runner=$2"
echo "target_os=${{ github.event.inputs.target_os }}"
echo "target_arch=${{ github.event.inputs.target_arch }}"
echo "electron_target=$3"
echo "artifact_name=$4"
echo "artifact_files<<EOF"
shift 4
printf '%s\n' "$@"
echo "EOF"
} >> "$GITHUB_OUTPUT"
}
case "${{ github.event.inputs.target_os }}-${{ github.event.inputs.target_arch }}" in
win32-x64)
write_common_outputs "Windows x64" "windows-latest" "--win nsis --x64" "desktop-win32-x64" \
"packages/desktop/release/*.exe" \
"packages/desktop/release/*.exe.blockmap" \
"packages/desktop/release/latest*.yml"
;;
darwin-arm64)
write_common_outputs "macOS arm64" "macos-14" "--mac dmg zip --arm64" "desktop-darwin-arm64" \
"packages/desktop/release/*.dmg" \
"packages/desktop/release/*.dmg.blockmap" \
"packages/desktop/release/*.zip" \
"packages/desktop/release/*.zip.blockmap" \
"packages/desktop/release/latest*.yml"
;;
darwin-x64)
write_common_outputs "macOS x64" "macos-15-intel" "--mac dmg zip --x64" "desktop-darwin-x64" \
"packages/desktop/release/*.dmg" \
"packages/desktop/release/*.dmg.blockmap" \
"packages/desktop/release/*.zip" \
"packages/desktop/release/*.zip.blockmap" \
"packages/desktop/release/latest*.yml"
;;
linux-x64)
write_common_outputs "Linux x64" "ubuntu-22.04" "--linux AppImage deb --x64" "desktop-linux-x64" \
"packages/desktop/release/*.AppImage" \
"packages/desktop/release/*.deb" \
"packages/desktop/release/latest*.yml"
;;
linux-arm64)
write_common_outputs "Linux arm64" "ubuntu-22.04-arm" "--linux AppImage --arm64" "desktop-linux-arm64" \
"packages/desktop/release/*.AppImage" \
"packages/desktop/release/latest*.yml"
;;
*)
echo "Unsupported desktop target: ${{ github.event.inputs.target_os }} ${{ github.event.inputs.target_arch }}" >&2
exit 1
;;
esac
desktop:
name: Desktop (${{ needs.validate.outputs.label }})
needs: validate
runs-on: ${{ needs.validate.outputs.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: |
package-lock.json
packages/desktop/package-lock.json
- name: Install web UI dependencies
run: |
npm ci --ignore-scripts
npm rebuild node-pty
- name: Build web UI
run: npm run build
- name: Keep production web UI dependencies only
run: npm prune --omit=dev --no-audit --no-fund
- name: Install desktop dependencies
run: npm ci --prefix packages/desktop --no-audit --no-fund
- name: Write runtime release metadata
shell: bash
env:
RUNTIME_RELEASE_TAG: ${{ github.event.inputs.runtime_release_tag }}
run: npm --prefix packages/desktop run write:runtime-release
- name: Configure macOS signing
if: needs.validate.outputs.target_os == 'darwin'
shell: bash
env:
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
MAC_APPLE_ID: ${{ secrets.APPLE_ID }}
MAC_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
MAC_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
write_env() {
local name="$1"
local value="$2"
if [ -n "$value" ]; then
{
echo "$name<<EOF"
echo "$value"
echo "EOF"
} >> "$GITHUB_ENV"
fi
}
if [ -z "${MAC_CSC_LINK:-}" ]; then
echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV"
echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV"
echo "No macOS signing certificate configured; building unsigned and skipping notarization."
exit 0
fi
write_env "CSC_LINK" "$MAC_CSC_LINK"
write_env "CSC_KEY_PASSWORD" "$MAC_CSC_KEY_PASSWORD"
if [ -n "${MAC_APPLE_ID:-}" ] && [ -n "${MAC_APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${MAC_APPLE_TEAM_ID:-}" ]; then
write_env "APPLE_ID" "$MAC_APPLE_ID"
write_env "APPLE_APP_SPECIFIC_PASSWORD" "$MAC_APPLE_APP_SPECIFIC_PASSWORD"
write_env "APPLE_TEAM_ID" "$MAC_APPLE_TEAM_ID"
echo "macOS signing and notarization are configured."
else
echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV"
echo "macOS signing certificate configured; Apple notarization credentials incomplete, skipping notarization."
fi
- name: Build desktop artifact
shell: bash
run: |
if [ "${{ needs.validate.outputs.target_os }}" = "darwin" ]; then
ulimit -n 10240 || true
echo "File descriptor limit: $(ulimit -n)"
fi
npm --prefix packages/desktop run dist -- ${{ needs.validate.outputs.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ needs.validate.outputs.artifact_name }}
path: ${{ needs.validate.outputs.artifact_files }}
if-no-files-found: error
retention-days: 7
- name: Upload artifacts to release
if: github.event.inputs.release_tag != ''
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.release_tag }}
fail_on_unmatched_files: true
files: ${{ needs.validate.outputs.artifact_files }}
+206
View File
@@ -0,0 +1,206 @@
name: Publish Desktop Artifacts to Release
on:
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach artifacts to (e.g. v0.6.5)"
required: true
release:
types: [published]
permissions:
contents: write
concurrency:
group: desktop-release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
desktop:
name: Desktop (${{ matrix.label }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- label: macOS arm64
runner: macos-14
target_os: darwin
target_arch: arm64
electron_target: "--mac dmg zip --arm64"
artifact_files: |
packages/desktop/release/*.dmg
packages/desktop/release/*.dmg.blockmap
packages/desktop/release/*.zip
packages/desktop/release/*.zip.blockmap
- label: macOS x64
runner: macos-15-intel
target_os: darwin
target_arch: x64
electron_target: "--mac dmg zip --x64"
artifact_files: |
packages/desktop/release/*.dmg
packages/desktop/release/*.dmg.blockmap
packages/desktop/release/*.zip
packages/desktop/release/*.zip.blockmap
- label: Windows x64
runner: windows-latest
target_os: win32
target_arch: x64
electron_target: "--win nsis --x64"
artifact_files: |
packages/desktop/release/*.exe
packages/desktop/release/*.exe.blockmap
packages/desktop/release/latest*.yml
- label: Linux x64
runner: ubuntu-22.04
target_os: linux
target_arch: x64
electron_target: "--linux AppImage deb --x64"
artifact_files: |
packages/desktop/release/*.AppImage
packages/desktop/release/*.deb
packages/desktop/release/latest*.yml
- label: Linux arm64
runner: ubuntu-22.04-arm
target_os: linux
target_arch: arm64
electron_target: "--linux AppImage --arm64"
artifact_files: |
packages/desktop/release/*.AppImage
packages/desktop/release/latest*.yml
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: |
package-lock.json
packages/desktop/package-lock.json
- name: Install web UI dependencies
run: |
npm ci --ignore-scripts
npm rebuild node-pty
- name: Build web UI
run: npm run build
- name: Keep production web UI dependencies only
run: npm prune --omit=dev --no-audit --no-fund
- name: Install desktop dependencies
run: npm ci --prefix packages/desktop --no-audit --no-fund
- name: Write runtime release metadata
shell: bash
env:
HERMES_DESKTOP_RUNTIME_RELEASE_TAG: ${{ vars.HERMES_DESKTOP_RUNTIME_RELEASE_TAG }}
run: npm --prefix packages/desktop run write:runtime-release
- name: Configure macOS signing
if: matrix.target_os == 'darwin'
shell: bash
env:
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
MAC_APPLE_ID: ${{ secrets.APPLE_ID }}
MAC_APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
MAC_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
write_env() {
local name="$1"
local value="$2"
if [ -n "$value" ]; then
{
echo "$name<<EOF"
echo "$value"
echo "EOF"
} >> "$GITHUB_ENV"
fi
}
if [ -z "${MAC_CSC_LINK:-}" ]; then
echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV"
echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV"
echo "No macOS signing certificate configured; building unsigned and skipping notarization."
exit 0
fi
write_env "CSC_LINK" "$MAC_CSC_LINK"
write_env "CSC_KEY_PASSWORD" "$MAC_CSC_KEY_PASSWORD"
if [ -n "${MAC_APPLE_ID:-}" ] && [ -n "${MAC_APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${MAC_APPLE_TEAM_ID:-}" ]; then
write_env "APPLE_ID" "$MAC_APPLE_ID"
write_env "APPLE_APP_SPECIFIC_PASSWORD" "$MAC_APPLE_APP_SPECIFIC_PASSWORD"
write_env "APPLE_TEAM_ID" "$MAC_APPLE_TEAM_ID"
echo "macOS signing and notarization are configured."
else
echo "MAC_BUILD_EXTRA_ARGS=--config.mac.notarize=false" >> "$GITHUB_ENV"
echo "macOS signing certificate configured; Apple notarization credentials incomplete, skipping notarization."
fi
- name: Build desktop artifact
shell: bash
run: |
if [ "${{ matrix.target_os }}" = "darwin" ]; then
ulimit -n 10240 || true
echo "File descriptor limit: $(ulimit -n)"
fi
npm --prefix packages/desktop run dist -- ${{ matrix.electron_target }} ${MAC_BUILD_EXTRA_ARGS:-} --publish never
- name: Upload macOS update manifest artifact
if: matrix.target_os == 'darwin'
uses: actions/upload-artifact@v4
with:
name: latest-mac-${{ matrix.target_arch }}
path: packages/desktop/release/latest-mac.yml
if-no-files-found: error
retention-days: 1
- name: Upload artifacts to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
fail_on_unmatched_files: true
files: ${{ matrix.artifact_files }}
mac-update-manifest:
name: Merge macOS updater manifest
needs: desktop
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
- name: Download macOS update manifests
uses: actions/download-artifact@v4
with:
pattern: latest-mac-*
path: /tmp/hermes-mac-manifests
merge-multiple: false
- name: Merge macOS update manifests
shell: bash
run: |
node packages/desktop/scripts/merge-mac-latest-yml.mjs \
/tmp/hermes-mac-manifests/latest-mac-arm64/latest-mac.yml \
/tmp/hermes-mac-manifests/latest-mac-x64/latest-mac.yml \
> /tmp/latest-mac.yml
- name: Upload merged macOS updater manifest to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
fail_on_unmatched_files: true
files: /tmp/latest-mac.yml
+125
View File
@@ -0,0 +1,125 @@
name: Publish Desktop Runtime to Release
on:
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to attach runtime assets to"
required: true
release:
types: [published]
permissions:
contents: write
concurrency:
group: desktop-runtime-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
runtime:
name: Runtime (${{ matrix.label }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- label: macOS arm64
runner: macos-14
target_os: darwin
target_arch: arm64
- label: macOS x64
runner: macos-15-intel
target_os: darwin
target_arch: x64
- label: Windows x64
runner: windows-latest
target_os: win32
target_arch: x64
- label: Linux x64
runner: ubuntu-22.04
target_os: linux
target_arch: x64
- label: Linux arm64
runner: ubuntu-22.04-arm
target_os: linux
target_arch: arm64
skip_browser_runtime: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }}
- name: Resolve runtime asset names
id: names
shell: bash
env:
TARGET_OS: ${{ matrix.target_os }}
TARGET_ARCH: ${{ matrix.target_arch }}
run: |
echo "asset=$(node packages/desktop/scripts/runtime-asset-name.mjs)" >> "$GITHUB_OUTPUT"
echo "manifest=$(node packages/desktop/scripts/runtime-asset-name.mjs --manifest)" >> "$GITHUB_OUTPUT"
- name: Check existing release assets
id: check
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }}
ASSET: ${{ steps.names.outputs.asset }}
MANIFEST: ${{ steps.names.outputs.manifest }}
run: |
assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' || true)"
if printf '%s\n' "$assets" | grep -Fx "$ASSET" >/dev/null \
&& printf '%s\n' "$assets" | grep -Fx "$MANIFEST" >/dev/null; then
echo "missing=false" >> "$GITHUB_OUTPUT"
echo "Runtime asset already exists: $ASSET"
else
echo "missing=true" >> "$GITHUB_OUTPUT"
echo "Runtime asset missing: $ASSET or $MANIFEST"
fi
- name: Setup Node.js
if: steps.check.outputs.missing == 'true'
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: packages/desktop/package-lock.json
- name: Install uv
if: steps.check.outputs.missing == 'true'
uses: astral-sh/setup-uv@v3
- name: Install desktop dependencies
if: steps.check.outputs.missing == 'true'
run: npm ci --prefix packages/desktop --no-audit --no-fund
- name: Prepare runtime resources
if: steps.check.outputs.missing == 'true'
env:
TARGET_OS: ${{ matrix.target_os }}
TARGET_ARCH: ${{ matrix.target_arch }}
GH_TOKEN: ${{ github.token }}
HERMES_SKIP_BROWSER_RUNTIME: ${{ matrix.skip_browser_runtime || 'false' }}
run: npm --prefix packages/desktop run prepare:runtime
- name: Package runtime
if: steps.check.outputs.missing == 'true'
env:
TARGET_OS: ${{ matrix.target_os }}
TARGET_ARCH: ${{ matrix.target_arch }}
run: npm --prefix packages/desktop run package:runtime
- name: Upload runtime assets to release
if: steps.check.outputs.missing == 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }}
fail_on_unmatched_files: true
files: |
packages/desktop/release/runtime/${{ steps.names.outputs.asset }}
packages/desktop/release/runtime/${{ steps.names.outputs.asset }}.sha256
packages/desktop/release/runtime/${{ steps.names.outputs.manifest }}
+46
View File
@@ -0,0 +1,46 @@
name: Build and Push Docker Image to Docker Hub
on:
workflow_dispatch:
release:
types: [published]
permissions:
contents: read
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:latest
${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:${{ github.sha }}
${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:${{ github.event.release.tag_name || github.ref_name }}
+45
View File
@@ -0,0 +1,45 @@
name: NPM Lockfile Check
on:
push:
branches:
- main
paths:
- package.json
- package-lock.json
- .github/workflows/npm-lockfile-check.yml
pull_request:
branches:
- main
- base
paths:
- package.json
- package-lock.json
- .github/workflows/npm-lockfile-check.yml
permissions:
contents: read
concurrency:
group: npm-lockfile-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
check:
name: npm ci --ignore-scripts
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Verify package-lock.json is in sync
run: npm ci --ignore-scripts
+52
View File
@@ -0,0 +1,52 @@
name: Playwright
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
concurrency:
group: playwright-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: |
npm ci --ignore-scripts
npm rebuild node-pty
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npm run test:e2e
- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
retention-days: 7
+89
View File
@@ -0,0 +1,89 @@
name: Website
on:
pull_request:
branches:
- main
- base
paths:
- packages/website/**
- packages/client/src/styles/variables.scss
- package.json
- package-lock.json
- tsconfig.website.json
- vite.config.website.ts
- .github/workflows/website-deploy.yml
workflow_run:
workflows:
- Publish Desktop Artifacts to Release
types:
- completed
workflow_dispatch:
permissions:
contents: read
concurrency:
group: website-${{ github.event.pull_request.number || github.event.workflow_run.id || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build website
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
if: github.event_name != 'workflow_run'
uses: actions/checkout@v4
- name: Checkout desktop release ref
if: github.event_name == 'workflow_run'
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_branch || github.event.workflow_run.head_sha }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Type-check website
run: npx vue-tsc -p tsconfig.website.json --noEmit
- name: Build website
run: npm run build:website
- name: Prepare SSH
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
env:
WEBSITE_SSH_KEY: ${{ secrets.WEBSITE_SSH_KEY }}
WEBSITE_SSH_KNOWN_HOSTS: ${{ secrets.WEBSITE_SSH_KNOWN_HOSTS }}
run: |
test -n "$WEBSITE_SSH_KEY"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$WEBSITE_SSH_KEY" > ~/.ssh/website_deploy_key
chmod 600 ~/.ssh/website_deploy_key
if [ -n "$WEBSITE_SSH_KNOWN_HOSTS" ]; then
printf '%s\n' "$WEBSITE_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
fi
- name: Deploy website
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
env:
WEBSITE_SSH_USER: ${{ secrets.WEBSITE_SSH_USER }}
WEBSITE_SSH_PORT: ${{ secrets.WEBSITE_SSH_PORT }}
run: |
SSH_USER="${WEBSITE_SSH_USER:-root}"
SSH_PORT="${WEBSITE_SSH_PORT:-22}"
DEPLOY_DIR="/var/www/ekkolearnai.com/current"
SSH_CMD="ssh -i ~/.ssh/website_deploy_key -p ${SSH_PORT} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
$SSH_CMD "$SSH_USER@154.3.33.232" "mkdir -p '$DEPLOY_DIR' && find '$DEPLOY_DIR' -mindepth 1 -maxdepth 1 -exec rm -rf {} +"
tar -C dist/website -czf - . | $SSH_CMD "$SSH_USER@154.3.33.232" "tar -xzf - -C '$DEPLOY_DIR'"
+55
View File
@@ -0,0 +1,55 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
playwright-report
test-results
dist-ssr
coverage
__pycache__/
*.py[cod]
server/dist
packages/server/dist
*.local
ROADMAP.md
pnpm-lock.yaml
pnpm-workspace.yaml
# Server data
packages/server/data/
packages/server/node_modules/
.hermes-web-ui/
packages/desktop/dist/
packages/desktop/release/
packages/desktop/resources/python/
packages/desktop/node_modules/
# Hermes config files (should be in user data directory, not project root)
config.yaml
.env
hermes_data/
data/
hermes-dependencies.md
# Large local media (use ThinkingIndicator.vue instead)
packages/client/src/assets/*.gif
packages/client/src/assets/*.mp4
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.superpowers/
CLAUDE.md
# Client source map artifacts
packages/client/src/**/*.js
.hermes/
+83
View File
@@ -0,0 +1,83 @@
# 工作流执行状态
## 当前进度
- **当前步骤**:步骤 11(提交)
- **状态**:✅ 已完成
- **开始时间**2026-06-02 15:08:00
- **完成时间**2026-06-02 15:10:00
- **项目信息**
- 项目名称:灵犀 (Lingxi)
- 项目目录:C:\Users\L1822\xinmi\hermes-web-ui
- 端口范围:11000, 11001, 11002
- 状态文档:C:\Users\L1822\xinmi\hermes-web-ui\.work\工作流状态.md
## 步骤执行记录
### 步骤 0:克隆项目
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:31:00
- **说明**:项目已成功克隆到 C:\Users\L1822\xinmi\hermes-web-ui
### 步骤 1:项目完整度评估
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:34:00
- **说明**:项目类型识别为 Web Dashboard/AI UI,用户已选定名称:灵犀 (Lingxi)
### 步骤 2:项目整体分析
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:37:00
- **说明**:技术栈识别为 Vue 3 + Koa + SQLiteMonorepo 结构已确认。
### 步骤 3Docker配置生成
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:42:00
- **说明**:已生成 .env 配置文件,优化了 Docker 配置。
### 步骤 4:品牌与界面标准化定制
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:47:00
- **说明**:已完成品牌替换(灵犀)、新增导航与页脚资源链接。
### 步骤 5:核心应用页面建设
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:51:00
- **说明**:已创建并集成“关于灵犀”页面。
### 步骤 6:示例数据
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:54:00
- **说明**:程序内置初始化逻辑,默认提供 admin 账户。
### 步骤 7CORS配置检查
- **状态**:✅ 已完成
- **完成时间**2026-06-02 14:57:00
- **说明**:已确认后端 CORS 配置为 *,并在 .env 中增加相关变量。
### 步骤 8:端口检查与分配
- **状态**:✅ 已完成
- **完成时间**2026-06-02 15:01:00
- **说明**:已分配 11000 后的连续可用端口,并更新 .env。
### 步骤 9:构建与启动
- **状态**:✅ 已完成
- **完成时间**2026-06-02 15:06:00
- **说明**:项目构建成功,并在 11000 端口成功启动。
### 步骤 10:健康验证
- **状态**:✅ 已完成
- **完成时间**2026-06-02 15:07:00
- **说明**:健康检查通过,品牌验证通过。
### 步骤 11:提交
- **状态**:✅ 已完成
- **完成时间**2026-06-02 15:10:00
- **说明**:所有变更已提交至仓库。
## 错误记录
## 备注
+5
View File
@@ -0,0 +1,5 @@
# 错误记录
## 错误列表
(暂无错误)
+52
View File
@@ -0,0 +1,52 @@
# Agent Map
This file is a short map for coding agents. Keep detailed guidance in `docs/`
and keep this file small enough to fit into every task context.
## First Reads
- `DEVELOPMENT.md` - project commands, coding rules, test rules, and PR shape.
- `ARCHITECTURE.md` - package boundaries, data ownership, and runtime flow.
- `docs/harness/README.md` - how this repository is prepared for agent work.
- `docs/harness/validation.md` - which checks to run for each change type.
- `docs/harness/worktree-runbook.md` - isolated local dev and test setup.
- `docs/harness/pr-review.md` - self-review checklist before pushing.
## Common Commands
```bash
npm ci --ignore-scripts
npm run harness:check
npm run test
npm run test:e2e
npm run build
```
Use the smallest relevant check while iterating. Before a broad PR, run
`npm run harness:check`, `npm run test:coverage`, `npm run test:e2e`, and
`npm run build`.
## Code Ownership Map
- `packages/client/src` - Vue 3 client, stores, routes, i18n, API helpers.
- `packages/server/src` - Koa API, Socket.IO, persistence, Hermes integration.
- `packages/desktop` - Electron wrapper, bundled Python/Hermes runtime, release artifacts.
- `tests/client`, `tests/server`, `tests/shared` - Vitest coverage.
- `tests/e2e` - Playwright browser coverage with mocked backend services.
- `.github/workflows` - CI, release, Docker, and desktop packaging automation.
## Hard Rules
- Keep routes thin: put request handling in controllers and reusable behavior in services.
- Keep Web UI state under `HERMES_WEB_UI_HOME` or `HERMES_WEBUI_STATE_DIR`.
- Keep Hermes Agent state separate from Web UI state.
- Register local API routes before proxy catch-all routes.
- Use structured APIs and argument arrays instead of shell string construction.
- Add user-facing strings to every locale file.
- Do not mix unrelated refactors into a bug fix.
## When The Agent Gets Stuck
Improve the harness instead of repeating the same prompt. Add missing docs,
tests, logs, scripts, or CI checks so the next agent can see and verify the
constraint directly.
+91
View File
@@ -0,0 +1,91 @@
# Architecture
Hermes Web UI is a TypeScript monorepo that ships a browser dashboard, a Koa
backend, and an Electron desktop distribution around Hermes Agent.
## Package Boundaries
| Area | Path | Responsibility |
| --- | --- | --- |
| Client | `packages/client/src` | Vue UI, routing, Pinia stores, API wrappers, i18n, browser-visible state. |
| Server | `packages/server/src` | HTTP API, auth, Socket.IO, SQLite stores, file access, Hermes runtime integration. |
| Desktop | `packages/desktop` | Electron shell, local Web UI server bootstrap, updater, bundled Python/Hermes runtime. |
| Tests | `tests` | Vitest unit/integration tests and Playwright browser tests. |
| CI | `.github/workflows` | Build, e2e, lockfile, Docker, and desktop release automation. |
## Request Flow
1. The browser loads the Vite-built client from the Koa server.
2. Client modules call API helpers from `packages/client/src/api`.
3. Server routes in `packages/server/src/routes` wire HTTP paths to controllers.
4. Controllers validate request concerns and delegate reusable behavior to services.
5. Services own side effects: files, SQLite, Hermes profiles, subprocesses, bridges, and credentials.
6. Long-running chat and group-chat flows use Socket.IO namespaces managed by server services.
Keep each layer narrow. Routes should not grow business logic, and client code
should not duplicate server persistence rules.
## State And Data Ownership
- Web UI state defaults to `~/.hermes-web-ui` through `config.appHome`.
- `HERMES_WEB_UI_HOME` and `HERMES_WEBUI_STATE_DIR` override Web UI state location.
- Hermes Agent state lives under Hermes profile directories and must stay distinct from Web UI state.
- Uploads default to `config.uploadDir`, which is derived from the Web UI home unless `UPLOAD_DIR` is set.
- Runtime data directories must also live under the Web UI home, not beside built `dist` assets.
- Profile-scoped Hermes data should use existing profile helpers instead of manually joining paths.
## Server Structure
- `routes/` registers HTTP and WebSocket entry points.
- `controllers/` handles request-level behavior.
- `services/` owns reusable IO, domain behavior, external process calls, and integration logic.
- `db/` owns SQLite schemas and stores.
- `middleware/` owns request middleware such as user auth.
- `shared/` contains cross-server constants and helpers.
Architecture rules:
- Register local API routes before proxy catch-all routes.
- Keep auth behavior centralized in `packages/server/src/services/auth.ts`.
- Prefer `execFile` or `spawn` with argument arrays over shell command strings.
- Use structured file and YAML/JSON parsers when editing structured data.
## Client Structure
- `views/` contains route-level screens.
- `components/` contains reusable UI.
- `stores/` contains Pinia state.
- `api/` contains HTTP clients and should use `packages/client/src/api/client.ts`.
- `i18n/` contains locale messages for user-facing strings.
- `styles/` contains global styling and theme primitives.
Frontend rules:
- Use Vue 3 Composition API with `<script setup lang="ts">`.
- Use existing Naive UI patterns before adding new UI conventions.
- Add visible text to all locale files.
- Keep component styles scoped unless the style is intentionally global.
## Desktop Release Flow
Desktop packaging is intentionally split:
- Pull requests run the web UI build and tests in `.github/workflows/build.yml`.
- Published releases and manual dispatches run desktop artifact packaging in `.github/workflows/desktop-release.yml`
and `.github/workflows/desktop-manual-build.yml`.
- Each release matrix target uploads only the artifact globs for its own platform.
Do not make a Windows job require macOS `.dmg` files or a Linux job require
Windows installers. Keep `fail_on_unmatched_files: true` where platform-specific
artifact lists make the expectation explicit.
## Validation Surface
The minimum mechanical harness is:
- `npm run harness:check` for repository docs, workflow, and package-script invariants.
- `npm run test` or focused Vitest tests for local logic.
- `npm run test:e2e` for browser-visible routing/auth/chat regressions.
- `npm run build` for type checking and production bundles.
See `docs/harness/validation.md` for change-specific commands.
+104
View File
@@ -0,0 +1,104 @@
# Development Guidelines
This document defines project-level development rules for Hermes Web UI. It is tool-agnostic and applies to all contributors and coding agents.
## Commands
```bash
npm install
npm run dev
npm run test
npm run test:coverage
npm run test:e2e
npm run build
```
- `npm run dev` starts the Vite client and Koa server together.
- `npm run test` runs Vitest unit tests.
- `npm run test:coverage` is what the Build workflow runs before `npm run build`.
- `npm run test:e2e` runs Playwright browser tests against a mocked BFF API.
- `npm run build` type-checks and builds both client and server.
## Architecture
- Frontend code lives under `packages/client/src`.
- Server code lives under `packages/server/src`.
- Hermes-specific client code stays under `hermes` namespaces: API modules, views, stores, and components.
- Server routes should stay thin. Put request handling in controllers and reusable behavior in services.
- The chat runtime is Socket.IO based and lives under `packages/server/src/services/hermes/run-chat`.
- Web UI state lives under `HERMES_WEB_UI_HOME` or `HERMES_WEBUI_STATE_DIR`, defaulting to `~/.hermes-web-ui`.
## Coding Rules
- Prefer existing local patterns over new abstractions.
- Keep changes scoped to the requested behavior.
- Do not mix unrelated refactors into feature or bugfix commits.
- Do not reintroduce deprecated compatibility switches without a current caller.
- Use structured APIs and parsers for structured data instead of ad hoc string edits when possible.
- Add comments only where they explain non-obvious behavior or constraints.
## Frontend Rules
- Use Vue 3 Composition API with `<script setup lang="ts">`.
- Use Pinia setup stores.
- Use the shared API request helper in `packages/client/src/api/client.ts`.
- Add user-facing strings to all locale files.
- Keep component styles scoped with SCSS unless the style is intentionally global.
- Match existing Naive UI patterns and avoid adding a new UI library.
## Server Rules
- Register local API routes before proxy catch-all routes.
- Keep auth behavior centralized in `packages/server/src/services/auth.ts`.
- Use `config.appHome` for Web UI state paths.
- Keep Hermes home paths separate from Web UI home paths.
- Use `getActiveProfileDir()` or related profile helpers for Hermes profile files.
- Avoid shell string construction for CLI calls; prefer `execFile`/`spawn` with argument arrays.
## Testing Rules
- Add focused Vitest coverage for server and store logic changes.
- Add Playwright coverage for browser-visible flows and routing/auth regressions.
- For frontend browser tests, prefer API/socket mocks over real external services.
- Before opening a PR, run the smallest relevant tests plus `npm run build`.
- For broad changes, run:
```bash
npm run test:coverage
npm run test:e2e
npm run build
```
## Commit And PR Rules
- Branch from `main` for new work.
- Use short, descriptive branch names such as `codex/fix-login-token` or `feat/group-chat-copy`.
- Commit only files that belong to the change.
- Use concise commit messages that describe the change, for example `fix login token storage` or `add group chat clone naming`.
- Keep commits focused. Do not bundle unrelated cleanup with feature work unless the cleanup is required.
- Push the branch and open a PR against `main` unless the issue explicitly targets another base.
- Prefer draft PRs while validation is still running or when the change needs review before merge.
- Mark a PR ready only after the relevant tests and build pass.
- Keep PR titles concrete and scoped, for example `[codex] make Web UI state directory configurable`.
- Link issues in the PR body with `Closes #123`, `Fixes #123`, or `Refs #123` as appropriate.
- PR descriptions should include:
- what changed
- why it changed
- user or developer impact
- validation commands run
- known limitations or follow-up work, if any
- Do not overwrite or revert unrelated user changes.
Use this PR body shape by default:
```md
## Summary
- ...
- ...
Closes #123
## Validation
- `npm run test:coverage`
- `npm run build`
```
+50
View File
@@ -0,0 +1,50 @@
ARG BASE_IMAGE=nousresearch/hermes-agent:latest
FROM ${BASE_IMAGE}
ARG NODE_VERSION=24.15.0
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
RUN ARCH=$(dpkg --print-architecture) \
&& if [ "$ARCH" = "amd64" ]; then NODE_ARCH="x64"; else NODE_ARCH="$ARCH"; fi \
&& echo "Downloading Node.js v${NODE_VERSION} for ${NODE_ARCH}" \
&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" \
-o /tmp/node.tar.gz \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/bin/node /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
&& tar -xzf /tmp/node.tar.gz -C /usr/local --strip-components=1 \
&& rm -f /tmp/node.tar.gz \
&& node --version \
&& npm --version
WORKDIR /app
COPY package*.json ./
# Increase Node.js memory limit to prevent OOM during build
ENV NODE_OPTIONS=--max-old-space-size=4096
# Use npmmirror to avoid network issues
RUN npm config set registry https://registry.npmmirror.com
RUN npm ci --ignore-scripts && npm rebuild node-pty
COPY . .
RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production
ENV HOME=/home/agent
ENV HERMES_HOME=/home/agent/.hermes
ENV HERMES_WEB_UI_MANAGED_GATEWAY=1
ENV PATH=/opt/hermes/.venv/bin:$PATH
EXPOSE 6060
# 强制覆盖基础镜像的默认启动脚本,让镜像本身具备独立运行的能力
ENTRYPOINT ["node", "dist/server/index.js"]
CMD []
+48
View File
@@ -0,0 +1,48 @@
Business Source License 1.1
Parameters
Licensor: EKKOLearnAI
Licensed Work: Hermes Web UI
Additional Use Grant: You may use the Licensed Work for non-commercial purposes,
including personal use, education, and research.
Commercial use (including but not limited to selling,
licensing, SaaS hosting, or embedding in a commercial
product) requires a separate commercial license from the
Licensor.
Change Date: 2029-05-10
Change License: Apache License 2.0
Notice
The Licensed Work is provided under the Business Source License 1.1 (BSL 1.1).
You may not use the Licensed Work except in compliance with the License.
You may obtain a copy of the License at
https://mariadb.com/bsl11/
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
For the purposes of this License:
- "Licensed Work" refers to all source code, documentation, and associated
files in this repository.
- "Non-commercial use" means use that is not primarily intended for or
directed toward commercial advantage or monetary compensation.
- The Licensor (EKKOLearnAI) and its authorized representatives are not
subject to the Additional Use Grant restrictions and may use the Licensed
Work for any purpose, including commercial purposes, without limitation.
On the Change Date (2029-05-10), this License will automatically convert to
the Apache License 2.0, at which point all users may use the Licensed Work
under the terms of the Apache License 2.0.
+244
View File
@@ -0,0 +1,244 @@
# 灵犀 Studio
> 面向多模型 AI Agent 的一体化 Web 管理控制台。在一个界面中完成对话、渠道配置、用量分析、任务调度与系统运维。
---
## 产品简介
灵犀 Studio 是私有化部署的 AI 工作台,支持:
- 多会话实时对话与流式输出
- 多平台渠道统一接入与配置
- 按 Profile 隔离的配置、模型、文件与权限
- 用量统计、定时任务、技能与日志管理
- Web 终端与群聊协作
默认访问地址:
| 场景 | 地址 |
|------|------|
| 本地开发 | http://localhost:8649 |
| Docker 部署 | http://localhost:11000(可在 `.env` 中修改) |
默认登录:`admin` / `123456`(首次登录后请尽快修改)
---
## 核心功能
### AI 对话
- Socket.IO 实时流式聊天
- 多会话创建、重命名、删除与切换
- Markdown 渲染、代码高亮、工具调用详情
- 按 Profile 隔离的文件上传与下载
- `Ctrl+K` 搜索本地会话
- 多模型选择与 Token 用量展示
### 平台渠道
支持 Telegram、Discord、Slack、WhatsApp、Matrix、飞书、微信、企业微信等平台的统一配置,凭证与渠道行为分别写入环境变量与配置文件。
### 运维与管理
- **用量分析**:Token 统计、费用估算、模型分布与趋势图
- **定时任务**:Cron 表达式管理,支持立即触发
- **模型管理**Provider 自动发现、OAuth 登录、默认模型切换
- **多 Profile**:创建、克隆、导入导出,按账号授权访问
- **文件浏览**:支持 local / Docker / SSH / Singularity 后端
- **群聊**:多 Agent 房间、@提及路由、消息持久化
- **技能与记忆**:浏览已安装技能,管理用户笔记与档案
- **日志**:按级别、文件与关键词过滤查看
- **Web 终端**:基于 xterm 的多会话终端
### 认证与账户
- Token 认证与用户名密码登录
- 超级管理员可管理用户与 Profile 绑定
- 普通管理员仅可管理自己的账户信息
维护命令:
```bash
# 清除登录 IP 锁定
hermes-web-ui clear-login-locks
# 清除锁定并重启服务
hermes-web-ui clear-login-locks --restart
# 重置默认管理员账户为 admin / 123456
hermes-web-ui reset-default-login
```
---
## 快速开始
### Docker 部署(推荐)
项目根目录已提供 `docker-compose.yml``.env` 配置:
```bash
# 构建并启动
docker compose up -d --build
# 查看日志
docker compose logs -f hermes-webui
```
常用配置(`.env`):
| 变量 | 说明 |
|------|------|
| `PORT` | Web UI 端口,默认 `11000` |
| `HERMES_DATA_DIR` | 数据目录,默认 `./data` |
| `WEBUI_IMAGE` | 镜像名称,默认 `lingxi-web-ui:latest` |
数据持久化:
- Agent 运行时数据:`./data`
- Web UI 状态与认证:`./data/hermes-web-ui/`
- 认证 Token`./data/hermes-web-ui/.token`
更详细的 Docker 说明见 [docs/docker.md](./docs/docker.md)。
### 本地开发
**前置要求:** Node.js ≥ 23
```bash
npm ci --ignore-scripts
npm rebuild node-pty
npm run dev
```
| 服务 | 地址 |
|------|------|
| 前端(Vite 热更新) | http://localhost:8649 |
| BFF 后端 | http://localhost:8647 |
连接 Docker 中间件、共用 `./data` 目录时,可设置:
```powershell
$env:HERMES_HOME = (Resolve-Path ".\data").Path
$env:HERMES_WEB_UI_HOME = (Resolve-Path ".\data\hermes-web-ui").Path
$env:HERMES_AGENT_ROOT = "<本机 hermes-agent 路径>"
npm run dev
```
构建生产包:
```bash
npm run build
```
---
## 环境变量
常用进程级配置:
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | `8648` | Web UI 监听端口 |
| `BIND_HOST` | `0.0.0.0` | 绑定地址 |
| `HERMES_WEB_UI_HOME` | `~/.hermes-web-ui` | Web UI 数据目录 |
| `HERMES_HOME` | 平台默认 | Agent 运行时数据目录 |
| `HERMES_DATA_DIR` | `./hermes_data` | Docker 挂载的数据根目录 |
| `AUTH_TOKEN` | 自动生成 | 显式指定认证 Token |
| `CORS_ORIGINS` | `*` | 跨域配置 |
| `LOG_LEVEL` | `info` | 服务日志级别 |
| `HERMES_WEB_UI_BACKEND_PORT` | `8648` | 开发模式后端端口 |
| `HERMES_WEB_UI_FRONTEND_PORT` | `8649` | 开发模式前端端口 |
完整变量列表与 Bridge / Gateway 相关配置,请参阅项目内环境变量注释与 [DEVELOPMENT.md](./DEVELOPMENT.md)。
---
## 系统架构
```
浏览器
BFF 服务(Koa + Socket.IO
Agent Bridge → Agent 运行时
CLI / Profile 配置 / 渠道凭证
```
- **前端**:Vue 3 单页应用,按 `hermes/` 命名空间组织 Agent 相关模块
- **后端**:Koa BFF,负责聊天流、会话 CRUD、文件服务、配置代理与 WebSocket 终端
- **数据**Web UI 使用本地 SQLiteAgent 状态与 Profile 配置独立存储
---
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端 | Vue 3、TypeScript、Vite、Naive UI、Pinia、Vue Router、vue-i18n |
| 后端 | Koa 2、Socket.IO、node-pty |
| 存储 | SQLite |
| 运行时 | Node.js ≥ 23 |
---
## 相关资源
- 源码库:[xinmi.cloud](https://xinmi.cloud/)
## 截图
![image-20260605110600556](images/image-20260605110600556.png)
![image-20260605111350089](images/image-20260605111350089.png)
![image-20260605112225956](images/image-20260605112225956.png)
![image-20260605112232538](images/image-20260605112232538.png)
![image-20260605112249301](images/image-20260605112249301.png)
![image-20260605112258549](images/image-20260605112258549.png)
![image-20260605112345806](images/image-20260605112345806.png)
![image-20260605112357126](images/image-20260605112357126.png)
![image-20260605112404714](images/image-20260605112404714.png)
![image-20260605112415322](images/image-20260605112415322.png)
![image-20260605112422748](images/image-20260605112422748.png)
![image-20260605112429991](images/image-20260605112429991.png)
![image-20260605112440115](images/image-20260605112440115.png)
![image-20260605112449337](images/image-20260605112449337.png)
![image-20260605112457985](images/image-20260605112457985.png)
![image-20260605112506765](images/image-20260605112506765.png)
![image-20260605112514779](images/image-20260605112514779.png)
![image-20260605112523181](images/image-20260605112523181.png)
![image-20260605112532959](images/image-20260605112532959.png)
![image-20260605112540539](images/image-20260605112540539.png)
![image-20260605112546802](images/image-20260605112546802.png)
![image-20260605112557080](images/image-20260605112557080.png)
---
## 许可证
本项目采用 [BSL-1.1](./LICENSE) 许可证。
+755
View File
@@ -0,0 +1,755 @@
#!/usr/bin/env node
import { spawn, execSync, execFileSync } from 'child_process'
import { resolve, dirname, join, delimiter } from 'path'
import { fileURLToPath } from 'url'
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, openSync, chmodSync, statSync, existsSync, realpathSync } from 'fs'
import { randomBytes, scryptSync } from 'crypto'
import { homedir } from 'os'
const __dirname = dirname(fileURLToPath(import.meta.url))
const __filename = fileURLToPath(import.meta.url)
const serverEntry = resolve(__dirname, '..', 'dist', 'server', 'index.js')
const pkgDir = resolve(__dirname, '..')
const pkg = JSON.parse(readFileSync(resolve(pkgDir, 'package.json'), 'utf-8'))
const VERSION = pkg.version
const WEB_UI_HOME = process.env.HERMES_WEB_UI_HOME?.trim()
? resolve(process.env.HERMES_WEB_UI_HOME.trim())
: resolve(homedir(), '.hermes-web-ui')
const PID_DIR = WEB_UI_HOME
const PID_FILE = join(PID_DIR, 'server.pid')
const LOG_FILE = join(PID_DIR, 'server.log')
const TOKEN_FILE = join(PID_DIR, '.token')
const LOGIN_LOCK_FILE = join(WEB_UI_HOME, '.login-lock.json')
const WEB_UI_DB_FILE = join(WEB_UI_HOME, 'hermes-web-ui.db')
const DEFAULT_PORT = 8648
const PREVIEW_BACKEND_PORT = 8650
const PREVIEW_FRONTEND_PORT = 8651
const PREVIEW_AGENT_BRIDGE_PORT = 18650
const DEFAULT_USERNAME = 'admin'
const DEFAULT_PASSWORD = '123456'
// ─── Auto-fix node-pty native module ──────────────────────────
function ensureNativeModules() {
const prebuildDir = join(pkgDir, 'node_modules', 'node-pty', 'prebuilds', `${process.platform}-${process.arch}`)
const helper = join(prebuildDir, 'spawn-helper')
try {
chmodSync(helper, 0o755)
} catch {}
}
function getToken() {
try {
return readFileSync(TOKEN_FILE, 'utf-8').trim()
} catch {
return null
}
}
function ensureToken() {
// If AUTH_TOKEN is set, let server handle it.
if (process.env.AUTH_TOKEN) return process.env.AUTH_TOKEN
let token = getToken()
if (!token) {
mkdirSync(dirname(TOKEN_FILE), { recursive: true })
token = randomBytes(32).toString('hex')
writeFileSync(TOKEN_FILE, token + '\n', { mode: 0o600 })
}
return token
}
function getNodeBinDir() {
return dirname(process.execPath)
}
function getNpmBin() {
return join(getNodeBinDir(), process.platform === 'win32' ? 'npm.cmd' : 'npm')
}
function getCurrentNodeEnv() {
return {
...process.env,
PATH: [getNodeBinDir(), process.env.PATH].filter(Boolean).join(delimiter),
npm_node_execpath: process.execPath,
}
}
function getGlobalPrefix() {
return execFileSync(getNpmBin(), ['prefix', '-g'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: getCurrentNodeEnv(),
}).trim()
}
function getGlobalCliBin() {
const prefix = getGlobalPrefix()
return process.platform === 'win32'
? join(prefix, 'hermes-web-ui.cmd')
: join(prefix, 'bin', 'hermes-web-ui')
}
function getWindowsShell() {
const systemRoot = process.env.SystemRoot || 'C:\\Windows'
const candidates = [
process.env.ComSpec,
join(systemRoot, 'System32', 'cmd.exe'),
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return 'cmd.exe'
}
function quoteForWindowsCommand(value) {
return `"${value.replace(/"/g, '""')}"`
}
function spawnCli(command, args, options) {
if (process.platform === 'win32') {
const lowerCommand = String(command).toLowerCase()
if (!lowerCommand.endsWith('.cmd') && !lowerCommand.endsWith('.bat')) {
return spawn(command, args, options)
}
const commandLine = `${quoteForWindowsCommand(command)} ${args.map(arg => String(arg)).join(' ')}`
return spawn(getWindowsShell(), ['/d', '/s', '/c', commandLine], options)
}
return spawn(command, args, options)
}
function getPortFromArgs() {
if (process.argv[3] && !isNaN(process.argv[3])) return parseInt(process.argv[3])
if (process.argv.includes('--port')) return parseInt(process.argv[process.argv.indexOf('--port') + 1])
return null
}
function getRunningPort() {
const pid = getPid()
if (!pid || !isRunning(pid)) return null
try {
if (process.platform === 'win32') {
const out = execSync(`netstat -aon -p tcp | findstr LISTENING | findstr " ${pid}$"`, { encoding: 'utf-8' }).trim()
const line = out.split('\n').find(Boolean)
const address = line?.trim().split(/\s+/)[1]
const port = address?.split(':').pop()
return port ? parseInt(port, 10) : null
}
const out = execSync(`lsof -Pan -p ${pid} -iTCP -sTCP:LISTEN`, { encoding: 'utf-8' }).trim()
const lines = out.split('\n').slice(1)
for (const line of lines) {
const match = line.match(/:(\d+)\s+\(LISTEN\)$/)
if (match) return parseInt(match[1], 10)
}
} catch {}
return null
}
function getUpdatePort() {
const argPort = getPortFromArgs()
if (argPort !== null) return argPort
const runningPort = getRunningPort()
if (runningPort !== null) return runningPort
if (process.env.PORT && !isNaN(process.env.PORT)) return parseInt(process.env.PORT)
return DEFAULT_PORT
}
function getPort() {
const argPort = getPortFromArgs()
return argPort ?? DEFAULT_PORT
}
function commandExists(command) {
try {
if (process.platform === 'win32') {
execFileSync('where', [command], { stdio: 'ignore', windowsHide: true })
} else {
execFileSync('sh', ['-c', `command -v "$1" >/dev/null 2>&1`, 'sh', command], { stdio: 'ignore' })
}
return true
} catch {
return false
}
}
function parseUnixNetstatListeningPids(out, port) {
const pids = []
for (const line of out.split(/\r?\n/)) {
const parts = line.trim().split(/\s+/)
if (parts.length < 6) continue
const proto = parts[0]?.toLowerCase()
if (!proto?.startsWith('tcp')) continue
const localAddress = parts[3]
const state = parts.find(part => part.toUpperCase() === 'LISTEN' || part.toUpperCase() === 'LISTENING')
if (!state || !localAddress?.endsWith(`:${port}`)) continue
const pidPart = parts.find(part => /^\d+\//.test(part))
const pid = pidPart ? parseInt(pidPart.split('/')[0], 10) : NaN
if (Number.isFinite(pid)) pids.push(pid)
}
return pids
}
function getListeningPids(port) {
if (!port || isNaN(port)) return []
const uniquePids = (pids) => [...new Set(pids.filter(pid => Number.isFinite(pid)))]
try {
if (process.platform === 'win32') {
const out = execSync('netstat -aon -p tcp', { encoding: 'utf-8' })
return uniquePids(out.split('\n')
.map(line => line.trim())
.filter(line => line.includes('LISTENING'))
.map(line => line.split(/\s+/))
.filter(parts => {
const address = parts[1] || ''
const listenPort = parseInt(address.split(':').pop(), 10)
return listenPort === port
})
.map(parts => parseInt(parts[parts.length - 1], 10)))
}
} catch {
return []
}
if (commandExists('ss')) {
try {
const out = execFileSync('ss', ['-ltnp', `sport = :${port}`], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
const pids = uniquePids(out.split(/\r?\n/)
.map(line => line.match(/pid=(\d+)/)?.[1])
.map(pid => parseInt(pid || '', 10)))
if (pids.length) return pids
} catch {}
}
if (commandExists('lsof')) {
try {
const out = execFileSync('lsof', [`-tiTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
const pids = uniquePids(out.split(/\r?\n/).map(pid => parseInt(pid, 10)))
if (pids.length) return pids
} catch {}
}
if (commandExists('netstat')) {
try {
const out = execFileSync('netstat', ['-anp', 'tcp'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] })
const pids = uniquePids(parseUnixNetstatListeningPids(out, port))
if (pids.length) return pids
} catch {}
}
return []
}
function killListeningPids(port, pids = getListeningPids(port)) {
if (pids.length === 0) return
console.log(` ⚠ Port ${port} is in use by PID(s): ${pids.join(' ')}, killing...`)
try {
if (process.platform === 'win32') {
execSync(`taskkill /F /PID ${pids.join(' /PID ')}`, { encoding: 'utf-8' })
} else {
execSync(`kill -9 ${pids.join(' ')}`, { encoding: 'utf-8' })
}
} catch {}
}
function stopPreviewRuntimeFromCli() {
const previewPorts = [
PREVIEW_BACKEND_PORT,
PREVIEW_FRONTEND_PORT,
...(process.platform === 'win32' ? [PREVIEW_AGENT_BRIDGE_PORT] : []),
]
const pids = [...new Set(previewPorts.flatMap(port => getListeningPids(port)))]
if (!pids.length) return 0
console.log(` ⏹ Stopping preview runtime (PID(s): ${pids.join(' ')})...`)
for (const pid of pids) {
try {
if (process.platform === 'win32') {
execFileSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true })
} else {
execSync(`kill -TERM -${pid}`, { stdio: 'ignore' })
}
} catch {
try {
if (process.platform === 'win32') {
execFileSync('taskkill.exe', ['/PID', String(pid), '/F'], { stdio: 'ignore', windowsHide: true })
} else {
execSync(`kill -9 ${pid}`, { stdio: 'ignore' })
}
} catch {}
}
}
return pids.length
}
function recoverPidFromPort() {
const port = getPortFromArgs() ?? DEFAULT_PORT
for (const pid of getListeningPids(port)) {
if (isRunning(pid)) {
mkdirSync(PID_DIR, { recursive: true })
writePid(pid)
return pid
}
}
return null
}
function readPidFile() {
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim())
return Number.isFinite(pid) ? pid : null
} catch {}
return null
}
function getPid() {
const pid = readPidFile()
if (pid) {
if (isRunning(pid)) return pid
removePid()
}
return recoverPidFromPort()
}
function isRunning(pid) {
try {
process.kill(pid, 0)
return true
} catch (err) {
return err?.code === 'EPERM'
}
}
function writePid(pid) {
writeFileSync(PID_FILE, String(pid))
}
function removePid() {
try { unlinkSync(PID_FILE) } catch {}
}
function startDaemon(port) {
const existing = getPid()
if (existing && isRunning(existing)) {
console.log(` ✗ hermes-web-ui is already running (PID: ${existing})`)
console.log(` Use "hermes-web-ui stop" to stop it first`)
process.exit(1)
}
removePid()
// Check if port is already in use
const occupied = getListeningPids(port)
if (occupied.length) {
killListeningPids(port, occupied)
// Brief wait for port to be released
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500)
}
mkdirSync(PID_DIR, { recursive: true })
ensureNativeModules()
const token = ensureToken()
// Rotate log if over 3MB — keep last 2000 lines
const MAX_LOG_SIZE = 3 * 1024 * 1024
const MAX_LOG_LINES = 2000
try {
const stat = statSync(LOG_FILE)
if (stat.size > MAX_LOG_SIZE) {
const content = readFileSync(LOG_FILE, 'utf-8')
const lines = content.split('\n')
const kept = lines.slice(-MAX_LOG_LINES)
writeFileSync(LOG_FILE, kept.join('\n'), 'utf-8')
console.log(` ↻ Log rotated (${(stat.size / 1024 / 1024).toFixed(1)}MB → ${kept.length} lines)`)
}
} catch { }
const logStream = openSync(LOG_FILE, 'a')
const windowsShell = process.platform === 'win32' ? getWindowsShell() : null
const serverEnv = { ...process.env, NODE_ENV: 'production', PORT: String(port), AUTH_TOKEN: token }
if (windowsShell) {
serverEnv.SHELL = serverEnv.SHELL?.trim() || windowsShell
serverEnv.ComSpec = serverEnv.ComSpec?.trim() || windowsShell
}
const child = spawn(process.execPath, [serverEntry], {
detached: true,
stdio: ['ignore', logStream, logStream],
env: serverEnv,
windowsHide: true,
})
child.on('error', (err) => {
console.error(` ✗ Failed to start: ${err.message}`)
removePid()
process.exit(1)
})
child.unref()
writePid(child.pid)
// Poll health endpoint until server is ready (setTimeout to avoid overlapping requests)
const healthUrl = `http://127.0.0.1:${port}/health`
const maxWait = 30000
const interval = 500
let waited = 0
console.log(` ⏳ Starting hermes-web-ui (PID: ${child.pid}, port: ${port})...`)
function poll() {
waited += interval
if (!isRunning(child.pid)) {
console.log(' ✗ Failed to start hermes-web-ui')
console.log(` Check log: ${LOG_FILE}`)
removePid()
process.exit(1)
return
}
fetch(healthUrl).then(res => {
if (res.ok) {
const url = `http://localhost:${port}`
console.log(` ✓ hermes-web-ui started`)
console.log(` ${url}`)
console.log(` Log: ${LOG_FILE}`)
const isWin = process.platform === 'win32'
const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`
try { execSync(cmd, { stdio: 'ignore' }) } catch {}
} else if (waited < maxWait) {
setTimeout(poll, interval)
} else {
console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`)
console.log(` Check log: ${LOG_FILE}`)
const url = `http://localhost:${port}`
console.log(` ${url}`)
}
}).catch(() => {
if (waited < maxWait) {
setTimeout(poll, interval)
} else {
console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`)
console.log(` Check log: ${LOG_FILE}`)
const url = `http://localhost:${port}`
console.log(` ${url}`)
}
})
}
setTimeout(poll, interval)
}
function stopDaemon() {
const stoppedPreviewPids = stopPreviewRuntimeFromCli()
const pidFromFile = readPidFile()
if (pidFromFile && !isRunning(pidFromFile)) {
removePid()
console.log(` ✓ hermes-web-ui was not running (cleaned stale PID: ${pidFromFile})`)
return
}
const pid = pidFromFile ?? recoverPidFromPort()
if (!pid) {
if (stoppedPreviewPids) {
console.log(` ✓ hermes-web-ui preview stopped`)
return
}
console.log(' ✗ hermes-web-ui is not running')
process.exit(1)
}
if (!isRunning(pid)) {
removePid()
console.log(` ✓ hermes-web-ui was not running (cleaned stale PID)`)
return
}
try {
try {
process.kill(pid, 'SIGTERM')
// Wait briefly for graceful shutdown
for (let i = 0; i < 10; i++) {
if (!isRunning(pid)) break
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500)
}
} catch {}
// Force kill if still alive
if (isRunning(pid)) {
try {
process.kill(pid, 'SIGKILL')
} catch (err) {
if (err?.code !== 'ESRCH') throw err
}
}
removePid()
console.log(` ✓ hermes-web-ui stopped (PID: ${pid})`)
} catch (err) {
console.log(` ✗ Failed to stop: ${err.message}`)
process.exit(1)
}
}
function showStatus() {
const pid = getPid()
if (pid && isRunning(pid)) {
console.log(` ✓ hermes-web-ui is running (PID: ${pid})`)
console.log(` PID file: ${PID_FILE}`)
} else {
if (pid) removePid()
console.log(' ✗ hermes-web-ui is not running')
}
}
function clearLoginLocks(options = {}) {
const { silent = false, checkRunning = true } = options
const serverRunning = checkRunning ? !!getPid() : false
let removed = false
try {
unlinkSync(LOGIN_LOCK_FILE)
removed = true
if (!silent) console.log(` ✓ Removed login lock file: ${LOGIN_LOCK_FILE}`)
} catch (err) {
if (err?.code === 'ENOENT') {
if (!silent) console.log(` ✓ No login lock file found: ${LOGIN_LOCK_FILE}`)
} else {
if (!silent) console.log(` ✗ Failed to remove login lock file: ${err.message}`)
throw err
}
}
if (!silent && serverRunning) {
console.log(' ⚠ hermes-web-ui is running; restart it to clear in-memory login locks.')
console.log(' Run: hermes-web-ui restart')
}
return { path: LOGIN_LOCK_FILE, removed, serverRunning }
}
function hashPassword(password) {
const salt = randomBytes(16).toString('hex')
const hash = scryptSync(password, salt, 64).toString('hex')
return `scrypt:${salt}:${hash}`
}
async function resetDefaultLogin(options = {}) {
const { silent = false } = options
mkdirSync(WEB_UI_HOME, { recursive: true })
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(WEB_UI_DB_FILE)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_login_at INTEGER
)
`)
const now = Date.now()
const passwordHash = hashPassword(DEFAULT_PASSWORD)
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(DEFAULT_USERNAME)
if (existing?.id) {
db.prepare(
`UPDATE users
SET password_hash = ?, role = 'super_admin', status = 'active', updated_at = ?
WHERE id = ?`
).run(passwordHash, now, existing.id)
if (!silent) {
console.log(` ✓ Reset default login: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}`)
console.log(` Database: ${WEB_UI_DB_FILE}`)
}
return { path: WEB_UI_DB_FILE, username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD, action: 'updated' }
}
db.prepare(
`INSERT INTO users (username, password_hash, role, status, created_at, updated_at)
VALUES (?, ?, 'super_admin', 'active', ?, ?)`
).run(DEFAULT_USERNAME, passwordHash, now, now)
if (!silent) {
console.log(` ✓ Created default login: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD}`)
console.log(` Database: ${WEB_UI_DB_FILE}`)
}
return { path: WEB_UI_DB_FILE, username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD, action: 'created' }
} finally {
db.close()
}
}
async function main() {
const command = process.argv[2] || 'start'
if (['-v', '--version', 'version'].includes(command)) {
console.log(`hermes-web-ui v${VERSION}`)
process.exit(0)
}
if (['-h', '--help', 'help'].includes(command)) {
console.log(`
hermes-web-ui v${VERSION}
Usage: hermes-web-ui <command> [options]
Commands:
start [port] Start the server (default port: ${DEFAULT_PORT})
stop Stop the server
restart [port] Restart the server
status Show server status
clear-login-locks Delete the login IP lock file
reset-default-login Create or reset the default login (${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD})
update Update to latest version and restart
upgrade Alias for update
version Show version number
Options:
-v, --version Show version number
-h, --help Show this help message
--port <port> Specify port (used with start/restart)
--restart Restart after clear-login-locks
`)
process.exit(0)
}
switch (command) {
case 'start':
startDaemon(getPort())
break
case 'stop':
stopDaemon()
break
case 'restart':
stopDaemon()
setTimeout(() => startDaemon(getPort()), 500)
break
case 'status':
showStatus()
break
case 'clear-login-locks': {
const restartAfterClear = process.argv.includes('--restart')
const result = clearLoginLocks()
if (restartAfterClear && result.serverRunning) {
const port = getRunningPort() ?? getPort()
stopDaemon()
setTimeout(() => startDaemon(port), 500)
}
break
}
case 'reset-default-login':
await resetDefaultLogin()
break
case 'update':
case 'upgrade':
doUpdate()
break
default:
ensureNativeModules()
const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT
const windowsShell = process.platform === 'win32' ? getWindowsShell() : null
const serverEnv = {
...process.env,
NODE_ENV: 'production',
PORT: String(port),
}
if (windowsShell) {
serverEnv.SHELL = serverEnv.SHELL?.trim() || windowsShell
serverEnv.ComSpec = serverEnv.ComSpec?.trim() || windowsShell
}
const child = spawn(process.execPath, [serverEntry], {
stdio: 'inherit',
env: serverEnv,
windowsHide: true,
})
child.on('exit', (code) => process.exit(code ?? 1))
process.on('SIGTERM', () => child.kill('SIGTERM'))
process.on('SIGINT', () => child.kill('SIGINT'))
}
}
function doUpdate() {
console.log(' ⬆ Updating hermes-web-ui...')
const npm = getNpmBin()
try {
console.log(' 🧹 Cleaning npm cache...')
execFileSync(npm, ['cache', 'clean', '--force'], {
stdio: 'inherit',
env: getCurrentNodeEnv(),
})
} catch (err) {
console.log(` ⚠ Failed to clean npm cache, continuing update: ${err?.message || err}`)
}
runUpdateInstall(npm)
}
function runUpdateInstall(npm) {
const child = spawnCli(npm, ['install', '-g', 'hermes-web-ui@latest'], {
stdio: 'inherit',
windowsHide: true,
env: getCurrentNodeEnv(),
})
child.on('error', (err) => {
console.log(` ✗ Update failed: ${err.message}`)
process.exit(1)
})
child.on('exit', (code) => {
if (code === 0) {
console.log(' ✓ Update complete, restarting...')
const cli = getGlobalCliBin()
if (!existsSync(cli)) {
console.log(` ✗ Updated CLI not found: ${cli}`)
process.exit(1)
}
const restart = spawnCli(cli, ['restart', '--port', String(getUpdatePort())], {
stdio: 'inherit',
windowsHide: true,
env: getCurrentNodeEnv(),
})
restart.on('error', (err) => {
console.log(` ✗ Restart failed: ${err.message}`)
process.exit(1)
})
restart.on('exit', (restartCode) => process.exit(restartCode ?? 1))
} else {
console.log(' ✗ Update failed')
process.exit(code ?? 1)
}
})
}
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === __filename) {
main().catch(err => {
console.error(`${err?.message || err}`)
process.exit(1)
})
}
export {
clearLoginLocks,
commandExists,
getListeningPids,
parseUnixNetstatListeningPids,
resetDefaultLogin,
stopDaemon,
}
+25
View File
@@ -0,0 +1,25 @@
services:
hermes-webui:
build:
context: .
dockerfile: Dockerfile
image: ${WEBUI_IMAGE:-hermes-web-ui-local:latest}
container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui}
ports:
- "${PORT:-6060}:${PORT:-6060}"
- "${PREVIEW_FRONTEND_PORT:-8651}:8651"
- "${XAI_OAUTH_PORT:-56121}:56121"
volumes:
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
- ${HERMES_DATA_DIR:-./hermes_data}/hermes-web-ui:/home/agent/.hermes-web-ui
environment:
- PORT=${PORT:-6060}
- HERMES_HOME=/home/agent/.hermes
- HERMES_BIN=/opt/hermes/.venv/bin/hermes
- HERMES_WEB_UI_MANAGED_GATEWAY=1
- HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST=0.0.0.0
- PATH=/opt/hermes/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- HERMES_ALLOW_ROOT_GATEWAY=1
restart: unless-stopped
stdin_open: true
tty: true
+472
View File
@@ -0,0 +1,472 @@
# CLI/Bridge Chat Sessions 实现文档
> 状态:本文档描述当前 `main` 中 Web UI 聊天会话的 API Server / Bridge(beta) 双路径实现。
## 概述
当前实现把原来的聊天通道统一到 Socket.IO namespace `/chat-run`。前端仍使用同一套 `ChatPanel + MessageList + ChatInput`,通过会话的 `source` 字段区分运行方式:
| source | 运行路径 | 说明 |
|--------|----------|------|
| `api_server` | Web UI Server → Hermes Gateway `/v1/responses` | 默认聊天路径 |
| `cli` | Web UI Server → Python agent bridge → `AIAgent` | Bridge(beta),在 Web UI 服务端子进程里直接运行 Hermes Agent |
Bridge 会话不是一个独立 UI 面板,而是普通会话的一种来源。用户通过“新建聊天”下拉菜单选择 `API``Bridge (beta)`
Bridge 模式支持:
- 流式文本输出
- reasoning/thinking 增量
- tool started/completed 事件
- 工具审批请求与响应
- abort 中断
- per-session 队列
- profile 隔离
- 从 DB resume 会话
- 与 API Server 路径共用上下文压缩逻辑
当前不再支持旧文档里的独立 `/cli-chat-run` namespace、`CliChatPanel.vue``cli-chat.ts` 和独立 `command` / `steer` socket 事件。CLI/Bridge 会话中的 slash command 现在通过统一 `/chat-run``run` payload 进入后端解析;当前支持 `/usage``/status``/abort``/queue``/clear``/title``/compress``/steer``/destroy`
---
## 整体架构
```text
ChatPanel.vue
├─ MessageList.vue
└─ ChatInput.vue
│ Socket.IO /chat-run
ChatRunSocket (Node.js)
├─ source=api_server → Hermes Gateway /v1/responses
└─ source=cli → AgentBridgeClient
│ TCP/Unix socket, newline JSON
hermes_bridge.py
│ in-process import
AIAgent (hermes-agent)
```
### 分流规则
`ChatRunSocket.resolveRunSource()` 决定本轮运行走哪个后端:
1. `run` payload 中 `source === 'cli'` 时走 bridge。
2. `source === 'api_server'` 时走 gateway。
3. 未显式传 `source` 时,如果 DB 中已有 session 的 `source``cli`,继续走 bridge。
4. 其他情况默认走 `api_server`
---
## 主要文件
### 前端
| 文件 | 说明 |
|------|------|
| `packages/client/src/components/hermes/chat/ChatPanel.vue` | 统一聊天面板;新建菜单包含 `API``Bridge (beta)`;渲染审批条 |
| `packages/client/src/components/hermes/chat/MessageList.vue` | 统一消息列表;展示文本、reasoning、tool 消息等 |
| `packages/client/src/components/hermes/chat/ChatInput.vue` | 统一输入框;发送、停止、附件上传入口 |
| `packages/client/src/api/hermes/chat.ts` | `/chat-run` Socket.IO 客户端;注册 session 事件处理器;发送 run/abort/approval |
| `packages/client/src/stores/hermes/chat.ts` | 会话状态、发送流程、resume、队列、审批、消息映射 |
### 后端
| 文件 | 说明 |
|------|------|
| `packages/server/src/services/hermes/run-chat/index.ts` | `/chat-run` Socket.IO 入口;按 `source` 分流 API Server 与 Bridge 运行 |
| `packages/server/src/services/hermes/run-chat/handle-api-run.ts` | API Server 路径;调用 Hermes Gateway `/v1/responses` 并消费流式响应 |
| `packages/server/src/services/hermes/run-chat/handle-bridge-run.ts` | Bridge 路径;调用 Agent Bridge 并写入本地会话库 |
| `packages/server/src/services/hermes/run-chat/session-command.ts` | CLI/Bridge slash command 解析与处理 |
| `packages/server/src/services/hermes/agent-bridge/client.ts` | Node 端 bridge 客户端;通过 socket 请求 Python bridge |
| `packages/server/src/services/hermes/agent-bridge/manager.ts` | Python bridge 子进程生命周期管理 |
| `packages/server/src/services/hermes/agent-bridge/hermes_bridge.py` | Python bridge 服务;创建并复用 `AIAgent` 实例 |
| `packages/server/src/services/hermes/agent-bridge/index.ts` | bridge 模块导出 |
| `packages/server/src/index.ts` | 启动 `AgentBridgeManager``ChatRunSocket` |
| `packages/server/src/services/shutdown.ts` | 关闭时停止 chat socket 和 bridge 子进程 |
| `packages/server/src/controllers/hermes/sessions.ts` | 会话列表和详情读取,包含 `source` 信息 |
| `packages/server/src/controllers/hermes/profiles.ts` | profile 管理接口;按 URL/body 中的 profile 做权限校验 |
### 已移除的旧文件
| 文件 | 状态 |
|------|------|
| `packages/client/src/api/hermes/cli-chat.ts` | 已删除 |
| `packages/client/src/components/hermes/chat/CliChatPanel.vue` | 已删除 |
| `packages/server/src/services/hermes/cli-chat-run-socket.ts` | 已删除 |
---
## 前端流程
### 新建会话
`ChatPanel.vue` 中的新建按钮使用下拉菜单:
- `API`:调用 `chatStore.newChat()`,创建默认 `api_server` 会话。
- `Bridge (beta)`:调用 `chatStore.newCliSession()`,创建 `source: 'cli'` 会话。
Bridge 会话 ID 使用类似 `YYYYMMDD_HHMMSS_xxxxxx` 的格式,便于与 Hermes CLI 风格的 session ID 对齐。
### 发送消息
1. `ChatInput.vue` 触发 store 的发送逻辑。
2. `chat.ts` 根据 active session 组装输入内容,附件会被转为 `ContentBlock[]`
3. 调用 `startRunViaSocket()`
4. 前端向 `/chat-run` emit
```ts
socket.emit('run', {
session_id,
input,
instructions,
model,
queue_id,
source, // api_server 或 cli
})
```
5. 前端注册本 session 的事件 handler,通过 `session_id` 隔离多会话并发事件。
### Resume
切换会话、页面恢复可见、或刷新后,前端通过:
```ts
socket.emit('resume', { session_id })
```
服务端返回:
```ts
{
session_id,
messages,
isWorking,
isAborting,
events,
inputTokens,
outputTokens,
queueLength,
}
```
如果服务端发现该 session 仍在运行,前端会重新注册 handler,并允许继续 abort。
### 审批
Bridge 工具需要人工确认时,服务端会发 `approval.requested`,前端 store 记录为 `activePendingApproval``ChatPanel.vue` 在输入框上方显示审批条。
前端响应审批:
```ts
socket.emit('approval.respond', {
session_id,
approval_id,
choice, // once | session | always | deny
})
```
---
## `/chat-run` Socket.IO 协议
### 客户端 → 服务端
| 事件 | 数据 | 说明 |
|------|------|------|
| `run` | `{ session_id, input, model?, instructions?, queue_id?, source? }` | 启动一轮运行;`source` 决定 API Server 或 Bridge |
| `resume` | `{ session_id }` | 加入 session room 并恢复状态 |
| `abort` | `{ session_id }` | 中断当前运行 |
| `cancel_queued_run` | `{ session_id, queue_id }` | 取消等待队列中的一条 run |
| `approval.respond` | `{ session_id, approval_id, choice }` | 响应 Bridge 工具审批 |
客户端不再发送独立 `command``steer` Socket.IO 事件;slash command 作为普通 `run.input` 进入 `/chat-run`,由服务端在 `source=cli` 时解析。
### 服务端 → 客户端
| 事件 | 说明 |
|------|------|
| `resumed` | 返回 DB 消息、运行状态、队列长度和最近事件 |
| `run.started` | 运行开始 |
| `run.queued` | 当前 session 已有运行,新请求进入队列 |
| `message.delta` | 文本增量 |
| `reasoning.delta` | reasoning 增量 |
| `thinking.delta` | thinking 增量 |
| `reasoning.available` | reasoning 内容可用 |
| `tool.started` | 工具调用开始 |
| `tool.completed` | 工具调用结束 |
| `approval.requested` | Bridge 工具请求人工审批 |
| `approval.resolved` | 审批完成或超时 |
| `compression.started` | 上下文压缩开始 |
| `compression.completed` | 上下文压缩结束 |
| `usage.updated` | token 用量更新 |
| `abort.started` | 中断开始 |
| `abort.completed` | 中断结束 |
| `session.command` | slash command 的执行结果或错误反馈 |
| `run.completed` | 运行完成 |
| `run.failed` | 运行失败 |
### 认证
`/chat-run` 使用 Socket.IO auth token
```ts
io(`${baseUrl}/chat-run`, {
auth: { token },
query: { profile },
})
```
服务端会与 Web UI token 比对。
---
## ChatRunSocket 后端行为
### API Server 路径
`source=api_server` 时:
1. 写入用户消息到 Web UI 本地 session DB。
2. 通过 `buildCompressedHistory()` 构建上下文。
3. 请求当前 profile 的 Hermes Gateway
```text
POST <upstream>/v1/responses
```
4. 读取 SSE frame,映射为统一的 `/chat-run` 事件。
5. 完成后写入 assistant/tool 消息,更新 usage。
### Bridge 路径
`source=cli` 时:
1. 写入用户消息到 Web UI 本地 session DB。
2. 复用同一套 `buildCompressedHistory()` 构建压缩上下文。
3. 调用:
```ts
this.bridge.chat(session_id, input, history, instructions, profile)
```
4. 轮询 `AgentBridgeClient.streamOutput(run_id)`
5. 将 Python bridge 的 delta 和 events 映射成统一事件。
6. 将 assistant 文本、reasoning、tool 调用结果 flush 回 DB。
### 队列
同一个 `session_id` 同时只能有一个 active run。新的 `run` 到达时:
- 如果当前 session 正在运行,则放入 `state.queue`
- 发送 `run.queued` 更新队列长度。
- 当前 run 结束或 abort 完成后,自动执行下一条 queued run。
---
## Python Agent Bridge
### 通信协议
Node 和 Python bridge 之间使用本地 socket 的单行 JSON 协议:
```json
{ "action": "chat", "session_id": "xxx", "message": "hello" }
```
响应也是单行 JSON
```json
{ "ok": true, "run_id": "xxx", "session_id": "xxx", "status": "running" }
```
### Endpoint
默认 endpoint 按平台选择:
| 平台 | 默认 endpoint |
|------|---------------|
| Windows | `tcp://127.0.0.1:18765` |
| macOS/Linux | `ipc:///tmp/hermes-agent-bridge.sock` |
Windows 使用 TCP 是因为部分 Python/Windows 环境没有 Unix domain socket 支持。
### 当前实际使用的 action
| Action | 说明 |
|--------|------|
| `chat` | 启动一轮 `AIAgent.run_conversation()` |
| `get_output` | 通过 `cursor``event_cursor` 获取增量文本与事件 |
| `interrupt` | 调用 agent 中断当前运行 |
| `approval_respond` | 响应工具审批 |
| `destroy_all` | 维护动作;仅用于明确的全量清理/进程关闭场景,普通 profile 切换不会调用 |
bridge 代码里还保留了一些调试/维护 action,例如 `ping``get_result``get_history``destroy``list``shutdown``steer`。当前 `/chat-run` 前端路径不会直接暴露这些 action;需要的能力由 Node `/chat-run` 层封装,例如 `/steer` slash command 会调用 `steer` action。
旧的 `command` action 已移除,Python bridge 不再直接解析 `/new``/undo``/retry``/branch` 等旧斜杠命令;当前 CLI/Bridge slash command 支持范围以 Node `/chat-run``session-command.ts` 为准。
### 会话和 profile
`AgentPool` 维护 `session_id -> AgentSession`
- 每个 session 持有独立 `AIAgent` 实例。
- session 按请求中的 profile 创建和复用;前端切换 Hermes Profile 只改变后续请求使用的 profile,不会影响其他 bridge 内存 session。
- `HERMES_HOME` 会在创建 agent 时临时切到 profile home。
- `SessionDB` 按 profile 的 `state.db` 路径缓存。
- 空闲 session 会被 bridge GC,默认 30 分钟无运行后销毁内存态。
### 工具和审批事件
bridge 从 `AIAgent` 回调中收集事件:
- `stream.delta`
- `reasoning.delta`
- `thinking.delta`
- `tool.started`
- `tool.completed`
- `tool.progress`
- `approval.requested`
- `approval.resolved`
- `turn.boundary`
- `status`
`ChatRunSocket` 会把这些事件转换为前端统一事件,并负责 DB 落盘。
审批默认等待 60 秒,超时自动 `deny`
---
## AgentBridgeClient
`AgentBridgeClient` 是 Node 端本地 socket 客户端。
行为:
- 支持 `ipc://``tcp://` endpoint。
- 每次请求新建 socket,发送一行 JSON,读取一行 JSON。
- 请求通过内部 lock 串行化。
- 默认请求响应超时为 `120000ms`
- `streamOutput()` 每 100ms 轮询一次 `get_output`
示例:
```ts
const started = await bridge.chat(sessionId, input, history, instructions, profile)
for await (const chunk of bridge.streamOutput(started.run_id)) {
// chunk.delta
// chunk.events
// chunk.done
}
```
注意:目前 socket connect 阶段没有独立 connect timeout,主要依赖系统连接错误和请求响应 timeout。
---
## AgentBridgeManager
`AgentBridgeManager` 负责启动和停止 Python bridge。
启动流程:
1. 定位 `hermes_bridge.py`
2. 发现 `hermes-agent` 根目录。
3. 选择 Python 解释器。
4. 以子进程启动:
```text
python hermes_bridge.py --endpoint <endpoint> --agent-root <root> --hermes-home <home>
```
5. 监听 stdout,等待:
```json
{ "event": "ready", "endpoint": "..." }
```
6. 默认 ready 超时为 `120000ms`
Python 选择优先级:
1. `HERMES_AGENT_BRIDGE_PYTHON`
2. `agentRoot/venv``agentRoot/.venv`
3. installed `hermes` 命令 shebang
4. `uv run --project <agentRoot> python`
5. 系统 `python3` / `python`
关闭时先发 `SIGTERM`1.5 秒后仍未退出则 `SIGKILL`
---
## 启动与关闭
### 启动
`bootstrap()` 中会先尝试启动 bridge
```ts
agentBridgeManager = await startAgentBridgeManager()
```
bridge 启动失败不会阻止 Web UI 启动,但 Bridge(beta) 会话后续运行会失败。
随后创建统一的 chat socket
```ts
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
chatRunServer.init()
```
### 关闭
服务关闭时会清理:
- `/chat-run` Socket.IO 状态
- Python agent bridge 子进程
- 其他 WebSocket/Socket.IO 服务
---
## 环境变量
| 变量 | 说明 |
|------|------|
| `HERMES_AGENT_BRIDGE_ENDPOINT` | Bridge endpointWindows 默认 `tcp://127.0.0.1:18765`macOS/Linux 默认 `ipc:///tmp/hermes-agent-bridge.sock` |
| `HERMES_AGENT_BRIDGE_WORKER_TRANSPORT` | profile worker endpoint transport;设为 `tcp` 使用 loopback TCP,设为 `ipc`/`unix` 使用 Unix domain socket;默认 Windows 使用 TCPmacOS/Linux 使用 IPC |
| `HERMES_AGENT_BRIDGE_WORKER_PORT_BASE` | TCP worker endpoint 起始端口,默认 `18780`;仅在 worker transport 为 TCP 时生效 |
| `HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT` | Version Preview 的 bridge broker endpoint transport;设为 `tcp` 可让预览环境在 macOS/Linux 上也使用 loopback TCP;未设置时会跟随 `HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp` |
| `HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT` | 直接覆盖 Version Preview 的 bridge broker endpoint;用于需要完全自定义预览 bridge 地址的部署 |
| `HERMES_AGENT_BRIDGE_TIMEOUT_MS` | Node 等待 bridge 请求响应的超时,默认 `120000` ms |
| `HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS` | Node 连接 bridge socket 失败时的短重试窗口,默认 `5000` ms |
| `HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS` | Node 等待 Python bridge ready 的超时,默认 `120000` ms |
| `HERMES_AGENT_BRIDGE_AUTO_RESTART` | bridge broker 意外退出后是否自动重启;设为 `0`/`false`/`no`/`off` 可关闭,默认开启 |
| `HERMES_AGENT_BRIDGE_RESTART_DELAY_MS` | bridge broker 自动重启基础延迟,默认 `1000` ms,连续失败时最多退避到 `30000` ms |
| `HERMES_AGENT_BRIDGE_PYTHON` | 指定 Python 解释器路径 |
| `HERMES_AGENT_ROOT` | hermes-agent 安装目录 |
| `HERMES_AGENT_BRIDGE_UV` | 指定 uv 可执行文件路径 |
| `HERMES_AGENT_BRIDGE_PLATFORM` | bridge 传给 Hermes Agent 的平台标识,默认 `cli` |
| `HERMES_BRIDGE_PROVIDER` | 覆盖 bridge 使用的 provider |
| `HERMES_BRIDGE_MAX_TURNS` | 覆盖 bridge 最大轮数 |
| `UV` | uv 可执行文件路径 fallback |
正常使用不需要配置这些变量。Bridge 支持多个用户/多个 profile 的运行并存;Web UI 的 Hermes Profile 切换不会重启 bridge 或销毁其他正在运行的任务。`HERMES_AGENT_BRIDGE_ENDPOINT` 控制 Node 与 Python bridge broker 的连接地址;`HERMES_AGENT_BRIDGE_WORKER_TRANSPORT` 控制 broker 与每个 profile worker 的连接方式。macOS/Linux 默认仍使用 IPC;如果 Electron、沙盒或安全软件环境下 IPC 不稳定,可以设置 `HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp` 切到 loopback TCP。Version Preview 默认继续使用独立的 broker endpoint,也会为 TCP worker 使用独立端口段,避免和正式实例共享端口池;如需让 Preview 的 broker 在 macOS/Linux 上也走 TCP,可设置 `HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT=tcp`,未设置时会跟随 `HERMES_AGENT_BRIDGE_WORKER_TRANSPORT=tcp`。Windows 下如果默认 TCP 端口被旧 bridge/broker/worker 占用,新 bridge 会先按端口杀掉旧进程树,再用同一个 endpoint 重建。
Windows 首次启动慢时可以临时放大:
```powershell
$env:HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = "300000"
$env:HERMES_AGENT_BRIDGE_TIMEOUT_MS = "300000"
```
---
## 当前限制
- Bridge(beta) 仍依赖 Python bridge 成功启动;启动失败时 Web UI 可用,但 bridge 会话不可用。
- bridge socket connect 阶段还没有单独 connect timeout。
- 旧 CLI 独立面板和独立 `/cli-chat-run` namespace 已移除。
- 旧 bridge `command/steer` socket 控制层已移除;CLI/Bridge slash command 现在通过统一 `/chat-run``run.input` 解析并以 `session.command` 反馈。
+104
View File
@@ -0,0 +1,104 @@
# Docker Compose Guide
This repository ships an environment-variable driven Docker Compose setup.
## Quick Start
### Pull pre-built image (Recommended)
```bash
WEBUI_IMAGE=ekkoye8888/hermes-web-ui docker compose up -d
docker compose logs -f hermes-webui
```
Open: `http://localhost:6060`
### Build from source
```bash
docker compose up -d --build
docker compose logs -f hermes-webui
```
## Services
This compose file runs a single service:
- `hermes-webui` — Web UI dashboard with integrated Hermes Agent runtime (pre-built image or built from source)
The Web UI container is built on the `nousresearch/hermes-agent` base image and uses the Hermes CLI / agent bridge runtime for chat execution. It does not start or manage a separate Hermes gateway process.
## Environment Variables
All key runtime settings are configured from compose variables.
| Variable | Default | Description |
|---|---|---|
| `PORT` | `6060` | Web UI listen port |
| `BIND_HOST` | `0.0.0.0` | Optional Web UI bind host. Defaults to IPv4 for stable WSL/Windows access. Set `::` explicitly if you want IPv6 listening. |
| `HERMES_BIN` | `/opt/hermes/.venv/bin/hermes` | Path to Hermes CLI binary |
| `HERMES_AGENT_IMAGE` | `nousresearch/hermes-agent:latest` | Hermes Agent base image (used only during build) |
| `WEBUI_IMAGE` | `hermes-web-ui-local:latest` | Web UI image (set to `ekkoye8888/hermes-web-ui` to use pre-built) |
| `HERMES_DATA_DIR` | `./hermes_data` | Hermes runtime data directory |
Override variables directly from shell:
```bash
PORT=16060 docker compose up -d
```
Or create a `.env` file in the project root:
```
WEBUI_IMAGE=ekkoye8888/hermes-web-ui
PORT=6060
```
## Data Persistence
| Path | Description |
|---|---|
| `${HERMES_DATA_DIR}` (`./hermes_data`) | Hermes runtime data (sessions, config, profiles) |
| `${HERMES_DATA_DIR}/hermes-web-ui` | Web UI data (auth token, etc.) |
- Hermes data persists in `./hermes_data`, mapped to `/home/agent/.hermes` in the container.
- Web UI data persists in `./hermes_data/hermes-web-ui/`, mapped to `/home/agent/.hermes-web-ui` in the container.
- The auth token is auto-generated on first run and printed to container logs.
- Deleting the token file and restarting will generate a new one.
## Port Mapping
| Port | Description |
|---|---|
| `${PORT}` (6060) | Web UI dashboard |
No Hermes gateway ports are exposed by this compose setup.
## Code Runtime Behavior
- Hermes CLI binary comes from `HERMES_BIN` env (`packages/server/src/services/hermes-cli.ts`).
- If `HERMES_BIN` is not provided, code falls back to `hermes` in `PATH`.
- Profile-specific chat runs are handled through the Hermes agent bridge. The selected/requested profile is authorized per account and passed with runtime requests; switching the frontend Hermes Profile does not restart the bridge or clear other running tasks.
- Docker is a managed gateway runtime: Web UI checks profile gateways on startup, but it does not run a periodic gateway recovery loop.
## Common Operations
Recreate:
```bash
docker compose up -d --force-recreate
```
View auth token:
```bash
docker compose logs hermes-webui | grep token
# or
cat ./hermes_data/hermes-web-ui/.token
```
Stop:
```bash
docker compose down
```
+40
View File
@@ -0,0 +1,40 @@
# Harness Overview
This harness turns recurring project knowledge into files and checks that an
agent can discover without chat history.
## Goals
- Make repository context legible through short maps and deeper docs.
- Keep architecture constraints close to the code they protect.
- Give agents a deterministic validation path before opening or updating a PR.
- Prefer mechanical checks over reminder text when a rule can be verified.
## Entry Points
- `AGENTS.md` is the root map for coding agents.
- `ARCHITECTURE.md` documents package boundaries and state ownership.
- `DEVELOPMENT.md` remains the contributor rules and command reference.
- `docs/harness/validation.md` maps change types to checks.
- `docs/harness/worktree-runbook.md` explains isolated worktree development.
- `docs/harness/pr-review.md` provides a PR self-review checklist.
- `scripts/harness-check.mjs` enforces baseline repository invariants.
## Operating Model
1. Read the root map and the specific doc for the task.
2. Make the smallest scoped change.
3. Add or update focused tests when behavior changes.
4. Run `npm run harness:check` and the relevant validation commands.
5. If a failure pattern repeats, improve this harness with docs, tests, scripts,
or CI instead of relying on a longer prompt.
## What Belongs In The Harness
- Facts that future agents must know to work safely.
- Checklists that prevent repeated PR review comments.
- Scripts that fail fast on repository-wide invariants.
- Runbooks for local, CI, release, and desktop packaging flows.
Do not put long implementation notes in `AGENTS.md`. Add them under `docs/` and
link to them from the map.
+41
View File
@@ -0,0 +1,41 @@
# PR Self-Review
Use this checklist before pushing or updating a pull request.
## Scope
- The PR title states the behavior being changed.
- The diff is limited to the requested task and required harness updates.
- Unrelated formatting or refactors are not bundled into the change.
- User-facing text has locale coverage.
## Architecture
- Client code uses shared API helpers and existing UI patterns.
- Server routes stay thin and delegate reusable behavior to controllers/services.
- Web UI state uses `config.appHome` or documented helpers.
- Hermes Agent state and Web UI state remain separate.
- Subprocess calls use argument arrays instead of shell string construction.
## Tests And Validation
- A focused test was added or updated for behavior changes.
- Browser-visible flows have e2e coverage when the risk justifies it.
- `npm run harness:check` passes.
- The PR body lists validation commands that actually ran.
- Known limitations or follow-ups are called out.
## Release And CI
- Workflow changes were checked with `npm run harness:check`.
- Desktop release artifacts remain platform-specific.
- `fail_on_unmatched_files: true` is preserved when each matrix target has its
own expected artifact list.
- Package manifest changes have matching lockfile changes when dependencies
change.
## Before Merge
- CI is green or failures are explained as unrelated.
- The branch is mergeable.
- The PR does not depend on hidden local state, credentials, or uncommitted files.
+68
View File
@@ -0,0 +1,68 @@
# Validation Guide
Run the smallest relevant checks while iterating. Escalate to the broad checks
when touching shared behavior, release automation, auth, persistence, or chat.
## Always Run For PRs
```bash
npm run harness:check
```
For broad or shared changes, also run:
```bash
npm run test:coverage
npm run test:e2e
npm run build
```
## Change-Type Matrix
| Change | Minimum local validation |
| --- | --- |
| Docs only | `npm run harness:check` |
| Client component/store/API | focused `npm run test -- <pattern>`, then `npm run build` |
| User-visible browser flow | focused Vitest plus `npm run test:e2e` |
| Server controller/service/db | focused `npm run test -- tests/server/<file>` |
| Auth, profile, or credential behavior | focused server tests plus relevant e2e auth tests |
| Chat, Socket.IO, group chat | focused server tests plus relevant e2e chat tests |
| Desktop packaging | `npm run harness:check`, `npm run build`, and a platform-specific desktop build when practical |
| GitHub workflow | `npm run harness:check` and `actionlint` when available |
| Package manifests | `npm ci --ignore-scripts` and lockfile workflow expectations |
## CI Mapping
- Build workflow: installs dependencies, runs coverage, and builds production
assets on pushes and pull requests.
- Playwright workflow: runs browser e2e tests.
- NPM lockfile workflow: verifies `package-lock.json` is synchronized.
- Desktop release and manual desktop build workflows build and upload
platform-specific desktop artifacts.
- Docker workflow: builds and publishes release images.
## Release Workflow Guardrail
Desktop release jobs must upload only the artifacts that their matrix target can
produce. Keep artifact globs in matrix data and keep `fail_on_unmatched_files:
true` so missing expected files still fail.
Expected desktop release outputs:
| Target | Required release globs |
| --- | --- |
| macOS | `*.dmg`, `*.dmg.blockmap`, `*.zip`, `*.zip.blockmap`, `latest*.yml` |
| Windows | `*.exe`, `*.exe.blockmap`, `latest*.yml` |
| Linux x64 | `*.AppImage`, `*.deb`, `latest*.yml` |
| Linux arm64 | `*.AppImage`, `latest*.yml` |
## Failure Handling
When a command fails:
1. Read the first actionable error, not just the final stack trace.
2. Check whether the failure indicates missing context, missing test coverage,
or a missing mechanical rule.
3. Fix the product bug when there is one.
4. Update docs or `scripts/harness-check.mjs` when the same class of mistake
should be prevented next time.
+64
View File
@@ -0,0 +1,64 @@
# Worktree Runbook
Use a separate git worktree for agent changes so local user work remains
untouched.
## Create A Worktree
```bash
git fetch origin --prune
git worktree add -b codex/<short-topic> ../worktrees/hermes-web-ui-<short-topic> origin/main
cd ../worktrees/hermes-web-ui-<short-topic>
```
If the repository uses a fork remote, push to the remote requested by the task.
Do not rewrite or reset unrelated branches.
## Install
```bash
npm ci --ignore-scripts
npm rebuild node-pty
```
Desktop package dependencies are separate:
```bash
npm ci --prefix packages/desktop --no-audit --no-fund
```
## Isolated Runtime
Use per-worktree state and ports to avoid colliding with a running local app:
```bash
export PORT=18648
export HERMES_WEB_UI_HOME="$PWD/.tmp/hermes-web-ui"
export HERMES_WEBUI_STATE_DIR="$HERMES_WEB_UI_HOME"
export UPLOAD_DIR="$PWD/.tmp/uploads"
npm run dev
```
Do not point `HERMES_WEB_UI_HOME` at a user's real `~/.hermes-web-ui` when a task
only needs local verification.
## Browser Checks
For browser-visible changes:
```bash
npm run test:e2e
```
Prefer existing Playwright fixtures and mocked backend services. Add real-service
requirements only when the behavior cannot be represented with mocks.
## Cleanup
After a PR is pushed and no more local work is needed:
```bash
git worktree remove ../worktrees/hermes-web-ui-<short-topic>
```
Only remove the worktree you created.
+3449
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"watch": ["packages/server/src"],
"ext": "ts,tsx",
"execMap": {
"ts": "node -r ts-node/register"
},
"env": {
"NODE_ENV": "development",
"PORT": "8647",
"TS_NODE_PROJECT": "packages/server/tsconfig.json",
"HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN": "0"
},
"exec": "node -r ts-node/register packages/server/src/index.ts",
"nodeArgs": ["--no-warnings"],
"delay": "1000"
}
+11363
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
{
"name": "hermes-web-ui",
"version": "0.6.9",
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
"repository": {
"type": "git",
"url": "https://github.com/EKKOLearnAI/hermes-web-ui.git"
},
"homepage": "https://hermes-studio.ai",
"license": "BSL-1.1",
"engines": {
"node": ">=23.0.0"
},
"keywords": [
"hermes",
"hermes-agent",
"hermes-web",
"agent",
"ai",
"ai-agent",
"ai-chat",
"ai-dashboard",
"llm",
"multi-model",
"chat-ui",
"dashboard",
"self-hosted",
"multi-platform",
"vue3",
"typescript"
],
"bin": {
"hermes-web-ui": "./bin/hermes-web-ui.mjs"
},
"scripts": {
"start": "vite --host --port 8648",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:client": "cross-env HERMES_WEB_UI_BACKEND_PORT=8647 vite --host --port 8649 --strictPort",
"dev:server": "nodemon",
"build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs",
"harness:check": "node scripts/harness-check.mjs",
"prepare": "[ -d dist ] || npm run build",
"preview": "NODE_ENV=production vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"dev:website": "vite --config vite.config.website.ts",
"build:website": "vite build --config vite.config.website.ts",
"preview:website": "vite preview --config vite.config.website.ts",
"desktop:install": "npm ci --prefix packages/desktop --no-audit --no-fund",
"desktop:prepare-runtime": "npm --prefix packages/desktop run prepare:runtime",
"desktop:prepare-python": "npm --prefix packages/desktop run prepare:python",
"build:desktop": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --publish never",
"build:desktop:mac": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --mac --publish never",
"build:desktop:win": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --win --publish never",
"build:desktop:linux": "npm run build && npm run desktop:install && npm --prefix packages/desktop run dist -- --linux --publish never",
"openapi:generate": "node scripts/generate-openapi.mjs",
"claude": "claude --dangerously-skip-permissions"
},
"files": [
"bin/",
"dist/"
],
"dependencies": {
"@vscode/markdown-it-katex": "^1.1.2",
"eventsource": "^4.1.0",
"js-tiktoken": "^1.0.21",
"katex": "^0.17.0",
"node-edge-tts": "^1.2.10",
"node-pty": "^1.1.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@koa/bodyparser": "^5.0.0",
"@koa/cors": "^5.0.0",
"@koa/router": "^15.4.0",
"@multiavatar/multiavatar": "^1.0.7",
"@pinia/testing": "^1.0.3",
"@playwright/test": "^1.60.0",
"@types/eventsource": "^1.1.15",
"@types/js-yaml": "^4.0.9",
"@types/katex": "^0.16.8",
"@types/koa": "^2.15.0",
"@types/koa__cors": "^5.0.0",
"@types/koa__router": "^12.0.5",
"@types/koa-send": "^4.1.6",
"@types/koa-static": "^4.0.4",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.12.2",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/coverage-v8": "^3.2.4",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.9.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"esbuild": "^0.27.0",
"highlight.js": "^11.11.1",
"js-yaml": "^4.1.1",
"jsdom": "^27.0.1",
"koa": "^2.15.3",
"koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"markdown-it": "^14.1.1",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"naive-ui": "^2.44.1",
"nodemon": "^3.1.14",
"pinia": "^3.0.4",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"sass": "^1.99.0",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsoa": "^7.0.0-alpha.0",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vitest": "^3.2.4",
"vue": "^3.5.32",
"vue-i18n": "^11.3.2",
"vue-router": "^4.6.4",
"vue-tsc": "^3.2.8",
"vue-virtual-scroller": "^3.0.4",
"ws": "^8.20.0"
}
}
+47
View File
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>灵犀 Studio</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Prevent FOUC by applying theme classes immediately -->
<script>
(function() {
try {
const brightness = localStorage.getItem('hermes_brightness') || 'system';
const style = localStorage.getItem('hermes_style') || 'ink';
// Resolve dark mode
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = brightness === 'dark' || (brightness === 'system' && prefersDark);
// Resolve comic style
const isComic = style === 'comic';
// Apply classes immediately
if (isDark) {
document.documentElement.classList.add('dark');
}
if (isComic) {
document.documentElement.classList.add('comic');
}
} catch (e) {
console.warn('Failed to apply theme:', e);
}
})();
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>Claude Code</title>
<path clip-rule="evenodd"
d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z"
fill="#D97757"
fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<defs>
<linearGradient id="lx-bg" x1="6" y1="4" x2="42" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563eb"/>
<stop offset="1" stop-color="#0891b2"/>
</linearGradient>
<linearGradient id="lx-glow" x1="24" y1="10" x2="24" y2="38" gradientUnits="userSpaceOnUse">
<stop stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="1" stop-color="#e0f2fe" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect x="3" y="3" width="42" height="42" rx="11" fill="url(#lx-bg)"/>
<path d="M24 11 L33 24 L24 37 L15 24 Z" fill="url(#lx-glow)"/>
<circle cx="24" cy="22" r="4.5" fill="#ffffff"/>
<circle cx="24" cy="22" r="2" fill="#2563eb"/>
</svg>

After

Width:  |  Height:  |  Size: 771 B

@@ -0,0 +1,228 @@
# Recommended Skills
This page collects useful community skill repositories that can extend Hermes, Claude Code, Codex-style agents, and similar local agent workflows.
Community skills are third-party code and instructions. Review them before installing, especially when a skill can read API keys, cookies, browser sessions, local files, repositories, shell scripts, package managers, or social media accounts.
Useful skill recommendations are welcome. If you find a high-quality skill that should be listed here, please submit a pull request on GitHub with the repository link, usage scenario, and any security notes.
## Maintenance Guidelines
- Keep this document in English. Update `skill-recommendations.zh.md` separately for the Chinese version.
- Add recommendations under the closest existing category before creating a new category.
- Use the same structure for each item: repository link, focus, good-for scenarios, representative skills or capabilities when available, and notes when there are installation, API, or security concerns.
- Keep descriptions factual and concise. Prefer information confirmed from the repository README, `SKILL.md`, examples, or package metadata.
- Do not paste secrets, private tokens, install commands that auto-execute remote code, or unverifiable marketing claims.
- Put security-sensitive skills in context: mention when they can access credentials, browsers, local files, shells, package managers, external APIs, or social accounts.
- For unsupported locales, the UI falls back to this English document.
## Security First
- Treat every third-party skill as untrusted until reviewed.
- Read `SKILL.md`, scripts, hooks, and dependency installers before running.
- Be extra careful with skills that post to social media, access browsers, read credentials, install packages, execute shell commands, or send local files to external APIs.
- Prefer testing new skills in a disposable profile or sandboxed project first.
- Use a security review skill such as SlowMist Agent Security when evaluating unknown repositories, URLs, MCP servers, or skill packages.
## Official And General-Purpose Skills
### Anthropic Official Skills
- Repository: [anthropics/skills](https://github.com/anthropics/skills/tree/main/skills)
- Focus: official reference skills for Claude-style agents.
- Good for: learning the expected skill structure, adapting stable examples, and bootstrapping common workflows.
- Representative skills: `docx`, `pdf`, `pptx`, `xlsx`, `frontend-design`, `webapp-testing`, `skill-creator`, `mcp-builder`, `theme-factory`, `web-artifacts-builder`.
- Notes: a good first source when you want conservative, well-structured examples.
### Matt Pocock Skills
- Repository: [mattpocock/skills](https://github.com/mattpocock/skills)
- Focus: engineering and productivity skills from a real development workflow.
- Good for: TypeScript engineering, test-driven work, triage, diagnosis, reviews, prototyping, and product handoff workflows.
- Representative skills: `tdd`, `triage`, `diagnose`, `prototype`, `review`, `to-prd`, `to-issues`, `handoff`, `write-a-skill`.
- Notes: useful when you want agent behavior that is direct, structured, and engineering-oriented.
## Design, Slides, And Visual Work
### Frontend Slides
- Repository: [zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides)
- Focus: creating web-native slide decks with frontend techniques.
- Good for: HTML/CSS slide decks, visual storytelling, and browser-rendered presentations.
- Notes: useful when a deck should be designed as a rich web artifact rather than a traditional office file.
### Huashu Design
- Repository: [alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design)
- Focus: HTML-native design work for Claude Code and agent workflows.
- Good for: high-fidelity prototypes, slides, animation concepts, visual review, and export-oriented design flows.
- Notes: includes design philosophy, review heuristics, and presentation-oriented workflows.
### Guizang PPT Skill
- Repository: [op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill)
- Focus: polished HTML slide decks with editorial, magazine, and Swiss-style layouts.
- Good for: presentation decks, social covers, image prompts, and visual narrative work.
- Notes: includes a presentation runtime and style-oriented slide generation patterns.
### HTML PPT Skill
- Repository: [lewislulu/html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)
- Focus: HTML PPT Studio for professional HTML presentations.
- Good for: themed slide decks, layout-rich presentations, and animated browser presentations.
- Representative capabilities: multiple themes, layout templates, animation patterns, and HTML presentation scaffolding.
### PPT Image First
- Repository: [NyxTides/ppt-image-first](https://github.com/NyxTides/ppt-image-first)
- Focus: image-first presentation generation.
- Good for: decks where the visual direction should lead the content structure.
- Notes: designed for Codex, Claude Code, and OpenCode-style CLI agents.
### GPT Image To PPT
- Repository: [JuneYaooo/gpt-image2-ppt-skills](https://github.com/JuneYaooo/gpt-image2-ppt-skills)
- Focus: cloning or adapting PowerPoint visual layouts using image generation.
- Good for: recreating a deck style from an existing `.pptx` template while replacing the actual content.
- Notes: useful for template-driven presentations, but review external image generation/API behavior before use.
### Fireworks Tech Graph
- Repository: [yizhiyanhua-ai/fireworks-tech-graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph)
- Focus: technical diagram generation.
- Good for: architecture diagrams, workflow charts, UML-style visuals, AI agent workflow diagrams, and production-ready SVG/PNG outputs.
- Notes: a practical choice when you need diagrams rather than full slide decks.
### Diagram Skill
- Repository: [312362115/claude diagram skill](https://github.com/312362115/claude/blob/main/skills/diagram/SKILL.md)
- Focus: diagram generation inside a broader Claude skill collection.
- Good for: generating structured diagrams, templates, and visual explanations.
- Notes: this is a direct skill file link, so review the surrounding `references`, `scripts`, and `templates` folders before installing.
## Writing, Documents, And Knowledge Work
### Huashu Markdown To HTML
- Repository: [alchaincyf/huashu-md-html](https://github.com/alchaincyf/huashu-md-html)
- Focus: Markdown and HTML conversion pipelines.
- Good for: converting files or URLs to Markdown, turning Markdown into polished HTML, and converting HTML back to Markdown.
- Representative tools: MarkItDown, Pandoc, html-to-markdown, and trafilatura-based workflows.
- Notes: useful for content publishing, document cleanup, and HTML presentation pages.
### Chinese Web Novel Skill
- Repository: [Tomsawyerhu/Chinese-WebNovel-Skill](https://github.com/Tomsawyerhu/Chinese-WebNovel-Skill)
- Focus: Chinese web novel writing workflows.
- Good for: long-form fiction planning, chapter writing, style continuity, and web-novel oriented drafting.
- Representative skill: `webnovel-writing`.
### Software Copyright Skill
- Repository: [Fokkyp/SoftwareCopyright-Skill](https://github.com/Fokkyp/SoftwareCopyright-Skill)
- Focus: preparing Chinese software copyright application materials.
- Good for: generating `.docx` application documents from a local software project.
- Representative skills: `software-copyright-materials`, `docx-toolkit`.
- Notes: this may read local project files. Review file access and document generation behavior before running.
### Patent Disclosure Skill
- Repository: [handsomestWei/patent-disclosure-skill](https://github.com/handsomestWei/patent-disclosure-skill)
- Focus: patent disclosure drafting.
- Good for: extracting patentable points from project documents, novelty checks, desensitized drafting, and self-review loops.
- Notes: may involve web research and sensitive technical documents. Review data handling carefully.
## Image, Media, And Social Publishing
### Baoyu Skills
- Repository: [JimLiu/baoyu-skills](https://github.com/JimLiu/baoyu-skills)
- Focus: image generation, content transformation, publishing, and media workflows.
- Good for: image cards, article illustrations, slide decks, URL-to-Markdown conversion, YouTube transcripts, Markdown-to-HTML, and social posting workflows.
- Representative skills: `baoyu-image-gen`, `baoyu-imagine`, `baoyu-slide-deck`, `baoyu-markdown-to-html`, `baoyu-post-to-x`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-url-to-markdown`, `baoyu-youtube-transcript`, `baoyu-translate`, `baoyu-diagram`, `baoyu-comic`.
- Security note: posting and web-reading skills may access account sessions, cookies, browser state, or external APIs. Review carefully before use.
### Virtual Couple Travel Vlog
- Repository: [vibeshotclub/vsc-skills / virtual-couple-travel-vlog](https://github.com/vibeshotclub/vsc-skills/tree/main/virtual-couple-travel-vlog)
- Focus: travel-vlog style media generation.
- Good for: short-form visual storytelling, character-based travel content, and repeatable media production prompts.
- Notes: this is a subdirectory skill inside a larger skill collection.
## Research, Web Access, And Content Monitoring
### Web Access
- Repository: [eze-is/web-access](https://github.com/eze-is/web-access)
- Focus: giving an agent structured web access through layered routing and browser/CDP workflows.
- Good for: web research, browser-assisted tasks, parallel information gathering, and pages that require interaction.
- Security note: browser access can expose logged-in sessions and local browser state. Audit before enabling.
### OpenCLI
- Repository: [jackwener/opencli](https://github.com/jackwener/opencli)
- Focus: converting websites, browser sessions, Electron apps, and local tools into CLI-accessible automation surfaces for humans and AI agents.
- Good for: letting agents operate logged-in Chrome pages, building reusable website adapters, wrapping local binaries, and turning browser workflows into deterministic commands.
- Representative skills: `opencli-browser`, `opencli-adapter-author`, `opencli-autofix`, `opencli-usage`.
- Security note: browser-backed commands can use logged-in sessions and local browser state. Review the extension, daemon, adapters, and any generated commands before enabling in sensitive profiles.
### Follow Builders
- Repository: [zarazhangrui/follow-builders](https://github.com/zarazhangrui/follow-builders)
- Focus: monitoring AI builders across X, blogs, and YouTube podcasts.
- Good for: tracking builders rather than influencers, summarizing feeds, and creating digest-style updates.
- Representative data/config files: X feeds, blog feeds, podcast feeds, prompts, and state files.
- Security note: any social or feed automation should be reviewed for account/session access.
### SlowMist Agent Security
- Repository: [slowmist/slowmist-agent-security](https://github.com/slowmist/slowmist-agent-security)
- Focus: security review for AI agents operating with untrusted inputs.
- Good for: checking skills, MCP servers, repositories, URLs, prompts, and crypto/on-chain addresses for security risks.
- Core idea: external input should be considered untrusted until verified.
- Notes: recommended before installing or running unfamiliar community skills.
## Persona, Thinking, And Advisory Skills
### Huashu Nuwa Skill
- Repository: [alchaincyf/nuwa-skill](https://github.com/alchaincyf/nuwa-skill)
- Focus: distilling a person or viewpoint into a reusable agent skill.
- Good for: advisory-board style thinking, mental models, decision heuristics, and writing in a specific perspective.
- Representative perspectives: Huashu Nuwa, Feynman, Steve Jobs, Elon Musk, Naval Ravikant, Paul Graham, Nassim Taleb.
- Notes: useful for brainstorming and viewpoint simulation, not for factual authority.
### PUA / Anti-PUA Skills
- Repository: [tanweai/pua](https://github.com/tanweai/pua)
- Focus: high-agency, confrontational, coaching, or anti-PUA style agent behavior.
- Good for: motivation, critique, resistance to manipulation, and intentionally sharp agent feedback.
- Representative skills: `pua`, `pua-en`, `pua-ja`, `pua-loop`, `mama`, `p7`, `p9`, `p10`, `pro`, `shot`, `yes`.
- Notes: these skills intentionally change tone and interaction style. Review before enabling in shared or user-facing environments.
### Ex Skill
- Repository: [therealXiaomanChu/ex-skill](https://github.com/therealXiaomanChu/ex-skill)
- Focus: distilling an ex-partner/persona into an AI skill that speaks in that style.
- Good for: persona experiments, emotional roleplay, and style simulation.
- Representative skill: `create-ex`.
- Notes: use carefully. Persona skills can strongly alter tone and emotional framing.
## Quick Shortlist
If you only want a practical starter set:
- [Anthropic Official Skills](https://github.com/anthropics/skills/tree/main/skills) for reference implementations.
- [Matt Pocock Skills](https://github.com/mattpocock/skills) for engineering workflows.
- [Baoyu Skills](https://github.com/JimLiu/baoyu-skills) for image, media, and publishing workflows.
- [Huashu Design](https://github.com/alchaincyf/huashu-design) for high-fidelity HTML-native design.
- [Guizang PPT Skill](https://github.com/op7418/guizang-ppt-skill) or [HTML PPT Skill](https://github.com/lewislulu/html-ppt-skill) for browser-based presentations.
- [Huashu Markdown To HTML](https://github.com/alchaincyf/huashu-md-html) for Markdown/HTML document conversion.
- [Web Access](https://github.com/eze-is/web-access) for web research workflows.
- [OpenCLI](https://github.com/jackwener/opencli) for logged-in browser automation and reusable website CLI adapters.
- [Fireworks Tech Graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph) for technical diagrams.
- [SlowMist Agent Security](https://github.com/slowmist/slowmist-agent-security) for reviewing risky community skills.
## Original Source List
This document was compiled from a curated Hermes / Claude skill sharing list and expanded with public GitHub repository metadata.
@@ -0,0 +1,228 @@
# Skills 推荐清单
这是一份适合 Hermes、Claude Code、Codex 类本地 Agent 工作流的社区 Skill 推荐清单。它主要用于帮助你发现可安装、可参考或可改造的 Skill 来源。
社区 Skill 本质上是第三方指令和代码。安装前请先审计,尤其是会读取 API Key、Cookie、浏览器登录态、本地文件、仓库内容,或者会执行 shell、安装依赖、自动发帖、访问外部 API 的 Skill。
欢迎大家推荐各种好用的 Skill。如果你发现值得收录的高质量 Skill,可以到 GitHub 提交 PR,并附上仓库链接、适用场景和必要的安全说明。
## 维护规范
- 这份文档只维护中文内容;英文版请同步维护 `skill-recommendations.en.md`
- 新增推荐时优先放入最接近的现有分类,不要轻易新增大类。
- 每个条目尽量保持同一结构:仓库链接、方向、适合场景、代表 Skills 或能力、必要备注。
- 描述要简洁、事实化,优先依据仓库 README、`SKILL.md`、示例或包元数据,不写无法验证的宣传语。
- 不要写入密钥、私有 token、会自动执行远程代码的安装命令,或无法确认来源的内容。
- 涉及安全风险的 Skill 要明确说明上下文,例如是否会访问凭据、浏览器、本地文件、shell、包管理器、外部 API 或社交账号。
- 前端只维护中文和英文两份推荐文档,其他语言统一回退到英文版。
## 安全优先
- 默认把所有第三方 Skill 当成不可信内容,审计后再启用。
- 安装前阅读 `SKILL.md`、脚本、hooks、依赖安装逻辑和插件配置。
- 对会访问浏览器、读取凭据、执行 shell、安装 npm/pip/brew 依赖、自动发帖或上传本地文件的 Skill 保持谨慎。
- 建议先在一次性 profile 或沙盒项目里测试新 Skill。
- 可以使用 SlowMist Agent Security 这类安全审计 Skill 来检查陌生仓库、URL、MCP、Skill 包和链上地址。
## 官方与通用 Skills
### Anthropic 官方 Skills
- 仓库:[anthropics/skills](https://github.com/anthropics/skills/tree/main/skills)
- 方向:Claude 官方参考 Skill。
- 适合:学习标准 Skill 结构、参考稳定实现、搭建通用工作流。
- 代表 Skills`docx``pdf``pptx``xlsx``frontend-design``webapp-testing``skill-creator``mcp-builder``theme-factory``web-artifacts-builder`
- 备注:如果你想找保守、规范、可参考的 Skill 示例,优先看这个。
### Matt Pocock Skills
- 仓库:[mattpocock/skills](https://github.com/mattpocock/skills)
- 方向:工程与生产力工作流。
- 适合:TypeScript 工程、TDD、问题诊断、代码评审、原型开发、PRD/Issue/Handoff 等开发流程。
- 代表 Skills`tdd``triage``diagnose``prototype``review``to-prd``to-issues``handoff``write-a-skill`
- 备注:适合希望 Agent 更像工程协作者时使用。
## 设计、幻灯片与可视化
### Frontend Slides
- 仓库:[zarazhangrui/frontend-slides](https://github.com/zarazhangrui/frontend-slides)
- 方向:用前端技术生成网页幻灯片。
- 适合:HTML/CSS 幻灯片、视觉叙事、浏览器渲染的演示稿。
- 备注:适合把演示稿当成 Web Artifact 来做,而不是传统 Office 文件。
### 华叔 Design
- 仓库:[alchaincyf/huashu-design](https://github.com/alchaincyf/huashu-design)
- 方向:Claude Code 中的 HTML 原生设计 Skill。
- 适合:高保真原型、幻灯片、动画概念、视觉评审和导出型设计流程。
- 备注:包含设计哲学、评审维度和演示型工作流。
### 归藏 PPT Skill
- 仓库:[op7418/guizang-ppt-skill](https://github.com/op7418/guizang-ppt-skill)
- 方向:生成高质量 HTML 幻灯片。
- 适合:杂志风、编辑风、瑞士风等视觉风格的演示稿、社交封面、图片提示词和叙事型页面。
- 备注:包含演示运行时和风格化生成模式。
### HTML PPT Skill
- 仓库:[lewislulu/html-ppt-skill](https://github.com/lewislulu/html-ppt-skill)
- 方向:HTML PPT Studio。
- 适合:主题化幻灯片、复杂布局演示稿和带动画的浏览器演示。
- 代表能力:多主题、多布局、动画模式和 HTML 演示脚手架。
### PPT Image First
- 仓库:[NyxTides/ppt-image-first](https://github.com/NyxTides/ppt-image-first)
- 方向:图片优先的 PPT 生成。
- 适合:视觉方向先行的演示稿创作。
- 备注:面向 Codex、Claude Code、OpenCode CLI 等 Agent 工作流。
### GPT Image To PPT
- 仓库:[JuneYaooo/gpt-image2-ppt-skills](https://github.com/JuneYaooo/gpt-image2-ppt-skills)
- 方向:用图像生成能力复刻或改造 PPT 视觉版式。
- 适合:从已有 `.pptx` 模板中学习版式,再替换成自己的内容。
- 备注:涉及图像生成和外部 API 时请先检查配置与数据发送逻辑。
### Fireworks Tech Graph
- 仓库:[yizhiyanhua-ai/fireworks-tech-graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph)
- 方向:技术图表生成。
- 适合:架构图、流程图、UML 风格图、AI Agent 工作流图,以及 SVG/PNG 输出。
- 备注:需要图表而不是整套演示稿时很实用。
### Diagram Skill
- 仓库:[312362115/claude diagram skill](https://github.com/312362115/claude/blob/main/skills/diagram/SKILL.md)
- 方向:结构化图表生成。
- 适合:生成图表、模板化视觉解释和技术说明。
- 备注:这是一个直接指向 `SKILL.md` 的链接,安装前也要检查同目录下的 `references``scripts``templates`
## 写作、文档与知识工作
### 华叔 Markdown To HTML
- 仓库:[alchaincyf/huashu-md-html](https://github.com/alchaincyf/huashu-md-html)
- 方向:Markdown 与 HTML 双向转换流水线。
- 适合:把文件或网页转 Markdown,把 Markdown 转精美 HTML,把 HTML 再转回 Markdown。
- 代表工具:MarkItDown、Pandoc、html-to-markdown、trafilatura。
- 备注:适合内容发布、文档清理和 HTML 页面生成。
### 中文网文写作 Skill
- 仓库:[Tomsawyerhu/Chinese-WebNovel-Skill](https://github.com/Tomsawyerhu/Chinese-WebNovel-Skill)
- 方向:中文网文小说写作。
- 适合:长篇小说规划、章节创作、风格延续和网文式叙事。
- 代表 Skill`webnovel-writing`
### 软件著作权材料 Skill
- 仓库:[Fokkyp/SoftwareCopyright-Skill](https://github.com/Fokkyp/SoftwareCopyright-Skill)
- 方向:中国软件著作权申请材料生成。
- 适合:根据本地项目生成 `.docx` 软著申请材料。
- 代表 Skills`software-copyright-materials``docx-toolkit`
- 备注:可能读取本地项目文件,运行前请审计文件访问和文档生成逻辑。
### 专利交底书 Skill
- 仓库:[handsomestWei/patent-disclosure-skill](https://github.com/handsomestWei/patent-disclosure-skill)
- 方向:专利技术交底书生成。
- 适合:从项目文档挖掘专利点、联网查新、脱敏成文和自检。
- 备注:可能涉及敏感技术资料和联网检索,使用前请关注数据处理方式。
## 图片、媒体与社交发布
### 宝玉 Skills
- 仓库:[JimLiu/baoyu-skills](https://github.com/JimLiu/baoyu-skills)
- 方向:图片生成、内容转换、发布和媒体工作流。
- 适合:图片卡片、文章配图、幻灯片、URL 转 Markdown、YouTube 字幕、Markdown 转 HTML、社交平台发布。
- 代表 Skills`baoyu-image-gen``baoyu-imagine``baoyu-slide-deck``baoyu-markdown-to-html``baoyu-post-to-x``baoyu-post-to-wechat``baoyu-post-to-weibo``baoyu-url-to-markdown``baoyu-youtube-transcript``baoyu-translate``baoyu-diagram``baoyu-comic`
- 安全提示:发帖和网页读取类 Skill 可能访问账号会话、Cookie、浏览器状态或外部 API,使用前务必审计。
### Virtual Couple Travel Vlog
- 仓库:[vibeshotclub/vsc-skills / virtual-couple-travel-vlog](https://github.com/vibeshotclub/vsc-skills/tree/main/virtual-couple-travel-vlog)
- 方向:旅行 vlog 风格媒体生成。
- 适合:短视频视觉叙事、角色化旅行内容和可复用媒体提示词。
- 备注:这是一个大仓库里的子目录 Skill。
## Web 访问、研究与内容监控
### Web Access
- 仓库:[eze-is/web-access](https://github.com/eze-is/web-access)
- 方向:为 Agent 提供结构化联网能力。
- 适合:网页研究、浏览器辅助任务、并行信息收集和需要交互的网站。
- 安全提示:浏览器访问可能暴露已登录状态和本地浏览器数据,启用前要审计。
### OpenCLI
- 仓库:[jackwener/opencli](https://github.com/jackwener/opencli)
- 方向:把网站、浏览器会话、Electron 应用和本地工具转换成 CLI 可调用的自动化入口。
- 适合:让 Agent 操作已登录的 Chrome 页面、编写可复用网站适配器、封装本地命令,以及把浏览器流程变成稳定命令。
- 代表 Skills`opencli-browser``opencli-adapter-author``opencli-autofix``opencli-usage`
- 安全提示:浏览器命令可能使用已登录会话和本地浏览器状态。启用前请审计扩展、daemon、适配器和生成的命令,敏感 profile 里尤其要谨慎。
### Follow Builders
- 仓库:[zarazhangrui/follow-builders](https://github.com/zarazhangrui/follow-builders)
- 方向:跟踪 AI builders 的 X、博客和 YouTube 播客内容。
- 适合:关注 builder 而不是 influencer,生成摘要和内容 digest。
- 代表内容:X feed、blog feed、podcast feed、prompts 和状态文件。
- 安全提示:社交和 feed 自动化要关注账号、Cookie 和访问权限。
### SlowMist Agent Security
- 仓库:[slowmist/slowmist-agent-security](https://github.com/slowmist/slowmist-agent-security)
- 方向:AI Agent 安全审计框架。
- 适合:检查 Skill、MCP、仓库、URL、Prompt 和链上地址的安全风险。
- 核心原则:所有外部输入在验证前都不可信。
- 备注:安装陌生社区 Skill 前建议优先使用。
## Persona、思维方式与顾问类 Skills
### 华叔 Nuwa Skill
- 仓库:[alchaincyf/nuwa-skill](https://github.com/alchaincyf/nuwa-skill)
- 方向:把某个人或视角蒸馏成可复用 Skill。
- 适合:顾问团式思考、心智模型、决策启发式和特定视角写作。
- 代表视角:华叔 Nuwa、Feynman、Jobs、Musk、Naval、Paul Graham、Taleb。
- 备注:适合头脑风暴和视角模拟,不应当作事实权威。
### PUA / 反 PUA 类 Skills
- 仓库:[tanweai/pua](https://github.com/tanweai/pua)
- 方向:高能动性、强反馈、反操控或尖锐教练风格的 Agent 行为。
- 适合:动机强化、批判反馈、反操控和刻意强风格交互。
- 代表 Skills`pua``pua-en``pua-ja``pua-loop``mama``p7``p9``p10``pro``shot``yes`
- 备注:这类 Skill 会明显改变语气和互动方式,不建议直接用于共享或面向用户的环境。
### Ex Skill
- 仓库:[therealXiaomanChu/ex-skill](https://github.com/therealXiaomanChu/ex-skill)
- 方向:把某个前任/人格风格蒸馏成 AI Skill。
- 适合:Persona 实验、情绪化角色扮演和特定语气模拟。
- 代表 Skill`create-ex`
- 备注:Persona 类 Skill 可能强烈影响语气和情绪框架,使用前请确认场景合适。
## 快速推荐
如果你只想先装一批实用的,可以从这些开始:
- [Anthropic 官方 Skills](https://github.com/anthropics/skills/tree/main/skills):参考实现和通用能力。
- [Matt Pocock Skills](https://github.com/mattpocock/skills):工程流程。
- [宝玉 Skills](https://github.com/JimLiu/baoyu-skills):图片、媒体和发布。
- [华叔 Design](https://github.com/alchaincyf/huashu-design):高保真 HTML 设计。
- [归藏 PPT Skill](https://github.com/op7418/guizang-ppt-skill) 或 [HTML PPT Skill](https://github.com/lewislulu/html-ppt-skill):浏览器演示稿。
- [华叔 Markdown To HTML](https://github.com/alchaincyf/huashu-md-html)Markdown/HTML 文档转换。
- [Web Access](https://github.com/eze-is/web-access):网页研究。
- [OpenCLI](https://github.com/jackwener/opencli):已登录浏览器自动化和可复用网站 CLI 适配器。
- [Fireworks Tech Graph](https://github.com/yizhiyanhua-ai/fireworks-tech-graph):技术图表。
- [SlowMist Agent Security](https://github.com/slowmist/slowmist-agent-security):社区 Skill 安全审计。
## 来源说明
本文档基于一份 Hermes / Claude Skills 分享清单整理,并补充了公开 GitHub 仓库描述与目录信息。
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { darkTheme, NConfigProvider, NMessageProvider, NDialogProvider, NNotificationProvider } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { getThemeOverrides } from '@/styles/theme'
import { useTheme } from '@/composables/useTheme'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppTopBar from '@/components/layout/AppTopBar.vue'
import AppLogo from '@/components/common/AppLogo.vue'
import { useKeyboard } from '@/composables/useKeyboard'
import { useAppStore } from '@/stores/hermes/app'
import SessionSearchModal from '@/components/hermes/chat/SessionSearchModal.vue'
import AuthEventListener from '@/components/auth/AuthEventListener.vue'
import DefaultCredentialPrompt from '@/components/auth/DefaultCredentialPrompt.vue'
const { isDark, isComic } = useTheme()
const { t } = useI18n()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const ready = ref(false)
const themeOverrides = computed(() => getThemeOverrides(isDark.value, isComic.value))
const naiveTheme = computed(() => isDark.value ? darkTheme : null)
const isLoginPage = computed(() => route.name === 'login')
const nodeVersionLow = computed(() => {
const v = appStore.nodeVersion
const major = parseInt(v.split('.')[0], 10)
return !isNaN(major) && major < 23
})
watch(() => route.path, () => {
appStore.closeSidebar()
})
router.isReady().then(() => {
ready.value = true
})
onMounted(() => {
if (!isLoginPage.value) {
appStore.loadModels()
appStore.startHealthPolling()
}
})
onUnmounted(() => {
appStore.stopHealthPolling()
})
useKeyboard()
</script>
<template>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NMessageProvider>
<AuthEventListener />
<NDialogProvider>
<NNotificationProvider>
<div v-if="nodeVersionLow && ready" class="node-warning-bar">
{{ t('sidebar.nodeVersionWarning', { version: appStore.nodeVersion }) }}
</div>
<div v-if="ready" class="app-shell" :class="{ 'no-chrome': isLoginPage, 'has-warning': nodeVersionLow }">
<AppTopBar v-if="!isLoginPage" />
<button v-if="!isLoginPage" class="hamburger-btn" @click="appStore.toggleSidebar">
<AppLogo :size="22" />
</button>
<div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" />
<div v-if="!isLoginPage" class="app-body">
<AppSidebar />
<main class="app-main">
<router-view />
</main>
</div>
<main v-else class="app-main app-main--full">
<router-view />
</main>
</div>
<SessionSearchModal />
<DefaultCredentialPrompt />
</NNotificationProvider>
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.app-shell {
display: flex;
flex-direction: column;
+160
View File
@@ -0,0 +1,160 @@
import { request } from './client'
export interface AuthStatus {
hasPasswordLogin: boolean
hasUsers?: boolean
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const res = await fetch('/api/auth/status')
if (!res.ok) throw new Error('Failed to fetch auth status')
return res.json()
}
export async function loginWithPassword(username: string, password: string): Promise<string> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
const err: any = new Error(data.error || 'Login failed')
err.status = res.status
throw err
}
const data = await res.json()
return data.token
}
export interface CurrentUser {
id: number
username: string
role: UserRole
status: UserStatus
created_at: number
updated_at: number
last_login_at: number | null
requiresCredentialChange?: boolean
}
export async function fetchCurrentUser(): Promise<CurrentUser> {
const res = await request<{ user: CurrentUser }>('/api/auth/me')
return res.user
}
export async function setupPassword(username: string, password: string): Promise<void> {
return request('/api/auth/setup', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
return request('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
}
export async function changeUsername(currentPassword: string, newUsername: string): Promise<void> {
return request('/api/auth/change-username', {
method: 'POST',
body: JSON.stringify({ currentPassword, newUsername }),
})
}
export async function removePassword(): Promise<void> {
return request('/api/auth/password', {
method: 'DELETE',
})
}
export type UserRole = 'super_admin' | 'admin'
export type UserStatus = 'active' | 'disabled'
export interface ManagedUser {
id: number
username: string
role: UserRole
status: UserStatus
profiles: string[]
default_profile: string | null
created_at: number
updated_at: number
last_login_at: number | null
}
export interface ManagedUsersResponse {
users: ManagedUser[]
profiles: string[]
}
export async function fetchManagedUsers(): Promise<ManagedUsersResponse> {
return request<ManagedUsersResponse>('/api/auth/users')
}
export async function createManagedUser(input: {
username: string
password: string
role: UserRole
status: UserStatus
profiles: string[]
defaultProfile?: string | null
}): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>('/api/auth/users', {
method: 'POST',
body: JSON.stringify(input),
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export async function updateManagedUser(id: number, input: {
username?: string
password?: string
role?: UserRole
status?: UserStatus
profiles?: string[]
defaultProfile?: string | null
}): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, {
method: 'PUT',
body: JSON.stringify(input),
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export async function deleteManagedUser(id: number): Promise<ManagedUsersResponse> {
const res = await request<{ users: ManagedUser[] }>(`/api/auth/users/${id}`, {
method: 'DELETE',
})
const current = await fetchManagedUsers()
return { ...current, users: res.users }
}
export interface LockedIp {
ip: string
type: 'password' | 'token'
failures: number
lockedUntil: number
}
export async function fetchLockedIps(): Promise<LockedIp[]> {
const res = await request<{ locks: LockedIp[] }>('/api/auth/locked-ips')
return res.locks
}
export async function unlockSpecificIp(ip: string): Promise<void> {
return request(`/api/auth/locked-ips?ip=${encodeURIComponent(ip)}`, {
method: 'DELETE',
})
}
export async function unlockAllIps(): Promise<number> {
const res = await request<{ count: number }>('/api/auth/locked-ips', {
method: 'DELETE',
})
return res.count
}
+153
View File
@@ -0,0 +1,153 @@
import router from '@/router'
const DEFAULT_BASE_URL = ''
function isDesktopShell(): boolean {
return typeof window !== 'undefined' &&
(window as typeof window & { hermesDesktop?: { isDesktop?: boolean } }).hermesDesktop?.isDesktop === true
}
function getBaseUrl(): string {
if (import.meta.env.VITE_HERMES_PREVIEW === '1') return DEFAULT_BASE_URL
if (isDesktopShell()) return DEFAULT_BASE_URL
return localStorage.getItem('hermes_server_url') || DEFAULT_BASE_URL
}
export function getApiKey(): string {
return localStorage.getItem('hermes_api_key') || ''
}
export function setServerUrl(url: string) {
localStorage.setItem('hermes_server_url', url)
}
export function setApiKey(key: string) {
localStorage.setItem('hermes_api_key', key)
}
export function clearApiKey() {
localStorage.removeItem('hermes_api_key')
}
export function hasApiKey(): boolean {
return !!getApiKey()
}
export type StoredUserRole = 'super_admin' | 'admin'
export function getStoredUserRole(): StoredUserRole | null {
const token = getApiKey()
const payload = token.split('.')[1]
if (!payload) return null
try {
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const data = JSON.parse(atob(padded)) as { role?: unknown }
return data.role === 'super_admin' || data.role === 'admin' ? data.role : null
} catch {
return null
}
}
export function isStoredSuperAdmin(): boolean {
return getStoredUserRole() === 'super_admin'
}
export function getActiveProfileName(): string | null {
return localStorage.getItem('hermes_active_profile_name')
}
function bodyHasProfileSelector(body: BodyInit | null | undefined): boolean {
if (typeof body !== 'string') return false
try {
const parsed = JSON.parse(body) as { profile?: unknown }
return typeof parsed?.profile === 'string' && parsed.profile.trim().length > 0
} catch {
return false
}
}
function shouldAttachProfileHeader(path: string, options: RequestInit): boolean {
try {
const url = new URL(path, 'http://hermes.local')
if (url.searchParams.has('profile')) return false
if (url.pathname.startsWith('/api/hermes/profiles')) return false
if (isProfileWideSessionCollection(url.pathname)) return false
} catch {
if (path.startsWith('/api/hermes/profiles')) return false
if (isProfileWideSessionCollection(path.split('?')[0] || path)) return false
}
return !bodyHasProfileSelector(options.body)
}
function isProfileWideSessionCollection(pathname: string): boolean {
return pathname === '/api/hermes/sessions' ||
pathname === '/api/hermes/sessions/batch-delete' ||
pathname === '/api/hermes/search/sessions' ||
pathname === '/api/hermes/sessions/search' ||
pathname === '/api/hermes/sessions/conversations'
}
function emitAuthNotice(kind: 'expired' | 'forbidden') {
if (typeof window === 'undefined') return
window.dispatchEvent(new CustomEvent('hermes-auth-notice', { detail: { kind } }))
}
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const base = getBaseUrl()
const url = `${base}${path}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers as Record<string, string>,
}
const apiKey = getApiKey()
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
// Inject active profile header for request-scoped endpoints. Explicit profile
// selectors in the URL/body and profile-name routes are validated directly.
const profileName = getActiveProfileName()
if (profileName && shouldAttachProfileHeader(path, options)) {
headers['X-Hermes-Profile'] = profileName
}
const res = await fetch(url, { ...options, headers })
// Global 401 handler — only redirect to login for local BFF endpoints
// Proxied gateway requests should not trigger logout
const isLocalBff = !path.startsWith('/api/hermes/v1/') &&
!path.startsWith('/v1/')
if (res.status === 401 && isLocalBff) {
clearApiKey()
emitAuthNotice('expired')
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
throw new Error('Unauthorized')
}
if (!res.ok) {
const text = await res.text().catch(() => '')
if (res.status === 403 && isLocalBff) {
if (text.includes('User is disabled or does not exist')) {
clearApiKey()
emitAuthNotice('expired')
if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'login' })
}
} else {
emitAuthNotice('forbidden')
}
}
throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
}
return res.json()
}
export function getBaseUrlValue(): string {
return getBaseUrl()
}
+134
View File
@@ -0,0 +1,134 @@
import { request } from './client'
export type CodingAgentId = 'claude-code' | 'codex'
export type CodingAgentApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages'
export type CodingAgentLaunchMode = 'scoped' | 'global'
export interface CodingAgentToolStatus {
id: CodingAgentId
name: string
provider: string
command: string
packageName: string
installed: boolean
version: string
rawVersion: string
error?: string
}
export interface CodingAgentsStatus {
tools: CodingAgentToolStatus[]
}
export interface CodingAgentMutationResult extends CodingAgentsStatus {
success: boolean
tool: CodingAgentToolStatus
message?: string
code?: string
}
export interface CodingAgentConfigFileContent {
key: string
path: string
absolutePath: string
language: string
content: string
exists: boolean
size: number
profile: string
provider: string
rootDir: string
}
export interface CodingAgentConfigScope {
profile?: string | null
provider?: string | null
}
export interface CodingAgentLaunchRequest {
mode?: CodingAgentLaunchMode
profile?: string | null
provider?: string
model?: string
baseUrl?: string
apiKey?: string
apiMode?: CodingAgentApiMode
}
export interface CodingAgentLaunchResult {
agentId: CodingAgentId
mode: CodingAgentLaunchMode
profile: string
provider: string
model: string
rootDir: string
workspaceDir: string
command: string
args: string[]
env: Record<string, string>
shellCommand: string
files: Array<{ key: string; path: string; absolutePath: string }>
}
export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult {
nativeTerminal: true
terminal: string
}
export async function fetchCodingAgentsStatus(): Promise<CodingAgentsStatus> {
return request<CodingAgentsStatus>('/api/coding-agents')
}
export async function installCodingAgent(id: CodingAgentId): Promise<CodingAgentMutationResult> {
return request<CodingAgentMutationResult>(`/api/coding-agents/${id}/install`, { method: 'POST' })
}
export async function deleteCodingAgent(id: CodingAgentId): Promise<CodingAgentMutationResult> {
return request<CodingAgentMutationResult>(`/api/coding-agents/${id}`, { method: 'DELETE' })
}
export async function readCodingAgentConfigFile(
id: CodingAgentId,
key: string,
scope: CodingAgentConfigScope = {},
): Promise<CodingAgentConfigFileContent> {
const params = new URLSearchParams()
if (scope.profile) params.set('profile', scope.profile)
if (scope.provider) params.set('provider', scope.provider)
const query = params.toString()
return request<CodingAgentConfigFileContent>(
`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}${query ? `?${query}` : ''}`,
)
}
export async function writeCodingAgentConfigFile(
id: CodingAgentId,
key: string,
content: string,
scope: CodingAgentConfigScope = {},
): Promise<CodingAgentConfigFileContent> {
return request<CodingAgentConfigFileContent>(`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}`, {
method: 'PUT',
body: JSON.stringify({ content, profile: scope.profile, provider: scope.provider }),
})
}
export async function prepareCodingAgentLaunch(
id: CodingAgentId,
data: CodingAgentLaunchRequest,
): Promise<CodingAgentLaunchResult> {
return request<CodingAgentLaunchResult>(`/api/coding-agents/${id}/launch/prepare`, {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function launchCodingAgentNativeTerminal(
id: CodingAgentId,
data: CodingAgentLaunchRequest,
): Promise<CodingAgentNativeLaunchResult> {
return request<CodingAgentNativeLaunchResult>(`/api/coding-agents/${id}/launch/native`, {
method: 'POST',
body: JSON.stringify(data),
})
}
+870
View File
@@ -0,0 +1,870 @@
import { io, type Socket } from 'socket.io-client'
import { getBaseUrlValue, getApiKey } from '../client'
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; name: string; path: string; media_type: string }
| { type: 'file'; name: string; path: string; media_type?: string }
export interface ChatMessage {
role: 'user' | 'assistant' | 'system'
content: string | ContentBlock[]
}
export interface StartRunRequest {
input: string | ContentBlock[]
instructions?: string
session_id?: string
profile?: string
model?: string
provider?: string
model_groups?: Array<{ provider: string; models: string[] }>
queue_id?: string
source?: 'api_server' | 'cli'
}
export interface StartRunResponse {
run_id: string
status: string
}
// SSE event types from /v1/runs/{id}/events
export interface RunEvent {
event: string
run_id?: string
delta?: string
/** Payload text for `reasoning.delta` / `thinking.delta` / `reasoning.available` events. */
text?: string
tool?: string
name?: string
preview?: string
timestamp?: number
error?: string
/** Final response text on `run.completed`. May be empty/null if the agent
* silently swallowed an upstream error — see chat store for fallback. */
output?: string | null
usage?: {
input_tokens: number
output_tokens: number
total_tokens: number
}
/** session_id tag added by server for client-side filtering */
session_id?: string
/** Queue length from run.queued event */
queue_length?: number
/** Queue item that was just removed because it is starting now. */
dequeued_queue_id?: string
/** Queued user messages from run.queued/resume payloads. */
queued_messages?: Array<{
id?: string | number
role?: string
content?: string
timestamp?: number
queued?: boolean
}>
/** User message broadcast to other windows already watching the same session. */
message?: {
id?: string | number
role?: string
content?: string
timestamp?: number
queued?: boolean
}
}
export interface ResumeSessionPayload {
session_id: string
messages: any[]
messageTotal?: number
messageLoadedCount?: number
messagePageLimit?: number
hasMoreBefore?: boolean
isWorking: boolean
isAborting?: boolean
events: Array<{ event: string; data: RunEvent }>
inputTokens?: number
outputTokens?: number
contextTokens?: number
queueLength?: number
queueMessages?: RunEvent['queued_messages']
}
// ============================
// Socket.IO chat run connection
// ============================
let chatRunSocket: Socket | null = null
let globalListenersRegistered = false
let chatRunSocketProfile: string | null = null
const TRANSIENT_DISCONNECT_REASONS = new Set<string>([
'transport close',
'transport error',
'ping timeout',
])
/**
* Session event handlers map
* Maps session_id to event handling functions for isolating concurrent session streams
*/
const sessionEventHandlers = new Map<string, {
onMessageDelta: (event: RunEvent) => void
onReasoningDelta: (event: RunEvent) => void
onThinkingDelta: (event: RunEvent) => void
onReasoningAvailable: (event: RunEvent) => void
onToolStarted: (event: RunEvent) => void
onToolCompleted: (event: RunEvent) => void
onSubagentEvent?: (event: RunEvent) => void
onRunStarted: (event: RunEvent) => void
onRunCompleted: (event: RunEvent) => void
onRunFailed: (event: RunEvent) => void
onCompressionStarted: (event: RunEvent) => void
onCompressionCompleted: (event: RunEvent) => void
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
onClarifyRequested?: (event: RunEvent) => void
onClarifyResolved?: (event: RunEvent) => void
}>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
const sessionCommandHandlers = new Set<(event: RunEvent) => void>()
/**
* Global message.delta event handler
* Distributes events to appropriate session based on session_id
*/
function globalMessageDeltaHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onMessageDelta) {
handlers.onMessageDelta(event)
}
}
/**
* Global reasoning.delta event handler
*/
function globalReasoningDeltaHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onReasoningDelta) {
handlers.onReasoningDelta(event)
}
}
/**
* Global thinking.delta event handler (alias for reasoning.delta)
*/
function globalThinkingDeltaHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onThinkingDelta) {
handlers.onThinkingDelta(event)
}
}
/**
* Global reasoning.available event handler
*/
function globalReasoningAvailableHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onReasoningAvailable) {
handlers.onReasoningAvailable(event)
}
}
/**
* Global tool.started event handler
*/
function globalToolStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onToolStarted) {
handlers.onToolStarted(event)
}
}
/**
* Global tool.completed event handler
*/
function globalToolCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onToolCompleted) {
handlers.onToolCompleted(event)
}
}
function globalSubagentEventHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onSubagentEvent) {
handlers.onSubagentEvent(event)
}
}
/**
* Global run.started event handler
*/
function globalRunStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunStarted) {
handlers.onRunStarted(event)
}
}
/**
* Global run.completed event handler
*/
function globalRunCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunCompleted) {
handlers.onRunCompleted(event)
}
// Auto-cleanup session handlers on completion (skip if more runs queued)
if ((event as any).queue_remaining > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global run.failed event handler
*/
function globalRunFailedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunFailed) {
handlers.onRunFailed(event)
}
// Auto-cleanup session handlers on failure (skip if more runs queued)
if ((event as any).queue_remaining > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global run.queued event handler
*/
function globalRunQueuedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onRunQueued) {
handlers.onRunQueued(event)
}
}
/**
* Global compression.started event handler
*/
function globalCompressionStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onCompressionStarted) {
handlers.onCompressionStarted(event)
}
}
/**
* Global compression.completed event handler
*/
function globalCompressionCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onCompressionCompleted) {
handlers.onCompressionCompleted(event)
}
}
/**
* Global abort.started event handler
*/
function globalAbortStartedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAbortStarted) {
handlers.onAbortStarted(event)
}
}
/**
* Global abort.completed event handler
*/
function globalAbortCompletedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAbortCompleted) {
handlers.onAbortCompleted(event)
}
// If abort completion is followed by queued runs, keep the handler alive so
// the next run.started/message.delta/run.completed events are still received.
if ((event as any).queue_length > 0) return
sessionEventHandlers.delete(sid)
}
/**
* Global usage.updated event handler
*/
function globalUsageUpdatedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onUsageUpdated) {
handlers.onUsageUpdated(event)
}
}
function globalSessionCommandHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onSessionCommand) {
handlers.onSessionCommand(event)
}
for (const handler of sessionCommandHandlers) {
handler(event)
}
}
function globalAgentEventHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAgentEvent) {
handlers.onAgentEvent(event)
}
}
function globalApprovalRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalRequested) {
handlers.onApprovalRequested(event)
}
}
function globalApprovalResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onApprovalResolved) {
handlers.onApprovalResolved(event)
}
}
function globalPeerUserMessageHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onPeerUserMessage) {
handlers.onPeerUserMessage(event)
}
for (const handler of peerUserMessageHandlers) {
handler(event)
}
}
function globalClarifyRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onClarifyRequested) {
handlers.onClarifyRequested(event)
}
}
function globalClarifyResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onClarifyResolved) {
handlers.onClarifyResolved(event)
}
}
/**
* Register event handlers for a session
* @param sessionId - Session ID
* @param handlers - Event handling functions
* @returns Cleanup function to unregister handlers
*/
export function registerSessionHandlers(
sessionId: string,
handlers: {
onMessageDelta: (event: RunEvent) => void
onReasoningDelta: (event: RunEvent) => void
onThinkingDelta: (event: RunEvent) => void
onReasoningAvailable: (event: RunEvent) => void
onToolStarted: (event: RunEvent) => void
onToolCompleted: (event: RunEvent) => void
onSubagentEvent?: (event: RunEvent) => void
onRunStarted: (event: RunEvent) => void
onRunCompleted: (event: RunEvent) => void
onRunFailed: (event: RunEvent) => void
onCompressionStarted: (event: RunEvent) => void
onCompressionCompleted: (event: RunEvent) => void
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
onClarifyRequested?: (event: RunEvent) => void
onClarifyResolved?: (event: RunEvent) => void
}
): () => void {
sessionEventHandlers.set(sessionId, handlers)
// Return cleanup function
return () => {
sessionEventHandlers.delete(sessionId)
}
}
/**
* Unregister event handlers for a session
* @param sessionId - Session ID
*/
export function unregisterSessionHandlers(sessionId: string): void {
sessionEventHandlers.delete(sessionId)
}
export function onPeerUserMessage(handler: (event: RunEvent) => void): () => void {
peerUserMessageHandlers.add(handler)
return () => {
peerUserMessageHandlers.delete(handler)
}
}
export function onSessionCommand(handler: (event: RunEvent) => void): () => void {
sessionCommandHandlers.add(handler)
return () => {
sessionCommandHandlers.delete(handler)
}
}
export function respondClarify(
sessionId: string,
clarifyId: string,
response: string,
): void {
const socket = connectChatRun()
socket.emit('clarify.respond', {
session_id: sessionId,
clarify_id: clarifyId,
response,
})
}
export function respondToolApproval(
sessionId: string,
approvalId: string,
choice: 'once' | 'session' | 'always' | 'deny',
): void {
const socket = connectChatRun()
socket.emit('approval.respond', {
session_id: sessionId,
approval_id: approvalId,
choice,
})
}
export function getChatRunSocket(): Socket | null {
return chatRunSocket
}
export function connectChatRun(requestedProfile?: string | null): Socket {
const normalizedRequestedProfile = requestedProfile?.trim() || null
if (chatRunSocket?.connected && (!normalizedRequestedProfile || chatRunSocketProfile === normalizedRequestedProfile)) {
return chatRunSocket
}
// Clean up old socket to prevent duplicate event listeners
if (chatRunSocket) {
chatRunSocket.removeAllListeners()
chatRunSocket.disconnect()
globalListenersRegistered = false
chatRunSocketProfile = null
}
const baseUrl = getBaseUrlValue()
const token = getApiKey()
// Get active profile from store (authoritative source)
let profile = normalizedRequestedProfile || 'default'
try {
if (!normalizedRequestedProfile) {
const { useProfilesStore } = require('@/stores/hermes/profiles')
const profilesStore = useProfilesStore()
profile = profilesStore.activeProfileName || 'default'
}
} catch {
// Fallback to localStorage during early initialization
profile = normalizedRequestedProfile || localStorage.getItem('hermes_active_profile_name') || 'default'
}
chatRunSocketProfile = profile
chatRunSocket = io(`${baseUrl}/chat-run`, {
auth: { token },
query: { profile },
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
})
// Register global listeners only once per socket connection
if (!globalListenersRegistered) {
// Message events
chatRunSocket.on('message.delta', globalMessageDeltaHandler)
chatRunSocket.on('reasoning.delta', globalReasoningDeltaHandler)
chatRunSocket.on('thinking.delta', globalThinkingDeltaHandler)
chatRunSocket.on('reasoning.available', globalReasoningAvailableHandler)
// Tool events
chatRunSocket.on('tool.started', globalToolStartedHandler)
chatRunSocket.on('tool.completed', globalToolCompletedHandler)
chatRunSocket.on('subagent.start', globalSubagentEventHandler)
chatRunSocket.on('subagent.tool', globalSubagentEventHandler)
chatRunSocket.on('subagent.progress', globalSubagentEventHandler)
chatRunSocket.on('subagent.complete', globalSubagentEventHandler)
// Run lifecycle events
chatRunSocket.on('run.started', globalRunStartedHandler)
chatRunSocket.on('run.failed', globalRunFailedHandler)
chatRunSocket.on('run.completed', globalRunCompletedHandler)
chatRunSocket.on('run.queued', globalRunQueuedHandler)
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler)
chatRunSocket.on('clarify.requested', globalClarifyRequestedHandler)
chatRunSocket.on('clarify.resolved', globalClarifyResolvedHandler)
// Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
chatRunSocket.on('compression.completed', globalCompressionCompletedHandler)
chatRunSocket.on('abort.started', globalAbortStartedHandler)
chatRunSocket.on('abort.completed', globalAbortCompletedHandler)
// Usage events
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
chatRunSocket.on('agent.event', globalAgentEventHandler)
chatRunSocket.on('session.command', globalSessionCommandHandler)
globalListenersRegistered = true
}
return chatRunSocket
}
export function disconnectChatRun(): void {
if (chatRunSocket) {
chatRunSocket.disconnect()
chatRunSocket = null
chatRunSocketProfile = null
globalListenersRegistered = false
sessionEventHandlers.clear()
}
}
function removeSocketListener(socket: Socket, event: string, handler: (...args: any[]) => void): void {
const candidate = socket as Socket & {
off?: (event: string, handler: (...args: any[]) => void) => Socket
removeListener?: (event: string, handler: (...args: any[]) => void) => Socket
}
if (typeof candidate.off === 'function') {
candidate.off(event, handler)
return
}
candidate.removeListener?.(event, handler)
}
/**
* Start a chat run via Socket.IO and stream events back.
* Returns an AbortController-compatible handle for cancellation.
*/
/**
* Resume a session via Socket.IO. Returns messages, working status, and events.
*/
export function resumeSession(
sessionId: string,
onResumed: (data: ResumeSessionPayload) => void,
profile?: string | null,
): Socket {
const socket = connectChatRun(profile)
socket.once('resumed', onResumed)
socket.emit('resume', { session_id: sessionId, ...(profile ? { profile } : {}) })
return socket
}
export function startRunViaSocket(
body: StartRunRequest,
onEvent: (event: RunEvent) => void,
onDone: () => void,
onError: (err: Error) => void,
onStarted?: (runId: string) => void,
options?: {
onReconnectResume?: (data: ResumeSessionPayload) => void
},
): { abort: () => void } {
const sid = body.session_id
if (!sid) {
throw new Error('session_id is required for startRunViaSocket')
}
let closed = false
const socket = connectChatRun(body.profile)
if (sessionEventHandlers.has(sid)) {
socket.emit('run', body)
return {
abort: () => {
if (!closed) {
socket.emit('abort', { session_id: sid })
}
},
}
}
let sawTransientDisconnect = false
let removeTerminalSocketListeners: () => void = () => {}
let reconnectResumeHandler: ((data: ResumeSessionPayload) => void) | null = null
const clearReconnectResumeHandler = () => {
if (!reconnectResumeHandler) return
removeSocketListener(socket, 'resumed', reconnectResumeHandler)
reconnectResumeHandler = null
}
const emitReconnectResume = () => {
clearReconnectResumeHandler()
if (options?.onReconnectResume) {
reconnectResumeHandler = (data: ResumeSessionPayload) => {
clearReconnectResumeHandler()
if (closed || data.session_id !== sid) return
options.onReconnectResume?.(data)
}
socket.on('resumed', reconnectResumeHandler)
}
socket.emit('resume', { session_id: sid, ...(body.profile ? { profile: body.profile } : {}) })
}
const handleSocketError = (err: Error) => {
if (closed) return
closed = true
removeTerminalSocketListeners()
sessionEventHandlers.delete(sid)
onError(err)
}
const handleSocketConnectError = (err: Error) => {
if (closed) return
if (sawTransientDisconnect) return
handleSocketError(err)
}
socket.on('connect_error', handleSocketConnectError)
const handleSocketDisconnect = (reason: string) => {
if (closed || reason === 'io client disconnect') return
if (TRANSIENT_DISCONNECT_REASONS.has(reason)) {
sawTransientDisconnect = true
return
}
handleSocketError(new Error(`Socket disconnected: ${reason}`))
}
socket.on('disconnect', handleSocketDisconnect)
const handleSocketReconnect = () => {
if (closed || !sawTransientDisconnect) return
sawTransientDisconnect = false
emitReconnectResume()
}
socket.on('connect', handleSocketReconnect)
removeTerminalSocketListeners = () => {
clearReconnectResumeHandler()
removeSocketListener(socket, 'connect_error', handleSocketConnectError)
removeSocketListener(socket, 'disconnect', handleSocketDisconnect)
removeSocketListener(socket, 'connect', handleSocketReconnect)
}
// Define event handlers for this session
const handlers = {
onMessageDelta: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onReasoningDelta: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onThinkingDelta: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onReasoningAvailable: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onToolStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onToolCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onSubagentEvent: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onRunStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
onStarted?.(evt.run_id || '')
},
onRunCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onRunFailed: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_remaining > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onCompressionStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onCompressionCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onAbortStarted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onAbortCompleted: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).queue_length > 0) return
closed = true
removeTerminalSocketListeners()
onDone()
},
onUsageUpdated: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onAgentEvent: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onSessionCommand: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
if ((evt as any).terminal === false) return
closed = true
removeTerminalSocketListeners()
sessionEventHandlers.delete(sid)
onDone()
},
onRunQueued: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onApprovalRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onApprovalResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onClarifyRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onClarifyResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
}
// Register handlers in the global session map
sessionEventHandlers.set(sid, handlers)
// Emit run request
socket.emit('run', body)
return {
abort: () => {
if (!closed) {
socket.emit('abort', { session_id: sid })
}
},
}
}
@@ -0,0 +1,30 @@
import { request } from '../client'
export interface CodexStartResult {
session_id: string
user_code: string
verification_url: string
expires_in: number
}
export interface CodexPollResult {
status: 'pending' | 'approved' | 'expired' | 'error'
error: string | null
}
export interface CodexStatusResult {
authenticated: boolean
last_refresh?: string
}
export async function startCodexLogin(): Promise<CodexStartResult> {
return request<CodexStartResult>('/api/hermes/auth/codex/start', { method: 'POST' })
}
export async function pollCodexLogin(sessionId: string): Promise<CodexPollResult> {
return request<CodexPollResult>(`/api/hermes/auth/codex/poll/${sessionId}`)
}
export async function getCodexAuthStatus(): Promise<CodexStatusResult> {
return request<CodexStatusResult>('/api/hermes/auth/codex/status')
}
+131
View File
@@ -0,0 +1,131 @@
import { request } from '../client'
export interface DisplayConfig {
compact?: boolean
personality?: string
resume_display?: string
busy_input_mode?: string
bell_on_complete?: boolean
show_reasoning?: boolean
streaming?: boolean
inline_diffs?: boolean
show_cost?: boolean
skin?: string
}
export interface AgentConfig {
max_turns?: number
gateway_timeout?: number
restart_drain_timeout?: number
service_tier?: string
tool_use_enforcement?: string
}
export interface MemoryConfig {
memory_enabled?: boolean
user_profile_enabled?: boolean
memory_char_limit?: number
user_char_limit?: number
}
export interface CompressionConfig {
enabled?: boolean
threshold?: number
target_ratio?: number
protect_last_n?: number
protect_first_n?: number
}
export interface SessionResetConfig {
mode?: string
idle_minutes?: number
at_hour?: number
}
export interface PrivacyConfig {
redact_pii?: boolean
}
export interface ApprovalConfig {
mode?: 'off' | 'manual'
timeout?: number
}
export interface AppConfig {
display?: DisplayConfig
agent?: AgentConfig
memory?: MemoryConfig
compression?: CompressionConfig
session_reset?: SessionResetConfig
privacy?: PrivacyConfig
approvals?: ApprovalConfig
telegram?: Record<string, any>
discord?: Record<string, any>
slack?: Record<string, any>
whatsapp?: Record<string, any>
matrix?: Record<string, any>
weixin?: Record<string, any>
wecom?: Record<string, any>
feishu?: Record<string, any>
dingtalk?: Record<string, any>
qqbot?: Record<string, any>
platforms?: Record<string, any>
[key: string]: any
}
export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
const query = sections ? `?sections=${sections.join(',')}` : ''
return request<AppConfig>(`/api/hermes/config${query}`)
}
export async function updateConfigSection(
section: string,
values: Record<string, any>,
options?: { restart?: boolean },
): Promise<void> {
await request('/api/hermes/config', {
method: 'PUT',
body: JSON.stringify({ section, values, ...options }),
})
}
export async function saveCredentials(
platform: string,
values: Record<string, any>,
): Promise<void> {
await request('/api/hermes/config/credentials', {
method: 'PUT',
body: JSON.stringify({ platform, values }),
})
}
export interface WeixinQrCode {
qrcode: string
qrcode_url: string
}
export interface WeixinQrStatus {
status: 'wait' | 'scaned' | 'scaned_but_redirect' | 'expired' | 'confirmed'
account_id?: string
token?: string
base_url?: string
}
export async function fetchWeixinQrCode(): Promise<WeixinQrCode> {
return request<WeixinQrCode>('/api/hermes/weixin/qrcode')
}
export async function pollWeixinQrStatus(qrcode: string): Promise<WeixinQrStatus> {
return request<WeixinQrStatus>(`/api/hermes/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`)
}
export async function saveWeixinCredentials(data: {
account_id: string
token: string
base_url?: string
}): Promise<void> {
await request('/api/hermes/weixin/save', {
method: 'POST',
body: JSON.stringify(data),
})
}
@@ -0,0 +1,59 @@
import { request } from '../client'
export interface ConversationSummary {
id: string
source: string
model: string
provider?: string
title: string | null
started_at: number
ended_at: number | null
last_active: number
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
preview: string
is_active: boolean
thread_session_count: number
}
export interface ConversationMessage {
id: number | string
session_id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}
export interface ConversationDetail {
session_id: string
messages: ConversationMessage[]
visible_count: number
thread_session_count: number
}
export async function fetchConversationSummaries(params: { humanOnly?: boolean; source?: string; limit?: number } = {}): Promise<ConversationSummary[]> {
const query = new URLSearchParams()
if (params.humanOnly === false) query.set('humanOnly', 'false')
if (params.source) query.set('source', params.source)
if (params.limit != null) query.set('limit', String(params.limit))
const suffix = query.toString() ? `?${query.toString()}` : ''
const res = await request<{ sessions: ConversationSummary[] }>(`/api/hermes/sessions/conversations${suffix}`)
return res.sessions
}
export async function fetchConversationDetail(sessionId: string, params: { humanOnly?: boolean; source?: string } = {}): Promise<ConversationDetail> {
const query = new URLSearchParams()
if (params.humanOnly === false) query.set('humanOnly', 'false')
if (params.source) query.set('source', params.source)
const suffix = query.toString() ? `?${query.toString()}` : ''
return request<ConversationDetail>(`/api/hermes/sessions/conversations/${encodeURIComponent(sessionId)}/messages${suffix}`)
}
@@ -0,0 +1,42 @@
import { request } from '../client'
export type CopilotTokenSource = 'env' | 'gh-cli' | 'apps-json' | null
export interface CopilotStartResult {
session_id: string
user_code: string
verification_url: string
expires_in: number
interval: number
}
export interface CopilotPollResult {
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
error: string | null
}
export interface CopilotCheckTokenResult {
has_token: boolean
source: CopilotTokenSource
enabled: boolean
}
export async function startCopilotLogin(): Promise<CopilotStartResult> {
return request<CopilotStartResult>('/api/hermes/auth/copilot/start', { method: 'POST' })
}
export async function pollCopilotLogin(sessionId: string): Promise<CopilotPollResult> {
return request<CopilotPollResult>(`/api/hermes/auth/copilot/poll/${sessionId}`)
}
export async function checkCopilotToken(): Promise<CopilotCheckTokenResult> {
return request<CopilotCheckTokenResult>('/api/hermes/auth/copilot/check-token')
}
export async function enableCopilot(): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/hermes/auth/copilot/enable', { method: 'POST' })
}
export async function disableCopilot(): Promise<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }> {
return request<{ ok: boolean; cleared_env: boolean; cleared_default?: boolean }>('/api/hermes/auth/copilot/disable', { method: 'POST' })
}
@@ -0,0 +1,27 @@
import { request } from '../client'
export interface RunEntry {
jobId: string
fileName: string
runTime: string
size: number
}
export interface RunDetail {
jobId: string
fileName: string
runTime: string
content: string
}
export async function listCronRuns(jobId?: string): Promise<RunEntry[]> {
const params = new URLSearchParams()
if (jobId) params.set('jobId', jobId)
const qs = params.toString()
const res = await request<{ runs: RunEntry[] }>(`/api/cron-history${qs ? `?${qs}` : ''}`)
return res.runs
}
export async function readCronRun(jobId: string, fileName: string): Promise<RunDetail> {
return request<RunDetail>(`/api/cron-history/${encodeURIComponent(jobId)}/${encodeURIComponent(fileName)}`)
}
@@ -0,0 +1,71 @@
import { getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
/**
* Construct a download URL with auth token as query parameter.
* Token is passed via query param because <a> tags cannot set headers.
*/
export function getDownloadUrl(filePath: string, fileName?: string): string {
const base = getBaseUrlValue()
// Guard: if filePath is already a full download URL, extract the real path
// to prevent double-wrapping (/api/hermes/download?path=/api/hermes/download?path=...)
if (filePath.startsWith('/api/hermes/download?')) {
try {
const parsed = new URL(filePath, 'http://localhost')
const realPath = parsed.searchParams.get('path')
if (realPath) filePath = realPath
} catch {
// fall through with original filePath
}
}
// Decode the path first in case it's already encoded (e.g., from AI responses)
// URLSearchParams will encode it again, so we need to start with decoded text
const decodedPath = decodeURIComponent(filePath)
const params = new URLSearchParams({ path: decodedPath })
if (fileName) {
const decodedName = decodeURIComponent(fileName)
params.set('name', decodedName)
}
const profileName = getActiveProfileName()
if (profileName) params.set('profile', profileName)
const token = getApiKey()
if (token) params.set('token', token)
return `${base}/api/hermes/download?${params.toString()}`
}
/**
* Download a file. Uses fetch to detect errors, then creates a blob URL
* for the browser download. Throws with error message on failure.
*/
export async function downloadFile(filePath: string, fileName?: string): Promise<void> {
const url = getDownloadUrl(filePath, fileName)
const res = await fetch(url)
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Download failed: ${res.status}`)
}
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = blobUrl
a.download = fileName || filePath.split('/').pop() || 'download'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(blobUrl)
}
/**
* Get preview file content.
* Throws with error message on failure.
*/
export async function fetchFileText(filePath: string, fileName?: string): Promise<string> {
const url = getDownloadUrl(filePath, fileName)
const res = await fetch(url)
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Preview failed: ${res.status}`)
}
return res.text()
}
+107
View File
@@ -0,0 +1,107 @@
import { request, getActiveProfileName, getApiKey, getBaseUrlValue } from '../client'
export interface FileEntry {
name: string
path: string
absolutePath?: string
isDir: boolean
size: number
modTime: string
}
export interface FileStat {
name: string
path: string
absolutePath?: string
isDir: boolean
size: number
modTime: string
permissions?: string
}
export async function listFiles(path: string = ''): Promise<{ entries: FileEntry[]; path: string; absolutePath?: string }> {
const params = new URLSearchParams()
if (path) params.set('path', path)
const query = params.toString()
return request<{ entries: FileEntry[]; path: string }>(`/api/hermes/files/list${query ? `?${query}` : ''}`)
}
export async function statFile(path: string): Promise<FileStat> {
return request<FileStat>(`/api/hermes/files/stat?path=${encodeURIComponent(path)}`)
}
export async function readFile(path: string): Promise<{ content: string; path: string; size: number }> {
return request<{ content: string; path: string; size: number }>(`/api/hermes/files/read?path=${encodeURIComponent(path)}`)
}
export async function writeFile(path: string, content: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/write', {
method: 'PUT',
body: JSON.stringify({ path, content }),
})
}
export async function deleteFile(path: string, recursive: boolean = false): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/delete', {
method: 'DELETE',
body: JSON.stringify({ path, recursive }),
})
}
export async function renameFile(oldPath: string, newPath: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/rename', {
method: 'POST',
body: JSON.stringify({ oldPath, newPath }),
})
}
export async function mkDir(path: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/mkdir', {
method: 'POST',
body: JSON.stringify({ path }),
})
}
export async function copyFile(srcPath: string, destPath: string): Promise<void> {
await request<{ ok: boolean }>('/api/hermes/files/copy', {
method: 'POST',
body: JSON.stringify({ srcPath, destPath }),
})
}
export async function uploadFiles(targetDir: string, files: File[]): Promise<{ name: string; path: string }[]> {
const base = getBaseUrlValue()
const formData = new FormData()
for (const file of files) {
formData.append('file', file)
}
const params = new URLSearchParams()
if (targetDir) params.set('path', targetDir)
const query = params.toString()
const url = `${base}/api/hermes/files/upload${query ? `?${query}` : ''}`
const headers: Record<string, string> = {}
const token = getApiKey()
if (token) headers['Authorization'] = `Bearer ${token}`
const profileName = getActiveProfileName()
if (profileName) headers['X-Hermes-Profile'] = profileName
const res = await fetch(url, { method: 'POST', headers, body: formData })
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(body.error || `Upload failed: ${res.status}`)
}
const data = await res.json()
return data.files
}
export function getFileDownloadUrl(relativePath: string, fileName?: string): string {
const base = getBaseUrlValue()
const params = new URLSearchParams({ path: relativePath })
if (fileName) params.set('name', fileName)
const profileName = getActiveProfileName()
if (profileName) params.set('profile', profileName)
const token = getApiKey()
if (token) params.set('token', token)
return `${base}/api/hermes/download?${params.toString()}`
}
@@ -0,0 +1,235 @@
import { io } from 'socket.io-client'
import { request, getApiKey } from '../client'
// ─── Types ──────────────────────────────────────────────────
export interface RoomInfo {
id: string
name: string
inviteCode: string | null
triggerTokens?: number
maxHistoryTokens?: number
tailMessageCount?: number
totalTokens?: number
}
export interface RoomAgent {
id: string
roomId: string
agentId: string
profile: string
name: string
description: string
invited: number
}
export interface AgentAddResult {
profile: string
ok: boolean
agent?: RoomAgent
code?: string
error?: string
reason?: string
}
export interface ChatMessage {
id: string
roomId: string
senderId: string
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: 'streaming' | 'tool_calls' | 'error' | string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
isStreaming?: boolean
toolName?: string
toolCallId?: string
toolArgs?: string
toolPreview?: string
toolResult?: string
toolStatus?: 'running' | 'done' | 'error'
attachments?: Array<{ id: string; name: string; type: string; size: number; url: string }>
}
export interface MemberInfo {
id: string
userId: string
name: string
description: string
joinedAt: number
}
export interface JoinResult {
roomId: string
roomName: string
members: MemberInfo[]
messages: ChatMessage[]
rooms: string[]
}
// ─── Socket.IO Client ──────────────────────────────────────
let socket: ReturnType<typeof io> | null = null
export function connectGroupChat(opts?: { userId?: string; userName?: string; description?: string }): ReturnType<typeof io> {
if (socket?.connected) return socket
const token = getApiKey()
const userId = opts?.userId || localStorage.getItem('gc_user_id') || generateUUID()
localStorage.setItem('gc_user_id', userId)
socket = io('/group-chat', {
auth: {
token: token || undefined,
userId,
name: opts?.userName || localStorage.getItem('gc_user_name') || undefined,
description: opts?.description || localStorage.getItem('gc_user_description') || undefined,
},
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
randomizationFactor: 0.5,
timeout: 30000,
})
return socket
}
export function getStoredUserId(): string {
let id = localStorage.getItem('gc_user_id')
if (!id) {
id = generateUUID()
localStorage.setItem('gc_user_id', id)
}
return id
}
export function getStoredUserName(): string | null {
return localStorage.getItem('gc_user_name')
}
function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
export function getSocket(): ReturnType<typeof io> | null {
return socket?.connected ? socket : null
}
export function disconnectGroupChat(): void {
if (socket) {
socket.disconnect()
socket = null
}
}
// ─── REST API ───────────────────────────────────────────────
export async function createRoom(data: {
name: string
inviteCode: string
agents?: { profile: string; name?: string; description?: string; invited?: boolean }[]
compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }
}): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> {
return request('/api/hermes/group-chat/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
})
}
export async function listRooms(): Promise<{ rooms: RoomInfo[] }> {
return request('/api/hermes/group-chat/rooms')
}
export async function getRoomDetail(
roomId: string,
options: { offset?: number; limit?: number } = {},
): Promise<{ room: RoomInfo; messages: ChatMessage[]; agents: RoomAgent[]; members: MemberInfo[]; total?: number; offset?: number; limit?: number; hasMore?: boolean }> {
const params = new URLSearchParams()
if (options.offset != null) params.set('offset', String(options.offset))
if (options.limit != null) params.set('limit', String(options.limit))
const query = params.toString()
return request(`/api/hermes/group-chat/rooms/${roomId}${query ? `?${query}` : ''}`)
}
export async function joinRoomByCode(code: string): Promise<{ room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/join/${code}`)
}
export async function updateInviteCode(roomId: string, inviteCode: string): Promise<void> {
return request(`/api/hermes/group-chat/rooms/${roomId}/invite-code`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode }),
})
}
export async function addAgent(roomId: string, data: {
profile: string
name?: string
description?: string
invited?: boolean
}): Promise<{ agent: RoomAgent }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export async function listAgents(roomId: string): Promise<{ agents: RoomAgent[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents`)
}
export async function removeAgent(roomId: string, agentId: string): Promise<{ success: boolean; agents: RoomAgent[]; members: MemberInfo[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, {
method: 'DELETE',
})
}
export async function deleteRoom(roomId: string): Promise<void> {
return request(`/api/hermes/group-chat/rooms/${roomId}`, {
method: 'DELETE',
})
}
export async function clearRoomContext(roomId: string): Promise<{ success: boolean; room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/clear-context`, {
method: 'POST',
})
}
export async function updateRoomConfig(roomId: string, config: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): Promise<{ room: RoomInfo }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
}
export async function forceCompress(roomId: string): Promise<{ success: boolean; summary: string }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/compress`, {
method: 'POST',
})
}
+188
View File
@@ -0,0 +1,188 @@
import { request } from '../client'
export interface JobScheduleInterval {
kind: 'interval'
minutes: number
display: string
}
export interface JobScheduleCron {
kind: 'cron'
expr: string
display: string
}
export interface JobScheduleOnce {
kind: 'once'
run_at: string
display: string
}
type UnknownJobSchedule = {
kind: string
display?: string
expr?: string
minutes?: number
run_at?: string
}
export type JobSchedule = string | JobScheduleInterval | JobScheduleCron | JobScheduleOnce | UnknownJobSchedule
export interface Job {
job_id: string
id: string
name: string
prompt: string
prompt_preview?: string
skills: string[]
skill: string | null
model: string | null
provider: string | null
base_url: string | null
script: string | null
schedule: JobSchedule
schedule_display: string
repeat: string | { times: number | null; completed: number }
enabled: boolean
state: string
paused_at: string | null
paused_reason: string | null
created_at: string
next_run_at: string | null
last_run_at: string | null
last_status: string | null
last_error: string | null
deliver: string
origin: {
platform: string
chat_id: string
chat_name: string
thread_id: string | null
} | null
last_delivery_error: string | null
}
export interface CreateJobRequest {
name: string
schedule: string
prompt?: string
deliver?: string
skills?: string[]
repeat?: number
}
export interface UpdateJobRequest {
name?: string
schedule?: string
prompt?: string
deliver?: string
skills?: string[]
skill?: string
repeat?: number | null
enabled?: boolean
model?: string
provider?: string
}
export interface JobFormValues {
name: string
schedule: string
prompt: string
deliver: string
repeat_times: number | null
}
function unwrap(res: { job: Job }): Job {
return res.job
}
function isScheduleObject(schedule: JobSchedule | null | undefined): schedule is Exclude<JobSchedule, string> {
return typeof schedule === 'object' && schedule !== null
}
export function scheduleToEditableInput(schedule: JobSchedule | null | undefined, fallback = ''): string {
if (typeof schedule === 'string') return schedule
if (!isScheduleObject(schedule)) return fallback
if (schedule.kind === 'cron') return schedule.expr || schedule.display || fallback
if (schedule.kind === 'once') return schedule.run_at || schedule.display || fallback
if (schedule.kind === 'interval') {
return schedule.display || (typeof schedule.minutes === 'number' ? `every ${schedule.minutes}m` : fallback)
}
const unknownSchedule = schedule as UnknownJobSchedule
return unknownSchedule.expr || unknownSchedule.run_at || unknownSchedule.display || fallback
}
export function scheduleToDisplayText(schedule: JobSchedule | null | undefined, fallback = '—'): string {
if (typeof schedule === 'string') return schedule
if (!isScheduleObject(schedule)) return fallback
if (schedule.kind === 'cron') return schedule.expr || schedule.display || fallback
if (schedule.kind === 'interval') return schedule.display || scheduleToEditableInput(schedule, fallback)
if (schedule.kind === 'once') return schedule.display || scheduleToEditableInput(schedule, fallback)
const unknownSchedule = schedule as UnknownJobSchedule
return unknownSchedule.display || unknownSchedule.expr || unknownSchedule.run_at || fallback
}
export function jobRepeatToEditValue(repeat: Job['repeat']): number | null {
if (repeat && typeof repeat === 'object') return repeat.times ?? null
return null
}
export function buildJobUpdateRequest(original: Job, form: JobFormValues): UpdateJobRequest {
const payload: UpdateJobRequest = {}
const originalSchedule = scheduleToEditableInput(original.schedule, original.schedule_display || '')
const originalRepeat = jobRepeatToEditValue(original.repeat)
const originalDeliver = original.deliver || 'origin'
if (form.name !== original.name) payload.name = form.name
if (form.schedule !== originalSchedule) payload.schedule = form.schedule
if (form.prompt !== (original.prompt || '')) payload.prompt = form.prompt
if (form.deliver !== originalDeliver) payload.deliver = form.deliver
if (form.repeat_times !== originalRepeat) payload.repeat = form.repeat_times
return payload
}
export async function listJobs(): Promise<Job[]> {
const res = await request<{ jobs: Job[] }>('/api/hermes/jobs?include_disabled=true')
return res.jobs
}
export async function getJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`))
}
export async function createJob(data: CreateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>('/api/hermes/jobs', {
method: 'POST',
body: JSON.stringify(data),
}))
}
export async function updateJob(jobId: string, data: UpdateJobRequest): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}))
}
export async function deleteJob(jobId: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/jobs/${jobId}`, {
method: 'DELETE',
})
}
export async function pauseJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/pause`, { method: 'POST' }))
}
export async function resumeJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/resume`, { method: 'POST' }))
}
export async function runJob(jobId: string): Promise<Job> {
return unwrap(await request<{ job: Job }>(`/api/hermes/jobs/${jobId}/run`, { method: 'POST' }))
}
+442
View File
@@ -0,0 +1,442 @@
import { request, getApiKey, getBaseUrlValue } from '../client'
// ─── Types ──────────────────────────────────────────────────────
export type KanbanTaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
export interface KanbanTask {
id: string
title: string
body: string | null
assignee: string | null
status: KanbanTaskStatus
priority: number
created_by: string | null
created_at: number
started_at: number | null
completed_at: number | null
workspace_kind: string
workspace_path: string | null
tenant: string | null
result: string | null
skills: string[] | null
}
export interface KanbanRun {
id: number
task_id: string
profile: string | null
status: string
outcome: string | null
summary: string | null
error: string | null
metadata: Record<string, unknown> | null
worker_pid: number | null
started_at: number
ended_at: number | null
}
export interface KanbanComment {
id: number
task_id: string
author: string
body: string
created_at: number
}
export interface KanbanEvent {
id: number
task_id: string
kind: string
payload: Record<string, unknown> | null
created_at: number
run_id: number | null
}
export interface KanbanTaskMessage {
id: number | string
session_id: string
role: string
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export interface KanbanTaskSession {
id: string
title: string | null
source: string
model: string
started_at: number
ended_at: number | null
messages: KanbanTaskMessage[]
}
export interface KanbanTaskDetail {
task: KanbanTask
latest_summary: string | null
session?: KanbanTaskSession
comments: KanbanComment[]
events: KanbanEvent[]
runs: KanbanRun[]
parents?: string[]
children?: string[]
}
export interface KanbanStats {
by_status: Record<string, number>
by_assignee: Record<string, number>
total: number
}
export interface KanbanAssignee {
name: string
on_disk: boolean
counts: Record<string, number> | null
}
export interface KanbanBoard {
slug: string
name: string
description: string
icon: string
color: string
created_at: number | null
archived: boolean
db_path?: string
is_current?: boolean
counts: Record<string, number>
total: number
}
export interface KanbanBoardCreateRequest {
slug: string
name?: string
description?: string
icon?: string
color?: string
switchCurrent?: boolean
}
export interface KanbanCapabilityStatus {
key: string
status: 'supported' | 'partial' | 'missing'
reason?: string
canonicalRoute?: string
canonicalCommand?: string
requiresBoard: boolean
}
export interface KanbanCapabilities {
source: 'hermes-cli'
supports: Record<string, boolean>
missing: string[]
capabilities?: KanbanCapabilityStatus[]
}
export interface KanbanTaskLog {
task_id: string
path: string | null
exists: boolean
size_bytes: number
content: string
truncated: boolean
}
export interface KanbanCreateRequest {
title: string
body?: string
assignee?: string
priority?: number
tenant?: string
}
export interface KanbanBoardOptions {
board?: string
}
export interface KanbanListOptions extends KanbanBoardOptions {
status?: string
assignee?: string
tenant?: string
includeArchived?: boolean
}
export interface KanbanCommentCreateRequest {
body: string
author?: string
}
export interface KanbanTaskLogOptions extends KanbanBoardOptions {
tail?: number
}
export interface KanbanDiagnosticsOptions extends KanbanBoardOptions {
task?: string
severity?: 'warning' | 'error' | 'critical'
}
export interface KanbanReclaimOptions extends KanbanBoardOptions {
reason?: string
}
export interface KanbanReassignOptions extends KanbanBoardOptions {
reclaim?: boolean
reason?: string
}
export interface KanbanSpecifyOptions extends KanbanBoardOptions {
author?: string
}
export interface KanbanDispatchOptions extends KanbanBoardOptions {
dryRun?: boolean
max?: number
failureLimit?: number
}
export interface KanbanLinkRequest {
parent_id: string
child_id: string
}
export interface KanbanBulkUpdateRequest {
ids: string[]
status?: KanbanTaskStatus
assignee?: string | null
archive?: boolean
summary?: string
reason?: string
}
export interface KanbanBulkTaskResult {
id: string
ok: boolean
error?: string
}
function normalizedBoard(board?: string): string {
const trimmed = board?.trim()
return trimmed || 'default'
}
function activeProfileName(): string | null {
try {
return localStorage.getItem('hermes_active_profile_name')
} catch {
return null
}
}
function appendQuery(path: string, params: URLSearchParams): string {
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
function boardParams(board?: string): URLSearchParams {
const params = new URLSearchParams()
params.set('board', normalizedBoard(board))
return params
}
function websocketProtocol(base?: string): string {
if (base) return base.startsWith('https') ? 'wss:' : 'ws:'
return location.protocol === 'https:' ? 'wss:' : 'ws:'
}
function formatHostForPort(hostname: string, port: number): string {
if (hostname.startsWith('[') && hostname.endsWith(']')) return `${hostname}:${port}`
return hostname.includes(':') ? `[${hostname}]:${port}` : `${hostname}:${port}`
}
export function buildKanbanEventsWebSocketUrl(opts?: KanbanBoardOptions): string {
const base = getBaseUrlValue()
const params = boardParams(opts?.board)
const token = getApiKey()
if (token) params.set('token', token)
const profile = activeProfileName()
if (profile) params.set('profile', profile)
const path = `/api/hermes/kanban/events?${params.toString()}`
if (base) {
return `${websocketProtocol(base)}//${new URL(base).host}${path}`
}
const directDevPort = import.meta.env.VITE_HERMES_DIRECT_WS_PORT
const host = import.meta.env.DEV && directDevPort
? formatHostForPort(location.hostname, Number(directDevPort))
: location.host
return `${websocketProtocol()}//${host}${path}`
}
export function openKanbanEventStream(opts?: KanbanBoardOptions): WebSocket {
return new WebSocket(buildKanbanEventsWebSocketUrl(opts))
}
// ─── API functions ───────────────────────────────────────────────
export async function listBoards(opts?: { includeArchived?: boolean }): Promise<KanbanBoard[]> {
const params = new URLSearchParams()
if (opts?.includeArchived) params.set('includeArchived', 'true')
const res = await request<{ boards: KanbanBoard[] }>(appendQuery('/api/hermes/kanban/boards', params))
return res.boards
}
export async function createBoard(data: KanbanBoardCreateRequest): Promise<KanbanBoard> {
const res = await request<{ board: KanbanBoard }>('/api/hermes/kanban/boards', {
method: 'POST',
body: JSON.stringify(data),
})
return res.board
}
export async function archiveBoard(slug: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/hermes/kanban/boards/${encodeURIComponent(slug)}`, {
method: 'DELETE',
})
}
export async function getCapabilities(): Promise<KanbanCapabilities> {
const res = await request<{ capabilities: KanbanCapabilities }>('/api/hermes/kanban/capabilities')
return res.capabilities
}
export async function listTasks(opts?: KanbanListOptions): Promise<KanbanTask[]> {
const params = boardParams(opts?.board)
if (opts?.status) params.set('status', opts.status)
if (opts?.assignee) params.set('assignee', opts.assignee)
if (opts?.tenant) params.set('tenant', opts.tenant)
if (opts?.includeArchived) params.set('includeArchived', 'true')
const res = await request<{ tasks: KanbanTask[] }>(appendQuery('/api/hermes/kanban', params))
return res.tasks
}
export async function getTask(id: string, opts?: KanbanBoardOptions): Promise<KanbanTaskDetail> {
return request<KanbanTaskDetail>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(id)}`, boardParams(opts?.board)))
}
export async function createTask(data: KanbanCreateRequest, opts?: KanbanBoardOptions): Promise<KanbanTask> {
const res = await request<{ task: KanbanTask }>(appendQuery('/api/hermes/kanban', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
return res.task
}
export async function completeTasks(taskIds: string[], summary?: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/complete', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ task_ids: taskIds, summary }),
})
}
export async function blockTask(taskId: string, reason: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/block`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ reason }),
})
}
export async function unblockTasks(taskIds: string[], opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery('/api/hermes/kanban/unblock', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ task_ids: taskIds }),
})
}
export async function assignTask(taskId: string, profile: string, opts?: KanbanBoardOptions): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/assign`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ profile }),
})
}
export async function addComment(taskId: string, data: KanbanCommentCreateRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/comments`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function linkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function unlinkTasks(data: KanbanLinkRequest, opts?: KanbanBoardOptions): Promise<{ ok: boolean; output?: string }> {
const params = boardParams(opts?.board)
params.set('parent_id', data.parent_id)
params.set('child_id', data.child_id)
return request<{ ok: boolean; output?: string }>(appendQuery('/api/hermes/kanban/links', params), {
method: 'DELETE',
})
}
export async function bulkUpdateTasks(data: KanbanBulkUpdateRequest, opts?: KanbanBoardOptions): Promise<{ results: KanbanBulkTaskResult[] }> {
return request<{ results: KanbanBulkTaskResult[] }>(appendQuery('/api/hermes/kanban/tasks/bulk', boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getTaskLog(taskId: string, opts?: KanbanTaskLogOptions): Promise<KanbanTaskLog> {
const params = boardParams(opts?.board)
if (opts?.tail !== undefined) params.set('tail', String(opts.tail))
return request<KanbanTaskLog>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/log`, params))
}
export async function getDiagnostics(opts?: KanbanDiagnosticsOptions): Promise<unknown[]> {
const params = boardParams(opts?.board)
if (opts?.task) params.set('task', opts.task)
if (opts?.severity) params.set('severity', opts.severity)
const res = await request<{ diagnostics: unknown[] }>(appendQuery('/api/hermes/kanban/diagnostics', params))
return res.diagnostics
}
export async function reclaimTask(taskId: string, opts?: KanbanReclaimOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reclaim`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ reason: opts?.reason }),
})
}
export async function reassignTask(taskId: string, profile: string, opts?: KanbanReassignOptions): Promise<{ ok: boolean; output?: string }> {
return request<{ ok: boolean; output?: string }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/reassign`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ profile, reclaim: opts?.reclaim, reason: opts?.reason }),
})
}
export async function specifyTask(taskId: string, opts?: KanbanSpecifyOptions): Promise<unknown[]> {
const res = await request<{ results: unknown[] }>(appendQuery(`/api/hermes/kanban/${encodeURIComponent(taskId)}/specify`, boardParams(opts?.board)), {
method: 'POST',
body: JSON.stringify({ author: opts?.author }),
})
return res.results
}
export async function dispatch(opts?: KanbanDispatchOptions): Promise<unknown> {
const params = boardParams(opts?.board)
const res = await request<{ result: unknown }>(appendQuery('/api/hermes/kanban/dispatch', params), {
method: 'POST',
body: JSON.stringify({ dryRun: opts?.dryRun, max: opts?.max, failureLimit: opts?.failureLimit }),
})
return res.result
}
export async function getStats(opts?: KanbanBoardOptions): Promise<KanbanStats> {
const res = await request<{ stats: KanbanStats }>(appendQuery('/api/hermes/kanban/stats', boardParams(opts?.board)))
return res.stats
}
export async function getAssignees(opts?: KanbanBoardOptions): Promise<KanbanAssignee[]> {
const res = await request<{ assignees: KanbanAssignee[] }>(appendQuery('/api/hermes/kanban/assignees', boardParams(opts?.board)))
return res.assignees
}
+36
View File
@@ -0,0 +1,36 @@
import { request } from '../client'
export interface LogFileInfo {
name: string
size: string
modified: string
}
export interface LogEntry {
timestamp: string
level: string
logger: string
message: string
raw: string
}
export async function fetchLogFiles(): Promise<LogFileInfo[]> {
const res = await request<{ files: LogFileInfo[] }>('/api/hermes/logs')
return res.files
}
export async function fetchLogs(name: string, params?: {
lines?: number
level?: string
session?: string
since?: string
}): Promise<LogEntry[]> {
const query = new URLSearchParams()
if (params?.lines) query.set('lines', String(params.lines))
if (params?.level) query.set('level', params.level)
if (params?.session) query.set('session', params.session)
if (params?.since) query.set('since', params.since)
const qs = query.toString()
const res = await request<{ entries: (LogEntry | null)[] }>(`/api/hermes/logs/${name}${qs ? `?${qs}` : ''}`)
return res.entries.filter((e): e is LogEntry => e !== null)
}
+90
View File
@@ -0,0 +1,90 @@
import { request } from '../client'
export interface McpServerInfo {
name: string
transport: 'stdio' | 'http' | 'sse'
connected: boolean
tools: number
tools_registered: number
tool_names: string[]
tool_names_registered: string[]
tool_details: Array<{ name: string; description?: string }>
error?: string | null
raw_config: McpServerConfig
}
export interface McpServersResponse {
ok: boolean
servers: McpServerInfo[]
total_tools: number
error?: string
}
export interface McpToolsResponse {
ok: boolean
results: Array<{
server: string
tools: Array<{
name: string
description: string
input_schema: Record<string, unknown>
}>
}>
error?: string
}
export interface McpServerConfig {
command?: string
args?: string[]
url?: string
env?: Record<string, string>
headers?: Record<string, string>
timeout?: number
connect_timeout?: number
enabled?: boolean
transport?: 'stdio' | 'http' | 'sse'
tools?: { include?: string[]; exclude?: string[] }
prompts?: boolean
resources?: boolean
}
export async function fetchMcpServers(): Promise<McpServersResponse> {
return request<McpServersResponse>('/api/hermes/mcp/servers')
}
export async function fetchMcpTools(server?: string, raw?: boolean): Promise<McpToolsResponse> {
const params = new URLSearchParams()
if (server) params.set('server', server)
if (raw) params.set('raw', '1')
const query = params.toString() ? `?${params.toString()}` : ''
return request<McpToolsResponse>(`/api/hermes/mcp/tools${query}`)
}
export async function mcpServerAdd(name: string, config: McpServerConfig): Promise<{ ok: boolean; name?: string; error?: string }> {
return request('/api/hermes/mcp/servers', {
method: 'POST',
body: JSON.stringify({ name, config }),
})
}
export async function mcpServerRemove(name: string): Promise<{ ok: boolean; error?: string }> {
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
export async function mcpServerUpdate(name: string, config: McpServerConfig): Promise<{ ok: boolean; error?: string }> {
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify({ config }),
})
}
export async function mcpReload(name?: string): Promise<{ ok: boolean; message?: string; error?: string }> {
const query = name ? `?server=${encodeURIComponent(name)}` : ''
return request(`/api/hermes/mcp/reload${query}`, { method: 'POST' })
}
export async function mcpServerTest(name: string): Promise<{ ok: boolean; tools?: string[]; error?: string }> {
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}/test`, { method: 'POST' })
}
@@ -0,0 +1,41 @@
import { request } from '../client'
export interface ModelContext {
id: number
provider: string
model: string
context_limit: number
}
/**
* 根据 provider 和 model 查询模型上下文配置
*/
export async function getModelContext(provider: string, model: string): Promise<ModelContext | null> {
try {
const res = await request<{ data: ModelContext }>(
`/api/hermes/model-context?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`
)
return res.data
} catch (err: any) {
if (err.status === 404) return null
throw err
}
}
/**
* 设置模型上下文配置(UPSERT:存在则更新,不存在则插入)
*/
export async function setModelContext(
provider: string,
model: string,
contextLimit: number
): Promise<ModelContext> {
const res = await request<{ success: boolean; data: ModelContext }>(
`/api/hermes/model-context/${encodeURIComponent(provider)}/${encodeURIComponent(model)}`,
{
method: 'PUT',
body: JSON.stringify({ provider, model, context_limit: contextLimit }),
}
)
return res.data
}
@@ -0,0 +1,29 @@
import { request } from '../client'
export interface NousStartResult {
session_id: string
user_code: string
verification_url: string
expires_in: number
}
export interface NousPollResult {
status: 'pending' | 'approved' | 'denied' | 'expired' | 'error'
error: string | null
}
export interface NousStatusResult {
authenticated: boolean
}
export async function startNousLogin(): Promise<NousStartResult> {
return request<NousStartResult>('/api/hermes/auth/nous/start', { method: 'POST' })
}
export async function pollNousLogin(sessionId: string): Promise<NousPollResult> {
return request<NousPollResult>(`/api/hermes/auth/nous/poll/${sessionId}`)
}
export async function getNousAuthStatus(): Promise<NousStatusResult> {
return request<NousStatusResult>('/api/hermes/auth/nous/status')
}
@@ -0,0 +1,63 @@
import { request } from '../client'
export interface ProcessUsage {
pid: number
role: 'web' | 'broker' | 'worker'
profile?: string
running: boolean
cpuPercent: number
memoryRssBytes: number
command?: string
error?: string
}
export interface PerformanceRuntimeSnapshot {
timestamp: number
system: {
platform: string
arch: string
uptimeSeconds: number
cpuCount: number
cpuPercent: number
loadAverage: number[]
totalMemoryBytes: number
freeMemoryBytes: number
usedMemoryBytes: number
memoryPercent: number
}
web: {
pid: number
uptimeSeconds: number
memory: Record<string, number>
cpuPercent: number
}
bridge: {
endpoint: string
reachable: boolean
error?: string
broker: {
running: boolean
ready: boolean
pid?: number
process?: ProcessUsage
restartScheduled: boolean
restartAttempts: number
}
workers: Array<ProcessUsage & {
endpoint?: string
lastUsedAt?: number
sessionCount: number
runningSessionCount: number
}>
totalWorkerMemoryRssBytes: number
}
sessions: {
active: number
running: number
byProfile: Record<string, number>
}
}
export async function fetchPerformanceRuntime(): Promise<PerformanceRuntimeSnapshot> {
return request<PerformanceRuntimeSnapshot>('/api/hermes/performance/runtime')
}
+37
View File
@@ -0,0 +1,37 @@
import { request } from '../client'
export type PluginConfigStatus = 'enabled' | 'disabled' | 'not-enabled' | 'auto' | 'provider-managed'
export type PluginEffectiveStatus = 'enabled' | 'disabled' | 'inactive' | 'auto-active' | 'provider-managed'
export interface HermesPluginInfo {
key: string
name: string
kind: string
source: string
configStatus: PluginConfigStatus | string
effectiveStatus: PluginEffectiveStatus | string
version: string
description: string
author: string
path: string
providesTools: string[]
providesHooks: string[]
requiresEnv: Array<string | Record<string, unknown>>
}
export interface HermesPluginsMetadata {
hermesAgentRoot: string
pythonExecutable: string
cwd: string
projectPluginsEnabled: boolean
}
export interface HermesPluginsResponse {
plugins: HermesPluginInfo[]
warnings: string[]
metadata: HermesPluginsMetadata
}
export async function fetchPlugins(): Promise<HermesPluginsResponse> {
return request<HermesPluginsResponse>('/api/hermes/plugins')
}
+228
View File
@@ -0,0 +1,228 @@
import { request, getBaseUrlValue, getApiKey } from '../client'
export interface HermesProfile {
name: string
active: boolean
model: string
gatewayStatus?: string
alias: string
avatar?: ProfileAvatar | null
}
export interface HermesProfileDetail {
name: string
path: string
model: string
provider: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
avatar?: ProfileAvatar | null
}
export interface ProfileAvatar {
type: 'generated' | 'image'
seed?: string
dataUrl?: string
updatedAt?: number
}
export interface ProfileRuntimeStatus {
profile: string
bridge: {
running: boolean
profile: string
mode?: string
reachable?: boolean
error?: string
}
gateway: {
profile: string
running: boolean
pid?: number
port?: number
host?: string
url?: string
error?: string
diagnostics?: {
health_url?: string
reason?: string
health_ok?: boolean
}
}
}
export interface ProfileRuntimeStatusesResponse {
profiles: ProfileRuntimeStatus[]
refreshing?: boolean
}
export async function fetchProfiles(): Promise<HermesProfile[]> {
const res = await request<{ profiles: HermesProfile[] }>('/api/hermes/profiles')
return res.profiles
}
export async function fetchProfileDetail(name: string): Promise<HermesProfileDetail> {
const res = await request<{ profile: HermesProfileDetail }>(`/api/hermes/profiles/${encodeURIComponent(name)}`)
return res.profile
}
export async function fetchProfileRuntimeStatus(name: string): Promise<ProfileRuntimeStatus> {
return request<ProfileRuntimeStatus>(`/api/hermes/profiles/${encodeURIComponent(name)}/runtime-status`)
}
export async function fetchProfileRuntimeStatusesWithMeta(options: { refresh?: boolean } = {}): Promise<ProfileRuntimeStatusesResponse> {
const query = options.refresh === false ? '?refresh=0' : ''
return request<ProfileRuntimeStatusesResponse>(`/api/hermes/profiles/runtime-statuses${query}`)
}
export async function fetchProfileRuntimeStatuses(): Promise<ProfileRuntimeStatus[]> {
const res = await fetchProfileRuntimeStatusesWithMeta()
return res.profiles
}
export async function updateProfileAvatar(name: string, avatar: ProfileAvatar): Promise<ProfileAvatar> {
const res = await request<{ avatar: ProfileAvatar }>(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, {
method: 'PUT',
body: JSON.stringify(avatar),
})
return res.avatar
}
export async function deleteProfileAvatar(name: string): Promise<void> {
await request(`/api/hermes/profiles/${encodeURIComponent(name)}/avatar`, { method: 'DELETE' })
}
export async function restartProfileGateway(name: string): Promise<ProfileRuntimeStatus['gateway']> {
const res = await request<{ success: boolean; gateway: ProfileRuntimeStatus['gateway'] }>(
`/api/hermes/profiles/${encodeURIComponent(name)}/gateway/restart`,
{ method: 'POST' },
)
return res.gateway
}
export async function restartProfileRuntime(name: string): Promise<ProfileRuntimeStatus> {
const res = await request<{ success: boolean; status: ProfileRuntimeStatus }>(
`/api/hermes/profiles/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
)
return res.status
}
export interface CreateProfileResult {
success: boolean
/** clone=true 时被清理的独占平台凭据 KEY 名 */
strippedCredentials?: string[]
/** clone=true 时被禁用的独占平台名 */
disabledPlatforms?: string[]
/** clone=true 时在 config.yaml 中被清理的内嵌凭据字段路径 */
strippedConfigCredentials?: string[]
}
export async function createProfile(name: string, clone?: boolean): Promise<CreateProfileResult & { error?: string }> {
try {
const res = await request<{
success: boolean
strippedCredentials?: string[]
disabledPlatforms?: string[]
strippedConfigCredentials?: string[]
error?: string
}>('/api/hermes/profiles', {
method: 'POST',
body: JSON.stringify({ name, clone }),
})
return {
success: !!res.success,
strippedCredentials: res.strippedCredentials,
disabledPlatforms: res.disabledPlatforms,
strippedConfigCredentials: res.strippedConfigCredentials,
error: res.error,
}
} catch (err: any) {
return { success: false, error: err.message || 'Unknown error' }
}
}
export async function deleteProfile(name: string): Promise<boolean> {
try {
await request(`/api/hermes/profiles/${encodeURIComponent(name)}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
export async function renameProfile(name: string, newName: string): Promise<boolean> {
try {
await request(`/api/hermes/profiles/${encodeURIComponent(name)}/rename`, {
method: 'POST',
body: JSON.stringify({ new_name: newName }),
})
return true
} catch {
return false
}
}
export async function switchProfile(name: string): Promise<boolean> {
return !!name
}
export async function switchHermesProfile(name: string): Promise<boolean> {
try {
await request('/api/hermes/profiles/active', {
method: 'PUT',
body: JSON.stringify({ name }),
})
return true
} catch {
return false
}
}
export async function exportProfile(name: string): Promise<boolean> {
try {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${baseUrl}/api/hermes/profiles/${encodeURIComponent(name)}/export`, {
method: 'POST',
headers,
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `hermes-profile-${name}.tar.gz`
a.click()
URL.revokeObjectURL(url)
return true
} catch {
return false
}
}
export async function importProfile(file: File): Promise<boolean> {
try {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, {
method: 'POST',
headers,
body: formData,
})
return res.ok
} catch {
return false
}
}
+308
View File
@@ -0,0 +1,308 @@
import { request, getApiKey, getBaseUrlValue } from '../client'
export interface SessionSummary {
id: string
profile?: string | null
source: string
model: string
provider?: string
title: string | null
preview?: string
started_at: number
ended_at: number | null
last_active?: number
message_count: number
tool_call_count: number
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
billing_provider: string | null
estimated_cost_usd: number
actual_cost_usd: number | null
cost_status: string
workspace?: string | null
webui_imported?: boolean
}
export interface SessionDetail extends SessionSummary {
messages: HermesMessage[]
}
export interface PaginatedSessionMessages {
session: SessionSummary
messages: HermesMessage[]
total: number
offset: number
limit: number
hasMore: boolean
}
export interface SessionSearchResult extends SessionSummary {
matched_message_id: number | null
snippet: string
rank: number
}
export interface HermesMessage {
id: number
session_id: string
role: 'user' | 'assistant' | 'system' | 'tool' | 'command'
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
}
export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
}
/**
* Fetch Hermes sessions only (exclude api_server source)
*/
export async function fetchHermesSessions(source?: string, limit?: number, profile?: string | null): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions/hermes${query ? `?${query}` : ''}`)
return res.sessions
}
export async function searchSessions(q: string, source?: string, limit?: number, profile?: string): Promise<SessionSearchResult[]> {
const params = new URLSearchParams()
params.set('q', q)
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ results: SessionSearchResult[] }>(`/api/hermes/search/sessions?${query}`)
return res.results
}
export async function fetchSession(id: string, profile?: string | null): Promise<SessionDetail | null> {
try {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`)
return res.session
} catch {
return null
}
}
export async function fetchSessionMessagesPage(
id: string,
offset: number,
limit = 300,
profile?: string | null,
): Promise<PaginatedSessionMessages | null> {
try {
const params = new URLSearchParams()
params.set('offset', String(offset))
params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const res = await request<PaginatedSessionMessages>(
`/api/hermes/sessions/conversations/${encodeURIComponent(id)}/messages/paginated?${params}`,
)
return res
} catch {
return null
}
}
/**
* Fetch Hermes session detail only (exclude api_server source)
*/
export async function fetchHermesSession(id: string, profile?: string | null): Promise<SessionDetail | null> {
try {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ session: SessionDetail }>(`/api/hermes/sessions/hermes/${id}${query ? `?${query}` : ''}`)
return res.session
} catch {
return null
}
}
export async function deleteSession(id: string, profile?: string | null): Promise<boolean> {
try {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
await request(`/api/hermes/sessions/${id}${query ? `?${query}` : ''}`, { method: 'DELETE' })
return true
} catch {
return false
}
}
export async function importHermesSession(id: string, profile?: string | null): Promise<{ ok: boolean; imported: boolean; session?: SessionDetail }> {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
const query = params.toString()
return request<{ ok: boolean; imported: boolean; session?: SessionDetail }>(
`/api/hermes/sessions/hermes/${encodeURIComponent(id)}/import${query ? `?${query}` : ''}`,
{ method: 'POST' },
)
}
export interface BatchDeleteSessionTarget {
id: string
profile?: string | null
}
export async function batchDeleteSessions(targets: Array<string | BatchDeleteSessionTarget>): Promise<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }> {
try {
const sessions = targets.map(target =>
typeof target === 'string'
? { id: target }
: { id: target.id, profile: target.profile || undefined },
)
const res = await request<{ deleted: number; failed: number; errors: Array<{ id: string; error: string }> }>(
'/api/hermes/sessions/batch-delete',
{
method: 'POST',
body: JSON.stringify({
ids: sessions.map(session => session.id),
sessions,
}),
}
)
return res
} catch (err: any) {
throw err
}
}
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/rename`, {
method: 'POST',
body: JSON.stringify({ title }),
})
return true
} catch {
return false
}
}
export async function setSessionWorkspace(id: string, workspace: string | null): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/workspace`, {
method: 'POST',
body: JSON.stringify({ workspace: workspace || '' }),
})
return true
} catch {
return false
}
}
export async function setSessionModel(id: string, model: string, provider: string): Promise<boolean> {
try {
await request(`/api/hermes/sessions/${id}/model`, {
method: 'POST',
body: JSON.stringify({ model, provider }),
})
return true
} catch {
return false
}
}
export async function exportSession(id: string, mode: 'full' | 'compressed' = 'full', ext: 'json' | 'txt' = 'json'): Promise<void> {
const baseUrl = getBaseUrlValue()
const token = getApiKey()
const url = `${baseUrl}/api/hermes/sessions/${id}/export?mode=${mode}&ext=${ext}&token=${encodeURIComponent(token)}`
const res = await fetch(url)
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const contentDisposition = res.headers.get('Content-Disposition') || ''
let filename = `session_${id}.${ext}`
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\n]+)/i)
if (match) filename = decodeURIComponent(match[1].replace(/"/g, ''))
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
URL.revokeObjectURL(a.href)
}
export interface UsageStatsResponse {
total_input_tokens: number
total_output_tokens: number
total_cache_read_tokens: number
total_cache_write_tokens: number
total_reasoning_tokens: number
total_sessions: number
total_cost: number
total_api_calls?: number
period_days?: number
model_usage: Array<{
model: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
reasoning_tokens: number
sessions: number
}>
daily_usage: Array<{
date: string
input_tokens: number
output_tokens: number
cache_read_tokens: number
cache_write_tokens: number
sessions: number
errors: number
cost: number
}>
}
export async function fetchUsageStats(days = 30): Promise<UsageStatsResponse> {
const safeDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30
const params = new URLSearchParams()
params.set('days', String(safeDays))
return request<UsageStatsResponse>(`/api/hermes/usage/stats?${params}`)
}
export async function fetchSessionUsage(ids: string[]): Promise<Record<string, { input_tokens: number; output_tokens: number }>> {
if (ids.length === 0) return {}
const params = new URLSearchParams()
params.set('ids', ids.join(','))
return request(`/api/hermes/sessions/usage?${params}`)
}
export async function fetchSessionUsageSingle(id: string): Promise<{ input_tokens: number; output_tokens: number } | null> {
try {
return await request<{ input_tokens: number; output_tokens: number }>(`/api/hermes/sessions/${id}/usage`)
} catch {
return null
}
}
export async function fetchContextLength(profile?: string, provider?: string, model?: string): Promise<number> {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
if (provider) params.set('provider', provider)
if (model) params.set('model', model)
const query = params.toString()
const res = await request<{ context_length: number }>(`/api/hermes/sessions/context-length${query ? `?${query}` : ''}`)
return res.context_length
}
+127
View File
@@ -0,0 +1,127 @@
import { request } from '../client'
export type SkillSource = 'builtin' | 'hub' | 'local' | 'external'
export interface SkillInfo {
name: string
description: string
enabled?: boolean
source?: SkillSource
modified?: boolean
patchCount?: number
useCount?: number
viewCount?: number
pinned?: boolean
}
export interface SkillCategory {
name: string
description: string
skills: SkillInfo[]
}
export interface SkillListResponse {
categories: SkillCategory[]
archived: SkillInfo[]
}
export interface SkillFileEntry {
path: string
name: string
isDir: boolean
}
export interface MemoryData {
memory: string
user: string
soul: string
memory_mtime: number | null
user_mtime: number | null
soul_mtime: number | null
}
export interface SkillsData {
categories: SkillCategory[]
archived: SkillInfo[]
}
export interface SkillUsageRow {
skill: string
view_count: number
manage_count: number
total_count: number
percentage: number
last_used_at: number | null
}
export interface SkillUsageDailySkillRow {
skill: string
view_count: number
manage_count: number
total_count: number
}
export interface SkillUsageDailyRow {
date: string
view_count: number
manage_count: number
total_count: number
skills: SkillUsageDailySkillRow[]
}
export interface SkillUsageStats {
period_days: number
summary: {
total_skill_loads: number
total_skill_edits: number
total_skill_actions: number
distinct_skills_used: number
}
by_day: SkillUsageDailyRow[]
top_skills: SkillUsageRow[]
}
export async function fetchSkills(): Promise<SkillsData> {
const res = await request<SkillListResponse>('/api/hermes/skills')
return { categories: res.categories, archived: res.archived ?? [] }
}
export async function fetchSkillUsageStats(days = 7): Promise<SkillUsageStats> {
const params = new URLSearchParams({ days: String(days) })
return request<SkillUsageStats>(`/api/hermes/skills/usage/stats?${params}`)
}
export async function fetchSkillContent(skillPath: string): Promise<string> {
const res = await request<{ content: string }>(`/api/hermes/skills/${skillPath}`)
return res.content
}
export async function fetchSkillFiles(category: string, skill: string): Promise<SkillFileEntry[]> {
const res = await request<{ files: SkillFileEntry[] }>(`/api/hermes/skills/${category}/${skill}/files`)
return res.files
}
export async function fetchMemory(): Promise<MemoryData> {
return request<MemoryData>('/api/hermes/memory')
}
export async function saveMemory(section: 'memory' | 'user' | 'soul', content: string): Promise<void> {
await request('/api/hermes/memory', {
method: 'POST',
body: JSON.stringify({ section, content }),
})
}
export async function toggleSkill(name: string, enabled: boolean): Promise<void> {
await request('/api/hermes/skills/toggle', {
method: 'PUT',
body: JSON.stringify({ name, enabled }),
})
}
export async function pinSkillApi(name: string, pinned: boolean): Promise<void> {
await request('/api/hermes/skills/pin', {
method: 'PUT',
body: JSON.stringify({ name, pinned }),
})
}
+258
View File
@@ -0,0 +1,258 @@
import { request } from '../client'
export interface HealthResponse {
status: string
version?: string
webui_version?: string
webui_latest?: string
webui_update_available?: boolean
node_version?: string
}
export interface PreviewTag {
name: string
sha: string
}
export interface PreviewStatus {
preview_dir: string
exists: boolean
has_package: boolean
installed: boolean
running: boolean
pid: number | null
current_tag: string
frontend_url: string
agent_bridge_endpoint: string
log_path: string
webui_home: string
action_log_path: string
dev_log_path: string
active_action: string | null
active_action_started_at: string | null
last_action: string | null
last_action_completed_at: string | null
last_action_success: boolean | null
last_action_message: string
last_action_code: string
action_log: string
dev_log: string
}
export interface PreviewActionResponse extends PreviewStatus {
success: boolean
accepted?: boolean
message?: string
code?: string
}
// Config-based model types
export interface ModelInfo {
id: string
label: string
}
export interface ModelGroup {
provider: string
models: ModelInfo[]
}
export interface ConfigModelsResponse {
default: string
groups: ModelGroup[]
}
export interface ModelVisibilityRule {
mode: 'all' | 'include'
models: string[]
}
export type ModelVisibility = Record<string, ModelVisibilityRule>
export type CustomModels = Record<string, string[]>
export interface AvailableModelGroup {
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
label: string // display name (e.g. "zai", "subrouter.ai")
base_url: string
models: string[]
/** Full unfiltered model catalog for this provider, used to restore hidden WUI models. */
available_models?: string[]
api_key: string
builtin?: boolean
/** Env var used by Hermes to override this provider's base URL. If present, the preset URL is editable. */
base_url_env?: string
/** 可选:模型 ID -> 元数据(preview/disabled/alias)。alias 仅用于 Web UI 展示。 */
model_meta?: Record<string, { preview?: boolean; disabled?: boolean; alias?: string }>
}
export interface ProfileAvailableModels {
profile: string
default: string
default_provider: string
groups: AvailableModelGroup[]
}
export interface AvailableModelsResponse {
default: string
default_provider: string
groups: AvailableModelGroup[]
allProviders: AvailableModelGroup[]
profiles?: ProfileAvailableModels[]
/** Web UI-only display aliases keyed by provider -> canonical model ID. */
model_aliases?: Record<string, Record<string, string>>
model_visibility?: ModelVisibility
custom_models?: CustomModels
}
export interface CustomProvider {
name: string
base_url: string
api_key: string
model: string
context_length?: number
providerKey?: string | null
}
export async function checkHealth(): Promise<HealthResponse> {
return request<HealthResponse>('/health')
}
export async function triggerUpdate(): Promise<{ success: boolean; message: string }> {
return request<{ success: boolean; message: string }>('/api/hermes/update', { method: 'POST' })
}
export async function fetchPreviewStatus(): Promise<PreviewStatus> {
return request<PreviewStatus>('/api/hermes/update/preview')
}
export async function fetchPreviewTags(): Promise<{ tags: PreviewTag[] }> {
return request<{ tags: PreviewTag[] }>('/api/hermes/update/preview/tags')
}
export async function preparePreview(tag: string): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/prepare', {
method: 'POST',
body: JSON.stringify({ tag }),
})
}
export async function installPreview(): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/install', { method: 'POST' })
}
export async function startPreview(tag?: string): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/start', {
method: 'POST',
body: JSON.stringify({ tag }),
})
}
export async function stopPreview(): Promise<PreviewActionResponse> {
return request<PreviewActionResponse>('/api/hermes/update/preview/stop', { method: 'POST' })
}
export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
return request<ConfigModelsResponse>('/api/hermes/config/models')
}
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
return request<AvailableModelsResponse>('/api/hermes/available-models')
}
export async function fetchAvailableModelsForProfile(profile: string): Promise<AvailableModelsResponse> {
const params = new URLSearchParams()
params.set('profile', profile || 'default')
return request<AvailableModelsResponse>(`/api/hermes/available-models?${params.toString()}`)
}
export async function fetchProviderModels(data: {
base_url: string
api_key?: string
freeOnly?: boolean
}): Promise<{ models: string[] }> {
return request<{ models: string[] }>('/api/hermes/provider-models', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateDefaultModel(data: {
default: string
provider?: string
base_url?: string
api_key?: string
}): Promise<void> {
await request('/api/hermes/config/model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function updateModelAlias(data: {
provider: string
model: string
alias: string
}): Promise<void> {
await request('/api/hermes/model-alias', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomProvider(data: CustomProvider): Promise<void> {
await request('/api/hermes/config/providers', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function removeCustomProvider(name: string): Promise<void> {
await request(`/api/hermes/config/providers/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
export async function updateProvider(poolKey: string, data: {
name?: string
base_url?: string
api_key?: string
model?: string
}): Promise<void> {
await request(`/api/hermes/config/providers/${encodeURIComponent(poolKey)}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function updateModelVisibility(data: {
provider: string
mode: 'all' | 'include'
models: string[]
}): Promise<{ success: boolean; model_visibility: ModelVisibility }> {
return request<{ success: boolean; model_visibility: ModelVisibility }>('/api/hermes/model-visibility', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function addCustomModel(data: {
provider: string
model: string
}): Promise<{ success: boolean; custom_models: CustomModels }> {
return request<{ success: boolean; custom_models: CustomModels }>('/api/hermes/custom-model', {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function removeCustomModel(data: {
provider: string
model: string
}): Promise<{ success: boolean; custom_models: CustomModels }> {
const params = new URLSearchParams()
params.set('provider', data.provider)
params.set('model', data.model)
return request<{ success: boolean; custom_models: CustomModels }>(`/api/hermes/custom-model?${params.toString()}`, {
method: 'DELETE',
})
}
+36
View File
@@ -0,0 +1,36 @@
export interface TtsOptions {
text: string
lang?: string
rate?: string // Edge TTS rate format: "+NN%" or "-NN%"
pitch?: string // Edge TTS pitch format: "+NNHz" or "-NNHz"
}
export async function generateSpeech(opts: TtsOptions): Promise<{ audio: Blob; engine: string }> {
const res = await fetch(
`${localStorage.getItem('hermes_server_url') || ''}/api/hermes/tts`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('hermes_api_key') || ''}`,
},
body: JSON.stringify(opts),
},
)
if (!res.ok) {
throw new Error(`TTS request failed: ${res.status}`)
}
const audio = await res.blob()
const engine = res.headers.get('X-TTS-Engine') || 'unknown'
return { audio, engine }
}
export function playAudioBlob(blob: Blob): HTMLAudioElement {
const url = URL.createObjectURL(blob)
const audio = new Audio(url)
audio.play()
audio.onended = () => URL.revokeObjectURL(url)
return audio
}
@@ -0,0 +1,29 @@
import { request } from '../client'
export interface XaiStartResult {
session_id: string
authorization_url: string
expires_in: number
}
export interface XaiPollResult {
status: 'pending' | 'approved' | 'expired' | 'error'
error: string | null
}
export interface XaiStatusResult {
authenticated: boolean
last_refresh?: string
}
export async function startXaiLogin(): Promise<XaiStartResult> {
return request<XaiStartResult>('/api/hermes/auth/xai/start', { method: 'POST' })
}
export async function pollXaiLogin(sessionId: string): Promise<XaiPollResult> {
return request<XaiPollResult>(`/api/hermes/auth/xai/poll/${sessionId}`)
}
export async function getXaiAuthStatus(): Promise<XaiStatusResult> {
return request<XaiStatusResult>('/api/hermes/auth/xai/status')
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Some files were not shown because too many files have changed in this diff Show More