feat: 灵犀 Studio Web UI 定制版
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
hermes_data
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'"
|
||||||
@@ -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/
|
||||||
@@ -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 + SQLite,Monorepo 结构已确认。
|
||||||
|
|
||||||
|
### 步骤 3:Docker配置生成
|
||||||
|
- **状态**:✅ 已完成
|
||||||
|
- **完成时间**: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 账户。
|
||||||
|
|
||||||
|
### 步骤 7:CORS配置检查
|
||||||
|
- **状态**:✅ 已完成
|
||||||
|
- **完成时间**: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
|
||||||
|
- **说明**:所有变更已提交至仓库。
|
||||||
|
|
||||||
|
## 错误记录
|
||||||
|
|
||||||
|
无
|
||||||
|
|
||||||
|
## 备注
|
||||||
|
|
||||||
|
无
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 错误记录
|
||||||
|
|
||||||
|
## 错误列表
|
||||||
|
|
||||||
|
(暂无错误)
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
|
```
|
||||||
@@ -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 []
|
||||||
@@ -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.
|
||||||
@@ -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 使用本地 SQLite;Agent 状态与 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/)
|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目采用 [BSL-1.1](./LICENSE) 许可证。
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 endpoint;Windows 默认 `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 使用 TCP,macOS/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` 反馈。
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 789 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 295 KiB |
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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 |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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 仓库描述与目录信息。
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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' }))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 212 KiB |