Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36b32f0e88 | |||
| 8a556c25a0 | |||
| f271602f31 | |||
| 63319e1046 | |||
| b730fa1518 | |||
| fadb5d75c4 | |||
| 45a39d319f | |||
| 5ea7aa9611 | |||
| a6fabb90b0 | |||
| db62354c97 | |||
| 20dedcd6fa | |||
| 4ad0f9e493 | |||
| ac4e1cd3cf | |||
| 01c9bda339 | |||
| 1b11793dad | |||
| 98f98b55d5 | |||
| f28c398d16 | |||
| 358ec3e65d | |||
| 5f3d04f44c | |||
| d169cbe9d5 | |||
| 6cedd8410f | |||
| 9033ff2973 | |||
| 676dbd7589 | |||
| 9330de7af0 | |||
| 6023b5ea24 | |||
| 166c9f9051 | |||
| 2d6e3537e8 | |||
| 3672e56994 | |||
| f378d7aed4 | |||
| 1a7bf8ca11 | |||
| 3907548a1d | |||
| b1888bd8ef | |||
| c29740a466 | |||
| 45c6b24928 | |||
| 5fb62bef8a | |||
| 068b0d31b8 | |||
| 97b8588dc3 | |||
| 6150ea96af | |||
| 81af81fb6f | |||
| 2877035c5c | |||
| 6a1366b472 |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(npx vite *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
name: Database Backup
|
||||||
|
run-name: 💾 DB Backup triggered by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
# Owner: DevOps (Architekt)
|
||||||
|
# Trigger: Manual (workflow_dispatch) + optional schedule.
|
||||||
|
#
|
||||||
|
# Strategy:
|
||||||
|
# 1. Connects to the live PostgreSQL container via docker exec.
|
||||||
|
# 2. Runs pg_dumpall (full cluster dump, single file).
|
||||||
|
# 3. Compresses with gzip.
|
||||||
|
# 4. Uploads as a Gitea Action artifact (or writes to host path).
|
||||||
|
# 5. Artifacts are retained per Gitea repo settings (default 90 days).
|
||||||
|
#
|
||||||
|
# Rotation: Gitea artifact expiration handles old backups automatically.
|
||||||
|
# For longer retention, configure an external cron job or use the
|
||||||
|
# host_path output to copy the backup elsewhere.
|
||||||
|
#
|
||||||
|
# Restoration: See phases/deployment.md for step-by-step instructions.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
concurrency:
|
||||||
|
group: db-backup
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
keep_on_host:
|
||||||
|
description: 'Also copy backup to host path?'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
host_backup_path:
|
||||||
|
description: 'Host path for backup (only if keep_on_host is true)'
|
||||||
|
required: false
|
||||||
|
default: '/opt/openclaw/backups'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
# Optional: uncomment to enable nightly automatic backups
|
||||||
|
# schedule:
|
||||||
|
# - cron: '0 3 * * *' # Every night at 03:00 UTC
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backup:
|
||||||
|
name: Backup PostgreSQL
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ENV_TMPFILE: /tmp/nexus-backup-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 1: Generate backup filename
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Generate backup identifier
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ')
|
||||||
|
echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT"
|
||||||
|
echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT"
|
||||||
|
echo "📅 Backup ID: ${TIMESTAMP}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 2: Dump PostgreSQL via docker exec
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Dump database
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "🗄️ Dumping PostgreSQL cluster..."
|
||||||
|
|
||||||
|
docker exec "${BACKUP_CONTAINER_NAME}" \
|
||||||
|
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
|
||||||
|
| gzip > "${{ steps.meta.outputs.filename }}"
|
||||||
|
|
||||||
|
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
||||||
|
echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Upload backup as Gitea artifact
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Upload backup artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: nexus-backup-${{ steps.meta.outputs.timestamp }}
|
||||||
|
path: ${{ steps.meta.outputs.filename }}
|
||||||
|
retention-days: 90
|
||||||
|
compression-level: 0 # already gzipped
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 4: Optional — copy to host filesystem
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Copy backup to host (optional)
|
||||||
|
if: inputs.keep_on_host == true
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST_PATH="${{ inputs.host_backup_path }}"
|
||||||
|
|
||||||
|
# Create host dir if it doesn't exist
|
||||||
|
docker run --rm \
|
||||||
|
-v "${HOST_PATH}:/backup-target" \
|
||||||
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -c "
|
||||||
|
mkdir -p /backup-target && \
|
||||||
|
cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \
|
||||||
|
echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}'
|
||||||
|
"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Verify backup integrity
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Verify backup integrity
|
||||||
|
run: |
|
||||||
|
echo "🔍 Verifying backup integrity..."
|
||||||
|
if gzip -t "${{ steps.meta.outputs.filename }}"; then
|
||||||
|
echo "✅ Backup gzip integrity check passed"
|
||||||
|
else
|
||||||
|
echo "❌ Backup file is corrupted!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Quick content check: should start with PostgreSQL dump header
|
||||||
|
HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1)
|
||||||
|
if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then
|
||||||
|
echo "✅ Backup content header check passed"
|
||||||
|
else
|
||||||
|
echo "⚠️ Unexpected backup header (may still be valid): $HEADER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Backup Summary
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Backup Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
STATUS="${{ job.status }}"
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 💾 Database Backup Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " File: ${{ steps.meta.outputs.filename }}"
|
||||||
|
echo " Timestamp: ${{ steps.meta.outputs.timestamp }}"
|
||||||
|
echo " Triggered: @${{ gitea.actor }}"
|
||||||
|
echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}"
|
||||||
|
echo " Status: ${STATUS}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
if [ "${STATUS}" = "success" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "💡 Restore command (manual, on host):"
|
||||||
|
echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres"
|
||||||
|
fi
|
||||||
@@ -27,14 +27,13 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore backend/Nexus.Api.csproj
|
run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release
|
run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ─── Frontend ──────────────────────────────────
|
# ─── Frontend ──────────────────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
@@ -54,16 +53,18 @@ jobs:
|
|||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@latest --activate
|
corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
# --prefer-offline: use cached packages if available in the runner image
|
|
||||||
# Lockfile IS committed — regenerated on changes via pnpm install.
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --no-frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: pnpm exec vue-tsc --noEmit
|
run: pnpm exec vue-tsc --noEmit
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -79,8 +80,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Check for .env leaks
|
- name: Check for .env leaks
|
||||||
run: |
|
run: |
|
||||||
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then
|
echo "🔍 Scanning for potential secrets in source code..."
|
||||||
echo "⚠️ Warning: Potential secrets in source code (review manually)"
|
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
|
||||||
|
echo "$HITS"
|
||||||
|
echo ""
|
||||||
|
echo "Remove these values and use environment variables or a secrets manager instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
|
||||||
|
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||||
|
if [ -n "$LOOSE" ]; then
|
||||||
|
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
|
||||||
|
echo "$LOOSE"
|
||||||
else
|
else
|
||||||
echo "✅ No obvious secrets found"
|
echo "✅ No obvious secrets found"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+246
-112
@@ -1,169 +1,274 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
|
run-name: 🚀 Deploy by @${{ gitea.actor }}
|
||||||
|
|
||||||
# ── Concurrency: one deploy at a time, cancel queued ones ──
|
# ───────────────────────────────────────────────────────
|
||||||
# Why: prevents race conditions when CI triggers deploy while
|
# Owner: DevOps (Architekt)
|
||||||
# a manual deploy is still running. The latest deploy wins.
|
# CD v3 — 2026-06-13
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# 1. AUTOMATIC after successful CI on main (workflow_run)
|
||||||
|
# → Uses safe defaults: patch bump, all services, main ref.
|
||||||
|
# → Commits marked with [skip ci] are filtered at job level
|
||||||
|
# (prevents version-bump loops).
|
||||||
|
# 2. MANUAL via workflow_dispatch with full parameter control.
|
||||||
|
#
|
||||||
|
# Concurrency: one deploy at a time.
|
||||||
|
# Queued deploys wait — no race conditions with parallel builds.
|
||||||
|
#
|
||||||
|
# Version-Bump / CI Loop Prevention:
|
||||||
|
# The version-bump commit includes "[skip ci]" in its message,
|
||||||
|
# which Gitea Actions respects. The auto-trigger additionally
|
||||||
|
# checks for "[skip ci]" as a second safety layer. Together
|
||||||
|
# they guarantee that a version-bump commit does NOT trigger
|
||||||
|
# another CI → Deploy → Bump → CI cycle.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────
|
|
||||||
# Trigger: automatic after CI success, or manual dispatch.
|
|
||||||
# Runner: uses ubuntu-latest label (consistently present on
|
|
||||||
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
|
|
||||||
# Standard labels avoid custom-label matching edge cases.
|
|
||||||
# ───────────────────────────────────────────────────
|
|
||||||
on:
|
on:
|
||||||
|
# ── Auto-Trigger: after successful CI on main ──
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI - Build & Test"]
|
workflows: ["CI - Build & Test"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# ── Manual Trigger (full control) ──
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump_version:
|
version_bump:
|
||||||
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
|
description: 'Version bump type'
|
||||||
required: false
|
required: true
|
||||||
default: 'patch'
|
default: 'patch'
|
||||||
type: string
|
type: choice
|
||||||
options:
|
options:
|
||||||
- 'patch'
|
- patch
|
||||||
- 'minor'
|
- minor
|
||||||
- 'major'
|
- major
|
||||||
service:
|
service:
|
||||||
description: 'Service to deploy (empty = all)'
|
description: 'Service to deploy (empty = all)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
type: string
|
type: string
|
||||||
no_cache:
|
no_cache:
|
||||||
description: 'Disable build cache'
|
description: 'Disable Docker build cache'
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
git_ref:
|
||||||
|
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
|
||||||
|
required: false
|
||||||
|
default: 'main'
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Nexus
|
name: Deploy Nexus
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }}
|
if: |
|
||||||
|
(github.event_name == 'workflow_dispatch') ||
|
||||||
|
(github.event_name == 'workflow_run' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
|
||||||
|
|
||||||
|
# ── Env for the deploy target path ──
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
ENV_TMPFILE: /tmp/nexus-deploy-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
|
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
||||||
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ── Step 1: Checkout ─────────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Checkout latest code
|
# Step 1: Checkout
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
# ── Step 2: Version bump (race-free) ─────
|
# ═══════════════════════════════════════════════════
|
||||||
# Derives current version from git tags (not VERSION file) to
|
# Step 2: Set up Git identity
|
||||||
# avoid race conditions where tag exists but VERSION is stale.
|
# ═══════════════════════════════════════════════════
|
||||||
# Uses --force on tag+push to handle retries after failed runs.
|
- name: Configure Git
|
||||||
- name: Version Bump
|
run: |
|
||||||
|
git config user.email "devops@noveria.net"
|
||||||
|
git config user.name "DevOps"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Resolve deploy version
|
||||||
|
#
|
||||||
|
# Deploying main: DevOps may bump VERSION and create a tag.
|
||||||
|
# Deploying any other ref: deploy exactly that ref, but DO NOT
|
||||||
|
# mutate main or create a version-bump commit on another branch.
|
||||||
|
#
|
||||||
|
# For auto-deploys (workflow_run): always "patch" bump on main.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Resolve Version
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Source of truth: latest git tag
|
# Determine bump type (auto-deploy → patch; manual → user choice)
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
|
||||||
CURRENT_VERSION="${TAG#v}"
|
|
||||||
echo "📦 Current version (from git tags): $CURRENT_VERSION"
|
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
|
# Read current version
|
||||||
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
|
if [ ! -f VERSION ]; then
|
||||||
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
|
echo "❌ VERSION file not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "${{ inputs.bump_version }}" in
|
CURRENT=$(cat VERSION | tr -d '[:space:]')
|
||||||
major)
|
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
echo "❌ Invalid semver in VERSION: '$CURRENT'"
|
||||||
minor)
|
exit 1
|
||||||
MINOR=$((MINOR + 1)); PATCH=0 ;;
|
fi
|
||||||
patch|*)
|
|
||||||
PATCH=$((PATCH + 1)) ;;
|
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||||
|
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||||
|
|
||||||
|
case "$BUMP_TYPE" in
|
||||||
|
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;;
|
||||||
|
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;;
|
||||||
|
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
|
||||||
|
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
# Determine git ref — auto-deploy always uses main
|
||||||
echo "🏷️ New version: $NEW_VERSION"
|
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
||||||
echo "$NEW_VERSION" > VERSION
|
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
|
||||||
|
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
||||||
|
echo "$NEW_VERSION" > VERSION
|
||||||
|
git add VERSION
|
||||||
|
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
|
||||||
|
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||||
|
git push origin HEAD:main --tags
|
||||||
|
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
|
||||||
|
echo "mutated_main=true" >> "$GITEA_OUTPUT"
|
||||||
|
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
|
||||||
|
else
|
||||||
|
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
|
||||||
|
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||||
|
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
|
||||||
|
fi
|
||||||
|
|
||||||
git config user.email "devops@noveria.net"
|
# ═══════════════════════════════════════════════════
|
||||||
git config user.name "DevOps"
|
# Step 4: Build .env from secrets (SAFE)
|
||||||
git add VERSION
|
#
|
||||||
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
|
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||||
|
# to a file inside the workspace that gets rsync'd to
|
||||||
# --force avoids "tag already exists" when re-running after a failed attempt
|
# the host. The temp file is deleted immediately after
|
||||||
git tag -f "v${NEW_VERSION}"
|
# compose operations complete.
|
||||||
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags
|
# ═══════════════════════════════════════════════════
|
||||||
echo "✅ Version bumped to v${NEW_VERSION}"
|
- name: Prepare .env (secrets → temp file)
|
||||||
|
|
||||||
# ── Step 3: Sync code + .env to host ──────
|
|
||||||
# Creates .env from Gitea secrets in the workspace, then syncs
|
|
||||||
# everything (except .git) to the host deploy path via DIND.
|
|
||||||
- name: Sync code + .env to host
|
|
||||||
run: |
|
run: |
|
||||||
# Create .env from Gitea secrets in the workspace
|
set -euo pipefail
|
||||||
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
|
|
||||||
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
# Managed via Gitea secrets → do not edit manually on the host
|
# Managed via Gitea Secrets → do NOT edit manually on the host.
|
||||||
|
# This file lives in /tmp and is removed after deploy completes.
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
POSTGRES_USER=nexus
|
POSTGRES_USER=nexus
|
||||||
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||||
JWT_KEY=${{ secrets.ENV_JWT_KEY }}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }}
|
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
||||||
OWNER_DISPLAY_NAME=
|
OWNER_DISPLAY_NAME=
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
ENVEOF
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
|
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Sync code to host (without .env in workspace)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Sync code to host
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# Sync everything (except .git) from workspace to host
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${{ gitea.workspace }}:/src:ro" \
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
alpine:latest \
|
alpine:latest \
|
||||||
sh -c "
|
sh -c "
|
||||||
cd /src && \
|
cd /src && \
|
||||||
find . -mindepth 1 -maxdepth 1 \
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
! -name .git \
|
! -name .git \
|
||||||
-exec cp -a {} /dest/ \;
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
"
|
"
|
||||||
echo "✅ Code + .env synced to host deploy path"
|
|
||||||
|
|
||||||
# ── Step 4: Docker Buildx ─────────────────
|
echo "✅ Code synced to ${DEPLOY_PATH}"
|
||||||
- name: Set up Docker Buildx
|
|
||||||
run: docker buildx create --use 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── Step 5: Build & Deploy ────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Build & Deploy
|
||||||
|
#
|
||||||
|
# The temp .env file is bind-mounted read-only into the
|
||||||
|
# docker:cli container so compose can resolve variables.
|
||||||
|
# It is NEVER written into the workspace directory.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Build & Deploy
|
- name: Build & Deploy
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Auto-deploy: always use cache. Manual: respect no_cache input.
|
||||||
|
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
|
||||||
BUILD_ARGS=""
|
BUILD_ARGS=""
|
||||||
if [ "${{ inputs.no_cache }}" = "true" ]; then
|
if [ "$NO_CACHE" = "true" ]; then
|
||||||
BUILD_ARGS="--no-cache"
|
BUILD_ARGS="--no-cache"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
|
-i \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c "
|
sh -c "
|
||||||
set -e
|
set -e
|
||||||
if [ -n '${{ inputs.service }}' ]; then
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
echo '🚀 Deploying service: ${{ inputs.service }}'
|
cat > /tmp/nexus-deploy-env
|
||||||
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
|
if [ -n '${SERVICE_ARG}' ]; then
|
||||||
docker compose up -d --force-recreate ${{ inputs.service }}
|
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
|
||||||
else
|
else
|
||||||
echo '🚀 Deploying all services'
|
echo '🚀 Deploying all services'
|
||||||
docker compose build ${BUILD_ARGS}
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
|
||||||
docker compose up -d --force-recreate
|
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
||||||
fi
|
fi
|
||||||
"
|
" < "${ENV_TMPFILE}"
|
||||||
|
|
||||||
# ── Step 6: Health Check (backoff) ────────
|
echo "✅ Docker compose up completed"
|
||||||
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
|
|
||||||
# Why: cold-start containers need variable warmup time;
|
# ═══════════════════════════════════════════════════
|
||||||
# fixed 5s intervals either wait too long or give up too early.
|
# Step 7: Clean up temp .env
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Clean up temp .env
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "${ENV_TMPFILE}" ]; then
|
||||||
|
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||||
|
echo "🧹 Temp .env removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 8: Health Check (exponential backoff)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Health Check
|
- name: Health Check
|
||||||
run: |
|
run: |
|
||||||
echo "🏥 Health check..."
|
echo "🏥 Health check..."
|
||||||
@@ -186,11 +291,10 @@ jobs:
|
|||||||
echo "❌ Health check failed after $MAX attempts"
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# ── Step 7: Smoke test (multi-endpoint) ───
|
# ═══════════════════════════════════════════════════
|
||||||
# Tests multiple endpoints to catch partial failures.
|
# Step 9: Smoke Test
|
||||||
# Why: a single /dashboard check can miss backend-only outages;
|
# ═══════════════════════════════════════════════════
|
||||||
# /health tests the API + database + runtime status.
|
- name: Smoke Test
|
||||||
- name: Verify (smoke test)
|
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Smoke test..."
|
echo "🔍 Smoke test..."
|
||||||
PASS=0
|
PASS=0
|
||||||
@@ -199,7 +303,8 @@ jobs:
|
|||||||
|
|
||||||
check() {
|
check() {
|
||||||
local path="$1" label="$2" expected="${3:-200}"
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
printf " %-25s HTTP %s" "${label}:" "${code}"
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
if [ "$code" = "$expected" ]; then
|
if [ "$code" = "$expected" ]; then
|
||||||
echo " ✅"
|
echo " ✅"
|
||||||
@@ -210,8 +315,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check "/dashboard" "Dashboard" 200
|
check "/dashboard" "Dashboard" 200
|
||||||
check "/health" "Health API" 200
|
check "/health" "Health API" 200
|
||||||
|
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
@@ -219,25 +325,53 @@ jobs:
|
|||||||
echo "❌ Smoke test failed!"
|
echo "❌ Smoke test failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✅ Deployment verified"
|
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
|
||||||
|
|
||||||
# ── Step 8: Rollback hint ────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
# On any failure, prints the previous deploy tag for quick manual rollback.
|
# Step 10: Deployment Summary
|
||||||
# Why: reduces MTTR (mean time to recovery) by providing the exact
|
# ═══════════════════════════════════════════════════
|
||||||
# git tag to roll back to without needing to look it up manually.
|
- name: Deployment Summary
|
||||||
- name: Rollback hint
|
if: always()
|
||||||
|
run: |
|
||||||
|
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
||||||
|
VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}"
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 📦 Deploy Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " Version: v${{ steps.version.outputs.version }}"
|
||||||
|
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
||||||
|
echo " Main bump: ${{ steps.version.outputs.mutated_main }}"
|
||||||
|
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
||||||
|
echo " Trigger: ${TRIGGER}"
|
||||||
|
echo " Bump type: ${VERSION_BUMP}"
|
||||||
|
echo " Actor: @${{ gitea.actor }}"
|
||||||
|
echo " Status: ${{ job.status }}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 11: Failure → Reviewer Handoff
|
||||||
|
#
|
||||||
|
# On failure: DevOps (Architekt) analyses the log,
|
||||||
|
# notifies Reviewer (Code-Fixer) with the exact error.
|
||||||
|
# This output provides a ready-to-copy message.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: 🔴 Failure — Reviewer Handoff
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔙 ─── Rollback Instructions ─── 🔙"
|
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||||
echo ""
|
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
|
||||||
echo " # 1. Checkout previous version:"
|
echo "├─────────────────────────────────────────────────────────────┤"
|
||||||
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
|
echo "│ │"
|
||||||
echo ""
|
echo "│ Version: v${{ steps.version.outputs.version }}"
|
||||||
echo " # 2. Redeploy:"
|
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
|
echo "│ │"
|
||||||
echo " docker compose up -d --force-recreate"
|
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||||
echo ""
|
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||||
echo " # 3. Or trigger rollback via Gitea:"
|
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||||
echo " Trigger 'Deploy to Production' workflow with the previous tag"
|
echo "│ │"
|
||||||
echo ""
|
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
|
||||||
|
echo "│ workflow manuell in Gitea Actions. │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
name: Rollback to Previous Version
|
||||||
|
run-name: 🔙 Rollback by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
# Owner: DevOps (Architekt)
|
||||||
|
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
|
||||||
|
#
|
||||||
|
# This workflow reverts the deploy path to the code at a
|
||||||
|
# given git tag/ref, then rebuilds and redeploys the stack.
|
||||||
|
#
|
||||||
|
# Strategy: git checkout <tag> → docker compose up -d --build
|
||||||
|
# This is a "full restart rollback" — safest for containerized
|
||||||
|
# apps where DB schema changes may need the matching API binary.
|
||||||
|
#
|
||||||
|
# DB migrations: the API runs MigrateAsync on startup. If the
|
||||||
|
# rollback-tag's migration history is a prefix of the current DB,
|
||||||
|
# EF Core handles this gracefully (no-op for already-applied
|
||||||
|
# migrations). If the tag predates a destructive migration, manual
|
||||||
|
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_tag:
|
||||||
|
description: 'Git tag to roll back to (e.g. v0.2.49)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
confirm:
|
||||||
|
description: 'Type "ROLLBACK" to confirm'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rollback:
|
||||||
|
name: Rollback Nexus
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
ENV_TMPFILE: /tmp/nexus-rollback-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
|
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
||||||
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 0: Safety gate — require explicit confirmation
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Safety Gate
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then
|
||||||
|
echo "❌ Rollback aborted: confirmation string must be 'ROLLBACK'"
|
||||||
|
echo " You entered: '${{ inputs.confirm }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Rollback confirmed — proceeding to ${{ inputs.target_tag }}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 1: Checkout target tag
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Checkout target tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: refs/tags/${{ inputs.target_tag }}
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 2: Verify tag exists
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Verify tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTUAL_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
|
||||||
|
if [ -z "$ACTUAL_TAG" ]; then
|
||||||
|
echo "❌ Tag '${{ inputs.target_tag }}' not found in repository"
|
||||||
|
echo " Available tags:"
|
||||||
|
git tag -l 'v*' | sort -V | tail -20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Checked out: $ACTUAL_TAG"
|
||||||
|
echo " Commit: $(git rev-parse --short HEAD)"
|
||||||
|
echo " Message: $(git log -1 --oneline)"
|
||||||
|
|
||||||
|
# Read version from VERSION file at this tag
|
||||||
|
if [ -f VERSION ]; then
|
||||||
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
echo " VERSION: $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Prepare .env from secrets (safe temp file)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Prepare .env (secrets → temp file)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
|
POSTGRES_DB=nexus
|
||||||
|
POSTGRES_USER=nexus
|
||||||
|
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||||
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
|
JWT_ISSUER=nexus
|
||||||
|
JWT_AUDIENCE=nexus-web
|
||||||
|
OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
|
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
||||||
|
OWNER_DISPLAY_NAME=
|
||||||
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
|
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 4: Sync rollback code to host
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Sync code to host
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -c "
|
||||||
|
cd /src && \
|
||||||
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name .git \
|
||||||
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Rebuild & Redeploy
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Rebuild & Redeploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
|
-v "/tmp:/tmp-host:ro" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-w /workspace/nexus \
|
||||||
|
docker:cli \
|
||||||
|
sh -c "
|
||||||
|
set -e
|
||||||
|
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||||
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||||
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Clean up temp .env
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Clean up temp .env
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "${ENV_TMPFILE}" ]; then
|
||||||
|
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||||
|
echo "🧹 Temp .env removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 7: Health Check
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Health Check
|
||||||
|
run: |
|
||||||
|
echo "🏥 Health check after rollback..."
|
||||||
|
RETRY=0
|
||||||
|
MAX=6
|
||||||
|
WAIT=1
|
||||||
|
while [ $RETRY -lt $MAX ]; do
|
||||||
|
RETRY=$((RETRY + 1))
|
||||||
|
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||||
|
sleep $WAIT
|
||||||
|
NEXT=$((WAIT + RETRY))
|
||||||
|
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||||
|
done
|
||||||
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 8: Smoke Test
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Smoke Test
|
||||||
|
run: |
|
||||||
|
echo "🔍 Smoke test after rollback..."
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
BASE="https://nexus.noveria.net"
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
|
if [ "$code" = "$expected" ]; then
|
||||||
|
echo " ✅"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ (expected $expected)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check "/dashboard" "Dashboard" 200
|
||||||
|
check "/health" "Health API" 200
|
||||||
|
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
echo "❌ Smoke test failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 9: Rollback Summary
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Rollback Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 🔙 Rollback Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " Rolled to: ${{ inputs.target_tag }}"
|
||||||
|
echo " Triggered: @${{ gitea.actor }}"
|
||||||
|
echo " Status: ${{ job.status }}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 10: Failure → Reviewer Handoff
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: 🔴 Rollback Failed — Reviewer Handoff
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||||
|
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
|
||||||
|
echo "├─────────────────────────────────────────────────────────────┤"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ Target: ${{ inputs.target_tag }}"
|
||||||
|
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||||
|
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||||
|
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ Letzter bekannter funktionierender Stand: │"
|
||||||
|
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||||
|
echo "│ → Manuellen Rollback erwägen: │"
|
||||||
|
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
|
||||||
|
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
@@ -31,3 +31,6 @@ docker-compose.override.yml
|
|||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
# pnpm (lockfile IS committed for reproducible CI builds)
|
# pnpm (lockfile IS committed for reproducible CI builds)
|
||||||
|
|
||||||
|
# Claude local config (per-developer, not repo-shared)
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
||||||
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||||
|
|
||||||
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy.
|
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||||
|
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
||||||
|
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
||||||
|
> stay read-only. Rollback and database backup are separate manual workflows.
|
||||||
|
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
||||||
|
|
||||||
## Current foundation
|
## Current foundation
|
||||||
|
|
||||||
@@ -287,4 +291,59 @@ The configured model-routing policy is:
|
|||||||
|
|
||||||
The Settings module reports runtime and provider state without exposing
|
The Settings module reports runtime and provider state without exposing
|
||||||
credentials.
|
credentials.
|
||||||
# Trigger CI
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
### CI — Automatic
|
||||||
|
|
||||||
|
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
|
||||||
|
- **Backend**: .NET restore → build → test
|
||||||
|
- **Frontend**: pnpm install → type-check → test → build
|
||||||
|
- **Security**: Scan for hardcoded secrets in source code
|
||||||
|
|
||||||
|
CI must never break. If it does, Reviewer fixes.
|
||||||
|
|
||||||
|
### CD — Auto + Manual (CD v3)
|
||||||
|
|
||||||
|
Deployment can happen automatically or manually:
|
||||||
|
|
||||||
|
#### Auto-Deploy (after successful CI on main)
|
||||||
|
|
||||||
|
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
|
||||||
|
- Uses safe defaults: `patch` bump, all services, main ref
|
||||||
|
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
|
||||||
|
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
|
||||||
|
|
||||||
|
#### Manual Deploy (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Deploy to Production` in Gitea Actions
|
||||||
|
2. Chooses version bump type: patch (default) / minor / major
|
||||||
|
3. Optionally scopes to a single service or specific git ref
|
||||||
|
4. Workflow bumps VERSION, creates git tag, builds and deploys
|
||||||
|
5. Health check + smoke test verify the deployment
|
||||||
|
|
||||||
|
#### Rollback (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Rollback to Previous Version` in Gitea Actions
|
||||||
|
2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK`
|
||||||
|
3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys
|
||||||
|
4. Health check + smoke test verify the rollback
|
||||||
|
|
||||||
|
#### Database Backup (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Database Backup` in Gitea Actions
|
||||||
|
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`)
|
||||||
|
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
|
||||||
|
4. Artifacts are retained for 90 days (configurable)
|
||||||
|
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
||||||
|
|
||||||
|
#### Failure Handling
|
||||||
|
|
||||||
|
When deploy or rollback fails:
|
||||||
|
- **DevOps (Architekt)** analyses the error
|
||||||
|
- **Reviewer (Code-Fixer)** fixes the problem
|
||||||
|
- **DevOps** re-deploys to verify the fix
|
||||||
|
|
||||||
|
The workflow outputs a formatted handoff message with the job URL.
|
||||||
|
|
||||||
|
Full CD documentation: [phases/deployment.md](phases/deployment.md)
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -27,12 +23,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -44,18 +36,60 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
||||||
Assert.Null(agent);
|
Assert.Null(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IConfiguration CreateConfiguration(string configPath)
|
||||||
|
=> new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AgentConfigPath"] = configPath
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static string CreateAgentConfigFile()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
|
File.WriteAllText(path,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": {
|
||||||
|
"primary": "deepseek/deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "iris",
|
||||||
|
"name": "iris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "programmer",
|
||||||
|
"name": "programmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reviewer",
|
||||||
|
"name": "reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "architekt",
|
||||||
|
"name": "architekt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FakeRuntime : IAgentRuntime
|
public sealed class FakeRuntime : IAgentRuntime
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Nexus.Api.Controllers;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
public class OperationsSnapshotTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_RequiresAuthorization()
|
||||||
|
{
|
||||||
|
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
|
||||||
|
{
|
||||||
|
var guard = new RepositoryConcurrencyGuard();
|
||||||
|
var runtime = new SnapshotRuntimeStub();
|
||||||
|
var agentService = new SnapshotAgentServiceStub();
|
||||||
|
var projectRepo = new GuardedProjectRepository(guard);
|
||||||
|
var taskRepo = new GuardedTaskRepository(guard);
|
||||||
|
var activityRepo = new GuardedActivityRepository(guard);
|
||||||
|
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
|
||||||
|
|
||||||
|
await service.GetSnapshotAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, guard.MaxConcurrentCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RepositoryConcurrencyGuard
|
||||||
|
{
|
||||||
|
private readonly Lock sync = new();
|
||||||
|
private int currentCalls;
|
||||||
|
|
||||||
|
public int MaxConcurrentCalls { get; private set; }
|
||||||
|
|
||||||
|
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
currentCalls++;
|
||||||
|
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(25, ct);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
currentCalls--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
|
||||||
|
{
|
||||||
|
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<Project>
|
||||||
|
{
|
||||||
|
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
|
||||||
|
{
|
||||||
|
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<WorkTask>
|
||||||
|
{
|
||||||
|
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
|
||||||
|
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
|
||||||
|
{
|
||||||
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<ActivityEvent>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotRuntimeStub : IAgentRuntime
|
||||||
|
{
|
||||||
|
public string Name => "stub";
|
||||||
|
|
||||||
|
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
|
||||||
|
|
||||||
|
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotAgentServiceStub : IAgentService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
|
||||||
|
[
|
||||||
|
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
|
||||||
|
]);
|
||||||
|
|
||||||
|
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Helpers;
|
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
@@ -15,6 +13,7 @@ public class AgentsController(
|
|||||||
IAgentService agentService,
|
IAgentService agentService,
|
||||||
IAgentRuntime runtime,
|
IAgentRuntime runtime,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
|
IAgentConfigService agentConfigService,
|
||||||
ILogger<AgentsController> logger) : ControllerBase
|
ILogger<AgentsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -22,8 +21,7 @@ public class AgentsController(
|
|||||||
{
|
{
|
||||||
var agents = await agentService.GetAgentsAsync(ct);
|
var agents = await agentService.GetAgentsAsync(ct);
|
||||||
return Results.Ok(agents.Select(a => new AgentListResponse(
|
return Results.Ok(agents.Select(a => new AgentListResponse(
|
||||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description
|
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@@ -34,8 +32,7 @@ public class AgentsController(
|
|||||||
return Results.Ok(new AgentDetailResponse(
|
return Results.Ok(new AgentDetailResponse(
|
||||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
||||||
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
||||||
agent.SubAgents, agent.IdentityName
|
agent.SubAgents, agent.IdentityName));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/activity")]
|
[HttpGet("{id}/activity")]
|
||||||
@@ -58,9 +55,7 @@ public class AgentsController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
||||||
|
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
|
||||||
|
|
||||||
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -73,79 +68,33 @@ public class AgentsController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Agent Config Editor ==========
|
// ── Config Editor ──
|
||||||
|
|
||||||
[HttpGet("{id}/config")]
|
[HttpGet("{id}/config")]
|
||||||
public IResult GetConfig(string id)
|
public IResult GetConfig(string id)
|
||||||
{
|
=> Results.Ok(agentConfigService.GetConfigFiles(id));
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
|
||||||
if (!Directory.Exists(workspacePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
|
||||||
};
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(workspacePath, "*.md")
|
|
||||||
.Select(f => new FileInfo(f))
|
|
||||||
.Where(f => allowedFiles.Contains(f.Name))
|
|
||||||
.OrderBy(f => f.Name)
|
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
fileName = f.Name,
|
|
||||||
size = f.Length,
|
|
||||||
modifiedAt = f.LastWriteTimeUtc
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Results.Ok(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id}/config/{fileName}")]
|
[HttpGet("{id}/config/{fileName}")]
|
||||||
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
|
||||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
return file is null
|
||||||
|
? Results.NotFound()
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
|
|
||||||
var fi = new FileInfo(safePath!);
|
|
||||||
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}/config/{fileName}")]
|
[HttpPut("{id}/config/{fileName}")]
|
||||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
|
||||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
|
||||||
|
|
||||||
if (request.Content is null)
|
if (request.Content is null)
|
||||||
return Results.BadRequest(new { error = "Content is required." });
|
return Results.BadRequest(new { error = "Content is required." });
|
||||||
|
|
||||||
if (request.Content.Length > 500 * 1024)
|
if (request.Content.Length > 500 * 1024)
|
||||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||||
|
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
return result is null
|
||||||
return Results.NotFound();
|
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||||
|
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||||
var tempPath = safePath + ".tmp";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct);
|
|
||||||
System.IO.File.Move(tempPath, safePath, overwrite: true);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
var fi = new FileInfo(safePath);
|
|
||||||
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/calendar")]
|
[Route("api/v1/calendar")]
|
||||||
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase
|
public class CalendarController(ICalendarService calendarService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await calendarService.GetCronJobsAsync(ct));
|
||||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
|
||||||
httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync("/api/cron", ct);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
|
||||||
return Results.Ok(data ?? new List<CronJobEntry>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallbackJobs = new List<object>
|
|
||||||
{
|
|
||||||
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
|
|
||||||
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
|
|
||||||
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
|
|
||||||
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
|
|
||||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
|
|
||||||
};
|
|
||||||
return Results.Ok(fallbackJobs);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("upcoming")]
|
[HttpGet("upcoming")]
|
||||||
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
|
||||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
|
||||||
httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
|
||||||
return Results.Ok(data ?? new List<UpcomingCronEntry>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var fallback = new List<object>
|
|
||||||
{
|
|
||||||
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
|
|
||||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
|
|
||||||
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
|
|
||||||
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
|
|
||||||
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
|
|
||||||
};
|
|
||||||
return Results.Ok(fallback);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.Models;
|
using Nexus.Api.Models;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
@@ -6,262 +7,157 @@ namespace Nexus.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
public class DashboardController(OpenClawGatewayClient gateway, ILogger<DashboardController> logger)
|
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
|
||||||
: ControllerBase
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Gateway health + session_status + subagents count.
|
|
||||||
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
public async Task<DashboardStatus> GetStatus()
|
public async Task<DashboardStatus> GetStatus()
|
||||||
{
|
=> await dashboardService.GetStatusAsync();
|
||||||
try
|
|
||||||
{
|
|
||||||
return await gateway.GetStatusAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard status check failed");
|
|
||||||
return new DashboardStatus(false, "Offline", 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all agents with their current status.
|
|
||||||
/// Combines sessions_list + sub_agents_list.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("agents")]
|
[HttpGet("agents")]
|
||||||
public async Task<List<DashboardAgentInfo>> GetAgents()
|
public async Task<List<DashboardAgentInfo>> GetAgents()
|
||||||
{
|
=> await dashboardService.GetAgentsAsync();
|
||||||
try
|
|
||||||
{
|
|
||||||
return await gateway.GetAgentsAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
|
||||||
return new List<DashboardAgentInfo>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the latest assistant messages (operations/feed) from the Iris session.
|
|
||||||
/// Filtered to role == "assistant" — those are the work feed entries.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("operations")]
|
[HttpGet("operations")]
|
||||||
public async Task<List<FeedEntry>> GetOperations([FromQuery] int limit = 20)
|
public async Task<List<FeedEntry>> GetOperations(
|
||||||
{
|
[FromQuery] int limit = 20,
|
||||||
try
|
[FromQuery] string? agent = null)
|
||||||
{
|
=> await dashboardService.GetOperationsAsync(limit, agent);
|
||||||
var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100));
|
|
||||||
var feed = new List<FeedEntry>();
|
|
||||||
|
|
||||||
foreach (var msg in messages)
|
|
||||||
{
|
|
||||||
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(msg.Content))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Parse timestamp for display-friendly "time ago"
|
|
||||||
var ts = ParseTimestamp(msg.Timestamp);
|
|
||||||
var timeAgo = FormatTimeAgo(ts);
|
|
||||||
|
|
||||||
// Extract a short agent indicator and action from content
|
|
||||||
var (agent, action) = ExtractAgentAction(msg.Content);
|
|
||||||
|
|
||||||
feed.Add(new FeedEntry(agent, action, msg.Timestamp, timeAgo));
|
|
||||||
}
|
|
||||||
|
|
||||||
return feed;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
|
||||||
return new List<FeedEntry>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Send a chat message to the Iris session.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("chat/send")]
|
[HttpPost("chat/send")]
|
||||||
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Message))
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
return new ChatResponse(false, null, "Message is required");
|
return new ChatResponse(false, null, "Message is required");
|
||||||
|
|
||||||
try
|
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
|
||||||
{
|
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
|
||||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
|
|
||||||
? "iris"
|
|
||||||
: request.AgentId.Trim();
|
|
||||||
|
|
||||||
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard chat send failed");
|
|
||||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns chat messages (user + assistant only, not tool messages).
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("chat/messages")]
|
[HttpGet("chat/messages")]
|
||||||
public async Task<List<MessageEntry>> GetMessages(
|
public async Task<List<MessageEntry>> GetMessages(
|
||||||
[FromQuery] string? sessionKey,
|
[FromQuery] string? sessionKey,
|
||||||
[FromQuery] int limit = 50,
|
[FromQuery] int limit = 50,
|
||||||
[FromQuery] int offset = 0)
|
[FromQuery] int offset = 0)
|
||||||
{
|
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
|
||||||
try
|
|
||||||
{
|
|
||||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
|
||||||
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
|
||||||
|
|
||||||
return messages
|
|
||||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
|
||||||
return new List<MessageEntry>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the cron queue / pending tasks.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("queue")]
|
[HttpGet("queue")]
|
||||||
public async Task<List<QueueItem>> GetQueue()
|
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||||
|
=> await dashboardService.GetQueueAsync(ct);
|
||||||
|
|
||||||
|
[HttpDelete("queue/{id}")]
|
||||||
|
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
|
||||||
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
return await gateway.GetQueueAsync();
|
QueueDeleteOutcome.Deleted => NoContent(),
|
||||||
}
|
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
|
||||||
catch (Exception ex)
|
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
|
||||||
{
|
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||||
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||||
return new List<QueueItem>();
|
_ => StatusCode(500, new { error = "Internal error" })
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("queue/{id}/priority")]
|
||||||
|
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
|
||||||
|
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||||
|
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||||
|
_ => Ok(new { status = "ok", priority = result.NewPriority })
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the current model and provider for a specific agent session.
|
|
||||||
/// Calls session_status with the agent's session key.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("agents/{id}/model")]
|
[HttpGet("agents/{id}/model")]
|
||||||
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
||||||
{
|
{
|
||||||
try
|
var info = await dashboardService.GetAgentModelAsync(id);
|
||||||
{
|
return info is null
|
||||||
var info = await gateway.GetAgentModelAsync(id);
|
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
|
||||||
if (info is null)
|
: Ok(info);
|
||||||
return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" });
|
|
||||||
return Ok(info);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id);
|
|
||||||
return StatusCode(500, new { error = "Internal error" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the model for a specific agent session.
|
|
||||||
/// Calls session_status with model parameter.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPut("agents/{id}/model")]
|
[HttpPut("agents/{id}/model")]
|
||||||
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Model))
|
if (string.IsNullOrWhiteSpace(request.Model))
|
||||||
return BadRequest(new { error = "Model is required" });
|
return BadRequest(new { error = "Model is required" });
|
||||||
|
|
||||||
try
|
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
|
||||||
{
|
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||||
var ok = await gateway.SetAgentModelAsync(id, request.Model);
|
|
||||||
if (!ok)
|
|
||||||
return StatusCode(502, new { error = "Gateway did not accept the change" });
|
|
||||||
return Ok(new { status = "ok", model = request.Model });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id);
|
|
||||||
return StatusCode(500, new { error = "Internal error" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
[HttpGet("agents/{id}/activity")]
|
||||||
/// Returns the list of available models that can be assigned to agents.
|
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
||||||
/// </summary>
|
=> await dashboardService.GetAgentActivityAsync(id, limit);
|
||||||
|
|
||||||
[HttpGet("models")]
|
[HttpGet("models")]
|
||||||
public ActionResult<List<ModelOption>> GetAvailableModels()
|
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||||
|
=> Ok(dashboardService.GetAvailableModels());
|
||||||
|
|
||||||
|
// ── Task Endpoints ──
|
||||||
|
|
||||||
|
[HttpGet("tasks")]
|
||||||
|
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var models = new List<ModelOption>
|
var tasks = await taskService.GetOpenAsync(ct);
|
||||||
|
return tasks.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
||||||
|
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
return BadRequest(new { error = "Title is required." });
|
||||||
|
|
||||||
|
var task = await taskService.CreateDashboardTaskAsync(
|
||||||
|
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||||
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||||
|
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await taskService.UpdateDashboardTaskAsync(
|
||||||
|
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||||
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
new ModelOption("openai/gpt-5.4", "GPT-5.4", "openai"),
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
new ModelOption("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek"),
|
_ => Ok(MapToDto(result.Task!))
|
||||||
new ModelOption("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek")
|
|
||||||
};
|
};
|
||||||
return Ok(models);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Helpers ==========
|
[HttpDelete("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
||||||
private static DateTimeOffset ParseTimestamp(string timestamp)
|
|
||||||
{
|
{
|
||||||
if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt))
|
var result = await taskService.DeleteAsync(id, ct);
|
||||||
return dt;
|
return result.Outcome switch
|
||||||
return DateTimeOffset.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatTimeAgo(DateTimeOffset ts)
|
|
||||||
{
|
|
||||||
var diff = DateTimeOffset.UtcNow - ts;
|
|
||||||
if (diff.TotalMinutes < 1) return "just now";
|
|
||||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
|
|
||||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
|
|
||||||
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
|
|
||||||
return ts.ToString("MMM dd");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string Agent, string Action) ExtractAgentAction(string content)
|
|
||||||
{
|
|
||||||
// Take first line or first ~80 chars as the action summary
|
|
||||||
var firstLine = content.Split('\n', 2)[0].Trim();
|
|
||||||
var summary = firstLine.Length > 80 ? firstLine[..80] + "…" : firstLine;
|
|
||||||
|
|
||||||
// Try to identify which agent this came from
|
|
||||||
var agent = "Iris";
|
|
||||||
foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" })
|
|
||||||
{
|
{
|
||||||
var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
if (idx >= 0)
|
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
|
||||||
{
|
_ => NoContent()
|
||||||
var after = content[(idx + marker.Length)..].TrimStart();
|
};
|
||||||
var end = after.IndexOfAny(['\n', '\r', ',', '.']);
|
|
||||||
var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(found) && found.Length < 30)
|
|
||||||
{
|
|
||||||
agent = found;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find agent name at the start in brackets like [Agent: Iris]
|
|
||||||
if (agent == "Iris")
|
|
||||||
{
|
|
||||||
var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]");
|
|
||||||
if (bracketMatch.Success)
|
|
||||||
agent = bracketMatch.Groups[1].Value.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (agent, summary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("tasks/{id:guid}/status")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||||
|
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/docs")]
|
[Route("api/v1/docs")]
|
||||||
public class DocsController : ControllerBase
|
public class DocsController(IDocService docService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IResult GetAll()
|
public IResult GetAll()
|
||||||
{
|
=> Results.Ok(docService.GetAll());
|
||||||
var workspaceRoot = "/mnt/workspace-iris";
|
|
||||||
var results = new List<object>();
|
|
||||||
|
|
||||||
void ScanDir(string dir, string category)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir)) return;
|
|
||||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
|
||||||
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
|
|
||||||
continue;
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
results.Add(new
|
|
||||||
{
|
|
||||||
name = fi.Name,
|
|
||||||
path = file.Replace(workspaceRoot, "").TrimStart('/'),
|
|
||||||
category,
|
|
||||||
type = ext.Replace(".", ""),
|
|
||||||
size = fi.Length,
|
|
||||||
modifiedAt = fi.LastWriteTimeUtc
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
|
|
||||||
ScanDir("/mnt/workspace-iris/skills", "skills");
|
|
||||||
ScanDir("/mnt/workspace-iris", "workspace");
|
|
||||||
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
|
|
||||||
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
|
|
||||||
|
|
||||||
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{**path}")]
|
[HttpGet("{**path}")]
|
||||||
public async Task<IResult> GetFile(string path)
|
public async Task<IResult> GetFile(string path)
|
||||||
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
return Results.BadRequest("Path required.");
|
return Results.BadRequest("Path required.");
|
||||||
|
|
||||||
string? resolvedPath = null;
|
var file = await docService.GetFileAsync(path);
|
||||||
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" })
|
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||||
{
|
|
||||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
|
|
||||||
{
|
|
||||||
resolvedPath = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedPath is null)
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
|
|
||||||
var fi = new FileInfo(resolvedPath);
|
|
||||||
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ namespace Nexus.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet("/health/live")]
|
||||||
|
public IResult Live()
|
||||||
|
{
|
||||||
|
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("/health")]
|
[HttpGet("/health")]
|
||||||
public async Task<IResult> Get(CancellationToken ct)
|
public async Task<IResult> Get(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,100 +1,20 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/incidents")]
|
[Route("api/v1/incidents")]
|
||||||
public class IncidentsController : ControllerBase
|
public class IncidentsController(IIncidentService incidentService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll()
|
public async Task<IResult> GetAll()
|
||||||
{
|
=> Results.Ok(await incidentService.GetAllAsync());
|
||||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
|
||||||
if (!Directory.Exists(basePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var incidents = new List<object>();
|
|
||||||
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
if (fi.Length > 1_000_000) continue;
|
|
||||||
var name = Path.GetFileNameWithoutExtension(file);
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(file);
|
|
||||||
|
|
||||||
var title = name;
|
|
||||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
|
||||||
if (titleMatch.Success)
|
|
||||||
title = titleMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var date = (string?)null;
|
|
||||||
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
|
|
||||||
if (dateMatch.Success)
|
|
||||||
date = dateMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
var severity = "unknown";
|
|
||||||
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
|
|
||||||
if (severityMatch.Success)
|
|
||||||
severity = severityMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
|
||||||
var excerpt = excerptEnd > 0
|
|
||||||
? content[..excerptEnd].Trim()
|
|
||||||
: content[..Math.Min(300, content.Length)].Trim();
|
|
||||||
if (excerpt.Length > 200)
|
|
||||||
excerpt = excerpt[..200] + "\u2026";
|
|
||||||
|
|
||||||
incidents.Add(new
|
|
||||||
{
|
|
||||||
name = Path.GetFileName(file),
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
severity,
|
|
||||||
excerpt,
|
|
||||||
size = fi.Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(incidents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<IResult> GetOne(string name)
|
public async Task<IResult> GetOne(string name)
|
||||||
{
|
{
|
||||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
var incident = await incidentService.GetByNameAsync(name);
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath))
|
return incident is null ? Results.NotFound() : Results.Ok(incident);
|
||||||
return Results.BadRequest("Invalid filename.");
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
{
|
|
||||||
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
filePath = Path.Combine(basePath, name + ".md");
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
|
||||||
var fi = new FileInfo(filePath!);
|
|
||||||
var fileName = Path.GetFileName(filePath!);
|
|
||||||
|
|
||||||
var title = fileName;
|
|
||||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
|
||||||
if (titleMatch.Success)
|
|
||||||
title = titleMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var date = (string?)null;
|
|
||||||
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
|
|
||||||
if (dateMatch.Success)
|
|
||||||
date = dateMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
name = fileName,
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
content,
|
|
||||||
size = fi.Length
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/memory")]
|
[Route("api/v1/memory")]
|
||||||
public class MemoryController : ControllerBase
|
public class MemoryController(IMemoryService memoryService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IResult GetAll()
|
public async Task<IResult> GetAll()
|
||||||
{
|
=> Results.Ok(await memoryService.GetAllAsync());
|
||||||
var basePath = "/mnt/workspace-iris/memory";
|
|
||||||
if (!Directory.Exists(basePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(basePath, "*.md")
|
|
||||||
.Select(f => new FileInfo(f))
|
|
||||||
.OrderByDescending(f => f.Name)
|
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
name = f.Name,
|
|
||||||
path = f.FullName.Replace(basePath, "").TrimStart('/'),
|
|
||||||
size = f.Length,
|
|
||||||
modifiedAt = f.LastWriteTimeUtc
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (System.IO.File.Exists(longTermPath))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(longTermPath);
|
|
||||||
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<IResult> Search([FromQuery] string q)
|
public async Task<IResult> Search([FromQuery] string q)
|
||||||
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||||
return Results.BadRequest("Query must be at least 2 characters.");
|
return Results.BadRequest("Query must be at least 2 characters.");
|
||||||
|
|
||||||
var basePath = "/mnt/workspace-iris/memory";
|
return Results.Ok(await memoryService.SearchAsync(q));
|
||||||
var results = new List<object>();
|
|
||||||
|
|
||||||
const int maxFiles = 50;
|
|
||||||
const int maxFileSize = 1_000_000;
|
|
||||||
|
|
||||||
async Task SearchDir(string dir)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir)) return;
|
|
||||||
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
if (fi.Length > maxFileSize) continue;
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(file))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
var start = Math.Max(0, idx - 60);
|
|
||||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
|
||||||
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SearchDir(basePath);
|
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (System.IO.File.Exists(longTermPath))
|
|
||||||
{
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(longTermPath))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
var start = Math.Max(0, idx - 60);
|
|
||||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
|
||||||
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(results);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<IResult> GetFile(string name)
|
public async Task<IResult> GetFile(string name)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath))
|
var file = await memoryService.GetFileAsync(name);
|
||||||
return Results.BadRequest("Invalid filename.");
|
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
filePath = longTermPath;
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
|
||||||
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,15 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.Integrations;
|
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/operations")]
|
[Route("api/v1/operations")]
|
||||||
public class OperationsController(
|
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||||
IAgentRuntime runtime,
|
|
||||||
IAgentService agentService,
|
|
||||||
IProjectRepository projectRepo,
|
|
||||||
ITaskRepository taskRepo,
|
|
||||||
IActivityRepository activityRepo) : ControllerBase
|
|
||||||
{
|
{
|
||||||
[HttpGet("snapshot")]
|
[HttpGet("snapshot")]
|
||||||
|
[Authorize]
|
||||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||||
var runtimeTask = runtime.GetStatusAsync(ct);
|
|
||||||
var agentsTask = agentService.GetAgentsAsync(ct);
|
|
||||||
var projectsTask = projectRepo.GetAllAsync(ct);
|
|
||||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
|
||||||
var activityTask = activityRepo.GetRecentAsync(20, ct);
|
|
||||||
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
|
|
||||||
|
|
||||||
var tasks = tasksTask.Result;
|
|
||||||
var projects = projectsTask.Result;
|
|
||||||
var agents = agentsTask.Result;
|
|
||||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
|
||||||
|
|
||||||
var runtimeStatus = runtimeTask.Result;
|
|
||||||
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
|
|
||||||
|
|
||||||
var lastIncident = tasks
|
|
||||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
|
||||||
.OrderByDescending(x => x.UpdatedAt)
|
|
||||||
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
var projectHealth = new
|
|
||||||
{
|
|
||||||
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
|
||||||
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
|
||||||
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
|
||||||
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
|
||||||
};
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
generatedAt = DateTimeOffset.UtcNow,
|
|
||||||
runtime = runtimeStatus,
|
|
||||||
models = Array.Empty<object>(),
|
|
||||||
runtimeHealthy,
|
|
||||||
metrics = new
|
|
||||||
{
|
|
||||||
activeAgents = agents.Count,
|
|
||||||
queuedTasks = tasks.Count - completedTasks,
|
|
||||||
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
|
||||||
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
|
||||||
},
|
|
||||||
lastIncident,
|
|
||||||
projectHealth,
|
|
||||||
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
|
||||||
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
|
||||||
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
|
||||||
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/projects")]
|
[Route("api/v1/projects")]
|
||||||
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase
|
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
=> Results.Ok(await projectRepo.GetAllAsync(ct));
|
=> Results.Ok(await projectService.GetAllAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var project = await projectService.GetByIdAsync(id, ct);
|
||||||
|
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
||||||
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
|
|||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var project = new Project
|
var project = await projectService.CreateAsync(request, ct);
|
||||||
{
|
|
||||||
Name = request.Name.Trim(),
|
|
||||||
Description = request.Description?.Trim() ?? string.Empty,
|
|
||||||
Status = OperationalStatus.Online
|
|
||||||
};
|
|
||||||
await projectRepo.AddAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
|
||||||
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
|
||||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
|
||||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
var project = await projectService.UpdateAsync(id, request, ct);
|
||||||
if (project is null) return Results.NotFound();
|
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
project.Name = request.Name.Trim();
|
|
||||||
if (request.Description is not null)
|
|
||||||
project.Description = request.Description.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
|
||||||
project.Status = parsedStatus;
|
|
||||||
|
|
||||||
await projectRepo.UpdateAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
|
||||||
return Results.Ok(project);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
var result = await projectService.DeleteAsync(id, ct);
|
||||||
if (project is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
|
||||||
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
|
|
||||||
if (hasTasks)
|
|
||||||
{
|
{
|
||||||
project.Status = OperationalStatus.Offline;
|
ProjectDeleteOutcome.NotFound => Results.NotFound(),
|
||||||
await projectRepo.UpdateAsync(project, ct);
|
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
_ => Results.NoContent()
|
||||||
return Results.Ok(project);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
await projectRepo.DeleteAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tasks")]
|
[Route("api/v1/tasks")]
|
||||||
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase
|
public class TasksController(ITaskService taskService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
=> Results.Ok(await taskRepo.GetAllAsync(ct));
|
=> Results.Ok(await taskService.GetAllAsync(ct));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
||||||
@@ -19,107 +19,84 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
|
|||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = await taskService.CreateAsync(request, ct);
|
||||||
{
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
|
||||||
ProjectId = request.ProjectId
|
|
||||||
};
|
|
||||||
await taskRepo.AddAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
|
||||||
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("pending-approval")]
|
[HttpGet("pending-approval")]
|
||||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pending = await taskRepo.GetPendingApprovalAsync(ct);
|
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||||
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/approve")]
|
[HttpPost("{id:guid}/approve")]
|
||||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.ApproveAsync(id, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
return Results.Problem(
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
title: "Approval denied",
|
title: "Approval denied",
|
||||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
};
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reject")]
|
[HttpPost("{id:guid}/reject")]
|
||||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.RejectAsync(id, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
return Results.Problem(
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
title: "Rejection denied",
|
title: "Rejection denied",
|
||||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
};
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/state")]
|
[HttpPatch("{id:guid}/state")]
|
||||||
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var allowedStates = TaskStateHelper.AllStates;
|
if (!TaskStateHelper.IsValidState(request.State))
|
||||||
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
|
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateStateAsync(id, request.State, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase));
|
{
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
_ => Results.Ok(result.Task)
|
||||||
return Results.Ok(task);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
|
||||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
|
||||||
if (task is null) return Results.NotFound();
|
|
||||||
|
|
||||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
|
||||||
return Results.Problem(
|
|
||||||
title: "Task deletion denied",
|
|
||||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
|
||||||
await taskRepo.DeleteAsync(task, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateAsync(id, request, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
[HttpDelete("{id:guid}")]
|
||||||
task.Title = request.Title.Trim();
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
{
|
||||||
task.Priority = request.Priority.Trim();
|
var result = await taskService.DeleteAsync(id, ct);
|
||||||
if (request.ProjectId.HasValue)
|
return result.Outcome switch
|
||||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
{
|
||||||
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
title: "Task deletion denied",
|
||||||
return Results.Ok(task);
|
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.NoContent()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/team")]
|
[Route("api/v1/team")]
|
||||||
public class TeamController(IAgentService agentService) : ControllerBase
|
public class TeamController(ITeamService teamService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetTeam(CancellationToken ct)
|
public async Task<IResult> GetTeam(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await teamService.GetTeamAsync(ct));
|
||||||
var agents = await agentService.GetAgentsAsync(ct);
|
|
||||||
var team = new List<object>();
|
|
||||||
|
|
||||||
foreach (var agent in agents)
|
|
||||||
{
|
|
||||||
string identity = "";
|
|
||||||
string workspace = agent.Workspace ?? "";
|
|
||||||
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
|
|
||||||
{
|
|
||||||
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
|
||||||
if (System.IO.File.Exists(identityFile))
|
|
||||||
{
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
|
|
||||||
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
|
|
||||||
identity = string.Join("\n", lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
team.Add(new
|
|
||||||
{
|
|
||||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
|
||||||
identity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(team);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,13 @@ public sealed class WorkTask
|
|||||||
{
|
{
|
||||||
public Guid Id { get; init; } = Guid.NewGuid();
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
|
public string? Detail { get; set; }
|
||||||
public string State { get; set; } = "Backlog";
|
public string State { get; set; } = "Backlog";
|
||||||
public string Priority { get; set; } = "Normal";
|
public string Priority { get; set; } = "Normal";
|
||||||
|
public string Source { get; set; } = "bao";
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
public Guid? ProjectId { get; set; }
|
public Guid? ProjectId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260611154800_AddTaskDetailFields")]
|
||||||
|
partial class AddTaskDetailFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskDetailFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(60)",
|
||||||
|
maxLength: 60,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
defaultValueSql: "NOW()");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Detail",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Source",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(60)",
|
||||||
|
maxLength: 60,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "bao");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_AssignedTo",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "AssignedTo");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_Source",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "Source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_AssignedTo",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_Source",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Detail",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Source",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,6 +172,17 @@ namespace Nexus.Api.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
b.Property<string>("Priority")
|
b.Property<string>("Priority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -179,6 +190,11 @@ namespace Nexus.Api.Migrations
|
|||||||
b.Property<Guid?>("ProjectId")
|
b.Property<Guid?>("ProjectId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -193,6 +209,10 @@ namespace Nexus.Api.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
b.ToTable("Tasks");
|
b.ToTable("Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
|||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
|
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
|
||||||
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240);
|
modelBuilder.Entity<WorkTask>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Title).HasMaxLength(240);
|
||||||
|
entity.Property(x => x.Detail).HasMaxLength(2000);
|
||||||
|
entity.Property(x => x.Source).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.AssignedTo).HasMaxLength(60);
|
||||||
|
entity.HasIndex(x => x.Source);
|
||||||
|
entity.HasIndex(x => x.AssignedTo);
|
||||||
|
});
|
||||||
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
||||||
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
||||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ RUN dotnet publish -c Release -o /app/publish
|
|||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
RUN apk add --no-cache curl
|
||||||
USER $APP_UID
|
USER $APP_UID
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
|
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ public sealed record DashboardAgentInfo(
|
|||||||
bool IsActive,
|
bool IsActive,
|
||||||
string? CurrentTask,
|
string? CurrentTask,
|
||||||
string? Description,
|
string? Description,
|
||||||
string[] Tags
|
string[] Tags,
|
||||||
|
int Progress = 0,
|
||||||
|
int Workload = 0,
|
||||||
|
string? Goal = null,
|
||||||
|
string RoleBadge = "badge-slate",
|
||||||
|
string StatusLabel = "Bereit",
|
||||||
|
string? Elapsed = null,
|
||||||
|
string? Think = null,
|
||||||
|
string? Next = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record MessageEntry(
|
public sealed record MessageEntry(
|
||||||
@@ -32,7 +40,9 @@ public sealed record FeedEntry(
|
|||||||
string Agent,
|
string Agent,
|
||||||
string Action,
|
string Action,
|
||||||
string Timestamp,
|
string Timestamp,
|
||||||
string Time
|
string Time,
|
||||||
|
string? AgentId = null,
|
||||||
|
string? Type = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record DashboardStatus(
|
public sealed record DashboardStatus(
|
||||||
@@ -45,7 +55,10 @@ public sealed record DashboardStatus(
|
|||||||
public sealed record QueueItem(
|
public sealed record QueueItem(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
string Status
|
string Status,
|
||||||
|
string Priority,
|
||||||
|
string Source,
|
||||||
|
string WaitTime
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record AgentModelInfo(
|
public sealed record AgentModelInfo(
|
||||||
@@ -62,3 +75,42 @@ public sealed record ModelOption(
|
|||||||
string Name,
|
string Name,
|
||||||
string Provider
|
string Provider
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Dashboard Task DTOs ──
|
||||||
|
|
||||||
|
public sealed record DashboardTaskDto(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string Source,
|
||||||
|
string State,
|
||||||
|
string Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset UpdatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record CreateDashboardTaskRequest(
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Source,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateDashboardTaskRequest(
|
||||||
|
string? Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Source,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateDashboardTaskStatusRequest(
|
||||||
|
string Status
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record AgentActivityEntry(
|
||||||
|
string Time,
|
||||||
|
string Text
|
||||||
|
);
|
||||||
|
|||||||
+11
-1
@@ -112,7 +112,7 @@ builder.Services.AddHttpClient("gateway", client =>
|
|||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
|
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
?? "http://127.0.0.1:18789");
|
?? "http://127.0.0.1:18789");
|
||||||
@@ -123,6 +123,16 @@ builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
|
|||||||
builder.Services.AddTransient<ModelRoutingService>();
|
builder.Services.AddTransient<ModelRoutingService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
builder.Services.AddScoped<IAgentService, AgentService>();
|
||||||
|
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||||
|
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||||
|
builder.Services.AddScoped<ITaskService, TaskService>();
|
||||||
|
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||||
|
builder.Services.AddScoped<ITeamService, TeamService>();
|
||||||
|
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||||
|
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
||||||
|
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
||||||
|
builder.Services.AddSingleton<IDocService, DocService>();
|
||||||
|
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
||||||
|
|
||||||
// --- Repositories ---
|
// --- Repositories ---
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ public interface IUserRepository
|
|||||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
|
||||||
// Refresh token operations
|
|
||||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||||
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||||
|
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
|
||||||
|
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task SaveChangesAsync(CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
||||||
=> db.SaveChangesAsync(ct);
|
=> db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||||
|
if (token is null || token.RevokedAt is not null) return;
|
||||||
|
|
||||||
|
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
|
token.ConcurrencyStamp = Guid.NewGuid();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var activeTokens = await db.RefreshTokens
|
||||||
|
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (activeTokens.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var token in activeTokens)
|
||||||
|
{
|
||||||
|
token.RevokedAt = now;
|
||||||
|
token.ConcurrencyStamp = Guid.NewGuid();
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
||||||
@@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
if (oldTokens.Count > 0)
|
if (oldTokens.Count > 0)
|
||||||
|
{
|
||||||
db.RefreshTokens.RemoveRange(oldTokens);
|
db.RefreshTokens.RemoveRange(oldTokens);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
|
||||||
=> db.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class AgentConfigService : IAgentConfigService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||||
|
};
|
||||||
|
|
||||||
|
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
|
||||||
|
{
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!Directory.Exists(workspacePath))
|
||||||
|
return Array.Empty<AgentConfigFileInfo>();
|
||||||
|
|
||||||
|
return Directory.GetFiles(workspacePath, "*.md")
|
||||||
|
.Select(f => new FileInfo(f))
|
||||||
|
.Where(f => AllowedFiles.Contains(f.Name))
|
||||||
|
.OrderBy(f => f.Name)
|
||||||
|
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(safePath!, ct);
|
||||||
|
var fi = new FileInfo(safePath!);
|
||||||
|
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var tempPath = safePath + ".tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||||
|
File.Move(tempPath, safePath!, overwrite: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fi = new FileInfo(safePath!);
|
||||||
|
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ public sealed class AuthService : IAuthService
|
|||||||
|
|
||||||
if (token.RevokedAt is not null)
|
if (token.RevokedAt is not null)
|
||||||
{
|
{
|
||||||
await RevokeFamilyAsync(token.FamilyId, ct);
|
await _users.RevokeFamilyAsync(token.FamilyId, ct);
|
||||||
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -84,23 +84,12 @@ public sealed class AuthService : IAuthService
|
|||||||
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
||||||
|
|
||||||
var tokenHash = HashToken(refreshToken);
|
var tokenHash = HashToken(refreshToken);
|
||||||
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
await _users.RevokeTokenAsync(tokenHash, ct);
|
||||||
if (token is null || token.RevokedAt is not null) return;
|
|
||||||
|
|
||||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
|
||||||
token.ConcurrencyStamp = Guid.NewGuid();
|
|
||||||
await _users.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||||
=> Task.Run(async () =>
|
=> _users.GetByIdAsync(userId, ct).AsTask();
|
||||||
{
|
|
||||||
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
|
|
||||||
// For read-only access, we call it but the result shouldn't be mutated
|
|
||||||
return await _users.GetByIdAsync(userId, ct);
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -228,19 +217,6 @@ public sealed class AuthService : IAuthService
|
|||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
foreach (var token in activeTokens)
|
|
||||||
{
|
|
||||||
token.RevokedAt = now;
|
|
||||||
token.ConcurrencyStamp = Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _users.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateRefreshToken()
|
private static string GenerateRefreshToken()
|
||||||
{
|
{
|
||||||
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class CalendarService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<CalendarService> logger) : ICalendarService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateGatewayClient();
|
||||||
|
var response = await client.GetAsync("/api/cron", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||||
|
return data ?? new List<CronJobEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildFallbackCronJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateGatewayClient();
|
||||||
|
var response = await client.GetAsync("/api/cron/upcoming", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||||
|
return data ?? new List<UpcomingCronEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildFallbackUpcomingJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateGatewayClient()
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient("gateway");
|
||||||
|
var token = configuration["Integrations:OpenClaw:Token"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
|
||||||
|
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
|
||||||
|
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
|
||||||
|
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
|
||||||
|
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
|
||||||
|
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
|
||||||
|
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
|
||||||
|
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
|
||||||
|
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DashboardService(
|
||||||
|
IOpenClawGatewayClient gateway,
|
||||||
|
ITaskService taskService,
|
||||||
|
ILogger<DashboardService> logger) : IDashboardService
|
||||||
|
{
|
||||||
|
public async Task<DashboardStatus> GetStatusAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetStatusAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard status check failed");
|
||||||
|
return new DashboardStatus(false, "Offline", 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentFilter))
|
||||||
|
{
|
||||||
|
entries = entries
|
||||||
|
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.SendChatMessageAsync(agentId, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||||
|
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||||
|
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||||
|
return messages
|
||||||
|
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cronTask = gateway.GetQueueAsync();
|
||||||
|
var tasksTask = taskService.GetOpenAsync(ct);
|
||||||
|
await Task.WhenAll(cronTask, tasksTask);
|
||||||
|
|
||||||
|
var merged = new List<QueueItem>(cronTask.Result);
|
||||||
|
foreach (var t in tasksTask.Result)
|
||||||
|
{
|
||||||
|
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var ok = await gateway.DeleteCronJobAsync(id);
|
||||||
|
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||||
|
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||||
|
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||||
|
|
||||||
|
var result = await taskService.CompleteViaQueueAsync(guid, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
|
||||||
|
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||||
|
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-"))
|
||||||
|
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
|
||||||
|
|
||||||
|
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||||
|
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
|
||||||
|
|
||||||
|
var result = await taskService.CyclePriorityAsync(guid, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
|
||||||
|
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentModelAsync(agentId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetAgentModelAsync(string agentId, string model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.SetAgentModelAsync(agentId, model);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
|
||||||
|
|
||||||
|
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" or "critical" or "urgent" => "high",
|
||||||
|
"low" or "minor" => "low",
|
||||||
|
_ => "medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["high"] = 0, ["medium"] = 1, ["low"] = 2
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DocService : IDocService
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
|
||||||
|
private static readonly string[] SearchRoots =
|
||||||
|
[
|
||||||
|
"/mnt/workspace-iris",
|
||||||
|
"/home/node/.openclaw/workspace/nexus"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string Dir, string Category)[] ScanDirectories =
|
||||||
|
[
|
||||||
|
("/mnt/workspace-iris/nexus-phases", "phases"),
|
||||||
|
("/mnt/workspace-iris/skills", "skills"),
|
||||||
|
("/mnt/workspace-iris", "workspace"),
|
||||||
|
("/home/node/.openclaw/workspace/nexus", "nexus"),
|
||||||
|
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
|
||||||
|
];
|
||||||
|
|
||||||
|
public IReadOnlyList<DocFileInfo> GetAll()
|
||||||
|
{
|
||||||
|
var results = new List<DocFileInfo>();
|
||||||
|
|
||||||
|
foreach (var (dir, category) in ScanDirectories)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir)) continue;
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext)) continue;
|
||||||
|
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
results.Add(new DocFileInfo(
|
||||||
|
fi.Name,
|
||||||
|
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
|
||||||
|
category,
|
||||||
|
ext.Replace(".", ""),
|
||||||
|
fi.Length,
|
||||||
|
fi.LastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocFileContent?> GetFileAsync(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string? resolvedPath = null;
|
||||||
|
foreach (var root in SearchRoots)
|
||||||
|
{
|
||||||
|
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
|
||||||
|
{
|
||||||
|
resolvedPath = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPath is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(resolvedPath);
|
||||||
|
var fi = new FileInfo(resolvedPath);
|
||||||
|
var relativePath = resolvedPath
|
||||||
|
.Replace("/mnt/workspace-iris/", "")
|
||||||
|
.Replace("/home/node/.openclaw/workspace/nexus/", "");
|
||||||
|
|
||||||
|
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public interface IAgentConfigService
|
||||||
|
{
|
||||||
|
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||||
|
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
||||||
|
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface ICalendarService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
|
||||||
|
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
|
||||||
|
|
||||||
|
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
|
||||||
|
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
|
||||||
|
|
||||||
|
public interface IDashboardService
|
||||||
|
{
|
||||||
|
Task<DashboardStatus> GetStatusAsync();
|
||||||
|
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||||
|
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
|
||||||
|
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||||
|
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||||
|
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||||
|
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||||
|
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||||
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
|
||||||
|
List<ModelOption> GetAvailableModels();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record DocFileInfo(
|
||||||
|
string Name,
|
||||||
|
string Path,
|
||||||
|
string Category,
|
||||||
|
string Type,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record DocFileContent(
|
||||||
|
string Name,
|
||||||
|
string Path,
|
||||||
|
string Content,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public interface IDocService
|
||||||
|
{
|
||||||
|
IReadOnlyList<DocFileInfo> GetAll();
|
||||||
|
Task<DocFileContent?> GetFileAsync(string path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record IncidentSummary(
|
||||||
|
string Name,
|
||||||
|
string Title,
|
||||||
|
string? Date,
|
||||||
|
string Severity,
|
||||||
|
string Excerpt,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public sealed record IncidentDetail(
|
||||||
|
string Name,
|
||||||
|
string Title,
|
||||||
|
string? Date,
|
||||||
|
string Content,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public interface IIncidentService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
|
||||||
|
Task<IncidentDetail?> GetByNameAsync(string name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
|
||||||
|
|
||||||
|
public interface IMemoryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
|
||||||
|
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
|
||||||
|
Task<MemoryFileContent?> GetFileAsync(string name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IOpenClawGatewayClient
|
||||||
|
{
|
||||||
|
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
|
||||||
|
Task<DashboardStatus> GetStatusAsync();
|
||||||
|
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||||
|
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
|
||||||
|
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||||
|
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||||
|
Task<List<QueueItem>> GetQueueAsync();
|
||||||
|
Task<bool> DeleteCronJobAsync(string id);
|
||||||
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
|
||||||
|
List<ModelOption> GetAvailableModels();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IOperationsService
|
||||||
|
{
|
||||||
|
Task<object> GetSnapshotAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
|
||||||
|
|
||||||
|
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
|
||||||
|
|
||||||
|
public interface IProjectService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
|
||||||
|
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
|
||||||
|
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
|
||||||
|
|
||||||
|
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
|
||||||
|
|
||||||
|
public interface ITaskService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Dashboard-facing task operations
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record TeamMember(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Role,
|
||||||
|
string Model,
|
||||||
|
OperationalStatus Status,
|
||||||
|
DateTimeOffset? LastSeen,
|
||||||
|
string? Workspace,
|
||||||
|
string? Description,
|
||||||
|
string Identity);
|
||||||
|
|
||||||
|
public interface ITeamService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed partial class IncidentService : IIncidentService
|
||||||
|
{
|
||||||
|
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(BasePath))
|
||||||
|
return Array.Empty<IncidentSummary>();
|
||||||
|
|
||||||
|
var incidents = new List<IncidentSummary>();
|
||||||
|
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
if (fi.Length > 1_000_000) continue;
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
var title = ExtractTitle(name, content);
|
||||||
|
var date = ExtractDate(name);
|
||||||
|
var severity = ExtractSeverity(content);
|
||||||
|
var excerpt = ExtractExcerpt(content);
|
||||||
|
|
||||||
|
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return incidents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IncidentDetail?> GetByNameAsync(string name)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
{
|
||||||
|
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
filePath = Path.Combine(BasePath, name + ".md");
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(filePath!);
|
||||||
|
var fi = new FileInfo(filePath!);
|
||||||
|
var fileName = Path.GetFileName(filePath!);
|
||||||
|
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
|
||||||
|
var date = ExtractDate(fileName);
|
||||||
|
|
||||||
|
return new IncidentDetail(fileName, title, date, content, fi.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTitle(string name, string content)
|
||||||
|
{
|
||||||
|
var match = TitleRegex().Match(content);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractDate(string fileName)
|
||||||
|
{
|
||||||
|
var match = DateRegex().Match(fileName);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractSeverity(string content)
|
||||||
|
{
|
||||||
|
var match = SeverityRegex().Match(content);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractExcerpt(string content)
|
||||||
|
{
|
||||||
|
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
||||||
|
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
|
||||||
|
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex TitleRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
|
||||||
|
private static partial Regex DateRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex SeverityRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class MemoryService : IMemoryService
|
||||||
|
{
|
||||||
|
private const string BasePath = "/mnt/workspace-iris/memory";
|
||||||
|
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||||
|
private const int MaxFileSize = 1_000_000;
|
||||||
|
private const int MaxFiles = 50;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
|
||||||
|
{
|
||||||
|
var files = new List<MemoryFileInfo>();
|
||||||
|
|
||||||
|
if (File.Exists(LongTermPath))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(LongTermPath);
|
||||||
|
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(BasePath))
|
||||||
|
{
|
||||||
|
var memFiles = Directory.GetFiles(BasePath, "*.md")
|
||||||
|
.Select(f => new FileInfo(f))
|
||||||
|
.OrderByDescending(f => f.Name)
|
||||||
|
.Select(f => new MemoryFileInfo(
|
||||||
|
f.Name,
|
||||||
|
f.FullName.Replace(BasePath, "").TrimStart('/'),
|
||||||
|
f.Length,
|
||||||
|
f.LastWriteTimeUtc));
|
||||||
|
files.AddRange(memFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
|
||||||
|
{
|
||||||
|
var results = new List<MemorySearchResult>();
|
||||||
|
|
||||||
|
async Task SearchDir(string dir)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir)) return;
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
if (fi.Length > MaxFileSize) continue;
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = Math.Max(0, idx - 60);
|
||||||
|
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||||
|
results.Add(new MemorySearchResult(
|
||||||
|
Path.GetFileName(file),
|
||||||
|
file.Replace(BasePath, "").TrimStart('/'),
|
||||||
|
excerpt,
|
||||||
|
fi.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SearchDir(BasePath);
|
||||||
|
|
||||||
|
if (File.Exists(LongTermPath))
|
||||||
|
{
|
||||||
|
var content = await File.ReadAllTextAsync(LongTermPath);
|
||||||
|
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = Math.Max(0, idx - 60);
|
||||||
|
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||||
|
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MemoryFileContent?> GetFileAsync(string name)
|
||||||
|
{
|
||||||
|
string? filePath;
|
||||||
|
|
||||||
|
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
filePath = LongTermPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(filePath!);
|
||||||
|
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using Nexus.Api.Models;
|
|||||||
|
|
||||||
namespace Nexus.Api.Services;
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration)
|
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -61,84 +61,351 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the agent session status from the Gateway via session_status tool.
|
||||||
|
/// Returns fields like model, provider, status, lastActivity, isActive, currentTask.
|
||||||
|
/// Returns null if the session is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<JsonNode?> TryGetAgentStatusAsync(string agentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await InvokeToolAsync("session_status", new { sessionKey = "agent:" + agentId + ":main" });
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||||
{
|
{
|
||||||
var agentDefs = new[]
|
// Fallback hardcoded descriptions and tags known for each agent
|
||||||
|
var knownInfo = new Dictionary<string, (string Name, string Description, string[] Tags)>
|
||||||
{
|
{
|
||||||
new { Id = "iris", Name = "Chief of Staff", Model = "deepseek/deepseek-v4-flash",
|
["iris"] = (
|
||||||
Description = "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
|
"Iris",
|
||||||
Tags = new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } },
|
"Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
|
||||||
new { Id = "programmer", Name = "Full-Stack Developer", Model = "deepseek/deepseek-v4-flash",
|
new[] { "Orchestration", "Delegation", "Approval", "Risk Management" }
|
||||||
Description = "Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
|
),
|
||||||
Tags = new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" } },
|
["programmer"] = (
|
||||||
new { Id = "reviewer", Name = "Code Quality Assurance", Model = "deepseek/deepseek-v4-pro",
|
"Full-Stack Developer",
|
||||||
Description = "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
|
"Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
|
||||||
Tags = new[] { "Code Review", "Testing", "Security", "Quality" } },
|
new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" }
|
||||||
new { Id = "architekt", Name = "Infrastructure Architect", Model = "deepseek/deepseek-v4-pro",
|
),
|
||||||
Description = "Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.",
|
["reviewer"] = (
|
||||||
Tags = new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } },
|
"Code Quality Assurance",
|
||||||
new { Id = "executor", Name = "Host Executor", Model = "deepseek/deepseek-v4-flash",
|
"Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
|
||||||
Description = "Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.",
|
new[] { "Code Review", "Testing", "Security", "Quality" }
|
||||||
Tags = new[] { "Docker", "Shell", "Host", "Deployment" } },
|
),
|
||||||
new { Id = "researcher", Name = "Research & Analysis", Model = "deepseek/deepseek-v4-pro",
|
["architekt"] = (
|
||||||
Description = "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
|
"Infrastructure Architect",
|
||||||
Tags = new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } },
|
"Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.",
|
||||||
|
new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" }
|
||||||
|
),
|
||||||
|
["executor"] = (
|
||||||
|
"Host Executor",
|
||||||
|
"Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.",
|
||||||
|
new[] { "Docker", "Shell", "Host", "Deployment" }
|
||||||
|
),
|
||||||
|
["researcher"] = (
|
||||||
|
"Research & Analysis",
|
||||||
|
"Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
|
||||||
|
new[] { "Research", "Quellenprüfung", "Analyse", "Docs" }
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load agent IDs from openclaw.json config
|
||||||
|
var agentIds = LoadAgentIdsFromConfig();
|
||||||
|
|
||||||
var agents = new List<DashboardAgentInfo>();
|
var agents = new List<DashboardAgentInfo>();
|
||||||
foreach (var def in agentDefs)
|
|
||||||
|
// Read queue once for workload calculation
|
||||||
|
var queueItems = new List<QueueItem>();
|
||||||
|
try { queueItems = await GetQueueAsync(); } catch { }
|
||||||
|
|
||||||
|
foreach (var id in agentIds)
|
||||||
{
|
{
|
||||||
|
// Skip the "main" agent (it's the default assistant, not a sub-agent)
|
||||||
|
if (string.Equals(id, "main", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// 1. Try to get dynamic session status from Gateway
|
||||||
|
var status = await TryGetAgentStatusAsync(id);
|
||||||
|
|
||||||
|
// 2. Extract model from session_status (dynamic)
|
||||||
|
var model = status?["model"]?.GetValue<string>();
|
||||||
|
|
||||||
|
// 3. Extract activity from session_status
|
||||||
var isActive = false;
|
var isActive = false;
|
||||||
string? currentTask = null;
|
string? currentTask = null;
|
||||||
try
|
if (status is not null)
|
||||||
{
|
{
|
||||||
var memDir = "/mnt/workspace-" + def.Id + "/memory";
|
// Check explicit isActive field
|
||||||
if (Directory.Exists(memDir))
|
var activeVal = status["isActive"];
|
||||||
{
|
if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.True)
|
||||||
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
|
isActive = true;
|
||||||
.Select(f => new FileInfo(f))
|
else if (activeVal is not null && activeVal.GetValueKind() == JsonValueKind.String)
|
||||||
.OrderByDescending(f => f.LastWriteTimeUtc)
|
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
.FirstOrDefault();
|
|
||||||
if (latestFile is not null)
|
// Fall back to status text
|
||||||
{
|
var statusText = status["status"]?.GetValue<string>();
|
||||||
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
|
if (!isActive && statusText is not null)
|
||||||
isActive = age.TotalMinutes < 15;
|
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||||
if (isActive)
|
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
|
||||||
{
|
|
||||||
try
|
currentTask = status["currentTask"]?.GetValue<string>()
|
||||||
{
|
?? status["task"]?.GetValue<string>()
|
||||||
var firstLine = File.ReadLines(latestFile.FullName).FirstOrDefault()?.Trim();
|
?? (isActive ? "Working..." : null);
|
||||||
if (!string.IsNullOrWhiteSpace(firstLine) && firstLine.Length > 60)
|
|
||||||
currentTask = firstLine[..60];
|
|
||||||
else if (!string.IsNullOrWhiteSpace(firstLine))
|
|
||||||
currentTask = firstLine;
|
|
||||||
else
|
|
||||||
currentTask = "Working...";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
currentTask = "Working...";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch { }
|
|
||||||
|
// 4. Try to read workspace metadata for richer info
|
||||||
|
var (name, description, tags) = await ReadAgentMetadataAsync(id);
|
||||||
|
|
||||||
|
// 5. Fallback to known info if workspace metadata not available
|
||||||
|
if (string.IsNullOrWhiteSpace(name) && knownInfo.TryGetValue(id, out var kn))
|
||||||
|
{
|
||||||
|
name = kn.Name;
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
description = kn.Description;
|
||||||
|
if (tags.Length == 0)
|
||||||
|
tags = kn.Tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
model = id.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"iris" or "programmer" or "executor" => "deepseek/deepseek-v4-flash",
|
||||||
|
"reviewer" or "architekt" or "researcher" => "deepseek/deepseek-v4-pro",
|
||||||
|
_ => "deepseek/deepseek-v4-flash"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Read goal from workspace files
|
||||||
|
var goal = await ReadAgentGoalAsync(id);
|
||||||
|
|
||||||
|
// 7. Calculate progress dynamically
|
||||||
|
var progress = CalculateAgentProgress(id, isActive, status);
|
||||||
|
|
||||||
|
// 8. Calculate workload from queue items
|
||||||
|
var workload = CalculateAgentWorkload(id, queueItems);
|
||||||
|
|
||||||
agents.Add(new DashboardAgentInfo(
|
agents.Add(new DashboardAgentInfo(
|
||||||
Id: def.Id,
|
Id: id,
|
||||||
Name: def.Name,
|
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||||
Role: DeriveRole(def.Id),
|
Role: DeriveRole(id),
|
||||||
Model: def.Model,
|
Model: model,
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
CurrentTask: currentTask,
|
CurrentTask: currentTask,
|
||||||
Description: def.Description,
|
Description: description,
|
||||||
Tags: def.Tags
|
Tags: tags,
|
||||||
|
Progress: progress,
|
||||||
|
Workload: workload,
|
||||||
|
Goal: goal,
|
||||||
|
RoleBadge: DeriveRoleBadge(id),
|
||||||
|
StatusLabel: DeriveStatusLabel(isActive, status),
|
||||||
|
Elapsed: FormatElapsed(status),
|
||||||
|
Think: null,
|
||||||
|
Next: DeriveNext(isActive, currentTask)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return agents;
|
return agents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
|
||||||
|
/// Falls back to the known list if the config file is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
private List<string> LoadAgentIdsFromConfig()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||||
|
?? "/home/node/.openclaw/openclaw.json";
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(configPath))
|
||||||
|
return GetDefaultAgentIds();
|
||||||
|
|
||||||
|
var json = System.IO.File.ReadAllText(configPath);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("agents", out var agentsEl))
|
||||||
|
return GetDefaultAgentIds();
|
||||||
|
if (!agentsEl.TryGetProperty("list", out var listEl))
|
||||||
|
return GetDefaultAgentIds();
|
||||||
|
|
||||||
|
var ids = new List<string>();
|
||||||
|
foreach (var agentEl in listEl.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (agentEl.TryGetProperty("id", out var idEl))
|
||||||
|
{
|
||||||
|
var id = idEl.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
ids.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids.Count > 0 ? ids : GetDefaultAgentIds();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return GetDefaultAgentIds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> GetDefaultAgentIds()
|
||||||
|
=> new() { "iris", "programmer", "reviewer", "architekt", "executor", "researcher" };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads agent metadata from workspace files (IDENTITY.md, SOUL.md).
|
||||||
|
/// Returns (Name, Description, Tags) — empty strings/arrays if unavailable.
|
||||||
|
/// Tags are not read from files (kept as empty for dynamic agents).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(string Name, string Description, string[] Tags)> ReadAgentMetadataAsync(string agentId)
|
||||||
|
{
|
||||||
|
var name = string.Empty;
|
||||||
|
var description = string.Empty;
|
||||||
|
var tags = Array.Empty<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Try the host-mounted workspace path (used by the API container)
|
||||||
|
var workspacePath = "/mnt/workspace-" + agentId;
|
||||||
|
var identityPath = Path.Combine(workspacePath, "IDENTITY.md");
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(identityPath))
|
||||||
|
{
|
||||||
|
var content = await System.IO.File.ReadAllTextAsync(identityPath);
|
||||||
|
ParseIdentityContent(content, out name, out description);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: try the OpenClaw workspace path inside the gateway container
|
||||||
|
var altPath = "/home/node/.openclaw/workspace-" + agentId + "/IDENTITY.md";
|
||||||
|
if (System.IO.File.Exists(altPath))
|
||||||
|
{
|
||||||
|
var content = await System.IO.File.ReadAllTextAsync(altPath);
|
||||||
|
ParseIdentityContent(content, out name, out description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If description is still empty, try SOUL.md
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
var soulPath = Path.Combine(workspacePath, "SOUL.md");
|
||||||
|
string? soulContent = null;
|
||||||
|
if (System.IO.File.Exists(soulPath))
|
||||||
|
{
|
||||||
|
soulContent = await System.IO.File.ReadAllTextAsync(soulPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var altSoulPath = "/home/node/.openclaw/workspace-" + agentId + "/SOUL.md";
|
||||||
|
if (System.IO.File.Exists(altSoulPath))
|
||||||
|
{
|
||||||
|
soulContent = await System.IO.File.ReadAllTextAsync(altSoulPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (soulContent is not null)
|
||||||
|
{
|
||||||
|
description = ExtractDescriptionFromSoul(soulContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback to hardcoded values will handle this
|
||||||
|
}
|
||||||
|
|
||||||
|
return (name, description, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses an IDENTITY.md file to extract Name and a short description (role/creature).
|
||||||
|
/// Looks for markdown list items like "- **Name:** ...", "- **Rolle:** ...", etc.
|
||||||
|
/// </summary>
|
||||||
|
private static void ParseIdentityContent(string content, out string name, out string description)
|
||||||
|
{
|
||||||
|
name = string.Empty;
|
||||||
|
description = string.Empty;
|
||||||
|
|
||||||
|
using var reader = new StringReader(content);
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
// Extract name from "- **Name:** ..."
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
var nameMarker = "- **Name:**";
|
||||||
|
var idx = line.IndexOf(nameMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
name = line[(idx + nameMarker.Length)..].Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract role/theme from "- **Rolle:** ..." or "- **Role:** ..." or "- **Creature:** ..."
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
{
|
||||||
|
var descMarkers = new[] { "- **Rolle:**", "- **Role:**", "- **Creature:**" };
|
||||||
|
foreach (var marker in descMarkers)
|
||||||
|
{
|
||||||
|
var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
description = line[(idx + marker.Length)..].Trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a short description from SOUL.md (first heading or first paragraph after the "Rolle" section).
|
||||||
|
/// Returns the first ~200 characters of meaningful content.
|
||||||
|
/// </summary>
|
||||||
|
private static string ExtractDescriptionFromSoul(string content)
|
||||||
|
{
|
||||||
|
// Look for "## Rolle" section and take the first paragraph after it
|
||||||
|
using var reader = new StringReader(content);
|
||||||
|
string? line;
|
||||||
|
var inRoleSection = false;
|
||||||
|
var paragraphs = new List<string>();
|
||||||
|
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("## ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (inRoleSection)
|
||||||
|
break; // We've moved past the "Rolle" section
|
||||||
|
|
||||||
|
if (trimmed.IndexOf("Rolle", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| trimmed.IndexOf("Role", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
inRoleSection = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
paragraphs.Add(trimmed);
|
||||||
|
if (paragraphs.Count >= 3)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = string.Join(" ", paragraphs);
|
||||||
|
return summary.Length > 200 ? summary[..200] + "…" : summary;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<MessageEntry>> GetSessionHistoryAsync(
|
public async Task<List<MessageEntry>> GetSessionHistoryAsync(
|
||||||
string sessionKey, int limit = 50, int offset = 0)
|
string sessionKey, int limit = 50, int offset = 0)
|
||||||
{
|
{
|
||||||
@@ -153,7 +420,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
if (toolResult is null)
|
if (toolResult is null)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o")));
|
var json = toolResult.ToJsonString();
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
@@ -214,6 +481,138 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects assistant messages from ALL agent sessions (multi-agent operations feed).
|
||||||
|
/// Merges, sorts by timestamp descending, and limits the result.
|
||||||
|
/// Falls back to an empty list if any agent session is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30)
|
||||||
|
{
|
||||||
|
var allEntries = new List<FeedEntry>();
|
||||||
|
var agentIds = LoadAgentIdsFromConfig();
|
||||||
|
|
||||||
|
foreach (var agentId in agentIds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sessionKey = $"agent:{agentId}:main";
|
||||||
|
var messages = await GetSessionHistoryAsync(sessionKey, Math.Min(limit * 2, 50));
|
||||||
|
foreach (var msg in messages)
|
||||||
|
{
|
||||||
|
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(msg.Content))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Parse timestamp
|
||||||
|
var ts = ParseTimestamp(msg.Timestamp);
|
||||||
|
var timeAgo = FormatTimeAgo(ts);
|
||||||
|
|
||||||
|
// Extract a short agent indicator and action from content
|
||||||
|
var (agent, action) = ExtractAgentAction(msg.Content);
|
||||||
|
|
||||||
|
// Determine event type based on content heuristics
|
||||||
|
var eventType = DetectEventType(msg.Content);
|
||||||
|
|
||||||
|
allEntries.Add(new FeedEntry(
|
||||||
|
agent,
|
||||||
|
action,
|
||||||
|
msg.Timestamp,
|
||||||
|
timeAgo,
|
||||||
|
AgentId: agentId,
|
||||||
|
Type: eventType
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Agent session unreachable — skip; we still have data from other agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending by timestamp, then limit
|
||||||
|
return allEntries
|
||||||
|
.OrderByDescending(e => ParseTimestamp(e.Timestamp))
|
||||||
|
.Take(Math.Clamp(limit, 1, 100))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ParseTimestamp(string timestamp)
|
||||||
|
{
|
||||||
|
if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt))
|
||||||
|
return dt;
|
||||||
|
return DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTimeAgo(DateTimeOffset ts)
|
||||||
|
{
|
||||||
|
var diff = DateTimeOffset.UtcNow - ts;
|
||||||
|
if (diff.TotalMinutes < 1) return "just now";
|
||||||
|
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
|
||||||
|
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
|
||||||
|
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
|
||||||
|
return ts.ToString("MMM dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines a FeedEntry event type from message content heuristics.
|
||||||
|
/// </summary>
|
||||||
|
private static string DetectEventType(string content)
|
||||||
|
{
|
||||||
|
if (content.Contains("Subagent Task") || content.Contains("subagent"))
|
||||||
|
{
|
||||||
|
if (content.Contains("complete") || content.Contains("done") || content.Contains("finished"))
|
||||||
|
return "task_complete";
|
||||||
|
return "task_start";
|
||||||
|
}
|
||||||
|
if (content.Contains("Deploy") || content.Contains("deploy") || content.Contains("publish"))
|
||||||
|
return "deploy";
|
||||||
|
if (content.Contains("System") || content.Contains("system") || content.Contains("health"))
|
||||||
|
return "system";
|
||||||
|
if (content.Contains("Gestartet") || content.Contains("started") || content.Contains("Session"))
|
||||||
|
return "session_start";
|
||||||
|
return "chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a human-readable agent name and action summary from message content.
|
||||||
|
/// </summary>
|
||||||
|
private static (string Agent, string Action) ExtractAgentAction(string content)
|
||||||
|
{
|
||||||
|
// Take first line or first ~80 chars as the action summary
|
||||||
|
var firstLine = content.Split('\n', 2)[0].Trim();
|
||||||
|
var summary = firstLine.Length > 80 ? firstLine[..80] + "\u2026" : firstLine;
|
||||||
|
|
||||||
|
// Try to identify which agent this came from
|
||||||
|
var agent = "Iris";
|
||||||
|
foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" })
|
||||||
|
{
|
||||||
|
var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
var after = content[(idx + marker.Length)..].TrimStart();
|
||||||
|
var end = after.IndexOfAny(['\n', '\r', ',', '.']);
|
||||||
|
var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(found) && found.Length < 30)
|
||||||
|
{
|
||||||
|
agent = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find agent name at the start in brackets like [Agent: Iris]
|
||||||
|
if (agent == "Iris")
|
||||||
|
{
|
||||||
|
var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]");
|
||||||
|
if (bracketMatch.Success)
|
||||||
|
agent = bracketMatch.Groups[1].Value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (agent, summary);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChatResponse> SendChatMessageAsync(string agentId, string message)
|
public async Task<ChatResponse> SendChatMessageAsync(string agentId, string message)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -262,7 +661,28 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
var status = j["state"]?["lastStatus"]?.GetValue<string>()
|
var status = j["state"]?["lastStatus"]?.GetValue<string>()
|
||||||
?? j["status"]?.GetValue<string>()
|
?? j["status"]?.GetValue<string>()
|
||||||
?? "unknown";
|
?? "unknown";
|
||||||
items.Add(new QueueItem(id, name, status));
|
|
||||||
|
// Calculate waitTime from nextRun if available
|
||||||
|
var waitTime = "--";
|
||||||
|
var nextRunStr = j["nextRun"]?.GetValue<string>()
|
||||||
|
?? j["next_run"]?.GetValue<string>()
|
||||||
|
?? j["scheduledAt"]?.GetValue<string>();
|
||||||
|
if (nextRunStr is not null && DateTimeOffset.TryParse(nextRunStr, out var nextRun))
|
||||||
|
{
|
||||||
|
var diff = nextRun - DateTimeOffset.UtcNow;
|
||||||
|
if (diff.TotalMinutes < 0)
|
||||||
|
waitTime = "now";
|
||||||
|
else if (diff.TotalMinutes < 1)
|
||||||
|
waitTime = "<1m";
|
||||||
|
else if (diff.TotalMinutes < 60)
|
||||||
|
waitTime = $"{(int)diff.TotalMinutes}m";
|
||||||
|
else if (diff.TotalHours < 24)
|
||||||
|
waitTime = $"{(int)diff.TotalHours}h";
|
||||||
|
else
|
||||||
|
waitTime = $"{(int)diff.TotalDays}d";
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new QueueItem(id, name, status, "medium", "cron", waitTime));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -272,6 +692,19 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteCronJobAsync(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await InvokeToolAsync("cron", new { action = "delete", id });
|
||||||
|
return result is not null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<DashboardStatus> GetStatusAsync()
|
public async Task<DashboardStatus> GetStatusAsync()
|
||||||
{
|
{
|
||||||
var gatewayOk = false;
|
var gatewayOk = false;
|
||||||
@@ -359,6 +792,323 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the agent's goal from workspace files (goals.md preferred, then AGENTS.md, SOUL.md).
|
||||||
|
/// Returns the first meaningful line or null.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string?> ReadAgentGoalAsync(string agentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var workspacePath = "/home/node/.openclaw/workspace-" + agentId;
|
||||||
|
|
||||||
|
// 1. Try goals.md first
|
||||||
|
var goalsPath = Path.Combine(workspacePath, "goals.md");
|
||||||
|
if (System.IO.File.Exists(goalsPath))
|
||||||
|
{
|
||||||
|
var content = await System.IO.File.ReadAllTextAsync(goalsPath);
|
||||||
|
foreach (var line in content.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('#') && !trimmed.StartsWith('-'))
|
||||||
|
return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try SOUL.md "## Rolle" section
|
||||||
|
var soulPath = Path.Combine(workspacePath, "SOUL.md");
|
||||||
|
if (System.IO.File.Exists(soulPath))
|
||||||
|
{
|
||||||
|
var soul = await System.IO.File.ReadAllTextAsync(soulPath);
|
||||||
|
using var reader = new StringReader(soul);
|
||||||
|
string? line;
|
||||||
|
var inRoleSection = false;
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.StartsWith("## ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (inRoleSection) break;
|
||||||
|
if (trimmed.IndexOf("Rolle", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| trimmed.IndexOf("Role", StringComparison.OrdinalIgnoreCase) >= 0
|
||||||
|
|| trimmed.IndexOf("Oberstes", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
inRoleSection = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('-'))
|
||||||
|
{
|
||||||
|
return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Look for "## Oberstes Prinzip" as second choice
|
||||||
|
inRoleSection = false;
|
||||||
|
using var reader2 = new StringReader(soul);
|
||||||
|
while ((line = reader2.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
{
|
||||||
|
inRoleSection = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inRoleSection && !string.IsNullOrWhiteSpace(trimmed) && !trimmed.StartsWith('-') && !trimmed.StartsWith('#'))
|
||||||
|
{
|
||||||
|
return trimmed.Length > 120 ? trimmed[..117] + "..." : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try AGENTS.md first heading
|
||||||
|
var agentsPath = Path.Combine(workspacePath, "AGENTS.md");
|
||||||
|
if (System.IO.File.Exists(agentsPath))
|
||||||
|
{
|
||||||
|
var agentsContent = await System.IO.File.ReadAllTextAsync(agentsPath);
|
||||||
|
foreach (var line in agentsContent.Split('\n'))
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
if (trimmed.StartsWith("# "))
|
||||||
|
return trimmed[2..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fallback to hardcoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: known goals
|
||||||
|
return agentId.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"iris" => "Mission Control — maximale Autonomie bei kontrolliertem Risiko",
|
||||||
|
"programmer" => "Nexus Dashboard & Dungeon System",
|
||||||
|
"reviewer" => "Zero critical findings before merge",
|
||||||
|
"architekt" => "Stabile Zero-Downtime-Deployments",
|
||||||
|
"executor" => "Sichere Host-Execution im Allowlist-Rahmen",
|
||||||
|
"researcher" => "Verifizierte, strukturierte Recherche-Ergebnisse",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates agent progress (0-100) based on session activity.
|
||||||
|
/// Active agents with recent activity get higher scores.
|
||||||
|
/// Idle agents or inactive sessions get lower scores.
|
||||||
|
/// </summary>
|
||||||
|
private static int CalculateAgentProgress(string agentId, bool isActive, JsonNode? status)
|
||||||
|
{
|
||||||
|
if (!isActive)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Base progress from active status
|
||||||
|
var progress = 50;
|
||||||
|
|
||||||
|
// Boost for agents that have a current task
|
||||||
|
var hasTask = status?["currentTask"]?.GetValue<string>() is not null
|
||||||
|
|| status?["task"]?.GetValue<string>() is not null;
|
||||||
|
if (hasTask)
|
||||||
|
progress += 20;
|
||||||
|
|
||||||
|
// Check for last activity timestamp — more recent = higher progress
|
||||||
|
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||||
|
?? status?["lastMessage"]?.GetValue<string>();
|
||||||
|
if (lastActivity is not null && DateTimeOffset.TryParse(lastActivity, out var lastTs))
|
||||||
|
{
|
||||||
|
var minutesSinceActivity = (DateTimeOffset.UtcNow - lastTs).TotalMinutes;
|
||||||
|
if (minutesSinceActivity < 1)
|
||||||
|
progress += 25; // actively working
|
||||||
|
else if (minutesSinceActivity < 5)
|
||||||
|
progress += 15;
|
||||||
|
else if (minutesSinceActivity < 15)
|
||||||
|
progress += 10;
|
||||||
|
else
|
||||||
|
progress -= 10; // stale
|
||||||
|
}
|
||||||
|
else if (hasTask)
|
||||||
|
{
|
||||||
|
// Has task but no timestamp — assume actively working
|
||||||
|
progress += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(progress, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates agent workload (0-100) based on queue items and active status.
|
||||||
|
/// More queued tasks or active sessions = higher workload.
|
||||||
|
/// </summary>
|
||||||
|
private static int CalculateAgentWorkload(string agentId, List<QueueItem> queueItems)
|
||||||
|
{
|
||||||
|
if (queueItems.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Calculate workload based on queue density per agent
|
||||||
|
var agentQueued = queueItems.Count(q =>
|
||||||
|
q.Name.Contains(agentId, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| q.Id.Contains(agentId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// Base workload from total queue pressure
|
||||||
|
var totalQueuePressure = Math.Min(queueItems.Count * 10, 60);
|
||||||
|
|
||||||
|
// Agent-specific queue items add extra weight
|
||||||
|
var agentPressure = agentQueued * 25;
|
||||||
|
|
||||||
|
return Math.Clamp(totalQueuePressure + agentPressure, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the most recent assistant activity (last N messages) for a specific agent.
|
||||||
|
/// Returns entries with timestamp and truncated content text.
|
||||||
|
/// Falls back to an empty list if the session is unreachable.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5)
|
||||||
|
{
|
||||||
|
var entries = new List<AgentActivityEntry>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sessionKey = $"agent:{agentId}:main";
|
||||||
|
var messages = await GetSessionHistoryAsync(sessionKey, Math.Clamp(limit * 2, 1, 100));
|
||||||
|
foreach (var msg in messages)
|
||||||
|
{
|
||||||
|
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
if (string.IsNullOrWhiteSpace(msg.Content))
|
||||||
|
continue;
|
||||||
|
if (msg.Content == "REPLY_SKIP" || msg.Content == "ANNOUNCE_SKIP")
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Truncate content to first 200 chars for compact display
|
||||||
|
var text = msg.Content.Length > 200
|
||||||
|
? msg.Content[..200] + "…"
|
||||||
|
: msg.Content;
|
||||||
|
var ts = ParseTimestamp(msg.Timestamp);
|
||||||
|
var timeAgo = FormatTimeAgo(ts);
|
||||||
|
|
||||||
|
entries.Add(new AgentActivityEntry(timeAgo, text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Return empty list if gateway is unreachable
|
||||||
|
}
|
||||||
|
return entries.Take(Math.Clamp(limit, 1, 20)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the list of available models by reading from the OpenClaw config,
|
||||||
|
/// with fallback to hardcoded list.
|
||||||
|
/// </summary>
|
||||||
|
public List<ModelOption> GetAvailableModels()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||||
|
?? "/home/node/.openclaw/openclaw.json";
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(configPath))
|
||||||
|
return GetDefaultModels();
|
||||||
|
|
||||||
|
var json = System.IO.File.ReadAllText(configPath);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
// Read models from agents.defaults.models
|
||||||
|
if (!root.TryGetProperty("agents", out var agentsEl))
|
||||||
|
return GetDefaultModels();
|
||||||
|
if (!agentsEl.TryGetProperty("defaults", out var defaultsEl))
|
||||||
|
return GetDefaultModels();
|
||||||
|
if (!defaultsEl.TryGetProperty("models", out var modelsEl))
|
||||||
|
return GetDefaultModels();
|
||||||
|
|
||||||
|
var models = new List<ModelOption>();
|
||||||
|
foreach (var modelProp in modelsEl.EnumerateObject())
|
||||||
|
{
|
||||||
|
var modelId = modelProp.Name;
|
||||||
|
var modelObj = modelProp.Value;
|
||||||
|
|
||||||
|
var name = modelId; // fallback: use model ID as name
|
||||||
|
var provider = ExtractProvider(modelId);
|
||||||
|
|
||||||
|
// Check for alias in the model object
|
||||||
|
if (modelObj.TryGetProperty("alias", out var aliasEl))
|
||||||
|
{
|
||||||
|
var alias = aliasEl.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(alias))
|
||||||
|
name = alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
models.Add(new ModelOption(modelId, name, provider));
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.Count > 0 ? models : GetDefaultModels();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return GetDefaultModels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ModelOption> GetDefaultModels()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
new ModelOption("openai/gpt-5.4", "GPT-5.4", "openai"),
|
||||||
|
new ModelOption("openai/gpt-5.5", "GPT-5.5", "openai"),
|
||||||
|
new ModelOption("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek"),
|
||||||
|
new ModelOption("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ExtractProvider(string modelId)
|
||||||
|
{
|
||||||
|
var slash = modelId.IndexOf('/');
|
||||||
|
return slash > 0 ? modelId[..slash] : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"iris" => "badge-purple",
|
||||||
|
"programmer" or "developer" => "badge-blue",
|
||||||
|
"reviewer" => "badge-amber",
|
||||||
|
"architekt" => "badge-cyan",
|
||||||
|
"executor" => "badge-rose",
|
||||||
|
"researcher" => "badge-green",
|
||||||
|
_ => "badge-slate"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
||||||
|
{
|
||||||
|
if (!isActive) return "Bereit";
|
||||||
|
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
||||||
|
return statusText switch
|
||||||
|
{
|
||||||
|
"thinking" or "think" => "Plant",
|
||||||
|
"blocked" or "block" => "Blockiert",
|
||||||
|
_ => "Arbeitet"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatElapsed(JsonNode? status)
|
||||||
|
{
|
||||||
|
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||||
|
?? status?["lastMessage"]?.GetValue<string>();
|
||||||
|
if (lastActivity is null) return null;
|
||||||
|
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
||||||
|
var diff = DateTimeOffset.UtcNow - ts;
|
||||||
|
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
||||||
|
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
||||||
|
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
||||||
|
return $"{(int)diff.TotalDays}d";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DeriveNext(bool isActive, string? currentTask)
|
||||||
|
{
|
||||||
|
if (!isActive) return "Standby";
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentTask) && currentTask != "Working...")
|
||||||
|
return currentTask.Length > 60 ? currentTask[..60] + "…" : currentTask;
|
||||||
|
return "Aufgabe ausführen";
|
||||||
|
}
|
||||||
|
|
||||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"iris" => "Chief of Staff",
|
"iris" => "Chief of Staff",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class OperationsService(
|
||||||
|
IAgentRuntime runtime,
|
||||||
|
IAgentService agentService,
|
||||||
|
IProjectRepository projectRepo,
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo) : IOperationsService
|
||||||
|
{
|
||||||
|
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var runtimeTask = runtime.GetStatusAsync(ct);
|
||||||
|
var agentsTask = agentService.GetAgentsAsync(ct);
|
||||||
|
// Repository calls share the scoped EF Core DbContext and must stay serialized.
|
||||||
|
var projects = await projectRepo.GetAllAsync(ct);
|
||||||
|
var tasks = await taskRepo.GetAllAsync(ct);
|
||||||
|
var activity = await activityRepo.GetRecentAsync(20, ct);
|
||||||
|
var agents = await agentsTask;
|
||||||
|
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||||
|
var runtimeStatus = await runtimeTask;
|
||||||
|
|
||||||
|
var lastIncident = tasks
|
||||||
|
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
|
.OrderByDescending(x => x.UpdatedAt)
|
||||||
|
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
generatedAt = DateTimeOffset.UtcNow,
|
||||||
|
runtime = runtimeStatus,
|
||||||
|
models = Array.Empty<object>(),
|
||||||
|
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
|
||||||
|
metrics = new
|
||||||
|
{
|
||||||
|
activeAgents = agents.Count,
|
||||||
|
queuedTasks = tasks.Count - completedTasks,
|
||||||
|
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
||||||
|
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
|
},
|
||||||
|
lastIncident,
|
||||||
|
projectHealth = new
|
||||||
|
{
|
||||||
|
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
||||||
|
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
||||||
|
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
||||||
|
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
||||||
|
},
|
||||||
|
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
||||||
|
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
||||||
|
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
||||||
|
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class ProjectService(
|
||||||
|
IProjectRepository projectRepo,
|
||||||
|
IActivityRepository activityRepo) : IProjectService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await projectRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
=> await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = new Project
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Description = request.Description?.Trim() ?? string.Empty,
|
||||||
|
Status = OperationalStatus.Online
|
||||||
|
};
|
||||||
|
await projectRepo.AddAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
if (project is null) return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
project.Name = request.Name.Trim();
|
||||||
|
if (request.Description is not null)
|
||||||
|
project.Description = request.Description.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
||||||
|
project.Status = parsedStatus;
|
||||||
|
|
||||||
|
await projectRepo.UpdateAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
|
||||||
|
|
||||||
|
if (await projectRepo.HasTasksAsync(id, ct))
|
||||||
|
{
|
||||||
|
project.Status = OperationalStatus.Offline;
|
||||||
|
await projectRepo.UpdateAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
||||||
|
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
||||||
|
await projectRepo.DeleteAsync(project, ct);
|
||||||
|
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class TaskService(
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo) : ITaskService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = new WorkTask
|
||||||
|
{
|
||||||
|
Title = request.Title.Trim(),
|
||||||
|
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||||
|
ProjectId = request.ProjectId
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
task.Title = request.Title.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||||
|
task.Priority = request.Priority.Trim();
|
||||||
|
if (request.ProjectId.HasValue)
|
||||||
|
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
||||||
|
await taskRepo.DeleteAsync(task, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard-facing operations ──
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||||
|
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = new WorkTask
|
||||||
|
{
|
||||||
|
Title = title.Trim(),
|
||||||
|
Detail = detail?.Trim(),
|
||||||
|
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
|
||||||
|
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||||
|
AssignedTo = assignedTo?.Trim()
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
||||||
|
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
|
||||||
|
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
|
||||||
|
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!TaskStateHelper.IsValidState(status))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||||
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.State = "Done";
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.Priority = task.Priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" => "Medium",
|
||||||
|
"medium" => "Low",
|
||||||
|
"low" => "High",
|
||||||
|
_ => "Medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class TeamService(IAgentService agentService) : ITeamService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var agents = await agentService.GetAgentsAsync(ct);
|
||||||
|
var team = new List<TeamMember>(agents.Count);
|
||||||
|
|
||||||
|
foreach (var agent in agents)
|
||||||
|
{
|
||||||
|
var identity = await ReadIdentityAsync(agent.Workspace, ct);
|
||||||
|
team.Add(new TeamMember(
|
||||||
|
agent.Id, agent.Name, agent.Role, agent.Model,
|
||||||
|
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
||||||
|
identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
||||||
|
if (!File.Exists(identityFile))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(identityFile, ct);
|
||||||
|
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-1
@@ -15,12 +15,24 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
networks: [nexus]
|
networks: [nexus]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
window: 120s
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
@@ -40,7 +52,14 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
volumes:
|
volumes:
|
||||||
|
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
||||||
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||||
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||||
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||||
@@ -50,15 +69,39 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- nexus
|
- nexus
|
||||||
- openclaw_default
|
- openclaw_default
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
window: 120s
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18880:80"
|
- "127.0.0.1:18880:80"
|
||||||
depends_on: [api]
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
networks: [nexus]
|
networks: [nexus]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nexus:
|
nexus:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#080a0f" />
|
<meta name="theme-color" content="#080a0f" />
|
||||||
<title>Nexus | Noveria Operations</title>
|
<title>Nexus | Noveria Operations</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=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView v-if="route.name === 'Login'" />
|
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
|
||||||
<div v-else class="shell">
|
<div v-else class="shell">
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
|
|||||||
@@ -4,6 +4,52 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ── Nexus V2 Theme (Tailwind v4 @theme directive) ── */
|
||||||
|
@theme {
|
||||||
|
/* Font families */
|
||||||
|
--font-sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-display: 'Space Grotesk', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Space surfaces */
|
||||||
|
--color-space-0: #050410;
|
||||||
|
--color-space-1: #0a0818;
|
||||||
|
--color-space-2: #0e0c20;
|
||||||
|
--color-space-3: #141130;
|
||||||
|
--color-space-4: #1b1742;
|
||||||
|
|
||||||
|
/* Glass */
|
||||||
|
--color-glass: rgba(20, 17, 48, 0.55);
|
||||||
|
--color-glass-2: rgba(28, 24, 64, 0.55);
|
||||||
|
|
||||||
|
/* Lines */
|
||||||
|
--color-line: rgba(150, 140, 255, 0.10);
|
||||||
|
--color-line-2: rgba(150, 140, 255, 0.18);
|
||||||
|
--color-line-3: rgba(150, 140, 255, 0.30);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-tx: #ece9ff;
|
||||||
|
--color-tx-2: #a8a3d6;
|
||||||
|
--color-tx-3: #6f6aa0;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--color-a-blue: #4f7cff;
|
||||||
|
--color-a-purple: #b557f6;
|
||||||
|
--color-a-mid: #7c6cff;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--color-st-work: #3ddc97;
|
||||||
|
--color-st-think: #34d6f5;
|
||||||
|
--color-st-queue: #fbbf24;
|
||||||
|
--color-st-block: #fb7185;
|
||||||
|
--color-st-idle: #6b6796;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-r: 14px;
|
||||||
|
--radius-r-sm: 10px;
|
||||||
|
--radius-r-lg: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
@@ -39,7 +85,7 @@
|
|||||||
body {
|
body {
|
||||||
background: hsl(var(--background));
|
background: hsl(var(--background));
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
'Segoe UI', sans-serif;
|
'Segoe UI', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* ================================================================
|
||||||
|
nexus-tokens.css — Nexus Mission Control V2 Design Tokens
|
||||||
|
Geladen NACH main.css, überschreibt shadcn/v1-Standards.
|
||||||
|
================================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ── Surfaces ─────────────────────────────────────── */
|
||||||
|
--space-0: #050410;
|
||||||
|
--space-1: #0a0818;
|
||||||
|
--space-2: #0e0c20;
|
||||||
|
--space-3: #141130;
|
||||||
|
--space-4: #1b1742;
|
||||||
|
--glass: rgba(20, 17, 48, 0.55);
|
||||||
|
--glass-2: rgba(28, 24, 64, 0.55);
|
||||||
|
|
||||||
|
/* ── Lines ────────────────────────────────────────── */
|
||||||
|
--line: rgba(150, 140, 255, 0.10);
|
||||||
|
--line-2: rgba(150, 140, 255, 0.18);
|
||||||
|
--line-3: rgba(150, 140, 255, 0.30);
|
||||||
|
|
||||||
|
/* ── Text ─────────────────────────────────────────── */
|
||||||
|
--tx: #ece9ff;
|
||||||
|
--tx-2: #a8a3d6;
|
||||||
|
--tx-3: #6f6aa0;
|
||||||
|
|
||||||
|
/* ── Accent Gradient ──────────────────────────────── */
|
||||||
|
--a-blue: #4f7cff;
|
||||||
|
--a-purple: #b557f6;
|
||||||
|
--a-mid: #7c6cff;
|
||||||
|
--grad: linear-gradient(120deg, var(--a-blue), var(--a-purple));
|
||||||
|
--grad-soft: linear-gradient(120deg, rgba(79,124,255,.18), rgba(181,87,246,.18));
|
||||||
|
|
||||||
|
/* ── Status ───────────────────────────────────────── */
|
||||||
|
--st-work: #3ddc97;
|
||||||
|
--st-think: #34d6f5;
|
||||||
|
--st-queue: #fbbf24;
|
||||||
|
--st-block: #fb7185;
|
||||||
|
--st-idle: #6b6796;
|
||||||
|
|
||||||
|
/* ── Glows ────────────────────────────────────────── */
|
||||||
|
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
|
||||||
|
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
|
||||||
|
--glow-purple: 0 0 24px -2px rgba(181,87,246,.60);
|
||||||
|
--glow-work: 0 0 16px -1px rgba(61,220,151,.70);
|
||||||
|
--glow-think: 0 0 16px -1px rgba(52,214,245,.65);
|
||||||
|
|
||||||
|
/* ── Radius ───────────────────────────────────────── */
|
||||||
|
--r: 14px;
|
||||||
|
--r-sm: 10px;
|
||||||
|
--r-lg: 20px;
|
||||||
|
|
||||||
|
/* ── Layout ───────────────────────────────────────── */
|
||||||
|
--sidebar-w: 248px;
|
||||||
|
--topbar-h: 62px;
|
||||||
|
--rail-w: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Glass card utility ────────────────────────────── */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gradient text ─────────────────────────────────── */
|
||||||
|
.grad-text {
|
||||||
|
background: var(--grad);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status dot keyframes ──────────────────────────── */
|
||||||
|
@keyframes pulse-work {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
|
||||||
|
70% { box-shadow: 0 0 0 7px rgba(61,220,151,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(61,220,151,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-think {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
|
||||||
|
70% { box-shadow: 0 0 0 7px rgba(52,214,245,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(52,214,245,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-block {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
|
||||||
|
70% { box-shadow: 0 0 0 7px rgba(251,113,133,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(251,113,133,0); }
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(180%); }
|
||||||
|
}
|
||||||
|
/* ── V2 Scrollbars ─────────────────────────────────── */
|
||||||
|
.v2-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||||
|
.v2-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(124,108,255,.22);
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.v2-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(124,108,255,.4);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.v2-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* ── Typography helpers ────────────────────────────── */
|
||||||
|
.font-display { font-family: 'Space Grotesk', sans-serif; }
|
||||||
|
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* GalaxyBackground – Canvas Starfield + Aurora Blobs
|
||||||
|
*
|
||||||
|
* Ported from assets/galaxy.js (design_handoff_nexus_v2).
|
||||||
|
* Auto-mounts onMounted; cleans up onUnmounted.
|
||||||
|
* Fixed overlay with z-index:0 and pointer-events:none.
|
||||||
|
*/
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
r: number
|
||||||
|
a: number
|
||||||
|
tw: number
|
||||||
|
ph: number
|
||||||
|
hue: number
|
||||||
|
dx: number
|
||||||
|
dy: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shoot {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
len: number
|
||||||
|
sp: number
|
||||||
|
ang: number
|
||||||
|
life: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let animFrameId = 0
|
||||||
|
let canvas: HTMLCanvasElement | null = null
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null
|
||||||
|
|
||||||
|
function mount(root: HTMLElement) {
|
||||||
|
canvas = document.createElement('canvas')
|
||||||
|
root.appendChild(canvas)
|
||||||
|
ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
let stars: Star[] = []
|
||||||
|
let shoots: Shoot[] = []
|
||||||
|
let W = 0
|
||||||
|
let H = 0
|
||||||
|
let t = 0
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const r = root.getBoundingClientRect()
|
||||||
|
W = r.width
|
||||||
|
H = r.height
|
||||||
|
canvas!.width = W * dpr
|
||||||
|
canvas!.height = H * dpr
|
||||||
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
|
||||||
|
const count = Math.round((W * H) / 5200)
|
||||||
|
stars = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
stars.push({
|
||||||
|
x: Math.random() * W,
|
||||||
|
y: Math.random() * H,
|
||||||
|
r: Math.random() * 1.3 + 0.25,
|
||||||
|
a: Math.random() * 0.6 + 0.2,
|
||||||
|
tw: Math.random() * 0.025 + 0.004,
|
||||||
|
ph: Math.random() * Math.PI * 2,
|
||||||
|
hue: Math.random() < 0.5 ? 230 : 270,
|
||||||
|
dx: (Math.random() - 0.5) * 0.04,
|
||||||
|
dy: (Math.random() - 0.5) * 0.04,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnShoot() {
|
||||||
|
const fromLeft = Math.random() < 0.6
|
||||||
|
shoots.push({
|
||||||
|
x: fromLeft ? -40 : W * (0.4 + Math.random() * 0.5),
|
||||||
|
y: Math.random() * H * 0.5,
|
||||||
|
len: 90 + Math.random() * 120,
|
||||||
|
sp: 6 + Math.random() * 5,
|
||||||
|
ang: Math.random() * 0.3 + 0.15,
|
||||||
|
life: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
ctx!.clearRect(0, 0, W, H)
|
||||||
|
t += 1
|
||||||
|
|
||||||
|
// Draw stars
|
||||||
|
for (let i = 0; i < stars.length; i++) {
|
||||||
|
const s = stars[i]
|
||||||
|
s.ph += s.tw
|
||||||
|
const a = s.a * (0.55 + 0.45 * Math.sin(s.ph))
|
||||||
|
s.x += s.dx
|
||||||
|
s.y += s.dy
|
||||||
|
if (s.x < 0) s.x = W
|
||||||
|
if (s.x > W) s.x = 0
|
||||||
|
if (s.y < 0) s.y = H
|
||||||
|
if (s.y > H) s.y = 0
|
||||||
|
|
||||||
|
ctx!.beginPath()
|
||||||
|
ctx!.fillStyle = `hsla(${s.hue},90%,82%,${a})`
|
||||||
|
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2)
|
||||||
|
ctx!.fill()
|
||||||
|
|
||||||
|
// Glow for larger stars
|
||||||
|
if (s.r > 1) {
|
||||||
|
ctx!.beginPath()
|
||||||
|
ctx!.fillStyle = `hsla(${s.hue},95%,80%,${a * 0.12})`
|
||||||
|
ctx!.arc(s.x, s.y, s.r * 3.5, 0, Math.PI * 2)
|
||||||
|
ctx!.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shooting stars
|
||||||
|
if (Math.random() < 0.004 && shoots.length < 2) spawnShoot()
|
||||||
|
for (let j = shoots.length - 1; j >= 0; j--) {
|
||||||
|
const sh = shoots[j]
|
||||||
|
sh.x += Math.cos(sh.ang) * sh.sp
|
||||||
|
sh.y += Math.sin(sh.ang) * sh.sp
|
||||||
|
sh.life -= 0.006
|
||||||
|
|
||||||
|
const ex = sh.x - Math.cos(sh.ang) * sh.len
|
||||||
|
const ey = sh.y - Math.sin(sh.ang) * sh.len
|
||||||
|
const g = ctx!.createLinearGradient(sh.x, sh.y, ex, ey)
|
||||||
|
g.addColorStop(0, `rgba(200,210,255,${0.9 * sh.life})`)
|
||||||
|
g.addColorStop(1, 'rgba(160,120,255,0)')
|
||||||
|
|
||||||
|
ctx!.strokeStyle = g
|
||||||
|
ctx!.lineWidth = 2
|
||||||
|
ctx!.lineCap = 'round'
|
||||||
|
ctx!.beginPath()
|
||||||
|
ctx!.moveTo(sh.x, sh.y)
|
||||||
|
ctx!.lineTo(ex, ey)
|
||||||
|
ctx!.stroke()
|
||||||
|
|
||||||
|
if (sh.life <= 0 || sh.x > W + 60 || sh.y > H + 60) shoots.splice(j, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
animFrameId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => resize())
|
||||||
|
resizeObserver.observe(root)
|
||||||
|
resize()
|
||||||
|
frame()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
|
if (animFrameId) {
|
||||||
|
cancelAnimationFrame(animFrameId)
|
||||||
|
animFrameId = 0
|
||||||
|
}
|
||||||
|
if (canvas && canvas.parentNode) {
|
||||||
|
canvas.parentNode.removeChild(canvas)
|
||||||
|
}
|
||||||
|
canvas = null
|
||||||
|
ctx = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (rootRef.value) mount(rootRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="galaxy-bg">
|
||||||
|
<div class="aurora a1"></div>
|
||||||
|
<div class="aurora a2"></div>
|
||||||
|
<div class="aurora a3"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.galaxy-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 800px at 18% -8%, rgba(79,124,255,.20), transparent 60%),
|
||||||
|
radial-gradient(1000px 760px at 92% 8%, rgba(181,87,246,.18), transparent 60%),
|
||||||
|
radial-gradient(900px 700px at 60% 110%, rgba(70,60,180,.20), transparent 60%),
|
||||||
|
linear-gradient(180deg, #070512, #0a0818 60%, #060410);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.galaxy-bg canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.aurora {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(70px);
|
||||||
|
opacity: 0.5;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.a1 {
|
||||||
|
width: 46vw;
|
||||||
|
height: 46vw;
|
||||||
|
left: -8vw;
|
||||||
|
top: -14vw;
|
||||||
|
background: radial-gradient(circle, rgba(79,124,255,.55), transparent 65%);
|
||||||
|
animation: drift1 26s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.a2 {
|
||||||
|
width: 40vw;
|
||||||
|
height: 40vw;
|
||||||
|
right: -10vw;
|
||||||
|
top: 2vw;
|
||||||
|
background: radial-gradient(circle, rgba(181,87,246,.5), transparent 65%);
|
||||||
|
animation: drift2 32s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.a3 {
|
||||||
|
width: 42vw;
|
||||||
|
height: 42vw;
|
||||||
|
left: 34vw;
|
||||||
|
bottom: -18vw;
|
||||||
|
background: radial-gradient(circle, rgba(95,80,220,.45), transparent 65%);
|
||||||
|
animation: drift3 30s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes drift1 {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(8vw, 6vw) scale(1.12); }
|
||||||
|
}
|
||||||
|
@keyframes drift2 {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(-7vw, 5vw) scale(1.1); }
|
||||||
|
}
|
||||||
|
@keyframes drift3 {
|
||||||
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
|
50% { transform: translate(4vw, -6vw) scale(1.15); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Clock } from '@lucide/vue'
|
|
||||||
|
|
||||||
type InitiativeStatus = 'healthy' | 'attention' | 'blocked' | 'paused' | 'completed'
|
|
||||||
|
|
||||||
interface Initiative {
|
|
||||||
title: string
|
|
||||||
progress: number
|
|
||||||
openTasks: number
|
|
||||||
blockers: number
|
|
||||||
status: InitiativeStatus
|
|
||||||
lastActivity: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const initiatives = ref<Initiative[]>([
|
|
||||||
{ title: 'OpenClaw Companion', progress: 55, openTasks: 7, blockers: 2, status: 'healthy', lastActivity: 'vor 8 Minuten' },
|
|
||||||
{ title: '2D Idle Game', progress: 42, openTasks: 4, blockers: 0, status: 'healthy', lastActivity: 'vor 2 Stunden' },
|
|
||||||
{ title: 'Deutsch B2', progress: 73, openTasks: 3, blockers: 0, status: 'attention', lastActivity: 'vor 1 Stunde' },
|
|
||||||
{ title: 'Nexus Dashboard', progress: 60, openTasks: 3, blockers: 0, status: 'healthy', lastActivity: 'vor 5 Minuten' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const statusMeta: Record<InitiativeStatus, { label: string; color: string; bg: string }> = {
|
|
||||||
healthy: { label: 'Healthy', color: '#22c55e', bg: 'rgba(34,197,94,0.1)' },
|
|
||||||
attention: { label: 'Needs Attention', color: '#eab308', bg: 'rgba(234,179,8,0.1)' },
|
|
||||||
blocked: { label: 'Blocked', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
|
|
||||||
paused: { label: 'Paused', color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
|
|
||||||
completed: { label: 'Completed', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInitiativeClick(title: string) {
|
|
||||||
console.log('[Dashboard] Open initiative:', title)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="initiatives-section">
|
|
||||||
<h2>Active Initiatives</h2>
|
|
||||||
<div class="initiatives-grid">
|
|
||||||
<div
|
|
||||||
v-for="(init, idx) in initiatives"
|
|
||||||
:key="idx"
|
|
||||||
:class="['initiative-card', 'status-' + init.status]"
|
|
||||||
@click="onInitiativeClick(init.title)"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@keyup.enter="onInitiativeClick(init.title)"
|
|
||||||
>
|
|
||||||
<div class="init-head">
|
|
||||||
<h3>{{ init.title }}</h3>
|
|
||||||
<span
|
|
||||||
class="status-badge"
|
|
||||||
:style="{
|
|
||||||
color: statusMeta[init.status].color,
|
|
||||||
background: statusMeta[init.status].bg,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ statusMeta[init.status].label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div
|
|
||||||
class="progress-fill"
|
|
||||||
:style="{ width: init.progress + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-label">{{ init.progress }}%</div>
|
|
||||||
<div class="init-stats">
|
|
||||||
<span>{{ init.openTasks }} offene Aufgaben</span>
|
|
||||||
<span v-if="init.blockers">· {{ init.blockers }} Blocker</span>
|
|
||||||
</div>
|
|
||||||
<div class="init-meta">
|
|
||||||
<Clock :size="11" />
|
|
||||||
<span>Letzte Aktivität {{ init.lastActivity }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.initiatives-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 18px;
|
|
||||||
background: rgba(22, 27, 34, 0.8);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.initiatives-section:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.18);
|
|
||||||
}
|
|
||||||
.initiatives-section h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.initiatives-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.initiative-card {
|
|
||||||
background: rgba(13, 17, 23, 0.5);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.initiative-card:hover {
|
|
||||||
transform: scale(1.02);
|
|
||||||
border-color: rgba(139, 124, 246, 0.2);
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.initiative-card:focus-visible {
|
|
||||||
outline: 2px solid #a78bfa;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.init-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.init-head h3 {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.status-badge {
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(139, 124, 246, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: linear-gradient(90deg, #a78bfa, #8b5cf6);
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
.initiative-card.status-attention .progress-fill {
|
|
||||||
background: linear-gradient(90deg, #eab308, #f59e0b);
|
|
||||||
}
|
|
||||||
.initiative-card.status-blocked .progress-fill {
|
|
||||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
|
||||||
}
|
|
||||||
.progress-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6b7385;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.init-stats {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #6b7385;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.init-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 9px;
|
|
||||||
color: #6b7385;
|
|
||||||
}
|
|
||||||
.init-meta svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.initiatives-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.initiatives-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { CheckCircle2, Circle, AlertTriangle } from '@lucide/vue'
|
|
||||||
|
|
||||||
interface AgendaItem {
|
|
||||||
text: string
|
|
||||||
time?: string
|
|
||||||
done?: boolean
|
|
||||||
overdue?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'nexus-agenda-done'
|
|
||||||
|
|
||||||
const agendaToday = ref<AgendaItem[]>([
|
|
||||||
{ text: 'Teammeeting', time: '14:00' },
|
|
||||||
{ text: 'Deutsch lernen', time: '18:00' },
|
|
||||||
{ text: 'Steuerunterlagen prüfen' },
|
|
||||||
{ text: 'Dungeon-Balance abschließen' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const agendaTomorrow = ref<AgendaItem[]>([
|
|
||||||
{ text: 'GitHub Issue #23' },
|
|
||||||
{ text: 'Backup überprüfen' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const agendaOverdue = ref<AgendaItem[]>([
|
|
||||||
{ text: 'Hangfire konfigurieren', overdue: true },
|
|
||||||
])
|
|
||||||
|
|
||||||
function loadDoneStates() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return
|
|
||||||
const keys: string[] = JSON.parse(raw)
|
|
||||||
const set = new Set(keys)
|
|
||||||
const sections = [
|
|
||||||
{ items: agendaToday.value, prefix: 'today' },
|
|
||||||
{ items: agendaTomorrow.value, prefix: 'tomorrow' },
|
|
||||||
{ items: agendaOverdue.value, prefix: 'overdue' },
|
|
||||||
]
|
|
||||||
for (const { items, prefix } of sections) {
|
|
||||||
items.forEach((item, i) => {
|
|
||||||
if (set.has(`${prefix}-${i}`)) item.done = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch { /* ignore malformed storage */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDoneStates() {
|
|
||||||
const keys: string[] = []
|
|
||||||
const sections = [
|
|
||||||
{ items: agendaToday.value, prefix: 'today' },
|
|
||||||
{ items: agendaTomorrow.value, prefix: 'tomorrow' },
|
|
||||||
{ items: agendaOverdue.value, prefix: 'overdue' },
|
|
||||||
]
|
|
||||||
for (const { items, prefix } of sections) {
|
|
||||||
items.forEach((item, i) => {
|
|
||||||
if (item.done) keys.push(`${prefix}-${i}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAgendaItem(item: AgendaItem) {
|
|
||||||
item.done = !item.done
|
|
||||||
saveDoneStates()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadDoneStates()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="agenda-panel">
|
|
||||||
<h2>Agenda</h2>
|
|
||||||
|
|
||||||
<div class="agenda-section">
|
|
||||||
<h3>Heute</h3>
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in agendaToday"
|
|
||||||
:key="'today-' + idx"
|
|
||||||
:class="['agenda-item', { done: item.done }]"
|
|
||||||
@click="toggleAgendaItem(item)"
|
|
||||||
>
|
|
||||||
<button class="agenda-check">
|
|
||||||
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
|
|
||||||
<Circle v-else :size="14" />
|
|
||||||
</button>
|
|
||||||
<span class="agenda-text">{{ item.text }}</span>
|
|
||||||
<span v-if="item.time" class="agenda-time">{{ item.time }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="agenda-section">
|
|
||||||
<h3>Morgen</h3>
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in agendaTomorrow"
|
|
||||||
:key="'tomorrow-' + idx"
|
|
||||||
:class="['agenda-item', { done: item.done }]"
|
|
||||||
@click="toggleAgendaItem(item)"
|
|
||||||
>
|
|
||||||
<button class="agenda-check">
|
|
||||||
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
|
|
||||||
<Circle v-else :size="14" />
|
|
||||||
</button>
|
|
||||||
<span class="agenda-text">{{ item.text }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="agenda-section">
|
|
||||||
<h3 class="overdue-heading">
|
|
||||||
<AlertTriangle :size="12" />
|
|
||||||
Überfällig
|
|
||||||
</h3>
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in agendaOverdue"
|
|
||||||
:key="'overdue-' + idx"
|
|
||||||
class="agenda-item overdue"
|
|
||||||
>
|
|
||||||
<button class="agenda-check">
|
|
||||||
<AlertTriangle :size="14" class="overdue-icon" />
|
|
||||||
</button>
|
|
||||||
<span class="agenda-text">{{ item.text }}</span>
|
|
||||||
<span class="agenda-sub">seit 2 Tagen</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.agenda-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 18px;
|
|
||||||
background: rgba(22, 27, 34, 0.8);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.agenda-panel:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.18);
|
|
||||||
}
|
|
||||||
.agenda-panel h2 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.agenda-section h3 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #6b7385;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
border-bottom: 1px solid rgba(139, 124, 246, 0.06);
|
|
||||||
}
|
|
||||||
.overdue-heading {
|
|
||||||
color: #ef4444 !important;
|
|
||||||
border-bottom-color: rgba(239, 68, 68, 0.15) !important;
|
|
||||||
}
|
|
||||||
.agenda-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 5px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.agenda-item:hover {
|
|
||||||
background: rgba(139, 124, 246, 0.04);
|
|
||||||
}
|
|
||||||
.agenda-item.done {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.agenda-item.done .agenda-text {
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
.agenda-check {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: #6b7385;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.agenda-check .checked {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.overdue .overdue-icon {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
.agenda-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: #7e8799;
|
|
||||||
}
|
|
||||||
.agenda-time {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #6b7385;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.agenda-sub {
|
|
||||||
font-size: 8px;
|
|
||||||
color: #ef4444;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.agenda-item.overdue {
|
|
||||||
background: rgba(239, 68, 68, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.agenda-panel {
|
|
||||||
order: 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { X, ExternalLink } from '@lucide/vue'
|
|
||||||
import type { AgentNodeData } from '../../composables/useDashboardData'
|
|
||||||
import { useToast } from '../../composables/useToast'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
|
||||||
import Select from '@/components/ui/Select.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
agent: AgentNodeData
|
|
||||||
runtime: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
interface ModelOption {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
provider: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableModels = ref<ModelOption[]>([])
|
|
||||||
const selectedModel = ref('')
|
|
||||||
const currentModel = ref('')
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
async function loadModels() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/dashboard/models', { credentials: 'include' })
|
|
||||||
if (res.ok) {
|
|
||||||
availableModels.value = await res.json()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCurrentModel() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, { credentials: 'include' })
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
selectedModel.value = data.model
|
|
||||||
currentModel.value = data.model
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// silent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveModel() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, {
|
|
||||||
method: 'PUT',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ model: selectedModel.value }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
currentModel.value = selectedModel.value
|
|
||||||
toast.success('Model updated successfully')
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to update model')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('Connection error')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadModels()
|
|
||||||
await loadCurrentModel()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="modal-overlay" @click.self="$emit('close')">
|
|
||||||
<div class="modal-card" :style="{ '--agent-color': agent.color }">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title-row">
|
|
||||||
<div class="modal-avatar" :style="{ background: `${agent.color}18`, color: agent.color }">
|
|
||||||
<span class="avatar-letter">{{ agent.name.charAt(0) }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2>{{ agent.name }}</h2>
|
|
||||||
<span class="modal-role">{{ agent.role }}</span>
|
|
||||||
<a :href="`/agents/${agent.id}`" class="agent-link-btn" title="Open agent config">
|
|
||||||
<ExternalLink :size="12" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('close')" aria-label="Close">
|
|
||||||
<X :size="16" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<section class="modal-section">
|
|
||||||
<h3 class="section-label">Status</h3>
|
|
||||||
<div class="status-row">
|
|
||||||
<Badge
|
|
||||||
:variant="agent.active ? 'default' : 'secondary'"
|
|
||||||
:class="agent.active ? 'bg-[rgba(81,212,154,0.1)] text-[#51d49a] border-[rgba(81,212,154,0.3)]' : 'bg-[rgba(107,115,133,0.08)] text-[#6b7385] border-[rgba(107,115,133,0.2)]'"
|
|
||||||
>
|
|
||||||
<span class="status-dot" :class="{ active: agent.active }" />
|
|
||||||
{{ agent.active ? 'Active' : 'Idle' }}
|
|
||||||
</Badge>
|
|
||||||
<span v-if="agent.active" class="footer-badge">Runtime: {{ runtime }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<p class="modal-desc">{{ agent.description }}</p>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
|
||||||
<section class="modal-section">
|
|
||||||
<h3 class="section-label">Tags</h3>
|
|
||||||
<div class="modal-tags-row">
|
|
||||||
<Badge
|
|
||||||
v-for="tag in agent.tags"
|
|
||||||
:key="tag"
|
|
||||||
variant="outline"
|
|
||||||
:style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Current Task -->
|
|
||||||
<section class="modal-section">
|
|
||||||
<h3 class="section-label">Current Task</h3>
|
|
||||||
<p class="section-value">
|
|
||||||
{{ agent.currentTask }}
|
|
||||||
<span v-if="agent.active" class="thinking-dots">
|
|
||||||
<span class="thinking-dot blue"></span>
|
|
||||||
<span class="thinking-dot violet"></span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Goal + Progress -->
|
|
||||||
<section class="modal-section">
|
|
||||||
<h3 class="section-label">Goal</h3>
|
|
||||||
<p class="section-value">{{ agent.goal }}</p>
|
|
||||||
<div class="progress-row">
|
|
||||||
<span class="progress-pct">{{ agent.progress }}%</span>
|
|
||||||
<div class="progress-track">
|
|
||||||
<div class="progress-fill" :style="{ width: `${agent.progress}%` }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Model -->
|
|
||||||
<section class="modal-section">
|
|
||||||
<h3 class="section-label">Model</h3>
|
|
||||||
<div class="model-select-row">
|
|
||||||
<Select v-model="selectedModel" class="flex-1 text-xs border-[#a78bfa]">
|
|
||||||
<option v-for="m in availableModels" :key="m.id" :value="m.id">
|
|
||||||
{{ m.name }} ({{ m.provider }})
|
|
||||||
</option>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
class="bg-[#a78bfa] hover:bg-[#c4b5fd]"
|
|
||||||
:disabled="saving || selectedModel === currentModel"
|
|
||||||
@click="saveModel"
|
|
||||||
>
|
|
||||||
{{ saving ? 'Saving...' : 'Save' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer Stats -->
|
|
||||||
<div class="modal-footer">
|
|
||||||
<Badge :class="agent.active ? 'bg-[rgba(81,212,154,0.06)] text-[#51d49a] border-[rgba(81,212,154,0.25)]' : 'bg-[rgba(107,115,133,0.04)] text-[#6b7385] border-[rgba(107,115,133,0.15)]'">
|
|
||||||
{{ agent.active ? '● Active' : '○ Idle' }}
|
|
||||||
</Badge>
|
|
||||||
<span v-if="agent.active" class="footer-badge">{{ runtime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
animation: overlay-in 0.2s ease;
|
|
||||||
}
|
|
||||||
@keyframes overlay-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-card {
|
|
||||||
width: min(480px, 100%);
|
|
||||||
max-height: 80vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px;
|
|
||||||
background: rgba(18, 22, 30, 0.96);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--agent-color) 25%, transparent);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
|
||||||
animation: card-in 0.25s ease;
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
@keyframes card-in {
|
|
||||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
.modal-card::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
.modal-card::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.modal-card::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(139, 124, 246, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.modal-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.modal-avatar {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.avatar-letter {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.modal-title-row h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.modal-role {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6b7385;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.agent-link-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-left: 4px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: #6b7385;
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.agent-link-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--agent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-desc {
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: #7e8799;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
padding-bottom: 14px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.section-label {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: #6b7385;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
}
|
|
||||||
.section-value {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #e8eaf0;
|
|
||||||
line-height: 1.4;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status */
|
|
||||||
.status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #6b7385;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.status-dot.active {
|
|
||||||
background: #51d49a;
|
|
||||||
box-shadow: 0 0 8px rgba(81, 212, 154, 0.6);
|
|
||||||
animation: pulse-dot 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes pulse-dot {
|
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
|
||||||
50% { opacity: 0.6; transform: scale(1.2); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tags */
|
|
||||||
.modal-tags-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress */
|
|
||||||
.progress-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
.progress-pct {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #7e8799;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.progress-track {
|
|
||||||
flex: 1;
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--agent-color);
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thinking Dots */
|
|
||||||
.thinking-dots {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.thinking-dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.thinking-dot.blue {
|
|
||||||
background: #3b82f6;
|
|
||||||
box-shadow: 0 0 8px #3b82f6;
|
|
||||||
animation: pulse-dot-blue 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.thinking-dot.violet {
|
|
||||||
background: #8b7cf6;
|
|
||||||
box-shadow: 0 0 8px #8b7cf6;
|
|
||||||
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
|
|
||||||
}
|
|
||||||
@keyframes pulse-dot-blue {
|
|
||||||
0%, 100% { opacity: 0.4; transform: scale(0.7); }
|
|
||||||
50% { opacity: 1; transform: scale(1.3); }
|
|
||||||
}
|
|
||||||
@keyframes pulse-dot-violet {
|
|
||||||
0%, 100% { opacity: 0.3; transform: scale(0.6); }
|
|
||||||
50% { opacity: 1; transform: scale(1.4); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding-top: 14px;
|
|
||||||
margin-top: 6px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
.footer-badge {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
color: #7e8799;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Model Selector */
|
|
||||||
.model-select-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Bot } from '@lucide/vue'
|
|
||||||
import type { ChatMessage } from '../../composables/useDashboardData'
|
|
||||||
import { renderMarkdown } from '../../utils/markdown'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
messages: ChatMessage[]
|
|
||||||
irisBusy: boolean
|
|
||||||
elapsedSeconds: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function formatTime(ts: number): string {
|
|
||||||
const d = new Date(ts)
|
|
||||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2.5">
|
|
||||||
<div
|
|
||||||
v-for="msg in messages"
|
|
||||||
:key="msg.id"
|
|
||||||
:class="['flex gap-2', msg.sender === 'user' ? 'justify-end' : '']"
|
|
||||||
>
|
|
||||||
<div v-if="msg.sender === 'iris'" class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
|
|
||||||
<Bot :size="12" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'px-3 py-2 rounded-lg text-[10.5px] leading-[1.45] max-w-[85%]',
|
|
||||||
msg.sender === 'iris'
|
|
||||||
? 'bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.1)] text-[#d4d8e0]'
|
|
||||||
: 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.15)] text-[#e8eaf0]',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div v-if="msg.sender === 'iris'" v-html="renderMarkdown(msg.text)" class="msg-md"></div>
|
|
||||||
<p v-else class="m-0">{{ msg.text }}</p>
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'text-[8px] mt-1 opacity-50',
|
|
||||||
msg.sender === 'user' ? 'text-right' : 'text-left',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ formatTime(msg.timestamp) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Busy Bubble -->
|
|
||||||
<div v-if="irisBusy" class="flex gap-2 max-w-[75%]">
|
|
||||||
<div class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
|
|
||||||
<Bot :size="12" />
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2 rounded-lg text-[10.5px] bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.18)] text-[#c4c8d4]">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="w-[7px] h-[7px] rounded-full bg-[#a78bfa] animate-pulse flex-shrink-0" />
|
|
||||||
<span>Denkt nach...</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-[8px] mt-1 opacity-50">läuft seit {{ elapsedSeconds }}s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="messages.length === 0" class="flex-1 grid place-items-center text-center py-8">
|
|
||||||
<p class="text-[10px] text-[#6b7385] max-w-[180px] leading-[1.4] m-0">
|
|
||||||
No messages yet. Start a conversation with Iris.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.msg-md { line-height: 1.55; }
|
|
||||||
.msg-md :deep(p) { margin: 0 0 6px 0; }
|
|
||||||
.msg-md :deep(p:last-child) { margin-bottom: 0; }
|
|
||||||
.msg-md :deep(strong) { color: #e8eaf0; font-weight: 700; }
|
|
||||||
.msg-md :deep(em) { font-style: italic; color: #c4c8d4; }
|
|
||||||
.msg-md :deep(code) { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 4px; font-family: 'JetBrains Mono','Fira Code',monospace; font-size: 10px; color: #f472b6; }
|
|
||||||
.msg-md :deep(pre) { background: rgba(0,0,0,0.35); padding: 8px 10px; border-radius: 8px; overflow-x: auto; margin: 6px 0; border: 1px solid rgba(255,255,255,0.04); }
|
|
||||||
.msg-md :deep(pre code) { background: none; padding: 0; font-size: 10px; color: #d4d8e0; }
|
|
||||||
.msg-md :deep(a) { color: #a78bfa; text-decoration: underline; text-underline-offset: 2px; }
|
|
||||||
.msg-md :deep(a:hover) { color: #c4b5fd; }
|
|
||||||
.msg-md :deep(ul) { margin: 4px 0; padding-left: 16px; }
|
|
||||||
.msg-md :deep(li) { margin: 2px 0; }
|
|
||||||
.msg-md :deep(h1), .msg-md :deep(h2), .msg-md :deep(h3), .msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { margin: 8px 0 4px 0; color: #e8eaf0; font-weight: 700; line-height: 1.3; }
|
|
||||||
.msg-md :deep(h1) { font-size: 13px; }
|
|
||||||
.msg-md :deep(h2) { font-size: 12px; }
|
|
||||||
.msg-md :deep(h3) { font-size: 11px; }
|
|
||||||
.msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { font-size: 10.5px; }
|
|
||||||
.msg-md :deep(hr) { border: none; border-top: 1px solid rgba(255,255,255,0.08); margin: 8px 0; }
|
|
||||||
</style>
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, nextTick, watch, onUnmounted } from 'vue'
|
|
||||||
import { Bot, Send, Maximize2 } from '@lucide/vue'
|
|
||||||
import type { ChatMessage } from '../../composables/useDashboardData'
|
|
||||||
import { useDashboardData } from '../../composables/useDashboardData'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import Textarea from '@/components/ui/Textarea.vue'
|
|
||||||
import Dialog from '@/components/ui/Dialog.vue'
|
|
||||||
import DialogHeader from '@/components/ui/DialogHeader.vue'
|
|
||||||
import DialogTitle from '@/components/ui/DialogTitle.vue'
|
|
||||||
import ChatMessageList from './ChatMessageList.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
messages: ChatMessage[]
|
|
||||||
irisBusy: boolean
|
|
||||||
irisFocus: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { sendChatMessage, busySince } = useDashboardData()
|
|
||||||
|
|
||||||
const elapsedSeconds = ref(0)
|
|
||||||
let elapsedInterval: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function startElapsedTimer(): void {
|
|
||||||
stopElapsedTimer()
|
|
||||||
const update = () => {
|
|
||||||
if (busySince.value > 0) {
|
|
||||||
elapsedSeconds.value = Math.floor((Date.now() - busySince.value) / 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
update()
|
|
||||||
elapsedInterval = setInterval(update, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopElapsedTimer(): void {
|
|
||||||
if (elapsedInterval) {
|
|
||||||
clearInterval(elapsedInterval)
|
|
||||||
elapsedInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.irisBusy, (busy) => {
|
|
||||||
if (busy) {
|
|
||||||
startElapsedTimer()
|
|
||||||
} else {
|
|
||||||
stopElapsedTimer()
|
|
||||||
elapsedSeconds.value = 0
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopElapsedTimer()
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputText = ref('')
|
|
||||||
const chatListRef = ref<HTMLElement | null>(null)
|
|
||||||
const chatModalListRef = ref<HTMLElement | null>(null)
|
|
||||||
const dialogOpen = ref(false)
|
|
||||||
|
|
||||||
function sendMessage(): void {
|
|
||||||
if (!inputText.value.trim()) return
|
|
||||||
sendChatMessage(inputText.value)
|
|
||||||
inputText.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.messages.length,
|
|
||||||
async () => {
|
|
||||||
await nextTick()
|
|
||||||
const el = dialogOpen.value ? chatModalListRef.value : chatListRef.value
|
|
||||||
if (el) {
|
|
||||||
el.scrollTop = el.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Inline Chat Panel -->
|
|
||||||
<div class="chat-panel">
|
|
||||||
<div class="chat-header">
|
|
||||||
<div class="chat-header-left">
|
|
||||||
<Bot :size="16" class="text-[#a78bfa]" />
|
|
||||||
<h2>Iris Chat</h2>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = true" title="Open larger chat">
|
|
||||||
<Maximize2 :size="14" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Focus Bar -->
|
|
||||||
<div v-if="irisBusy && irisFocus" class="focus-bar">
|
|
||||||
<span class="focus-label">Current Focus</span>
|
|
||||||
<span class="focus-text">{{ irisFocus }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages -->
|
|
||||||
<div ref="chatListRef" class="chat-messages">
|
|
||||||
<ChatMessageList
|
|
||||||
:messages="messages"
|
|
||||||
:iris-busy="irisBusy"
|
|
||||||
:elapsed-seconds="elapsedSeconds"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Input -->
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<Textarea
|
|
||||||
v-model="inputText"
|
|
||||||
rows="1"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
class="min-h-0 h-9 resize-none text-xs bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385] text-[10px]"
|
|
||||||
@keyup.enter.exact="sendMessage"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
|
|
||||||
:disabled="!inputText.trim()"
|
|
||||||
@click="sendMessage"
|
|
||||||
aria-label="Send"
|
|
||||||
>
|
|
||||||
<Send :size="14" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Expanded Chat Dialog -->
|
|
||||||
<Dialog :open="dialogOpen" class="sm:max-w-[820px] sm:h-[78vh] p-0 gap-0" @update:open="dialogOpen = $event">
|
|
||||||
<template #default>
|
|
||||||
<DialogHeader class="flex-row items-center justify-between px-5 py-4 border-b border-[rgba(255,255,255,0.06)]">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Bot :size="18" class="text-[#a78bfa]" />
|
|
||||||
<DialogTitle>Iris Chat</DialogTitle>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = false" aria-label="Close">
|
|
||||||
<span class="text-lg leading-none">×</span>
|
|
||||||
</Button>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div v-if="irisBusy && irisFocus" class="focus-bar !px-5">
|
|
||||||
<span class="focus-label">Current Focus</span>
|
|
||||||
<span class="focus-text">{{ irisFocus }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="chatModalListRef" class="flex-1 overflow-y-auto px-5 py-4">
|
|
||||||
<ChatMessageList
|
|
||||||
:messages="messages"
|
|
||||||
:iris-busy="irisBusy"
|
|
||||||
:elapsed-seconds="elapsedSeconds"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 px-4 py-3 border-t border-[rgba(255,255,255,0.06)]">
|
|
||||||
<Textarea
|
|
||||||
v-model="inputText"
|
|
||||||
rows="1"
|
|
||||||
placeholder="Type a message..."
|
|
||||||
class="min-h-0 h-10 resize-none text-sm bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385]"
|
|
||||||
@keyup.enter.exact="sendMessage"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
class="h-10 w-10 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
|
|
||||||
:disabled="!inputText.trim()"
|
|
||||||
@click="sendMessage"
|
|
||||||
aria-label="Send"
|
|
||||||
>
|
|
||||||
<Send :size="18" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.chat-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 360px;
|
|
||||||
max-height: 480px;
|
|
||||||
background: rgba(22, 27, 34, 0.75);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 16px;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.chat-panel:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
.chat-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.chat-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus Bar */
|
|
||||||
.focus-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: rgba(234, 179, 8, 0.04);
|
|
||||||
border-bottom: 1px solid rgba(234, 179, 8, 0.08);
|
|
||||||
}
|
|
||||||
.focus-label {
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
.focus-text {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #7e8799;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Messages */
|
|
||||||
.chat-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
.chat-messages::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
.chat-messages::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.chat-messages::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(139, 124, 246, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input */
|
|
||||||
.chat-input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile: compact mode ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.chat-panel {
|
|
||||||
min-height: 280px;
|
|
||||||
max-height: 360px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.chat-header {
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
.chat-header h2 {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.chat-messages {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
.chat-input-row {
|
|
||||||
padding: 8px 10px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.focus-bar {
|
|
||||||
padding: 6px 12px;
|
|
||||||
}
|
|
||||||
.focus-label {
|
|
||||||
font-size: 7px;
|
|
||||||
}
|
|
||||||
.focus-text {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
|
|
||||||
import type { FeedEntry } from '../../composables/useDashboardData'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
entries: FeedEntry[]
|
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const selectedDayOffset = ref(0) // 0 = today, -1 = yesterday, etc.
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(offset: number): string {
|
|
||||||
if (offset === 0) return 'Heute'
|
|
||||||
if (offset === -1) return 'Gestern'
|
|
||||||
if (offset === -2) return 'Vorgestern'
|
|
||||||
const d = new Date()
|
|
||||||
d.setDate(d.getDate() + offset)
|
|
||||||
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateDay(dir: -1 | 1) {
|
|
||||||
const next = selectedDayOffset.value + dir
|
|
||||||
if (next >= -6 && next <= 0) {
|
|
||||||
selectedDayOffset.value = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredEntries = computed(() => {
|
|
||||||
const targetDate = new Date()
|
|
||||||
targetDate.setDate(targetDate.getDate() + selectedDayOffset.value)
|
|
||||||
const targetStr = targetDate.toISOString().slice(0, 10)
|
|
||||||
return props.entries.filter(e => e.timestamp.slice(0, 10) === targetStr)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="modelValue" class="feed-modal-overlay" @click.self="close">
|
|
||||||
<div class="feed-modal-card">
|
|
||||||
<div class="feed-modal-header">
|
|
||||||
<h2 class="feed-modal-title">Operations Log</h2>
|
|
||||||
<button class="feed-modal-close-btn" @click="close" aria-label="Close">
|
|
||||||
<X :size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feed-modal-nav">
|
|
||||||
<button
|
|
||||||
class="feed-nav-btn"
|
|
||||||
:disabled="selectedDayOffset <= -6"
|
|
||||||
@click="navigateDay(-1)"
|
|
||||||
aria-label="Previous day"
|
|
||||||
>
|
|
||||||
<ChevronLeft :size="14" />
|
|
||||||
</button>
|
|
||||||
<span class="feed-nav-label">{{ dayLabel(selectedDayOffset) }}</span>
|
|
||||||
<button
|
|
||||||
class="feed-nav-btn"
|
|
||||||
:disabled="selectedDayOffset >= 0"
|
|
||||||
@click="navigateDay(1)"
|
|
||||||
aria-label="Next day"
|
|
||||||
>
|
|
||||||
<ChevronRight :size="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feed-modal-entries">
|
|
||||||
<div v-if="filteredEntries.length === 0" class="feed-modal-empty">
|
|
||||||
Keine Einträge für diesen Tag.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(entry, idx) in filteredEntries"
|
|
||||||
:key="entry.timestamp + '-' + idx"
|
|
||||||
class="feed-modal-entry"
|
|
||||||
>
|
|
||||||
<span class="feed-time">{{ entry.time }}</span>
|
|
||||||
<span class="feed-bullet">·</span>
|
|
||||||
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
|
|
||||||
{{ entry.agent }}
|
|
||||||
</span>
|
|
||||||
<span class="feed-action">{{ entry.action }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.feed-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
padding: 20px;
|
|
||||||
animation: feed-overlay-in 0.2s ease;
|
|
||||||
}
|
|
||||||
@keyframes feed-overlay-in {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-modal-card {
|
|
||||||
background: #161b22;
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.15);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
animation: feed-card-in 0.25s ease;
|
|
||||||
}
|
|
||||||
@keyframes feed-card-in {
|
|
||||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.feed-modal-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.feed-modal-close-btn {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: none;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #7e8799;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.feed-modal-close-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-modal-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.feed-nav-btn {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.15);
|
|
||||||
background: rgba(139, 124, 246, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #a78bfa;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.feed-nav-btn:hover:not(:disabled) {
|
|
||||||
background: rgba(139, 124, 246, 0.16);
|
|
||||||
border-color: rgba(139, 124, 246, 0.3);
|
|
||||||
}
|
|
||||||
.feed-nav-btn:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.feed-nav-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #d1d5db;
|
|
||||||
min-width: 100px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-modal-entries {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: 50vh;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
.feed-modal-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6b7385;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-modal-entry {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 5px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 9.5px;
|
|
||||||
line-height: 1.3;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.feed-modal-entry:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-time {
|
|
||||||
color: #6b7385;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
.feed-bullet {
|
|
||||||
color: #6b7385;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.feed-agent {
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.agent-iris {
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
.agent-developer {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
.agent-devops {
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
.agent-researcher {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.agent-reviewer {
|
|
||||||
color: #a855f7;
|
|
||||||
}
|
|
||||||
.feed-action {
|
|
||||||
color: #7e8799;
|
|
||||||
white-space: normal;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { Bot, Sparkles, MessageSquareText, ListTodo, Zap, FileText, Send, Lightbulb } from '@lucide/vue'
|
|
||||||
import { useTime } from '../../composables/useTime'
|
|
||||||
import { useOperationsStore } from '../../stores/operations'
|
|
||||||
|
|
||||||
interface Suggestion {
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const { greeting } = useTime()
|
|
||||||
const store = useOperationsStore()
|
|
||||||
|
|
||||||
const chatInput = ref('')
|
|
||||||
|
|
||||||
const meters = computed(() => {
|
|
||||||
const tasks = store.snapshot.tasks
|
|
||||||
return {
|
|
||||||
openTasks: store.snapshot.metrics.queuedTasks,
|
|
||||||
blocked: store.snapshot.metrics.incidents,
|
|
||||||
critical: tasks.filter(t => t.state === 'Blocked').length,
|
|
||||||
active: tasks.filter(t => t.state === 'In progress').length,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const suggestions = ref<Suggestion[]>([
|
|
||||||
{ text: 'Du solltest zuerst das Dungeon-System abschließen.' },
|
|
||||||
{ text: 'Die Dokumentation wurde seit 3 Tagen nicht aktualisiert.' },
|
|
||||||
{ text: 'Das Projekt OpenClaw benötigt Aufmerksamkeit.' },
|
|
||||||
])
|
|
||||||
|
|
||||||
function sendChat() {
|
|
||||||
if (!chatInput.value.trim()) return
|
|
||||||
console.log('[Iris] Chat received:', chatInput.value)
|
|
||||||
chatInput.value = ''
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<aside class="iris-panel">
|
|
||||||
<div class="iris-profile">
|
|
||||||
<div class="iris-avatar">
|
|
||||||
<Bot :size="32" />
|
|
||||||
</div>
|
|
||||||
<div class="iris-name-block">
|
|
||||||
<h2>Iris</h2>
|
|
||||||
<span class="iris-role">Chief of Staff</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="iris-greeting">{{ greeting }} Bao.</p>
|
|
||||||
<p class="iris-status">Du hast heute <strong>4 wichtige Punkte.</strong></p>
|
|
||||||
|
|
||||||
<div class="meters">
|
|
||||||
<div class="meter-item">
|
|
||||||
<span class="meter-value">{{ meters.openTasks }}</span>
|
|
||||||
<span class="meter-label">Offene Aufgaben</span>
|
|
||||||
</div>
|
|
||||||
<div class="meter-item">
|
|
||||||
<span class="meter-value meter-blocked">{{ meters.blocked }}</span>
|
|
||||||
<span class="meter-label">Blockiert</span>
|
|
||||||
</div>
|
|
||||||
<div class="meter-item">
|
|
||||||
<span class="meter-value meter-critical">{{ meters.critical }}</span>
|
|
||||||
<span class="meter-label">Kritisch</span>
|
|
||||||
</div>
|
|
||||||
<div class="meter-item">
|
|
||||||
<span class="meter-value meter-active">{{ meters.active }}</span>
|
|
||||||
<span class="meter-label">Aktiv</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="suggestions">
|
|
||||||
<h3><Sparkles :size="14" /> Vorschläge</h3>
|
|
||||||
<div
|
|
||||||
v-for="(s, idx) in suggestions"
|
|
||||||
:key="idx"
|
|
||||||
class="suggestion-card"
|
|
||||||
>
|
|
||||||
<Lightbulb :size="14" class="bulb" />
|
|
||||||
<span>{{ s.text }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="quick-actions">
|
|
||||||
<button class="qa-btn">
|
|
||||||
<MessageSquareText :size="14" /> Chat öffnen
|
|
||||||
</button>
|
|
||||||
<button class="qa-btn">
|
|
||||||
<ListTodo :size="14" /> Tagesplanung
|
|
||||||
</button>
|
|
||||||
<button class="qa-btn">
|
|
||||||
<Zap :size="14" /> Prioritäten setzen
|
|
||||||
</button>
|
|
||||||
<button class="qa-btn">
|
|
||||||
<FileText :size="14" /> Zusammenfassung
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-box">
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<input
|
|
||||||
v-model="chatInput"
|
|
||||||
type="text"
|
|
||||||
placeholder="Frag Iris etwas..."
|
|
||||||
@keyup.enter="sendChat"
|
|
||||||
/>
|
|
||||||
<button class="chat-send" @click="sendChat">
|
|
||||||
<Send :size="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.iris-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
background: rgba(22, 27, 34, 0.8);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.iris-panel:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iris-profile {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.iris-avatar {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(167, 139, 250, 0.15);
|
|
||||||
color: #a78bfa;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.iris-name-block h2 {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.iris-role {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #a78bfa;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.iris-greeting {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.iris-status {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #7e8799;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.iris-status strong {
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Meters */
|
|
||||||
.meters {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.meter-item {
|
|
||||||
background: rgba(139, 124, 246, 0.06);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.meter-item:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.18);
|
|
||||||
background: rgba(139, 124, 246, 0.1);
|
|
||||||
}
|
|
||||||
.meter-value {
|
|
||||||
display: block;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.meter-blocked { color: #eab308; }
|
|
||||||
.meter-critical { color: #ef4444; }
|
|
||||||
.meter-active { color: #3b82f6; }
|
|
||||||
.meter-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 8px;
|
|
||||||
color: #6b7385;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Suggestions */
|
|
||||||
.suggestions h3 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #a78bfa;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
}
|
|
||||||
.suggestion-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 7px 8px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: default;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.suggestion-card:hover {
|
|
||||||
background: rgba(139, 124, 246, 0.08);
|
|
||||||
}
|
|
||||||
.suggestion-card .bulb {
|
|
||||||
color: #eab308;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
.suggestion-card span {
|
|
||||||
font-size: 10.5px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: #7e8799;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick Actions */
|
|
||||||
.quick-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.qa-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(139, 124, 246, 0.04);
|
|
||||||
color: #7e8799;
|
|
||||||
font-size: 10.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.qa-btn:hover {
|
|
||||||
background: rgba(139, 124, 246, 0.12);
|
|
||||||
border-color: rgba(139, 124, 246, 0.2);
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat Box */
|
|
||||||
.chat-box {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
.chat-input-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
.chat-input-row input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(13, 17, 23, 0.6);
|
|
||||||
color: #e8eaf0;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.chat-input-row input:focus {
|
|
||||||
border-color: #a78bfa;
|
|
||||||
}
|
|
||||||
.chat-input-row input::placeholder {
|
|
||||||
color: #6b7385;
|
|
||||||
}
|
|
||||||
.chat-send {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #a78bfa;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
.chat-send:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.iris-panel {
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { Activity } from '@lucide/vue'
|
|
||||||
import type { FeedEntry } from '../../composables/useDashboardData'
|
|
||||||
import FeedDetailModal from './FeedDetailModal.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
entries: FeedEntry[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// ── Compact feed (5 items) ──
|
|
||||||
const compactEntries = computed(() => props.entries.slice(0, 5))
|
|
||||||
|
|
||||||
// ── Feed Detail Modal ──
|
|
||||||
const showDetailModal = ref(false)
|
|
||||||
|
|
||||||
function openDetailModal() {
|
|
||||||
showDetailModal.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="feed-panel">
|
|
||||||
<div class="feed-header">
|
|
||||||
<Activity :size="14" class="feed-icon" />
|
|
||||||
<h2>Operations Feed</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feed-list">
|
|
||||||
<TransitionGroup name="feed">
|
|
||||||
<div
|
|
||||||
v-for="(entry, idx) in compactEntries"
|
|
||||||
:key="entry.timestamp + '-' + idx"
|
|
||||||
class="feed-entry"
|
|
||||||
>
|
|
||||||
<span class="feed-time">{{ entry.time }}</span>
|
|
||||||
<span class="feed-bullet">·</span>
|
|
||||||
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
|
|
||||||
{{ entry.agent }}
|
|
||||||
</span>
|
|
||||||
<span class="feed-action">{{ entry.action }}</span>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
|
|
||||||
<div v-if="entries.length === 0" class="feed-empty">
|
|
||||||
<span>No operations recorded yet.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button v-if="entries.length > 5" class="feed-more-btn" @click="openDetailModal">
|
|
||||||
Mehr anzeigen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FeedDetailModal
|
|
||||||
:entries="entries"
|
|
||||||
:model-value="showDetailModal"
|
|
||||||
@update:model-value="showDetailModal = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.feed-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px;
|
|
||||||
background: rgba(22, 27, 34, 0.65);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
|
||||||
border-radius: 14px;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.feed-panel:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.feed-icon {
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
.feed-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-entry {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 5px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 9.5px;
|
|
||||||
line-height: 1.3;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.feed-entry:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-time {
|
|
||||||
color: #6b7385;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
width: 32px;
|
|
||||||
}
|
|
||||||
.feed-bullet {
|
|
||||||
color: #6b7385;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.feed-agent {
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.agent-iris {
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
.agent-developer {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
.agent-devops {
|
|
||||||
color: #eab308;
|
|
||||||
}
|
|
||||||
.agent-researcher {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.agent-reviewer {
|
|
||||||
color: #a855f7;
|
|
||||||
}
|
|
||||||
.feed-action {
|
|
||||||
color: #7e8799;
|
|
||||||
white-space: normal;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6b7385;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-more-btn {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
margin-top: 4px;
|
|
||||||
background: rgba(139, 124, 246, 0.08);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #a78bfa;
|
|
||||||
font-size: 9.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.feed-more-btn:hover {
|
|
||||||
background: rgba(139, 124, 246, 0.14);
|
|
||||||
border-color: rgba(139, 124, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TransitionGroup */
|
|
||||||
.feed-enter-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.feed-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
.feed-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-10px);
|
|
||||||
}
|
|
||||||
.feed-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(10px);
|
|
||||||
}
|
|
||||||
.feed-move {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import {
|
|
||||||
ListTodo,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
Trash2,
|
|
||||||
Zap,
|
|
||||||
} from '@lucide/vue'
|
|
||||||
import type { QueueItem } from '../../composables/useDashboardData'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
items: QueueItem[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
remove: [id: string]
|
|
||||||
moveUp: [id: string]
|
|
||||||
moveDown: [id: string]
|
|
||||||
changePriority: [id: string, priority: QueueItem['priority']]
|
|
||||||
executeNow: [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const expanded = ref(true)
|
|
||||||
|
|
||||||
const priorityColor: Record<string, string> = {
|
|
||||||
high: '#ef4444',
|
|
||||||
medium: '#eab308',
|
|
||||||
low: '#6b7385',
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragIndex = ref<number | null>(null)
|
|
||||||
const dragOverIndex = ref<number | null>(null)
|
|
||||||
|
|
||||||
function onDragStart(idx: number): void {
|
|
||||||
dragIndex.value = idx
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragOver(e: DragEvent, idx: number): void {
|
|
||||||
e.preventDefault()
|
|
||||||
dragOverIndex.value = idx
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(): void {
|
|
||||||
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
|
|
||||||
const id = props.items[dragIndex.value]?.id
|
|
||||||
if (id) {
|
|
||||||
const targetId = props.items[dragOverIndex.value]?.id
|
|
||||||
if (targetId) {
|
|
||||||
if (dragIndex.value < dragOverIndex.value) {
|
|
||||||
for (let i = dragIndex.value; i < dragOverIndex.value; i++) {
|
|
||||||
emit('moveDown', props.items[i]!.id)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = dragIndex.value; i > dragOverIndex.value; i--) {
|
|
||||||
emit('moveUp', props.items[i]!.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragIndex.value = null
|
|
||||||
dragOverIndex.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragEnd(): void {
|
|
||||||
dragIndex.value = null
|
|
||||||
dragOverIndex.value = null
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="queue-panel">
|
|
||||||
<div class="queue-header" @click="expanded = !expanded" role="button" tabindex="0" :aria-expanded="expanded" @keyup.enter="expanded = !expanded">
|
|
||||||
<div class="queue-header-left">
|
|
||||||
<ListTodo :size="14" class="text-[#a78bfa]" />
|
|
||||||
<h2>Chat Queue</h2>
|
|
||||||
<Badge variant="outline" class="text-[10px] font-bold text-[#a78bfa] bg-[rgba(167,139,250,0.1)] border-0 rounded-full px-2">
|
|
||||||
{{ items.length }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="expanded ? 'Collapse' : 'Expand'">
|
|
||||||
<ChevronUp v-if="expanded" :size="14" />
|
|
||||||
<ChevronDown v-else :size="14" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition name="queue-expand">
|
|
||||||
<div v-if="expanded" class="queue-list">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in items"
|
|
||||||
:key="item.id"
|
|
||||||
class="queue-item"
|
|
||||||
:class="{
|
|
||||||
'drag-source': dragIndex === idx,
|
|
||||||
'drag-over': dragOverIndex === idx && dragIndex !== idx,
|
|
||||||
}"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="onDragStart(idx)"
|
|
||||||
@dragover="onDragOver($event, idx)"
|
|
||||||
@drop="onDrop"
|
|
||||||
@dragend="onDragEnd"
|
|
||||||
>
|
|
||||||
<div class="queue-item-body">
|
|
||||||
<div class="queue-item-head">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="text-[7px] font-bold uppercase tracking-wider py-0 px-1.5 border"
|
|
||||||
:style="{
|
|
||||||
color: priorityColor[item.priority],
|
|
||||||
borderColor: `${priorityColor[item.priority]}30`,
|
|
||||||
background: `${priorityColor[item.priority]}10`,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ item.priority }}
|
|
||||||
</Badge>
|
|
||||||
<span class="queue-wait">{{ item.waitTime }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="queue-text">{{ item.text }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="queue-actions">
|
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Execute now" @click.stop="emit('executeNow', item.id)">
|
|
||||||
<Zap :size="12" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move up" :disabled="idx === 0" @click.stop="emit('moveUp', item.id)">
|
|
||||||
<ArrowUp :size="12" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move down" :disabled="idx === items.length - 1" @click.stop="emit('moveDown', item.id)">
|
|
||||||
<ArrowDown :size="12" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)]" title="Remove" @click.stop="emit('remove', item.id)">
|
|
||||||
<Trash2 :size="12" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="items.length === 0" class="queue-empty">
|
|
||||||
<p>Queue is empty</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.queue-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: rgba(22, 27, 34, 0.75);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
|
||||||
border-radius: 16px;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
|
||||||
.queue-panel:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.queue-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
.queue-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0 10px 10px;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
.queue-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
.queue-item:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
.drag-source {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
.drag-over {
|
|
||||||
background: rgba(167, 139, 250, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.queue-item-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
|
||||||
.queue-wait {
|
|
||||||
font-size: 8px;
|
|
||||||
color: #6b7385;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.queue-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 9.5px;
|
|
||||||
color: #7e8799;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.queue-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.queue-item:hover .queue-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.queue-empty p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6b7385;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition */
|
|
||||||
.queue-expand-enter-active,
|
|
||||||
.queue-expand-leave-active {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.queue-expand-enter-from,
|
|
||||||
.queue-expand-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const recentlyFinished = ref([
|
|
||||||
'Docker Image gebaut',
|
|
||||||
'Memory Compression',
|
|
||||||
'Enemy AI verbessert',
|
|
||||||
'Daily Backup',
|
|
||||||
'TeamView deployt',
|
|
||||||
'Config-Editor live',
|
|
||||||
])
|
|
||||||
|
|
||||||
function onChipClick(item: string) {
|
|
||||||
console.log('[Dashboard] Recently finished:', item)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="finished-section">
|
|
||||||
<h3>Recently Finished</h3>
|
|
||||||
<div class="finished-scroll">
|
|
||||||
<span
|
|
||||||
v-for="(item, idx) in recentlyFinished"
|
|
||||||
:key="idx"
|
|
||||||
class="finished-chip"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@click="onChipClick(item)"
|
|
||||||
@keyup.enter="onChipClick(item)"
|
|
||||||
>
|
|
||||||
{{ item }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.finished-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.finished-section h3 {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #7e8799;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.finished-scroll {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
.finished-scroll::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.finished-chip {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 5px 12px;
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.1);
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(139, 124, 246, 0.06);
|
|
||||||
color: #7e8799;
|
|
||||||
font-size: 9.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.finished-chip:hover {
|
|
||||||
background: rgba(139, 124, 246, 0.12);
|
|
||||||
border-color: rgba(139, 124, 246, 0.2);
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.finished-chip:focus-visible {
|
|
||||||
outline: 2px solid #a78bfa;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.finished-section {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Plus, Circle, ChevronRight } from '@lucide/vue'
|
|
||||||
import type { OpenTask } from '../../composables/useDashboardData'
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
tasks: OpenTask[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
newTask: []
|
|
||||||
'go-board': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const expandedId = ref<string | null>(null)
|
|
||||||
|
|
||||||
function toggleExpand(id: string) {
|
|
||||||
expandedId.value = expandedId.value === id ? null : id
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="task-card-panel">
|
|
||||||
<div class="task-header">
|
|
||||||
<h2 class="task-title">Offene Aufgaben</h2>
|
|
||||||
<Button variant="outline" size="sm" class="h-7 text-[9px] gap-1 border-[rgba(139,124,246,0.2)] bg-[rgba(139,124,246,0.12)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.2)]" @click="emit('newTask')">
|
|
||||||
<Plus :size="12" />
|
|
||||||
<span>New Task</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="task-list">
|
|
||||||
<div v-if="tasks.length === 0" class="task-empty">
|
|
||||||
Keine offenen Aufgaben. Erstelle eine mit + New Task.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransitionGroup name="task">
|
|
||||||
<div
|
|
||||||
v-for="task in tasks"
|
|
||||||
:key="task.id"
|
|
||||||
class="task-item"
|
|
||||||
:class="{ expanded: expandedId === task.id }"
|
|
||||||
@click="toggleExpand(task.id)"
|
|
||||||
>
|
|
||||||
<div class="task-main">
|
|
||||||
<Circle
|
|
||||||
:size="8"
|
|
||||||
class="task-source-dot"
|
|
||||||
:class="task.source === 'iris' ? 'dot-iris' : 'dot-bao'"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
<div class="task-content">
|
|
||||||
<div class="task-title-row">
|
|
||||||
<span class="task-name">{{ task.title }}</span>
|
|
||||||
<span class="task-time">{{ task.createdAt }}</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
:class="task.source === 'iris'
|
|
||||||
? 'bg-[rgba(167,139,250,0.15)] text-[#a78bfa] border-0 text-[8px] py-0 px-1.5'
|
|
||||||
: 'bg-[rgba(59,130,246,0.15)] text-[#3b82f6] border-0 text-[8px] py-0 px-1.5'"
|
|
||||||
>
|
|
||||||
{{ task.source === 'iris' ? 'Iris' : 'Bao' }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="expandedId === task.id" class="task-detail">
|
|
||||||
{{ task.detail }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="ghost" class="w-full mt-3 h-9 gap-1.5 text-[10px] border border-[rgba(139,124,246,0.15)] bg-[rgba(139,124,246,0.08)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.15)]" @click="emit('go-board')">
|
|
||||||
<span>Zum Task Board</span>
|
|
||||||
<ChevronRight :size="14" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.task-card-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px;
|
|
||||||
background: rgba(22, 27, 34, 0.65);
|
|
||||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
|
||||||
border-radius: 14px;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
.task-card-panel:hover {
|
|
||||||
border-color: rgba(139, 124, 246, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.task-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
.task-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border-color: rgba(139, 124, 246, 0.08);
|
|
||||||
}
|
|
||||||
.task-item.expanded {
|
|
||||||
background: rgba(139, 124, 246, 0.04);
|
|
||||||
border-color: rgba(139, 124, 246, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-source-dot {
|
|
||||||
margin-top: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.dot-iris {
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
.dot-bao {
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
.task-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.task-name {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #d1d5db;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.task-time {
|
|
||||||
font-size: 8.5px;
|
|
||||||
color: #6b7385;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-detail {
|
|
||||||
padding: 6px 10px;
|
|
||||||
margin: 0 0 2px 16px;
|
|
||||||
font-size: 9.5px;
|
|
||||||
color: #7e8799;
|
|
||||||
line-height: 1.45;
|
|
||||||
background: rgba(0, 0, 0, 0.15);
|
|
||||||
border-radius: 6px;
|
|
||||||
border-left: 2px solid rgba(139, 124, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 16px 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6b7385;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TransitionGroup */
|
|
||||||
.task-enter-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.task-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
.task-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-6px);
|
|
||||||
}
|
|
||||||
.task-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(6px);
|
|
||||||
}
|
|
||||||
.task-move {
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,541 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, toRef, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
|
|
||||||
import type { AgentNodeData } from '../../composables/useDashboardData'
|
|
||||||
import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
agents: AgentNodeData[]
|
|
||||||
heroId?: string
|
|
||||||
activeAgents?: string[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// ── Network ref ──
|
|
||||||
const networkRef = ref<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
// ── Computed data ──
|
|
||||||
const heroId = computed(() => props.heroId ?? props.agents[0]?.id ?? '')
|
|
||||||
|
|
||||||
function isActive(id: string): boolean {
|
|
||||||
return props.activeAgents?.includes(id) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SVG composable ──
|
|
||||||
const {
|
|
||||||
svgWidth,
|
|
||||||
svgHeight,
|
|
||||||
childAgents,
|
|
||||||
connectionPaths,
|
|
||||||
storePathRef,
|
|
||||||
storePulseRef,
|
|
||||||
storePulseRef2,
|
|
||||||
} = useTeamNetworkSvg(networkRef, toRef(props, 'agents'), heroId, isActive)
|
|
||||||
|
|
||||||
// ── Icon resolver ──
|
|
||||||
function resolveIcon(iconName: string) {
|
|
||||||
switch (iconName) {
|
|
||||||
case 'bot': return Bot
|
|
||||||
case 'code': return Code2
|
|
||||||
case 'server': return Server
|
|
||||||
case 'shield': return Shield
|
|
||||||
case 'search': return Search
|
|
||||||
case 'terminal': return Terminal
|
|
||||||
default: return Bot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Runtime formatter ──
|
|
||||||
function formatRuntime(seconds: number): string {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Model formatter ──
|
|
||||||
function formatModel(model: string): string {
|
|
||||||
const parts = model.split('/')
|
|
||||||
const name = parts[parts.length - 1]
|
|
||||||
return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mobile media query ──
|
|
||||||
const isMobile = ref(false)
|
|
||||||
let mq: MediaQueryList | null = null
|
|
||||||
|
|
||||||
function onMqChange(e: MediaQueryListEvent) {
|
|
||||||
isMobile.value = e.matches
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
mq = window.matchMedia('(max-width: 600px)')
|
|
||||||
isMobile.value = mq.matches
|
|
||||||
mq.addEventListener('change', onMqChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (mq) {
|
|
||||||
mq.removeEventListener('change', onMqChange)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function visibleTags(tags: string[]) {
|
|
||||||
if (!isMobile.value || tags.length <= 4) {
|
|
||||||
return { shown: tags, overflow: 0 }
|
|
||||||
}
|
|
||||||
return { shown: tags.slice(0, 4), overflow: tags.length - 4 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Hero computed ──
|
|
||||||
const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="networkRef" class="ai-team-network">
|
|
||||||
<!-- SVG Connection Layer -->
|
|
||||||
<svg
|
|
||||||
v-if="svgWidth > 0 && svgHeight > 0"
|
|
||||||
class="network-svg"
|
|
||||||
:width="svgWidth"
|
|
||||||
:height="svgHeight"
|
|
||||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<filter
|
|
||||||
v-for="agent in childAgents"
|
|
||||||
:key="`glow-${agent.id}`"
|
|
||||||
:id="`glow-${agent.id}`"
|
|
||||||
x="-30%" y="-30%" width="160%" height="160%"
|
|
||||||
>
|
|
||||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Connection lines for each agent -->
|
|
||||||
<template v-for="agent in childAgents" :key="agent.id">
|
|
||||||
<!-- Base line -->
|
|
||||||
<path
|
|
||||||
v-if="connectionPaths[agent.id]"
|
|
||||||
:ref="storePathRef(agent.id)"
|
|
||||||
:d="connectionPaths[agent.id]!.d"
|
|
||||||
:stroke="agent.color"
|
|
||||||
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
|
|
||||||
fill="none"
|
|
||||||
:opacity="isActive(agent.id) ? 0.7 : 0.25"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Glow line for active agent -->
|
|
||||||
<path
|
|
||||||
v-if="isActive(agent.id) && connectionPaths[agent.id]"
|
|
||||||
:d="connectionPaths[agent.id]!.d"
|
|
||||||
:stroke="agent.color"
|
|
||||||
stroke-width="4"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
:filter="`url(#glow-${agent.id})`"
|
|
||||||
opacity="0.5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Pulse line 1 (white dashed segment moving along) -->
|
|
||||||
<path
|
|
||||||
v-if="connectionPaths[agent.id]"
|
|
||||||
:ref="storePulseRef(agent.id)"
|
|
||||||
:d="connectionPaths[agent.id]!.d"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="3"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
:opacity="isActive(agent.id) ? 1 : 0.4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Pulse line 2 (offset by half cycle) -->
|
|
||||||
<path
|
|
||||||
v-if="connectionPaths[agent.id]"
|
|
||||||
:ref="storePulseRef2(agent.id)"
|
|
||||||
:d="connectionPaths[agent.id]!.d"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="3"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
:opacity="isActive(agent.id) ? 0.8 : 0.3"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Cards Layer (above SVG) -->
|
|
||||||
<div class="cards-layer">
|
|
||||||
<!-- Hero: Iris centered top -->
|
|
||||||
<div class="hero-slot" :data-agent-id="hero.id">
|
|
||||||
<article
|
|
||||||
class="agent-card hero-card"
|
|
||||||
:style="{
|
|
||||||
'--card-color': hero.color,
|
|
||||||
...(isActive(hero.id) ? {
|
|
||||||
boxShadow: `0 0 20px ${hero.color}44`,
|
|
||||||
borderColor: hero.color
|
|
||||||
} : {})
|
|
||||||
}"
|
|
||||||
@click="emit('select', hero.id)"
|
|
||||||
>
|
|
||||||
<div class="card-main">
|
|
||||||
<div class="card-icon-wrap" :style="{ background: `${hero.color}18`, color: hero.color }">
|
|
||||||
<component :is="resolveIcon(hero.icon)" :size="20" />
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-name-row">
|
|
||||||
<h3 class="card-name">{{ hero.name }}</h3>
|
|
||||||
<span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="card-desc">{{ hero.description }}</p>
|
|
||||||
<div v-if="hero.currentTask" class="task-row">
|
|
||||||
<span class="node-task">
|
|
||||||
<span class="node-task-dot" :style="{ color: hero.color }">●</span>
|
|
||||||
{{ hero.currentTask }}
|
|
||||||
</span>
|
|
||||||
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
|
|
||||||
<span v-if="hero.model" class="node-model">{{ formatModel(hero.model) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="idle-row">
|
|
||||||
<span class="idle-badge">Idle</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-tags">
|
|
||||||
<template v-for="(tag, idx) in visibleTags(hero.tags).shown" :key="tag">
|
|
||||||
<span class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
|
|
||||||
</template>
|
|
||||||
<span v-if="visibleTags(hero.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${hero.color}18`, color: hero.color }">+{{ visibleTags(hero.tags).overflow }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-arrow">
|
|
||||||
<span class="arrow-icon">→</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Agent Grid: 2 columns x 2 rows -->
|
|
||||||
<div class="agent-grid">
|
|
||||||
<div
|
|
||||||
v-for="agent in childAgents"
|
|
||||||
:key="agent.id"
|
|
||||||
:data-agent-id="agent.id"
|
|
||||||
class="agent-slot"
|
|
||||||
>
|
|
||||||
<article
|
|
||||||
class="agent-card"
|
|
||||||
:style="{
|
|
||||||
'--card-color': agent.color,
|
|
||||||
...(isActive(agent.id) ? {
|
|
||||||
boxShadow: `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`,
|
|
||||||
borderColor: agent.color
|
|
||||||
} : {})
|
|
||||||
}"
|
|
||||||
@click="emit('select', agent.id)"
|
|
||||||
>
|
|
||||||
<div class="card-main">
|
|
||||||
<div class="card-icon-wrap" :style="{ background: `${agent.color}18`, color: agent.color }">
|
|
||||||
<component :is="resolveIcon(agent.icon)" :size="18" />
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="card-name-row">
|
|
||||||
<h3 class="card-name">{{ agent.name }}</h3>
|
|
||||||
<span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="card-desc">{{ agent.description }}</p>
|
|
||||||
<div v-if="agent.currentTask" class="task-row">
|
|
||||||
<span class="node-task">
|
|
||||||
<span class="node-task-dot" :style="{ color: agent.color }">●</span>
|
|
||||||
{{ agent.currentTask }}
|
|
||||||
</span>
|
|
||||||
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
|
|
||||||
<span v-if="agent.model" class="node-model">{{ formatModel(agent.model) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="idle-row">
|
|
||||||
<span class="idle-badge">Idle</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-tags">
|
|
||||||
<template v-for="(tag, idx) in visibleTags(agent.tags).shown" :key="tag">
|
|
||||||
<span class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
|
|
||||||
</template>
|
|
||||||
<span v-if="visibleTags(agent.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${agent.color}18`, color: agent.color }">+{{ visibleTags(agent.tags).overflow }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-arrow">
|
|
||||||
<span class="arrow-icon">→</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ai-team-network {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.network-svg {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards-layer {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-slot {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
transition: border-color 0.3s, box-shadow 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 820px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-slot {
|
|
||||||
width: 100%;
|
|
||||||
transition: border-color 0.3s, box-shadow 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Agent Card ── */
|
|
||||||
.agent-card {
|
|
||||||
background: rgba(18, 22, 30, 0.45);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.agent-card:hover {
|
|
||||||
background: rgba(18, 22, 30, 0.65);
|
|
||||||
border-color: var(--card-color, #8b7cf6);
|
|
||||||
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
|
|
||||||
}
|
|
||||||
.hero-card {
|
|
||||||
background: rgba(18, 22, 30, 0.45);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
|
|
||||||
}
|
|
||||||
.hero-card:hover {
|
|
||||||
background: rgba(18, 22, 30, 0.65);
|
|
||||||
border-color: #8b7cf6;
|
|
||||||
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
|
|
||||||
}
|
|
||||||
.card-main {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.card-icon-wrap {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.card-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.card-name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.card-name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e8eaf0;
|
|
||||||
}
|
|
||||||
.card-role-tag {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 8.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.card-desc {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: #7e8799;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Task + Runtime Row ── */
|
|
||||||
.task-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.node-task {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #9ea5b3;
|
|
||||||
line-height: 1.4;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.node-task-dot {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 4px;
|
|
||||||
font-size: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.node-runtime {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #6b7385;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.node-model {
|
|
||||||
font-size: 8.5px;
|
|
||||||
color: #6b7385;
|
|
||||||
font-weight: 500;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Idle Row ── */
|
|
||||||
.idle-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.idle-badge {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #6b7385;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(107, 115, 133, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tags ── */
|
|
||||||
.card-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.card-tag {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 5px;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.tag-overflow {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Hover Arrow ── */
|
|
||||||
.card-arrow {
|
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
color: #6b7385;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-6px);
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.agent-card:hover .card-arrow {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
.arrow-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tablet ── */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.agent-grid {
|
|
||||||
max-width: 100%;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.hero-slot {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.card-name {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.card-desc {
|
|
||||||
font-size: 9.5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.agent-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.agent-card {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
.card-icon-wrap {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
}
|
|
||||||
.card-name {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.card-role-tag {
|
|
||||||
font-size: 7.5px;
|
|
||||||
}
|
|
||||||
.card-desc {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
.card-tag {
|
|
||||||
font-size: 8px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
}
|
|
||||||
.cards-layer {
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import type { AgentDetailData } from './types'
|
||||||
|
import { icons } from '../../../composables/icons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
agent: AgentDetailData
|
||||||
|
agentOrder: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
select: [id: string]
|
||||||
|
changeModel: [agentId: string, modelId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/* ── Progress animation ────────────────────────── */
|
||||||
|
const displayProgress = ref(0)
|
||||||
|
|
||||||
|
function animateProgress() {
|
||||||
|
displayProgress.value = 0
|
||||||
|
setTimeout(() => { displayProgress.value = props.agent.progress }, 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typewriter ────────────────────────────────── */
|
||||||
|
const thinkDisplay = ref('')
|
||||||
|
let thinkTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function startTypewriter() {
|
||||||
|
if (thinkTimer) clearInterval(thinkTimer)
|
||||||
|
thinkDisplay.value = ''
|
||||||
|
if (!props.agent.think) return
|
||||||
|
const text = props.agent.think
|
||||||
|
let i = 0
|
||||||
|
thinkTimer = setInterval(() => {
|
||||||
|
thinkDisplay.value = text.slice(0, i)
|
||||||
|
i = i >= text.length ? 0 : i + 1
|
||||||
|
}, 38)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selected model ────────────────────────────── */
|
||||||
|
const selectedModel = ref(props.agent.model)
|
||||||
|
|
||||||
|
watch(() => props.agent.id, () => {
|
||||||
|
selectedModel.value = props.agent.model
|
||||||
|
animateProgress()
|
||||||
|
startTypewriter()
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectModel(alias: string) {
|
||||||
|
selectedModel.value = alias
|
||||||
|
emit('changeModel', props.agent.id, alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Keyboard / Backdrop ───────────────────────── */
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { emit('close'); return }
|
||||||
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = props.agentOrder.indexOf(props.agent.id)
|
||||||
|
if (idx === -1) return
|
||||||
|
const next = e.key === 'ArrowRight'
|
||||||
|
? (idx + 1) % props.agentOrder.length
|
||||||
|
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
|
||||||
|
emit('select', props.agentOrder[next])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('modal-ov')) emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
animateProgress()
|
||||||
|
startTypewriter()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
if (thinkTimer) clearInterval(thinkTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
work: 'var(--st-work)',
|
||||||
|
think: 'var(--st-think)',
|
||||||
|
idle: 'var(--st-idle)',
|
||||||
|
block: 'var(--st-block)',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="modal-ov" @click="onBackdrop">
|
||||||
|
<div class="modal-card">
|
||||||
|
|
||||||
|
<!-- Close -->
|
||||||
|
<button class="m-close" @click="emit('close')">×</button>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="m-head">
|
||||||
|
<div :class="['m-av', { iris: agent.id === 'iris' }]">
|
||||||
|
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0">
|
||||||
|
<div class="m-name">{{ agent.name }}</div>
|
||||||
|
<div class="m-sub">
|
||||||
|
<span :class="['badge', agent.roleBadge]">{{ agent.role }}</span>
|
||||||
|
<span class="m-pill">{{ selectedModel }}</span>
|
||||||
|
<span class="m-status" :style="{ color: statusColors[agent.status] }">
|
||||||
|
<span class="dot" :class="agent.status"></span>
|
||||||
|
{{ agent.statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aktuelle Aufgabe -->
|
||||||
|
<div v-if="agent.task" class="m-sec">
|
||||||
|
<h4>Aktuelle Aufgabe</h4>
|
||||||
|
<div class="m-task">{{ agent.task }}</div>
|
||||||
|
<div class="m-goal">
|
||||||
|
<span v-html="icons.target || ''"></span>
|
||||||
|
Ziel: {{ agent.goal }}
|
||||||
|
</div>
|
||||||
|
<div class="m-bar" :class="{ work: agent.status === 'work' }">
|
||||||
|
<i :style="{ width: displayProgress + '%' }"></i>
|
||||||
|
</div>
|
||||||
|
<div class="m-pct-row">
|
||||||
|
<div class="m-pct grad-tx">{{ displayProgress }}%</div>
|
||||||
|
<div class="m-next">→ {{ agent.next }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metriken -->
|
||||||
|
<div class="m-sec">
|
||||||
|
<h4>Metriken</h4>
|
||||||
|
<div class="m-metrics">
|
||||||
|
<div class="m-metric">
|
||||||
|
<div class="mk">Elapsed</div>
|
||||||
|
<div class="mv">{{ agent.elapsed }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-metric">
|
||||||
|
<div class="mk">Token</div>
|
||||||
|
<div class="mv">{{ agent.tokens }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-metric">
|
||||||
|
<div class="mk">Kosten</div>
|
||||||
|
<div class="mv grad-tx">${{ agent.cost }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-metric">
|
||||||
|
<div class="mk">Fortschritt</div>
|
||||||
|
<div class="mv">{{ agent.progress }}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Thinking -->
|
||||||
|
<div v-if="agent.think" class="m-sec">
|
||||||
|
<h4>Live Thinking</h4>
|
||||||
|
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modell wählen -->
|
||||||
|
<div class="m-sec">
|
||||||
|
<h4>Modell wählen</h4>
|
||||||
|
<div class="m-models">
|
||||||
|
<button
|
||||||
|
v-for="m in agent.availableModels"
|
||||||
|
:key="m.id"
|
||||||
|
:class="['m-model-btn', { active: m.alias === selectedModel }]"
|
||||||
|
@click="selectModel(m.alias)"
|
||||||
|
>{{ m.alias }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MD Footer -->
|
||||||
|
<div v-if="agent.md" class="m-md">
|
||||||
|
<span class="dot work"></span>
|
||||||
|
Synced: <span class="m-md-path">{{ agent.md }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Overlay ─────────────────────────────────── */
|
||||||
|
.modal-ov {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(5,4,16,.78);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
animation: ov-in .2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ov-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ────────────────────────────────────── */
|
||||||
|
.modal-card {
|
||||||
|
width: 640px;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 20px;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(160deg, rgba(22,18,50,.97), rgba(10,8,28,.97));
|
||||||
|
border: 1px solid rgba(150,140,255,.28);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(124,108,255,.15),
|
||||||
|
0 32px 80px -16px rgba(0,0,0,.8),
|
||||||
|
0 0 60px -10px rgba(124,108,255,.35);
|
||||||
|
animation: card-in .24s cubic-bezier(.2,.8,.3,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-in {
|
||||||
|
from { opacity: 0; transform: scale(.94) translateY(12px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card::-webkit-scrollbar { width: 7px; }
|
||||||
|
.modal-card::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(124,108,255,.22);
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.modal-card::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* ── Close ───────────────────────────────────── */
|
||||||
|
.m-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: rgba(124,108,255,.07);
|
||||||
|
color: var(--tx-2);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: .15s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-close:hover {
|
||||||
|
background: rgba(124,108,255,.18);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Head ────────────────────────────────────── */
|
||||||
|
.m-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 26px 26px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-av {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--grad-soft);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-av.iris {
|
||||||
|
background: var(--grad);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 7px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
|
||||||
|
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
|
||||||
|
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
|
||||||
|
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
|
||||||
|
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
|
||||||
|
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
|
||||||
|
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
|
||||||
|
|
||||||
|
.m-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: rgba(124,108,255,.07);
|
||||||
|
color: var(--tx-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.work { background: var(--st-work); box-shadow: 0 0 0 0 rgba(61,220,151,.5); animation: pulse-work 1.8s infinite; }
|
||||||
|
.dot.think { background: var(--st-think); box-shadow: 0 0 0 0 rgba(52,214,245,.5); animation: pulse-think 1.6s infinite; }
|
||||||
|
.dot.idle { background: var(--st-idle); }
|
||||||
|
.dot.block { background: var(--st-block); }
|
||||||
|
|
||||||
|
/* ── Section ─────────────────────────────────── */
|
||||||
|
.m-sec {
|
||||||
|
padding: 16px 26px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-sec h4 {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task ────────────────────────────────────── */
|
||||||
|
.m-task {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-goal {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tx-2);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-goal :deep(svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--a-mid);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress Bar ────────────────────────────── */
|
||||||
|
.m-bar {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(124,108,255,.12);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--grad);
|
||||||
|
box-shadow: 0 0 14px -2px rgba(124,108,255,.8);
|
||||||
|
transition: width .6s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-bar.work i {
|
||||||
|
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||||
|
box-shadow: var(--glow-work);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-bar i::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
|
||||||
|
animation: shimmer 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(180%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-pct-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-pct {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grad-tx {
|
||||||
|
background: var(--grad);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-next {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metrics ─────────────────────────────────── */
|
||||||
|
.m-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-metric {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mk {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mv {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Live Thinking ───────────────────────────── */
|
||||||
|
.m-think {
|
||||||
|
background: rgba(5,20,36,.7);
|
||||||
|
border: 1px solid rgba(52,214,245,.2);
|
||||||
|
border-radius: 13px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #9fe8fb;
|
||||||
|
min-height: 72px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-think::before {
|
||||||
|
content: '▶ thinking';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 14px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--st-think);
|
||||||
|
letter-spacing: .12em;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret::after {
|
||||||
|
content: '▍';
|
||||||
|
animation: blink 1s steps(1) infinite;
|
||||||
|
color: var(--st-think);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ── Models ──────────────────────────────────── */
|
||||||
|
.m-models {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-model-btn {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
color: var(--tx-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-model-btn:hover {
|
||||||
|
border-color: var(--line-3);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-model-btn.active {
|
||||||
|
background: var(--grad);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MD Footer ───────────────────────────────── */
|
||||||
|
.m-md {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--tx-2);
|
||||||
|
padding: 12px 26px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-md .dot { width: 7px; height: 7px; }
|
||||||
|
.m-md-path { color: var(--tx-3); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* AgentNode — Einzelner Agenten-Knoten im FlowCanvas
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* agent – AgentNodeData
|
||||||
|
* left – x-Position in % (0–100)
|
||||||
|
* top – y-Position in % (0–100)
|
||||||
|
* entering – true wenn Node gerade frisch ins DOM kam (Enter-Animation)
|
||||||
|
*
|
||||||
|
* Emits:
|
||||||
|
* select – Agent ausgewählt (id)
|
||||||
|
*/
|
||||||
|
import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
agent: AgentNodeData
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
entering?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: [id: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'node',
|
||||||
|
agent.id === 'iris' ? 'is-iris' : `is-${agent.status}`,
|
||||||
|
{ entering }
|
||||||
|
]"
|
||||||
|
:style="{ left: left + '%', top: top + '%' }"
|
||||||
|
@click="$emit('select', agent.id)"
|
||||||
|
>
|
||||||
|
<div class="ncard">
|
||||||
|
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
||||||
|
<div class="nc-top">
|
||||||
|
<div :class="['nc-av', { 'iris-av': agent.id === 'iris' }]">
|
||||||
|
<span v-html="agent.avatar === '</>' ? '</>' : agent.avatar"></span>
|
||||||
|
</div>
|
||||||
|
<div class="nc-info">
|
||||||
|
<div class="nc-name">{{ agent.name }}</div>
|
||||||
|
<div class="nc-role">{{ agent.role }}</div>
|
||||||
|
</div>
|
||||||
|
<span :class="['nc-stat', 'dot', agent.status]"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task (2-line clamp) -->
|
||||||
|
<div class="nc-task">{{ agent.task || 'Bereit · ' + agent.next }}</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="nc-bar">
|
||||||
|
<i :style="{ width: (agent.progress || 3) + '%' }"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta-Zeile -->
|
||||||
|
<div class="nc-meta">
|
||||||
|
<span
|
||||||
|
class="st"
|
||||||
|
:style="{ color: `var(--st-${agent.status})` }"
|
||||||
|
>
|
||||||
|
{{ agent.statusLabel }}
|
||||||
|
</span>
|
||||||
|
<span>{{ agent.task ? (agent.progress + '% · ' + agent.elapsed) : agent.model }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 3;
|
||||||
|
width: 188px;
|
||||||
|
transition:
|
||||||
|
left 0.55s cubic-bezier(.4, 0, .2, 1),
|
||||||
|
top 0.55s cubic-bezier(.4, 0, .2, 1),
|
||||||
|
opacity 0.35s,
|
||||||
|
scale 0.35s;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.entering {
|
||||||
|
opacity: 0;
|
||||||
|
scale: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ncard {
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 13px;
|
||||||
|
background: var(--glass-2);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ncard:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status-glow border */
|
||||||
|
.node.is-work .ncard {
|
||||||
|
border-color: rgba(61, 220, 151, 0.45);
|
||||||
|
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.2), 0 0 26px -6px rgba(61, 220, 151, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.is-think .ncard {
|
||||||
|
border-color: rgba(52, 214, 245, 0.45);
|
||||||
|
box-shadow: 0 0 0 1px rgba(52, 214, 245, 0.2), 0 0 26px -6px rgba(52, 214, 245, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.is-block .ncard {
|
||||||
|
border-color: rgba(251, 113, 133, 0.45);
|
||||||
|
box-shadow: 0 0 0 1px rgba(251, 113, 133, 0.2), 0 0 26px -6px rgba(251, 113, 133, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.is-iris .ncard {
|
||||||
|
border-color: rgba(124, 108, 255, 0.55);
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
background: linear-gradient(160deg, rgba(124, 108, 255, 0.2), rgba(28, 24, 64, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.is-idle .ncard {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Content ────────────────────────────── */
|
||||||
|
.nc-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-av {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--grad-soft);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-av.iris-av {
|
||||||
|
background: var(--grad);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-av :deep(svg) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-info {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-role {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-stat {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.work {
|
||||||
|
background: var(--st-work);
|
||||||
|
box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55);
|
||||||
|
animation: pulse-work 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.think {
|
||||||
|
background: var(--st-think);
|
||||||
|
box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55);
|
||||||
|
animation: pulse-think 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.idle {
|
||||||
|
background: var(--st-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.block {
|
||||||
|
background: var(--st-block);
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55);
|
||||||
|
animation: pulse-block 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task ────────────────────────────────────── */
|
||||||
|
.nc-task {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tx-2);
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress Bar ────────────────────────────── */
|
||||||
|
.nc-bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(124, 108, 255, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--grad);
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.is-work .nc-bar i {
|
||||||
|
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Meta ────────────────────────────────────── */
|
||||||
|
.nc-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
margin-top: 5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nc-meta .st {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* AlertBar — Status-Übersicht im V2 Dashboard
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* activeCount – Agents mit status 'work'
|
||||||
|
* thinkCount – Agents mit status 'think'
|
||||||
|
* idleCount – Agents mit status 'idle'
|
||||||
|
* blockerCount – Blocker-Anzahl
|
||||||
|
* todayCost – Kosten heute (z.B. "$6.40")
|
||||||
|
* todayTokens – Token heute (z.B. "282k")
|
||||||
|
*/
|
||||||
|
import { icons } from '../../../composables/icons'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
activeCount: number
|
||||||
|
thinkCount: number
|
||||||
|
idleCount: number
|
||||||
|
blockerCount: number
|
||||||
|
todayCost: string
|
||||||
|
todayTokens: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
blockerClick: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="alertbar glass-panel">
|
||||||
|
<!-- Active (arbeitet) -->
|
||||||
|
<div class="seg">
|
||||||
|
<span class="dot work"></span>
|
||||||
|
<span class="seg-label">{{ activeCount }} arbeiten</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Think (plant) -->
|
||||||
|
<div class="seg">
|
||||||
|
<span class="dot think"></span>
|
||||||
|
<span class="seg-label">{{ thinkCount }} planen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Idle (bereit) -->
|
||||||
|
<div class="seg">
|
||||||
|
<span class="dot idle"></span>
|
||||||
|
<span class="seg-label">{{ idleCount }} bereit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Separator -->
|
||||||
|
<div class="sep"></div>
|
||||||
|
|
||||||
|
<!-- Kosten heute -->
|
||||||
|
<div class="seg tx2">
|
||||||
|
<span class="seg-icon" v-html="icons.coin || ''"></span>
|
||||||
|
heute <span class="cost-value">{{ todayCost }}</span> · {{ todayTokens }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocker Alert (rechts) -->
|
||||||
|
<button
|
||||||
|
v-if="blockerCount > 0"
|
||||||
|
class="blk"
|
||||||
|
@click="$emit('blockerClick')"
|
||||||
|
>
|
||||||
|
<span class="dot block"></span>
|
||||||
|
{{ blockerCount }} Blocker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alertbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 11px 16px;
|
||||||
|
border-radius: var(--r);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tx);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-label {
|
||||||
|
color: var(--tx-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-icon :deep(svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--a-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx2 .seg-icon :deep(svg) {
|
||||||
|
color: var(--tx-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.work {
|
||||||
|
background: var(--st-work);
|
||||||
|
box-shadow: 0 0 0 0 rgba(61,220,151,.55);
|
||||||
|
animation: pulse-work 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.think {
|
||||||
|
background: var(--st-think);
|
||||||
|
box-shadow: 0 0 0 0 rgba(52,214,245,.55);
|
||||||
|
animation: pulse-think 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.idle {
|
||||||
|
background: var(--st-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.block {
|
||||||
|
background: var(--st-block);
|
||||||
|
box-shadow: 0 0 0 0 rgba(251,113,133,.55);
|
||||||
|
animation: pulse-block 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--line-2);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--grad);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blk {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(251,113,133,.12);
|
||||||
|
border: 1px solid rgba(251,113,133,.3);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fda4b0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blk:hover {
|
||||||
|
background: rgba(251,113,133,.22);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* FlowCanvas — SVG-Kanten + Auto-Layout + AgentNode-Karten
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* agents – Liste der AgentNodeData
|
||||||
|
* positions – Record<id, {x,y}> mit aktuellen Positionen
|
||||||
|
*
|
||||||
|
* Emits:
|
||||||
|
* select – Agent ausgewählt (id)
|
||||||
|
* add – Neuen Agent hinzufügen
|
||||||
|
* updatePositions – Positionsänderung
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
|
||||||
|
import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
||||||
|
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
||||||
|
import { icons } from '../../../composables/icons'
|
||||||
|
import AgentNode from './AgentNode.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
agents: AgentNodeData[]
|
||||||
|
positions: Record<string, { x: number; y: number }>
|
||||||
|
enteringIds: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: string]
|
||||||
|
add: []
|
||||||
|
resetLayout: []
|
||||||
|
updatePositions: [positions: Record<string, { x: number; y: number }>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/* ── Refs ───────────────────────────────────────── */
|
||||||
|
const flowRef = ref<HTMLElement | null>(null)
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null)
|
||||||
|
const edgesDefs = ref('')
|
||||||
|
const edgesPaths = ref('')
|
||||||
|
const edgesPulses = ref('')
|
||||||
|
|
||||||
|
/* ── Computed ───────────────────────────────────── */
|
||||||
|
const agentCount = computed(() => props.agents.length)
|
||||||
|
|
||||||
|
const autoPositions = computed(() => autoLayout(props.agents))
|
||||||
|
|
||||||
|
// Layout label
|
||||||
|
const layoutLabel = computed(() => {
|
||||||
|
const n = props.agents.length - 1
|
||||||
|
if (n <= 0) return `${props.agents.length} Agents`
|
||||||
|
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||||
|
const rows = Math.ceil(n / maxPerRow)
|
||||||
|
const hasCustom = Object.keys(props.positions).length > 0
|
||||||
|
if (hasCustom) {
|
||||||
|
return `✦ Eigenes Layout · ${props.agents.length} Agents gesamt`
|
||||||
|
}
|
||||||
|
return `Layout: ${rows} ${rows === 1 ? 'Reihe' : 'Reihen'} × ${maxPerRow} · ${props.agents.length} Agents gesamt`
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ── Edge Rendering ────────────────────────────── */
|
||||||
|
function isActive(status: string) {
|
||||||
|
return status === 'work' || status === 'think'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdges() {
|
||||||
|
const flow = flowRef.value
|
||||||
|
if (!flow) return
|
||||||
|
|
||||||
|
const fr = flow.getBoundingClientRect()
|
||||||
|
const svg = svgRef.value
|
||||||
|
if (!svg) return
|
||||||
|
|
||||||
|
svg.setAttribute('width', String(fr.width))
|
||||||
|
svg.setAttribute('height', String(fr.height))
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${fr.width} ${fr.height}`)
|
||||||
|
|
||||||
|
// Node centers in pixel coordinates
|
||||||
|
function center(id: string): { x: number; y: number } | null {
|
||||||
|
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
|
||||||
|
if (!el) return null
|
||||||
|
const nr = el.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: nr.left - fr.left + nr.width / 2,
|
||||||
|
y: nr.top - fr.top + nr.height / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeList = buildEdges(props.agents)
|
||||||
|
|
||||||
|
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
|
||||||
|
let paths = ''
|
||||||
|
let pulses = ''
|
||||||
|
let idCounter = 0
|
||||||
|
|
||||||
|
edgeList.forEach((e) => {
|
||||||
|
const c1 = center(e.a)
|
||||||
|
const c2 = center(e.b)
|
||||||
|
if (!c1 || !c2) return
|
||||||
|
|
||||||
|
const d = curve(c1, c2)
|
||||||
|
const a1Status = props.agents.find(a => a.id === e.a)?.status || 'idle'
|
||||||
|
const a2Status = props.agents.find(a => a.id === e.b)?.status || 'idle'
|
||||||
|
const live = isActive(a1Status) && isActive(a2Status)
|
||||||
|
const pathId = `ep${idCounter++}`
|
||||||
|
|
||||||
|
if (e.kind === 'flow' && live) {
|
||||||
|
// Active flow: gradient stroke + animate pulse
|
||||||
|
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
|
||||||
|
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||||
|
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||||
|
} else if (e.kind === 'flow') {
|
||||||
|
// Inactive flow
|
||||||
|
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
|
||||||
|
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||||
|
} else {
|
||||||
|
// Orchestration (Iris → Agent)
|
||||||
|
const targetAgent = props.agents.find(a => a.id === e.b)
|
||||||
|
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
|
||||||
|
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
edgesDefs.value = defs
|
||||||
|
edgesPaths.value = paths
|
||||||
|
edgesPulses.value = pulses
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Resize Observer ──────────────────────────── */
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function setupObserver() {
|
||||||
|
if (!flowRef.value) return
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Debounce via requestAnimationFrame
|
||||||
|
if (!debounceRaf) debounceRaf = requestAnimationFrame(() => {
|
||||||
|
debounceRaf = null
|
||||||
|
renderEdges()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
resizeObserver.observe(flowRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceRaf: number | null = null
|
||||||
|
|
||||||
|
function teardownObserver() {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
}
|
||||||
|
if (debounceRaf) {
|
||||||
|
cancelAnimationFrame(debounceRaf)
|
||||||
|
debounceRaf = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupObserver()
|
||||||
|
// Initial render after DOM settles
|
||||||
|
requestAnimationFrame(() => renderEdges())
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
teardownObserver()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-render edges when agents or positions change
|
||||||
|
watch(
|
||||||
|
() => [props.agents.length, props.positions],
|
||||||
|
() => {
|
||||||
|
// Wait for DOM update (AgentNode transitions)
|
||||||
|
setTimeout(() => renderEdges(), 200)
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── Drag & Drop ──────────────────────────────── */
|
||||||
|
const DRAG_THRESHOLD = 5
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
id: string
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
ox: number
|
||||||
|
oy: number
|
||||||
|
moved: boolean
|
||||||
|
raf: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let drag: DragState | null = null
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
const nr = node.getBoundingClientRect()
|
||||||
|
|
||||||
|
drag = {
|
||||||
|
id: node.dataset.id || '',
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
ox: e.clientX - (nr.left + nr.width / 2),
|
||||||
|
oy: e.clientY - (nr.top + nr.height / 2),
|
||||||
|
moved: false,
|
||||||
|
raf: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
node.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!drag) return
|
||||||
|
|
||||||
|
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
|
||||||
|
if (!drag.moved && dist < DRAG_THRESHOLD) return
|
||||||
|
|
||||||
|
if (!drag.moved) {
|
||||||
|
drag.moved = true
|
||||||
|
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) node.classList.add('dragging')
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = flowRef.value
|
||||||
|
if (!flow) return
|
||||||
|
|
||||||
|
const fr = flow.getBoundingClientRect()
|
||||||
|
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
|
||||||
|
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
|
||||||
|
|
||||||
|
// Direct DOM manipulation for responsiveness
|
||||||
|
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) {
|
||||||
|
node.style.left = x + '%'
|
||||||
|
node.style.top = y + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update positions state
|
||||||
|
const newPos = { ...props.positions }
|
||||||
|
newPos[drag.id] = { x, y }
|
||||||
|
emit('updatePositions', newPos)
|
||||||
|
|
||||||
|
// Debounced edge re-render
|
||||||
|
if (!drag.raf) {
|
||||||
|
drag.raf = requestAnimationFrame(() => {
|
||||||
|
renderEdges()
|
||||||
|
if (drag) drag.raf = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (!drag) return
|
||||||
|
|
||||||
|
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) node.classList.remove('dragging')
|
||||||
|
|
||||||
|
if (!drag.moved) {
|
||||||
|
// Was a click — emit select
|
||||||
|
emit('select', drag.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
drag = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Keyboard handler for Enter key on buttons ── */
|
||||||
|
function handleReset() {
|
||||||
|
emit('resetLayout')
|
||||||
|
nextTick(() => renderEdges())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="flowRef"
|
||||||
|
class="flow"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerUp"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flow-h">
|
||||||
|
<span class="header-icon" v-html="icons.flow || ''"></span>
|
||||||
|
<h3>Live-Orchestrierung</h3>
|
||||||
|
<span class="flow-count">{{ agentCount }} Agents</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="reset-btn"
|
||||||
|
title="Auto-Layout wiederherstellen"
|
||||||
|
@click="handleReset"
|
||||||
|
>
|
||||||
|
<span class="btn-icon" v-html="icons.flow || ''"></span>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="add-btn" @click="emit('add')">
|
||||||
|
<span class="btn-icon" v-html="icons.plus || ''"></span>
|
||||||
|
Agent hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG Layer -->
|
||||||
|
<svg
|
||||||
|
ref="svgRef"
|
||||||
|
class="edges"
|
||||||
|
v-html="edgesDefs + edgesPaths + edgesPulses"
|
||||||
|
></svg>
|
||||||
|
|
||||||
|
<!-- Agent Nodes -->
|
||||||
|
<AgentNode
|
||||||
|
v-for="agent in agents"
|
||||||
|
:key="agent.id"
|
||||||
|
:agent="agent"
|
||||||
|
:left="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).x"
|
||||||
|
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
|
||||||
|
:entering="enteringIds.includes(agent.id)"
|
||||||
|
:data-id="agent.id"
|
||||||
|
@select="(id: string) => emit('select', id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Layout Label -->
|
||||||
|
<div class="layout-label">{{ layoutLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flow {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--r);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 50% 0%, rgba(124, 108, 255, 0.10), transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-h {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-h h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon :deep(svg) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--a-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-count {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(124, 108, 255, 0.14);
|
||||||
|
color: var(--tx-2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(124, 108, 255, 0.1);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
color: var(--tx-2);
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn:hover {
|
||||||
|
background: rgba(124, 108, 255, 0.18);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn .btn-icon :deep(svg) {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--grad);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn .btn-icon :deep(svg) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SVG Layer ────────────────────────────────── */
|
||||||
|
.edges {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout Label ─────────────────────────────── */
|
||||||
|
.layout-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 14px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 5;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
background: rgba(10, 8, 24, 0.7);
|
||||||
|
padding: 5px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag state ───────────────────────────────── */
|
||||||
|
:deep(.node.dragging) {
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none !important;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.node.dragging .ncard) {
|
||||||
|
box-shadow: 0 0 0 2px var(--a-mid), 0 0 36px -2px rgba(124, 108, 255, 0.9) !important;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dash animation */
|
||||||
|
@keyframes dashmove {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node cursor */
|
||||||
|
:deep(.node) {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.node.dragging) {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick, watch } from 'vue'
|
||||||
|
import { icons } from '../../../composables/icons'
|
||||||
|
import type { ChatMessage } from './types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
messages: ChatMessage[]
|
||||||
|
isThinking: boolean
|
||||||
|
error?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
send: [text: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputText = ref('')
|
||||||
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
const text = inputText.value.trim()
|
||||||
|
if (!text) return
|
||||||
|
emit('send', text)
|
||||||
|
inputText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.messages.length,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="iris-panel">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="iris-head">
|
||||||
|
<div class="iris-av" v-html="icons.bot || ''"></div>
|
||||||
|
<div>
|
||||||
|
<div class="iris-name">Iris</div>
|
||||||
|
<div class="iris-sub">Chief of Staff · <span class="online">online</span></div>
|
||||||
|
</div>
|
||||||
|
<button class="expand-btn" type="button" v-html="icons.expand || ''"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat Scroll -->
|
||||||
|
<div ref="scrollEl" class="chat-scroll">
|
||||||
|
<div v-if="error" class="chat-msg-info error">⚠ {{ error }}</div>
|
||||||
|
<div v-else-if="!messages.length && !isThinking" class="chat-msg-info">Noch keine Nachrichten.</div>
|
||||||
|
|
||||||
|
<div v-for="(msg, i) in messages" :key="i" class="chat-row">
|
||||||
|
<template v-if="msg.sender === 'iris'">
|
||||||
|
<div class="bubble iris">{{ msg.text }}</div>
|
||||||
|
<div v-if="msg.tool" class="tool">
|
||||||
|
<span v-html="icons.doc || ''"></span>{{ msg.tool }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="bubble me">{{ msg.text }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isThinking" class="chat-row">
|
||||||
|
<div class="bubble iris"><span class="caret"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="chat-in">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
type="text"
|
||||||
|
placeholder="Nachricht an Iris…"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button class="send" type="button" @click="handleSend" v-html="icons.send || ''"></button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.iris-panel {
|
||||||
|
width: var(--rail-w, 360px);
|
||||||
|
flex: 0 0 var(--rail-w, 360px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(20,17,48,.6), rgba(12,10,30,.6));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────── */
|
||||||
|
.iris-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iris-av {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--grad);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iris-av :deep(svg) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iris-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--tx);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iris-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online {
|
||||||
|
color: var(--st-work);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tx-2);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn:hover {
|
||||||
|
background: rgba(124,108,255,.10);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn :deep(svg) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Messages ────────────────────────────────── */
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.chat-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(124,108,255,.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.chat-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
.chat-msg-info {
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
|
||||||
|
|
||||||
|
.chat-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 84%;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.iris {
|
||||||
|
background: rgba(124,108,255,.12);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.me {
|
||||||
|
background: var(--grad);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
margin-left: auto;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--st-think);
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool :deep(svg) {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret::after {
|
||||||
|
content: '▍';
|
||||||
|
animation: blink 1s steps(1) infinite;
|
||||||
|
color: var(--st-think);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ── Input ───────────────────────────────────── */
|
||||||
|
.chat-in {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-in input {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
color: var(--tx);
|
||||||
|
padding: 0 14px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-in input::placeholder { color: var(--tx-3); }
|
||||||
|
.chat-in input:focus { border-color: var(--line-3); }
|
||||||
|
|
||||||
|
.send {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: none;
|
||||||
|
background: var(--grad);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: filter .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.send :deep(svg) {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskItem } from './types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tasks: TaskItem[]
|
||||||
|
loading?: boolean
|
||||||
|
error?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function prioLabel(p: TaskItem['priority']): string {
|
||||||
|
return p === 'high' ? 'P0' : p === 'medium' ? 'P1' : 'P2'
|
||||||
|
}
|
||||||
|
|
||||||
|
function prioColor(p: TaskItem['priority']): string {
|
||||||
|
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotClass(s: TaskItem['status']): string {
|
||||||
|
return s === 'active' ? 'work' : s === 'blocked' ? 'block' : 'queue'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: TaskItem['status']): string {
|
||||||
|
return s === 'active' ? 'Läuft' : s === 'blocked' ? 'Blocker' : 'Queue'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tstrip">
|
||||||
|
<template v-if="loading">
|
||||||
|
<div v-for="n in 4" :key="n" class="tcard skeleton"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="tstrip-msg">⚠ {{ error }}</div>
|
||||||
|
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="task in tasks.slice(0, 4)"
|
||||||
|
:key="task.id"
|
||||||
|
class="tcard"
|
||||||
|
:class="{ block: task.status === 'blocked' }"
|
||||||
|
>
|
||||||
|
<div class="tcard-row">
|
||||||
|
<span class="pr" :style="{ background: 'rgba(124,108,255,.14)', color: prioColor(task.priority) }">
|
||||||
|
{{ prioLabel(task.priority) }}
|
||||||
|
</span>
|
||||||
|
<span class="dot" :class="dotClass(task.status)"></span>
|
||||||
|
<span class="stl">{{ statusLabel(task.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tt">{{ task.title }}</div>
|
||||||
|
<div class="ow">{{ task.agent }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tstrip {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcard {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcard.block {
|
||||||
|
border-color: rgba(251,113,133,.35);
|
||||||
|
background: rgba(251,113,133,.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcard-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
|
||||||
|
.dot.queue { background: var(--st-queue); }
|
||||||
|
.dot.block { background: var(--st-block); animation: pulse-block 1.4s infinite; }
|
||||||
|
.dot.idle { background: var(--st-idle); }
|
||||||
|
|
||||||
|
.stl {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ow {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
height: 78px;
|
||||||
|
background: var(--glass);
|
||||||
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; }
|
||||||
|
50% { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tstrip-msg {
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
padding: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for V2 Dashboard components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
sender: 'iris' | 'user'
|
||||||
|
text: string
|
||||||
|
ts: string
|
||||||
|
tool?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
agent: string
|
||||||
|
priority: 'high' | 'medium' | 'low'
|
||||||
|
status: 'active' | 'pending' | 'blocked'
|
||||||
|
progress: number // 0–100
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agent Detail Modal Types ─────────────────── */
|
||||||
|
|
||||||
|
export interface ThinkingItem {
|
||||||
|
type: 'thought' | 'action' | 'result'
|
||||||
|
text: string
|
||||||
|
ts: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard view-model for an agent detail modal */
|
||||||
|
export interface AgentDetailData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
roleBadge: string
|
||||||
|
model: string
|
||||||
|
status: 'work' | 'think' | 'idle'
|
||||||
|
statusLabel: string
|
||||||
|
task: string | null
|
||||||
|
goal: string | null
|
||||||
|
progress: number
|
||||||
|
elapsed: string
|
||||||
|
next: string
|
||||||
|
tokens: string
|
||||||
|
cost: string
|
||||||
|
think: string | null
|
||||||
|
md?: string
|
||||||
|
tokensToday: number
|
||||||
|
costToday: number
|
||||||
|
workload: number
|
||||||
|
uptime: string
|
||||||
|
lastActive: string
|
||||||
|
activeTaskCount: number
|
||||||
|
thinking: ThinkingItem[]
|
||||||
|
availableModels: { id: string; alias: string }[]
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { initials } from '../../utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activeView: string
|
activeView: string
|
||||||
@@ -23,7 +24,9 @@ const emit = defineEmits<{
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const ownerInitials = computed(() => auth.user?.displayName.split(' ').map(part => part[0]).join('').slice(0, 2).toUpperCase() ?? 'OW')
|
const ownerInitials = computed(() =>
|
||||||
|
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||||
|
)
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard },
|
{ label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NavItemDef } from '../../composables/icons'
|
||||||
|
import NavItem from './NavItem.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
items: NavItemDef[]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="nav-group">
|
||||||
|
<div class="nav-group-label">{{ label }}</div>
|
||||||
|
<NavItem
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.label"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.label"
|
||||||
|
:route="item.route"
|
||||||
|
:count="item.count"
|
||||||
|
:active="item.active"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-group-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 16px 10px 7px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { icons } from '../../composables/icons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
route?: string
|
||||||
|
count?: string
|
||||||
|
active?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isActive = computed(() => {
|
||||||
|
if (props.active) return true
|
||||||
|
if (props.route && route.path === props.route) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigate() {
|
||||||
|
if (props.route) {
|
||||||
|
router.push(props.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:class="['nav-item', { active: isActive }]"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
<span class="nav-icon" v-html="icons[icon] || ''"></span>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
|
||||||
|
<!-- Count badge -->
|
||||||
|
<span v-if="count !== undefined" class="count">{{ count }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tx-2);
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background .16s, color .16s;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(124,108,255,.08);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--grad);
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon :deep(svg) {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(124,108,255,.16);
|
||||||
|
color: var(--tx);
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useAgentStore } from '../../stores/agents'
|
||||||
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
|
import { navigation, icons } from '../../composables/icons'
|
||||||
|
import type { NavGroupDef } from '../../composables/icons'
|
||||||
|
import NavGroup from './NavGroup.vue'
|
||||||
|
import { initials } from '../../utils/format'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const agentStore = useAgentStore()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
|
const ownerInitials = computed(() =>
|
||||||
|
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||||
|
)
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
auth.logout()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamische Nav-Item-Counts aus den Stores.
|
||||||
|
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
|
||||||
|
*/
|
||||||
|
const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||||
|
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
|
||||||
|
return navigation.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.map(item => {
|
||||||
|
let dynamicCount: string | undefined
|
||||||
|
|
||||||
|
switch (item.label) {
|
||||||
|
case 'Agenten':
|
||||||
|
case 'Hosts · OpenClaw':
|
||||||
|
dynamicCount = String(agentStore.agentList.length)
|
||||||
|
break
|
||||||
|
case 'Task Board':
|
||||||
|
dynamicCount = String(taskStore.taskList.length)
|
||||||
|
break
|
||||||
|
case 'Kosten & Tokens':
|
||||||
|
dynamicCount = agentStore.todayCost
|
||||||
|
break
|
||||||
|
case 'Docs & .md':
|
||||||
|
dynamicCount = '0'
|
||||||
|
break
|
||||||
|
case 'Incidents':
|
||||||
|
dynamicCount = '0'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
count: dynamicCount ?? item.count,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="side-top">
|
||||||
|
<div class="brand-mark" v-html="icons.command || ''"></div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-name">NEXUS</div>
|
||||||
|
<div class="brand-sub">Mission Control</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav">
|
||||||
|
<NavGroup
|
||||||
|
v-for="(group, idx) in dynamicNavigation"
|
||||||
|
:key="idx"
|
||||||
|
:label="group.group"
|
||||||
|
:items="group.items"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="side-foot">
|
||||||
|
<div class="avatar">{{ ownerInitials }}</div>
|
||||||
|
<div class="owner-info">
|
||||||
|
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
|
||||||
|
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: 248px;
|
||||||
|
flex: 0 0 248px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 11px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--grad);
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark :deep(svg) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 17px;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
letter-spacing: .05em;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-foot {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-foot:hover {
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--grad-soft);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--tx);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-info {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tx);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner-role {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
margin-top: 1px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { icons } from '../../composables/icons'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
connected?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="topbar">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search">
|
||||||
|
<span class="search-icon" v-html="icons.search || ''"></span>
|
||||||
|
<span class="search-placeholder">Operationen, Agents oder Tasks suchen…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spacer -->
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<!-- Status Pill -->
|
||||||
|
<span :class="['pill', connected ? 'live' : 'preview']">
|
||||||
|
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
|
||||||
|
{{ connected ? 'OpenClaw verbunden' : 'Preview' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Ask Iris Button -->
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<span class="btn-icon" v-html="icons.spark || ''"></span>
|
||||||
|
Ask Iris
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topbar {
|
||||||
|
height: 62px;
|
||||||
|
flex: 0 0 62px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 22px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(8,6,20,.5);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon :deep(svg) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-placeholder {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: rgba(124,108,255,.07);
|
||||||
|
color: var(--tx-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.on {
|
||||||
|
background: var(--st-work);
|
||||||
|
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
|
||||||
|
animation: pulse-work 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.off {
|
||||||
|
background: var(--st-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: filter .16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--grad);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon :deep(svg) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Inline SVG icons for Nexus V2
|
||||||
|
* All stroke-based, currentColor, viewBox 0 0 24 24
|
||||||
|
*/
|
||||||
|
export const icons: Record<string, string> = {
|
||||||
|
grid: `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>`,
|
||||||
|
cpu: `<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>`,
|
||||||
|
list: `<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>`,
|
||||||
|
flow: `<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.5 7.5 11 16M16.5 7.5 13 16"/>`,
|
||||||
|
brain: `<path d="M9 3a3 3 0 0 0-3 3 3 3 0 0 0-1 5.8A3 3 0 0 0 8 17a3 3 0 0 0 4 1 3 3 0 0 0 4-1 3 3 0 0 0 3-5.2A3 3 0 0 0 18 6a3 3 0 0 0-3-3 3 3 0 0 0-3 1.5A3 3 0 0 0 9 3Z"/>`,
|
||||||
|
doc: `<path d="M14 3v5h5M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>`,
|
||||||
|
search: `<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>`,
|
||||||
|
server: `<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>`,
|
||||||
|
model: `<path d="M12 2 3 7l9 5 9-5-9-5ZM3 12l9 5 9-5M3 17l9 5 9-5"/>`,
|
||||||
|
activity: `<path d="M3 12h4l3 8 4-16 3 8h4"/>`,
|
||||||
|
coin: `<circle cx="12" cy="12" r="9"/><path d="M12 7v10M9.5 9.5h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4"/>`,
|
||||||
|
shield: `<path d="M12 3 5 6v5c0 4 3 7 7 9 4-2 7-5 7-9V6z"/>`,
|
||||||
|
alert: `<path d="M12 3 2 20h20zM12 9v5M12 17h.01"/>`,
|
||||||
|
send: `<path d="M22 2 11 13M22 2 15 22l-4-9-9-4z"/>`,
|
||||||
|
spark: `<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>`,
|
||||||
|
expand: `<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>`,
|
||||||
|
bot: `<rect x="4" y="7" width="16" height="12" rx="3"/><path d="M12 7V4M9 13h.01M15 13h.01M8 19v2M16 19v2"/>`,
|
||||||
|
clock: `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`,
|
||||||
|
target: `<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>`,
|
||||||
|
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
|
||||||
|
plus: `<path d="M12 5v14M5 12h14"/>`,
|
||||||
|
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
|
||||||
|
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
|
||||||
|
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
|
||||||
|
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function svg(name: string, cls = ''): string {
|
||||||
|
const inner = icons[name]
|
||||||
|
if (!inner) return ''
|
||||||
|
return `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItemDef {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
route?: string
|
||||||
|
count?: string
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroupDef {
|
||||||
|
group: string
|
||||||
|
items: NavItemDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation structure matching NEXUS.nav from agents.js
|
||||||
|
*/
|
||||||
|
export const navigation: NavGroupDef[] = [
|
||||||
|
{
|
||||||
|
group: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
|
||||||
|
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
||||||
|
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
||||||
|
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Knowledge',
|
||||||
|
items: [
|
||||||
|
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
||||||
|
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
|
||||||
|
{ icon: 'search', label: 'Research', route: '/research' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Infrastructure',
|
||||||
|
items: [
|
||||||
|
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
|
||||||
|
{ icon: 'model', label: 'Modelle', route: '/models' },
|
||||||
|
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Governance',
|
||||||
|
items: [
|
||||||
|
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
|
||||||
|
{ icon: 'shield', label: 'Security', route: '/security' },
|
||||||
|
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
// ── Shared State (singleton: same state regardless of how many times useDashboardData() is called) ──
|
|
||||||
const sessionStart = Date.now()
|
|
||||||
|
|
||||||
// Intervals registry for cleanup
|
|
||||||
const intervals: ReturnType<typeof setInterval>[] = []
|
|
||||||
let cleanupRegistered = false
|
|
||||||
|
|
||||||
// ── Interfaces (exported for components) ──
|
|
||||||
|
|
||||||
export interface AgentNodeData {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
description: string
|
|
||||||
tags: string[]
|
|
||||||
color: string
|
|
||||||
icon: string
|
|
||||||
model?: string
|
|
||||||
hero?: boolean
|
|
||||||
currentTask: string
|
|
||||||
goal: string
|
|
||||||
progress: number
|
|
||||||
workload: number // 0-100
|
|
||||||
active: boolean
|
|
||||||
runtimeSeconds: number
|
|
||||||
workingFeed: Array<{ time: string; text: string }>
|
|
||||||
thinkingStream?: Array<{ time: string; text: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenTask {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
detail: string
|
|
||||||
source: 'bao' | 'iris'
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeedEntry {
|
|
||||||
time: string
|
|
||||||
agent: string
|
|
||||||
action: string
|
|
||||||
timestamp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: string
|
|
||||||
sender: 'user' | 'iris'
|
|
||||||
text: string
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueueItem {
|
|
||||||
id: string
|
|
||||||
text: string
|
|
||||||
priority: 'high' | 'medium' | 'low'
|
|
||||||
waitTime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API Response Interfaces ──
|
|
||||||
|
|
||||||
interface DashboardStatusResponse {
|
|
||||||
gatewayOk: boolean
|
|
||||||
irisStatus: string
|
|
||||||
activeAgents: number
|
|
||||||
pendingTasks: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardAgentInfo {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
model: string
|
|
||||||
isActive: boolean
|
|
||||||
currentTask: string
|
|
||||||
description?: string
|
|
||||||
tags?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardOperationEntry {
|
|
||||||
agent: string
|
|
||||||
action: string
|
|
||||||
timestamp: string
|
|
||||||
time: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardChatMessage {
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
content: string
|
|
||||||
timestamp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardSendResponse {
|
|
||||||
ok: boolean
|
|
||||||
reply?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardQueueItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent Catalog (static enrichment) ──
|
|
||||||
|
|
||||||
const AGENT_CATALOG: Record<string, Partial<AgentNodeData>> = {
|
|
||||||
iris: {
|
|
||||||
description: 'Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.',
|
|
||||||
tags: ['Orchestration', 'Delegation', 'Approval', 'Risk Management'],
|
|
||||||
color: '#8b7cf6',
|
|
||||||
icon: 'bot',
|
|
||||||
hero: true,
|
|
||||||
goal: 'Mission Control — maximale Autonomie bei kontrolliertem Risiko',
|
|
||||||
progress: 90,
|
|
||||||
workload: 60,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
developer: {
|
|
||||||
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
|
|
||||||
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
|
|
||||||
color: '#3b82f6',
|
|
||||||
icon: 'code',
|
|
||||||
goal: 'Nexus Dashboard & Dungeon System',
|
|
||||||
progress: 70,
|
|
||||||
workload: 65,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
architekt: {
|
|
||||||
description: 'Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.',
|
|
||||||
tags: ['Docker', 'Nginx', 'CI/CD', 'Firewall', 'VPS'],
|
|
||||||
color: '#eab308',
|
|
||||||
icon: 'server',
|
|
||||||
goal: 'Stabile Zero-Downtime-Deployments',
|
|
||||||
progress: 60,
|
|
||||||
workload: 45,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
researcher: {
|
|
||||||
description: 'Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.',
|
|
||||||
tags: ['Research', 'Quellenprüfung', 'Analyse', 'Docs'],
|
|
||||||
color: '#22c55e',
|
|
||||||
icon: 'search',
|
|
||||||
goal: 'Verifizierte, strukturierte Recherche-Ergebnisse',
|
|
||||||
progress: 40,
|
|
||||||
workload: 30,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
reviewer: {
|
|
||||||
description: 'Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.',
|
|
||||||
tags: ['Code Review', 'Testing', 'Security', 'Quality'],
|
|
||||||
color: '#a855f7',
|
|
||||||
icon: 'shield',
|
|
||||||
goal: 'Zero critical findings before merge',
|
|
||||||
progress: 85,
|
|
||||||
workload: 55,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
executor: {
|
|
||||||
description: 'Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.',
|
|
||||||
tags: ['Docker', 'Shell', 'Host', 'Deployment'],
|
|
||||||
color: '#f59e0b',
|
|
||||||
icon: 'server',
|
|
||||||
goal: 'Sichere Host-Execution im Allowlist-Rahmen',
|
|
||||||
progress: 95,
|
|
||||||
workload: 20,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
// Alias: API sends "programmer" but AGENT_CATALOG uses "developer" as canonical key
|
|
||||||
programmer: {
|
|
||||||
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
|
|
||||||
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
|
|
||||||
color: '#3b82f6',
|
|
||||||
icon: 'code',
|
|
||||||
goal: 'Nexus Dashboard & Dungeon System',
|
|
||||||
progress: 70,
|
|
||||||
workload: 65,
|
|
||||||
workingFeed: [],
|
|
||||||
thinkingStream: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
|
|
||||||
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']
|
|
||||||
return {
|
|
||||||
id: api.id,
|
|
||||||
name: api.name,
|
|
||||||
role: api.role,
|
|
||||||
model: api.model,
|
|
||||||
currentTask: api.currentTask ?? 'Idle',
|
|
||||||
active: api.isActive,
|
|
||||||
description: api.description ?? catalog.description ?? '',
|
|
||||||
tags: api.tags ?? catalog.tags ?? [],
|
|
||||||
color: catalog.color ?? '#6b7385',
|
|
||||||
icon: catalog.icon ?? 'bot',
|
|
||||||
hero: catalog.hero ?? false,
|
|
||||||
goal: catalog.goal ?? 'No goal set',
|
|
||||||
progress: catalog.progress ?? 0,
|
|
||||||
workload: catalog.workload ?? 0,
|
|
||||||
runtimeSeconds: 0,
|
|
||||||
workingFeed: catalog.workingFeed ?? [],
|
|
||||||
thinkingStream: catalog.thinkingStream ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helper: API Fetch with auth ──
|
|
||||||
|
|
||||||
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
||||||
const base = '' // same-origin proxy
|
|
||||||
return fetch(`${base}${path}`, {
|
|
||||||
...init,
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(init.headers as Record<string, string> ?? {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State ──
|
|
||||||
|
|
||||||
// Status
|
|
||||||
const gatewayOk = ref(true)
|
|
||||||
const irisStatus = ref('Active')
|
|
||||||
const activeAgents = ref(0)
|
|
||||||
const pendingTasks = ref(0)
|
|
||||||
|
|
||||||
// Agents
|
|
||||||
const agents = ref<AgentNodeData[]>([])
|
|
||||||
|
|
||||||
// Chat
|
|
||||||
const chatMessages = ref<ChatMessage[]>([])
|
|
||||||
const irisBusy = ref(false)
|
|
||||||
const irisFocus = ref('')
|
|
||||||
const busySince = ref(0)
|
|
||||||
|
|
||||||
// Operations Feed
|
|
||||||
const feedEntries = ref<FeedEntry[]>([])
|
|
||||||
|
|
||||||
// Open Tasks (mock only – no API endpoint)
|
|
||||||
const openTasks = ref<OpenTask[]>([
|
|
||||||
{ id: 't1', title: 'Agent Thinking Panel visualisieren', detail: 'Live-Animation der Denkprozesse im AgentModal', source: 'iris', createdAt: '22:30' },
|
|
||||||
{ id: 't2', title: 'CI/CD Pipeline Monitoring Dashboard', detail: 'Echtzeit-Status der Gitea Actions im Dashboard', source: 'iris', createdAt: '21:15' },
|
|
||||||
{ id: 't3', title: 'Dungeon System Dokumentation', detail: 'API-Doku für Room-Generation-Endpunkte schreiben', source: 'bao', createdAt: '20:00' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// Queue
|
|
||||||
const queue = ref<QueueItem[]>([])
|
|
||||||
|
|
||||||
// Runtime
|
|
||||||
const runtimeSeconds = ref(0)
|
|
||||||
let runtimeInterval: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
// ── Fetch Functions ──
|
|
||||||
|
|
||||||
async function fetchStatus(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/dashboard/status')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data: DashboardStatusResponse = await res.json()
|
|
||||||
gatewayOk.value = data.gatewayOk
|
|
||||||
irisStatus.value = data.irisStatus
|
|
||||||
activeAgents.value = data.activeAgents
|
|
||||||
pendingTasks.value = data.pendingTasks
|
|
||||||
} catch {
|
|
||||||
// API unreachable – keep current values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAgents(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/dashboard/agents')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data: DashboardAgentInfo[] = await res.json()
|
|
||||||
agents.value = data.map(enrichAgent)
|
|
||||||
} catch {
|
|
||||||
// API unreachable – keep current values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOperations(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/dashboard/operations?limit=20')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data: DashboardOperationEntry[] = await res.json()
|
|
||||||
feedEntries.value = data.map((entry) => ({
|
|
||||||
time: entry.time,
|
|
||||||
agent: entry.agent,
|
|
||||||
action: entry.action,
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
// API unreachable – keep current values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChatMessages(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data: DashboardChatMessage[] = await res.json()
|
|
||||||
// Merge instead of replace — only add messages not already present
|
|
||||||
const existingTexts = new Set(chatMessages.value.map(m => m.text))
|
|
||||||
const existingTimestamps = new Set(chatMessages.value.map(m => m.timestamp))
|
|
||||||
for (const msg of data) {
|
|
||||||
const msgTime = new Date(msg.timestamp).getTime()
|
|
||||||
if (existingTexts.has(msg.content) && existingTimestamps.has(msgTime)) continue
|
|
||||||
chatMessages.value.push({
|
|
||||||
id: `msg-${msgTime}-${msg.role}`,
|
|
||||||
sender: msg.role === 'assistant' ? 'iris' : 'user',
|
|
||||||
text: msg.content,
|
|
||||||
timestamp: msgTime,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// API unreachable – keep current values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchQueue(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/dashboard/queue')
|
|
||||||
if (!res.ok) return
|
|
||||||
const data: DashboardQueueItem[] = await res.json()
|
|
||||||
queue.value = data.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
text: item.name,
|
|
||||||
priority: (item.status === 'high' || item.status === 'medium' || item.status === 'low')
|
|
||||||
? item.status as 'high' | 'medium' | 'low'
|
|
||||||
: 'medium',
|
|
||||||
waitTime: '--',
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
// API unreachable – keep current values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chat Send ──
|
|
||||||
|
|
||||||
async function sendChatMessage(text: string): Promise<void> {
|
|
||||||
if (!text.trim()) return
|
|
||||||
|
|
||||||
// Optimistic add
|
|
||||||
chatMessages.value.push({
|
|
||||||
id: `user-${Date.now()}`,
|
|
||||||
sender: 'user',
|
|
||||||
text: text.trim(),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
irisBusy.value = true
|
|
||||||
busySince.value = Date.now()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/dashboard/chat/send', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ message: text.trim() }),
|
|
||||||
})
|
|
||||||
const data: DashboardSendResponse = await res.json()
|
|
||||||
|
|
||||||
if (data.ok && data.reply) {
|
|
||||||
chatMessages.value.push({
|
|
||||||
id: `iris-${Date.now()}`,
|
|
||||||
sender: 'iris',
|
|
||||||
text: data.reply,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
} else if (data.error) {
|
|
||||||
chatMessages.value.push({
|
|
||||||
id: `error-${Date.now()}`,
|
|
||||||
sender: 'iris',
|
|
||||||
text: `⚠️ ${data.error}`,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
chatMessages.value.push({
|
|
||||||
id: `error-${Date.now()}`,
|
|
||||||
sender: 'iris',
|
|
||||||
text: '⚠️ Connection error. Please try again.',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
irisBusy.value = false
|
|
||||||
busySince.value = 0
|
|
||||||
irisFocus.value = text.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Queue Operations ──
|
|
||||||
|
|
||||||
function removeQueueItem(id: string): void {
|
|
||||||
const idx = queue.value.findIndex(q => q.id === id)
|
|
||||||
if (idx !== -1) queue.value.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveQueueItem(fromIdx: number, toIdx: number): void {
|
|
||||||
if (toIdx < 0 || toIdx >= queue.value.length) return
|
|
||||||
const [item] = queue.value.splice(fromIdx, 1)
|
|
||||||
queue.value.splice(toIdx, 0, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeQueuePriority(id: string, priority: QueueItem['priority']): void {
|
|
||||||
const item = queue.value.find(q => q.id === id)
|
|
||||||
if (item) item.priority = priority
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Runtime ──
|
|
||||||
|
|
||||||
function startRuntime(): void {
|
|
||||||
const startTs = sessionStart
|
|
||||||
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
|
|
||||||
runtimeInterval = setInterval(() => {
|
|
||||||
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRuntime(): void {
|
|
||||||
if (runtimeInterval) {
|
|
||||||
clearInterval(runtimeInterval)
|
|
||||||
runtimeInterval = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatRuntime = (seconds: number): string => {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const irisRuntime = computed(() => formatRuntime(runtimeSeconds.value))
|
|
||||||
|
|
||||||
const getAgentRuntime = (_id: string): string => {
|
|
||||||
// Could be extended to track per-agent runtimes from API
|
|
||||||
return formatRuntime(runtimeSeconds.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Polling starten (nur einmal) ──
|
|
||||||
|
|
||||||
function startPolling(): void {
|
|
||||||
if (cleanupRegistered) return
|
|
||||||
cleanupRegistered = true
|
|
||||||
|
|
||||||
// Initial fetches
|
|
||||||
fetchStatus()
|
|
||||||
fetchAgents()
|
|
||||||
fetchOperations()
|
|
||||||
fetchChatMessages()
|
|
||||||
fetchQueue()
|
|
||||||
|
|
||||||
// Polling intervals
|
|
||||||
intervals.push(setInterval(fetchStatus, 5000))
|
|
||||||
intervals.push(setInterval(fetchAgents, 10000))
|
|
||||||
intervals.push(setInterval(fetchOperations, 10000))
|
|
||||||
intervals.push(setInterval(fetchChatMessages, 3000))
|
|
||||||
intervals.push(setInterval(fetchQueue, 10000))
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling(): void {
|
|
||||||
for (const interval of intervals) {
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
intervals.length = 0
|
|
||||||
cleanupRegistered = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Composable Export ──
|
|
||||||
|
|
||||||
export function useDashboardData() {
|
|
||||||
// Start polling on first call
|
|
||||||
startPolling()
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
agents,
|
|
||||||
openTasks,
|
|
||||||
feedEntries,
|
|
||||||
chatMessages,
|
|
||||||
irisBusy,
|
|
||||||
irisFocus,
|
|
||||||
busySince,
|
|
||||||
irisRuntime,
|
|
||||||
queue,
|
|
||||||
gatewayOk,
|
|
||||||
irisStatus,
|
|
||||||
pendingTasks,
|
|
||||||
activeAgents,
|
|
||||||
|
|
||||||
// Runtime
|
|
||||||
runtimeSeconds,
|
|
||||||
getAgentRuntime,
|
|
||||||
startRuntime,
|
|
||||||
stopRuntime,
|
|
||||||
formatRuntime,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
sendChatMessage,
|
|
||||||
removeQueueItem,
|
|
||||||
moveQueueItem,
|
|
||||||
changeQueuePriority,
|
|
||||||
|
|
||||||
// Fetch (for manual refresh)
|
|
||||||
fetchStatus,
|
|
||||||
fetchAgents,
|
|
||||||
fetchOperations,
|
|
||||||
fetchChatMessages,
|
|
||||||
fetchQueue,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* useFlowLayout — Auto-Layout und Edge-Formeln für das V2 FlowCanvas
|
||||||
|
*
|
||||||
|
* Portiert von agents.js (design_handoff_nexus_v2).
|
||||||
|
* Enthält alle Positionslogik, Edge-Erzeugung und den Typ AgentNodeData.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentNodeData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
roleBadge?: string
|
||||||
|
model: string
|
||||||
|
avatar: string
|
||||||
|
status: 'work' | 'think' | 'idle' | 'block'
|
||||||
|
statusLabel: string
|
||||||
|
task: string | null
|
||||||
|
goal: string | null
|
||||||
|
progress: number
|
||||||
|
elapsed: string
|
||||||
|
next: string
|
||||||
|
tokens: string
|
||||||
|
cost: string
|
||||||
|
think: string | null
|
||||||
|
handoff?: string
|
||||||
|
from?: string
|
||||||
|
links?: string[]
|
||||||
|
md?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeData {
|
||||||
|
a: string
|
||||||
|
b: string
|
||||||
|
kind: 'orch' | 'flow'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-Layout Algorithmus
|
||||||
|
* Iris immer top-center (x:50%, y:14%).
|
||||||
|
* Andere Agenten in Reihen (maxPerRow variiert nach Gesamtzahl).
|
||||||
|
*/
|
||||||
|
export function autoLayout(agents: AgentNodeData[]): Record<string, Point> {
|
||||||
|
const positions: Record<string, Point> = { iris: { x: 50, y: 14 } }
|
||||||
|
const others = agents.filter(a => a.id !== 'iris')
|
||||||
|
const n = others.length
|
||||||
|
if (n === 0) return positions
|
||||||
|
|
||||||
|
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||||
|
const numRows = Math.ceil(n / maxPerRow)
|
||||||
|
const yStart = n <= 3 ? 58 : 30
|
||||||
|
const yEnd = 86
|
||||||
|
const yVals = numRows === 1
|
||||||
|
? [yStart]
|
||||||
|
: Array.from({ length: numRows }, (_, i) => yStart + (yEnd - yStart) * i / (numRows - 1))
|
||||||
|
|
||||||
|
let idx = 0
|
||||||
|
yVals.forEach(y => {
|
||||||
|
const rowN = Math.min(maxPerRow, n - idx)
|
||||||
|
const xSpan = Math.min(72, 24 * (rowN - 1) + 18)
|
||||||
|
const xOff = 50 - xSpan / 2
|
||||||
|
const xStep = rowN > 1 ? xSpan / (rowN - 1) : 0
|
||||||
|
for (let ci = 0; ci < rowN; ci++, idx++) {
|
||||||
|
positions[others[idx].id] = { x: xOff + ci * xStep, y }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt die Kantenliste basierend auf Agenten-Daten
|
||||||
|
* (gleiche Logik wie buildEdges in agents.js).
|
||||||
|
*/
|
||||||
|
export function buildEdges(agents: AgentNodeData[]): EdgeData[] {
|
||||||
|
const edges: EdgeData[] = []
|
||||||
|
|
||||||
|
// Orchestrierung: Iris → jeder Agent
|
||||||
|
agents.filter(a => a.id !== 'iris').forEach(a => {
|
||||||
|
edges.push({ a: 'iris', b: a.id, kind: 'orch' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Spezifische Flows (wenn Agent existiert)
|
||||||
|
const hasId = (id: string) => agents.some(a => a.id === id)
|
||||||
|
if (hasId('dev') && hasId('rev')) edges.push({ a: 'dev', b: 'rev', kind: 'flow' })
|
||||||
|
if (hasId('arch') && hasId('exec')) edges.push({ a: 'arch', b: 'exec', kind: 'flow' })
|
||||||
|
if (hasId('res')) edges.push({ a: 'res', b: 'iris', kind: 'flow' })
|
||||||
|
if (hasId('qa') && hasId('rev')) edges.push({ a: 'qa', b: 'rev', kind: 'flow' })
|
||||||
|
if (hasId('security')) edges.push({ a: 'security', b: 'iris', kind: 'flow' })
|
||||||
|
if (hasId('pm')) edges.push({ a: 'pm', b: 'iris', kind: 'flow' })
|
||||||
|
if (hasId('devops') && hasId('exec')) edges.push({ a: 'exec', b: 'devops', kind: 'flow' })
|
||||||
|
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bézier-Kurve zwischen zwei Punkten
|
||||||
|
* Verwendet die README.p1 und p2)) Formel:
|
||||||
|
* M p1 Q Kontrollpunkt p2
|
||||||
|
* mx,my = Mittelpunkt; off = min(50, hypot*0.14)
|
||||||
|
* len = hypot(-dy, dx) (Normale)
|
||||||
|
* cp = (mx + (-dy/len)*off, my + (dx/len)*off)
|
||||||
|
*/
|
||||||
|
export function curve(p1: Point, p2: Point): string {
|
||||||
|
const mx = (p1.x + p2.x) / 2
|
||||||
|
const my = (p1.y + p2.y) / 2
|
||||||
|
const dx = p2.x - p1.x
|
||||||
|
const dy = p2.y - p1.y
|
||||||
|
const off = Math.min(50, Math.hypot(dx, dy) * 0.14)
|
||||||
|
const len = Math.hypot(-dy, dx) || 1
|
||||||
|
const cx = mx + (-dy / len) * off
|
||||||
|
const cy = my + (dx / len) * off
|
||||||
|
return `M${p1.x},${p1.y} Q${cx},${cy} ${p2.x},${p2.y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra agents that can be added to the FlowBoard dynamically
|
||||||
|
*/
|
||||||
|
export const extraAgentPool: AgentNodeData[] = [
|
||||||
|
{
|
||||||
|
id: 'qa',
|
||||||
|
name: 'QA Automator',
|
||||||
|
role: 'Test Automation',
|
||||||
|
roleBadge: 'badge-cyan',
|
||||||
|
avatar: 'QA',
|
||||||
|
status: 'idle',
|
||||||
|
statusLabel: 'Bereit',
|
||||||
|
task: 'End-to-End Tests schreiben',
|
||||||
|
goal: '100% Coverage für auth/',
|
||||||
|
progress: 0,
|
||||||
|
elapsed: '—',
|
||||||
|
next: 'Testplan erstellen',
|
||||||
|
model: 'Deepseek V4 Flash',
|
||||||
|
tokens: '0',
|
||||||
|
cost: '0.00',
|
||||||
|
think: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devops',
|
||||||
|
name: 'DevOps',
|
||||||
|
role: 'CI/CD Pipeline',
|
||||||
|
roleBadge: 'badge-amber',
|
||||||
|
avatar: 'DO',
|
||||||
|
status: 'idle',
|
||||||
|
statusLabel: 'Bereit',
|
||||||
|
task: 'GitHub Actions Workflow',
|
||||||
|
goal: 'Automatisches Deploy auf merge',
|
||||||
|
progress: 0,
|
||||||
|
elapsed: '—',
|
||||||
|
next: 'Pipeline konfigurieren',
|
||||||
|
model: 'Deepseek V4 Pro',
|
||||||
|
tokens: '0',
|
||||||
|
cost: '0.00',
|
||||||
|
think: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
name: 'Security Scanner',
|
||||||
|
role: 'Security Analysis',
|
||||||
|
roleBadge: 'badge-rose',
|
||||||
|
avatar: 'SC',
|
||||||
|
status: 'think',
|
||||||
|
statusLabel: 'Scannt',
|
||||||
|
task: 'Dependency-Audit durchführen',
|
||||||
|
goal: 'CVEs in api/ aufdecken',
|
||||||
|
progress: 18,
|
||||||
|
elapsed: '00:01:44',
|
||||||
|
next: 'Report an Iris',
|
||||||
|
model: 'Deepseek V4 Pro',
|
||||||
|
tokens: '9k',
|
||||||
|
cost: '0.18',
|
||||||
|
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pm',
|
||||||
|
name: 'Project Manager',
|
||||||
|
role: 'Coordination',
|
||||||
|
roleBadge: 'badge-purple',
|
||||||
|
avatar: 'PM',
|
||||||
|
status: 'think',
|
||||||
|
statusLabel: 'Plant',
|
||||||
|
task: 'Sprint-Retrospektive vorbereiten',
|
||||||
|
goal: 'Blockers identifizieren',
|
||||||
|
progress: 35,
|
||||||
|
elapsed: '00:05:10',
|
||||||
|
next: 'Meeting-Summary an Team',
|
||||||
|
model: 'Deepseek V4 Flash',
|
||||||
|
tokens: '14k',
|
||||||
|
cost: '0.24',
|
||||||
|
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
|
|
||||||
import type { AgentNodeData } from './useDashboardData'
|
|
||||||
|
|
||||||
export interface CardBox {
|
|
||||||
left: number
|
|
||||||
right: number
|
|
||||||
top: number
|
|
||||||
bottom: number
|
|
||||||
cx: number
|
|
||||||
cy: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionPath {
|
|
||||||
d: string
|
|
||||||
length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTeamNetworkSvg(
|
|
||||||
networkRef: Ref<HTMLElement | null>,
|
|
||||||
agents: Ref<AgentNodeData[]>,
|
|
||||||
heroId: Ref<string>,
|
|
||||||
isActive: (id: string) => boolean,
|
|
||||||
) {
|
|
||||||
// ── Layout ──
|
|
||||||
const cardPositions = ref<Record<string, CardBox>>({})
|
|
||||||
const svgWidth = ref(0)
|
|
||||||
const svgHeight = ref(0)
|
|
||||||
|
|
||||||
const childAgents = computed(() => agents.value.filter(a => a.id !== heroId.value))
|
|
||||||
|
|
||||||
function updatePositions() {
|
|
||||||
const el = networkRef.value
|
|
||||||
if (!el) return
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
svgWidth.value = rect.width
|
|
||||||
svgHeight.value = rect.height
|
|
||||||
|
|
||||||
const cards = el.querySelectorAll('[data-agent-id]')
|
|
||||||
const positions: Record<string, CardBox> = {}
|
|
||||||
cards.forEach(card => {
|
|
||||||
const id = card.getAttribute('data-agent-id')
|
|
||||||
if (!id) return
|
|
||||||
const r = card.getBoundingClientRect()
|
|
||||||
positions[id] = {
|
|
||||||
left: r.left - rect.left,
|
|
||||||
right: r.left + r.width - rect.left,
|
|
||||||
top: r.top - rect.top,
|
|
||||||
bottom: r.top + r.height - rect.top,
|
|
||||||
cx: r.left + r.width / 2 - rect.left,
|
|
||||||
cy: r.top + r.height / 2 - rect.top,
|
|
||||||
width: r.width,
|
|
||||||
height: r.height,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cardPositions.value = positions
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connection paths ──
|
|
||||||
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
|
|
||||||
const result: Record<string, ConnectionPath | null> = {}
|
|
||||||
const pos = cardPositions.value
|
|
||||||
const iris = pos[heroId.value]
|
|
||||||
if (!iris) return result
|
|
||||||
|
|
||||||
const children = childAgents.value
|
|
||||||
const total = children.length
|
|
||||||
if (total === 0) return result
|
|
||||||
|
|
||||||
for (let idx = 0; idx < total; idx++) {
|
|
||||||
const agent = children[idx]
|
|
||||||
const agentPos = pos[agent.id]
|
|
||||||
if (!agentPos) {
|
|
||||||
result[agent.id] = null
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spread start points across Iris bottom edge (30%-70% range)
|
|
||||||
const t = total > 1 ? idx / (total - 1) : 0.5
|
|
||||||
const startX = iris.left + iris.width * (0.38 + t * 0.24)
|
|
||||||
const startY = iris.bottom - 1
|
|
||||||
|
|
||||||
// Determine column: left or right of Iris center
|
|
||||||
const isLeftColumn = agentPos.cx < iris.cx
|
|
||||||
|
|
||||||
// End point: approach from side, 8px before card edge
|
|
||||||
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
|
|
||||||
const endY = agentPos.cy
|
|
||||||
|
|
||||||
// Bézier control points
|
|
||||||
const cp1x = startX
|
|
||||||
const cp1y = startY + 70
|
|
||||||
const cp2x = endX + (isLeftColumn ? 35 : -35)
|
|
||||||
const cp2y = endY - 10
|
|
||||||
|
|
||||||
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
|
|
||||||
result[agent.id] = { d, length: 0 }
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Path refs (template ref functions) ──
|
|
||||||
const pathElements = ref<Record<string, SVGPathElement | null>>({})
|
|
||||||
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
|
|
||||||
const pulseElements2 = ref<Record<string, SVGPathElement | null>>({})
|
|
||||||
const pulseOffsets = ref<Record<string, number>>({})
|
|
||||||
const pulseOffsets2 = ref<Record<string, number>>({})
|
|
||||||
|
|
||||||
function storePathRef(id: string) {
|
|
||||||
return (el: SVGPathElement | null) => {
|
|
||||||
pathElements.value[id] = el
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function storePulseRef(id: string) {
|
|
||||||
return (el: SVGPathElement | null) => {
|
|
||||||
pulseElements.value[id] = el
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function storePulseRef2(id: string) {
|
|
||||||
return (el: SVGPathElement | null) => {
|
|
||||||
pulseElements2.value[id] = el
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pulse animation ──
|
|
||||||
let animFrameId: number | null = null
|
|
||||||
let lastAnimTime = 0
|
|
||||||
const speeds: Record<string, number> = {}
|
|
||||||
|
|
||||||
function refreshPathLengths() {
|
|
||||||
for (const id of childAgents.value.map(a => a.id)) {
|
|
||||||
const pathEl = pathElements.value[id]
|
|
||||||
const pulseEl = pulseElements.value[id]
|
|
||||||
const p = connectionPaths.value[id]
|
|
||||||
if (pathEl && p) {
|
|
||||||
p.length = pathEl.getTotalLength()
|
|
||||||
}
|
|
||||||
if (pulseEl && p && p.length > 0) {
|
|
||||||
if (pulseOffsets.value[id] === undefined) {
|
|
||||||
pulseOffsets.value[id] = 0
|
|
||||||
}
|
|
||||||
pulseEl.setAttribute('stroke-dasharray', `40 ${p.length}`)
|
|
||||||
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
||||||
}
|
|
||||||
const pulseEl2 = pulseElements2.value[id]
|
|
||||||
if (pulseEl2 && p && p.length > 0) {
|
|
||||||
if (pulseOffsets2.value[id] === undefined) {
|
|
||||||
pulseOffsets2.value[id] = 0
|
|
||||||
}
|
|
||||||
pulseEl2.setAttribute('stroke-dasharray', `40 ${p.length}`)
|
|
||||||
pulseEl2.setAttribute('stroke-dashoffset', String(-pulseOffsets2.value[id]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPulseAnimation() {
|
|
||||||
refreshPathLengths()
|
|
||||||
|
|
||||||
for (const id of childAgents.value.map(a => a.id)) {
|
|
||||||
const p = connectionPaths.value[id]
|
|
||||||
if (p && p.length > 0) {
|
|
||||||
speeds[id] = p.length / 3000
|
|
||||||
if (pulseOffsets.value[id] === undefined) pulseOffsets.value[id] = 0
|
|
||||||
if (pulseOffsets2.value[id] === undefined) pulseOffsets2.value[id] = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastAnimTime = performance.now()
|
|
||||||
|
|
||||||
function tick(now: number) {
|
|
||||||
const dt = now - lastAnimTime
|
|
||||||
lastAnimTime = now
|
|
||||||
|
|
||||||
const children = childAgents.value
|
|
||||||
for (let i = 0; i < children.length; i++) {
|
|
||||||
const id = children[i].id
|
|
||||||
const pathEl = pathElements.value[id]
|
|
||||||
const pulseEl = pulseElements.value[id]
|
|
||||||
const pulseEl2 = pulseElements2.value[id]
|
|
||||||
const p = connectionPaths.value[id]
|
|
||||||
if (!pathEl || !pulseEl || !p) continue
|
|
||||||
|
|
||||||
const len = p.length
|
|
||||||
if (len <= 0) continue
|
|
||||||
|
|
||||||
const speed = speeds[id] ?? len / 3000
|
|
||||||
const cycleLen = len + 40
|
|
||||||
|
|
||||||
// Pulse 1
|
|
||||||
const currentOffset = pulseOffsets.value[id] ?? 0
|
|
||||||
const newOffset = currentOffset + speed * dt
|
|
||||||
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
|
|
||||||
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
|
|
||||||
|
|
||||||
// Pulse 2 (offset by half cycle)
|
|
||||||
if (pulseEl2) {
|
|
||||||
const offset2 = (pulseOffsets.value[id] + cycleLen / 2) % cycleLen
|
|
||||||
pulseOffsets2.value[id] = offset2
|
|
||||||
pulseEl2.setAttribute('stroke-dashoffset', String(-offset2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
animFrameId = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
animFrameId = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPulseAnimation() {
|
|
||||||
if (animFrameId !== null) {
|
|
||||||
cancelAnimationFrame(animFrameId)
|
|
||||||
animFrameId = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Lifecycle ──
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
updatePositions()
|
|
||||||
|
|
||||||
// Wait for SVG to render so path refs are populated
|
|
||||||
await nextTick()
|
|
||||||
updatePositions()
|
|
||||||
refreshPathLengths()
|
|
||||||
|
|
||||||
startPulseAnimation()
|
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
updatePositions()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
refreshPathLengths()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (networkRef.value) {
|
|
||||||
resizeObserver.observe(networkRef.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopPulseAnimation()
|
|
||||||
resizeObserver?.disconnect()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
cardPositions,
|
|
||||||
svgWidth,
|
|
||||||
svgHeight,
|
|
||||||
childAgents,
|
|
||||||
connectionPaths,
|
|
||||||
pathElements,
|
|
||||||
pulseElements,
|
|
||||||
pulseElements2,
|
|
||||||
pulseOffsets,
|
|
||||||
pulseOffsets2,
|
|
||||||
storePathRef,
|
|
||||||
storePulseRef,
|
|
||||||
storePulseRef2,
|
|
||||||
updatePositions,
|
|
||||||
refreshPathLengths,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { AgentNodeData } from '../types/agentNode'
|
||||||
|
|
||||||
|
export const EXTRA_AGENT_POOL: AgentNodeData[] = [
|
||||||
|
{
|
||||||
|
id: 'qa',
|
||||||
|
name: 'QA Automator',
|
||||||
|
role: 'Test Automation',
|
||||||
|
roleBadge: 'badge-cyan',
|
||||||
|
avatar: 'QA',
|
||||||
|
status: 'idle',
|
||||||
|
statusLabel: 'Bereit',
|
||||||
|
task: 'End-to-End Tests schreiben',
|
||||||
|
goal: '100% Coverage für auth/',
|
||||||
|
progress: 0,
|
||||||
|
elapsed: '—',
|
||||||
|
next: 'Testplan erstellen',
|
||||||
|
model: 'Deepseek V4 Flash',
|
||||||
|
tokens: '0',
|
||||||
|
cost: '0.00',
|
||||||
|
think: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devops',
|
||||||
|
name: 'DevOps',
|
||||||
|
role: 'CI/CD Pipeline',
|
||||||
|
roleBadge: 'badge-amber',
|
||||||
|
avatar: 'DO',
|
||||||
|
status: 'idle',
|
||||||
|
statusLabel: 'Bereit',
|
||||||
|
task: 'GitHub Actions Workflow',
|
||||||
|
goal: 'Automatisches Deploy auf merge',
|
||||||
|
progress: 0,
|
||||||
|
elapsed: '—',
|
||||||
|
next: 'Pipeline konfigurieren',
|
||||||
|
model: 'Deepseek V4 Pro',
|
||||||
|
tokens: '0',
|
||||||
|
cost: '0.00',
|
||||||
|
think: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'security',
|
||||||
|
name: 'Security Scanner',
|
||||||
|
role: 'Security Analysis',
|
||||||
|
roleBadge: 'badge-rose',
|
||||||
|
avatar: 'SC',
|
||||||
|
status: 'think',
|
||||||
|
statusLabel: 'Scannt',
|
||||||
|
task: 'Dependency-Audit durchführen',
|
||||||
|
goal: 'CVEs in api/ aufdecken',
|
||||||
|
progress: 18,
|
||||||
|
elapsed: '00:01:44',
|
||||||
|
next: 'Report an Iris',
|
||||||
|
model: 'Deepseek V4 Pro',
|
||||||
|
tokens: '9k',
|
||||||
|
cost: '0.18',
|
||||||
|
think: 'Analysiere package-lock.json auf bekannte Vulnerabilities…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pm',
|
||||||
|
name: 'Project Manager',
|
||||||
|
role: 'Coordination',
|
||||||
|
roleBadge: 'badge-purple',
|
||||||
|
avatar: 'PM',
|
||||||
|
status: 'think',
|
||||||
|
statusLabel: 'Plant',
|
||||||
|
task: 'Sprint-Retrospektive vorbereiten',
|
||||||
|
goal: 'Blockers identifizieren',
|
||||||
|
progress: 35,
|
||||||
|
elapsed: '00:05:10',
|
||||||
|
next: 'Meeting-Summary an Team',
|
||||||
|
model: 'Deepseek V4 Flash',
|
||||||
|
tokens: '14k',
|
||||||
|
cost: '0.24',
|
||||||
|
think: 'Analysiere Velocity-Daten der letzten 3 Sprints…',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* NexusLayout — V2 Dashboard Shell
|
||||||
|
* Flex row, 100vh, overflow hidden.
|
||||||
|
* Sidebar (248px) + Main (flex:1, flex-column)
|
||||||
|
*/
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
import { useAgentStore } from '../stores/agents'
|
||||||
|
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||||
|
import Sidebar from '../components/layout/Sidebar.vue'
|
||||||
|
import Topbar from '../components/layout/Topbar.vue'
|
||||||
|
|
||||||
|
const agentStore = useAgentStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="nexus-layout">
|
||||||
|
<GalaxyBackground />
|
||||||
|
<Sidebar />
|
||||||
|
<main class="nexus-main">
|
||||||
|
<Topbar :connected="agentStore.isConnected" />
|
||||||
|
<div class="nexus-content">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nexus-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
import './assets/nexus-tokens.css'
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import type { AgentNodeData } from '../types/agentNode'
|
||||||
|
import type { AgentDetailData, ThinkingItem } from '../types/agentDetail'
|
||||||
|
import type { DashboardAgentDto, ModelDto } from '../services/agentService'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
|
||||||
|
work: 'Arbeitet',
|
||||||
|
think: 'Plant',
|
||||||
|
idle: 'Bereit',
|
||||||
|
block: 'Blockiert',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
elapsed: string
|
||||||
|
think: string | null
|
||||||
|
next: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENT_CATALOG: Record<string, CatalogEntry> = {
|
||||||
|
iris: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
programmer: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
developer: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
architekt: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
reviewer: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
executor: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
researcher: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
|
||||||
|
if (!isActive) return 'idle'
|
||||||
|
if (currentTask && currentTask !== 'Idle') return 'work'
|
||||||
|
return 'think'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAvatar(id: string, name: string): string {
|
||||||
|
if (id === 'iris') return 'IR'
|
||||||
|
if (id === 'programmer' || id === 'developer') return '</>'
|
||||||
|
return name.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThinkingItems(data: AgentNodeData): ThinkingItem[] {
|
||||||
|
if (!data.think) return []
|
||||||
|
const now = new Date()
|
||||||
|
const ts = (ago: number) => {
|
||||||
|
const d = new Date(now.getTime() - ago * 1000)
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
|
||||||
|
|
||||||
|
if (sentences.length >= 2) {
|
||||||
|
const items: ThinkingItem[] = [
|
||||||
|
{ type: 'thought', text: sentences[0].trim() + '.', ts: ts(30) },
|
||||||
|
{ type: 'action', text: sentences[1].trim() + '…', ts: ts(18) },
|
||||||
|
]
|
||||||
|
const lastSentence = sentences.length >= 3
|
||||||
|
? sentences[sentences.length - 1].trim() + '.'
|
||||||
|
: 'Verarbeitung abgeschlossen.'
|
||||||
|
items.push({ type: 'result', text: lastSentence, ts: ts(3) })
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
if (sentences.length === 1) {
|
||||||
|
return [
|
||||||
|
{ type: 'thought', text: sentences[0].trim(), ts: ts(15) },
|
||||||
|
{ type: 'action', text: 'Analysiere Daten und erstelle nächsten Schritt…', ts: ts(6) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [{ type: 'thought', text: data.think, ts: ts(10) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAgentNode(dto: DashboardAgentDto): AgentNodeData {
|
||||||
|
const cat = AGENT_CATALOG[dto.id] ?? AGENT_CATALOG['reviewer']!
|
||||||
|
const status = resolveStatus(dto.isActive, dto.currentTask)
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
role: dto.role,
|
||||||
|
model: dto.model,
|
||||||
|
avatar: resolveAvatar(dto.id, dto.name),
|
||||||
|
status,
|
||||||
|
statusLabel: STATUS_LABELS[status],
|
||||||
|
task: dto.currentTask,
|
||||||
|
goal: dto.goal ?? null,
|
||||||
|
progress: dto.progress ?? 0,
|
||||||
|
elapsed: cat.elapsed,
|
||||||
|
next: cat.next,
|
||||||
|
tokens: '0',
|
||||||
|
cost: '0.00',
|
||||||
|
think: cat.think,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toModelAlias(dtos: ModelDto[]): { id: string; alias: string }[] {
|
||||||
|
return dtos.map(m => ({ id: m.id, alias: m.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAgentDetail(
|
||||||
|
data: AgentNodeData,
|
||||||
|
models: { id: string; alias: string }[]
|
||||||
|
): AgentDetailData {
|
||||||
|
const tokenNum = parseFloat(data.tokens?.replace(/[^0-9.]/g, '') || '0')
|
||||||
|
const tokenMultiplier = data.tokens?.includes('M')
|
||||||
|
? 1_000_000
|
||||||
|
: data.tokens?.includes('k') ? 1_000 : 1
|
||||||
|
const tokensToday = Math.round(tokenNum * tokenMultiplier)
|
||||||
|
|
||||||
|
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
|
||||||
|
const displayModel = matchingModel?.alias ?? data.model
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
role: data.role,
|
||||||
|
model: displayModel,
|
||||||
|
status: data.status === 'block' ? 'idle' : data.status,
|
||||||
|
tokensToday,
|
||||||
|
costToday: parseFloat(data.cost || '0'),
|
||||||
|
workload: data.progress,
|
||||||
|
uptime: data.elapsed || '—',
|
||||||
|
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
|
||||||
|
activeTaskCount: data.task ? 1 : 0,
|
||||||
|
thinking: buildThinkingItems(data),
|
||||||
|
availableModels: models,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { TaskItem } from '../types/task'
|
||||||
|
import type { TaskDto } from '../services/taskService'
|
||||||
|
|
||||||
|
function toPriority(raw: string): TaskItem['priority'] {
|
||||||
|
const p = raw.toLowerCase()
|
||||||
|
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
|
||||||
|
if (p === 'low' || p === 'minor') return 'low'
|
||||||
|
return 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatus(raw: string): TaskItem['status'] {
|
||||||
|
const s = raw.toLowerCase()
|
||||||
|
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
|
||||||
|
if (s === 'blocked' || s === 'block') return 'blocked'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProgress(raw: string): number {
|
||||||
|
const s = raw.toLowerCase()
|
||||||
|
if (s === 'in progress' || s === 'active' || s === 'working') return 50
|
||||||
|
if (s === 'done') return 100
|
||||||
|
if (s === 'blocked') return 30
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTaskItem(dto: TaskDto): TaskItem {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
title: dto.title,
|
||||||
|
agent: dto.assignedTo ?? '—',
|
||||||
|
priority: toPriority(dto.priority),
|
||||||
|
status: toStatus(dto.state),
|
||||||
|
progress: toProgress(dto.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -9,12 +9,22 @@ import AgentsIndexView from './views/AgentsIndexView.vue'
|
|||||||
import SecurityView from './views/SecurityView.vue'
|
import SecurityView from './views/SecurityView.vue'
|
||||||
import IncidentsView from './views/IncidentsView.vue'
|
import IncidentsView from './views/IncidentsView.vue'
|
||||||
import CalendarView from './views/CalendarView.vue'
|
import CalendarView from './views/CalendarView.vue'
|
||||||
import DashboardView from './views/DashboardView.vue'
|
import NexusLayout from './layouts/NexusLayout.vue'
|
||||||
|
import FlowBoard from './views/Dashboard/FlowBoard.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||||
{ path: '/', redirect: '/dashboard' },
|
{ path: '/', redirect: '/dashboard' },
|
||||||
{ path: '/dashboard', name: 'Dashboard', component: DashboardView },
|
|
||||||
|
// V2 Dashboard (neues NexusLayout + FlowBoard)
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
component: NexusLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'Dashboard', component: FlowBoard },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{ path: '/memory', name: 'Memory', component: MemoryView },
|
{ path: '/memory', name: 'Memory', component: MemoryView },
|
||||||
{ path: '/docs', name: 'Docs', component: DocsView },
|
{ path: '/docs', name: 'Docs', component: DocsView },
|
||||||
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Agent Store – V2 Dashboard
|
||||||
|
*
|
||||||
|
* Fetches agents from /api/dashboard/agents and available models
|
||||||
|
* from /api/dashboard/models. Enriches raw API data with catalog
|
||||||
|
* metadata (color, icon, description, hero) and maps into
|
||||||
|
* AgentNodeData (for FlowCanvas) and AgentDetail (for Modal).
|
||||||
|
*
|
||||||
|
* Auto-refresh: every 30 seconds.
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { apiFetch } from '../services/api'
|
||||||
|
import type { AgentNodeData } from '../composables/useFlowLayout'
|
||||||
|
import type { AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
|
||||||
|
|
||||||
|
/* ── API Response Shapes ──────────────────────────── */
|
||||||
|
|
||||||
|
interface DashboardAgentInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
model: string
|
||||||
|
isActive: boolean
|
||||||
|
currentTask: string | null
|
||||||
|
description?: string
|
||||||
|
tags?: string[]
|
||||||
|
progress?: number
|
||||||
|
workload?: number
|
||||||
|
goal?: string | null
|
||||||
|
roleBadge?: string
|
||||||
|
statusLabel?: string
|
||||||
|
elapsed?: string | null
|
||||||
|
think?: string | null
|
||||||
|
next?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status Mapping ───────────────────────────────── */
|
||||||
|
|
||||||
|
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
|
||||||
|
if (!isActive) return 'idle'
|
||||||
|
if (currentTask && currentTask !== 'Idle') return 'work'
|
||||||
|
return 'think'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<AgentNodeData['status'], string> = {
|
||||||
|
work: 'Arbeitet',
|
||||||
|
think: 'Plant',
|
||||||
|
idle: 'Bereit',
|
||||||
|
block: 'Blockiert',
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarFor(id: string, name: string): string {
|
||||||
|
if (id === 'iris') return 'IR'
|
||||||
|
if (id === 'programmer' || id === 'developer') return '</>'
|
||||||
|
return name.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Enrich API Agent → AgentNodeData ─────────────── */
|
||||||
|
|
||||||
|
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
|
||||||
|
const status = mapStatus(api.isActive, api.currentTask)
|
||||||
|
return {
|
||||||
|
id: api.id,
|
||||||
|
name: api.name,
|
||||||
|
role: api.role,
|
||||||
|
roleBadge: api.roleBadge ?? 'badge-slate',
|
||||||
|
model: api.model,
|
||||||
|
avatar: avatarFor(api.id, api.name),
|
||||||
|
status,
|
||||||
|
statusLabel: api.statusLabel ?? STATUS_LABELS[status],
|
||||||
|
task: api.currentTask,
|
||||||
|
goal: api.goal ?? null,
|
||||||
|
progress: api.progress ?? 0,
|
||||||
|
elapsed: api.elapsed ?? '--',
|
||||||
|
next: api.next ?? 'Standby',
|
||||||
|
tokens: '0',
|
||||||
|
cost: '0.00',
|
||||||
|
think: api.think ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Build AgentDetail from AgentNodeData ─────────── */
|
||||||
|
|
||||||
|
function buildThinkingItems(data: AgentNodeData): ThinkingItem[] {
|
||||||
|
if (!data.think) return []
|
||||||
|
const now = new Date()
|
||||||
|
const ts = (ago: number) => {
|
||||||
|
const d = new Date(now.getTime() - ago * 1000)
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
const sentences = data.think.split(/[.…!?]+/).filter(s => s.trim().length > 5)
|
||||||
|
const items: ThinkingItem[] = []
|
||||||
|
if (sentences.length >= 2) {
|
||||||
|
items.push({ type: 'thought', text: sentences[0].trim() + '.', ts: ts(30) })
|
||||||
|
items.push({ type: 'action', text: sentences[1].trim() + '…', ts: ts(18) })
|
||||||
|
if (sentences.length >= 3) {
|
||||||
|
items.push({ type: 'result', text: sentences[sentences.length - 1].trim() + '.', ts: ts(3) })
|
||||||
|
} else {
|
||||||
|
items.push({ type: 'result', text: 'Verarbeitung abgeschlossen.', ts: ts(3) })
|
||||||
|
}
|
||||||
|
} else if (sentences.length === 1) {
|
||||||
|
items.push({ type: 'thought', text: sentences[0].trim(), ts: ts(15) })
|
||||||
|
items.push({ type: 'action', text: 'Analysiere Daten und erstelle nächsten Schritt…', ts: ts(6) })
|
||||||
|
} else {
|
||||||
|
items.push({ type: 'thought', text: data.think, ts: ts(10) })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentDetail(data: AgentNodeData, models: { id: string; alias: string }[]): AgentDetailData {
|
||||||
|
const tokenNum = parseFloat(data.tokens?.replace(/[^0-9.]/g, '') || '0')
|
||||||
|
const tokenMultiplier = data.tokens?.includes('M') ? 1_000_000 : data.tokens?.includes('k') ? 1_000 : 1
|
||||||
|
const tokensToday = Math.round(tokenNum * tokenMultiplier)
|
||||||
|
const costNum = parseFloat(data.cost || '0')
|
||||||
|
const progress = data.progress || 0
|
||||||
|
|
||||||
|
// Map model ID to display name for the modal dropdown (which uses alias for comparison)
|
||||||
|
const matchingModel = models.find(m => m.id === data.model || m.alias === data.model)
|
||||||
|
const displayModel = matchingModel?.alias ?? data.model
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
role: data.role,
|
||||||
|
roleBadge: data.roleBadge || 'badge-slate',
|
||||||
|
model: displayModel,
|
||||||
|
status: data.status === 'block' ? 'idle' : data.status,
|
||||||
|
statusLabel: data.statusLabel,
|
||||||
|
task: data.task,
|
||||||
|
goal: data.goal,
|
||||||
|
progress,
|
||||||
|
elapsed: data.elapsed || '—',
|
||||||
|
next: data.next || '—',
|
||||||
|
tokens: data.tokens || '0',
|
||||||
|
cost: data.cost || '0.00',
|
||||||
|
think: data.think,
|
||||||
|
md: data.md,
|
||||||
|
tokensToday,
|
||||||
|
costToday: costNum,
|
||||||
|
workload: progress,
|
||||||
|
uptime: data.elapsed || '—',
|
||||||
|
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
|
||||||
|
activeTaskCount: data.task ? 1 : 0,
|
||||||
|
thinking: buildThinkingItems(data),
|
||||||
|
availableModels: models,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAgentStore = defineStore('agents', {
|
||||||
|
state: () => ({
|
||||||
|
agents: [] as AgentNodeData[],
|
||||||
|
models: [] as { id: string; alias: string }[],
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
selectedAgentId: null as string | null,
|
||||||
|
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||||
|
isConnected: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
/** AgentNodeData list for FlowCanvas */
|
||||||
|
agentList: (state) => state.agents,
|
||||||
|
|
||||||
|
/** Agent IDs in display order (Iris first) */
|
||||||
|
agentOrder: (state) => {
|
||||||
|
const ordered = state.agents.filter(a => a.id === 'iris')
|
||||||
|
state.agents.forEach(a => { if (a.id !== 'iris') ordered.push(a) })
|
||||||
|
return ordered.map(a => a.id)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Selected agent detail for modal */
|
||||||
|
selectedAgent(state): AgentDetailData | null {
|
||||||
|
if (!state.selectedAgentId) return null
|
||||||
|
const data = state.agents.find(a => a.id === state.selectedAgentId)
|
||||||
|
if (!data) return null
|
||||||
|
return buildAgentDetail(data, state.models)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Is the modal open? */
|
||||||
|
modalOpen: (state) => state.selectedAgentId !== null,
|
||||||
|
|
||||||
|
/* ── AlertBar Metrics ────────────────────────── */
|
||||||
|
activeCount: (state) => state.agents.filter(a => a.status === 'work').length,
|
||||||
|
thinkCount: (state) => state.agents.filter(a => a.status === 'think').length,
|
||||||
|
idleCount: (state) => state.agents.filter(a => a.status === 'idle').length,
|
||||||
|
blockerCount: (state) => state.agents.filter(a => a.status === 'block').length,
|
||||||
|
todayCost: (state) => {
|
||||||
|
const total = state.agents.reduce((s, a) => s + parseFloat(a.cost || '0'), 0)
|
||||||
|
return '$' + total.toFixed(2)
|
||||||
|
},
|
||||||
|
todayTokens: (state) => {
|
||||||
|
const total = state.agents.reduce((s, a) => {
|
||||||
|
const raw = a.tokens?.replace(/[^0-9.]/g, '') || '0'
|
||||||
|
const v = parseFloat(raw)
|
||||||
|
return Number.isFinite(v) ? s + v : s
|
||||||
|
}, 0)
|
||||||
|
return total >= 1000 ? Math.round(total / 1000) + 'k' : Math.round(total) + ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/* ── API: Fetch agents ──────────────────────── */
|
||||||
|
async fetchAgents() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/agents')
|
||||||
|
if (!res.ok) { this.isConnected = false; return }
|
||||||
|
const data: DashboardAgentInfo[] = await res.json()
|
||||||
|
this.agents = data.map(enrichAgent)
|
||||||
|
this.isConnected = true
|
||||||
|
} catch (err) {
|
||||||
|
this.isConnected = false
|
||||||
|
console.warn('[AgentStore] fetchAgents failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── API: Fetch available models ────────────── */
|
||||||
|
async fetchModels() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/models')
|
||||||
|
if (!res.ok) return
|
||||||
|
const data: ModelOption[] = await res.json()
|
||||||
|
this.models = data.map(m => ({ id: m.id, alias: m.name }))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AgentStore] fetchModels failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── API: Change agent model ────────────────── */
|
||||||
|
async changeModel(agentId: string, modelId: string) {
|
||||||
|
// Optimistic update
|
||||||
|
const agent = this.agents.find(a => a.id === agentId)
|
||||||
|
if (agent) agent.model = modelId
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/model`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ model: modelId }),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AgentStore] changeModel failed', err)
|
||||||
|
// Refetch to revert on failure
|
||||||
|
await this.fetchAgents()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── Selection ───────────────────────────────── */
|
||||||
|
selectAgent(id: string | null) {
|
||||||
|
this.selectedAgentId = id
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── Polling ─────────────────────────────────── */
|
||||||
|
startPolling() {
|
||||||
|
if (this.refreshInterval) return
|
||||||
|
this.fetchAgents()
|
||||||
|
this.fetchModels()
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.fetchAgents()
|
||||||
|
this.fetchModels()
|
||||||
|
}, 15000)
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval)
|
||||||
|
this.refreshInterval = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Chat Store – V2 Dashboard
|
||||||
|
*
|
||||||
|
* Fetches chat messages from /api/dashboard/chat/messages and
|
||||||
|
* sends new messages via /api/dashboard/chat/send.
|
||||||
|
*
|
||||||
|
* Auto-refresh: every 10 seconds (incoming Iris messages).
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { apiFetch } from '../services/api'
|
||||||
|
import type { ChatMessage } from '../components/dashboard/v2/types'
|
||||||
|
|
||||||
|
/* ── API Response Shapes ──────────────────────────── */
|
||||||
|
|
||||||
|
interface MessageEntry {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatResponse {
|
||||||
|
ok: boolean
|
||||||
|
reply: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChatStore = defineStore('chat', {
|
||||||
|
state: () => ({
|
||||||
|
messages: [] as ChatMessage[],
|
||||||
|
isThinking: false,
|
||||||
|
error: null as string | null,
|
||||||
|
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||||
|
/** Tracks last process timestamp to avoid duplicates */
|
||||||
|
lastProcessedTs: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
messageList: (state) => state.messages,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/* ── API: Fetch history ─────────────────────── */
|
||||||
|
async fetchHistory() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
|
||||||
|
if (!res.ok) return
|
||||||
|
const data: MessageEntry[] = await res.json()
|
||||||
|
|
||||||
|
// Merge new messages (avoid duplicates)
|
||||||
|
let mostRecentTs = this.lastProcessedTs
|
||||||
|
for (const msg of data) {
|
||||||
|
const msgTs = new Date(msg.timestamp).getTime()
|
||||||
|
if (msgTs <= this.lastProcessedTs) continue
|
||||||
|
if (msgTs > mostRecentTs) mostRecentTs = msgTs
|
||||||
|
|
||||||
|
const sender = msg.role === 'assistant' ? 'iris' as const : 'user' as const
|
||||||
|
const tsFormatted = new Date(msg.timestamp).toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avoid appending duplicates already present
|
||||||
|
const exists = this.messages.some(
|
||||||
|
m => m.sender === sender && m.text === msg.content && m.ts === tsFormatted
|
||||||
|
)
|
||||||
|
if (exists) continue
|
||||||
|
|
||||||
|
this.messages.push({
|
||||||
|
sender,
|
||||||
|
text: msg.content,
|
||||||
|
ts: tsFormatted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mostRecentTs > this.lastProcessedTs) {
|
||||||
|
this.lastProcessedTs = mostRecentTs
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ChatStore] fetchHistory failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── API: Send message ──────────────────────── */
|
||||||
|
async sendMessage(text: string) {
|
||||||
|
if (!text.trim()) return
|
||||||
|
|
||||||
|
const tsFormatted = new Date().toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optimistic add: user message
|
||||||
|
this.messages.push({
|
||||||
|
sender: 'user',
|
||||||
|
text: text.trim(),
|
||||||
|
ts: tsFormatted,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.isThinking = true
|
||||||
|
this.error = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/chat/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ message: text.trim() }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data: ChatResponse = await res.json()
|
||||||
|
|
||||||
|
if (data.ok && data.reply) {
|
||||||
|
this.messages.push({
|
||||||
|
sender: 'iris',
|
||||||
|
text: data.reply,
|
||||||
|
ts: new Date().toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} else if (data.error) {
|
||||||
|
this.messages.push({
|
||||||
|
sender: 'iris',
|
||||||
|
text: `⚠️ ${data.error}`,
|
||||||
|
ts: new Date().toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ChatStore] sendMessage failed', err)
|
||||||
|
this.messages.push({
|
||||||
|
sender: 'iris',
|
||||||
|
text: '⚠️ Connection error. Please try again.',
|
||||||
|
ts: new Date().toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
this.isThinking = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── Polling ─────────────────────────────────── */
|
||||||
|
startPolling() {
|
||||||
|
if (this.refreshInterval) return
|
||||||
|
this.fetchHistory()
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.fetchHistory()
|
||||||
|
}, 10000) // 10s for chat (more responsive)
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval)
|
||||||
|
this.refreshInterval = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -4,36 +4,15 @@ import { apiFetch } from '../services/api'
|
|||||||
|
|
||||||
const fallback: OperationsSnapshot = {
|
const fallback: OperationsSnapshot = {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
runtime: { runtime: 'OpenClaw', status: 'Online', detail: 'Gateway responding' },
|
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
|
||||||
models: [
|
models: [],
|
||||||
{ provider: 'OpenClaw', model: 'deepseek/deepseek-v4-flash', status: 'Online', isLocal: false, detail: 'Programmer agent' },
|
metrics: { activeAgents: 0, queuedTasks: 0, successRate: 0, incidents: 0 },
|
||||||
{ provider: 'OpenClaw', model: 'deepseek/deepseek-v4-pro', status: 'Online', isLocal: false, detail: 'Reviewer agent' },
|
projects: [],
|
||||||
{ provider: 'OpenClaw', model: 'openai/gpt-5.3-chat-latest', status: 'Online', isLocal: false, detail: 'Iris orchestrator' },
|
tasks: [],
|
||||||
],
|
activity: [],
|
||||||
metrics: { activeAgents: 3, queuedTasks: 7, successRate: 98.4, incidents: 0 },
|
|
||||||
projects: [
|
|
||||||
{ id: 'nexus', name: 'Nexus', status: 'Active', progress: 18 },
|
|
||||||
{ id: 'openclaw', name: 'OpenClaw Runtime', status: 'Online', progress: 100 },
|
|
||||||
{ id: 'infra', name: 'Noveria Infrastructure', status: 'Stable', progress: 74 },
|
|
||||||
],
|
|
||||||
tasks: [
|
|
||||||
{ id: 'preview-foundation', title: 'Nexus foundation', state: 'In progress', priority: 'Critical', updatedAt: new Date().toISOString() },
|
|
||||||
{ id: 'preview-runtime', title: 'Connect OpenClaw adapter', state: 'In progress', priority: 'High', updatedAt: new Date().toISOString() },
|
|
||||||
{ id: 'preview-routing', title: 'Configure model routing', state: 'In progress', priority: 'High', updatedAt: new Date().toISOString() },
|
|
||||||
{ id: 'preview-auth', title: 'Owner authentication', state: 'Done', priority: 'Critical', updatedAt: new Date().toISOString() },
|
|
||||||
],
|
|
||||||
activity: [
|
|
||||||
{ type: 'runtime', message: 'OpenClaw runtime health checked', at: new Date().toISOString() },
|
|
||||||
{ type: 'deploy', message: 'Nexus foundation initialized', at: new Date(Date.now() - 720000).toISOString() },
|
|
||||||
{ type: 'deploy', message: 'Model routing configured for DeepSeek agents', at: new Date(Date.now() - 1140000).toISOString() },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackRouting: RoutingTarget[] = [
|
const fallbackRouting: RoutingTarget[] = []
|
||||||
{ priority: 1, provider: 'OpenClaw', model: 'deepseek/deepseek-v4-flash', purpose: 'Programmer agent', status: 'Online', detail: 'Routed through OpenClaw' },
|
|
||||||
{ priority: 2, provider: 'OpenClaw', model: 'deepseek/deepseek-v4-pro', purpose: 'Reviewer agent', status: 'Online', detail: 'Routed through OpenClaw' },
|
|
||||||
{ priority: 3, provider: 'OpenClaw', model: 'openai/gpt-5.3-chat-latest', purpose: 'Iris orchestrator', status: 'Online', detail: 'Routed through OpenClaw' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const useOperationsStore = defineStore('operations', {
|
export const useOperationsStore = defineStore('operations', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Task Store – V2 Dashboard
|
||||||
|
*
|
||||||
|
* Fetches tasks from /api/dashboard/tasks and maps them into
|
||||||
|
* TaskItem[] format for the TaskStrip component.
|
||||||
|
*
|
||||||
|
* Auto-refresh: every 30 seconds.
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { apiFetch } from '../services/api'
|
||||||
|
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||||
|
|
||||||
|
/* ── API Response Shapes ──────────────────────────── */
|
||||||
|
|
||||||
|
interface DashboardTaskDto {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
detail: string | null
|
||||||
|
source: string
|
||||||
|
state: string
|
||||||
|
priority: string
|
||||||
|
assignedTo: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── State Mapping ────────────────────────────────── */
|
||||||
|
|
||||||
|
function mapPriority(priority: string): TaskItem['priority'] {
|
||||||
|
const p = priority.toLowerCase()
|
||||||
|
if (p === 'high' || p === 'critical' || p === 'urgent') return 'high'
|
||||||
|
if (p === 'low' || p === 'minor') return 'low'
|
||||||
|
return 'medium'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapState(state: string): TaskItem['status'] {
|
||||||
|
const s = state.toLowerCase()
|
||||||
|
if (s === 'in progress' || s === 'active' || s === 'working') return 'active'
|
||||||
|
if (s === 'blocked' || s === 'block') return 'blocked'
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProgress(state: string): number {
|
||||||
|
const s = state.toLowerCase()
|
||||||
|
if (s === 'in progress' || s === 'active' || s === 'working') return 50
|
||||||
|
if (s === 'done') return 100
|
||||||
|
if (s === 'blocked') return 30
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTask(t: DashboardTaskDto): TaskItem {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
agent: t.assignedTo ?? '—',
|
||||||
|
priority: mapPriority(t.priority),
|
||||||
|
status: mapState(t.state),
|
||||||
|
progress: mapProgress(t.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTaskStore = defineStore('tasks', {
|
||||||
|
state: () => ({
|
||||||
|
tasks: [] as TaskItem[],
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
taskList: (state) => state.tasks,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
/* ── API: Fetch tasks ───────────────────────── */
|
||||||
|
async fetchTasks() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/tasks')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data: DashboardTaskDto[] = await res.json()
|
||||||
|
this.tasks = data.map(mapTask)
|
||||||
|
this.error = null
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[TaskStore] fetchTasks failed', err)
|
||||||
|
this.error = 'Tasks could not be loaded'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── API: Add task ──────────────────────────── */
|
||||||
|
async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
detail: detail ?? null,
|
||||||
|
priority: priority ?? null,
|
||||||
|
assignedTo: assignedTo ?? null,
|
||||||
|
source: 'bao',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
// Refresh task list
|
||||||
|
await this.fetchTasks()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[TaskStore] addTask failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── API: Update task ───────────────────────── */
|
||||||
|
async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
await this.fetchTasks()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[TaskStore] updateTask failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── Polling ─────────────────────────────────── */
|
||||||
|
startPolling() {
|
||||||
|
if (this.refreshInterval) return
|
||||||
|
this.fetchTasks()
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.fetchTasks()
|
||||||
|
}, 30000)
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval)
|
||||||
|
this.refreshInterval = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Shared formatting utilities for Nexus Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Format a number with SI suffixes (k, M) */
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format currency with $ prefix */
|
||||||
|
export function formatCurrency(n: number): string {
|
||||||
|
return '$' + n.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a Date as German locale time HH:MM:SS */
|
||||||
|
export function formatTime(d: Date): string {
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a Date as German locale time HH:MM */
|
||||||
|
export function formatTimeShort(d: Date): string {
|
||||||
|
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract initials (max 2 chars) from a display name */
|
||||||
|
export function initials(name: string): string {
|
||||||
|
return name.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* FlowBoard – Das neue V2 Dashboard
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* Stage (AlertBar + FlowCanvas) + Rail (IrisChat) + TaskStrip (unten)
|
||||||
|
*
|
||||||
|
* Datenquellen:
|
||||||
|
* - AgentStore: agents, models, AlertBar-Metriken, Modal-Status
|
||||||
|
* - ChatStore: messages, isThinking, sendMessage()
|
||||||
|
* - TaskStore: tasks
|
||||||
|
*
|
||||||
|
* Polling startet bei Mount, stoppt bei Unmount.
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useAgentStore } from '../../stores/agents'
|
||||||
|
import { useChatStore } from '../../stores/chat'
|
||||||
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
|
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||||
|
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||||
|
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||||
|
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
||||||
|
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
||||||
|
import type { AgentNodeData } from '../../composables/useFlowLayout'
|
||||||
|
import { extraAgentPool } from '../../composables/useFlowLayout'
|
||||||
|
|
||||||
|
/* ── Stores ──────────────────────────────────────── */
|
||||||
|
const agentStore = useAgentStore()
|
||||||
|
const chatStore = useChatStore()
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
|
/* ── Agent Layout State ───────────────────────────── */
|
||||||
|
const agentPositions = ref<Record<string, { x: number; y: number }>>({})
|
||||||
|
const enteringIds = ref<string[]>([])
|
||||||
|
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
||||||
|
|
||||||
|
/* ── Event Handlers ───────────────────────────────── */
|
||||||
|
|
||||||
|
function handleSelect(id: string) {
|
||||||
|
agentStore.selectAgent(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseModal() {
|
||||||
|
agentStore.selectAgent(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeModel(agentId: string, modelAlias: string) {
|
||||||
|
// Modal emits the alias (display name); resolve to model ID for the API
|
||||||
|
const model = agentStore.models.find(m => m.alias === modelAlias)
|
||||||
|
const modelId = model?.id ?? modelAlias
|
||||||
|
agentStore.changeModel(agentId, modelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const pool = localAgentPool.value
|
||||||
|
if (pool.length === 0) return
|
||||||
|
const next = pool.shift()!
|
||||||
|
enteringIds.value.push(next.id)
|
||||||
|
agentStore.agents.push(next)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const idx = enteringIds.value.indexOf(next.id)
|
||||||
|
if (idx !== -1) enteringIds.value.splice(idx, 1)
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetLayout() {
|
||||||
|
agentPositions.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdatePositions(pos: Record<string, { x: number; y: number }>) {
|
||||||
|
agentPositions.value = { ...pos }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlockerClick() {
|
||||||
|
console.log('[FlowBoard] blocker clicked')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChatSend(text: string) {
|
||||||
|
chatStore.sendMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lifecycle ────────────────────────────────────── */
|
||||||
|
onMounted(() => {
|
||||||
|
agentStore.startPolling()
|
||||||
|
chatStore.startPolling()
|
||||||
|
taskStore.startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
agentStore.stopPolling()
|
||||||
|
chatStore.stopPolling()
|
||||||
|
taskStore.stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flow-board">
|
||||||
|
<!-- Stage + Rail row -->
|
||||||
|
<div class="board-body">
|
||||||
|
<!-- Stage: AlertBar + FlowCanvas + TaskStrip -->
|
||||||
|
<div class="stage">
|
||||||
|
<AlertBar
|
||||||
|
:active-count="agentStore.activeCount"
|
||||||
|
:think-count="agentStore.thinkCount"
|
||||||
|
:idle-count="agentStore.idleCount"
|
||||||
|
:blocker-count="agentStore.blockerCount"
|
||||||
|
:today-cost="agentStore.todayCost"
|
||||||
|
:today-tokens="agentStore.todayTokens"
|
||||||
|
@blocker-click="handleBlockerClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlowCanvas
|
||||||
|
:agents="agentStore.agentList"
|
||||||
|
:positions="agentPositions"
|
||||||
|
:entering-ids="enteringIds"
|
||||||
|
@select="handleSelect"
|
||||||
|
@add="handleAdd"
|
||||||
|
@reset-layout="handleResetLayout"
|
||||||
|
@update-positions="handleUpdatePositions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rail: IrisChat -->
|
||||||
|
<IrisChat
|
||||||
|
:messages="chatStore.messageList"
|
||||||
|
:is-thinking="chatStore.isThinking"
|
||||||
|
:error="chatStore.error"
|
||||||
|
@send="handleChatSend"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent Detail Modal -->
|
||||||
|
<AgentDetailModal
|
||||||
|
v-if="agentStore.modalOpen && agentStore.selectedAgent"
|
||||||
|
:agent="agentStore.selectedAgent"
|
||||||
|
:agent-order="agentStore.agentOrder"
|
||||||
|
@close="handleCloseModal"
|
||||||
|
@select="handleSelect"
|
||||||
|
@change-model="handleChangeModel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flow-board {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
animation: fade-in 0.35s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user