Compare commits

..

22 Commits

Author SHA1 Message Date
iris c496608c86 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris c040696d91 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris 7ba0bd26fa docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
iris 4b1d140b53 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
developer e0c88238da refactor: extract DI, helpers from Program.cs into extension classes
CI - Build & Test / Backend (.NET) (push) Successful in 1m18s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 48s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-16 16:52:17 +02:00
AzuTear b0e65e3980 style: strengthen flow lines and tighten modal demo parity
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:57:12 +02:00
devops 648a5d2151 refactor: move landingpage to separate repo bao/noveria-landing
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:53:00 +02:00
devops 1a024eef96 feat: noveria.net landingpage template
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:45:23 +02:00
devops 6280e87078 infra: landingpage compose + nginx config
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:51 +02:00
AzuTear 64459ccdb3 feat: wire dashboard v2 to backend data
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:05 +02:00
devops 38dc2efc6c docs: devops deploy-actor documentation
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:41:38 +02:00
AzuTear 390bffa208 fix: detect drag state on pointer release
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 15:33:51 +02:00
AzuTear e034883abd fix: open agent cards only on click
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:23:05 +02:00
AzuTear 6d4e8e7927 refactor: streamline flow board interactions
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:11:05 +02:00
reviewer 0f8939306d feat: mobile-responsive dashboard v2
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 12:16:06 +02:00
reviewer 58675f0c69 ops: enhanced deploy verification with web-recovery + incident docs
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 11:31:46 +02:00
reviewer 88cafc7b8e review: remove version-bump from deploy workflow — VERSION is read-only source of truth
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 11:31:04 +02:00
reviewer 485357c6dc review: error-handling for config file write + compose resource limits
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
- AgentsController.SaveConfigFile: catch UnauthorizedAccessException and IOException
  instead of letting them bubble up unhandled; return clean 500 with logged message
- compose.yaml: add deploy.resources.limits.memory and reservations.memory for
  api (512M/128M), web (128M/32M), postgres (256M/64M)
