diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c66535e1..cc7a5558a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -36,6 +38,9 @@ jobs: 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 diff --git a/.github/workflows/desktop-mac-update-manifest.yml b/.github/workflows/desktop-mac-update-manifest.yml new file mode 100644 index 000000000..e86b634d1 --- /dev/null +++ b/.github/workflows/desktop-mac-update-manifest.yml @@ -0,0 +1,111 @@ +name: Repair macOS Update Manifest + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to update (e.g. v0.6.11)" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: desktop-mac-update-manifest-${{ github.event.inputs.tag }} + cancel-in-progress: false + +jobs: + repair: + name: Generate and upload latest-mac.yml + runs-on: ubuntu-22.04 + steps: + - name: Download macOS release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + mkdir -p /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-arm64.zip' --dir /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-arm64.dmg' --dir /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-x64.zip' --dir /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-x64.dmg' --dir /tmp/hermes-mac-assets + ls -lh /tmp/hermes-mac-assets + + - name: Generate merged latest-mac.yml + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const { createHash } = require('node:crypto') + const { readdirSync, readFileSync, statSync, writeFileSync } = require('node:fs') + const { join } = require('node:path') + + const dir = '/tmp/hermes-mac-assets' + const names = readdirSync(dir) + const byKey = new Map() + let version = null + + for (const name of names) { + const match = /^Hermes\.Studio-(.+)-(arm64|x64)\.(zip|dmg)$/.exec(name) + if (!match) continue + if (version && version !== match[1]) { + throw new Error(`Mixed macOS asset versions: ${version} and ${match[1]}`) + } + version = match[1] + byKey.set(`${match[2]}.${match[3]}`, name) + } + + if (!version) throw new Error('No macOS release assets found') + + const order = ['arm64.zip', 'arm64.dmg', 'x64.zip', 'x64.dmg'] + const missing = order.filter(key => !byKey.has(key)) + if (missing.length > 0) { + throw new Error(`Missing macOS release assets: ${missing.join(', ')}`) + } + + const entries = order.map(key => { + const name = byKey.get(key) + const file = join(dir, name) + return { + url: name, + sha512: createHash('sha512').update(readFileSync(file)).digest('base64'), + size: statSync(file).size, + } + }) + + const head = entries[0] + const lines = [`version: ${version}`, 'files:'] + for (const entry of entries) { + lines.push(` - url: ${entry.url}`) + lines.push(` sha512: ${entry.sha512}`) + lines.push(` size: ${entry.size}`) + } + lines.push(`path: ${head.url}`) + lines.push(`sha512: ${head.sha512}`) + lines.push(`releaseDate: '${new Date().toISOString()}'`) + writeFileSync('/tmp/latest-mac.yml', `${lines.join('\n')}\n`) + NODE + cat /tmp/latest-mac.yml + + - name: Upload merged macOS update manifest to release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + gh release upload "$TAG" /tmp/latest-mac.yml --repo "$GITHUB_REPOSITORY" --clobber + + asset_url="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[] | select(.name == "latest-mac.yml") | .apiUrl')" + if [ -z "$asset_url" ]; then + echo "Uploaded latest-mac.yml was not found on release ${TAG}" >&2 + exit 1 + fi + curl -fsSL \ + -H "Accept: application/octet-stream" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + "$asset_url" > /tmp/latest-mac-uploaded.yml + diff -u /tmp/latest-mac.yml /tmp/latest-mac-uploaded.yml diff --git a/.github/workflows/desktop-manual-build.yml b/.github/workflows/desktop-manual-build.yml new file mode 100644 index 000000000..969c6a75d --- /dev/null +++ b/.github/workflows/desktop-manual-build.yml @@ -0,0 +1,327 @@ +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<> "$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" + ;; + 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" + ;; + 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<> "$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 }} + + mac-update-manifest: + name: Repair macOS update manifest + needs: [validate, desktop] + if: needs.validate.outputs.target_os == 'darwin' && github.event.inputs.release_tag != '' + runs-on: ubuntu-22.04 + steps: + - name: Check macOS release asset completeness + id: check + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.release_tag }} + run: | + set -euo pipefail + assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')" + missing=0 + for pattern in \ + '^Hermes\.Studio-.+-arm64\.zip$' \ + '^Hermes\.Studio-.+-arm64\.dmg$' \ + '^Hermes\.Studio-.+-x64\.zip$' \ + '^Hermes\.Studio-.+-x64\.dmg$' + do + if ! printf '%s\n' "$assets" | grep -E "$pattern" >/dev/null; then + missing=1 + echo "Missing macOS release asset matching ${pattern}" + fi + done + if [ "$missing" -eq 0 ]; then + echo "complete=true" >> "$GITHUB_OUTPUT" + else + echo "complete=false" >> "$GITHUB_OUTPUT" + echo "Both macOS architectures are not available yet; leaving latest-mac.yml unchanged." + fi + + - name: Download macOS release assets + if: steps.check.outputs.complete == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.release_tag }} + run: | + set -euo pipefail + mkdir -p /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-arm64.zip' --dir /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-arm64.dmg' --dir /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-x64.zip' --dir /tmp/hermes-mac-assets + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'Hermes.Studio-*-x64.dmg' --dir /tmp/hermes-mac-assets + ls -lh /tmp/hermes-mac-assets + + - name: Generate merged latest-mac.yml + if: steps.check.outputs.complete == 'true' + shell: bash + run: | + set -euo pipefail + node <<'NODE' + const { createHash } = require('node:crypto') + const { readdirSync, readFileSync, statSync, writeFileSync } = require('node:fs') + const { join } = require('node:path') + + const dir = '/tmp/hermes-mac-assets' + const names = readdirSync(dir) + const byKey = new Map() + let version = null + + for (const name of names) { + const match = /^Hermes\.Studio-(.+)-(arm64|x64)\.(zip|dmg)$/.exec(name) + if (!match) continue + if (version && version !== match[1]) { + throw new Error(`Mixed macOS asset versions: ${version} and ${match[1]}`) + } + version = match[1] + byKey.set(`${match[2]}.${match[3]}`, name) + } + + if (!version) throw new Error('No macOS release assets found') + + const order = ['arm64.zip', 'arm64.dmg', 'x64.zip', 'x64.dmg'] + const missing = order.filter(key => !byKey.has(key)) + if (missing.length > 0) { + throw new Error(`Missing macOS release assets: ${missing.join(', ')}`) + } + + const entries = order.map(key => { + const name = byKey.get(key) + const file = join(dir, name) + return { + url: name, + sha512: createHash('sha512').update(readFileSync(file)).digest('base64'), + size: statSync(file).size, + } + }) + + const head = entries[0] + const lines = [`version: ${version}`, 'files:'] + for (const entry of entries) { + lines.push(` - url: ${entry.url}`) + lines.push(` sha512: ${entry.sha512}`) + lines.push(` size: ${entry.size}`) + } + lines.push(`path: ${head.url}`) + lines.push(`sha512: ${head.sha512}`) + lines.push(`releaseDate: '${new Date().toISOString()}'`) + writeFileSync('/tmp/latest-mac.yml', `${lines.join('\n')}\n`) + NODE + cat /tmp/latest-mac.yml + + - name: Upload merged macOS update manifest to release + if: steps.check.outputs.complete == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.release_tag }} + run: | + set -euo pipefail + gh release upload "$TAG" /tmp/latest-mac.yml --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 000000000..2cb4c34c1 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,218 @@ +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 + +permissions: + contents: write + +concurrency: + group: desktop-release-${{ github.event.inputs.tag }} + 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.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<> "$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.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.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.inputs.tag }} + fail_on_unmatched_files: true + files: /tmp/latest-mac.yml + + mark-latest: + name: Mark desktop release as latest + needs: mac-update-manifest + runs-on: ubuntu-22.04 + steps: + - name: Mark release as latest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest diff --git a/.github/workflows/desktop-runtime.yml b/.github/workflows/desktop-runtime.yml new file mode 100644 index 000000000..37d416b5e --- /dev/null +++ b/.github/workflows/desktop-runtime.yml @@ -0,0 +1,130 @@ +name: Publish Desktop Runtime to Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to attach runtime assets to" + required: true + hermes_version: + description: "Hermes Agent version to package (defaults to repository runtime config)" + required: false + type: string + +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 }} + HERMES_VERSION: ${{ github.event.inputs.hermes_version }} + 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_VERSION: ${{ github.event.inputs.hermes_version }} + 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 }} + HERMES_VERSION: ${{ github.event.inputs.hermes_version }} + 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 }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index fcb57e9dd..1039c6272 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,7 +6,7 @@ on: types: [published] permissions: - contents: read + contents: write concurrency: group: docker-${{ github.ref }} @@ -44,3 +44,18 @@ jobs: ${{ 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 }} + + keep-release-out-of-latest: + name: Keep release out of GitHub latest + if: always() && github.event_name == 'release' + needs: build-and-push + runs-on: ubuntu-latest + steps: + - name: Mark release as not latest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest=false diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml new file mode 100644 index 000000000..47bdd6e4f --- /dev/null +++ b/.github/workflows/website-deploy.yml @@ -0,0 +1,96 @@ +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: + inputs: + download_version: + description: "Optional release/download version used for website download links (defaults to package.json)" + required: false + type: string + +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 + env: + WEBSITE_DOWNLOAD_VERSION: ${{ github.event.inputs.download_version }} + 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'" diff --git a/.github/workflows/webui-release.yml b/.github/workflows/webui-release.yml new file mode 100644 index 000000000..2877de755 --- /dev/null +++ b/.github/workflows/webui-release.yml @@ -0,0 +1,108 @@ +name: Publish Web UI Artifact to Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to attach the Web UI artifact to" + required: true + release: + types: [published] + +permissions: + contents: write + +concurrency: + group: webui-release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + webui: + name: Web UI + 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: 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: Build Web UI + run: npm run build + + - name: Keep production dependencies only + run: npm prune --omit=dev --no-audit --no-fund + + - name: Package Web UI + id: package + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./package.json').version")" + asset="hermes-web-ui-${version}.tar.gz" + manifest="hermes-web-ui-${version}.json" + mkdir -p release/webui /tmp/hermes-web-ui-package/webui + + cp package.json package-lock.json /tmp/hermes-web-ui-package/webui/ + cp -R bin dist node_modules /tmp/hermes-web-ui-package/webui/ + + tar -czf "release/webui/${asset}" -C /tmp/hermes-web-ui-package webui + sha256="$(shasum -a 256 "release/webui/${asset}" | awk '{print $1}')" + size="$(stat -c%s "release/webui/${asset}")" + printf '%s %s\n' "$sha256" "$asset" > "release/webui/${asset}.sha256" + + node - <<'NODE' "$version" "$asset" "$sha256" "$size" "release/webui/${manifest}" + const { writeFileSync } = require('node:fs') + const [version, asset, sha256, size, manifest] = process.argv.slice(2) + writeFileSync(manifest, JSON.stringify({ + schema: 1, + webUiVersion: version, + createdAt: new Date().toISOString(), + asset: { + name: asset, + sha256, + size: Number(size), + }, + }, null, 2) + '\n') + NODE + + echo "asset=${asset}" >> "$GITHUB_OUTPUT" + echo "manifest=${manifest}" >> "$GITHUB_OUTPUT" + + - name: Upload Web UI artifact to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name || github.event.inputs.tag }} + make_latest: false + fail_on_unmatched_files: true + files: | + release/webui/${{ steps.package.outputs.asset }} + release/webui/${{ steps.package.outputs.asset }}.sha256 + release/webui/${{ steps.package.outputs.manifest }} + + keep-release-out-of-latest: + name: Keep release out of GitHub latest + if: always() && github.event_name == 'release' + needs: webui + runs-on: ubuntu-22.04 + steps: + - name: Mark release as not latest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --latest=false diff --git a/.gitignore b/.gitignore index b2354a5dc..b2544d4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,11 @@ pnpm-workspace.yaml packages/server/data/ packages/server/node_modules/ .hermes-web-ui/ +packages/desktop/dist/ +packages/desktop/release/ +packages/desktop/resources/ +packages/desktop/resources/python/ +packages/desktop/node_modules/ # Hermes config files (should be in user data directory, not project root) config.yaml @@ -44,3 +49,4 @@ hermes-dependencies.md CLAUDE.md # Client source map artifacts packages/client/src/**/*.js +.hermes/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..3afb5145b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..4578f879e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,95 @@ +# 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 ` - \ No newline at end of file + diff --git a/packages/client/public/coding-agents/claude-code.svg b/packages/client/public/coding-agents/claude-code.svg new file mode 100644 index 000000000..b4a213139 --- /dev/null +++ b/packages/client/public/coding-agents/claude-code.svg @@ -0,0 +1,7 @@ + + Claude Code + + \ No newline at end of file diff --git a/packages/client/public/coding-agents/codex-openai.png b/packages/client/public/coding-agents/codex-openai.png new file mode 100644 index 000000000..44fc39caf Binary files /dev/null and b/packages/client/public/coding-agents/codex-openai.png differ diff --git a/packages/client/public/coding-agents/hermes.png b/packages/client/public/coding-agents/hermes.png new file mode 100644 index 000000000..5d234213d Binary files /dev/null and b/packages/client/public/coding-agents/hermes.png differ diff --git a/packages/client/public/notification-sw.js b/packages/client/public/notification-sw.js new file mode 100644 index 000000000..5cd9fa0cb --- /dev/null +++ b/packages/client/public/notification-sw.js @@ -0,0 +1,10 @@ +self.addEventListener('notificationclick', (event) => { + event.notification.close() + event.waitUntil((async () => { + const windows = await clients.matchAll({ type: 'window', includeUncontrolled: true }) + for (const client of windows) { + if ('focus' in client) return client.focus() + } + if (clients.openWindow) return clients.openWindow('/') + })()) +}) diff --git a/packages/client/src/App.vue b/packages/client/src/App.vue index 896a44a76..e4c8347e0 100644 --- a/packages/client/src/App.vue +++ b/packages/client/src/App.vue @@ -1,11 +1,12 @@ + diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue index 9747c9450..c962ce789 100644 --- a/packages/client/src/components/hermes/chat/TerminalPanel.vue +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -12,7 +12,7 @@ import type { ITheme } from "@xterm/xterm"; const { t } = useI18n(); const message = useMessage(); -const props = defineProps<{ visible?: boolean }>(); +const props = defineProps<{ visible?: boolean; initialCommand?: string }>(); // ─── Terminal themes ──────────────────────────────────────────── @@ -106,6 +106,10 @@ const MAX_RECONNECT_ATTEMPTS = 3; let touchScrollLastY: number | null = null; let touchScrollRemainder = 0; const TOUCH_SCROLL_LINE_PX = 18; +const INITIAL_COMMAND_CHUNK_SIZE = 128; +const INITIAL_COMMAND_CHUNK_DELAY_MS = 8; +const initialCommandSent = ref(false); +const initialCommandTimers = new Set>(); // ─── Computed ────────────────────────────────────────────────── @@ -224,6 +228,7 @@ function handleControl(msg: any) { exited: false, }); switchSession(msg.id); + runInitialCommand(); break; case "exited": { @@ -251,6 +256,26 @@ function createSession() { send({ type: "create" }); } +function runInitialCommand() { + const command = props.initialCommand?.trim(); + if (!command || initialCommandSent.value) return; + initialCommandSent.value = true; + scheduleInitialCommandChunk(`${command}\r`, 0, 100); +} + +function scheduleInitialCommandChunk(command: string, offset: number, delay: number) { + const timer = setTimeout(() => { + initialCommandTimers.delete(timer); + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const nextOffset = Math.min(offset + INITIAL_COMMAND_CHUNK_SIZE, command.length); + send({ type: "input", data: command.slice(offset, nextOffset) }); + if (nextOffset < command.length) { + scheduleInitialCommandChunk(command, nextOffset, INITIAL_COMMAND_CHUNK_DELAY_MS); + } + }, delay); + initialCommandTimers.add(timer); +} + function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } { let entry = termMap.get(id); if (!entry) { @@ -420,6 +445,8 @@ watch(() => props.visible, (visible) => { }, { immediate: true }); onUnmounted(() => { + for (const timer of initialCommandTimers) clearTimeout(timer); + initialCommandTimers.clear(); unmountActiveTerminal(); for (const entry of termMap.values()) { entry.term.dispose(); @@ -567,11 +594,16 @@ onUnmounted(() => { diff --git a/packages/client/src/components/hermes/chat/VoiceDialogueControls.vue b/packages/client/src/components/hermes/chat/VoiceDialogueControls.vue new file mode 100644 index 000000000..f5ca4bc08 --- /dev/null +++ b/packages/client/src/components/hermes/chat/VoiceDialogueControls.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/VoiceTranscriptOverlay.vue b/packages/client/src/components/hermes/chat/VoiceTranscriptOverlay.vue new file mode 100644 index 000000000..e77d3fb24 --- /dev/null +++ b/packages/client/src/components/hermes/chat/VoiceTranscriptOverlay.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/packages/client/src/components/hermes/chat/highlight.ts b/packages/client/src/components/hermes/chat/highlight.ts index 30a2ae3f9..0c6394fce 100644 --- a/packages/client/src/components/hermes/chat/highlight.ts +++ b/packages/client/src/components/hermes/chat/highlight.ts @@ -9,6 +9,18 @@ const LANGUAGE_ALIASES: Record = { vue: 'xml', } +const UNIFIED_DIFF_LANGUAGES = new Set(['diff', 'patch']) +const DIFF_CONTEXT_FOLD_THRESHOLD = 8 +const DIFF_CONTEXT_FOLD_EDGE_LINES = 3 +const DIFF_PAYLOAD_FIELD_NAMES = new Set([ + 'difference', + 'diff', + 'patch', + 'stdout', + 'output', + 'content', +]) + function escapeHtml(value: string): string { return value .replaceAll('&', '&') @@ -22,22 +34,286 @@ function sanitizeLanguageClass(value: string): string { return value.replace(/[^a-z0-9_-]/gi, '-') || 'plain' } +function renderCodeBlockWrapper( + highlighted: string, + codeClassLanguage: string, + labelLanguage: string | undefined, + copyLabel: string, + extraClasses: string[] = [], + rawCopyText?: string, +): string { + const languageLabelHtml = labelLanguage + ? `${escapeHtml(labelLanguage)}` + : '' + const blockClasses = ['hljs-code-block', ...extraClasses].join(' ') + const copyTextAttr = rawCopyText == null + ? '' + : ` data-copy-text="${escapeHtml(rawCopyText)}"` + + return `
${languageLabelHtml}
${highlighted}
` +} + +function isUnifiedDiffLanguage(lang?: string): boolean { + return UNIFIED_DIFF_LANGUAGES.has(lang?.trim().toLowerCase() || '') +} + +function isDiffFileHeader(line: string): boolean { + return /^(diff --git |index |---(?:\s|$)|\+\+\+(?:\s|$))/.test(line) +} + +function isDiffHunkHeader(line: string): boolean { + return /^@@(?:\s|$)/.test(line) +} + +function isDiffAddedLine(line: string): boolean { + return /^\+(?!\+\+(?:\s|$))/.test(line) +} + +function isDiffRemovedLine(line: string): boolean { + return /^-(?!---(?:\s|$))/.test(line) +} + +type DiffLineNumbers = { + oldNumber?: number + newNumber?: number +} + +type RenderedDiffLine = { + html: string + foldableContext: boolean +} + +function parseDiffHunkHeader(line: string): DiffLineNumbers | null { + const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/) + if (!match) return null + return { + oldNumber: Number(match[1]), + newNumber: Number(match[2]), + } +} + +function formatDiffLineNumber(line: string, numbers: DiffLineNumbers): { value: string; className: string } { + if (isDiffFileHeader(line) || isDiffHunkHeader(line)) { + return { value: '', className: 'diff-line-number-empty' } + } + if (isDiffRemovedLine(line)) { + return { + value: numbers.oldNumber != null ? String(numbers.oldNumber) : '', + className: 'diff-line-number-old', + } + } + if (isDiffAddedLine(line)) { + return { + value: numbers.newNumber != null ? String(numbers.newNumber) : '', + className: 'diff-line-number-new', + } + } + if (!isDiffFileHeader(line) && !isDiffHunkHeader(line) && numbers.newNumber != null) { + return { + value: String(numbers.newNumber), + className: 'diff-line-number-context', + } + } + return { value: '', className: 'diff-line-number-empty' } +} + +function advanceDiffLineNumber(line: string, numbers: DiffLineNumbers): void { + if (isDiffFileHeader(line) || isDiffHunkHeader(line)) return + if (isDiffRemovedLine(line)) { + if (numbers.oldNumber != null) numbers.oldNumber += 1 + return + } + if (isDiffAddedLine(line)) { + if (numbers.newNumber != null) numbers.newNumber += 1 + return + } + if (numbers.oldNumber != null) numbers.oldNumber += 1 + if (numbers.newNumber != null) numbers.newNumber += 1 +} + +function renderDiffContextFoldLine(foldLabel: string): string { + return `⋮ ${escapeHtml(foldLabel)}` +} + +function collapseFoldableContextRows( + rows: RenderedDiffLine[], + formatFoldLabel: (hiddenCount: number) => string, +): RenderedDiffLine[] { + const folded: RenderedDiffLine[] = [] + let index = 0 + + while (index < rows.length) { + if (!rows[index].foldableContext) { + folded.push(rows[index]) + index += 1 + continue + } + + const runStart = index + while (index < rows.length && rows[index].foldableContext) index += 1 + const run = rows.slice(runStart, index) + + if (run.length <= DIFF_CONTEXT_FOLD_THRESHOLD) { + folded.push(...run) + continue + } + + const edge = Math.min(DIFF_CONTEXT_FOLD_EDGE_LINES, Math.floor(run.length / 2)) + const hiddenCount = run.length - edge * 2 + folded.push(...run.slice(0, edge)) + folded.push({ + html: renderDiffContextFoldLine(formatFoldLabel(hiddenCount)), + foldableContext: false, + }) + folded.push(...run.slice(run.length - edge)) + } + + return folded +} + +function renderUnifiedDiffCode( + content: string, + labelLanguage: string, + copyLabel: string, + formatFoldLabel: (hiddenCount: number) => string, +): string { + const numbers: DiffLineNumbers = {} + const lines = content.split(/\r?\n/) + if (lines.at(-1) === '') lines.pop() + + const renderedRows = lines + .map((line) => { + const classes = ['diff-line'] + let foldableContext = false + if (isDiffFileHeader(line)) classes.push('diff-line-file-header') + else if (isDiffHunkHeader(line)) { + classes.push('diff-line-hunk-header') + const hunkNumbers = parseDiffHunkHeader(line) + if (hunkNumbers) { + numbers.oldNumber = hunkNumbers.oldNumber + numbers.newNumber = hunkNumbers.newNumber + } + } + else if (isDiffAddedLine(line)) classes.push('diff-line-added') + else if (isDiffRemovedLine(line)) classes.push('diff-line-removed') + else foldableContext = true + + const lineNumber = formatDiffLineNumber(line, numbers) + const html = `${escapeHtml(line || ' ')}` + advanceDiffLineNumber(line, numbers) + return { html, foldableContext } + }) + + const highlighted = collapseFoldableContextRows(renderedRows, formatFoldLabel) + .map((row) => row.html) + .join('') + + return renderCodeBlockWrapper(highlighted, 'diff', labelLanguage, copyLabel, ['hljs-unified-diff'], content) +} + export function normalizeHighlightLanguage(lang?: string): string { const normalized = lang?.trim().toLowerCase() || '' return LANGUAGE_ALIASES[normalized] || normalized } +function looksLikeDiff(content: string): boolean { + const trimmed = content.trimStart() + if (/^\*\*\* Begin Patch/m.test(trimmed)) return true + if (/^\*\*\* (Update|Add|Delete) File:/m.test(trimmed)) return true + if (/^---\s+[^\n]+\n\+\+\+\s+[^\n]+\n@@/m.test(trimmed)) return true + return false +} + export function inferStructuredLanguage(content: string): string | undefined { - try { - JSON.parse(content) - return 'json' - } catch { - return undefined + const trimmed = content.trimStart() + if (/^[\[{]/.test(trimmed)) { + try { + JSON.parse(content) + return 'json' + } catch { + // Fall through to diff/text detection. + } } + return looksLikeDiff(content) ? 'diff' : undefined +} + +export function isUnifiedDiffContent(content: string, lang?: string): boolean { + const lines = content.split(/\r?\n/) + if (lines.length < 3) return false + + let fileHeaders = 0 + let hunkHeaders = 0 + let addedLines = 0 + let removedLines = 0 + let diffHeaders = 0 + + for (const line of lines) { + if (/^(diff --git |index )/.test(line)) { + diffHeaders += 1 + continue + } + if (/^---(?:\s|$)|^\+\+\+(?:\s|$)/.test(line)) { + fileHeaders += 1 + continue + } + if (isDiffHunkHeader(line)) { + hunkHeaders += 1 + continue + } + if (isDiffAddedLine(line)) { + addedLines += 1 + continue + } + if (isDiffRemovedLine(line)) { + removedLines += 1 + } + } + + const hasChangedLines = addedLines > 0 || removedLines > 0 + if (!hasChangedLines) return false + + if (isUnifiedDiffLanguage(lang)) { + return hunkHeaders > 0 || fileHeaders >= 2 || diffHeaders > 0 + } + + return fileHeaders >= 2 && hunkHeaders > 0 +} + +export function extractUnifiedDiffPayload(value: unknown, depth = 0): string | null { + if (depth > 4 || value === null || typeof value !== 'object') return null + + if (Array.isArray(value)) { + for (const item of value) { + const diff = extractUnifiedDiffPayload(item, depth + 1) + if (diff) return diff + } + return null + } + + const entries = Object.entries(value as Record) + for (const [key, candidate] of entries) { + if ( + DIFF_PAYLOAD_FIELD_NAMES.has(key.toLowerCase()) + && typeof candidate === 'string' + && isUnifiedDiffContent(candidate) + ) { + return candidate + } + } + + for (const [, candidate] of entries) { + if (candidate && typeof candidate === 'object') { + const diff = extractUnifiedDiffPayload(candidate, depth + 1) + if (diff) return diff + } + } + + return null } type RenderHighlightedCodeBlockOptions = { maxHighlightLength?: number + formatDiffFoldLabel?: (hiddenCount: number) => string } export function renderHighlightedCodeBlock( @@ -50,6 +326,11 @@ export function renderHighlightedCodeBlock( const normalizedLanguage = normalizeHighlightLanguage(requestedLanguage) const highlightLimit = options.maxHighlightLength ?? Number.POSITIVE_INFINITY + if (isUnifiedDiffContent(content, requestedLanguage || normalizedLanguage)) { + const formatDiffFoldLabel = options.formatDiffFoldLabel ?? ((hiddenCount: number) => String(hiddenCount)) + return renderUnifiedDiffCode(content, requestedLanguage || 'diff', copyLabel, formatDiffFoldLabel) + } + let highlighted = '' let codeClassLanguage = normalizedLanguage || requestedLanguage || 'plain' let labelLanguage = requestedLanguage @@ -74,11 +355,7 @@ export function renderHighlightedCodeBlock( } } - const languageLabelHtml = labelLanguage - ? `${escapeHtml(labelLanguage)}` - : '' - - return `
${languageLabelHtml}
${highlighted}
` + return renderCodeBlockWrapper(highlighted, codeClassLanguage, labelLanguage, copyLabel) } export async function copyTextToClipboard(text: string): Promise { @@ -94,9 +371,9 @@ export async function handleCodeBlockCopyClick(event: MouseEvent): Promise('.hljs-code-block') const code = block?.querySelector('code') - const text = code?.textContent ?? '' + const text = block?.getAttribute('data-copy-text') ?? code?.textContent ?? '' if (!text) return false return copyTextToClipboard(text) diff --git a/packages/client/src/components/hermes/files/FileContextMenu.vue b/packages/client/src/components/hermes/files/FileContextMenu.vue index f36834fab..7a8e3111d 100644 --- a/packages/client/src/components/hermes/files/FileContextMenu.vue +++ b/packages/client/src/components/hermes/files/FileContextMenu.vue @@ -2,7 +2,7 @@ import { ref, nextTick } from 'vue' import { NDropdown, useMessage, useDialog } from 'naive-ui' import { useI18n } from 'vue-i18n' -import { useFilesStore, isTextFile, isImageFile, isMarkdownFile } from '@/stores/hermes/files' +import { useFilesStore, isTextFile, isPreviewableFile } from '@/stores/hermes/files' import { downloadFile } from '@/api/hermes/download' import type { FileEntry } from '@/api/hermes/files' import { copyToClipboard } from '@/utils/clipboard' @@ -20,6 +20,7 @@ const targetEntry = ref(null) const emit = defineEmits<{ (e: 'rename', entry: FileEntry): void + (e: 'newFolder', entry: FileEntry): void }>() function show(e: MouseEvent, entry: FileEntry) { @@ -43,13 +44,14 @@ function getOptions() { if (isTextFile(entry.name)) { options.push({ label: t('files.edit'), key: 'edit' }) } - if (isImageFile(entry.name) || isMarkdownFile(entry.name)) { + if (isPreviewableFile(entry.name)) { options.push({ label: t('files.preview'), key: 'preview' }) } options.push({ label: t('files.download'), key: 'download' }) } options.push({ type: 'divider', key: 'd1' }) options.push({ label: t('files.copyPath'), key: 'copyPath' }) + options.push({ label: t('files.newFolder'), key: 'newFolder' }) options.push({ label: t('files.rename'), key: 'rename' }) options.push({ type: 'divider', key: 'd2' }) options.push({ label: t('files.delete'), key: 'delete' }) @@ -86,6 +88,9 @@ async function handleSelect(key: string) { case 'rename': emit('rename', entry) break + case 'newFolder': + emit('newFolder', entry) + break case 'delete': dialog.warning({ title: t('files.delete'), diff --git a/packages/client/src/components/hermes/files/FileList.vue b/packages/client/src/components/hermes/files/FileList.vue index 37adc78f1..3b3803e4f 100644 --- a/packages/client/src/components/hermes/files/FileList.vue +++ b/packages/client/src/components/hermes/files/FileList.vue @@ -1,7 +1,7 @@
- +
{{ store.userName || 'You' }} {{ t('groupChat.you') }} @@ -437,7 +604,7 @@ watch(() => store.sortedMessages.length, async () => {
- +
@@ -672,12 +844,21 @@ export default defineComponent({ components: { CreateRoomForm } }) height: 100%; overflow: hidden; position: relative; + min-width: 0; + max-width: 100%; } .sidebar-backdrop { display: none; } +.group-message-shell { + position: relative; + flex: 1; + min-height: 0; + display: flex; +} + @media (max-width: $breakpoint-mobile) { .sidebar-backdrop { display: block; @@ -751,66 +932,68 @@ export default defineComponent({ components: { CreateRoomForm } }) } } -.approval-bar { - display: flex; - align-items: flex-start; - gap: 10px; - margin: 0 16px 12px; - padding: 12px; - border: 1px solid $border-color; - border-radius: 8px; - background: $bg-card; - box-shadow: none; -} - -.approval-icon { - display: grid; - place-items: center; - flex: 0 0 32px; - width: 32px; - height: 32px; - color: var(--accent-primary); - background: rgba(var(--accent-primary-rgb), 0.12); - border: 1px solid rgba(var(--accent-primary-rgb), 0.2); - border-radius: 8px; -} - -.approval-content { - flex: 1; - min-width: 0; -} +.approval-float-panel { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 8; + width: min(720px, calc(100% - 32px)); + padding: 10px; + border: 1px solid rgba(var(--accent-primary-rgb), 0.24); + border-radius: 16px; + background: #ffffff; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.14); + backdrop-filter: blur(14px); -.approval-main { - min-width: 0; + .dark & { + background: #262626; + } } -.approval-kicker { - margin-bottom: 2px; - font-size: 10px; +.approval-float-header { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 4px 8px; + color: var(--accent-primary); + font-size: 11px; font-weight: 700; line-height: 1.2; letter-spacing: 0.08em; text-transform: uppercase; +} + +.approval-float-icon { + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; color: var(--accent-primary); + background: rgba(var(--accent-primary-rgb), 0.12); + border: 1px solid rgba(var(--accent-primary-rgb), 0.24); } -.approval-title { +.approval-float-title { + padding: 0 4px; font-size: 14px; font-weight: 700; line-height: 1.3; color: $text-primary; } -.approval-desc { - margin-top: 4px; +.approval-float-desc { + padding: 0 4px; + margin-top: 5px; font-size: 12px; line-height: 1.45; color: $text-secondary; } -.approval-command { +.approval-float-command { display: block; - margin-top: 8px; + margin: 8px 4px 0; max-height: 96px; overflow: auto; white-space: pre-wrap; @@ -819,52 +1002,55 @@ export default defineComponent({ components: { CreateRoomForm } }) font-size: 11px; line-height: 1.45; color: $text-primary; - background: $bg-secondary; + background: rgba(255, 255, 255, 0.68); border: 1px solid $border-color; - border-radius: 6px; + border-radius: 11px; padding: 8px 10px; + + .dark & { + background: rgba(255, 255, 255, 0.08); + } } -.approval-actions { +.approval-float-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 10px; - padding-top: 10px; + padding: 10px 4px 0; border-top: 1px solid $border-color; } -@media (max-width: 768px) { - .approval-bar { - margin: 0 10px 10px; - padding: 10px; - } - - .approval-icon { - flex-basis: 28px; - width: 28px; - height: 28px; +@media (max-width: 640px) { + .approval-float-panel { + left: 8px; + right: 8px; + bottom: 8px; + width: auto; + padding: 7px; + border-radius: 14px; } - .approval-actions { + .approval-float-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } - .approval-actions :deep(.n-button) { + .approval-float-actions :deep(.n-button) { width: 100%; } } -@media (max-width: 420px) { - .approval-bar { - gap: 8px; - } +.approval-float-enter-active, +.approval-float-leave-active { + transition: opacity 0.2s ease, transform 0.2s ease; +} - .approval-actions { - grid-template-columns: 1fr; - } +.approval-float-enter-from, +.approval-float-leave-to { + opacity: 0; + transform: translateY(10px) scale(0.98); } .typing-indicator { @@ -901,7 +1087,7 @@ export default defineComponent({ components: { CreateRoomForm } }) // ─── Room Sidebar ──────────────────────────────────────── .room-sidebar { - width: 220px; + width: $sidebar-width; flex-shrink: 0; border-right: 1px solid $border-color; display: flex; @@ -909,21 +1095,80 @@ export default defineComponent({ components: { CreateRoomForm } }) } .sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; padding: 12px; flex-shrink: 0; +} - .sidebar-title { - font-size: 15px; - font-weight: 600; +.page-sidebar-tab { + width: 100%; + min-width: 0; + height: 34px; + border: none; + border-radius: $radius-sm; + background: transparent; + color: $text-secondary; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 7px 10px; + cursor: pointer; + transition: + background-color $transition-fast, + color $transition-fast; + + svg { + flex-shrink: 0; + } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 18px; + } + + &:hover { + background: rgba(var(--accent-primary-rgb), 0.06); color: $text-primary; } +} - .sidebar-actions { - display: flex; - gap: 4px; +.conversation-switch { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px; + margin: 0 12px 8px; + padding: 2px; + border-radius: $radius-sm; + background: rgba(var(--accent-primary-rgb), 0.05); + flex-shrink: 0; +} + +.conversation-switch-tab { + min-width: 0; + height: 28px; + border: none; + border-radius: 5px; + background: transparent; + color: $text-secondary; + font-size: 12px; + line-height: 16px; + cursor: pointer; + transition: + background-color $transition-fast, + color $transition-fast; + + &:hover { + color: $text-primary; + } + + &.active { + background: $bg-card; + color: $text-primary; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); } } @@ -1019,6 +1264,262 @@ export default defineComponent({ components: { CreateRoomForm } }) color: $text-muted; } +.page-sidebar-bottom { + flex-shrink: 0; + padding: 10px 12px; +} + +.page-sidebar-menu-btn { + width: 100%; + min-width: 0; + height: 36px; + border: none; + border-radius: $radius-sm; + background: transparent; + color: $text-secondary; + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 8px 10px; + cursor: pointer; + transition: + background-color $transition-fast, + color $transition-fast; + + &:hover { + background: rgba(var(--accent-primary-rgb), 0.06); + color: $text-primary; + } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 18px; + } +} + +.page-sidebar-popover { + width: $sidebar-width; + padding: 12px; + border: 1px solid $border-color; + border-radius: $radius-md; + background: $bg-card; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.18); +} + +.page-sidebar-popover :deep(.profile-selector), +.page-sidebar-popover :deep(.model-selector) { + padding: 0; +} + +.page-sidebar-popover :deep(.model-selector) { + margin-bottom: 10px; +} + +.page-sidebar-popover :deep(.language-switch) { + width: 88px; + flex: 0 0 88px; +} + +.page-sidebar-popover :deep(.language-switch .n-base-selection-input__content) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-sidebar-popover-row, +.page-sidebar-version-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-top: 1px solid $border-color; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 12px; + color: $text-secondary; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-indicator.connected .status-dot { + background-color: $success; + box-shadow: 0 0 6px rgba(var(--success-rgb), 0.5); +} + +.status-indicator.disconnected .status-dot { + background-color: $error; +} + +.status-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-sidebar-version-row { + gap: 6px; +} + +.page-sidebar-version-links { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 6px; +} + +.page-sidebar-link { + color: $text-muted; + display: flex; + align-items: center; + transition: color $transition-fast; + + &:hover { + color: $text-primary; + } +} + +.page-sidebar-version-text { + flex: 0 0 auto; + color: $text-muted; + font-size: 11px; + line-height: 16px; + white-space: nowrap; + cursor: pointer; + transition: color $transition-fast; + + &:hover { + color: $accent-primary; + } +} + +.page-sidebar-version-row :deep(.theme-switch-container) { + flex-shrink: 0; +} + +.page-sidebar-nav-btn, +.page-sidebar-logout-btn { + width: 100%; + min-width: 0; + height: 36px; + border: none; + border-top: 1px solid $border-color; + border-radius: 0; + background: transparent; + color: $text-secondary; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 0; + cursor: pointer; + transition: color $transition-fast; + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 18px; + } +} + +.page-sidebar-nav-btn:hover { + color: $text-primary; +} + +.page-sidebar-logout-btn { + margin-bottom: 6px; + + &:hover { + color: $error; + } +} + +.page-sidebar-logout-user { + margin-left: auto; + min-width: 0; + max-width: 112px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: $text-muted; + font-size: 12px; +} + +.changelog-list { + max-height: min(70vh, 640px); + overflow-y: auto; +} + +.changelog-version-block { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } +} + +.changelog-version-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.changelog-version-tag { + font-weight: 600; + font-size: 14px; + color: $text-primary; + font-family: $font-code; +} + +.changelog-date { + font-size: 12px; + color: $text-muted; +} + +.changelog-changes { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: 13px; + color: $text-secondary; + padding: 4px 0 4px 16px; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 12px; + width: 6px; + height: 6px; + border-radius: 50%; + background: $text-muted; + } + } +} + // ─── Chat Main ────────────────────────────────────────── .chat-main { @@ -1325,5 +1826,18 @@ export default defineComponent({ components: { CreateRoomForm } }) .chat-header { padding: 16px 12px 16px 52px; } + + .header-sidebar-toggle { + display: none; + } + + .page-sidebar-popover { + width: min($sidebar-width, calc(100vw - 24px)); + } + + .page-sidebar-popover :deep(.language-switch) { + width: 86px; + flex-basis: 86px; + } } diff --git a/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue b/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue index 38939c744..2c32b8071 100644 --- a/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue +++ b/packages/client/src/components/hermes/group-chat/GroupMessageItem.vue @@ -7,7 +7,9 @@ import ProfileAvatar from '@/components/hermes/profiles/ProfileAvatar.vue' import { useProfilesStore } from '@/stores/hermes/profiles' import { copyTextToClipboard, + extractUnifiedDiffPayload, handleCodeBlockCopyClick, + inferStructuredLanguage, renderHighlightedCodeBlock, } from '../chat/highlight' import { parseThinking, countThinkingChars } from '@/utils/thinking-parser' @@ -15,7 +17,7 @@ import { useGlobalSpeech } from '@/composables/useSpeech' import { useVoiceSettings } from '@/composables/useVoiceSettings' import { speedToEdgeRate, hzToEdgePitch } from '@/utils/ttsHelpers' import { getDownloadUrl } from '@/api/hermes/download' -import type { ChatMessage, RoomAgent } from '@/api/hermes/group-chat' +import type { ChatMessage, RoomAgent, MemberInfo } from '@/api/hermes/group-chat' const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000 const JSON_STRING_DISPLAY_LIMIT = 200 @@ -28,6 +30,7 @@ const JSON_TRUNCATED_KEY = '__truncated__' const props = defineProps<{ message: ChatMessage agents: RoomAgent[] + members?: MemberInfo[] currentUserId?: string }>() @@ -63,6 +66,40 @@ const timeStr = computed(() => { const avatarProfileName = computed(() => agentInfo.value?.profile || props.message.senderName || props.message.senderId) const avatarProfile = computed(() => profilesStore.profiles.find(profile => profile.name === agentInfo.value?.profile)) +// 找当前消息发送者在 members 里的记录 +const memberInfo = computed(() => { + if (isAgent.value) return null + return props.members?.find(m => + m.userId === props.message.senderId || + m.name === props.message.senderName + ) || null +}) + +// 解析 member 的 avatar JSON +const memberAvatar = computed(() => { + const av = memberInfo.value?.avatar + if (!av) return null + try { + const parsed = typeof av === 'string' ? JSON.parse(av) : av + if (parsed && parsed.type === 'image' && parsed.dataUrl) return parsed + } catch {} + return null +}) + +// 当前消息要显示的头像(profile / member / fallback) +const currentAvatar = computed(() => { + if (isAgent.value) { + return avatarProfile.value?.avatar ?? null + } + return memberAvatar.value +}) + +// 给 ProfileAvatar 的 name seed +const avatarDisplayName = computed(() => { + if (isAgent.value) return avatarProfileName.value + return props.message.senderName || props.message.senderId || 'user' +}) + const mentionNames = computed(() => ['all', ...props.agents.map(a => a.name).filter(Boolean)]) const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming })) const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0)) @@ -139,9 +176,9 @@ const copyableContent = computed(() => { const toolExpanded = ref(false) const isToolMessage = computed(() => props.message.role === 'tool') -const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult)) const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs)) -const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult)) +const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult, true)) +const hasToolDetails = computed(() => !!(toolArgsPayload.value.full || toolResultPayload.value.full)) const fullToolArgs = computed(() => toolArgsPayload.value.full) const formattedToolArgs = computed(() => toolArgsPayload.value.display) const fullToolResult = computed(() => toolResultPayload.value.full) @@ -237,26 +274,57 @@ function truncateJsonValue(value: unknown, marker: string): unknown { return { [JSON_TRUNCATED_KEY]: marker } } -function formatToolPayload(raw?: string): ToolPayload { - if (!raw) return { full: '', display: '' } +function normalizeToolPayload(raw: unknown): string { + if (raw === null || raw === undefined || raw === '') return '' + if (typeof raw === 'string') return raw try { - const parsed = JSON.parse(raw) - const full = JSON.stringify(parsed, null, 2) - const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT - ? JSON.stringify(truncateJsonValue(parsed, t('chat.truncated')), null, 2) - : full - return { full, display, language: 'json' } + const serialized = JSON.stringify(raw) + if (serialized !== undefined) return serialized } catch { - return { - full: raw, - display: raw.length > TOOL_PAYLOAD_DISPLAY_LIMIT ? raw.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + '\n' + t('chat.truncated') : raw, + // Fall through to String(raw) for non-serializable runtime payloads. + } + return String(raw) +} + +function formatToolPayload(raw?: unknown, extractDiff = false): ToolPayload { + const text = normalizeToolPayload(raw) + if (!text) return { full: '', display: '' } + + const shouldParseJson = typeof raw !== 'string' || /^[\[{]/.test(text.trim()) + if (shouldParseJson) { + try { + const parsed = JSON.parse(text) + const full = JSON.stringify(parsed, null, 2) + const extractedDiff = extractDiff ? extractUnifiedDiffPayload(parsed) : null + if (extractedDiff) { + return { + full, + display: extractedDiff, + language: 'diff', + } + } + const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT + ? JSON.stringify(truncateJsonValue(parsed, t('chat.truncated')), null, 2) + : full + return { full, display, language: 'json' } + } catch { + // Fall through to text rendering for non-JSON strings. } } + + const language = inferStructuredLanguage(text) + return { + full: text, + display: language === 'diff' || text.length <= TOOL_PAYLOAD_DISPLAY_LIMIT ? text : text.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + '\n' + t('chat.truncated'), + language, + } } + function renderToolPayload(content: string, language?: string): string { return renderHighlightedCodeBlock(content, language, t('common.copy'), { maxHighlightLength: TOOL_PAYLOAD_DISPLAY_LIMIT, + formatDiffFoldLabel: (hiddenCount) => t('chat.unchangedLines', { count: hiddenCount }), }) } @@ -286,56 +354,69 @@ async function handleToolDetailClick(event: MouseEvent): Promise { else if (copyResult === false) toast.error(t('chat.copyFailed')) } +function handleAutoplayTtsError(err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return + console.warn('[GroupMessageItem] TTS autoplay failed:', err) +} + function playSpeech(content: string, autoplay = false) { if (!content.trim()) return if (voiceSettings.provider.value === 'openai') { if (!voiceSettings.openaiBaseUrl.value) return - const play = autoplay ? speech.openaiPlay : speech.openaiToggle - play(props.message.id, content, { + const options = { + provider: 'openai' as const, baseUrl: voiceSettings.openaiBaseUrl.value, apiKey: voiceSettings.openaiApiKey.value, model: voiceSettings.openaiModel.value, voice: voiceSettings.openaiVoice.value, - }) + } + if (autoplay) void speech.openaiPlay(props.message.id, content, options).catch(handleAutoplayTtsError) + else speech.openaiToggle(props.message.id, content, options) return } if (voiceSettings.provider.value === 'custom') { if (!voiceSettings.customUrl.value) return - const play = autoplay ? speech.openaiPlay : speech.openaiToggle - play(props.message.id, content, { + const options = { + provider: 'custom' as const, baseUrl: voiceSettings.customUrl.value, apiKey: voiceSettings.customApiKey.value || undefined, - }) + } + if (autoplay) void speech.openaiPlay(props.message.id, content, options).catch(handleAutoplayTtsError) + else speech.openaiToggle(props.message.id, content, options) return } if (voiceSettings.provider.value === 'edge') { - const play = autoplay ? speech.openaiPlay : speech.openaiToggle - play(props.message.id, content, { + const options = { + provider: 'edge' as const, baseUrl: '/api/tts/proxy', voice: voiceSettings.edgeVoice.value, rate: speedToEdgeRate(voiceSettings.edgeRate.value), pitch: hzToEdgePitch(voiceSettings.edgePitchHz.value), - }) + } + if (autoplay) void speech.openaiPlay(props.message.id, content, options).catch(handleAutoplayTtsError) + else speech.openaiToggle(props.message.id, content, options) return } if (voiceSettings.provider.value === 'mimo') { - if (!voiceSettings.mimoApiKey.value) return - const play = autoplay ? speech.mimoPlay : speech.mimoToggle - play(props.message.id, content, { + const apiKey = voiceSettings.mimoApiKey.value + const options = { baseUrl: voiceSettings.mimoBaseUrl.value, - apiKey: voiceSettings.mimoApiKey.value, + apiKey: apiKey || undefined, + authMode: voiceSettings.mimoAuthMode.value, model: voiceSettings.mimoModel.value, + voiceMode: voiceSettings.mimoModel.value === 'mimo-v2.5-tts-voicedesign' ? 'voiceDesign' as const : voiceSettings.mimoModel.value === 'mimo-v2.5-tts-voiceclone' ? 'voiceClone' as const : 'preset' as const, voice: voiceSettings.mimoVoice.value, voiceDesignDesc: voiceSettings.mimoVoiceDesignDesc.value || undefined, + voiceCloneDataUri: voiceSettings.mimoVoiceCloneDataUri.value || undefined, + voiceCloneFormat: voiceSettings.mimoVoiceCloneFormat.value, stylePrompt: voiceSettings.mimoStylePrompt.value || undefined, - }) + } + if (autoplay) void speech.mimoPlay(props.message.id, content, options).catch(handleAutoplayTtsError) + else speech.mimoToggle(props.message.id, content, options) return } if (voiceSettings.provider.value === 'webspeech') { - const text = speech.extractReadableText(content) - if (!text) return - speech.stop(false) - speech.speakViaBrowser(props.message.id, text, { + speech.toggleBrowser(props.message.id, content, { voiceName: voiceSettings.webspeechVoice.value || undefined, }) return @@ -384,14 +465,14 @@ onMounted(() => { onBeforeUnmount(() => { if (autoPlayHandler) window.removeEventListener('auto-play-speech', autoPlayHandler) - if (speech.currentMessageId.value === props.message.id) speech.stop() + if (speech.currentMessageId.value === props.message.id || speech.currentCustomMessageId.value === props.message.id) speech.stop() })