2026-06-14 11:30:25 +02:00
devops 36b32f0e88 chore: bump version to 0.2.56 [skip ci] 2026-06-14 07:50:18 +00:00
reviewer 8a556c25a0 Add local liveness health endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:49:25 +02:00
devops f271602f31 chore: bump version to 0.2.55 [skip ci] 2026-06-14 07:29:01 +00:00
reviewer 63319e1046 fix: stream deploy env into docker cli
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:27:56 +02:00
32 changed files with 1321 additions and 487 deletions
+29 -61
View File
@@ -15,12 +15,11 @@ run-name: 🚀 Deploy by @${{ gitea.actor }}
# Concurrency: one deploy at a time. # Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds. # Queued deploys wait — no race conditions with parallel builds.
# #
# Version-Bump / CI Loop Prevention: # Version Management:
# The version-bump commit includes "[skip ci]" in its message, # The VERSION file in the repo root is the single source of truth.
# which Gitea Actions respects. The auto-trigger additionally # Version bumps happen in the Dev workflow BEFORE merge to main.
# checks for "[skip ci]" as a second safety layer. Together # The deploy workflow only reads, validates, and logs the version.
# they guarantee that a version-bump commit does NOT trigger # The [skip ci] filter remains as a safety layer for auto-triggers.
# another CI → Deploy → Bump → CI cycle.
# ─────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────
concurrency: concurrency:
group: deploy-production group: deploy-production
@@ -36,15 +35,6 @@ on:
# ── Manual Trigger (full control) ── # ── Manual Trigger (full control) ──
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version_bump:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
service: service:
description: 'Service to deploy (empty = all)' description: 'Service to deploy (empty = all)'
required: false required: false
@@ -102,60 +92,39 @@ jobs:
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version # Step 3: Resolve deploy version
# #
# Deploying main: DevOps may bump VERSION and create a tag. # Reads VERSION from repo root — the single source of truth.
# Deploying any other ref: deploy exactly that ref, but DO NOT # Validates semver format, logs version + git metadata.
# mutate main or create a version-bump commit on another branch. # No git mutation: version bumps happen in the Dev workflow.
#
# For auto-deploys (workflow_run): always "patch" bump on main.
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
- name: Resolve Version - name: Resolve Version
id: version id: version
run: | run: |
set -euo pipefail set -euo pipefail
# Determine bump type (auto-deploy → patch; manual → user choice) # 1. Check VERSION exists
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
# Read current version
if [ ! -f VERSION ]; then if [ ! -f VERSION ]; then
echo "❌ VERSION file not found" echo "❌ VERSION file not found"
exit 1 exit 1
fi fi
CURRENT=$(cat VERSION | tr -d '[:space:]') # 2. Read and validate semver format
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then VERSION=$(cat VERSION | tr -d '[:space:]')
echo "❌ Invalid semver in VERSION: '$CURRENT'" if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$VERSION'"
exit 1 exit 1
fi fi
MAJOR=$(echo "$CURRENT" | cut -d. -f1) # 3. Log version, git ref, and describe
MINOR=$(echo "$CURRENT" | cut -d. -f2) GIT_REF=$(git rev-parse --short HEAD)
PATCH=$(echo "$CURRENT" | cut -d. -f3) GIT_DESCRIBE=$(git describe --always --dirty)
case "$BUMP_TYPE" in echo "📦 Deploy version: v${VERSION}"
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;; echo "🔖 Git ref: ${GIT_REF}"
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;; echo "🏷️ Git describe: ${GIT_DESCRIBE}"
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
esac
# Determine git ref — auto-deploy always uses main # 4. Set outputs for downstream steps
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}" echo "version=${VERSION}" >> "$GITEA_OUTPUT"
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 "mutated_main=false" >> "$GITEA_OUTPUT"
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
fi
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# Step 4: Build .env from secrets (SAFE) # Step 4: Build .env from secrets (SAFE)
@@ -234,22 +203,24 @@ jobs:
docker run --rm \ docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \ -v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-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
trap 'rm -f /tmp/nexus-deploy-env' EXIT
cat > /tmp/nexus-deploy-env
if [ -n '${SERVICE_ARG}' ]; then if [ -n '${SERVICE_ARG}' ]; then
echo '🚀 Deploying service: ${SERVICE_ARG}' echo '🚀 Deploying service: ${SERVICE_ARG}'
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build ${BUILD_ARGS} ${SERVICE_ARG} docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate ${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 --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build ${BUILD_ARGS} docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
fi fi
" " < "${ENV_TMPFILE}"
echo "✅ Docker compose up completed" echo "✅ Docker compose up completed"
@@ -332,17 +303,14 @@ jobs:
if: always() if: always()
run: | run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}" 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 "═══════════════════════════════════════" echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary" echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════" echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}" echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}" 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 " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
echo " Trigger: ${TRIGGER}" echo " Trigger: ${TRIGGER}"
echo " Bump type: ${VERSION_BUMP}"
echo " Actor: @${{ gitea.actor }}" echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}" echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════" echo "═══════════════════════════════════════"
+22 -19
View File
@@ -15,10 +15,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
- ASP.NET Core 10 REST API (Minimal API pattern) - ASP.NET Core 10 REST API (Minimal API pattern)
- Entity Framework Core and PostgreSQL - Entity Framework Core and PostgreSQL
- JWT owner authentication with rotating refresh sessions - JWT owner authentication with rotating refresh sessions
- `IAgentRuntime` abstraction with an OpenClaw adapter - `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
- `IModelProvider` abstractions for Ollama and NVIDIA
- Responsive dark-mode operations dashboard - Responsive dark-mode operations dashboard
- Container-only entry point on `127.0.0.1:18880` - Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
## Local/container start ## Local/container start
@@ -31,12 +30,11 @@ curl http://127.0.0.1:18880/health
``` ```
On an empty database the API creates exactly one owner from `OWNER_EMAIL`, On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14 `OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
characters. Existing databases are never overwritten by the bootstrap process. characters. Existing databases are never overwritten by the bootstrap process.
The web service is loopback-only. Public reverse-proxy activation for The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
`nexus.noveria.net` remains a separate infrastructure change and must terminate Health checks, rate limiting, and security headers are active.
TLS before forwarding to port `18880`.
## Workspace mounts ## Workspace mounts
@@ -45,12 +43,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
| Host path | Container mount | | Host path | Container mount |
|---|---| |---|---|
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
## Frontend architecture ## Frontend architecture
@@ -283,11 +281,16 @@ Backlog → Blocked → In progress / Done
provider key. Conversation IDs are stable per browser and Iris is the default provider key. Conversation IDs are stable per browser and Iris is the default
agent target. agent target.
The configured model-routing policy is: The configured model-routing policy routes through the OpenClaw Gateway only.
Ollama and NVIDIA providers have been removed. Currently active models:
1. `qwen3:4b` through Ollama for routine and monitoring work | Agent | Model |
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work |-------|-------|
3. `gpt-5.5` through OpenClaw for strategic and critical review | Iris | `openai/gpt-5.4` |
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
The Settings module reports runtime and provider state without exposing The Settings module reports runtime and provider state without exposing
credentials. credentials.
@@ -316,7 +319,7 @@ Deployment can happen automatically or manually:
#### Manual Deploy (`workflow_dispatch`) #### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions 1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
2. Chooses version bump type: patch (default) / minor / major 2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref 3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys 4. Workflow bumps VERSION, creates git tag, builds and deploys
@@ -332,7 +335,7 @@ Deployment can happen automatically or manually:
#### Database Backup (`workflow_dispatch`) #### Database Backup (`workflow_dispatch`)
1. DevOps triggers `Database Backup` in Gitea Actions 1. DevOps triggers `Database Backup` in Gitea Actions
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`) 2. Optionally also copies backup to a host path (`/home/projekte_bao/backups`)
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact 3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
4. Artifacts are retained for 90 days (configurable) 4. Artifacts are retained for 90 days (configurable)
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`) 5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
+1 -1
View File
@@ -1 +1 @@
0.2.54 0.2.56
+19
View File
@@ -92,9 +92,28 @@ public class AgentsController(
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." });
try
{
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct); var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null return result is null
? Results.BadRequest(new { error = "Invalid filename or path." }) ? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt }); : Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
} }
catch (UnauthorizedAccessException ex)
{
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "Permission denied",
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
statusCode: StatusCodes.Status500InternalServerError);
}
catch (IOException ex)
{
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "File write error",
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
statusCode: StatusCodes.Status500InternalServerError);
}
}
} }
+6
View File
@@ -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)
{ {
@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Helpers;
using Nexus.Api.Middleware;
using Nexus.Api.Services;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for configuring the Nexus application pipeline and startup.
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
/// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app)
{
var configuration = app.Configuration;
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? PasswordHelper.GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
}
/// <summary>
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
/// </summary>
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseForwardedHeaders();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (env.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
return app;
}
}
@@ -0,0 +1,214 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for registering Nexus application services in the DI container.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures JWT authentication, authorization, and antiforgery.
/// </summary>
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
{
var jwtKey = configuration["Jwt:Key"];
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
services.AddAuthorization();
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
return services;
}
/// <summary>
/// Configures rate limiting policies (auth and agents).
/// </summary>
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
return services;
}
/// <summary>
/// Configures forwarded headers for reverse proxy scenarios.
/// </summary>
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
return services;
}
/// <summary>
/// Configures Swagger and JSON serialization options.
/// </summary>
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
return services;
}
/// <summary>
/// Registers the Entity Framework Core DbContext with Npgsql.
/// </summary>
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
return services;
}
/// <summary>
/// Registers typed and named HTTP clients for OpenClaw integration.
/// </summary>
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
return services;
}
/// <summary>
/// Registers application domain services (transient, scoped, singleton).
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{
services.AddTransient<ModelRoutingService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAgentService, AgentService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IProjectService, ProjectService>();
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IOperationsService, OperationsService>();
services.AddScoped<ITeamService, TeamService>();
services.AddSingleton<IAgentConfigService, AgentConfigService>();
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddScoped<ICalendarService, CalendarService>();
return services;
}
/// <summary>
/// Registers data repositories.
/// </summary>
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IActivityRepository, ActivityRepository>();
return services;
}
/// <summary>
/// Configures health checks (PostgreSQL connectivity and runtime status).
/// </summary>
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
namespace Nexus.Api.Helpers;
/// <summary>
/// Helper methods for password generation and name construction.
/// </summary>
public static class PasswordHelper
{
/// <summary>
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
/// </summary>
public static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
/// <summary>
/// Builds a human-readable display name from an email address.
/// </summary>
public static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
}
+14 -222
View File
@@ -1,234 +1,26 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Nexus.Api.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Middleware;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- JWT Configuration --- // --- Service Registration ---
var jwtKey = builder.Configuration["Jwt:Key"]; builder.Services.AddNexusAuth(builder.Configuration);
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; builder.Services.AddNexusRateLimiting();
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; builder.Services.AddNexusForwardedHeaders();
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) builder.Services.AddNexusSwagger();
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddNexusApplicationServices();
.AddJwtBearer(options => builder.Services.AddNexusRepositories();
{ builder.Services.AddNexusHealthChecks(builder.Configuration);
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
// --- Rate Limiting ---
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
// --- Forwarded Headers ---
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// --- Swagger & JSON ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// --- Database ---
builder.Services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
// --- HTTP Clients ---
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
// --- Application Services ---
builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>();
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 ---
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
// --- Health Checks ---
builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
// --- Controllers ---
builder.Services.AddControllers(); builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
// --- Database Migration & Owner Seeding --- // --- Database Migration & Seeding ---
await using (var scope = app.Services.CreateAsyncScope()) await app.EnsureDatabaseAsync();
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = builder.Configuration["Owner:Password"];
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseForwardedHeaders(); app.UseNexusPipeline(app.Environment);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
// --- Helpers ---
static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
+17 -1
View File
@@ -4,6 +4,12 @@ services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 64M
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-nexus} POSTGRES_DB: ${POSTGRES_DB:-nexus}
POSTGRES_USER: ${POSTGRES_USER:-nexus} POSTGRES_USER: ${POSTGRES_USER:-nexus}
@@ -28,6 +34,11 @@ services:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
restart_policy: restart_policy:
condition: on-failure condition: on-failure
delay: 5s delay: 5s
@@ -53,7 +64,7 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -80,6 +91,11 @@ services:
context: ./frontend context: ./frontend
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32M
restart_policy: restart_policy:
condition: on-failure condition: on-failure
delay: 5s delay: 5s
@@ -87,6 +87,12 @@ const statusColors: Record<string, string> = {
idle: 'var(--st-idle)', idle: 'var(--st-idle)',
block: 'var(--st-block)', block: 'var(--st-block)',
} }
function avatarLabel() {
if (props.agent.id === 'iris') return 'IR'
if (props.agent.name === 'Full-Stack Developer') return '</>'
return props.agent.name.slice(0, 2).toUpperCase()
}
</script> </script>
<template> <template>
@@ -99,7 +105,7 @@ const statusColors: Record<string, string> = {
<!-- Header --> <!-- Header -->
<div class="m-head"> <div class="m-head">
<div :class="['m-av', { iris: agent.id === 'iris' }]"> <div :class="['m-av', { iris: agent.id === 'iris' }]">
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }} {{ avatarLabel() }}
</div> </div>
<div style="flex:1; min-width:0"> <div style="flex:1; min-width:0">
<div class="m-name">{{ agent.name }}</div> <div class="m-name">{{ agent.name }}</div>
@@ -160,6 +166,16 @@ const statusColors: Record<string, string> = {
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div> <div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
</div> </div>
<div v-if="agent.activity.length" class="m-sec">
<h4>Agent Activity</h4>
<div class="m-activity">
<div v-for="(entry, index) in agent.activity" :key="index" class="m-activity-row">
<span class="m-activity-time">{{ entry.time }}</span>
<span class="m-activity-text">{{ entry.text }}</span>
</div>
</div>
</div>
<!-- Modell wählen --> <!-- Modell wählen -->
<div class="m-sec"> <div class="m-sec">
<h4>Modell wählen</h4> <h4>Modell wählen</h4>
@@ -519,6 +535,35 @@ const statusColors: Record<string, string> = {
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
.m-activity {
display: flex;
flex-direction: column;
gap: 10px;
}
.m-activity-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 10px;
align-items: start;
padding: 10px 12px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.m-activity-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
.m-activity-text {
font-size: 12px;
line-height: 1.45;
color: var(--tx-2);
}
/* ── Models ──────────────────────────────────── */ /* ── Models ──────────────────────────────────── */
.m-models { .m-models {
display: flex; display: flex;
@@ -33,7 +33,11 @@ defineEmits<{
{ entering } { entering }
]" ]"
:style="{ left: left + '%', top: top + '%' }" :style="{ left: left + '%', top: top + '%' }"
@click="$emit('select', agent.id)" tabindex="0"
role="button"
:aria-label="`${agent.name} öffnen`"
@keydown.enter.prevent="$emit('select', agent.id)"
@keydown.space.prevent="$emit('select', agent.id)"
> >
<div class="ncard"> <div class="ncard">
<!-- Header: Avatar + Name + Role + Status-Dot --> <!-- Header: Avatar + Name + Role + Status-Dot -->
@@ -19,6 +19,7 @@ defineProps<{
blockerCount: number blockerCount: number
todayCost: string todayCost: string
todayTokens: string todayTokens: string
blockerLabel?: string
}>() }>()
defineEmits<{ defineEmits<{
@@ -62,7 +63,7 @@ defineEmits<{
@click="$emit('blockerClick')" @click="$emit('blockerClick')"
> >
<span class="dot block"></span> <span class="dot block"></span>
{{ blockerCount }} Blocker {{ blockerLabel || `${blockerCount} Blocker` }}
</button> </button>
</div> </div>
</template> </template>
@@ -168,4 +169,24 @@ defineEmits<{
.blk:hover { .blk:hover {
background: rgba(251,113,133,.22); background: rgba(251,113,133,.22);
} }
@media (max-width: 767px) {
.alertbar {
flex-wrap: wrap;
gap: 8px;
padding: 10px;
}
.seg {
flex: 0 0 calc(50% - 4px);
}
.sep {
display: none;
}
.blk {
margin-left: 0;
}
}
</style> </style>
@@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout' import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons' import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue' import AgentNode from './AgentNode.vue'
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
const props = defineProps<{ const props = defineProps<{
agents: AgentNodeData[] agents: AgentNodeData[]
@@ -113,8 +114,8 @@ function renderEdges() {
} else { } else {
// Orchestration (Iris → Agent) // Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b) const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18 const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>` paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
} }
}) })
@@ -172,93 +173,19 @@ watch(
) )
/* ── Drag & Drop ──────────────────────────────── */ /* ── Drag & Drop ──────────────────────────────── */
const DRAG_THRESHOLD = 5 const {
onClick,
interface DragState { onClickCapture,
id: string onPointerDown,
startX: number onPointerMove,
startY: number onPointerUp,
ox: number } = useFlowCanvasInteractions({
oy: number flowRef,
moved: boolean renderEdges,
raf: number | null updatePositions: positions => emit('updatePositions', positions),
} selectAgent: id => emit('select', id),
getPositions: () => props.positions,
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 ── */ /* ── Keyboard handler for Enter key on buttons ── */
function handleReset() { function handleReset() {
@@ -271,6 +198,8 @@ function handleReset() {
<div <div
ref="flowRef" ref="flowRef"
class="flow" class="flow"
@click="onClick"
@click.capture="onClickCapture"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@pointermove="onPointerMove" @pointermove="onPointerMove"
@pointerup="onPointerUp" @pointerup="onPointerUp"
@@ -288,12 +217,12 @@ function handleReset() {
@click="handleReset" @click="handleReset"
> >
<span class="btn-icon" v-html="icons.flow || ''"></span> <span class="btn-icon" v-html="icons.flow || ''"></span>
Reset <span class="reset-label">Reset</span>
</button> </button>
<button class="add-btn" @click="emit('add')"> <button class="add-btn" @click="emit('add')" title="Agent hinzufügen">
<span class="btn-icon" v-html="icons.plus || ''"></span> <span class="btn-icon" v-html="icons.plus || ''"></span>
Agent hinzufügen <span class="add-label">Agent hinzufügen</span>
</button> </button>
</div> </div>
@@ -481,4 +410,28 @@ function handleReset() {
:deep(.node.dragging) { :deep(.node.dragging) {
cursor: grabbing; cursor: grabbing;
} }
@media (max-width: 767px) {
.add-label {
display: none;
}
.reset-label {
display: none;
}
.add-btn {
width: 34px;
padding: 0;
display: grid;
place-items: center;
}
.reset-btn {
width: 30px;
padding: 0;
display: grid;
place-items: center;
}
}
</style> </style>
@@ -251,6 +251,22 @@ watch(
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
@media (max-width: 767px) {
.iris-panel {
width: 100%;
flex: 0 0 auto;
max-height: 45vh;
}
.chat-scroll {
max-height: 30vh;
}
.expand-btn {
display: none;
}
}
/* ── Input ───────────────────────────────────── */ /* ── Input ───────────────────────────────────── */
.chat-in { .chat-in {
padding: 12px; padding: 12px;
@@ -146,4 +146,20 @@ function statusLabel(s: TaskItem['status']): string {
padding: 12px; padding: 12px;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 767px) {
.tstrip {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tstrip::-webkit-scrollbar {
display: none;
}
.tcard {
flex: 0 0 200px;
}
}
</style> </style>
@@ -16,6 +16,8 @@ export interface TaskItem {
priority: 'high' | 'medium' | 'low' priority: 'high' | 'medium' | 'low'
status: 'active' | 'pending' | 'blocked' status: 'active' | 'pending' | 'blocked'
progress: number // 0100 progress: number // 0100
detail?: string | null
source?: string
} }
/* ── Agent Detail Modal Types ─────────────────── */ /* ── Agent Detail Modal Types ─────────────────── */
@@ -26,6 +28,11 @@ export interface ThinkingItem {
ts: string ts: string
} }
export interface AgentActivityItem {
time: string
text: string
}
/** Dashboard view-model for an agent detail modal */ /** Dashboard view-model for an agent detail modal */
export interface AgentDetailData { export interface AgentDetailData {
id: string id: string
@@ -51,5 +58,6 @@ export interface AgentDetailData {
lastActive: string lastActive: string
activeTaskCount: number activeTaskCount: number
thinking: ThinkingItem[] thinking: ThinkingItem[]
activity: AgentActivityItem[]
availableModels: { id: string; alias: string }[] availableModels: { id: string; alias: string }[]
} }
@@ -0,0 +1,135 @@
import { ref } from 'vue'
const DRAG_THRESHOLD = 5
const CLICK_SUPPRESSION_MS = 400
export interface FlowPosition {
x: number
y: number
}
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
interface UseFlowCanvasInteractionsOptions {
flowRef: { value: HTMLElement | null }
renderEdges: () => void
updatePositions: (positions: Record<string, FlowPosition>) => void
selectAgent: (id: string) => void
getPositions: () => Record<string, FlowPosition>
}
function findNode(target: EventTarget | null) {
return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null
}
export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) {
const drag = ref<DragState | null>(null)
const suppressClickUntil = ref(0)
function onPointerDown(e: PointerEvent) {
const node = findNode(e.target)
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag.value = {
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.value) return
const currentDrag = drag.value
const dist = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
if (!currentDrag.moved && dist < DRAG_THRESHOLD) return
if (!currentDrag.moved) {
currentDrag.moved = true
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = options.flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - currentDrag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - currentDrag.oy - fr.top) / fr.height) * 100))
const node = flow.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
options.updatePositions({
...options.getPositions(),
[currentDrag.id]: { x, y },
})
if (!currentDrag.raf) {
currentDrag.raf = requestAnimationFrame(() => {
options.renderEdges()
if (drag.value) drag.value.raf = null
})
}
}
function onPointerUp(e: PointerEvent) {
if (!drag.value) return
const currentDrag = drag.value
const endDistance = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
const wasDragged = currentDrag.moved || endDistance >= DRAG_THRESHOLD
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (wasDragged) {
suppressClickUntil.value = performance.now() + CLICK_SUPPRESSION_MS
}
drag.value = null
}
function onClick(e: MouseEvent) {
const node = findNode(e.target)
if (!node) return
if (performance.now() < suppressClickUntil.value) return
const id = node.dataset.id
if (id) options.selectAgent(id)
}
function onClickCapture(e: MouseEvent) {
if (performance.now() >= suppressClickUntil.value) return
if (!findNode(e.target)) return
e.preventDefault()
e.stopPropagation()
}
return {
onClick,
onClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
}
}
+58 -1
View File
@@ -6,6 +6,14 @@ import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons' import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons' import type { NavGroupDef } from '../../composables/icons'
defineProps<{
mobileOpen?: boolean
}>()
defineEmits<{
close: []
}>()
import NavGroup from './NavGroup.vue' import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format' import { initials } from '../../utils/format'
@@ -63,7 +71,8 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
</script> </script>
<template> <template>
<aside class="sidebar"> <aside :class="['sidebar', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<!-- Brand --> <!-- Brand -->
<div class="side-top"> <div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div> <div class="brand-mark" v-html="icons.command || ''"></div>
@@ -171,6 +180,54 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
background: rgba(124,108,255,.06); background: rgba(124,108,255,.06);
} }
.sidebar-close {
display: none;
}
@media (max-width: 767px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 18px;
right: 12px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
z-index: 1;
}
.sidebar-close:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.sidebar-close :deep(svg) {
width: 18px;
height: 18px;
}
}
.avatar { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
+72 -3
View File
@@ -3,11 +3,19 @@ import { icons } from '../../composables/icons'
defineProps<{ defineProps<{
connected?: boolean connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>() }>()
</script> </script>
<template> <template>
<header class="topbar"> <header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- Search --> <!-- Search -->
<div class="search"> <div class="search">
<span class="search-icon" v-html="icons.search || ''"></span> <span class="search-icon" v-html="icons.search || ''"></span>
@@ -20,13 +28,13 @@ defineProps<{
<!-- Status Pill --> <!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']"> <span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span> <span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'OpenClaw verbunden' : 'Preview' }} {{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span> </span>
<!-- Ask Iris Button --> <!-- Ask Iris Button -->
<button class="btn btn-primary"> <button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span> <span class="btn-icon" v-html="icons.spark || ''"></span>
Ask Iris <span class="ask-label">Ask Iris</span>
</button> </button>
</header> </header>
</template> </template>
@@ -138,4 +146,65 @@ defineProps<{
height: 15px; height: 15px;
} }
.hamburger {
display: none;
}
@media (max-width: 767px) {
.topbar {
padding: 0 14px;
}
.search {
flex: 1;
max-width: none;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
flex: 0 0 auto;
}
.hamburger:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.hamburger :deep(svg) {
width: 20px;
height: 20px;
}
.pill {
display: none;
}
.ask-iris-btn {
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
border-radius: 9px;
flex: 0 0 auto;
}
.ask-label {
display: none;
}
.ask-iris-btn .btn-icon {
display: flex;
}
}
</style> </style>
@@ -0,0 +1,86 @@
import { ref } from 'vue'
import { extraAgentPool } from './useFlowLayout'
import type { AgentNodeData } from './useFlowLayout'
interface FlowBoardAgentStore {
agents: AgentNodeData[]
models: Array<{ id: string; alias: string }>
changeModel: (agentId: string, modelId: string) => void
selectAgent: (id: string | null) => void
}
interface FlowBoardChatStore {
sendMessage: (text: string) => void
}
const STORAGE_KEY = 'nexus-flow-positions'
function readStoredPositions() {
if (typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) as Record<string, { x: number; y: number }> : {}
} catch {
return {}
}
}
export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) {
const agentPositions = ref<Record<string, { x: number; y: number }>>(readStoredPositions())
const enteringIds = ref<string[]>([])
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
function selectAgent(id: string) {
agentStore.selectAgent(id)
}
function closeAgent() {
agentStore.selectAgent(null)
}
function changeModel(agentId: string, modelAlias: string) {
const model = agentStore.models.find(m => m.alias === modelAlias)
const modelId = model?.id ?? modelAlias
agentStore.changeModel(agentId, modelId)
}
function addAgent() {
const next = localAgentPool.value.shift()
if (!next) return
enteringIds.value = [...enteringIds.value, next.id]
agentStore.agents.push(next)
window.setTimeout(() => {
enteringIds.value = enteringIds.value.filter(id => id !== next.id)
}, 600)
}
function resetLayout() {
agentPositions.value = {}
if (typeof window !== 'undefined') window.localStorage.removeItem(STORAGE_KEY)
}
function updatePositions(positions: Record<string, { x: number; y: number }>) {
agentPositions.value = { ...positions }
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(agentPositions.value))
}
}
function sendChatMessage(text: string) {
chatStore.sendMessage(text)
}
return {
addAgent,
agentPositions,
changeModel,
closeAgent,
enteringIds,
resetLayout,
selectAgent,
sendChatMessage,
updatePositions,
}
}
+46 -4
View File
@@ -3,22 +3,46 @@
* NexusLayout — V2 Dashboard Shell * NexusLayout — V2 Dashboard Shell
* Flex row, 100vh, overflow hidden. * Flex row, 100vh, overflow hidden.
* Sidebar (248px) + Main (flex:1, flex-column) * Sidebar (248px) + Main (flex:1, flex-column)
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
*/ */
import { ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useAgentStore } from '../stores/agents' import { useDashboardStore } from '../stores/dashboard'
import GalaxyBackground from '../components/background/GalaxyBackground.vue' import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue' import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue' import Topbar from '../components/layout/Topbar.vue'
const agentStore = useAgentStore() const dashboardStore = useDashboardStore()
/* ── Mobile Sidebar State ───────────────────────── */
const mobileMenuOpen = ref(false)
function closeMobileMenu() {
mobileMenuOpen.value = false
}
</script> </script>
<template> <template>
<div class="nexus-layout"> <div class="nexus-layout">
<GalaxyBackground /> <GalaxyBackground />
<Sidebar /> <Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop -->
<div
v-if="mobileMenuOpen"
class="mobile-backdrop"
@click="closeMobileMenu"
></div>
<main class="nexus-main"> <main class="nexus-main">
<Topbar :connected="agentStore.isConnected" /> <Topbar
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content"> <div class="nexus-content">
<RouterView /> <RouterView />
</div> </div>
@@ -49,4 +73,22 @@ const agentStore = useAgentStore()
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
.mobile-backdrop {
display: none;
}
@media (max-width: 767px) {
.nexus-main {
width: 100%;
}
.mobile-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 99;
background: rgba(0, 0, 0, 0.5);
}
}
</style> </style>
+27 -2
View File
@@ -11,7 +11,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiFetch } from '../services/api' import { apiFetch } from '../services/api'
import type { AgentNodeData } from '../composables/useFlowLayout' import type { AgentNodeData } from '../composables/useFlowLayout'
import type { AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types' import type { AgentActivityItem, AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */ /* ── API Response Shapes ──────────────────────────── */
@@ -40,6 +40,11 @@ interface ModelOption {
provider: string provider: string
} }
interface AgentActivityEntry {
time: string
text: string
}
/* ── Status Mapping ───────────────────────────────── */ /* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] { function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
@@ -148,6 +153,7 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv', lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
activeTaskCount: data.task ? 1 : 0, activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data), thinking: buildThinkingItems(data),
activity: [],
availableModels: models, availableModels: models,
} }
} }
@@ -159,6 +165,7 @@ export const useAgentStore = defineStore('agents', {
loading: false, loading: false,
error: null as string | null, error: null as string | null,
selectedAgentId: null as string | null, selectedAgentId: null as string | null,
activityByAgentId: {} as Record<string, AgentActivityItem[]>,
refreshInterval: null as ReturnType<typeof setInterval> | null, refreshInterval: null as ReturnType<typeof setInterval> | null,
isConnected: false, isConnected: false,
}), }),
@@ -179,7 +186,10 @@ export const useAgentStore = defineStore('agents', {
if (!state.selectedAgentId) return null if (!state.selectedAgentId) return null
const data = state.agents.find(a => a.id === state.selectedAgentId) const data = state.agents.find(a => a.id === state.selectedAgentId)
if (!data) return null if (!data) return null
return buildAgentDetail(data, state.models) return {
...buildAgentDetail(data, state.models),
activity: state.activityByAgentId[data.id] ?? [],
}
}, },
/** Is the modal open? */ /** Is the modal open? */
@@ -249,9 +259,24 @@ export const useAgentStore = defineStore('agents', {
} }
}, },
async fetchAgentActivity(agentId: string) {
try {
const res = await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/activity?limit=5`)
if (!res.ok) return
const data: AgentActivityEntry[] = await res.json()
this.activityByAgentId[agentId] = data.map(entry => ({
time: entry.time,
text: entry.text,
}))
} catch (err) {
console.warn('[AgentStore] fetchAgentActivity failed', err)
}
},
/* ── Selection ───────────────────────────────── */ /* ── Selection ───────────────────────────────── */
selectAgent(id: string | null) { selectAgent(id: string | null) {
this.selectedAgentId = id this.selectedAgentId = id
if (id) void this.fetchAgentActivity(id)
}, },
/* ── Polling ─────────────────────────────────── */ /* ── Polling ─────────────────────────────────── */
+110
View File
@@ -0,0 +1,110 @@
import { defineStore } from 'pinia'
import { apiFetch } from '../services/api'
interface DashboardStatusDto {
gatewayOk: boolean
irisStatus: string
activeAgents: number
pendingTasks: number
}
interface FeedEntryDto {
agent: string
action: string
timestamp: string
time: string
agentId?: string | null
type?: string | null
}
interface QueueItemDto {
id: string
name: string
status: string
priority: string
source: string
waitTime: string
}
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
status: null as DashboardStatusDto | null,
operations: [] as FeedEntryDto[],
queue: [] as QueueItemDto[],
loading: false,
error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
}),
getters: {
isGatewayConnected: state => state.status?.gatewayOk ?? false,
irisStatusLabel: state => state.status?.irisStatus ?? 'Offline',
},
actions: {
async fetchStatus() {
try {
const res = await apiFetch('/api/dashboard/status')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.status = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchStatus failed', err)
this.status = null
}
},
async fetchOperations() {
try {
const res = await apiFetch('/api/dashboard/operations?limit=20')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.operations = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchOperations failed', err)
this.operations = []
}
},
async fetchQueue() {
try {
const res = await apiFetch('/api/dashboard/queue')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
this.queue = await res.json()
} catch (err) {
console.warn('[DashboardStore] fetchQueue failed', err)
this.queue = []
}
},
async refresh() {
this.loading = true
try {
await Promise.all([
this.fetchStatus(),
this.fetchOperations(),
this.fetchQueue(),
])
this.error = null
} catch (err) {
console.warn('[DashboardStore] refresh failed', err)
this.error = 'Dashboard metadata could not be loaded'
} finally {
this.loading = false
}
},
startPolling() {
if (this.refreshInterval) return
this.refresh()
this.refreshInterval = setInterval(() => {
this.refresh()
}, 30000)
},
stopPolling() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval)
this.refreshInterval = null
}
},
},
})
+2
View File
@@ -56,6 +56,8 @@ function mapTask(t: DashboardTaskDto): TaskItem {
priority: mapPriority(t.priority), priority: mapPriority(t.priority),
status: mapState(t.state), status: mapState(t.state),
progress: mapProgress(t.state), progress: mapProgress(t.state),
detail: t.detail,
source: t.source,
} }
} }
+47 -56
View File
@@ -12,84 +12,62 @@
* *
* Polling startet bei Mount, stoppt bei Unmount. * Polling startet bei Mount, stoppt bei Unmount.
*/ */
import { ref, onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useAgentStore } from '../../stores/agents' import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat' import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue' import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue' import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import IrisChat from '../../components/dashboard/v2/IrisChat.vue' import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue' import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue' import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
import type { AgentNodeData } from '../../composables/useFlowLayout' import { useFlowBoardState } from '../../composables/useFlowBoardState'
import { extraAgentPool } from '../../composables/useFlowLayout'
/* ── Stores ──────────────────────────────────────── */ /* ── Stores ──────────────────────────────────────── */
const agentStore = useAgentStore() const agentStore = useAgentStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const dashboardStore = useDashboardStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
/* ── Agent Layout State ───────────────────────────── */ const {
const agentPositions = ref<Record<string, { x: number; y: number }>>({}) addAgent,
const enteringIds = ref<string[]>([]) agentPositions,
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool]) changeModel,
closeAgent,
/* ── Event Handlers ───────────────────────────────── */ enteringIds,
resetLayout,
function handleSelect(id: string) { selectAgent,
agentStore.selectAgent(id) sendChatMessage,
} updatePositions,
} = useFlowBoardState(agentStore, chatStore)
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() { function handleBlockerClick() {
console.log('[FlowBoard] blocker clicked') console.log('[FlowBoard] blocker clicked')
} }
function handleChatSend(text: string) { function blockerLabel() {
chatStore.sendMessage(text) const blockedTask = taskStore.taskList.find(task => task.status === 'blocked')
if (!blockedTask) return undefined
return `${taskStore.taskList.filter(task => task.status === 'blocked').length} Blocker — ${blockedTask.title}`
}
function blockerCount() {
return taskStore.taskList.filter(task => task.status === 'blocked').length
} }
/* ── Lifecycle ────────────────────────────────────── */ /* ── Lifecycle ────────────────────────────────────── */
onMounted(() => { onMounted(() => {
agentStore.startPolling() agentStore.startPolling()
chatStore.startPolling() chatStore.startPolling()
dashboardStore.startPolling()
taskStore.startPolling() taskStore.startPolling()
}) })
onUnmounted(() => { onUnmounted(() => {
agentStore.stopPolling() agentStore.stopPolling()
chatStore.stopPolling() chatStore.stopPolling()
dashboardStore.stopPolling()
taskStore.stopPolling() taskStore.stopPolling()
}) })
</script> </script>
@@ -104,9 +82,10 @@ onUnmounted(() => {
:active-count="agentStore.activeCount" :active-count="agentStore.activeCount"
:think-count="agentStore.thinkCount" :think-count="agentStore.thinkCount"
:idle-count="agentStore.idleCount" :idle-count="agentStore.idleCount"
:blocker-count="agentStore.blockerCount" :blocker-count="blockerCount()"
:today-cost="agentStore.todayCost" :today-cost="agentStore.todayCost"
:today-tokens="agentStore.todayTokens" :today-tokens="agentStore.todayTokens"
:blocker-label="blockerLabel()"
@blocker-click="handleBlockerClick" @blocker-click="handleBlockerClick"
/> />
@@ -114,10 +93,10 @@ onUnmounted(() => {
:agents="agentStore.agentList" :agents="agentStore.agentList"
:positions="agentPositions" :positions="agentPositions"
:entering-ids="enteringIds" :entering-ids="enteringIds"
@select="handleSelect" @select="selectAgent"
@add="handleAdd" @add="addAgent"
@reset-layout="handleResetLayout" @reset-layout="resetLayout"
@update-positions="handleUpdatePositions" @update-positions="updatePositions"
/> />
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" /> <TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
@@ -128,7 +107,7 @@ onUnmounted(() => {
:messages="chatStore.messageList" :messages="chatStore.messageList"
:is-thinking="chatStore.isThinking" :is-thinking="chatStore.isThinking"
:error="chatStore.error" :error="chatStore.error"
@send="handleChatSend" @send="sendChatMessage"
/> />
</div> </div>
@@ -137,9 +116,9 @@ onUnmounted(() => {
v-if="agentStore.modalOpen && agentStore.selectedAgent" v-if="agentStore.modalOpen && agentStore.selectedAgent"
:agent="agentStore.selectedAgent" :agent="agentStore.selectedAgent"
:agent-order="agentStore.agentOrder" :agent-order="agentStore.agentOrder"
@close="handleCloseModal" @close="closeAgent"
@select="handleSelect" @select="selectAgent"
@change-model="handleChangeModel" @change-model="changeModel"
/> />
</div> </div>
</template> </template>
@@ -177,4 +156,16 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 767px) {
.board-body {
flex-direction: column;
padding: 8px;
gap: 10px;
}
.stage {
flex: 1;
}
}
</style> </style>
+35 -1
View File
@@ -25,7 +25,41 @@ docker compose ps
echo "" echo ""
echo "[4/4] Verifikation..." echo "[4/4] Verifikation..."
curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden" check_code() {
local path="$1"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:18880${path}"
}
HEALTH_CODE=$(check_code /health)
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
if [ "$HEALTH_CODE" = "200" ] && [ "$DASHBOARD_CODE" != "200" ]; then
WEB_CID="$(docker compose ps -q web || true)"
if [ -n "$WEB_CID" ]; then
WEB_STATE="$(docker inspect -f '{{.State.Status}}' "$WEB_CID" 2>/dev/null || true)"
if [ "$WEB_STATE" = "created" ]; then
echo " ️ API healthy, aber web noch im Status 'created' — starte web nach"
docker compose up -d web
sleep 2
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
fi
fi
fi
echo " /health -> ${HEALTH_CODE}"
echo " /dashboard -> ${DASHBOARD_CODE}"
echo " /api/v1/operations/snapshot -> ${OPS_CODE}"
if [ "$HEALTH_CODE" != "200" ] || [ "$DASHBOARD_CODE" != "200" ] || [ "$OPS_CODE" != "401" ]; then
echo " ❌ Verifikation fehlgeschlagen"
exit 1
fi
echo " ✅ Health-Check bestanden"
echo " ✅ Dashboard erreichbar"
echo " ✅ Operations API fordert Auth an"
echo "" echo ""
echo "=== Deployment abgeschlossen ===" echo "=== Deployment abgeschlossen ==="
+55
View File
@@ -0,0 +1,55 @@
# ==============================================================================
# Noveria.net Landingpage — Nginx Server Block
# ==============================================================================
# Diese Config gehört in den Host-Nginx unter /etc/nginx/sites-available/
# und muss via Symlink nach /etc/nginx/sites-enabled/ aktiviert werden.
#
# WICHTIG: Falls "noveria.net" oder "www.noveria.net" bereits in einem anderen
# Serverblock (z.B. dem nexus.noveria.net-Block) als server_name auftaucht,
# muss es dort entfernt werden, sonst schlägt nginx -t fehl.
# ==============================================================================
server {
listen 443 ssl http2;
server_name noveria.net www.noveria.net;
# SSL (gleiche Zertifikate wie nexus)
ssl_certificate /etc/letsencrypt/live/noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/noveria.net/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
# Security Header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
location / {
proxy_pass http://127.0.0.1:18881;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name noveria.net www.noveria.net;
return 301 https://$host$request_uri;
}
# ==============================================================================
# Diagnose-Kommandos (auf dem Host auszuführen, nicht im Container!)
# ==============================================================================
# 1. Prüfen ob noveria.net bereits in bestehender Config referenziert wird
# grep -rn "noveria.net" /etc/nginx/sites-available/
# grep -rn "www.noveria.net" /etc/nginx/sites-available/
#
# 2. Config testen nach Änderung
# nginx -t
#
# 3. Nginx neuladen
# systemctl reload nginx
# ==============================================================================
+8 -1
View File
@@ -1,7 +1,14 @@
# Changelog # Changelog
> Letzte Aktualisierung: 2026-06-09 > Letzte Aktualisierung: 2026-06-16
- 2026-06-16: Program.cs refactored: DI extrahiert in `Extensions/ServiceCollectionExtensions.cs`, Middleware in `Extensions/ApplicationBuilderExtensions.cs`, Helpers in `Helpers/PasswordHelper.cs`. Program.cs von ~200 auf 26 Zeilen reduziert.
- 2026-06-16: Nexus auf Netcup (mission-control) redeployed. Neuer Stack unter `/home/projekte_bao/nexus/`. Traefik reverse-proxy mit Let's Encrypt TLS. Volume und Netzwerk-Namen bereinigt (postgres-data, internal). Compose-Pfade von Ionos auf Netcup migriert.
- 2026-06-16: Ollama-Modelle (2.4 GB) und alle ungenutzten Runtime-Dateien entfernt. Codex-Logs bereinigt (~342 MB). Workspace-Aufräumung (~3.1 GB gesamt).
- 2026-06-16: Modell-Healthcheck nach Migration: Alle 7 aktiven Modelle laufen (DeepSeek Flash/Pro, GPT-5.4/5.5, Claude Sonnet/Opus via CLI-Backend). Ollama und NVIDIA endgültig deaktiviert.
- 2026-06-14: Server-Migration von Ionos (85.214.180.137) nach Netcup (178.105.105.106). Hostname: mission-control. Migration: OpenClaw, Gitea, Nexus-Volume.
- 2026-06-12: Agent-Workspaces finalisiert. Iris als Chief of Staff mit Approval-Autonomie. Bidirektionale Kommunikation etabliert.
- 2026-06-11: Gitea CI/CD-Pipeline aktiv. Agent-Repo-Permissions mit API-Tokens (statt Passwort-Auth). DevOps-Token für Deploy-Trigger.
- 2026-06-09: Phase 2 Backend + Frontend implementiert: Memory-Browser (Liste, Detail, Volltextsuche), Docs-Browser (Kategorien, Filter), Team-Org-Map (Karten + Kommunikationsmatrix), Security-Center (Auth, Tokens, Rate-Limit, Cookies). Backend-Build 0 Errors, Frontend-Build (vue-tsc + vite) 0 Errors. - 2026-06-09: Phase 2 Backend + Frontend implementiert: Memory-Browser (Liste, Detail, Volltextsuche), Docs-Browser (Kategorien, Filter), Team-Org-Map (Karten + Kommunikationsmatrix), Security-Center (Auth, Tokens, Rate-Limit, Cookies). Backend-Build 0 Errors, Frontend-Build (vue-tsc + vite) 0 Errors.
- 2026-06-09: Researcher-Agent zum Team hinzugefügt (DeepSeek V4 Pro, Nur-Lese-Rechte, YouTube-Vision-Skill). Kommunikationsmatrix erweitert (Researcher↔Iris only). - 2026-06-09: Researcher-Agent zum Team hinzugefügt (DeepSeek V4 Pro, Nur-Lese-Rechte, YouTube-Vision-Skill). Kommunikationsmatrix erweitert (Researcher↔Iris only).
- 2026-06-09: Phase 1 komplettiert: Live-Agentinventar, Dashboard-Metriken, Approval-Workflow, Healthchecks (PostgreSQL + Runtime), Tests (Backend 3/3 + Frontend 2/2). - 2026-06-09: Phase 1 komplettiert: Live-Agentinventar, Dashboard-Metriken, Approval-Workflow, Healthchecks (PostgreSQL + Runtime), Tests (Backend 3/3 + Frontend 2/2).
+16
View File
@@ -185,9 +185,25 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
- Let's Encrypt TLS-Zertifikat aktiv - Let's Encrypt TLS-Zertifikat aktiv
- Nginx-Proxy → 127.0.0.1:18880 - Nginx-Proxy → 127.0.0.1:18880
## Incident-Hinweis (2026-06-14)
- Verifizierter Ausfallpfad: `api` konnte wegen DB-Passwort-Mismatch nicht healthy werden; dadurch blieb `web` per `depends_on: service_healthy` im Status `Created`.
- Nach einem isolierten API-Fix startet `web` nicht automatisch nach. Sicherer Minimalpfad:
1. `docker compose ps`
2. `curl http://127.0.0.1:18880/health`
3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
- Der manuelle Helper [`ops/deploy.sh`](/home/node/.openclaw/workspace/nexus/ops/deploy.sh) verifiziert deshalb jetzt nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
## Offene Arbeit ## Offene Arbeit
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter) - [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
- [ ] Docker-Logs und Container-Health-Monitoring einrichten - [ ] Docker-Logs und Container-Health-Monitoring einrichten
- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren - [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren
- [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern - [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern
### Deploy-Trigger-Actor (2026-06-14)
- Deploy-Trigger werden durch DevOps (nicht Iris) ausgelöst
- Git-Remote origin verwendet DevOps-Token → Gitea zeigt devops als Actor
- Workflow-Dispatch API-Calls mit DevOps-Token authentifizieren
+4 -3
View File
@@ -1,13 +1,14 @@
# Phase 1 MVP # Phase 1 MVP
> Letzte Aktualisierung: 2026-06-09 > Letzte Aktualisierung: 2026-06-16
> Fokus: Mission-Control-Board bereitstellen und Infrastruktur anschliessen > Status: ✅ Abgeschlossen
## Status ## Status
- Gesamtfortschritt: ca. 95 % - Gesamtfortschritt: 100 % ✅
- Produktiv live: ja (https://nexus.noveria.net) - Produktiv live: ja (https://nexus.noveria.net)
- Letzter Build: Backend + Frontend erfolgreich - Letzter Build: Backend + Frontend erfolgreich
- Ollama/NVIDIA entfernt, nur OpenClaw-Integration
## Prioritaet ## Prioritaet
+15 -12
View File
@@ -1,23 +1,26 @@
# Runtime und Routing # Runtime und Routing
> Letzte Aktualisierung: 2026-06-08 > Letzte Aktualisierung: 2026-06-16
## Aktive Modelle ## Aktive Modelle (7 von 8 konfiguriert)
| Priorität | Modell | Zweck | Provider | | Agent | Modell | Provider |
|-----------|--------|-------|----------| |-------|--------|----------|
| 1 | deepseek/deepseek-v4-flash | Programmer Agent | DeepSeek (über OpenClaw) | | Iris | `openai/gpt-5.4` | OpenAI (OAuth) |
| 2 | deepseek/deepseek-v4-pro | Reviewer Agent, Iris Fallback | DeepSeek (über OpenClaw) | | Programmer, Executor | `deepseek/deepseek-v4-flash` | DeepSeek (API-Key) |
| 3 | openai/gpt-5.3-chat-latest | Iris Hauptmodell | OpenAI (über OpenClaw) | | Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` | DeepSeek (API-Key) |
| — | `openai/gpt-5.5` | OpenAI (verfügbar) |
| — | `anthropic/claude-sonnet-4-6` | Anthropic (CLI-Backend) |
| — | `anthropic/claude-opus-4-6/4.8` | Anthropic (CLI-Backend) |
## Deaktiviert ## Entfernt / Deaktiviert
- **Ollama** (qwen3:4b): deaktiviert, funktioniert aktuell nicht. Wird später wieder aufgegriffen. - **Ollama** (qwen3:4b): komplett entfernt (2.4 GB Models gelöscht 16.06.)
- **NVIDIA** (moonshotai/kimi-k2.6): vollständig entfernt. - **NVIDIA** (moonshotai/kimi-k2.6): vollständig entfernt.
- **Kimi 2.6**: vollständig entfernt. - **IModelProvider-Abstraktion**: entfernt, nur noch `IAgentRuntime` mit OpenClaw-Adapter.
## Integration ## Integration
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime` - Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
- Keine direkten Provider-Registrierungen mehr im Backend (OllamaProvider, NvidiaProvider entfernt) - Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
- Model-Routing läuft zentral über OpenClaw Gateway - API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)