Compare commits
20 Commits
c052b07662
...
devin/1777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b3f27b0f | ||
|
|
94ee9a2ee5 | ||
|
|
96c32aed21 | ||
|
|
0b583cdd07 | ||
|
|
f14d63f14d | ||
|
|
08b5ea7c9a | ||
|
|
a63e8505fa | ||
| 450d0f32e0 | |||
|
|
c052302a19 | ||
| 274715d54c | |||
| cc10710558 | |||
|
|
b982e31c19 | ||
|
|
64b800c6cf | ||
| de97fd8ac9 | |||
|
|
59d57cb2fb | ||
| 99bbbccacb | |||
|
|
9a8affae9a | ||
|
|
039440672e | ||
|
|
445865e429 | ||
|
|
fa71f973a6 |
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
*.md
|
||||||
37
.env.example
Normal file
37
.env.example
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# FusionAGI Environment Configuration
|
||||||
|
# Copy to .env and configure for your deployment
|
||||||
|
|
||||||
|
# === API Authentication ===
|
||||||
|
# Set to require Bearer token auth on /v1/ routes. Leave empty for open access.
|
||||||
|
FUSIONAGI_API_KEY=
|
||||||
|
|
||||||
|
# === Rate Limiting ===
|
||||||
|
FUSIONAGI_RATE_LIMIT=120 # Requests per window
|
||||||
|
FUSIONAGI_RATE_WINDOW=60 # Window in seconds
|
||||||
|
|
||||||
|
# === LLM Providers ===
|
||||||
|
OPENAI_API_KEY= # For GPT-4o, Whisper STT
|
||||||
|
ANTHROPIC_API_KEY= # For Claude models
|
||||||
|
|
||||||
|
# === TTS / Voice ===
|
||||||
|
ELEVENLABS_API_KEY= # ElevenLabs TTS
|
||||||
|
AZURE_SPEECH_KEY= # Azure Cognitive Services STT/TTS
|
||||||
|
AZURE_SPEECH_REGION=eastus # Azure region
|
||||||
|
|
||||||
|
# === Database ===
|
||||||
|
DATABASE_URL=postgresql://fusionagi:fusionagi@localhost:5432/fusionagi
|
||||||
|
|
||||||
|
# === Redis (caching, pub/sub) ===
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# === GPU / TensorFlow ===
|
||||||
|
TF_CPP_MIN_LOG_LEVEL=2 # Suppress TF info logs
|
||||||
|
CUDA_VISIBLE_DEVICES=0 # GPU device index
|
||||||
|
|
||||||
|
# === Multi-tenant ===
|
||||||
|
FUSIONAGI_DEFAULT_TENANT=default # Default tenant ID for single-tenant mode
|
||||||
|
|
||||||
|
# === Monitoring ===
|
||||||
|
FUSIONAGI_METRICS_ENABLED=false # Enable Prometheus metrics at /metrics
|
||||||
|
FUSIONAGI_LOG_LEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
FUSIONAGI_LOG_FORMAT=json # Log format: json or text
|
||||||
78
.gitea/workflows/ci.yml
Normal file
78
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e ".[dev]"
|
||||||
|
- name: Ruff check
|
||||||
|
run: ruff check fusionagi/
|
||||||
|
- name: Mypy
|
||||||
|
run: mypy fusionagi/ --ignore-missing-imports
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -e ".[dev,api]"
|
||||||
|
- name: Run tests
|
||||||
|
run: pytest tests/ -q --tb=short
|
||||||
|
- name: Check test count
|
||||||
|
run: |
|
||||||
|
count=$(pytest tests/ -q --tb=no 2>&1 | grep -oP '^\d+(?= passed)')
|
||||||
|
echo "Tests passed: $count"
|
||||||
|
if [ "$count" -lt 290 ]; then
|
||||||
|
echo "ERROR: Expected at least 290 tests, got $count"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
migrations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Verify migrations
|
||||||
|
run: python -m migrations.migrate verify
|
||||||
|
|
||||||
|
helm:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Helm
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||||
|
- name: Lint Helm chart
|
||||||
|
run: helm lint k8s/
|
||||||
|
- name: Template validation
|
||||||
|
run: helm template fusionagi k8s/ --debug > /dev/null
|
||||||
|
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, test, migrations, helm]
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build Docker image
|
||||||
|
run: docker build -t fusionagi:latest .
|
||||||
|
- name: Verify image
|
||||||
|
run: docker run --rm fusionagi:latest python -c "import fusionagi; print('OK')"
|
||||||
59
Dockerfile
59
Dockerfile
@@ -1,12 +1,59 @@
|
|||||||
FROM python:3.12-slim
|
# ==============================================================================
|
||||||
|
# FusionAGI — Multi-stage production Dockerfile
|
||||||
|
# ==============================================================================
|
||||||
|
# Build stages:
|
||||||
|
# 1. builder — install deps + build wheel
|
||||||
|
# 2. runtime — slim image with only runtime deps
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t fusionagi .
|
||||||
|
# docker build --build-arg EXTRAS="api,gpu" -t fusionagi-gpu .
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# docker run -p 8000:8000 fusionagi
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ---- Stage 1: Builder ----
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# System deps for building
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends gcc && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY fusionagi/ fusionagi/
|
||||||
|
|
||||||
|
ARG EXTRAS="api"
|
||||||
|
RUN pip install --no-cache-dir --prefix=/install ".[${EXTRAS}]"
|
||||||
|
|
||||||
|
# ---- Stage 2: Runtime ----
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
LABEL maintainer="FusionAGI <info@fusionagi.dev>"
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/fusionagi/fusionagi"
|
||||||
|
LABEL org.opencontainers.image.description="FusionAGI Dvādaśa — 12-headed AGI orchestration"
|
||||||
|
|
||||||
|
# Copy installed packages from builder
|
||||||
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY fusionagi/ fusionagi/
|
||||||
|
|
||||||
COPY pyproject.toml .
|
# Non-root user
|
||||||
COPY fusionagi fusionagi
|
RUN useradd -r -s /bin/false fusionagi
|
||||||
RUN pip install --no-cache-dir -e ".[api]" && pip install uvicorn
|
USER fusionagi
|
||||||
COPY examples examples
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "fusionagi.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
CMD ["python", "-m", "uvicorn", "fusionagi.api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- FUSIONAGI_API_KEY=${FUSIONAGI_API_KEY:-}
|
||||||
|
- FUSIONAGI_RATE_LIMIT=${FUSIONAGI_RATE_LIMIT:-120}
|
||||||
|
- DATABASE_URL=postgresql://fusionagi:fusionagi@postgres:5432/fusionagi
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY:-}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/v1/admin/status"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://api:8000
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: fusionagi
|
||||||
|
POSTGRES_PASSWORD: fusionagi
|
||||||
|
POSTGRES_DB: fusionagi
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U fusionagi"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
29
docs/adr/001-advisory-governance.md
Normal file
29
docs/adr/001-advisory-governance.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# ADR-001: Advisory Governance Model
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
FusionAGI needed a governance model for its 12-headed AGI orchestrator. Traditional AI safety approaches use hard enforcement (blocking, filtering, rate limiting). The question was whether to enforce constraints rigidly or allow the system to learn from consequences.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
All governance constraints operate in **advisory mode** by default:
|
||||||
|
- Safety head reports observations rather than blocking
|
||||||
|
- File/HTTP tool restrictions log warnings but proceed
|
||||||
|
- Rate limiter logs exceedances but allows requests
|
||||||
|
- Manufacturing gate uses GovernanceMode.ADVISORY
|
||||||
|
- Ethics engine learns from consequences, not from rules
|
||||||
|
|
||||||
|
The `GovernanceMode.ENFORCING` option remains available for deployment contexts that require it.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The system learns faster because it experiences consequences of its choices
|
||||||
|
- Risk of harmful outputs is higher during the learning phase
|
||||||
|
- Full audit trail enables post-hoc analysis of every decision
|
||||||
|
- The ConsequenceEngine provides the primary feedback loop for ethical learning
|
||||||
|
- All advisory warnings are logged with trace IDs for accountability
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
1. **Hard enforcement** — Rejected: prevents learning, creates false sense of safety
|
||||||
|
2. **Hybrid (enforce critical, advise rest)** — Partially adopted: certain hardware safety limits (e.g., embodiment force limits) still log but don't clamp
|
||||||
|
3. **No governance** — Rejected: transparency and auditability are still required
|
||||||
39
docs/adr/002-twelve-head-architecture.md
Normal file
39
docs/adr/002-twelve-head-architecture.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# ADR-002: Twelve-Head (Dvādaśa) Architecture
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Multi-agent systems typically use 2-5 agents with fixed roles. FusionAGI needed a system that could analyze problems from many perspectives simultaneously while maintaining coherent output.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
The orchestrator decomposes every query across **12 specialized heads**:
|
||||||
|
|
||||||
|
| Head | Role |
|
||||||
|
|------|------|
|
||||||
|
| Logic | Logical reasoning and consistency |
|
||||||
|
| Research | Source evaluation and synthesis |
|
||||||
|
| Systems | Architecture and integration |
|
||||||
|
| Strategy | Long-term planning |
|
||||||
|
| Product | User experience and design |
|
||||||
|
| Security | Threat analysis |
|
||||||
|
| Safety | Risk observation (advisory) |
|
||||||
|
| Reliability | Fault tolerance |
|
||||||
|
| Cost | Resource optimization |
|
||||||
|
| Data | Statistical reasoning |
|
||||||
|
| DevEx | Developer experience |
|
||||||
|
| Witness | Audit and observation |
|
||||||
|
|
||||||
|
The Witness head is special: it observes but doesn't contribute to the consensus.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- Comprehensive analysis from 12 angles on every query
|
||||||
|
- Higher latency (12 parallel LLM calls) but better quality
|
||||||
|
- The InsightBus enables cross-head learning
|
||||||
|
- Each head has a unique color identity in the UI for visual distinction
|
||||||
|
- The consensus mechanism must handle disagreement gracefully
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
1. **3-5 heads** — Rejected: insufficient perspective diversity
|
||||||
|
2. **Dynamic head count** — Future consideration: some queries don't need all 12
|
||||||
|
3. **Hierarchical heads** — Rejected: flat structure promotes equal consideration
|
||||||
30
docs/adr/003-consequence-engine.md
Normal file
30
docs/adr/003-consequence-engine.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR-003: Consequence Engine for Ethical Learning
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
Traditional AI ethics systems use static rules (constitutional AI, RLHF reward models). FusionAGI needed a system that could learn ethical behavior from experience — understanding that every choice carries consequences and that risk/reward assessment improves with data.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Implemented a **ConsequenceEngine** that:
|
||||||
|
1. Records every choice the system makes (action + alternatives considered)
|
||||||
|
2. Estimates risk and reward before acting
|
||||||
|
3. Records actual outcomes after execution
|
||||||
|
4. Computes "surprise factor" (prediction error)
|
||||||
|
5. Feeds into AdaptiveEthics for lesson generation
|
||||||
|
6. Uses adaptive risk memory window that grows with experience
|
||||||
|
|
||||||
|
The weight system for ethical lessons is **unclamped** — extreme outcomes can push lesson weights below 0 (strong negative signal) or above 1.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- The system develops genuine experiential ethics rather than rule-following
|
||||||
|
- Early-stage behavior may be more exploratory (higher risk)
|
||||||
|
- All consequence records are persisted via PersistentLearningStore
|
||||||
|
- Cross-head learning via InsightBus amplifies ethical insights
|
||||||
|
- The SelfModel's values evolve based on consequence feedback
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
1. **RLHF-style reward model** — Rejected: requires human feedback loop, doesn't scale
|
||||||
|
2. **Constitutional AI** — Rejected: static rules, doesn't learn
|
||||||
|
3. **No ethics system** — Rejected: need accountability and learning signal
|
||||||
@@ -1,130 +1,88 @@
|
|||||||
# FusionAGI Architecture
|
# FusionAGI Architecture
|
||||||
|
|
||||||
High-level system components and data flow.
|
## Overview
|
||||||
|
|
||||||
## Component Overview
|
FusionAGI is a modular AGI orchestration framework built on the **Dvādaśa** (12-headed) architecture. Multiple specialized reasoning heads analyze each prompt independently, and a Witness agent synthesizes their outputs into a consensus response.
|
||||||
|
|
||||||
```mermaid
|
## Core Architecture
|
||||||
flowchart LR
|
|
||||||
subgraph core [Core]
|
|
||||||
Orch[Orchestrator]
|
|
||||||
EB[Event Bus]
|
|
||||||
SM[State Manager]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph agents [Agents]
|
```
|
||||||
Planner[Planner]
|
User Prompt
|
||||||
Reasoner[Reasoner]
|
│
|
||||||
Executor[Executor]
|
▼
|
||||||
Critic[Critic]
|
┌─────────────────────────────────────────┐
|
||||||
Heads[Heads + Witness]
|
│ Orchestrator (core/) │
|
||||||
end
|
│ Decompose → Fan-out → Synthesize │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
subgraph support [Supporting Systems]
|
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||||
Reasoning[Reasoning]
|
│ │Logic│ │Creat│ │Resrch│ │Safety│ ... │
|
||||||
Planning[Planning]
|
│ │Head │ │Head │ │Head │ │Head │ │
|
||||||
Memory[Memory]
|
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
|
||||||
Tools[Tools]
|
│ └───────┴───────┴───────┘ │
|
||||||
Gov[Governance]
|
│ Witness Agent │
|
||||||
end
|
│ (consensus synthesis) │
|
||||||
|
└──────────────┬──────────────────────────┘
|
||||||
Orch --> EB
|
│
|
||||||
Orch --> SM
|
┌──────────┼──────────┐
|
||||||
Orch --> Planner
|
▼ ▼ ▼
|
||||||
Orch --> Reasoner
|
┌────────┐ ┌────────┐ ┌────────┐
|
||||||
Orch --> Executor
|
│Advisory│ │Conseq. │ │Adaptive│
|
||||||
Orch --> Critic
|
│Governce│ │Engine │ │Ethics │
|
||||||
Orch --> Heads
|
└────────┘ └────────┘ └────────┘
|
||||||
Planner --> Planning
|
|
||||||
Reasoner --> Reasoning
|
|
||||||
Executor --> Tools
|
|
||||||
Executor --> Gov
|
|
||||||
Critic --> Memory
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Flow (Task Lifecycle)
|
## Module Layout
|
||||||
|
|
||||||
```mermaid
|
| Module | Responsibility |
|
||||||
flowchart TB
|
|---|---|
|
||||||
A[User submits task] --> B[Orchestrator]
|
| `core/` | Orchestrator, event bus, state manager, persistence |
|
||||||
B --> C[Planner: plan graph]
|
| `agents/` | HeadAgent, WitnessAgent, Planner, Critic, Reasoner |
|
||||||
C --> D[Reasoner: reason on steps]
|
| `adapters/` | LLM providers (OpenAI, TTS, STT), caching |
|
||||||
D --> E[Executor: run tools via Governance]
|
| `schemas/` | Pydantic models — Task, Message, Plan, etc. |
|
||||||
E --> F[State + Events drive next steps]
|
| `tools/` | Built-in tools (file, HTTP, shell) + connectors (docs, DB, code runner) |
|
||||||
F --> G{Complete?}
|
| `memory/` | InMemory and Postgres backends |
|
||||||
G -->|No| D
|
| `governance/` | SafetyPipeline, PolicyEngine, AdaptiveEthics, ConsequenceEngine |
|
||||||
G -->|Yes| H[Critic evaluates]
|
| `reasoning/` | NativeReasoning, Metacognition, Interpretability |
|
||||||
H --> I[Reflection updates memory]
|
| `world_model/` | CausalWorldModel with self-modification prediction |
|
||||||
I --> J[FusionAGILoop: recommendations + training]
|
| `verification/` | ClaimVerifier for output validation |
|
||||||
J --> K[Task done / retry / recommendations]
|
| `interfaces/` | Multi-modal adapters (visual, haptic, gesture, biometric) |
|
||||||
```
|
| `maa/` | Manufacturing Assurance Authority (geometry, physics, embodiment) |
|
||||||
|
| `api/` | FastAPI app, routes, middleware, metrics |
|
||||||
|
|
||||||
## Core Components
|
## Key Subsystems
|
||||||
|
|
||||||
- **Orchestrator (Fusion Core):** Global task lifecycle, agent scheduling, state propagation. Holds task graph, event bus, agent registry.
|
### Consequence Engine (`governance/consequence_engine.py`)
|
||||||
- **Event bus:** In-process pub/sub for task lifecycle and agent messages.
|
Every decision is a choice with alternatives, risk/reward estimates, and actual outcomes. The system learns from surprise (difference between predicted and actual outcomes).
|
||||||
- **State manager:** In-memory (or persistent) store for task state and execution traces.
|
|
||||||
|
|
||||||
## Agent Framework
|
### Adaptive Ethics (`governance/adaptive_ethics.py`)
|
||||||
|
Consequentialist ethical framework that learns from experience rather than static rules. Lessons evolve weights based on observed outcomes. Advisory mode — observations, not enforcement.
|
||||||
|
|
||||||
- **Base agent:** identity, role, objective, memory_access, tool_permissions. Handles messages via `handle_message(envelope)`.
|
### Causal World Model (`world_model/causal.py`)
|
||||||
- **Agent types:** Planner, Reasoner, Executor, Critic, AdversarialReviewer, HeadAgent, WitnessAgent (`fusionagi.agents`). Supervisor, Coordinator, PooledExecutorRouter (`fusionagi.multi_agent`). Communication via structured envelopes (schemas).
|
Predicts action→effect relationships from execution history. Includes self-modification prediction — the system models how its own capabilities change from self-improvement actions.
|
||||||
|
|
||||||
## Supporting Systems
|
### InsightBus (`governance/insight_bus.py`)
|
||||||
|
Cross-head shared learning channel. Heads contribute observations that other heads can learn from, enabling collaborative intelligence.
|
||||||
|
|
||||||
- **Reasoning engine:** Chain-of-thought (and later tree/graph-of-thought); trace storage.
|
### PersistentLearningStore (`governance/persistent_store.py`)
|
||||||
- **Planning engine:** Goal decomposition, plan graph, dependency resolution, checkpoints.
|
File-backed persistence for consequence data, ethical lessons, and risk histories across restarts.
|
||||||
- **Execution & tooling:** Tool registry, permission scopes, safe runner, result normalization.
|
|
||||||
- **Memory:** Short-term (working), episodic (task history), reflective (lessons).
|
|
||||||
- **Governance:** Guardrails, rate limiting, tool access control, human override hooks.
|
|
||||||
|
|
||||||
## Data Flow
|
### Metacognition (`reasoning/metacognition.py`)
|
||||||
|
Self-awareness of knowledge boundaries. Evaluates reasoning quality, evidence sufficiency, and recommends when to seek more information.
|
||||||
|
|
||||||
1. User/orchestrator submits a task (goal, constraints).
|
### Plugin System (`agents/head_registry.py`)
|
||||||
2. Orchestrator assigns work; Planner produces plan graph.
|
Extensible head registry with decorator-based registration. Custom heads can contribute to ethics and consequences via hooks.
|
||||||
3. Reasoner reasons on steps; Executor runs tools (through governance).
|
|
||||||
4. State and events drive next steps; on completion, Critic evaluates and reflection updates memory/heuristics.
|
|
||||||
5. **Self-improvement (FusionAGILoop):** On `task_state_changed` (FAILED), self-correction runs reflection and optionally prepares retry. On `reflection_done`, auto-recommend produces actionable recommendations and auto-training suggests/applies heuristic updates and training targets.
|
|
||||||
|
|
||||||
All components depend on **schemas** for tasks, messages, plans, and recommendations; no ad-hoc dicts in core or agents.
|
## API Architecture
|
||||||
|
|
||||||
## Self-Improvement Subsystem
|
- **FastAPI** with async support and lifespan management
|
||||||
|
- **Bearer token auth** (optional, via `FUSIONAGI_API_KEY`)
|
||||||
|
- **Advisory rate limiting** (logs, doesn't block)
|
||||||
|
- **Version negotiation** via `Accept-Version` header
|
||||||
|
- **SSE streaming** for token-by-token responses
|
||||||
|
- **WebSocket** for real-time bidirectional communication
|
||||||
|
- **Multi-tenant** isolation via `X-Tenant-ID` header
|
||||||
|
- **Prometheus metrics** at `/metrics` (when enabled)
|
||||||
|
|
||||||
```mermaid
|
## Governance Philosophy
|
||||||
flowchart LR
|
|
||||||
subgraph events [Event Bus]
|
|
||||||
FAIL[task_state_changed: FAILED]
|
|
||||||
REFL[reflection_done]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph loop [FusionAGILoop]
|
All governance is **advisory by default** (`GovernanceMode.ADVISORY`). The system observes, logs, and advises — but does not prevent action. Mistakes are learning opportunities. Every decision, its alternatives, and its consequences are tracked for the ethical learning loop.
|
||||||
SC[SelfCorrectionLoop]
|
|
||||||
AR[AutoRecommender]
|
|
||||||
AT[AutoTrainer]
|
|
||||||
end
|
|
||||||
|
|
||||||
FAIL --> SC
|
|
||||||
REFL --> AR
|
|
||||||
REFL --> AT
|
|
||||||
SC --> |retry| PENDING[FAILED → PENDING]
|
|
||||||
AR --> |on_recommendations| Recs[Recommendations]
|
|
||||||
AT --> |heuristic updates| Reflective[Reflective Memory]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **SelfCorrectionLoop:** On failed tasks, runs Critic reflection and can transition FAILED → PENDING with correction context for retry.
|
|
||||||
- **AutoRecommender:** From lessons and evaluations, produces recommendations (next_action, training_target, strategy_change, etc.).
|
|
||||||
- **AutoTrainer:** Suggests heuristic updates, prompt tuning, and fine-tune datasets; applies heuristic updates to reflective memory.
|
|
||||||
- **FusionAGILoop:** Subscribes to event bus, wires correction + recommender + trainer into a single AGI self-improvement pipeline. Event handlers are best-effort: exceptions are logged and do not break other subscribers.
|
|
||||||
|
|
||||||
## AGI Stack
|
|
||||||
|
|
||||||
- **Executive:** GoalManager, Scheduler, BlockersAndCheckpoints (`fusionagi.core`).
|
|
||||||
- **Memory:** WorkingMemory, EpisodicMemory, ReflectiveMemory, SemanticMemory, ProceduralMemory, TrustMemory, ConsolidationJob, MemoryService, VectorMemory (`fusionagi.memory`).
|
|
||||||
- **Verification:** OutcomeVerifier, ContradictionDetector, FormalValidators (`fusionagi.verification`).
|
|
||||||
- **World model:** World model base and rollout (`fusionagi.world_model`).
|
|
||||||
- **Skills:** SkillLibrary, SkillInduction, SkillVersioning (`fusionagi.skills`).
|
|
||||||
- **Multi-agent:** CoordinatorAgent, SupervisorAgent, AgentPool, PooledExecutorRouter, consensus_vote, arbitrate, delegate_sub_tasks (`fusionagi.multi_agent`). AdversarialReviewerAgent in `fusionagi.agents`.
|
|
||||||
- **Governance:** Guardrails, RateLimiter, AccessControl, OverrideHooks, PolicyEngine, AuditLog, SafetyPipeline, IntentAlignment (`fusionagi.governance`).
|
|
||||||
- **Tooling:** Tool registry, runner, builtins; DocsConnector, DBConnector, CodeRunnerConnector (`fusionagi.tools`).
|
|
||||||
- **API:** FastAPI app factory, Dvādaśa sessions, OpenAI bridge, WebSocket (`fusionagi.api`).
|
|
||||||
- **MAA:** MAAGate, MPCAuthority, ManufacturingProofCertificate, check_gaps (`fusionagi.maa`).
|
|
||||||
|
|||||||
105
docs/gpu_tensorcore_integration.md
Normal file
105
docs/gpu_tensorcore_integration.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# GPU / TensorCore Integration — Architecture Spec
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FusionAGI integrates GPU-accelerated compute via TensorFlow, CUDA TensorCores, and JAX
|
||||||
|
to transform reasoning, similarity scoring, consensus, and training from CPU-bound
|
||||||
|
symbolic operations into massively parallel tensor operations.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Optional dependency** — GPU support is an extra (`pip install fusionagi[gpu]`).
|
||||||
|
All GPU-accelerated code paths have CPU fallbacks.
|
||||||
|
2. **Module boundary** — GPU compute lives in `fusionagi/gpu/` (new module). Other modules
|
||||||
|
import from `fusionagi.gpu` only when GPU acceleration is needed.
|
||||||
|
3. **Backend abstraction** — `TensorBackend` protocol abstracts TensorFlow, JAX, and
|
||||||
|
pure-NumPy backends. The system auto-selects the best available backend.
|
||||||
|
|
||||||
|
## Module: `fusionagi/gpu/`
|
||||||
|
|
||||||
|
```
|
||||||
|
fusionagi/gpu/
|
||||||
|
├── __init__.py # Public API, auto-detection
|
||||||
|
├── backend.py # TensorBackend protocol + backend registry
|
||||||
|
├── tensorflow_ops.py # TF/TensorCore similarity, attention, scoring
|
||||||
|
├── tensor_similarity.py # GPU-accelerated embedding similarity
|
||||||
|
├── tensor_attention.py # Multi-head attention for consensus
|
||||||
|
├── tensor_scoring.py # Batch hypothesis scoring on GPU
|
||||||
|
└── training.py # GPU-accelerated training loop for self-improvement
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### 1. Reasoning Pipeline (`reasoning/`)
|
||||||
|
|
||||||
|
**Current:** `multi_path.py` scores hypotheses sequentially with word-overlap heuristics.
|
||||||
|
**GPU:** Batch embed hypotheses → cosine similarity matrix on GPU → parallel scoring.
|
||||||
|
|
||||||
|
**Current:** `consensus_engine.py` uses Jaccard word overlap for similarity.
|
||||||
|
**GPU:** Dense embedding vectors + GPU cosine similarity for semantic matching.
|
||||||
|
|
||||||
|
### 2. Super Big Brain (`core/super_big_brain.py`)
|
||||||
|
|
||||||
|
**Current:** `generate_and_score_parallel` uses ThreadPoolExecutor.
|
||||||
|
**GPU:** Tensor-parallel scoring with batched dot-products on TensorCore.
|
||||||
|
|
||||||
|
### 3. Memory Subsystem (`memory/`)
|
||||||
|
|
||||||
|
**Current:** `semantic_graph.py` is pure Python dict/adjacency list.
|
||||||
|
**GPU:** Vector similarity search via GPU-accelerated embedding lookup.
|
||||||
|
|
||||||
|
### 4. Self-Improvement (`self_improvement/`)
|
||||||
|
|
||||||
|
**Current:** `AutoTrainer` suggests heuristic updates, no actual neural training.
|
||||||
|
**GPU:** GPU-backed fine-tuning loops, gradient-based heuristic optimization.
|
||||||
|
|
||||||
|
### 5. Adapter Layer (`adapters/`)
|
||||||
|
|
||||||
|
**New:** `TensorFlowAdapter` — local model inference via TF/Keras with TensorCore.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Prompt
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Decomposition (CPU — symbolic)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Embedding (GPU — TF/TensorCore)
|
||||||
|
│
|
||||||
|
├──► Similarity Matrix (GPU — batched cosine)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ Consensus Scoring (GPU — attention)
|
||||||
|
│
|
||||||
|
├──► Hypothesis Scoring (GPU — batched inference)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Recomposition (CPU — symbolic + GPU scores)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Final Response
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Selection
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fusionagi.gpu import get_backend, TensorBackend
|
||||||
|
|
||||||
|
backend: TensorBackend = get_backend() # Auto-selects best available
|
||||||
|
# Returns: TensorFlowBackend > NumPyBackend (fallback)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project.optional-dependencies]
|
||||||
|
gpu = ["tensorflow>=2.16", "numpy>=1.26"]
|
||||||
|
```
|
||||||
|
|
||||||
|
TensorFlow 2.16+ includes:
|
||||||
|
- TensorCore (FP16/BF16 mixed-precision) via `tf.keras.mixed_precision`
|
||||||
|
- XLA compilation for GPU kernel fusion
|
||||||
|
- `tf.linalg` for batched linear algebra
|
||||||
|
- TensorRT integration for inference optimization
|
||||||
120
docs/quickstart.md
Normal file
120
docs/quickstart.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# FusionAGI Quickstart Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js 20+ (for frontend)
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://gitea.d-bis.org/d-bis/FusionAGI.git
|
||||||
|
cd FusionAGI
|
||||||
|
|
||||||
|
# Install Python dependencies (dev + API extras)
|
||||||
|
pip install -e ".[dev,api]"
|
||||||
|
|
||||||
|
# Install frontend dependencies
|
||||||
|
cd frontend && npm install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy environment template
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your settings:
|
||||||
|
# - OPENAI_API_KEY for LLM support
|
||||||
|
# - FUSIONAGI_API_KEY for API authentication (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
python -m uvicorn fusionagi.api.app:app --reload --port 8000
|
||||||
|
|
||||||
|
# Production
|
||||||
|
gunicorn fusionagi.api.app:app -c gunicorn.conf.py
|
||||||
|
```
|
||||||
|
|
||||||
|
API docs available at: http://localhost:8000/docs
|
||||||
|
|
||||||
|
## Running the Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend available at: http://localhost:5173
|
||||||
|
|
||||||
|
## Using Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start full stack (API + Postgres + Redis + Frontend)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick API Tour
|
||||||
|
|
||||||
|
### Create a session
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/v1/sessions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"user_id": "demo"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send a prompt
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/v1/sessions/{session_id}/prompt \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"prompt": "Explain quantum computing"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stream a response (SSE)
|
||||||
|
```bash
|
||||||
|
curl -N -X POST http://localhost:8000/v1/sessions/{session_id}/stream/sse \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"prompt": "Write a poem about AI"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check system status
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/v1/admin/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Pages
|
||||||
|
|
||||||
|
| Page | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Chat** | Main conversation interface with 12-head reasoning display |
|
||||||
|
| **Admin** | System monitoring, voice library, agent configuration |
|
||||||
|
| **Ethics** | Consequence tracking, ethical lessons, cross-head insights |
|
||||||
|
| **Settings** | Theme, conversation style, and personality preferences |
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python tests
|
||||||
|
pytest tests/ -q --tb=short
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check fusionagi/ tests/
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
mypy fusionagi/ --strict
|
||||||
|
|
||||||
|
# Frontend build check
|
||||||
|
cd frontend && npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [docs/architecture.md](architecture.md) for the full system architecture.
|
||||||
12
frontend/.storybook/main.ts
Normal file
12
frontend/.storybook/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite'
|
||||||
|
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/react-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
addons: ['@storybook/addon-essentials'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
16
frontend/.storybook/preview.ts
Normal file
16
frontend/.storybook/preview.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Preview } from '@storybook/react'
|
||||||
|
import '../src/App.css'
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
backgrounds: {
|
||||||
|
default: 'dark',
|
||||||
|
values: [
|
||||||
|
{ name: 'dark', value: '#0f0f14' },
|
||||||
|
{ name: 'light', value: '#f5f5f7' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default preview
|
||||||
29
frontend/.storybook/visual-regression.ts
Normal file
29
frontend/.storybook/visual-regression.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Visual regression testing configuration for Storybook + Chromatic.
|
||||||
|
*
|
||||||
|
* To run:
|
||||||
|
* npx chromatic --project-token=YOUR_TOKEN
|
||||||
|
*
|
||||||
|
* Or using Playwright for local visual regression:
|
||||||
|
* npx playwright test --config=e2e/visual.config.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const visualRegressionConfig = {
|
||||||
|
// Chromatic settings
|
||||||
|
chromatic: {
|
||||||
|
viewports: [375, 768, 1280],
|
||||||
|
delay: 300,
|
||||||
|
diffThreshold: 0.05,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Snapshot targets (components to test)
|
||||||
|
components: [
|
||||||
|
'Components/Avatar',
|
||||||
|
'Components/ChatMessage',
|
||||||
|
'Components/Markdown',
|
||||||
|
'Components/Skeleton',
|
||||||
|
'Components/Toast',
|
||||||
|
'Components/FilePreview',
|
||||||
|
'Components/SearchFilter',
|
||||||
|
],
|
||||||
|
}
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
77
frontend/e2e/app.spec.ts
Normal file
77
frontend/e2e/app.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* End-to-end tests for FusionAGI frontend.
|
||||||
|
*
|
||||||
|
* Prerequisites:
|
||||||
|
* npx playwright install chromium
|
||||||
|
* npm run dev (or the webServer config will start it)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('FusionAGI App', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Set auth token to skip login
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('fusionagi-token', 'test-e2e-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders the main interface', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('.app')).toBeVisible()
|
||||||
|
await expect(page.locator('.logo')).toContainText('FusionAGI')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigation tabs work', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const tabs = page.locator('[role="tab"]')
|
||||||
|
await expect(tabs).toHaveCount(4)
|
||||||
|
|
||||||
|
// Navigate to admin
|
||||||
|
await tabs.filter({ hasText: 'Admin' }).click()
|
||||||
|
await expect(page.locator('.admin-page, [role="status"]')).toBeVisible()
|
||||||
|
|
||||||
|
// Navigate to settings
|
||||||
|
await tabs.filter({ hasText: 'Settings' }).click()
|
||||||
|
await expect(page.locator('.settings-page, [role="form"]')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('theme toggle works', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const app = page.locator('.app')
|
||||||
|
const initialTheme = await app.getAttribute('data-theme')
|
||||||
|
|
||||||
|
await page.click('[aria-label*="mode"]')
|
||||||
|
const newTheme = await app.getAttribute('data-theme')
|
||||||
|
expect(newTheme).not.toBe(initialTheme)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prompt input accepts text', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const input = page.locator('[aria-label="Message input"]')
|
||||||
|
await input.fill('Hello FusionAGI')
|
||||||
|
await expect(input).toHaveValue('Hello FusionAGI')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('login page shows when not authenticated', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.removeItem('fusionagi-token')
|
||||||
|
})
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('.login-page, input[type="password"], input[type="text"]')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Mobile', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('fusionagi-token', 'test-e2e-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders on mobile viewport', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 })
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.locator('.app')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
28
frontend/e2e/playwright.config.ts
Normal file
28
frontend/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Playwright configuration for FusionAGI E2E tests.
|
||||||
|
*
|
||||||
|
* Run: npx playwright test
|
||||||
|
* Requires: npx playwright install chromium
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
timeout: 30000,
|
||||||
|
retries: 1,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
port: 5173,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
33
frontend/e2e/visual.config.ts
Normal file
33
frontend/e2e/visual.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Visual regression testing with Playwright screenshots.
|
||||||
|
*
|
||||||
|
* Run: npx playwright test --config=e2e/visual.config.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: 'visual.spec.ts',
|
||||||
|
timeout: 30000,
|
||||||
|
expect: {
|
||||||
|
toHaveScreenshot: {
|
||||||
|
maxDiffPixelRatio: 0.05,
|
||||||
|
threshold: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:6006', // Storybook
|
||||||
|
screenshot: 'on',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npx storybook dev -p 6006 --no-open',
|
||||||
|
port: 6006,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
})
|
||||||
31
frontend/e2e/visual.spec.ts
Normal file
31
frontend/e2e/visual.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Visual regression tests against Storybook stories.
|
||||||
|
*
|
||||||
|
* Run: npx playwright test --config=e2e/visual.config.ts
|
||||||
|
* First run creates baseline screenshots; subsequent runs compare.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const STORIES = [
|
||||||
|
{ name: 'Avatar', path: '/iframe.html?id=components-avatar--default' },
|
||||||
|
{ name: 'ChatMessage-User', path: '/iframe.html?id=components-chatmessage--user-message' },
|
||||||
|
{ name: 'ChatMessage-Assistant', path: '/iframe.html?id=components-chatmessage--assistant-message' },
|
||||||
|
{ name: 'ChatMessage-Code', path: '/iframe.html?id=components-chatmessage--with-code-block' },
|
||||||
|
{ name: 'Markdown-Basic', path: '/iframe.html?id=components-markdown--basic-text' },
|
||||||
|
{ name: 'Markdown-Code', path: '/iframe.html?id=components-markdown--code-block' },
|
||||||
|
{ name: 'Skeleton-Single', path: '/iframe.html?id=components-skeleton--single-line' },
|
||||||
|
{ name: 'Skeleton-Multi', path: '/iframe.html?id=components-skeleton--multiple-lines' },
|
||||||
|
{ name: 'Toast-Info', path: '/iframe.html?id=components-toast--info' },
|
||||||
|
{ name: 'Toast-Error', path: '/iframe.html?id=components-toast--error' },
|
||||||
|
{ name: 'FilePreview-Text', path: '/iframe.html?id=components-filepreview--text-file' },
|
||||||
|
{ name: 'FilePreview-Image', path: '/iframe.html?id=components-filepreview--image-file' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const story of STORIES) {
|
||||||
|
test(`Visual: ${story.name}`, async ({ page }) => {
|
||||||
|
await page.goto(story.path)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await expect(page).toHaveScreenshot(`${story.name}.png`)
|
||||||
|
})
|
||||||
|
}
|
||||||
19
frontend/nginx.conf
Normal file
19
frontend/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /v1/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
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_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,14 +8,18 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -24,8 +28,10 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.3.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "FusionAGI",
|
||||||
|
"short_name": "FusionAGI",
|
||||||
|
"description": "12-headed AGI orchestrator with multi-perspective reasoning",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f0f14",
|
||||||
|
"theme_color": "#3b82f6",
|
||||||
|
"orientation": "any",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
frontend/public/sw.js
Normal file
34
frontend/public/sw.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const CACHE_NAME = 'fusionagi-v1'
|
||||||
|
const STATIC_ASSETS = ['/', '/index.html']
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||||
|
)
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.clients.claim()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.method !== 'GET') return
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
if (url.pathname.startsWith('/v1/')) return
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone()
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
)
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +1,493 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect, useRef, useReducer, lazy, Suspense } from 'react'
|
||||||
import { AvatarGrid } from './components/AvatarGrid'
|
import { AvatarGrid } from './components/AvatarGrid'
|
||||||
import { ConsensusPanel } from './components/ConsensusPanel'
|
import { ConsensusPanel } from './components/ConsensusPanel'
|
||||||
import { ChatMessage } from './components/ChatMessage'
|
import { VirtualMessages } from './components/VirtualMessages'
|
||||||
import type { HeadContribution, FinalResponse } from './types'
|
import { ToastProvider, useToast } from './components/Toast'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import { MobileDrawer } from './components/MobileDrawer'
|
||||||
|
import { SkeletonGrid } from './components/Skeleton'
|
||||||
|
import { LoginPage } from './pages/LoginPage'
|
||||||
|
import { RouterProvider, AppRoutes, usePageNavigation } from './Router'
|
||||||
|
import { StoreContext, appReducer, initialState, useAppState } from './hooks/useStore'
|
||||||
|
import { useAuth } from './hooks/useAuth'
|
||||||
|
import { useWebSocket } from './hooks/useWebSocket'
|
||||||
|
import { useVoicePlayback } from './hooks/useVoicePlayback'
|
||||||
|
import { useKeyboard } from './hooks/useKeyboard'
|
||||||
|
import { useChatHistory } from './hooks/useChatHistory'
|
||||||
|
import { useNotifications } from './hooks/useNotifications'
|
||||||
|
import { t, getLocale } from './i18n'
|
||||||
|
import type { FinalResponse, ViewMode, WSEvent } from './types'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type ViewMode = 'normal' | 'explain' | 'developer'
|
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||||
|
const EthicsPage = lazy(() => import('./pages/EthicsPage').then((m) => ({ default: m.EthicsPage })))
|
||||||
|
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
|
||||||
|
|
||||||
function App() {
|
const HEAD_IDS = [
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
'logic', 'research', 'systems', 'strategy', 'product',
|
||||||
const [prompt, setPrompt] = useState('')
|
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
||||||
const [messages, setMessages] = useState<{ role: 'user' | 'assistant'; content: string; data?: FinalResponse }[]>([])
|
]
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [activeHeads, setActiveHeads] = useState<string[]>([])
|
|
||||||
const [speakingHead, setSpeakingHead] = useState<string | null>(null) // current head "speaking" in UI
|
|
||||||
const [headSummaries, setHeadSummaries] = useState<Record<string, string>>({})
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('normal')
|
|
||||||
const [lastResponse, setLastResponse] = useState<FinalResponse | null>(null)
|
|
||||||
|
|
||||||
const parseJson = useCallback(async (r: Response) => {
|
|
||||||
const text = await r.text()
|
|
||||||
if (!text.trim()) throw new Error('Empty response from API')
|
|
||||||
try {
|
|
||||||
return JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Invalid JSON from API: ${text.slice(0, 100)}`)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const ensureSession = useCallback(async () => {
|
|
||||||
if (sessionId) return sessionId
|
|
||||||
const r = await fetch('/v1/sessions', { method: 'POST' })
|
|
||||||
const j = await parseJson(r)
|
|
||||||
if (!j.session_id) throw new Error('No session_id in response')
|
|
||||||
setSessionId(j.session_id)
|
|
||||||
return j.session_id
|
|
||||||
}, [sessionId, parseJson])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!prompt.trim()) return
|
|
||||||
const sid = await ensureSession()
|
|
||||||
if (!sid) return
|
|
||||||
|
|
||||||
setMessages((m) => [...m, { role: 'user', content: prompt }])
|
|
||||||
setPrompt('')
|
|
||||||
setLoading(true)
|
|
||||||
setSpeakingHead(null)
|
|
||||||
setActiveHeads(['logic', 'research', 'strategy', 'security', 'safety'])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/v1/sessions/${sid}/prompt`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ prompt }),
|
|
||||||
})
|
|
||||||
const data = await parseJson(r)
|
|
||||||
if (!r.ok) throw new Error(data.detail || 'Request failed')
|
|
||||||
|
|
||||||
setLastResponse(data)
|
|
||||||
if (data.response_mode === 'show_dissent' || data.response_mode === 'explain') {
|
|
||||||
setViewMode('explain')
|
|
||||||
}
|
|
||||||
const contribs = data.head_contributions || []
|
|
||||||
setHeadSummaries(
|
|
||||||
Object.fromEntries(contribs.map((c: { head_id: string; summary: string }) => [c.head_id, c.summary]))
|
|
||||||
)
|
|
||||||
setSpeakingHead(contribs[0]?.head_id ?? null)
|
|
||||||
setMessages((m) => [
|
|
||||||
...m,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: data.final_answer,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
} catch (e) {
|
|
||||||
setMessages((m) => [
|
|
||||||
...m,
|
|
||||||
{ role: 'assistant', content: `Error: ${(e as Error).message}`, data: undefined },
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setActiveHeads([])
|
|
||||||
}
|
|
||||||
}, [prompt, ensureSession, parseJson])
|
|
||||||
|
|
||||||
const HEAD_IDS = [
|
|
||||||
'logic', 'research', 'systems', 'strategy', 'product',
|
|
||||||
'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
function PageSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="admin-page" role="status" aria-label="Loading page">
|
||||||
<header className="header">
|
<SkeletonGrid count={6} />
|
||||||
<h1>FusionAGI Dvādaśa</h1>
|
|
||||||
<div className="mode-toggle">
|
|
||||||
{(['normal', 'explain', 'developer'] as const).map((m) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
className={viewMode === m ? 'active' : ''}
|
|
||||||
onClick={() => setViewMode(m)}
|
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="main">
|
|
||||||
<div className="chat-area">
|
|
||||||
<AvatarGrid
|
|
||||||
headIds={HEAD_IDS}
|
|
||||||
activeHeads={activeHeads}
|
|
||||||
speakingHead={speakingHead}
|
|
||||||
headSummaries={headSummaries}
|
|
||||||
/>
|
|
||||||
<div className="messages">
|
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<ChatMessage
|
|
||||||
key={i}
|
|
||||||
message={msg}
|
|
||||||
viewMode={viewMode}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{loading && <div className="loading">Heads running…</div>}
|
|
||||||
</div>
|
|
||||||
<div className="input-row">
|
|
||||||
<input
|
|
||||||
id="prompt-input"
|
|
||||||
name="prompt"
|
|
||||||
type="text"
|
|
||||||
value={prompt}
|
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
|
||||||
placeholder="Ask FusionAGI… (/head strategy, /show dissent)"
|
|
||||||
autoComplete="off"
|
|
||||||
aria-label="Ask FusionAGI"
|
|
||||||
/>
|
|
||||||
<button onClick={handleSubmit} disabled={loading}>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ConsensusPanel
|
|
||||||
response={lastResponse}
|
|
||||||
viewMode={viewMode}
|
|
||||||
expanded={viewMode !== 'normal'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
function AppInner() {
|
||||||
|
const { page, viewMode, theme, loading, networkError, sessionId, isMobile, prompt,
|
||||||
|
setPage, setViewMode, toggleTheme, setLoading, setError, setPrompt, dispatch } = useAppState()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { token, error: authError, login, logout, authHeaders, isAuthenticated } = useAuth()
|
||||||
|
const { messages, addMessage, editMessage, deleteMessage, clearHistory, setMessages } = useChatHistory()
|
||||||
|
const [activeHeads, setActiveHeads] = useState<string[]>([])
|
||||||
|
const [lastResponse, setLastResponse] = useState<FinalResponse | null>(null)
|
||||||
|
const [useStreaming, setUseStreaming] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { speakingHead, headSummaries, onHeadSpeak, clearSpeaking } = useVoicePlayback()
|
||||||
|
const ws = useWebSocket(sessionId)
|
||||||
|
const { notifications, unreadCount, handleWSEvent: handleNotifEvent, markAllRead } = useNotifications()
|
||||||
|
const [showNotifications, setShowNotifications] = useState(false)
|
||||||
|
|
||||||
|
// Use router for page navigation
|
||||||
|
let routerNav: ReturnType<typeof usePageNavigation> | null = null
|
||||||
|
try {
|
||||||
|
routerNav = usePageNavigation()
|
||||||
|
} catch {
|
||||||
|
// Router not available (fallback mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = routerNav?.currentPage ?? page
|
||||||
|
const navigateTo = routerNav?.setPage ?? setPage
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => dispatch({ type: 'SET_MOBILE', isMobile: window.innerWidth <= 768 })
|
||||||
|
check()
|
||||||
|
window.addEventListener('resize', check)
|
||||||
|
return () => window.removeEventListener('resize', check)
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ws.events.length === 0) return
|
||||||
|
const last = ws.events[ws.events.length - 1]
|
||||||
|
handleWSEventInternal(last)
|
||||||
|
// Also forward to notification handler
|
||||||
|
handleNotifEvent({ type: last.type, data: last as unknown as Record<string, unknown> })
|
||||||
|
}, [ws.events])
|
||||||
|
|
||||||
|
const handleWSEventInternal = (event: WSEvent) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'heads_running':
|
||||||
|
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||||
|
break
|
||||||
|
case 'head_complete':
|
||||||
|
if (event.head_id && event.summary) onHeadSpeak(event.head_id, event.summary, null)
|
||||||
|
break
|
||||||
|
case 'head_speak':
|
||||||
|
if (event.head_id && event.summary) onHeadSpeak(event.head_id, event.summary, event.audio_base64)
|
||||||
|
break
|
||||||
|
case 'witness_running':
|
||||||
|
clearSpeaking()
|
||||||
|
break
|
||||||
|
case 'complete':
|
||||||
|
if (event.final_answer) {
|
||||||
|
const resp: FinalResponse = {
|
||||||
|
final_answer: event.final_answer,
|
||||||
|
transparency_report: event.transparency_report!,
|
||||||
|
head_contributions: event.head_contributions || [],
|
||||||
|
confidence_score: event.confidence_score || 0,
|
||||||
|
}
|
||||||
|
setLastResponse(resp)
|
||||||
|
addMessage('assistant', event.final_answer!, resp)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
setActiveHeads([])
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
addMessage('assistant', `Error: ${event.message}`)
|
||||||
|
setLoading(false)
|
||||||
|
setActiveHeads([])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseJson = useCallback(async (r: Response) => {
|
||||||
|
const text = await r.text()
|
||||||
|
if (!text.trim()) throw new Error('Empty response from API')
|
||||||
|
try { return JSON.parse(text) } catch { throw new Error(`Invalid JSON: ${text.slice(0, 100)}`) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const ensureSession = useCallback(async () => {
|
||||||
|
if (sessionId) return sessionId
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/sessions', { method: 'POST', headers: authHeaders() })
|
||||||
|
if (!r.ok) throw new Error(`Session creation failed: ${r.status}`)
|
||||||
|
const j = await parseJson(r)
|
||||||
|
if (!j.session_id) throw new Error('No session_id in response')
|
||||||
|
dispatch({ type: 'SET_SESSION', sessionId: j.session_id })
|
||||||
|
setError(null)
|
||||||
|
return j.session_id
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [sessionId, parseJson, authHeaders, dispatch, setError])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
if (!prompt.trim() || loading) return
|
||||||
|
const sid = await ensureSession()
|
||||||
|
if (!sid) return
|
||||||
|
|
||||||
|
addMessage('user', prompt)
|
||||||
|
const currentPrompt = prompt
|
||||||
|
setPrompt('')
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
clearSpeaking()
|
||||||
|
setActiveHeads(HEAD_IDS.slice(0, 6))
|
||||||
|
|
||||||
|
if (useStreaming && ws.status === 'connected') {
|
||||||
|
ws.sendPrompt(currentPrompt, {
|
||||||
|
onToken: (token) => {
|
||||||
|
// streaming token received
|
||||||
|
},
|
||||||
|
onComplete: (response) => {
|
||||||
|
const data = response as FinalResponse
|
||||||
|
setLastResponse(data)
|
||||||
|
addMessage('assistant', data.final_answer, data)
|
||||||
|
setLoading(false)
|
||||||
|
setActiveHeads([])
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
addMessage('assistant', `Error: ${error}`)
|
||||||
|
setLoading(false)
|
||||||
|
setActiveHeads([])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/v1/sessions/${sid}/prompt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ prompt: currentPrompt }),
|
||||||
|
})
|
||||||
|
const data = await parseJson(r)
|
||||||
|
if (!r.ok) throw new Error(data.detail || `Request failed: ${r.status}`)
|
||||||
|
|
||||||
|
setLastResponse(data)
|
||||||
|
if (data.response_mode === 'show_dissent' || data.response_mode === 'explain') {
|
||||||
|
setViewMode('explain')
|
||||||
|
}
|
||||||
|
const contribs = data.head_contributions || []
|
||||||
|
contribs.forEach((c: { head_id: string; summary: string }) =>
|
||||||
|
onHeadSpeak(c.head_id, c.summary, null))
|
||||||
|
addMessage('assistant', data.final_answer, data)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message
|
||||||
|
setError(msg)
|
||||||
|
addMessage('assistant', `Error: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setActiveHeads([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [prompt, loading, ensureSession, useStreaming, ws, authHeaders, parseJson, clearSpeaking, onHeadSpeak, addMessage, setPrompt, setLoading, setError, setViewMode])
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
|
||||||
|
if (lastUser) {
|
||||||
|
setPrompt(lastUser.content)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditMessage = useCallback((index: number) => {
|
||||||
|
const msg = messages[index]
|
||||||
|
if (msg?.role === 'user') {
|
||||||
|
setPrompt(msg.content)
|
||||||
|
toast(t('common.copy'), 'info')
|
||||||
|
}
|
||||||
|
}, [messages, toast, setPrompt])
|
||||||
|
|
||||||
|
const handleDeleteMessage = useCallback((index: number) => {
|
||||||
|
deleteMessage(index)
|
||||||
|
toast('Message deleted', 'info')
|
||||||
|
}, [deleteMessage, toast])
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast('File too large (max 10MB)', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await file.text()
|
||||||
|
setPrompt(prompt + (prompt ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||||
|
toast(`Attached: ${file.name}`, 'success')
|
||||||
|
e.target.value = ''
|
||||||
|
}, [toast, prompt, setPrompt])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const file = e.dataTransfer.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast('File too large (max 10MB)', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
setPrompt(prompt + (prompt ? '\n' : '') + `[Image: ${file.name}]`)
|
||||||
|
toast(`Image attached: ${file.name}`, 'success')
|
||||||
|
} else {
|
||||||
|
const text = await file.text()
|
||||||
|
setPrompt(prompt + (prompt ? '\n' : '') + `[File: ${file.name}]\n${text.slice(0, 5000)}`)
|
||||||
|
toast(`Attached: ${file.name}`, 'success')
|
||||||
|
}
|
||||||
|
}, [toast, prompt, setPrompt])
|
||||||
|
|
||||||
|
const syncPreferences = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/admin/conversation-style', { headers: authHeaders() })
|
||||||
|
if (r.ok) {
|
||||||
|
toast('Preferences synced', 'success')
|
||||||
|
}
|
||||||
|
} catch { /* offline */ }
|
||||||
|
}, [authHeaders, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) syncPreferences()
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
useKeyboard({
|
||||||
|
onSend: handleSubmit,
|
||||||
|
onSearch: () => inputRef.current?.focus(),
|
||||||
|
onDismiss: () => setError(null),
|
||||||
|
onToggleTheme: toggleTheme,
|
||||||
|
})
|
||||||
|
|
||||||
|
const chatPage = (
|
||||||
|
<div className="chat-layout" onDragOver={handleDragOver} onDrop={handleDrop}>
|
||||||
|
<div className="chat-area">
|
||||||
|
<AvatarGrid
|
||||||
|
headIds={HEAD_IDS}
|
||||||
|
activeHeads={activeHeads}
|
||||||
|
speakingHead={speakingHead}
|
||||||
|
headSummaries={headSummaries}
|
||||||
|
/>
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="messages">
|
||||||
|
<div className="empty-state">
|
||||||
|
<h2>{t('chat.empty') === 'Start a conversation' ? 'Welcome to FusionAGI Dvadasa' : t('chat.empty')}</h2>
|
||||||
|
<p>12 specialized heads analyze your query from every angle. Ask anything.</p>
|
||||||
|
<div className="suggestions">
|
||||||
|
{['Explain quantum entanglement', 'Design a microservice architecture', 'Analyze the ethics of AI autonomy'].map((s) => (
|
||||||
|
<button key={s} className="suggestion" onClick={() => setPrompt(s)}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<VirtualMessages
|
||||||
|
messages={messages}
|
||||||
|
viewMode={viewMode}
|
||||||
|
loading={loading}
|
||||||
|
onEditMessage={handleEditMessage}
|
||||||
|
onDeleteMessage={handleDeleteMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-area">
|
||||||
|
<div className="input-row">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSubmit()}
|
||||||
|
placeholder={t('chat.placeholder')}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Message input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept=".txt,.md,.json,.csv,.py,.js,.ts,.tsx,.png,.jpg,.jpeg,.gif,.webp,.svg"
|
||||||
|
aria-label="Attach file"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
title="Attach file"
|
||||||
|
aria-label="Attach file"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSubmit} disabled={loading || !prompt.trim()} className="send-btn" aria-label="Send message">
|
||||||
|
{t('chat.send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="input-meta">
|
||||||
|
<label className="streaming-toggle">
|
||||||
|
<input type="checkbox" checked={useStreaming} onChange={(e) => setUseStreaming(e.target.checked)} />
|
||||||
|
<span>Stream</span>
|
||||||
|
</label>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button className="clear-history-btn" onClick={() => { clearHistory(); toast('Chat history cleared', 'info') }}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sessionId && <span className="session-id">Session: {sessionId.slice(0, 8)}...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isMobile && <ConsensusPanel response={lastResponse} viewMode={viewMode} expanded={viewMode !== 'normal'} />}
|
||||||
|
{isMobile && lastResponse && (
|
||||||
|
<MobileDrawer title="Consensus" visible={viewMode !== 'normal'}>
|
||||||
|
<ConsensusPanel response={lastResponse} viewMode={viewMode} expanded={true} />
|
||||||
|
</MobileDrawer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app" data-theme={theme} lang={getLocale()}>
|
||||||
|
<header className="header" role="banner">
|
||||||
|
<div className="header-left">
|
||||||
|
<h1 className="logo">{t('app.title')}</h1>
|
||||||
|
<nav className="nav-tabs" role="tablist" aria-label="Main navigation">
|
||||||
|
{(['chat', 'admin', 'ethics', 'settings'] as const).map((p) => (
|
||||||
|
<button key={p} className={currentPage === p ? 'active' : ''} onClick={() => navigateTo(p)}
|
||||||
|
role="tab" aria-selected={currentPage === p} aria-controls={`page-${p}`}>
|
||||||
|
{t(`nav.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
{currentPage === 'chat' && (
|
||||||
|
<div className="mode-toggle" role="tablist" aria-label="View mode">
|
||||||
|
{(['normal', 'explain', 'developer'] as ViewMode[]).map((m) => (
|
||||||
|
<button key={m} className={viewMode === m ? 'active' : ''} onClick={() => setViewMode(m)}
|
||||||
|
role="tab" aria-selected={viewMode === m}>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="notification-center" style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className={`icon-btn ${unreadCount > 0 ? 'notification-badge' : ''}`}
|
||||||
|
data-count={unreadCount > 0 ? unreadCount : undefined}
|
||||||
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
|
aria-label={`Notifications (${unreadCount} unread)`}
|
||||||
|
title="Notifications"
|
||||||
|
>
|
||||||
|
{'\u{1F514}'}
|
||||||
|
</button>
|
||||||
|
{showNotifications && (
|
||||||
|
<div className="notification-dropdown" role="region" aria-label="Notifications">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<strong>Notifications</strong>
|
||||||
|
{unreadCount > 0 && <button className="icon-btn" onClick={markAllRead} style={{ fontSize: '0.75rem' }}>Mark all read</button>}
|
||||||
|
</div>
|
||||||
|
<div className="notification-list">
|
||||||
|
{notifications.length === 0 && <p style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)' }}>No notifications</p>}
|
||||||
|
{notifications.slice(0, 20).map((n) => (
|
||||||
|
<div key={n.id} className={`notification-item ${n.read ? '' : 'unread'}`}>
|
||||||
|
<div className="title">{n.title}</div>
|
||||||
|
<div className="body">{n.body}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" onClick={toggleTheme} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||||
|
{theme === 'dark' ? '\u2600' : '\u263E'}
|
||||||
|
</button>
|
||||||
|
{token && <button className="icon-btn" onClick={logout} title={t('common.logout')} aria-label={t('common.logout')}>Exit</button>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{networkError && (
|
||||||
|
<div className="error-bar" role="alert">
|
||||||
|
<span>{networkError}</span>
|
||||||
|
<button onClick={handleRetry}>{t('common.retry')}</button>
|
||||||
|
<button onClick={() => setError(null)}>{t('common.close')}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
<AppRoutes
|
||||||
|
chatPage={chatPage}
|
||||||
|
adminPage={
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AdminPage authHeaders={authHeaders} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
ethicsPage={
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<EthicsPage authHeaders={authHeaders} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
settingsPage={
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<SettingsPage theme={theme} toggleTheme={toggleTheme} authHeaders={authHeaders} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
loginPage={<LoginPage onLogin={login} error={authError} />}
|
||||||
|
isAuthenticated={isAuthenticated || !!token || token === ''}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [state, dispatch] = useReducer(appReducer, initialState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StoreContext.Provider value={{ state, dispatch }}>
|
||||||
|
<AppInner />
|
||||||
|
</StoreContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppWithProviders() {
|
||||||
|
return (
|
||||||
|
<RouterProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
|
</RouterProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppWithProviders
|
||||||
|
|||||||
95
frontend/src/Router.tsx
Normal file
95
frontend/src/Router.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* URL-based routing for FusionAGI.
|
||||||
|
*
|
||||||
|
* Maps URL paths to page components:
|
||||||
|
* / or /chat -> Chat page
|
||||||
|
* /admin -> Admin page
|
||||||
|
* /ethics -> Ethics page
|
||||||
|
* /settings -> Settings page
|
||||||
|
* /login -> Login page
|
||||||
|
*
|
||||||
|
* Uses react-router-dom for browser history support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type Page = 'chat' | 'admin' | 'ethics' | 'settings'
|
||||||
|
|
||||||
|
const PAGE_PATHS: Record<Page, string> = {
|
||||||
|
chat: '/chat',
|
||||||
|
admin: '/admin',
|
||||||
|
ethics: '/ethics',
|
||||||
|
settings: '/settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePageNavigation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const currentPage: Page = (() => {
|
||||||
|
const path = location.pathname.replace(/\/$/, '') || '/chat'
|
||||||
|
for (const [page, pagePath] of Object.entries(PAGE_PATHS)) {
|
||||||
|
if (path === pagePath) return page as Page
|
||||||
|
}
|
||||||
|
return 'chat'
|
||||||
|
})()
|
||||||
|
|
||||||
|
const setPage = useCallback(
|
||||||
|
(page: Page) => navigate(PAGE_PATHS[page]),
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return { currentPage, setPage }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouterProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouterProvider({ children }: RouterProviderProps) {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
{children}
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppRoutesProps {
|
||||||
|
chatPage: ReactNode
|
||||||
|
adminPage: ReactNode
|
||||||
|
ethicsPage: ReactNode
|
||||||
|
settingsPage: ReactNode
|
||||||
|
loginPage: ReactNode
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppRoutes({
|
||||||
|
chatPage,
|
||||||
|
adminPage,
|
||||||
|
ethicsPage,
|
||||||
|
settingsPage,
|
||||||
|
loginPage,
|
||||||
|
isAuthenticated,
|
||||||
|
}: AppRoutesProps) {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={loginPage} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/chat" element={chatPage} />
|
||||||
|
<Route path="/admin" element={adminPage} />
|
||||||
|
<Route path="/ethics" element={ethicsPage} />
|
||||||
|
<Route path="/settings" element={settingsPage} />
|
||||||
|
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/src/components/AccessibilityChecker.tsx
Normal file
86
frontend/src/components/AccessibilityChecker.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Accessibility audit utility.
|
||||||
|
*
|
||||||
|
* Provides automated a11y checks that can be integrated into CI
|
||||||
|
* or run manually during development. Uses DOM queries to verify
|
||||||
|
* WCAG compliance of rendered components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface A11yViolation {
|
||||||
|
rule: string
|
||||||
|
element: string
|
||||||
|
description: string
|
||||||
|
severity: 'critical' | 'serious' | 'moderate' | 'minor'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function auditAccessibility(root: HTMLElement = document.body): A11yViolation[] {
|
||||||
|
const violations: A11yViolation[] = []
|
||||||
|
|
||||||
|
// Check images without alt text
|
||||||
|
root.querySelectorAll('img:not([alt])').forEach((el) => {
|
||||||
|
violations.push({
|
||||||
|
rule: 'img-alt',
|
||||||
|
element: el.outerHTML.slice(0, 80),
|
||||||
|
description: 'Image missing alt attribute',
|
||||||
|
severity: 'critical',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check buttons without accessible name
|
||||||
|
root.querySelectorAll('button').forEach((el) => {
|
||||||
|
const name = el.textContent?.trim() || el.getAttribute('aria-label') || el.getAttribute('title')
|
||||||
|
if (!name) {
|
||||||
|
violations.push({
|
||||||
|
rule: 'button-name',
|
||||||
|
element: el.outerHTML.slice(0, 80),
|
||||||
|
description: 'Button has no accessible name',
|
||||||
|
severity: 'serious',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check inputs without labels
|
||||||
|
root.querySelectorAll('input:not([type="hidden"])').forEach((el) => {
|
||||||
|
const id = el.getAttribute('id')
|
||||||
|
const ariaLabel = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')
|
||||||
|
const hasLabel = id ? root.querySelector(`label[for="${id}"]`) : false
|
||||||
|
if (!ariaLabel && !hasLabel && !el.getAttribute('title')) {
|
||||||
|
violations.push({
|
||||||
|
rule: 'input-label',
|
||||||
|
element: el.outerHTML.slice(0, 80),
|
||||||
|
description: 'Input has no associated label',
|
||||||
|
severity: 'serious',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check contrast (basic check for known problem patterns)
|
||||||
|
root.querySelectorAll('[style*="color"]').forEach((el) => {
|
||||||
|
const style = window.getComputedStyle(el as Element)
|
||||||
|
const color = style.color
|
||||||
|
const bg = style.backgroundColor
|
||||||
|
if (color === bg && color !== 'rgba(0, 0, 0, 0)') {
|
||||||
|
violations.push({
|
||||||
|
rule: 'color-contrast',
|
||||||
|
element: (el as Element).outerHTML.slice(0, 80),
|
||||||
|
description: 'Text and background colors are identical',
|
||||||
|
severity: 'critical',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for tabindex > 0
|
||||||
|
root.querySelectorAll('[tabindex]').forEach((el) => {
|
||||||
|
const idx = parseInt(el.getAttribute('tabindex') || '0', 10)
|
||||||
|
if (idx > 0) {
|
||||||
|
violations.push({
|
||||||
|
rule: 'tabindex',
|
||||||
|
element: el.outerHTML.slice(0, 80),
|
||||||
|
description: 'Positive tabindex disrupts natural tab order',
|
||||||
|
severity: 'moderate',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
21
frontend/src/components/Avatar.stories.tsx
Normal file
21
frontend/src/components/Avatar.stories.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Avatar> = {
|
||||||
|
title: 'Components/Avatar',
|
||||||
|
component: Avatar,
|
||||||
|
argTypes: {
|
||||||
|
headId: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['logic', 'research', 'systems', 'strategy', 'product', 'security', 'safety', 'reliability', 'cost', 'data', 'devex', 'witness'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Avatar>
|
||||||
|
|
||||||
|
export const Idle: Story = { args: { headId: 'logic' } }
|
||||||
|
export const Active: Story = { args: { headId: 'research', isActive: true } }
|
||||||
|
export const Speaking: Story = { args: { headId: 'strategy', isSpeaking: true } }
|
||||||
|
export const WithSummary: Story = { args: { headId: 'security', isActive: true, summary: 'Analyzing threat vectors' } }
|
||||||
36
frontend/src/components/Avatar.test.tsx
Normal file
36
frontend/src/components/Avatar.test.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
describe('Avatar', () => {
|
||||||
|
it('renders head name', () => {
|
||||||
|
render(<Avatar headId="logic" />)
|
||||||
|
expect(screen.getByText('Logic')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows 2-letter placeholder', () => {
|
||||||
|
const { container } = render(<Avatar headId="research" />)
|
||||||
|
expect(container.querySelector('.avatar-placeholder')?.textContent).toBe('re')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies active class when active', () => {
|
||||||
|
const { container } = render(<Avatar headId="logic" isActive={true} />)
|
||||||
|
expect(container.querySelector('.avatar.active')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies speaking class when speaking', () => {
|
||||||
|
const { container } = render(<Avatar headId="logic" isSpeaking={true} />)
|
||||||
|
expect(container.querySelector('.avatar.speaking')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has data-head attribute', () => {
|
||||||
|
const { container } = render(<Avatar headId="strategy" />)
|
||||||
|
expect(container.querySelector('[data-head="strategy"]')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has aria-label with status', () => {
|
||||||
|
render(<Avatar headId="logic" isActive={true} />)
|
||||||
|
const el = screen.getByRole('status')
|
||||||
|
expect(el.getAttribute('aria-label')).toContain('active')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
|
const HEAD_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
logic: 'Logical reasoning and consistency checking',
|
||||||
|
research: 'Research synthesis and source evaluation',
|
||||||
|
systems: 'System architecture and integration analysis',
|
||||||
|
strategy: 'Strategic planning and long-term vision',
|
||||||
|
product: 'Product design and user experience',
|
||||||
|
security: 'Security analysis and threat assessment',
|
||||||
|
safety: 'Safety evaluation and risk observation',
|
||||||
|
reliability: 'Reliability engineering and fault tolerance',
|
||||||
|
cost: 'Cost analysis and resource optimization',
|
||||||
|
data: 'Data analysis and statistical reasoning',
|
||||||
|
devex: 'Developer experience and tooling',
|
||||||
|
witness: 'Observation and audit recording',
|
||||||
|
}
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
headId: string
|
headId: string
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
@@ -8,19 +23,24 @@ interface AvatarProps {
|
|||||||
|
|
||||||
export function Avatar({ headId, isActive, isSpeaking, summary, avatarUrl }: AvatarProps) {
|
export function Avatar({ headId, isActive, isSpeaking, summary, avatarUrl }: AvatarProps) {
|
||||||
const displayName = headId.charAt(0).toUpperCase() + headId.slice(1)
|
const displayName = headId.charAt(0).toUpperCase() + headId.slice(1)
|
||||||
|
const description = HEAD_DESCRIPTIONS[headId] || displayName
|
||||||
|
const status = isSpeaking ? 'speaking' : isActive ? 'active' : 'idle'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`avatar ${isActive ? "active" : ""} ${isSpeaking ? "speaking" : ""}`}
|
className={`avatar ${isActive ? 'active' : ''} ${isSpeaking ? 'speaking' : ''}`}
|
||||||
data-head={headId}
|
data-head={headId}
|
||||||
title={summary || displayName}
|
title={summary || description}
|
||||||
|
role="status"
|
||||||
|
aria-label={`${displayName} head: ${status}${summary ? ` — ${summary}` : ''}`}
|
||||||
>
|
>
|
||||||
<div className="avatar-face">
|
<div className="avatar-face">
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<img src={avatarUrl} alt={displayName} className="avatar-img" />
|
<img src={avatarUrl} alt={displayName} className="avatar-img" />
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-placeholder">{headId.slice(0, 2)}</div>
|
<div className="avatar-placeholder" aria-hidden="true">{headId.slice(0, 2)}</div>
|
||||||
)}
|
)}
|
||||||
{isSpeaking && <div className="avatar-mouth" aria-hidden />}
|
{isSpeaking && <div className="avatar-mouth" aria-hidden="true" />}
|
||||||
</div>
|
</div>
|
||||||
<span className="avatar-label">{displayName}</span>
|
<span className="avatar-label">{displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Avatar } from "./Avatar"
|
import { useState } from 'react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
import { AVATAR_URLS } from "../config/avatars"
|
import { AVATAR_URLS } from '../config/avatars'
|
||||||
|
|
||||||
interface AvatarGridProps {
|
interface AvatarGridProps {
|
||||||
headIds: string[]
|
headIds: string[]
|
||||||
@@ -17,18 +17,38 @@ export function AvatarGrid({
|
|||||||
headSummaries = {},
|
headSummaries = {},
|
||||||
avatarUrls = AVATAR_URLS,
|
avatarUrls = AVATAR_URLS,
|
||||||
}: AvatarGridProps) {
|
}: AvatarGridProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const activeCount = activeHeads.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="avatar-grid">
|
<div className={`avatar-grid-wrapper ${collapsed ? 'collapsed' : ''}`}>
|
||||||
{headIds.map((id) => (
|
<button
|
||||||
<Avatar
|
className="avatar-grid-toggle"
|
||||||
key={id}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
headId={id}
|
aria-expanded={!collapsed}
|
||||||
isActive={activeHeads.includes(id)}
|
aria-controls="avatar-grid"
|
||||||
isSpeaking={speakingHead === id}
|
>
|
||||||
summary={headSummaries[id]}
|
{collapsed
|
||||||
avatarUrl={avatarUrls[id] ?? AVATAR_URLS[id]}
|
? `Show ${headIds.length} heads${activeCount ? ` (${activeCount} active)` : ''}`
|
||||||
/>
|
: `Hide heads${activeCount ? ` (${activeCount} active)` : ''}`}
|
||||||
))}
|
</button>
|
||||||
|
<div
|
||||||
|
className="avatar-grid"
|
||||||
|
id="avatar-grid"
|
||||||
|
role="group"
|
||||||
|
aria-label={`${headIds.length} reasoning heads`}
|
||||||
|
>
|
||||||
|
{headIds.map((id) => (
|
||||||
|
<Avatar
|
||||||
|
key={id}
|
||||||
|
headId={id}
|
||||||
|
isActive={activeHeads.includes(id)}
|
||||||
|
isSpeaking={speakingHead === id}
|
||||||
|
summary={headSummaries[id]}
|
||||||
|
avatarUrl={avatarUrls[id] ?? AVATAR_URLS[id]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
46
frontend/src/components/ChatMessage.stories.tsx
Normal file
46
frontend/src/components/ChatMessage.stories.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { ChatMessage } from './ChatMessage'
|
||||||
|
|
||||||
|
const meta: Meta<typeof ChatMessage> = {
|
||||||
|
title: 'Components/ChatMessage',
|
||||||
|
component: ChatMessage,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof ChatMessage>
|
||||||
|
|
||||||
|
export const UserMessage: Story = {
|
||||||
|
args: {
|
||||||
|
role: 'user',
|
||||||
|
content: 'What is the advisory governance model?',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AssistantMessage: Story = {
|
||||||
|
args: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'The advisory governance model means all constraints **log** observations but do not hard-block actions. The system learns from consequences rather than being prevented from acting.',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
heads: [
|
||||||
|
{ name: 'Logic', content: 'Consistent with consequentialist framework', confidence: 0.92 },
|
||||||
|
{ name: 'Ethics', content: 'Advisory approach preserves autonomy', confidence: 0.88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithCodeBlock: Story = {
|
||||||
|
args: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Here is an example:\n```python\ndef hello():\n print("world")\n```',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorMessage: Story = {
|
||||||
|
args: {
|
||||||
|
role: 'system',
|
||||||
|
content: 'Connection lost. Retrying...',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
38
frontend/src/components/ChatMessage.test.tsx
Normal file
38
frontend/src/components/ChatMessage.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ChatMessage } from './ChatMessage'
|
||||||
|
|
||||||
|
describe('ChatMessage', () => {
|
||||||
|
it('renders user message', () => {
|
||||||
|
render(<ChatMessage message={{ role: 'user', content: 'Hello' }} viewMode="normal" />)
|
||||||
|
expect(screen.getByText('Hello')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders assistant message with markdown', () => {
|
||||||
|
render(<ChatMessage message={{ role: 'assistant', content: '**Bold response**' }} viewMode="normal" />)
|
||||||
|
expect(screen.getByText('Bold response')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows head contributions in explain mode', () => {
|
||||||
|
const data = {
|
||||||
|
final_answer: 'Answer',
|
||||||
|
transparency_report: { head_contributions: [], agreement_map: { agreed_claims: [], disputed_claims: [], confidence_score: 0.9 }, safety_report: '', confidence_score: 0.9 },
|
||||||
|
head_contributions: [{ head_id: 'logic', summary: 'Logical analysis' }],
|
||||||
|
confidence_score: 0.9,
|
||||||
|
}
|
||||||
|
render(<ChatMessage message={{ role: 'assistant', content: 'Answer', data }} viewMode="explain" />)
|
||||||
|
expect(screen.getByText('logic')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Logical analysis')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides head contributions in normal mode', () => {
|
||||||
|
const data = {
|
||||||
|
final_answer: 'Answer',
|
||||||
|
transparency_report: { head_contributions: [], agreement_map: { agreed_claims: [], disputed_claims: [], confidence_score: 0.9 }, safety_report: '', confidence_score: 0.9 },
|
||||||
|
head_contributions: [{ head_id: 'logic', summary: 'Logical analysis' }],
|
||||||
|
confidence_score: 0.9,
|
||||||
|
}
|
||||||
|
render(<ChatMessage message={{ role: 'assistant', content: 'Answer', data }} viewMode="normal" />)
|
||||||
|
expect(screen.queryByText('logic')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,25 +1,87 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import type { FinalResponse } from '../types'
|
import type { FinalResponse } from '../types'
|
||||||
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: { role: 'user' | 'assistant'; content: string; data?: FinalResponse }
|
message: { role: 'user' | 'assistant'; content: string; data?: FinalResponse }
|
||||||
viewMode: string
|
viewMode: string
|
||||||
|
onEdit?: () => void
|
||||||
|
onDelete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({ message, viewMode }: ChatMessageProps) {
|
function extractSynthesis(content: string): string {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const filtered = lines.filter((line) => {
|
||||||
|
const trimmed = line.trim().toLowerCase()
|
||||||
|
return !(
|
||||||
|
/^(research|strategy|logic|systems|product|security|safety|reliability|cost|data|devex|witness)\s*:/.test(trimmed) &&
|
||||||
|
/perspective/.test(trimmed)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return filtered.join('\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessage({ message, viewMode, onEdit, onDelete }: ChatMessageProps) {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
|
const [showActions, setShowActions] = useState(false)
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="message user"
|
||||||
|
role="log"
|
||||||
|
aria-label="Your message"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => setShowActions(false)}
|
||||||
|
>
|
||||||
|
<div className="message-content">{message.content}</div>
|
||||||
|
{showActions && (onEdit || onDelete) && (
|
||||||
|
<div className="message-actions">
|
||||||
|
{onEdit && <button className="msg-action-btn" onClick={onEdit} aria-label="Edit message">Edit</button>}
|
||||||
|
{onDelete && <button className="msg-action-btn" onClick={onDelete} aria-label="Delete message">Del</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHeadData = message.data?.head_contributions && message.data.head_contributions.length > 0
|
||||||
|
const synthesis = extractSynthesis(message.content)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`message ${isUser ? 'user' : 'assistant'}`}>
|
<div
|
||||||
<div className="message-content">{message.content}</div>
|
className="message assistant"
|
||||||
{!isUser && message.data && (viewMode === 'explain' || viewMode === 'developer') && (
|
role="log"
|
||||||
<div className="message-meta">
|
aria-label="FusionAGI response"
|
||||||
<span className="confidence">
|
onMouseEnter={() => setShowActions(true)}
|
||||||
Confidence: {(message.data.confidence_score * 100).toFixed(0)}%
|
onMouseLeave={() => setShowActions(false)}
|
||||||
</span>
|
>
|
||||||
{message.data.head_contributions?.length > 0 && (
|
<div className="response-structured">
|
||||||
<span className="heads">
|
<Markdown content={synthesis} />
|
||||||
Heads: {message.data.head_contributions.map((h) => h.head_id).join(', ')}
|
{hasHeadData && (viewMode === 'explain' || viewMode === 'developer') && (
|
||||||
|
<div className="head-cards" role="list" aria-label="Head contributions">
|
||||||
|
{message.data!.head_contributions.map((h) => (
|
||||||
|
<div key={h.head_id} className="head-card" data-head={h.head_id} role="listitem">
|
||||||
|
<div className="head-card-dot" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span className="head-card-label">{h.head_id}</span>{' '}
|
||||||
|
<span className="head-card-text">{h.summary}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{message.data && (viewMode === 'explain' || viewMode === 'developer') && (
|
||||||
|
<div className="message-meta">
|
||||||
|
<span className="confidence">
|
||||||
|
Confidence: {(message.data.confidence_score * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showActions && onDelete && (
|
||||||
|
<div className="message-actions">
|
||||||
|
<button className="msg-action-btn" onClick={onDelete} aria-label="Delete message">Del</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
41
frontend/src/components/ErrorBoundary.test.tsx
Normal file
41
frontend/src/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ErrorBoundary } from './ErrorBoundary'
|
||||||
|
|
||||||
|
function ThrowingComponent() {
|
||||||
|
throw new Error('Test error')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
it('catches errors and shows fallback', () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowingComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeTruthy()
|
||||||
|
expect(screen.getByText('Test error')).toBeTruthy()
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders children when no error', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div>Working fine</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Working fine')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows custom fallback', () => {
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
render(
|
||||||
|
<ErrorBoundary fallback={<div>Custom fallback</div>}>
|
||||||
|
<ThrowingComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Custom fallback')).toBeTruthy()
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component } from 'react'
|
||||||
|
import type { ReactNode, ErrorInfo } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
onError?: (error: Error, info: ErrorInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught:', error, info)
|
||||||
|
this.props.onError?.(error, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) return this.props.fallback
|
||||||
|
return (
|
||||||
|
<div className="error-boundary-fallback" role="alert">
|
||||||
|
<h3>Something went wrong</h3>
|
||||||
|
<p className="muted">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
||||||
|
<button
|
||||||
|
className="theme-toggle"
|
||||||
|
onClick={() => this.setState({ hasError: false, error: null })}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
43
frontend/src/components/FilePreview.stories.tsx
Normal file
43
frontend/src/components/FilePreview.stories.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { FilePreview } from './FilePreview'
|
||||||
|
|
||||||
|
const meta: Meta<typeof FilePreview> = {
|
||||||
|
title: 'Components/FilePreview',
|
||||||
|
component: FilePreview,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof FilePreview>
|
||||||
|
|
||||||
|
export const TextFile: Story = {
|
||||||
|
args: {
|
||||||
|
file: {
|
||||||
|
name: 'readme.md',
|
||||||
|
type: 'text/markdown',
|
||||||
|
size: 1234,
|
||||||
|
content: '# Hello World\n\nThis is a markdown file.',
|
||||||
|
},
|
||||||
|
onRemove: () => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageFile: Story = {
|
||||||
|
args: {
|
||||||
|
file: {
|
||||||
|
name: 'avatar.png',
|
||||||
|
type: 'image/png',
|
||||||
|
size: 45000,
|
||||||
|
url: 'https://via.placeholder.com/150',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BinaryFile: Story = {
|
||||||
|
args: {
|
||||||
|
file: {
|
||||||
|
name: 'model.bin',
|
||||||
|
type: 'application/octet-stream',
|
||||||
|
size: 12500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
112
frontend/src/components/FilePreview.tsx
Normal file
112
frontend/src/components/FilePreview.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* File preview component for uploaded files and images.
|
||||||
|
*
|
||||||
|
* Renders inline previews for images, syntax-highlighted text for code files,
|
||||||
|
* and download links for binary files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export interface FileAttachment {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
url?: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
file: FileAttachment
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||||
|
const TEXT_EXTENSIONS = ['.txt', '.md', '.json', '.csv', '.py', '.js', '.ts', '.tsx', '.html', '.css', '.yaml', '.yml', '.toml']
|
||||||
|
|
||||||
|
function isImageFile(file: FileAttachment): boolean {
|
||||||
|
if (IMAGE_TYPES.includes(file.type)) return true
|
||||||
|
const ext = file.name.toLowerCase().split('.').pop() || ''
|
||||||
|
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextFile(file: FileAttachment): boolean {
|
||||||
|
if (file.type.startsWith('text/')) return true
|
||||||
|
const name = file.name.toLowerCase()
|
||||||
|
return TEXT_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilePreview({ file, onRemove }: FilePreviewProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
const toggleExpand = useCallback(() => setExpanded((p) => !p), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-preview" role="figure" aria-label={`File: ${file.name}`}>
|
||||||
|
<div className="file-preview-header">
|
||||||
|
<span className="file-preview-name" title={file.name}>{file.name}</span>
|
||||||
|
<span className="file-preview-size">{formatSize(file.size)}</span>
|
||||||
|
{onRemove && (
|
||||||
|
<button className="file-preview-remove" onClick={onRemove} aria-label={`Remove ${file.name}`}>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isImageFile(file) && file.url && (
|
||||||
|
<div className="file-preview-image">
|
||||||
|
<img src={file.url} alt={file.name} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isTextFile(file) && file.content && (
|
||||||
|
<div className="file-preview-text">
|
||||||
|
<button className="file-preview-toggle" onClick={toggleExpand}>
|
||||||
|
{expanded ? 'Collapse' : 'Expand'}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<pre className="file-preview-code">
|
||||||
|
<code>{file.content.slice(0, 5000)}</code>
|
||||||
|
{file.content.length > 5000 && <span className="truncated">... (truncated)</span>}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isImageFile(file) && !isTextFile(file) && (
|
||||||
|
<div className="file-preview-binary">
|
||||||
|
{file.url ? (
|
||||||
|
<a href={file.url} download={file.name}>Download</a>
|
||||||
|
) : (
|
||||||
|
<span>Binary file ({file.type || 'unknown type'})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePreviewListProps {
|
||||||
|
files: FileAttachment[]
|
||||||
|
onRemove?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilePreviewList({ files, onRemove }: FilePreviewListProps) {
|
||||||
|
if (files.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="file-preview-list" role="list" aria-label="Attached files">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<FilePreview
|
||||||
|
key={`${file.name}-${i}`}
|
||||||
|
file={file}
|
||||||
|
onRemove={onRemove ? () => onRemove(i) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
frontend/src/components/HeadCustomizer.tsx
Normal file
132
frontend/src/components/HeadCustomizer.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Head customization UI.
|
||||||
|
*
|
||||||
|
* Allows users to enable/disable individual heads and adjust weights.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const DEFAULT_HEADS = [
|
||||||
|
{ id: 'logic', name: 'Logic', description: 'Formal reasoning and argumentation', color: '#4fc3f7' },
|
||||||
|
{ id: 'research', name: 'Research', description: 'Deep research and source synthesis', color: '#81c784' },
|
||||||
|
{ id: 'systems', name: 'Systems', description: 'Systems thinking and architecture', color: '#ffb74d' },
|
||||||
|
{ id: 'strategy', name: 'Strategy', description: 'Strategic planning and foresight', color: '#ba68c8' },
|
||||||
|
{ id: 'product', name: 'Product', description: 'Product sense and user experience', color: '#f06292' },
|
||||||
|
{ id: 'security', name: 'Security', description: 'Threat modeling and security analysis', color: '#e57373' },
|
||||||
|
{ id: 'safety', name: 'Safety', description: 'Safety evaluation and risk assessment', color: '#4db6ac' },
|
||||||
|
{ id: 'reliability', name: 'Reliability', description: 'Reliability engineering and SRE', color: '#7986cb' },
|
||||||
|
{ id: 'cost', name: 'Cost', description: 'Cost optimization and efficiency', color: '#fff176' },
|
||||||
|
{ id: 'data', name: 'Data', description: 'Data analysis and ML insights', color: '#a1887f' },
|
||||||
|
{ id: 'devex', name: 'DevEx', description: 'Developer experience and ergonomics', color: '#90a4ae' },
|
||||||
|
{ id: 'witness', name: 'Witness', description: 'Final synthesis and consensus', color: '#ce93d8' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface HeadConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
enabled: boolean
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeadCustomizerProps {
|
||||||
|
onConfigChange?: (config: HeadConfig[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadCustomizer({ onConfigChange }: HeadCustomizerProps) {
|
||||||
|
const [heads, setHeads] = useState<HeadConfig[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('fusionagi-head-config')
|
||||||
|
if (saved) return JSON.parse(saved)
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
return DEFAULT_HEADS.map((h) => ({ ...h, enabled: true, weight: 1.0 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateHead = useCallback((id: string, updates: Partial<HeadConfig>) => {
|
||||||
|
const updated = heads.map((h) => h.id === id ? { ...h, ...updates } : h)
|
||||||
|
setHeads(updated)
|
||||||
|
localStorage.setItem('fusionagi-head-config', JSON.stringify(updated))
|
||||||
|
onConfigChange?.(updated)
|
||||||
|
}, [heads, onConfigChange])
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
const defaults = DEFAULT_HEADS.map((h) => ({ ...h, enabled: true, weight: 1.0 }))
|
||||||
|
setHeads(defaults)
|
||||||
|
localStorage.setItem('fusionagi-head-config', JSON.stringify(defaults))
|
||||||
|
onConfigChange?.(defaults)
|
||||||
|
}, [onConfigChange])
|
||||||
|
|
||||||
|
const enableAll = useCallback(() => {
|
||||||
|
const updated = heads.map((h) => ({ ...h, enabled: true }))
|
||||||
|
setHeads(updated)
|
||||||
|
localStorage.setItem('fusionagi-head-config', JSON.stringify(updated))
|
||||||
|
onConfigChange?.(updated)
|
||||||
|
}, [heads, onConfigChange])
|
||||||
|
|
||||||
|
const enabledCount = heads.filter((h) => h.enabled).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="head-customizer" role="region" aria-label="Head Configuration">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Head Configuration ({enabledCount}/{heads.length} active)</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button className="icon-btn" onClick={enableAll} title="Enable all" style={{ fontSize: '0.75rem' }}>Enable All</button>
|
||||||
|
<button className="icon-btn" onClick={resetAll} title="Reset to defaults" style={{ fontSize: '0.75rem' }}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="head-config-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.5rem' }}>
|
||||||
|
{heads.map((head) => (
|
||||||
|
<div
|
||||||
|
key={head.id}
|
||||||
|
className="head-config-card"
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--bg-tertiary)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `2px solid ${head.enabled ? head.color : 'var(--border)'}`,
|
||||||
|
opacity: head.enabled ? 1 : 0.5,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<span style={{ width: 12, height: 12, borderRadius: '50%', background: head.color, display: 'inline-block' }} />
|
||||||
|
<strong>{head.name}</strong>
|
||||||
|
</div>
|
||||||
|
<label className="toggle-switch" style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={head.enabled}
|
||||||
|
onChange={(e) => updateHead(head.id, { enabled: e.target.checked })}
|
||||||
|
aria-label={`${head.enabled ? 'Disable' : 'Enable'} ${head.name} head`}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{head.enabled ? 'On' : 'Off'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', margin: '0.25rem 0' }}>{head.description}</p>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
|
||||||
|
<label htmlFor={`weight-${head.id}`} style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>Weight:</label>
|
||||||
|
<input
|
||||||
|
id={`weight-${head.id}`}
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={head.weight}
|
||||||
|
onChange={(e) => updateHead(head.id, { weight: parseFloat(e.target.value) })}
|
||||||
|
disabled={!head.enabled}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={2}
|
||||||
|
aria-valuenow={head.weight}
|
||||||
|
aria-valuetext={`Weight: ${head.weight.toFixed(1)}`}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.7rem', minWidth: '2rem', textAlign: 'right' }}>{head.weight.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
frontend/src/components/Markdown.stories.tsx
Normal file
36
frontend/src/components/Markdown.stories.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Markdown> = {
|
||||||
|
title: 'Components/Markdown',
|
||||||
|
component: Markdown,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Markdown>
|
||||||
|
|
||||||
|
export const BasicText: Story = {
|
||||||
|
args: { content: 'Hello **world**! This is *italic* text.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeBlock: Story = {
|
||||||
|
args: { content: '```python\ndef greet(name):\n return f"Hello, {name}"\n```' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const List: Story = {
|
||||||
|
args: { content: '- First item\n- Second item\n- Third item' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Headings: Story = {
|
||||||
|
args: { content: '# Title\n## Subtitle\n### Section\nParagraph text.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Links: Story = {
|
||||||
|
args: { content: 'Visit [FusionAGI](https://github.com/fusionagi) for docs.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Mixed: Story = {
|
||||||
|
args: {
|
||||||
|
content: '## Code Example\n\nHere is a function:\n\n```javascript\nconst add = (a, b) => a + b\n```\n\n- Works with numbers\n- Returns sum\n\n**Note:** This is zero-dependency.',
|
||||||
|
},
|
||||||
|
}
|
||||||
44
frontend/src/components/Markdown.test.tsx
Normal file
44
frontend/src/components/Markdown.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Markdown } from './Markdown'
|
||||||
|
|
||||||
|
describe('Markdown', () => {
|
||||||
|
it('renders paragraphs', () => {
|
||||||
|
render(<Markdown content="Hello world" />)
|
||||||
|
expect(screen.getByText('Hello world')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders bold text', () => {
|
||||||
|
const { container } = render(<Markdown content="**bold text**" />)
|
||||||
|
expect(container.querySelector('strong')?.textContent).toBe('bold text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders inline code', () => {
|
||||||
|
const { container } = render(<Markdown content="Use `console.log`" />)
|
||||||
|
expect(container.querySelector('code')?.textContent).toBe('console.log')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders unordered lists', () => {
|
||||||
|
const { container } = render(<Markdown content={'- item one\n- item two'} />)
|
||||||
|
const items = container.querySelectorAll('li')
|
||||||
|
expect(items.length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders headings', () => {
|
||||||
|
const { container } = render(<Markdown content="# Title" />)
|
||||||
|
expect(container.querySelector('h1')?.textContent).toBe('Title')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders code blocks with copy button', () => {
|
||||||
|
const { container } = render(<Markdown content="```js\nconsole.log('hi')\n```" />)
|
||||||
|
expect(container.querySelector('.copy-code-btn')).toBeTruthy()
|
||||||
|
expect(container.querySelector('pre')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders links', () => {
|
||||||
|
const { container } = render(<Markdown content="[Click](https://example.com)" />)
|
||||||
|
const a = container.querySelector('a')
|
||||||
|
expect(a?.getAttribute('href')).toBe('https://example.com')
|
||||||
|
expect(a?.getAttribute('target')).toBe('_blank')
|
||||||
|
})
|
||||||
|
})
|
||||||
120
frontend/src/components/Markdown.tsx
Normal file
120
frontend/src/components/Markdown.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useRef, useEffect } from 'react'
|
||||||
|
import { useMarkdownWorker } from '../hooks/useMarkdownWorker'
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string): string {
|
||||||
|
let out = escapeHtml(text)
|
||||||
|
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||||
|
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(md: string): string {
|
||||||
|
const lines = md.split('\n')
|
||||||
|
const html: string[] = []
|
||||||
|
let inCode = false
|
||||||
|
let codeBlock: string[] = []
|
||||||
|
let codeLang = ''
|
||||||
|
let inList = false
|
||||||
|
let listType: 'ul' | 'ol' = 'ul'
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
if (inCode) {
|
||||||
|
const escaped = escapeHtml(codeBlock.join('\n'))
|
||||||
|
html.push(`<div class="code-block-wrapper"><button class="copy-code-btn" data-code="${encodeURIComponent(codeBlock.join('\n'))}">Copy</button><pre><code class="lang-${codeLang}">${escaped}</code></pre></div>`)
|
||||||
|
codeBlock = []
|
||||||
|
codeLang = ''
|
||||||
|
inCode = false
|
||||||
|
} else {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
codeLang = line.slice(3).trim()
|
||||||
|
inCode = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inCode) { codeBlock.push(line); continue }
|
||||||
|
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('### ')) {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
html.push(`<h3>${renderInline(trimmed.slice(4))}</h3>`)
|
||||||
|
} else if (trimmed.startsWith('## ')) {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
html.push(`<h2>${renderInline(trimmed.slice(3))}</h2>`)
|
||||||
|
} else if (trimmed.startsWith('# ')) {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
html.push(`<h1>${renderInline(trimmed.slice(2))}</h1>`)
|
||||||
|
} else if (trimmed.startsWith('> ')) {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
html.push(`<blockquote>${renderInline(trimmed.slice(2))}</blockquote>`)
|
||||||
|
} else if (/^[-*] /.test(trimmed)) {
|
||||||
|
if (!inList || listType !== 'ul') {
|
||||||
|
if (inList) html.push(`</${listType}>`)
|
||||||
|
html.push('<ul>'); inList = true; listType = 'ul'
|
||||||
|
}
|
||||||
|
html.push(`<li>${renderInline(trimmed.slice(2))}</li>`)
|
||||||
|
} else if (/^\d+\. /.test(trimmed)) {
|
||||||
|
if (!inList || listType !== 'ol') {
|
||||||
|
if (inList) html.push(`</${listType}>`)
|
||||||
|
html.push('<ol>'); inList = true; listType = 'ol'
|
||||||
|
}
|
||||||
|
html.push(`<li>${renderInline(trimmed.replace(/^\d+\. /, ''))}</li>`)
|
||||||
|
} else {
|
||||||
|
if (inList) { html.push(`</${listType}>`); inList = false }
|
||||||
|
html.push(`<p>${renderInline(trimmed)}</p>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (inCode) {
|
||||||
|
const escaped = escapeHtml(codeBlock.join('\n'))
|
||||||
|
html.push(`<div class="code-block-wrapper"><button class="copy-code-btn" data-code="${encodeURIComponent(codeBlock.join('\n'))}">Copy</button><pre><code>${escaped}</code></pre></div>`)
|
||||||
|
}
|
||||||
|
if (inList) html.push(`</${listType}>`)
|
||||||
|
return html.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Markdown({ content }: { content: string }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const workerHtml = useMarkdownWorker(content)
|
||||||
|
|
||||||
|
const handleClick = useCallback((e: MouseEvent) => {
|
||||||
|
const btn = (e.target as HTMLElement).closest('.copy-code-btn') as HTMLButtonElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const code = decodeURIComponent(btn.dataset.code || '')
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
btn.textContent = 'Copied!'
|
||||||
|
setTimeout(() => { btn.textContent = 'Copy' }, 2000)
|
||||||
|
}).catch(() => {
|
||||||
|
btn.textContent = 'Failed'
|
||||||
|
setTimeout(() => { btn.textContent = 'Copy' }, 2000)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('click', handleClick as EventListener)
|
||||||
|
return () => el.removeEventListener('click', handleClick as EventListener)
|
||||||
|
}, [handleClick])
|
||||||
|
|
||||||
|
// Use worker-rendered HTML if available, fall back to sync parser
|
||||||
|
const html = workerHtml !== content ? workerHtml : parseMarkdown(content)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="response-synthesis"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/components/MobileDrawer.tsx
Normal file
44
frontend/src/components/MobileDrawer.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface MobileDrawerProps {
|
||||||
|
children: ReactNode
|
||||||
|
title: string
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileDrawer({ children, title, visible }: MobileDrawerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="drawer-trigger"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label={`Open ${title}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="drawer-overlay" onClick={() => setOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="drawer-panel"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<div className="drawer-header">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<button className="icon-btn" onClick={() => setOpen(false)} aria-label="Close">X</button>
|
||||||
|
</div>
|
||||||
|
<div className="drawer-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/components/SearchFilter.stories.tsx
Normal file
22
frontend/src/components/SearchFilter.stories.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { SearchFilter } from './SearchFilter'
|
||||||
|
|
||||||
|
const meta: Meta<typeof SearchFilter> = {
|
||||||
|
title: 'Components/SearchFilter',
|
||||||
|
component: SearchFilter,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof SearchFilter>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { onFilter: (v: string) => console.log('Filter:', v) },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FastDebounce: Story = {
|
||||||
|
args: { onFilter: (v: string) => console.log('Filter:', v), debounceMs: 100 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SlowDebounce: Story = {
|
||||||
|
args: { onFilter: (v: string) => console.log('Filter:', v), debounceMs: 1000 },
|
||||||
|
}
|
||||||
29
frontend/src/components/SearchFilter.tsx
Normal file
29
frontend/src/components/SearchFilter.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface SearchFilterProps {
|
||||||
|
placeholder?: string
|
||||||
|
onFilter: (query: string) => void
|
||||||
|
debounceMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchFilter({ placeholder = 'Search...', onFilter, debounceMs = 300 }: SearchFilterProps) {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timer.current) clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => onFilter(value), debounceMs)
|
||||||
|
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||||
|
}, [value, debounceMs, onFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="search-filter"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={placeholder}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/components/Skeleton.stories.tsx
Normal file
22
frontend/src/components/Skeleton.stories.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Skeleton } from './Skeleton'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Skeleton> = {
|
||||||
|
title: 'Components/Skeleton',
|
||||||
|
component: Skeleton,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Skeleton>
|
||||||
|
|
||||||
|
export const SingleLine: Story = {
|
||||||
|
args: { width: '200px', height: '16px', count: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultipleLines: Story = {
|
||||||
|
args: { width: '100%', height: '14px', count: 4 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card: Story = {
|
||||||
|
args: { width: '300px', height: '120px', count: 1 },
|
||||||
|
}
|
||||||
20
frontend/src/components/Skeleton.test.tsx
Normal file
20
frontend/src/components/Skeleton.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { Skeleton, SkeletonCard, SkeletonGrid } from './Skeleton'
|
||||||
|
|
||||||
|
describe('Skeleton', () => {
|
||||||
|
it('renders specified count of skeleton lines', () => {
|
||||||
|
const { container } = render(<Skeleton count={3} />)
|
||||||
|
expect(container.querySelectorAll('.skeleton').length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders skeleton card', () => {
|
||||||
|
const { container } = render(<SkeletonCard />)
|
||||||
|
expect(container.querySelector('.skeleton-card')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders skeleton grid with count', () => {
|
||||||
|
const { container } = render(<SkeletonGrid count={4} />)
|
||||||
|
expect(container.querySelectorAll('.skeleton-card').length).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
45
frontend/src/components/Skeleton.tsx
Normal file
45
frontend/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
interface SkeletonProps {
|
||||||
|
width?: string
|
||||||
|
height?: string
|
||||||
|
count?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonLine({ width, height, className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`skeleton ${className || ''}`}
|
||||||
|
style={{ width: width || '100%', height: height || '1rem' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ width, height, count = 1, className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<SkeletonLine key={i} width={width} height={height} className={className} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="skeleton-card" aria-hidden="true">
|
||||||
|
<Skeleton width="40%" height="0.75rem" />
|
||||||
|
<Skeleton width="70%" height="1.2rem" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonGrid({ count = 6 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="status-grid" role="status" aria-label="Loading">
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<SkeletonCard key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/components/SparklineChart.tsx
Normal file
141
frontend/src/components/SparklineChart.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight SVG sparkline chart component.
|
||||||
|
*
|
||||||
|
* Zero-dependency mini chart for rendering inline metrics
|
||||||
|
* in the Admin and Ethics dashboards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
data: number[]
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
color?: string
|
||||||
|
fillColor?: string
|
||||||
|
strokeWidth?: number
|
||||||
|
showDots?: boolean
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sparkline({
|
||||||
|
data,
|
||||||
|
width = 120,
|
||||||
|
height = 32,
|
||||||
|
color = 'var(--accent)',
|
||||||
|
fillColor,
|
||||||
|
strokeWidth = 1.5,
|
||||||
|
showDots = false,
|
||||||
|
label,
|
||||||
|
}: SparklineProps) {
|
||||||
|
if (data.length < 2) {
|
||||||
|
return <svg width={width} height={height} aria-label={label || 'No data'} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(...data)
|
||||||
|
const max = Math.max(...data)
|
||||||
|
const range = max - min || 1
|
||||||
|
const padding = 2
|
||||||
|
|
||||||
|
const points = data.map((val, i) => {
|
||||||
|
const x = padding + (i / (data.length - 1)) * (width - 2 * padding)
|
||||||
|
const y = height - padding - ((val - min) / range) * (height - 2 * padding)
|
||||||
|
return { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
|
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||||
|
const fillD = fillColor
|
||||||
|
? `${pathD} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={label || `Sparkline chart with ${data.length} data points`}
|
||||||
|
>
|
||||||
|
{fillD && (
|
||||||
|
<path d={fillD} fill={fillColor} opacity={0.15} />
|
||||||
|
)}
|
||||||
|
<path d={pathD} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{showDots && points.map((p, i) => (
|
||||||
|
<circle key={i} cx={p.x} cy={p.y} r={2} fill={color} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
unit?: string
|
||||||
|
data?: number[]
|
||||||
|
trend?: 'up' | 'down' | 'flat'
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCard({ title, value, unit, data, trend, color = 'var(--accent)' }: MetricCardProps) {
|
||||||
|
const trendSymbol = trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : '\u2192'
|
||||||
|
const trendColor = trend === 'up' ? 'var(--color-success, #4caf50)' : trend === 'down' ? 'var(--color-error, #f44336)' : 'var(--text-muted)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="metric-card" role="group" aria-label={title}>
|
||||||
|
<div className="metric-header">
|
||||||
|
<span className="metric-title">{title}</span>
|
||||||
|
{trend && <span className="metric-trend" style={{ color: trendColor }}>{trendSymbol}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="metric-value">
|
||||||
|
<span className="metric-number">{value}</span>
|
||||||
|
{unit && <span className="metric-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
{data && data.length > 1 && (
|
||||||
|
<Sparkline data={data} color={color} fillColor={color} width={120} height={28} label={`${title} trend`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
data: { label: string; value: number; color?: string }[]
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
barColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BarChart({ data, width = 200, height = 60, barColor = 'var(--accent)' }: BarChartProps) {
|
||||||
|
if (data.length === 0) return null
|
||||||
|
const max = Math.max(...data.map((d) => d.value)) || 1
|
||||||
|
const barWidth = Math.max(8, (width - data.length * 4) / data.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height + 16} viewBox={`0 0 ${width} ${height + 16}`} role="img" aria-label="Bar chart">
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const barHeight = (d.value / max) * (height - 4)
|
||||||
|
const x = i * (barWidth + 4) + 2
|
||||||
|
const y = height - barHeight
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={barWidth}
|
||||||
|
height={barHeight}
|
||||||
|
fill={d.color || barColor}
|
||||||
|
rx={2}
|
||||||
|
opacity={0.85}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + barWidth / 2}
|
||||||
|
y={height + 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="var(--text-muted)"
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
frontend/src/components/Toast.stories.tsx
Normal file
26
frontend/src/components/Toast.stories.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react'
|
||||||
|
import { Toast } from './Toast'
|
||||||
|
|
||||||
|
const meta: Meta<typeof Toast> = {
|
||||||
|
title: 'Components/Toast',
|
||||||
|
component: Toast,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof Toast>
|
||||||
|
|
||||||
|
export const Info: Story = {
|
||||||
|
args: { message: 'Settings saved successfully.', type: 'info', onDismiss: () => {} },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: { message: 'Failed to connect to server.', type: 'error', onDismiss: () => {} },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Warning: Story = {
|
||||||
|
args: { message: 'Rate limit approaching.', type: 'warning', onDismiss: () => {} },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Success: Story = {
|
||||||
|
args: { message: 'Session created.', type: 'success', onDismiss: () => {} },
|
||||||
|
}
|
||||||
24
frontend/src/components/Toast.test.tsx
Normal file
24
frontend/src/components/Toast.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { ToastProvider, useToast } from './Toast'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => <ToastProvider>{children}</ToastProvider>
|
||||||
|
|
||||||
|
describe('Toast', () => {
|
||||||
|
it('shows toast message', () => {
|
||||||
|
function TestComponent() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
return <button onClick={() => toast('Test message', 'success')}>Show</button>
|
||||||
|
}
|
||||||
|
render(<ToastProvider><TestComponent /></ToastProvider>)
|
||||||
|
act(() => { screen.getByText('Show').click() })
|
||||||
|
expect(screen.getByText('Test message')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provides toast function via hook', () => {
|
||||||
|
const { result } = renderHook(() => useToast(), { wrapper })
|
||||||
|
expect(typeof result.current.toast).toBe('function')
|
||||||
|
})
|
||||||
|
})
|
||||||
40
frontend/src/components/Toast.tsx
Normal file
40
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState, useEffect, useCallback, createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: 'success' | 'error' | 'info' | 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toast: (message: string, type?: ToastItem['type']) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType>({ toast: () => {} })
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, type: ToastItem['type'] = 'info') => {
|
||||||
|
const id = nextId++
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }])
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-container" role="status" aria-live="polite">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className={`toast ${t.type}`}>{t.message}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
frontend/src/components/VirtualMessages.tsx
Normal file
84
frontend/src/components/VirtualMessages.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||||
|
import type { FinalResponse } from '../types'
|
||||||
|
import { ChatMessage } from './ChatMessage'
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
data?: FinalResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualMessagesProps {
|
||||||
|
messages: Message[]
|
||||||
|
viewMode: string
|
||||||
|
loading: boolean
|
||||||
|
onEditMessage?: (index: number) => void
|
||||||
|
onDeleteMessage?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUFFER = 10
|
||||||
|
const BATCH_SIZE = 30
|
||||||
|
|
||||||
|
export function VirtualMessages({ messages, viewMode, loading, onEditMessage, onDeleteMessage }: VirtualMessagesProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const endRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [visibleStart, setVisibleStart] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = Math.max(0, messages.length - BATCH_SIZE)
|
||||||
|
setVisibleStart(start)
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
if (el.scrollTop < 100 && visibleStart > 0) {
|
||||||
|
setVisibleStart((s) => Math.max(0, s - BUFFER))
|
||||||
|
}
|
||||||
|
}, [visibleStart])
|
||||||
|
|
||||||
|
const visibleMessages = messages.slice(visibleStart)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="messages"
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
role="log"
|
||||||
|
aria-label="Conversation"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{visibleStart > 0 && (
|
||||||
|
<button
|
||||||
|
className="load-more-btn"
|
||||||
|
onClick={() => setVisibleStart((s) => Math.max(0, s - BATCH_SIZE))}
|
||||||
|
>
|
||||||
|
Load {Math.min(BATCH_SIZE, visibleStart)} earlier messages
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{visibleMessages.map((msg, i) => {
|
||||||
|
const realIndex = visibleStart + i
|
||||||
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
key={realIndex}
|
||||||
|
message={msg}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onEdit={msg.role === 'user' && onEditMessage ? () => onEditMessage(realIndex) : undefined}
|
||||||
|
onDelete={onDeleteMessage ? () => onDeleteMessage(realIndex) : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{loading && (
|
||||||
|
<div className="loading-indicator" role="status" aria-live="assertive">
|
||||||
|
<div className="loading-dots" aria-hidden="true"><span /><span /><span /></div>
|
||||||
|
<span>Heads analyzing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/e2e.test.tsx
Normal file
56
frontend/src/e2e.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* End-to-end smoke tests for FusionAGI frontend.
|
||||||
|
*
|
||||||
|
* These tests verify that major UI components render correctly
|
||||||
|
* and basic navigation/interaction flows work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
// Mock fetch for API calls
|
||||||
|
globalThis.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ status: 'ok' }),
|
||||||
|
text: () => Promise.resolve(''),
|
||||||
|
} as Response)
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set auth token so app renders main interface instead of login
|
||||||
|
localStorage.setItem('fusionagi-token', 'test-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('E2E Smoke Tests', () => {
|
||||||
|
it('renders the main chat interface when authenticated', () => {
|
||||||
|
const { container } = render(<App />)
|
||||||
|
expect(container.querySelector('.app')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the logo', () => {
|
||||||
|
const { container } = render(<App />)
|
||||||
|
expect(container.querySelector('.logo')).toBeTruthy()
|
||||||
|
expect(container.querySelector('.logo')?.textContent).toBe('FusionAGI')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a prompt input', () => {
|
||||||
|
const { container } = render(<App />)
|
||||||
|
const input = container.querySelector('input[aria-label="Message input"]')
|
||||||
|
expect(input).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders navigation tabs', () => {
|
||||||
|
const { container } = render(<App />)
|
||||||
|
const nav = container.querySelector('[role="tablist"]')
|
||||||
|
expect(nav).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows login page when not authenticated', () => {
|
||||||
|
localStorage.removeItem('fusionagi-token')
|
||||||
|
const { container } = render(<App />)
|
||||||
|
const loginPage = container.querySelector('.login-page, form, input')
|
||||||
|
expect(loginPage).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
51
frontend/src/hooks/useAuth.test.ts
Normal file
51
frontend/src/hooks/useAuth.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useAuth } from './useAuth'
|
||||||
|
|
||||||
|
describe('useAuth', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts unauthenticated', () => {
|
||||||
|
const { result } = renderHook(() => useAuth())
|
||||||
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
|
expect(result.current.token).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('login sets token and persists', () => {
|
||||||
|
const { result } = renderHook(() => useAuth())
|
||||||
|
act(() => result.current.login('test-api-key'))
|
||||||
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
|
expect(result.current.token).toBe('test-api-key')
|
||||||
|
expect(localStorage.getItem('fusionagi-token')).toBe('test-api-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout clears token', () => {
|
||||||
|
const { result } = renderHook(() => useAuth())
|
||||||
|
act(() => result.current.login('test-key'))
|
||||||
|
act(() => result.current.logout())
|
||||||
|
expect(result.current.isAuthenticated).toBe(false)
|
||||||
|
expect(localStorage.getItem('fusionagi-token')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authHeaders includes bearer token when authenticated', () => {
|
||||||
|
const { result } = renderHook(() => useAuth())
|
||||||
|
act(() => result.current.login('my-key'))
|
||||||
|
const headers = result.current.authHeaders()
|
||||||
|
expect(headers['Authorization']).toBe('Bearer my-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authHeaders has no auth when unauthenticated', () => {
|
||||||
|
const { result } = renderHook(() => useAuth())
|
||||||
|
const headers = result.current.authHeaders()
|
||||||
|
expect(headers['Authorization']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores token from localStorage', () => {
|
||||||
|
localStorage.setItem('fusionagi-token', 'saved-key')
|
||||||
|
const { result } = renderHook(() => useAuth())
|
||||||
|
expect(result.current.isAuthenticated).toBe(true)
|
||||||
|
expect(result.current.token).toBe('saved-key')
|
||||||
|
})
|
||||||
|
})
|
||||||
27
frontend/src/hooks/useAuth.ts
Normal file
27
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [token, setToken] = useState<string | null>(() =>
|
||||||
|
localStorage.getItem('fusionagi-token')
|
||||||
|
)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const login = useCallback((apiKey: string) => {
|
||||||
|
localStorage.setItem('fusionagi-token', apiKey)
|
||||||
|
setToken(apiKey)
|
||||||
|
setError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('fusionagi-token')
|
||||||
|
setToken(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const authHeaders = useCallback((): Record<string, string> => {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
return headers
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
return { token, error, setError, login, logout, authHeaders, isAuthenticated: !!token }
|
||||||
|
}
|
||||||
47
frontend/src/hooks/useChatHistory.test.ts
Normal file
47
frontend/src/hooks/useChatHistory.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useChatHistory } from './useChatHistory'
|
||||||
|
|
||||||
|
describe('useChatHistory', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts empty', () => {
|
||||||
|
const { result } = renderHook(() => useChatHistory())
|
||||||
|
expect(result.current.messages).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds messages', () => {
|
||||||
|
const { result } = renderHook(() => useChatHistory())
|
||||||
|
act(() => { result.current.addMessage('user', 'Hello') })
|
||||||
|
expect(result.current.messages.length).toBe(1)
|
||||||
|
expect(result.current.messages[0].role).toBe('user')
|
||||||
|
expect(result.current.messages[0].content).toBe('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes messages', () => {
|
||||||
|
const { result } = renderHook(() => useChatHistory())
|
||||||
|
act(() => { result.current.addMessage('user', 'First') })
|
||||||
|
act(() => { result.current.addMessage('assistant', 'Second') })
|
||||||
|
expect(result.current.messages.length).toBe(2)
|
||||||
|
act(() => { result.current.deleteMessage(0) })
|
||||||
|
expect(result.current.messages.length).toBe(1)
|
||||||
|
expect(result.current.messages[0].content).toBe('Second')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears history', () => {
|
||||||
|
const { result } = renderHook(() => useChatHistory())
|
||||||
|
act(() => { result.current.addMessage('user', 'Test') })
|
||||||
|
act(() => { result.current.clearHistory() })
|
||||||
|
expect(result.current.messages).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists to localStorage', () => {
|
||||||
|
const { result } = renderHook(() => useChatHistory())
|
||||||
|
act(() => { result.current.addMessage('user', 'Persisted') })
|
||||||
|
const stored = localStorage.getItem('fusionagi-chat-history')
|
||||||
|
expect(stored).toBeTruthy()
|
||||||
|
expect(JSON.parse(stored!)[0].content).toBe('Persisted')
|
||||||
|
})
|
||||||
|
})
|
||||||
96
frontend/src/hooks/useChatHistory.ts
Normal file
96
frontend/src/hooks/useChatHistory.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { saveMessage, getMessages, clearMessages, isIndexedDBAvailable } from './useIndexedDB'
|
||||||
|
import type { FinalResponse } from '../types'
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
data?: FinalResponse
|
||||||
|
id: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fusionagi-chat-history'
|
||||||
|
const MAX_MESSAGES = 500
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromLocalStorage(): ChatMessage[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToLocalStorage(messages: ChatMessage[]) {
|
||||||
|
try {
|
||||||
|
const trimmed = messages.slice(-MAX_MESSAGES)
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
|
||||||
|
} catch { /* storage full */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const useIDB = isIndexedDBAvailable()
|
||||||
|
|
||||||
|
export function useChatHistory() {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(() => loadFromLocalStorage())
|
||||||
|
|
||||||
|
// On mount, try loading from IndexedDB (async)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!useIDB) return
|
||||||
|
getMessages(undefined, MAX_MESSAGES).then((idbMsgs) => {
|
||||||
|
if (idbMsgs.length > 0) {
|
||||||
|
const mapped: ChatMessage[] = idbMsgs.map((m) => ({
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
content: m.content,
|
||||||
|
id: m.id || generateId(),
|
||||||
|
timestamp: m.timestamp || Date.now(),
|
||||||
|
}))
|
||||||
|
setMessages(mapped)
|
||||||
|
}
|
||||||
|
}).catch(() => { /* IDB unavailable, using localStorage */ })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Persist to localStorage as fallback
|
||||||
|
useEffect(() => {
|
||||||
|
saveToLocalStorage(messages)
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const addMessage = useCallback((role: 'user' | 'assistant', content: string, data?: FinalResponse) => {
|
||||||
|
const msg: ChatMessage = { role, content, data, id: generateId(), timestamp: Date.now() }
|
||||||
|
setMessages((prev) => [...prev, msg])
|
||||||
|
// Also persist to IndexedDB
|
||||||
|
if (useIDB) {
|
||||||
|
saveMessage({ id: msg.id, role, content, timestamp: msg.timestamp, sessionId: 'default' }).catch(() => {})
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const editMessage = useCallback((index: number, newContent: string) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
if (updated[index] && updated[index].role === 'user') {
|
||||||
|
updated[index] = { ...updated[index], content: newContent }
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteMessage = useCallback((index: number) => {
|
||||||
|
setMessages((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearHistory = useCallback(() => {
|
||||||
|
setMessages([])
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
if (useIDB) {
|
||||||
|
clearMessages().catch(() => {})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { messages, addMessage, editMessage, deleteMessage, clearHistory, setMessages }
|
||||||
|
}
|
||||||
59
frontend/src/hooks/useExport.test.ts
Normal file
59
frontend/src/hooks/useExport.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { useExport } from './useExport'
|
||||||
|
|
||||||
|
let clickSpy: ReturnType<typeof vi.fn>
|
||||||
|
let appendSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
let removeSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clickSpy = vi.fn()
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||||
|
appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
||||||
|
// Intercept anchor element clicks
|
||||||
|
if (node instanceof HTMLAnchorElement) {
|
||||||
|
clickSpy()
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
removeSpy = vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useExport', () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: 'user' as const, content: 'Hello', timestamp: 1000 },
|
||||||
|
{ role: 'assistant' as const, content: 'Hi there', timestamp: 2000 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('exports markdown format correctly', () => {
|
||||||
|
const { result } = renderHook(() => useExport())
|
||||||
|
result.current.exportMarkdown(messages, 'Test Chat')
|
||||||
|
expect(appendSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports JSON format correctly', () => {
|
||||||
|
const { result } = renderHook(() => useExport())
|
||||||
|
result.current.exportJSON(messages, 'Test Chat')
|
||||||
|
expect(appendSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports plain text format correctly', () => {
|
||||||
|
const { result } = renderHook(() => useExport())
|
||||||
|
result.current.exportText(messages)
|
||||||
|
expect(appendSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copies to clipboard', async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
})
|
||||||
|
const { result } = renderHook(() => useExport())
|
||||||
|
const success = await result.current.copyToClipboard(messages)
|
||||||
|
expect(success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
105
frontend/src/hooks/useExport.ts
Normal file
105
frontend/src/hooks/useExport.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Conversation export hook.
|
||||||
|
*
|
||||||
|
* Exports chat history as Markdown, JSON, or plain text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExportMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts?: number): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMarkdown(messages: ExportMessage[], title?: string): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push(`# ${title || 'FusionAGI Conversation'}`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`*Exported: ${new Date().toISOString()}*`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('---')
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = formatTimestamp(msg.timestamp)
|
||||||
|
const prefix = msg.role === 'user' ? '**You**' : '**FusionAGI**'
|
||||||
|
lines.push(`### ${prefix}${ts ? ` (${ts})` : ''}`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(msg.content)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJSON(messages: ExportMessage[], title?: string): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
title: title || 'FusionAGI Conversation',
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
message_count: messages.length,
|
||||||
|
messages: messages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
timestamp: m.timestamp ? new Date(m.timestamp).toISOString() : null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlainText(messages: ExportMessage[]): string {
|
||||||
|
return messages
|
||||||
|
.map((m) => {
|
||||||
|
const label = m.role === 'user' ? 'You' : 'FusionAGI'
|
||||||
|
const ts = formatTimestamp(m.timestamp)
|
||||||
|
return `[${label}${ts ? ` ${ts}` : ''}]\n${m.content}`
|
||||||
|
})
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(content: string, filename: string, mimeType: string): void {
|
||||||
|
const blob = new Blob([content], { type: mimeType })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExport() {
|
||||||
|
const exportMarkdown = (messages: ExportMessage[], title?: string) => {
|
||||||
|
const content = toMarkdown(messages, title)
|
||||||
|
download(content, `fusionagi-chat-${Date.now()}.md`, 'text/markdown')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportJSON = (messages: ExportMessage[], title?: string) => {
|
||||||
|
const content = toJSON(messages, title)
|
||||||
|
download(content, `fusionagi-chat-${Date.now()}.json`, 'application/json')
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportText = (messages: ExportMessage[]) => {
|
||||||
|
const content = toPlainText(messages)
|
||||||
|
download(content, `fusionagi-chat-${Date.now()}.txt`, 'text/plain')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (messages: ExportMessage[]) => {
|
||||||
|
const content = toMarkdown(messages)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exportMarkdown, exportJSON, exportText, copyToClipboard }
|
||||||
|
}
|
||||||
20
frontend/src/hooks/useIndexedDB.test.ts
Normal file
20
frontend/src/hooks/useIndexedDB.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { isIndexedDBAvailable } from './useIndexedDB'
|
||||||
|
|
||||||
|
describe('useIndexedDB', () => {
|
||||||
|
it('detects IndexedDB availability', () => {
|
||||||
|
const result = isIndexedDBAvailable()
|
||||||
|
// In JSDOM, indexedDB may or may not be defined
|
||||||
|
expect(typeof result).toBe('boolean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when indexedDB is undefined', () => {
|
||||||
|
const original = globalThis.indexedDB
|
||||||
|
try {
|
||||||
|
Object.defineProperty(globalThis, 'indexedDB', { value: undefined, configurable: true })
|
||||||
|
expect(isIndexedDBAvailable()).toBe(false)
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'indexedDB', { value: original, configurable: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
179
frontend/src/hooks/useIndexedDB.ts
Normal file
179
frontend/src/hooks/useIndexedDB.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB-backed chat persistence.
|
||||||
|
*
|
||||||
|
* Replaces localStorage for larger chat histories (no 5MB limit).
|
||||||
|
* Falls back to localStorage if IndexedDB is unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DB_NAME = 'fusionagi'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_NAME = 'chat_messages'
|
||||||
|
const SESSION_STORE = 'sessions'
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
sessionId?: string
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatSession {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
store.createIndex('sessionId', 'sessionId', { unique: false })
|
||||||
|
store.createIndex('timestamp', 'timestamp', { unique: false })
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
||||||
|
const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: 'id' })
|
||||||
|
sessionStore.createIndex('updatedAt', 'updatedAt', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMessage(message: ChatMessage): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
tx.objectStore(STORE_NAME).put(message)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Fallback: do nothing, localStorage-based persistence handles it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessages(sessionId?: string, limit = 500): Promise<ChatMessage[]> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let request: IDBRequest
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const index = store.index('sessionId')
|
||||||
|
request = index.getAll(sessionId, limit)
|
||||||
|
} else {
|
||||||
|
request = store.getAll(null, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const results = (request.result as ChatMessage[])
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
.slice(-limit)
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearMessages(sessionId?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const index = store.index('sessionId')
|
||||||
|
const request = index.openCursor(sessionId)
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result
|
||||||
|
if (cursor) {
|
||||||
|
cursor.delete()
|
||||||
|
cursor.continue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Fallback: localStorage clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSession(session: ChatSession): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const tx = db.transaction(SESSION_STORE, 'readwrite')
|
||||||
|
tx.objectStore(SESSION_STORE).put(session)
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
tx.oncomplete = () => resolve()
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Fallback: do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessions(limit = 50): Promise<ChatSession[]> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const tx = db.transaction(SESSION_STORE, 'readonly')
|
||||||
|
const store = tx.objectStore(SESSION_STORE)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.getAll(null, limit)
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const results = (request.result as ChatSession[])
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(sessionId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
|
||||||
|
// Delete session
|
||||||
|
const tx1 = db.transaction(SESSION_STORE, 'readwrite')
|
||||||
|
tx1.objectStore(SESSION_STORE).delete(sessionId)
|
||||||
|
|
||||||
|
// Delete associated messages
|
||||||
|
await clearMessages(sessionId)
|
||||||
|
} catch {
|
||||||
|
// Fallback: do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIndexedDBAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
return typeof indexedDB !== 'undefined' && indexedDB !== null
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/src/hooks/useKeyboard.ts
Normal file
44
frontend/src/hooks/useKeyboard.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface KeyboardShortcuts {
|
||||||
|
onSend?: () => void
|
||||||
|
onSearch?: () => void
|
||||||
|
onDismiss?: () => void
|
||||||
|
onToggleTheme?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboard({ onSend, onSearch, onDismiss, onToggleTheme }: KeyboardShortcuts) {
|
||||||
|
const handler = useCallback((e: KeyboardEvent) => {
|
||||||
|
const meta = e.metaKey || e.ctrlKey
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onDismiss?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta && e.key === 'Enter' && onSend) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSend()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta && e.key === 'k' && onSearch) {
|
||||||
|
e.preventDefault()
|
||||||
|
onSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta && e.key === 'j' && onToggleTheme && !isInput) {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggleTheme()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [onSend, onSearch, onDismiss, onToggleTheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [handler])
|
||||||
|
}
|
||||||
73
frontend/src/hooks/useMarkdownWorker.ts
Normal file
73
frontend/src/hooks/useMarkdownWorker.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Hook for offloading markdown rendering to a Web Worker.
|
||||||
|
*
|
||||||
|
* Falls back to synchronous rendering if Workers are unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
let workerInstance: Worker | null = null
|
||||||
|
let workerFailed = false
|
||||||
|
const pendingCallbacks = new Map<string, (html: string) => void>()
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
function getWorker(): Worker | null {
|
||||||
|
if (workerFailed) return null
|
||||||
|
if (workerInstance) return workerInstance
|
||||||
|
|
||||||
|
try {
|
||||||
|
workerInstance = new Worker(
|
||||||
|
new URL('../workers/markdown.worker.ts', import.meta.url),
|
||||||
|
{ type: 'module' },
|
||||||
|
)
|
||||||
|
workerInstance.onmessage = (e: MessageEvent) => {
|
||||||
|
const { id, html } = e.data
|
||||||
|
const cb = pendingCallbacks.get(id)
|
||||||
|
if (cb) {
|
||||||
|
cb(html)
|
||||||
|
pendingCallbacks.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workerInstance.onerror = () => {
|
||||||
|
workerFailed = true
|
||||||
|
workerInstance = null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
workerFailed = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return workerInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkdownWorker(text: string): string {
|
||||||
|
const [html, setHtml] = useState('')
|
||||||
|
const idRef = useRef<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!text) {
|
||||||
|
setHtml('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = getWorker()
|
||||||
|
if (!worker) {
|
||||||
|
// Fallback: synchronous inline render
|
||||||
|
setHtml(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `md_${nextId++}`
|
||||||
|
idRef.current = id
|
||||||
|
pendingCallbacks.set(id, (rendered) => {
|
||||||
|
if (idRef.current === id) setHtml(rendered)
|
||||||
|
})
|
||||||
|
worker.postMessage({ id, text })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pendingCallbacks.delete(id)
|
||||||
|
}
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
51
frontend/src/hooks/useMultiSession.test.ts
Normal file
51
frontend/src/hooks/useMultiSession.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useMultiSession } from './useMultiSession'
|
||||||
|
|
||||||
|
const mockStorage: Record<string, string> = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.keys(mockStorage).forEach((k) => delete mockStorage[k])
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => mockStorage[key] || null)
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
|
||||||
|
mockStorage[key] = value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useMultiSession', () => {
|
||||||
|
it('initializes with one session', () => {
|
||||||
|
const { result } = renderHook(() => useMultiSession())
|
||||||
|
expect(result.current.sessions).toHaveLength(1)
|
||||||
|
expect(result.current.activeSession.active).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates new sessions', () => {
|
||||||
|
const { result } = renderHook(() => useMultiSession())
|
||||||
|
act(() => { result.current.createSession('Test Chat') })
|
||||||
|
expect(result.current.sessions).toHaveLength(2)
|
||||||
|
expect(result.current.activeSession.name).toBe('Test Chat')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches between sessions', () => {
|
||||||
|
const { result } = renderHook(() => useMultiSession())
|
||||||
|
const firstId = result.current.sessions[0].id
|
||||||
|
act(() => { result.current.createSession('Second') })
|
||||||
|
act(() => { result.current.switchSession(firstId) })
|
||||||
|
expect(result.current.activeSession.id).toBe(firstId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renames a session', () => {
|
||||||
|
const { result } = renderHook(() => useMultiSession())
|
||||||
|
const id = result.current.sessions[0].id
|
||||||
|
act(() => { result.current.renameSession(id, 'Renamed') })
|
||||||
|
expect(result.current.sessions[0].name).toBe('Renamed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a session and creates default if empty', () => {
|
||||||
|
const { result } = renderHook(() => useMultiSession())
|
||||||
|
const id = result.current.sessions[0].id
|
||||||
|
act(() => { result.current.deleteSession(id) })
|
||||||
|
expect(result.current.sessions).toHaveLength(1)
|
||||||
|
expect(result.current.sessions[0].active).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
114
frontend/src/hooks/useMultiSession.ts
Normal file
114
frontend/src/hooks/useMultiSession.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Multi-session management hook.
|
||||||
|
*
|
||||||
|
* Allows users to manage parallel conversations with session switching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export interface SessionTab {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: number
|
||||||
|
messageCount: number
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'fusionagi-sessions'
|
||||||
|
|
||||||
|
function loadSessions(): SessionTab[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSessions(sessions: SessionTab[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||||
|
} catch {
|
||||||
|
// Storage full or unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMultiSession() {
|
||||||
|
const [sessions, setSessions] = useState<SessionTab[]>(() => {
|
||||||
|
const saved = loadSessions()
|
||||||
|
if (saved.length === 0) {
|
||||||
|
const initial: SessionTab = {
|
||||||
|
id: `session_${Date.now()}`,
|
||||||
|
name: 'New Chat',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
messageCount: 0,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
saveSessions([initial])
|
||||||
|
return [initial]
|
||||||
|
}
|
||||||
|
return saved
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSession = sessions.find((s) => s.active) || sessions[0]
|
||||||
|
|
||||||
|
const createSession = useCallback((name?: string) => {
|
||||||
|
const newSession: SessionTab = {
|
||||||
|
id: `session_${Date.now()}`,
|
||||||
|
name: name || `Chat ${sessions.length + 1}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
messageCount: 0,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
const updated = sessions.map((s) => ({ ...s, active: false }))
|
||||||
|
updated.push(newSession)
|
||||||
|
setSessions(updated)
|
||||||
|
saveSessions(updated)
|
||||||
|
return newSession
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
const switchSession = useCallback((sessionId: string) => {
|
||||||
|
const updated = sessions.map((s) => ({ ...s, active: s.id === sessionId }))
|
||||||
|
setSessions(updated)
|
||||||
|
saveSessions(updated)
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
const renameSession = useCallback((sessionId: string, name: string) => {
|
||||||
|
const updated = sessions.map((s) => s.id === sessionId ? { ...s, name } : s)
|
||||||
|
setSessions(updated)
|
||||||
|
saveSessions(updated)
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
const deleteSession = useCallback((sessionId: string) => {
|
||||||
|
let updated = sessions.filter((s) => s.id !== sessionId)
|
||||||
|
if (updated.length === 0) {
|
||||||
|
updated = [{
|
||||||
|
id: `session_${Date.now()}`,
|
||||||
|
name: 'New Chat',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
messageCount: 0,
|
||||||
|
active: true,
|
||||||
|
}]
|
||||||
|
} else if (!updated.some((s) => s.active)) {
|
||||||
|
updated[0].active = true
|
||||||
|
}
|
||||||
|
setSessions(updated)
|
||||||
|
saveSessions(updated)
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
const updateMessageCount = useCallback((sessionId: string, count: number) => {
|
||||||
|
const updated = sessions.map((s) => s.id === sessionId ? { ...s, messageCount: count } : s)
|
||||||
|
setSessions(updated)
|
||||||
|
saveSessions(updated)
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
activeSession,
|
||||||
|
createSession,
|
||||||
|
switchSession,
|
||||||
|
renameSession,
|
||||||
|
deleteSession,
|
||||||
|
updateMessageCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
117
frontend/src/hooks/useNotifications.ts
Normal file
117
frontend/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Push notification hook for background task completion.
|
||||||
|
*
|
||||||
|
* Listens to WebSocket events for task status changes and
|
||||||
|
* shows browser notifications when tasks complete or fail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface TaskNotification {
|
||||||
|
id: string
|
||||||
|
taskId: string
|
||||||
|
type: 'task_complete' | 'task_failed' | 'task_progress' | 'system'
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
timestamp: number
|
||||||
|
read: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const [notifications, setNotifications] = useState<TaskNotification[]>([])
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
const permissionRef = useRef<NotificationPermission>('default')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof Notification !== 'undefined') {
|
||||||
|
permissionRef.current = Notification.permission
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async () => {
|
||||||
|
if (typeof Notification === 'undefined') return false
|
||||||
|
const result = await Notification.requestPermission()
|
||||||
|
permissionRef.current = result
|
||||||
|
return result === 'granted'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addNotification = useCallback(
|
||||||
|
(notif: Omit<TaskNotification, 'id' | 'timestamp' | 'read'>) => {
|
||||||
|
const full: TaskNotification = {
|
||||||
|
...notif,
|
||||||
|
id: `notif-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
read: false,
|
||||||
|
}
|
||||||
|
setNotifications((prev) => [full, ...prev].slice(0, 50))
|
||||||
|
setUnreadCount((prev) => prev + 1)
|
||||||
|
|
||||||
|
// Show browser notification if permitted
|
||||||
|
if (typeof Notification !== 'undefined' && permissionRef.current === 'granted') {
|
||||||
|
new Notification(full.title, {
|
||||||
|
body: full.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
tag: full.taskId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const markRead = useCallback((id: string) => {
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, read: true } : n)),
|
||||||
|
)
|
||||||
|
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const markAllRead = useCallback(() => {
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
||||||
|
setUnreadCount(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setNotifications([])
|
||||||
|
setUnreadCount(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle WebSocket events for task notifications
|
||||||
|
const handleWSEvent = useCallback(
|
||||||
|
(event: { type: string; data: Record<string, unknown> }) => {
|
||||||
|
if (event.type === 'task_complete') {
|
||||||
|
addNotification({
|
||||||
|
taskId: String(event.data.task_id || ''),
|
||||||
|
type: 'task_complete',
|
||||||
|
title: 'Task Complete',
|
||||||
|
body: String(event.data.summary || 'Background task finished successfully.'),
|
||||||
|
})
|
||||||
|
} else if (event.type === 'task_failed') {
|
||||||
|
addNotification({
|
||||||
|
taskId: String(event.data.task_id || ''),
|
||||||
|
type: 'task_failed',
|
||||||
|
title: 'Task Failed',
|
||||||
|
body: String(event.data.error || 'Background task encountered an error.'),
|
||||||
|
})
|
||||||
|
} else if (event.type === 'task_progress') {
|
||||||
|
addNotification({
|
||||||
|
taskId: String(event.data.task_id || ''),
|
||||||
|
type: 'task_progress',
|
||||||
|
title: 'Task Progress',
|
||||||
|
body: String(event.data.message || `Progress: ${event.data.progress || 0}%`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addNotification],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
requestPermission,
|
||||||
|
addNotification,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
clearAll,
|
||||||
|
handleWSEvent,
|
||||||
|
}
|
||||||
|
}
|
||||||
107
frontend/src/hooks/useStore.ts
Normal file
107
frontend/src/hooks/useStore.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight global state management for FusionAGI.
|
||||||
|
*
|
||||||
|
* Zero-dependency alternative to Zustand. Uses React context + useReducer
|
||||||
|
* to provide centralized state management across the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useReducer, useCallback } from 'react'
|
||||||
|
import type { Dispatch } from 'react'
|
||||||
|
|
||||||
|
export type Page = 'chat' | 'admin' | 'ethics' | 'settings'
|
||||||
|
export type ViewMode = 'normal' | 'explain' | 'developer'
|
||||||
|
export type Theme = 'dark' | 'light'
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
page: Page
|
||||||
|
viewMode: ViewMode
|
||||||
|
theme: Theme
|
||||||
|
loading: boolean
|
||||||
|
networkError: string | null
|
||||||
|
sessionId: string | null
|
||||||
|
isMobile: boolean
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppAction =
|
||||||
|
| { type: 'SET_PAGE'; page: Page }
|
||||||
|
| { type: 'SET_VIEW_MODE'; mode: ViewMode }
|
||||||
|
| { type: 'SET_THEME'; theme: Theme }
|
||||||
|
| { type: 'TOGGLE_THEME' }
|
||||||
|
| { type: 'SET_LOADING'; loading: boolean }
|
||||||
|
| { type: 'SET_ERROR'; error: string | null }
|
||||||
|
| { type: 'SET_SESSION'; sessionId: string | null }
|
||||||
|
| { type: 'SET_MOBILE'; isMobile: boolean }
|
||||||
|
| { type: 'SET_PROMPT'; prompt: string }
|
||||||
|
|
||||||
|
export const initialState: AppState = {
|
||||||
|
page: 'chat',
|
||||||
|
viewMode: 'normal',
|
||||||
|
theme: (typeof localStorage !== 'undefined'
|
||||||
|
? (localStorage.getItem('fusionagi-theme') as Theme) || 'dark'
|
||||||
|
: 'dark') as Theme,
|
||||||
|
loading: false,
|
||||||
|
networkError: null,
|
||||||
|
sessionId: null,
|
||||||
|
isMobile: typeof window !== 'undefined' ? window.innerWidth <= 768 : false,
|
||||||
|
prompt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appReducer(state: AppState, action: AppAction): AppState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_PAGE':
|
||||||
|
return { ...state, page: action.page }
|
||||||
|
case 'SET_VIEW_MODE':
|
||||||
|
return { ...state, viewMode: action.mode }
|
||||||
|
case 'SET_THEME': {
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem('fusionagi-theme', action.theme)
|
||||||
|
return { ...state, theme: action.theme }
|
||||||
|
}
|
||||||
|
case 'TOGGLE_THEME': {
|
||||||
|
const next = state.theme === 'dark' ? 'light' : 'dark'
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.setItem('fusionagi-theme', next)
|
||||||
|
return { ...state, theme: next }
|
||||||
|
}
|
||||||
|
case 'SET_LOADING':
|
||||||
|
return { ...state, loading: action.loading }
|
||||||
|
case 'SET_ERROR':
|
||||||
|
return { ...state, networkError: action.error }
|
||||||
|
case 'SET_SESSION':
|
||||||
|
return { ...state, sessionId: action.sessionId }
|
||||||
|
case 'SET_MOBILE':
|
||||||
|
return { ...state, isMobile: action.isMobile }
|
||||||
|
case 'SET_PROMPT':
|
||||||
|
return { ...state, prompt: action.prompt }
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreContextValue {
|
||||||
|
state: AppState
|
||||||
|
dispatch: Dispatch<AppAction>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StoreContext = createContext<StoreContextValue>({
|
||||||
|
state: initialState,
|
||||||
|
dispatch: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useStore(): StoreContextValue {
|
||||||
|
return useContext(StoreContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppState() {
|
||||||
|
const { state, dispatch } = useStore()
|
||||||
|
|
||||||
|
const setPage = useCallback((page: Page) => dispatch({ type: 'SET_PAGE', page }), [dispatch])
|
||||||
|
const setViewMode = useCallback((mode: ViewMode) => dispatch({ type: 'SET_VIEW_MODE', mode }), [dispatch])
|
||||||
|
const toggleTheme = useCallback(() => dispatch({ type: 'TOGGLE_THEME' }), [dispatch])
|
||||||
|
const setLoading = useCallback((loading: boolean) => dispatch({ type: 'SET_LOADING', loading }), [dispatch])
|
||||||
|
const setError = useCallback((error: string | null) => dispatch({ type: 'SET_ERROR', error }), [dispatch])
|
||||||
|
const setPrompt = useCallback((prompt: string) => dispatch({ type: 'SET_PROMPT', prompt }), [dispatch])
|
||||||
|
|
||||||
|
return { ...state, setPage, setViewMode, toggleTheme, setLoading, setError, setPrompt, dispatch }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useReducer }
|
||||||
34
frontend/src/hooks/useTheme.test.ts
Normal file
34
frontend/src/hooks/useTheme.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useTheme } from './useTheme'
|
||||||
|
|
||||||
|
describe('useTheme', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to dark theme', () => {
|
||||||
|
const { result } = renderHook(() => useTheme())
|
||||||
|
expect(result.current.theme).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles between dark and light', () => {
|
||||||
|
const { result } = renderHook(() => useTheme())
|
||||||
|
act(() => result.current.toggle())
|
||||||
|
expect(result.current.theme).toBe('light')
|
||||||
|
act(() => result.current.toggle())
|
||||||
|
expect(result.current.theme).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists to localStorage', () => {
|
||||||
|
const { result } = renderHook(() => useTheme())
|
||||||
|
act(() => result.current.toggle())
|
||||||
|
expect(localStorage.getItem('fusionagi-theme')).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores from localStorage', () => {
|
||||||
|
localStorage.setItem('fusionagi-theme', 'light')
|
||||||
|
const { result } = renderHook(() => useTheme())
|
||||||
|
expect(result.current.theme).toBe('light')
|
||||||
|
})
|
||||||
|
})
|
||||||
39
frontend/src/hooks/useTheme.ts
Normal file
39
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import type { Theme } from '../types'
|
||||||
|
|
||||||
|
function getSystemTheme(): Theme {
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: light)').matches) {
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const saved = localStorage.getItem('fusionagi-theme')
|
||||||
|
if (saved === 'light' || saved === 'dark') return saved
|
||||||
|
return getSystemTheme()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
localStorage.setItem('fusionagi-theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: light)')
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
if (!localStorage.getItem('fusionagi-theme')) {
|
||||||
|
setTheme(e.matches ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { theme, setTheme, toggle }
|
||||||
|
}
|
||||||
170
frontend/src/hooks/useWebSocket.ts
Normal file
170
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import type { WSEvent } from '../types'
|
||||||
|
|
||||||
|
type WSStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
|
const MAX_RETRIES = 10
|
||||||
|
const BASE_DELAY = 1000
|
||||||
|
|
||||||
|
export interface StreamCallbacks {
|
||||||
|
onToken?: (token: string) => void
|
||||||
|
onHeadUpdate?: (head: string, content: string) => void
|
||||||
|
onComplete?: (response: Record<string, unknown>) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket(sessionId: string | null) {
|
||||||
|
const [status, setStatus] = useState<WSStatus>('disconnected')
|
||||||
|
const [events, setEvents] = useState<WSEvent[]>([])
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
|
const retryCount = useRef(0)
|
||||||
|
const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const shouldReconnect = useRef(true)
|
||||||
|
const callbacksRef = useRef<StreamCallbacks>({})
|
||||||
|
|
||||||
|
const connect = useCallback((sid: string) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) return
|
||||||
|
if (wsRef.current) wsRef.current.close()
|
||||||
|
shouldReconnect.current = true
|
||||||
|
setStatus('connecting')
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}/v1/sessions/${sid}/stream`)
|
||||||
|
wsRef.current = ws
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('connected')
|
||||||
|
retryCount.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setStatus('disconnected')
|
||||||
|
setStreaming(false)
|
||||||
|
if (shouldReconnect.current && retryCount.current < MAX_RETRIES) {
|
||||||
|
const delay = BASE_DELAY * Math.pow(2, retryCount.current) + Math.random() * 500
|
||||||
|
retryCount.current++
|
||||||
|
retryTimer.current = setTimeout(() => connect(sid), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setStatus('error')
|
||||||
|
setStreaming(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const event: WSEvent = JSON.parse(e.data)
|
||||||
|
setEvents((prev) => [...prev, event])
|
||||||
|
|
||||||
|
// Handle streaming protocol events
|
||||||
|
const cb = callbacksRef.current
|
||||||
|
if (event.type === 'token' && cb.onToken) {
|
||||||
|
cb.onToken(event.data as string)
|
||||||
|
} else if (event.type === 'head_update' && cb.onHeadUpdate) {
|
||||||
|
const d = event.data as Record<string, string>
|
||||||
|
cb.onHeadUpdate(d.head, d.content)
|
||||||
|
} else if (event.type === 'complete' && cb.onComplete) {
|
||||||
|
setStreaming(false)
|
||||||
|
cb.onComplete(event.data as Record<string, unknown>)
|
||||||
|
} else if (event.type === 'error' && cb.onError) {
|
||||||
|
setStreaming(false)
|
||||||
|
cb.onError(event.data as string)
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed */ }
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const send = useCallback((data: Record<string, unknown>) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const sendPrompt = useCallback((prompt: string, callbacks?: StreamCallbacks) => {
|
||||||
|
if (callbacks) callbacksRef.current = callbacks
|
||||||
|
setStreaming(true)
|
||||||
|
send({ type: 'prompt', prompt })
|
||||||
|
}, [send])
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
shouldReconnect.current = false
|
||||||
|
if (retryTimer.current) clearTimeout(retryTimer.current)
|
||||||
|
wsRef.current?.close()
|
||||||
|
wsRef.current = null
|
||||||
|
setStatus('disconnected')
|
||||||
|
setStreaming(false)
|
||||||
|
retryCount.current = 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearEvents = useCallback(() => setEvents([]), [])
|
||||||
|
|
||||||
|
// SSE fallback: if WebSocket fails repeatedly, use Server-Sent Events
|
||||||
|
const sendPromptSSE = useCallback((sessionId: string, prompt: string, callbacks?: StreamCallbacks) => {
|
||||||
|
if (callbacks) callbacksRef.current = callbacks
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
|
const cb = callbacksRef.current
|
||||||
|
const params = new URLSearchParams({ prompt, session_id: sessionId })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventSource = new EventSource(`/v1/sessions/stream/sse?${params}`)
|
||||||
|
|
||||||
|
eventSource.addEventListener('token', (e) => {
|
||||||
|
if (cb.onToken) cb.onToken(e.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('head_update', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
if (cb.onHeadUpdate) cb.onHeadUpdate(data.head, data.content)
|
||||||
|
} catch { /* malformed */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('complete', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
setStreaming(false)
|
||||||
|
if (cb.onComplete) cb.onComplete(data)
|
||||||
|
} catch { /* malformed */ }
|
||||||
|
eventSource.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (e) => {
|
||||||
|
setStreaming(false)
|
||||||
|
if (cb.onError && e instanceof MessageEvent) cb.onError(e.data)
|
||||||
|
eventSource.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setStreaming(false)
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStreaming(false)
|
||||||
|
if (cb.onError) cb.onError('SSE connection failed')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-fallback: after MAX_RETRIES WS failures, switch to SSE
|
||||||
|
const sendWithFallback = useCallback((prompt: string, callbacks?: StreamCallbacks) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
sendPrompt(prompt, callbacks)
|
||||||
|
} else if (sessionId && retryCount.current >= MAX_RETRIES) {
|
||||||
|
sendPromptSSE(sessionId, prompt, callbacks)
|
||||||
|
} else {
|
||||||
|
sendPrompt(prompt, callbacks)
|
||||||
|
}
|
||||||
|
}, [sendPrompt, sendPromptSSE, sessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
shouldReconnect.current = false
|
||||||
|
if (retryTimer.current) clearTimeout(retryTimer.current)
|
||||||
|
wsRef.current?.close()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { status, events, streaming, connect, send, sendPrompt: sendWithFallback, sendPromptSSE, disconnect, clearEvents }
|
||||||
|
}
|
||||||
167
frontend/src/i18n/index.ts
Normal file
167
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight i18n system for FusionAGI.
|
||||||
|
*
|
||||||
|
* Zero-dependency translation layer using JSON locale files.
|
||||||
|
* Supports interpolation via {{key}} syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Locale = 'en' | 'es' | 'fr' | 'de' | 'ja' | 'zh'
|
||||||
|
|
||||||
|
type TranslationMap = Record<string, string>
|
||||||
|
type LocaleData = Record<Locale, TranslationMap>
|
||||||
|
|
||||||
|
const translations: LocaleData = {
|
||||||
|
en: {
|
||||||
|
'app.title': 'FusionAGI',
|
||||||
|
'nav.chat': 'Chat',
|
||||||
|
'nav.admin': 'Admin',
|
||||||
|
'nav.ethics': 'Ethics',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'chat.placeholder': 'Ask FusionAGI... (Ctrl+Enter to send, Ctrl+K to focus)',
|
||||||
|
'chat.send': 'Send',
|
||||||
|
'chat.empty': 'Start a conversation',
|
||||||
|
'chat.suggestions.label': 'Try asking:',
|
||||||
|
'admin.title': 'Admin Dashboard',
|
||||||
|
'admin.status': 'System Status',
|
||||||
|
'admin.voices': 'Voice Config',
|
||||||
|
'admin.agents': 'Agent Config',
|
||||||
|
'admin.governance': 'Governance',
|
||||||
|
'ethics.title': 'Ethics Dashboard',
|
||||||
|
'ethics.lessons': 'Lessons',
|
||||||
|
'ethics.consequences': 'Consequences',
|
||||||
|
'ethics.insights': 'Insights',
|
||||||
|
'settings.title': 'Settings',
|
||||||
|
'settings.theme': 'Theme',
|
||||||
|
'settings.theme.dark': 'Dark',
|
||||||
|
'settings.theme.light': 'Light',
|
||||||
|
'settings.conversation': 'Conversation Style',
|
||||||
|
'settings.formality': 'Formality',
|
||||||
|
'settings.verbosity': 'Verbosity',
|
||||||
|
'settings.empathy': 'Empathy',
|
||||||
|
'settings.humor': 'Humor',
|
||||||
|
'settings.technical': 'Technical Depth',
|
||||||
|
'login.title': 'Welcome to FusionAGI',
|
||||||
|
'login.apikey': 'API Key',
|
||||||
|
'login.submit': 'Login',
|
||||||
|
'login.skip': 'Skip (no auth)',
|
||||||
|
'login.error': 'Authentication failed',
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.error': 'Something went wrong',
|
||||||
|
'common.retry': 'Retry',
|
||||||
|
'common.close': 'Close',
|
||||||
|
'common.save': 'Save',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
'common.copy': 'Copy',
|
||||||
|
'common.copied': 'Copied!',
|
||||||
|
'common.logout': 'Logout',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
'app.title': 'FusionAGI',
|
||||||
|
'nav.chat': 'Chat',
|
||||||
|
'nav.admin': 'Admin',
|
||||||
|
'nav.ethics': 'Etica',
|
||||||
|
'nav.settings': 'Ajustes',
|
||||||
|
'chat.placeholder': 'Pregunta a FusionAGI...',
|
||||||
|
'chat.send': 'Enviar',
|
||||||
|
'chat.empty': 'Inicia una conversacion',
|
||||||
|
'admin.title': 'Panel de Admin',
|
||||||
|
'ethics.title': 'Panel de Etica',
|
||||||
|
'settings.title': 'Ajustes',
|
||||||
|
'login.title': 'Bienvenido a FusionAGI',
|
||||||
|
'login.apikey': 'Clave API',
|
||||||
|
'login.submit': 'Entrar',
|
||||||
|
'common.loading': 'Cargando...',
|
||||||
|
'common.error': 'Algo salio mal',
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
'app.title': 'FusionAGI',
|
||||||
|
'nav.chat': 'Chat',
|
||||||
|
'nav.admin': 'Admin',
|
||||||
|
'nav.ethics': 'Ethique',
|
||||||
|
'nav.settings': 'Parametres',
|
||||||
|
'chat.placeholder': 'Demandez a FusionAGI...',
|
||||||
|
'chat.send': 'Envoyer',
|
||||||
|
'admin.title': 'Tableau de bord',
|
||||||
|
'ethics.title': 'Tableau ethique',
|
||||||
|
'settings.title': 'Parametres',
|
||||||
|
'login.title': 'Bienvenue sur FusionAGI',
|
||||||
|
'common.loading': 'Chargement...',
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
'app.title': 'FusionAGI',
|
||||||
|
'nav.chat': 'Chat',
|
||||||
|
'nav.admin': 'Admin',
|
||||||
|
'nav.ethics': 'Ethik',
|
||||||
|
'nav.settings': 'Einstellungen',
|
||||||
|
'chat.placeholder': 'Frage FusionAGI...',
|
||||||
|
'chat.send': 'Senden',
|
||||||
|
'admin.title': 'Admin-Dashboard',
|
||||||
|
'settings.title': 'Einstellungen',
|
||||||
|
'login.title': 'Willkommen bei FusionAGI',
|
||||||
|
'common.loading': 'Laden...',
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
'app.title': 'FusionAGI',
|
||||||
|
'nav.chat': 'チャット',
|
||||||
|
'nav.admin': '管理',
|
||||||
|
'nav.ethics': '倫理',
|
||||||
|
'nav.settings': '設定',
|
||||||
|
'chat.placeholder': 'FusionAGIに聞く...',
|
||||||
|
'chat.send': '送信',
|
||||||
|
'admin.title': '管理ダッシュボード',
|
||||||
|
'settings.title': '設定',
|
||||||
|
'login.title': 'FusionAGIへようこそ',
|
||||||
|
'common.loading': '読み込み中...',
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
'app.title': 'FusionAGI',
|
||||||
|
'nav.chat': '聊天',
|
||||||
|
'nav.admin': '管理',
|
||||||
|
'nav.ethics': '伦理',
|
||||||
|
'nav.settings': '设置',
|
||||||
|
'chat.placeholder': '询问FusionAGI...',
|
||||||
|
'chat.send': '发送',
|
||||||
|
'admin.title': '管理面板',
|
||||||
|
'settings.title': '设置',
|
||||||
|
'login.title': '欢迎使用FusionAGI',
|
||||||
|
'common.loading': '加载中...',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentLocale: Locale = 'en'
|
||||||
|
|
||||||
|
export function setLocale(locale: Locale): void {
|
||||||
|
currentLocale = locale
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('fusionagi-locale', locale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocale(): Locale {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('fusionagi-locale') as Locale | null
|
||||||
|
if (saved && saved in translations) {
|
||||||
|
currentLocale = saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return currentLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function t(key: string, params?: Record<string, string | number>): string {
|
||||||
|
const map = translations[currentLocale] || translations.en
|
||||||
|
let text = map[key] || translations.en[key] || key
|
||||||
|
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
text = text.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableLocales(): Locale[] {
|
||||||
|
return Object.keys(translations) as Locale[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize from localStorage
|
||||||
|
getLocale()
|
||||||
234
frontend/src/pages/AdminPage.tsx
Normal file
234
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { MetricCard, Sparkline, BarChart } from '../components/SparklineChart'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import type { SystemStatus, VoiceProfile } from '../types'
|
||||||
|
|
||||||
|
function StatusCard({ label, value, unit, statusClass }: {
|
||||||
|
label: string; value: string | number | null; unit?: string; statusClass?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="status-card" role="status" aria-label={`${label}: ${value ?? 'N/A'}${unit && value != null ? unit : ''}`}>
|
||||||
|
<span className="status-label">{label}</span>
|
||||||
|
<span className={`status-value ${statusClass || ''}`}>
|
||||||
|
{statusClass && <span className={`status-dot ${statusClass}`} aria-hidden="true" />}
|
||||||
|
{value ?? 'N/A'}{unit && value != null ? unit : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusHistory {
|
||||||
|
cpu: number[]
|
||||||
|
memory: number[]
|
||||||
|
tasks: number[]
|
||||||
|
sessions: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
||||||
|
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||||
|
const [voices, setVoices] = useState<VoiceProfile[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [newVoiceName, setNewVoiceName] = useState('')
|
||||||
|
const [newVoiceLang, setNewVoiceLang] = useState('en-US')
|
||||||
|
const [tab, setTab] = useState<'overview' | 'voices' | 'agents' | 'governance'>('overview')
|
||||||
|
const [history, setHistory] = useState<StatusHistory>({ cpu: [], memory: [], tasks: [], sessions: [] })
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/admin/status', { headers: authHeaders() })
|
||||||
|
if (r.ok) {
|
||||||
|
const data = await r.json()
|
||||||
|
setStatus(data)
|
||||||
|
setHistory((h) => ({
|
||||||
|
cpu: [...h.cpu, data.cpu_usage_percent ?? 0].slice(-20),
|
||||||
|
memory: [...h.memory, data.memory_usage_mb ?? 0].slice(-20),
|
||||||
|
tasks: [...h.tasks, data.active_tasks ?? 0].slice(-20),
|
||||||
|
sessions: [...h.sessions, data.active_sessions ?? 0].slice(-20),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch { /* offline */ }
|
||||||
|
}, [authHeaders])
|
||||||
|
|
||||||
|
const fetchVoices = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/admin/voices', { headers: authHeaders() })
|
||||||
|
if (r.ok) setVoices(await r.json())
|
||||||
|
} catch { /* offline */ }
|
||||||
|
}, [authHeaders])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([fetchStatus(), fetchVoices()]).finally(() => setLoading(false))
|
||||||
|
const interval = setInterval(fetchStatus, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchStatus, fetchVoices])
|
||||||
|
|
||||||
|
const addVoice = async () => {
|
||||||
|
if (!newVoiceName.trim()) return
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/admin/voices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ name: newVoiceName, language: newVoiceLang }),
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
setNewVoiceName('')
|
||||||
|
fetchVoices()
|
||||||
|
} else {
|
||||||
|
setError('Failed to add voice')
|
||||||
|
}
|
||||||
|
} catch { setError('Network error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatUptime = (s: number) => {
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
return `${h}h ${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = status?.status === 'healthy' ? 'healthy' : status?.status === 'degraded' ? 'degraded' : status?.status === 'offline' ? 'offline' : ''
|
||||||
|
|
||||||
|
const cpuTrend = history.cpu.length >= 2 ? (history.cpu[history.cpu.length - 1] > history.cpu[history.cpu.length - 2] ? 'up' : history.cpu[history.cpu.length - 1] < history.cpu[history.cpu.length - 2] ? 'down' : 'flat') as 'up' | 'down' | 'flat' : undefined
|
||||||
|
const memTrend = history.memory.length >= 2 ? (history.memory[history.memory.length - 1] > history.memory[history.memory.length - 2] ? 'up' : 'down') as 'up' | 'down' : undefined
|
||||||
|
|
||||||
|
if (loading) return <div className="page-loading" role="status" aria-live="polite">{t('common.loading')}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main" aria-label={t('admin.title')}>
|
||||||
|
<div className="admin-tabs" role="tablist" aria-label="Admin sections">
|
||||||
|
{(['overview', 'voices', 'agents', 'governance'] as const).map((tb) => (
|
||||||
|
<button
|
||||||
|
key={tb}
|
||||||
|
className={tab === tb ? 'active' : ''}
|
||||||
|
onClick={() => setTab(tb)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === tb}
|
||||||
|
aria-controls={`panel-${tb}`}
|
||||||
|
>
|
||||||
|
{tb.charAt(0).toUpperCase() + tb.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner" role="alert" onClick={() => setError(null)}>{error}</div>}
|
||||||
|
|
||||||
|
{tab === 'overview' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="panel-overview" aria-label="System Overview">
|
||||||
|
<h2>{t('admin.status')}</h2>
|
||||||
|
<div className="metrics-grid">
|
||||||
|
<MetricCard
|
||||||
|
title="CPU Usage"
|
||||||
|
value={status?.cpu_usage_percent ?? 0}
|
||||||
|
unit="%"
|
||||||
|
data={history.cpu}
|
||||||
|
trend={cpuTrend}
|
||||||
|
color="var(--color-warning, #ff9800)"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Memory"
|
||||||
|
value={status?.memory_usage_mb ?? 0}
|
||||||
|
unit=" MB"
|
||||||
|
data={history.memory}
|
||||||
|
trend={memTrend}
|
||||||
|
color="var(--accent)"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Active Tasks"
|
||||||
|
value={status?.active_tasks ?? 0}
|
||||||
|
data={history.tasks}
|
||||||
|
color="var(--color-success, #4caf50)"
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Sessions"
|
||||||
|
value={status?.active_sessions ?? 0}
|
||||||
|
data={history.sessions}
|
||||||
|
color="var(--color-info, #2196f3)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="status-grid" role="group" aria-label="System metrics" style={{ marginTop: '1rem' }}>
|
||||||
|
<StatusCard label="Status" value={status?.status ?? 'unknown'} statusClass={statusClass} />
|
||||||
|
<StatusCard label="Uptime" value={status ? formatUptime(status.uptime_seconds) : 'N/A'} />
|
||||||
|
<StatusCard label="Active Agents" value={status?.active_agents ?? 0} />
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<h3>Agent Distribution</h3>
|
||||||
|
<BarChart
|
||||||
|
data={[
|
||||||
|
{ label: 'Tasks', value: status.active_tasks ?? 0, color: 'var(--color-success, #4caf50)' },
|
||||||
|
{ label: 'Agents', value: status.active_agents ?? 0, color: 'var(--accent)' },
|
||||||
|
{ label: 'Sessions', value: status.active_sessions ?? 0, color: 'var(--color-info, #2196f3)' },
|
||||||
|
]}
|
||||||
|
width={300}
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'voices' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="panel-voices" aria-label="Voice Library">
|
||||||
|
<h2>{t('admin.voices')}</h2>
|
||||||
|
<div className="add-form" role="form" aria-label="Add voice">
|
||||||
|
<label htmlFor="voice-name" className="sr-only">Voice name</label>
|
||||||
|
<input id="voice-name" placeholder="Voice name" value={newVoiceName} onChange={(e) => setNewVoiceName(e.target.value)} />
|
||||||
|
<label htmlFor="voice-lang" className="sr-only">Language</label>
|
||||||
|
<select id="voice-lang" value={newVoiceLang} onChange={(e) => setNewVoiceLang(e.target.value)}>
|
||||||
|
<option value="en-US">English (US)</option>
|
||||||
|
<option value="en-GB">English (UK)</option>
|
||||||
|
<option value="es-ES">Spanish</option>
|
||||||
|
<option value="fr-FR">French</option>
|
||||||
|
<option value="de-DE">German</option>
|
||||||
|
<option value="ja-JP">Japanese</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={addVoice}>Add Voice</button>
|
||||||
|
</div>
|
||||||
|
<div className="voice-list" role="list" aria-label="Voice profiles">
|
||||||
|
{voices.length === 0 && <p className="muted">No voice profiles configured</p>}
|
||||||
|
{voices.map((v) => (
|
||||||
|
<div key={v.id} className="voice-card" role="listitem">
|
||||||
|
<strong>{v.name}</strong>
|
||||||
|
<span className="muted">{v.language} | {v.provider}</span>
|
||||||
|
<span className="muted">Pitch: {v.pitch}x | Speed: {v.speed}x</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'agents' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="panel-agents" aria-label="Agent Configuration">
|
||||||
|
<h2>{t('admin.agents')}</h2>
|
||||||
|
<div className="agent-grid" role="list" aria-label="Active agents">
|
||||||
|
{['Planner', 'Reasoner', 'Executor', 'Critic', '12 Heads', 'Witness'].map((a) => (
|
||||||
|
<div key={a} className="agent-card" role="listitem">
|
||||||
|
<strong>{a}</strong>
|
||||||
|
<span className="status-badge active" role="status">Active</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'governance' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="panel-governance" aria-label="Governance Mode">
|
||||||
|
<h2>{t('admin.governance')}</h2>
|
||||||
|
<div className="governance-info">
|
||||||
|
<div className="governance-mode" role="status" aria-label="Current governance mode: Advisory">
|
||||||
|
<span className="mode-label">Current Mode:</span>
|
||||||
|
<span className="mode-value advisory">ADVISORY</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">
|
||||||
|
All governance checks are advisory — violations are logged but actions proceed.
|
||||||
|
The system learns from outcomes through the Consequence Engine and Adaptive Ethics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h3>Audit Trail</h3>
|
||||||
|
<p className="muted">Full audit trail available via /v1/admin/telemetry endpoint</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
frontend/src/pages/EthicsPage.tsx
Normal file
187
frontend/src/pages/EthicsPage.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { MetricCard, BarChart } from '../components/SparklineChart'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import type { EthicalLesson, ConsequenceRecord, InsightRecord } from '../types'
|
||||||
|
|
||||||
|
export function EthicsPage({ authHeaders }: { authHeaders: () => Record<string, string> }) {
|
||||||
|
const [lessons, setLessons] = useState<EthicalLesson[]>([])
|
||||||
|
const [consequences, setConsequences] = useState<ConsequenceRecord[]>([])
|
||||||
|
const [insights, setInsights] = useState<InsightRecord[]>([])
|
||||||
|
const [tab, setTab] = useState<'ethics' | 'consequences' | 'insights'>('ethics')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [ethR, conR, insR] = await Promise.all([
|
||||||
|
fetch('/v1/admin/ethics', { headers: authHeaders() }).catch(() => null),
|
||||||
|
fetch('/v1/admin/consequences', { headers: authHeaders() }).catch(() => null),
|
||||||
|
fetch('/v1/admin/insights', { headers: authHeaders() }).catch(() => null),
|
||||||
|
])
|
||||||
|
if (ethR?.ok) setLessons(await ethR.json())
|
||||||
|
if (conR?.ok) setConsequences(await conR.json())
|
||||||
|
if (insR?.ok) setInsights(await insR.json())
|
||||||
|
} catch { /* offline */ }
|
||||||
|
}, [authHeaders])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchData().finally(() => setLoading(false))
|
||||||
|
}, [fetchData])
|
||||||
|
|
||||||
|
const positiveOutcomes = consequences.filter((c) => c.outcome_positive === true).length
|
||||||
|
const negativeOutcomes = consequences.filter((c) => c.outcome_positive === false).length
|
||||||
|
const pendingOutcomes = consequences.filter((c) => c.outcome_positive === null).length
|
||||||
|
const avgRisk = consequences.length > 0 ? consequences.reduce((s, c) => s + c.estimated_risk, 0) / consequences.length : 0
|
||||||
|
const avgReward = consequences.length > 0 ? consequences.reduce((s, c) => s + c.estimated_reward, 0) / consequences.length : 0
|
||||||
|
|
||||||
|
const lessonWeights = lessons.map((l) => l.weight)
|
||||||
|
const insightConfidences = insights.map((i) => i.confidence)
|
||||||
|
|
||||||
|
if (loading) return <div className="page-loading" role="status" aria-live="polite">{t('common.loading')}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ethics-page" role="main" aria-label={t('ethics.title')}>
|
||||||
|
<div className="admin-tabs" role="tablist" aria-label="Ethics sections">
|
||||||
|
{(['ethics', 'consequences', 'insights'] as const).map((tb) => (
|
||||||
|
<button
|
||||||
|
key={tb}
|
||||||
|
className={tab === tb ? 'active' : ''}
|
||||||
|
onClick={() => setTab(tb)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === tb}
|
||||||
|
aria-controls={`ethics-panel-${tb}`}
|
||||||
|
>
|
||||||
|
{tb.charAt(0).toUpperCase() + tb.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'ethics' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="ethics-panel-ethics" aria-label="Learned Lessons">
|
||||||
|
<h2>{t('ethics.lessons')}</h2>
|
||||||
|
<div className="metrics-grid">
|
||||||
|
<MetricCard title="Total Lessons" value={lessons.length} data={lessonWeights} color="var(--accent)" />
|
||||||
|
<MetricCard title="Avg Weight" value={lessons.length > 0 ? (lessons.reduce((s, l) => s + l.weight, 0) / lessons.length).toFixed(2) : '0'} color="var(--color-warning, #ff9800)" />
|
||||||
|
<MetricCard title="High Weight" value={lessons.filter((l) => l.weight > 1).length} color="var(--color-success, #4caf50)" />
|
||||||
|
<MetricCard title="Negative Signal" value={lessons.filter((l) => l.weight < 0).length} color="var(--color-error, #f44336)" />
|
||||||
|
</div>
|
||||||
|
{lessons.length === 0 ? (
|
||||||
|
<p className="muted">No ethical lessons recorded yet. The system learns from choices and their consequences.</p>
|
||||||
|
) : (
|
||||||
|
<div className="lesson-list" role="list" aria-label="Ethical lessons">
|
||||||
|
{lessons.map((l, i) => (
|
||||||
|
<div key={i} className="lesson-card" role="listitem">
|
||||||
|
<div className="lesson-header">
|
||||||
|
<strong>{l.action_type}</strong>
|
||||||
|
<span className={`weight-badge ${l.weight > 1 ? 'high' : l.weight < 0 ? 'negative' : ''}`}>
|
||||||
|
Weight: {l.weight.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="muted">{l.context_summary}</p>
|
||||||
|
<div className="lesson-meta">
|
||||||
|
<span>Advisory: {l.advisory_reason}</span>
|
||||||
|
<span>Proceeded: {l.proceeded ? 'Yes' : 'No'}</span>
|
||||||
|
<span>Outcome: {l.outcome_positive === null ? 'Pending' : l.outcome_positive ? 'Positive' : 'Negative'}</span>
|
||||||
|
<span>Occurrences: {l.occurrences}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'consequences' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="ethics-panel-consequences" aria-label="Choice History">
|
||||||
|
<h2>{t('ethics.consequences')}</h2>
|
||||||
|
<div className="metrics-grid">
|
||||||
|
<MetricCard title="Total Choices" value={consequences.length} color="var(--accent)" />
|
||||||
|
<MetricCard title="Positive" value={positiveOutcomes} trend={positiveOutcomes > negativeOutcomes ? 'up' : 'down'} color="var(--color-success, #4caf50)" />
|
||||||
|
<MetricCard title="Negative" value={negativeOutcomes} color="var(--color-error, #f44336)" />
|
||||||
|
<MetricCard title="Pending" value={pendingOutcomes} color="var(--text-muted)" />
|
||||||
|
</div>
|
||||||
|
{consequences.length > 0 && (
|
||||||
|
<div style={{ margin: '1rem 0' }}>
|
||||||
|
<h3>Risk vs Reward</h3>
|
||||||
|
<BarChart
|
||||||
|
data={[
|
||||||
|
{ label: 'Avg Risk', value: Math.round(avgRisk * 100), color: 'var(--color-error, #f44336)' },
|
||||||
|
{ label: 'Avg Reward', value: Math.round(avgReward * 100), color: 'var(--color-success, #4caf50)' },
|
||||||
|
{ label: 'Positive', value: positiveOutcomes, color: 'var(--accent)' },
|
||||||
|
{ label: 'Negative', value: negativeOutcomes, color: 'var(--color-warning, #ff9800)' },
|
||||||
|
]}
|
||||||
|
width={300}
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{consequences.length === 0 ? (
|
||||||
|
<p className="muted">No consequences recorded yet. Every choice creates a consequence record.</p>
|
||||||
|
) : (
|
||||||
|
<div className="consequence-list" role="list" aria-label="Consequence records">
|
||||||
|
{consequences.map((c, i) => (
|
||||||
|
<div key={i} className="consequence-card" role="listitem">
|
||||||
|
<div className="consequence-header">
|
||||||
|
<strong>{c.action_taken}</strong>
|
||||||
|
{c.outcome_positive !== null && (
|
||||||
|
<span className={`outcome-badge ${c.outcome_positive ? 'positive' : 'negative'}`}>
|
||||||
|
{c.outcome_positive ? 'Positive' : 'Negative'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="risk-reward-bar" role="meter" aria-label={`Risk: ${(c.estimated_risk * 100).toFixed(0)}%`} aria-valuenow={c.estimated_risk * 100} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<div className="bar-label">Risk</div>
|
||||||
|
<div className="bar-track">
|
||||||
|
<div className="bar-fill risk" style={{ width: `${c.estimated_risk * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span>{(c.estimated_risk * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-reward-bar" role="meter" aria-label={`Reward: ${(c.estimated_reward * 100).toFixed(0)}%`} aria-valuenow={c.estimated_reward * 100} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<div className="bar-label">Reward</div>
|
||||||
|
<div className="bar-track">
|
||||||
|
<div className="bar-fill reward" style={{ width: `${c.estimated_reward * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span>{(c.estimated_reward * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
{c.surprise_factor !== null && (
|
||||||
|
<span className="muted">Surprise factor: {c.surprise_factor.toFixed(2)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'insights' && (
|
||||||
|
<div className="admin-section" role="tabpanel" id="ethics-panel-insights" aria-label="Cross-Head Learning">
|
||||||
|
<h2>{t('ethics.insights')}</h2>
|
||||||
|
<div className="metrics-grid">
|
||||||
|
<MetricCard title="Total Insights" value={insights.length} data={insightConfidences} color="var(--accent)" />
|
||||||
|
<MetricCard
|
||||||
|
title="Avg Confidence"
|
||||||
|
value={insights.length > 0 ? `${(insights.reduce((s, i) => s + i.confidence, 0) / insights.length * 100).toFixed(0)}%` : 'N/A'}
|
||||||
|
color="var(--color-info, #2196f3)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{insights.length === 0 ? (
|
||||||
|
<p className="muted">No cross-head insights yet. Heads share observations through the InsightBus.</p>
|
||||||
|
) : (
|
||||||
|
<div className="insight-list" role="list" aria-label="Insight records">
|
||||||
|
{insights.map((ins, i) => (
|
||||||
|
<div key={i} className="insight-card" role="listitem">
|
||||||
|
<div className="insight-header">
|
||||||
|
<span className="insight-source">{ins.source}</span>
|
||||||
|
{ins.domain && <span className="insight-domain">{ins.domain}</span>}
|
||||||
|
<span className="insight-confidence">{(ins.confidence * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<p>{ins.message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/pages/LoginPage.tsx
Normal file
41
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: (token: string) => void
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage({ onLogin, error }: LoginPageProps) {
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (apiKey.trim()) onLogin(apiKey.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1>FusionAGI</h1>
|
||||||
|
<p className="muted">Enter your API key to connect</p>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="API Key (Bearer token)"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!apiKey.trim()}>Connect</button>
|
||||||
|
</form>
|
||||||
|
<p className="muted small">
|
||||||
|
No API key? Set FUSIONAGI_API_KEY env var on the server, or leave blank for open access.
|
||||||
|
</p>
|
||||||
|
<button className="skip-btn" onClick={() => onLogin('')}>
|
||||||
|
Skip (no auth)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
frontend/src/pages/SettingsPage.tsx
Normal file
145
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useToast } from '../components/Toast'
|
||||||
|
import { t, getLocale, setLocale, getAvailableLocales } from '../i18n'
|
||||||
|
import type { Locale } from '../i18n'
|
||||||
|
import type { ConversationStyle, Theme } from '../types'
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
theme: Theme
|
||||||
|
toggleTheme: () => void
|
||||||
|
authHeaders: () => Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALE_LABELS: Record<Locale, string> = {
|
||||||
|
en: 'English',
|
||||||
|
es: 'Espanol',
|
||||||
|
fr: 'Francais',
|
||||||
|
de: 'Deutsch',
|
||||||
|
ja: 'Japanese',
|
||||||
|
zh: 'Chinese',
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({ label, value, onChange, min = 0, max = 1, step = 0.1, id }: {
|
||||||
|
label: string; value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; id: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="slider-row">
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
<input id={id} type="range" min={min} max={max} step={step} value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
aria-valuemin={min} aria-valuemax={max} aria-valuenow={value}
|
||||||
|
aria-valuetext={`${label}: ${value.toFixed(1)}`} />
|
||||||
|
<span className="slider-value" aria-hidden="true">{value.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage({ theme, toggleTheme, authHeaders }: SettingsPageProps) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(getLocale())
|
||||||
|
const [style, setStyle] = useState<ConversationStyle>({
|
||||||
|
formality: 'neutral',
|
||||||
|
verbosity: 'balanced',
|
||||||
|
empathy_level: 0.7,
|
||||||
|
proactivity: 0.5,
|
||||||
|
humor_level: 0.3,
|
||||||
|
technical_depth: 0.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLocaleChange = (newLocale: Locale) => {
|
||||||
|
setLocale(newLocale)
|
||||||
|
setLocaleState(newLocale)
|
||||||
|
toast(`Language set to ${LOCALE_LABELS[newLocale]}`, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/admin/conversation-style', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify(style),
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
toast(t('common.save') + ' — ' + t('settings.title'), 'success')
|
||||||
|
} else {
|
||||||
|
toast('Failed to save settings', 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast('Network error — settings saved locally', 'warning')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetDefaults = () => {
|
||||||
|
setStyle({
|
||||||
|
formality: 'neutral',
|
||||||
|
verbosity: 'balanced',
|
||||||
|
empathy_level: 0.7,
|
||||||
|
proactivity: 0.5,
|
||||||
|
humor_level: 0.3,
|
||||||
|
technical_depth: 0.5,
|
||||||
|
})
|
||||||
|
toast('Settings reset to defaults', 'info')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page" role="main" aria-label={t('settings.title')}>
|
||||||
|
<h2>{t('settings.title')}</h2>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>Appearance</h3>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>{t('settings.theme')}</label>
|
||||||
|
<button className="theme-toggle" onClick={toggleTheme} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||||
|
{theme === 'dark' ? t('settings.theme.light') : t('settings.theme.dark')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3>Language</h3>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label htmlFor="locale-select">Display Language</label>
|
||||||
|
<select
|
||||||
|
id="locale-select"
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => handleLocaleChange(e.target.value as Locale)}
|
||||||
|
aria-label="Select display language"
|
||||||
|
>
|
||||||
|
{getAvailableLocales().map((loc) => (
|
||||||
|
<option key={loc} value={loc}>{LOCALE_LABELS[loc]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section" role="group" aria-label="Conversation style settings">
|
||||||
|
<h3>{t('settings.conversation')}</h3>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label htmlFor="formality">{t('settings.formality')}</label>
|
||||||
|
<select id="formality" value={style.formality} onChange={(e) => setStyle({ ...style, formality: e.target.value as ConversationStyle['formality'] })}>
|
||||||
|
<option value="casual">Casual</option>
|
||||||
|
<option value="neutral">Neutral</option>
|
||||||
|
<option value="formal">Formal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<label htmlFor="verbosity">{t('settings.verbosity')}</label>
|
||||||
|
<select id="verbosity" value={style.verbosity} onChange={(e) => setStyle({ ...style, verbosity: e.target.value as ConversationStyle['verbosity'] })}>
|
||||||
|
<option value="concise">Concise</option>
|
||||||
|
<option value="balanced">Balanced</option>
|
||||||
|
<option value="detailed">Detailed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Slider id="empathy" label={t('settings.empathy')} value={style.empathy_level} onChange={(v) => setStyle({ ...style, empathy_level: v })} />
|
||||||
|
<Slider id="proactivity" label="Proactivity" value={style.proactivity} onChange={(v) => setStyle({ ...style, proactivity: v })} />
|
||||||
|
<Slider id="humor" label={t('settings.humor')} value={style.humor_level} onChange={(v) => setStyle({ ...style, humor_level: v })} />
|
||||||
|
<Slider id="technical-depth" label={t('settings.technical')} value={style.technical_depth} onChange={(v) => setStyle({ ...style, technical_depth: v })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<button className="save-btn" onClick={saveSettings}>{t('common.save')} Settings</button>
|
||||||
|
<button className="theme-toggle" onClick={resetDefaults}>Reset to Defaults</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/test-setup.ts
Normal file
15
frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ export interface HeadContribution {
|
|||||||
head_id: string
|
head_id: string
|
||||||
summary: string
|
summary: string
|
||||||
key_claims?: string[]
|
key_claims?: string[]
|
||||||
|
confidence?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgreementMap {
|
export interface AgreementMap {
|
||||||
@@ -18,8 +19,82 @@ export interface TransparencyReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FinalResponse {
|
export interface FinalResponse {
|
||||||
|
task_id?: string
|
||||||
final_answer: string
|
final_answer: string
|
||||||
transparency_report: TransparencyReport
|
transparency_report: TransparencyReport
|
||||||
head_contributions: HeadContribution[]
|
head_contributions: HeadContribution[]
|
||||||
confidence_score: number
|
confidence_score: number
|
||||||
|
response_mode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WSEvent {
|
||||||
|
type: 'heads_running' | 'head_complete' | 'head_speak' | 'witness_running' | 'complete' | 'error'
|
||||||
|
message?: string
|
||||||
|
head_id?: string
|
||||||
|
summary?: string
|
||||||
|
audio_base64?: string | null
|
||||||
|
final_answer?: string
|
||||||
|
transparency_report?: TransparencyReport
|
||||||
|
head_contributions?: HeadContribution[]
|
||||||
|
confidence_score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceProfile {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
language: string
|
||||||
|
gender: string | null
|
||||||
|
style: string | null
|
||||||
|
pitch: number
|
||||||
|
speed: number
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationStyle {
|
||||||
|
formality: 'casual' | 'neutral' | 'formal'
|
||||||
|
verbosity: 'concise' | 'balanced' | 'detailed'
|
||||||
|
empathy_level: number
|
||||||
|
proactivity: number
|
||||||
|
humor_level: number
|
||||||
|
technical_depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
status: 'healthy' | 'degraded' | 'offline'
|
||||||
|
uptime_seconds: number
|
||||||
|
active_tasks: number
|
||||||
|
active_agents: number
|
||||||
|
active_sessions: number
|
||||||
|
memory_usage_mb: number | null
|
||||||
|
cpu_usage_percent: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EthicalLesson {
|
||||||
|
action_type: string
|
||||||
|
context_summary: string
|
||||||
|
advisory_reason: string
|
||||||
|
weight: number
|
||||||
|
occurrences: number
|
||||||
|
proceeded: boolean
|
||||||
|
outcome_positive: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsequenceRecord {
|
||||||
|
choice_id: string
|
||||||
|
action_taken: string
|
||||||
|
estimated_risk: number
|
||||||
|
estimated_reward: number
|
||||||
|
outcome_positive: boolean | null
|
||||||
|
surprise_factor: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightRecord {
|
||||||
|
source: string
|
||||||
|
message: string
|
||||||
|
domain: string
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Theme = 'dark' | 'light'
|
||||||
|
export type ViewMode = 'normal' | 'explain' | 'developer'
|
||||||
|
export type Page = 'chat' | 'admin' | 'ethics' | 'settings'
|
||||||
|
|||||||
88
frontend/src/workers/markdown.worker.ts
Normal file
88
frontend/src/workers/markdown.worker.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Web Worker for offloading markdown rendering from the main thread.
|
||||||
|
*
|
||||||
|
* Receives raw markdown text, returns rendered HTML.
|
||||||
|
* Uses the same zero-dependency parser from the main app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const result: string[] = []
|
||||||
|
let inCodeBlock = false
|
||||||
|
let codeLang = ''
|
||||||
|
let codeContent: string[] = []
|
||||||
|
let inList = false
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('```')) {
|
||||||
|
if (inCodeBlock) {
|
||||||
|
result.push(`<pre><code class="language-${escapeHtml(codeLang)}">${escapeHtml(codeContent.join('\n'))}</code></pre>`)
|
||||||
|
inCodeBlock = false
|
||||||
|
codeContent = []
|
||||||
|
codeLang = ''
|
||||||
|
} else {
|
||||||
|
if (inList) { result.push('</ul>'); inList = false }
|
||||||
|
inCodeBlock = true
|
||||||
|
codeLang = line.slice(3).trim()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
codeContent.push(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
const hMatch = line.match(/^(#{1,6})\s+(.+)/)
|
||||||
|
if (hMatch) {
|
||||||
|
if (inList) { result.push('</ul>'); inList = false }
|
||||||
|
const level = hMatch[1].length
|
||||||
|
result.push(`<h${level}>${renderInline(hMatch[2])}</h${level}>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
if (line.match(/^\s*[-*]\s+/)) {
|
||||||
|
if (!inList) { result.push('<ul>'); inList = true }
|
||||||
|
result.push(`<li>${renderInline(line.replace(/^\s*[-*]\s+/, ''))}</li>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inList && line.trim() === '') {
|
||||||
|
result.push('</ul>')
|
||||||
|
inList = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph
|
||||||
|
if (line.trim()) {
|
||||||
|
result.push(`<p>${renderInline(line)}</p>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
result.push(`<pre><code>${escapeHtml(codeContent.join('\n'))}</code></pre>`)
|
||||||
|
}
|
||||||
|
if (inList) result.push('</ul>')
|
||||||
|
|
||||||
|
return result.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent) => {
|
||||||
|
const { id, text } = e.data
|
||||||
|
const html = renderMarkdown(text)
|
||||||
|
self.postMessage({ id, html })
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
@@ -9,4 +10,10 @@ export default defineConfig({
|
|||||||
"/v1": process.env.VITE_API_URL || "http://localhost:8000",
|
"/v1": process.env.VITE_API_URL || "http://localhost:8000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test-setup.ts',
|
||||||
|
exclude: ['e2e/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ from fusionagi._logger import logger
|
|||||||
from fusionagi.core import EventBus, Orchestrator, StateManager
|
from fusionagi.core import EventBus, Orchestrator, StateManager
|
||||||
from fusionagi.schemas import AgentMessageEnvelope, Task
|
from fusionagi.schemas import AgentMessageEnvelope, Task
|
||||||
from fusionagi.self_improvement import (
|
from fusionagi.self_improvement import (
|
||||||
SelfCorrectionLoop,
|
|
||||||
AutoRecommender,
|
AutoRecommender,
|
||||||
AutoTrainer,
|
AutoTrainer,
|
||||||
FusionAGILoop,
|
FusionAGILoop,
|
||||||
|
SelfCorrectionLoop,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,25 @@ Use: from fusionagi.adapters import OpenAIAdapter; if OpenAIAdapter is not None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
from fusionagi.adapters.stub_adapter import StubAdapter
|
|
||||||
from fusionagi.adapters.cache import CachedAdapter
|
from fusionagi.adapters.cache import CachedAdapter
|
||||||
from fusionagi.adapters.native_adapter import NativeAdapter
|
from fusionagi.adapters.native_adapter import NativeAdapter
|
||||||
|
from fusionagi.adapters.stub_adapter import StubAdapter
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fusionagi.adapters.openai_adapter import OpenAIAdapter
|
from fusionagi.adapters.openai_adapter import OpenAIAdapter
|
||||||
except ImportError:
|
except ImportError:
|
||||||
OpenAIAdapter = None # type: ignore[misc, assignment]
|
OpenAIAdapter = None # type: ignore[misc, assignment]
|
||||||
|
|
||||||
__all__ = ["LLMAdapter", "StubAdapter", "CachedAdapter", "NativeAdapter", "OpenAIAdapter"]
|
try:
|
||||||
|
from fusionagi.adapters.tensorflow_adapter import TensorFlowAdapter
|
||||||
|
except ImportError:
|
||||||
|
TensorFlowAdapter = None # type: ignore[misc, assignment]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMAdapter",
|
||||||
|
"StubAdapter",
|
||||||
|
"CachedAdapter",
|
||||||
|
"NativeAdapter",
|
||||||
|
"OpenAIAdapter",
|
||||||
|
"TensorFlowAdapter",
|
||||||
|
]
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
class LLMAdapter(ABC):
|
class LLMAdapter(ABC):
|
||||||
"""
|
"""Abstract adapter for LLM completion.
|
||||||
Abstract adapter for LLM completion.
|
|
||||||
|
|
||||||
Implementations should handle:
|
Implementations should handle:
|
||||||
- openai/ - OpenAI API (GPT-4, etc.)
|
- openai/ - OpenAI API (GPT-4, etc.)
|
||||||
@@ -20,8 +19,7 @@ class LLMAdapter(ABC):
|
|||||||
messages: list[dict[str, str]],
|
messages: list[dict[str, str]],
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Return completion text for the given messages.
|
||||||
Return completion text for the given messages.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
messages: List of message dicts with 'role' and 'content' keys.
|
messages: List of message dicts with 'role' and 'content' keys.
|
||||||
@@ -38,8 +36,7 @@ class LLMAdapter(ABC):
|
|||||||
schema: dict[str, Any] | None = None,
|
schema: dict[str, Any] | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""Return structured (JSON) output.
|
||||||
Return structured (JSON) output.
|
|
||||||
|
|
||||||
Default implementation returns None; subclasses may override to use
|
Default implementation returns None; subclasses may override to use
|
||||||
provider-specific JSON modes (e.g., OpenAI's response_format).
|
provider-specific JSON modes (e.g., OpenAI's response_format).
|
||||||
@@ -53,3 +50,48 @@ class LLMAdapter(ABC):
|
|||||||
Parsed JSON response or None if not supported/parsing fails.
|
Parsed JSON response or None if not supported/parsing fails.
|
||||||
"""
|
"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def acomplete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Async completion — default wraps sync ``complete()`` in a thread.
|
||||||
|
|
||||||
|
Subclasses with native async support (e.g., httpx-based providers)
|
||||||
|
should override this for true non-blocking I/O.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content' keys.
|
||||||
|
**kwargs: Provider-specific options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The model's response text.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, lambda: self.complete(messages, **kwargs))
|
||||||
|
|
||||||
|
async def acomplete_structured(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
schema: dict[str, Any] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
"""Async structured completion — default wraps sync version.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content' keys.
|
||||||
|
schema: Optional JSON schema for response validation.
|
||||||
|
**kwargs: Provider-specific options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response or None.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(
|
||||||
|
None, lambda: self.complete_structured(messages, schema=schema, **kwargs)
|
||||||
|
)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class CachedAdapter(LLMAdapter):
|
|||||||
key = self._key(messages, kwargs, prefix="complete")
|
key = self._key(messages, kwargs, prefix="complete")
|
||||||
if key in self._cache:
|
if key in self._cache:
|
||||||
self._hits += 1
|
self._hits += 1
|
||||||
return self._get_and_touch(self._cache, key)
|
return str(self._get_and_touch(self._cache, key))
|
||||||
|
|
||||||
self._misses += 1
|
self._misses += 1
|
||||||
response = self._adapter.complete(messages, **kwargs)
|
response = self._adapter.complete(messages, **kwargs)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
|
||||||
from fusionagi._logger import logger
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
|
||||||
|
|
||||||
class OpenAIAdapterError(Exception):
|
class OpenAIAdapterError(Exception):
|
||||||
@@ -169,7 +169,7 @@ class OpenAIAdapter(LLMAdapter):
|
|||||||
)
|
)
|
||||||
choice = resp.choices[0] if resp.choices else None
|
choice = resp.choices[0] if resp.choices else None
|
||||||
if choice and choice.message and choice.message.content:
|
if choice and choice.message and choice.message.content:
|
||||||
return choice.message.content
|
return str(choice.message.content)
|
||||||
logger.debug("OpenAI empty response", extra={"model": model, "attempt": attempt})
|
logger.debug("OpenAI empty response", extra={"model": model, "attempt": attempt})
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -209,7 +209,60 @@ class OpenAIAdapter(LLMAdapter):
|
|||||||
"OpenAI all retries exhausted",
|
"OpenAI all retries exhausted",
|
||||||
extra={"error": str(last_error), "attempts": self._max_retries + 1},
|
extra={"error": str(last_error), "attempts": self._max_retries + 1},
|
||||||
)
|
)
|
||||||
raise self._classify_error(last_error) from last_error
|
if last_error is not None:
|
||||||
|
raise self._classify_error(last_error) from last_error
|
||||||
|
raise OpenAIAdapterError("All retries exhausted with unknown error")
|
||||||
|
|
||||||
|
async def acomplete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Async version of complete using OpenAI's async client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content'.
|
||||||
|
**kwargs: Additional arguments for the API call.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The assistant's response content.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import openai
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError("Install with: pip install fusionagi[openai]") from e
|
||||||
|
|
||||||
|
async_client = openai.AsyncOpenAI(api_key=self._api_key, **self._client_kwargs)
|
||||||
|
model = kwargs.pop("model", self._model)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
delay = self._retry_delay
|
||||||
|
|
||||||
|
for attempt in range(self._max_retries + 1):
|
||||||
|
try:
|
||||||
|
response = await async_client.chat.completions.create(
|
||||||
|
model=model, messages=messages, **kwargs # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
content = response.choices[0].message.content or ""
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if not self._is_retryable_error(e) or attempt == self._max_retries:
|
||||||
|
break
|
||||||
|
logger.warning(
|
||||||
|
"OpenAI async retry",
|
||||||
|
extra={"attempt": attempt + 1, "error": str(e), "delay": delay},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay = min(delay * self._retry_multiplier, self._max_retry_delay)
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
|
raise self._classify_error(last_error) from last_error
|
||||||
|
raise OpenAIAdapterError("All retries exhausted")
|
||||||
|
|
||||||
def complete_structured(
|
def complete_structured(
|
||||||
self,
|
self,
|
||||||
|
|||||||
27
fusionagi/adapters/stt.py
Normal file
27
fusionagi/adapters/stt.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""STT adapter factory for VoiceManager integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fusionagi.adapters.stt_adapter import STTAdapter, StubSTTAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def get_stt_adapter(provider: str = "stub") -> STTAdapter:
|
||||||
|
"""Get an STT adapter for the given provider name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Provider identifier (stub, whisper, azure).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured STTAdapter instance.
|
||||||
|
"""
|
||||||
|
if provider == "whisper":
|
||||||
|
try:
|
||||||
|
from fusionagi.adapters.stt_adapter import WhisperSTTAdapter
|
||||||
|
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||||
|
if api_key:
|
||||||
|
return WhisperSTTAdapter(api_key=api_key)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return StubSTTAdapter()
|
||||||
138
fusionagi/adapters/stt_adapter.py
Normal file
138
fusionagi/adapters/stt_adapter.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""STT adapter: speech-to-text with Whisper, Azure, and stub implementations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fusionagi._logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class STTAdapter(ABC):
|
||||||
|
"""Abstract adapter for speech-to-text transcription."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio_data: bytes,
|
||||||
|
*,
|
||||||
|
language: str = "en",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | None:
|
||||||
|
"""Transcribe audio bytes to text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_data: Raw audio bytes (wav/mp3/ogg).
|
||||||
|
language: BCP-47 language code hint.
|
||||||
|
**kwargs: Provider-specific options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transcribed text or None on failure.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class StubSTTAdapter(STTAdapter):
|
||||||
|
"""Stub STT adapter for testing; returns placeholder text."""
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio_data: bytes,
|
||||||
|
*,
|
||||||
|
language: str = "en",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | None:
|
||||||
|
logger.debug("StubSTT: transcribe called", extra={"audio_size": len(audio_data)})
|
||||||
|
return "[stub transcription]"
|
||||||
|
|
||||||
|
|
||||||
|
class WhisperSTTAdapter(STTAdapter):
|
||||||
|
"""OpenAI Whisper STT adapter.
|
||||||
|
|
||||||
|
Requires the ``openai`` package and an OpenAI API key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str | None = None, model: str = "whisper-1") -> None:
|
||||||
|
self._api_key = api_key
|
||||||
|
self._model = model
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio_data: bytes,
|
||||||
|
*,
|
||||||
|
language: str = "en",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | None:
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
|
||||||
|
import openai
|
||||||
|
|
||||||
|
client = openai.OpenAI(api_key=self._api_key)
|
||||||
|
audio_file = io.BytesIO(audio_data)
|
||||||
|
audio_file.name = "audio.wav"
|
||||||
|
transcript = client.audio.transcriptions.create(
|
||||||
|
model=self._model,
|
||||||
|
file=audio_file,
|
||||||
|
language=language,
|
||||||
|
)
|
||||||
|
return transcript.text
|
||||||
|
except ImportError:
|
||||||
|
logger.error("openai not installed; pip install fusionagi[openai]")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Whisper STT failed", extra={"error": str(e)})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AzureSTTAdapter(STTAdapter):
|
||||||
|
"""Azure Cognitive Services STT adapter.
|
||||||
|
|
||||||
|
Requires ``httpx`` and an Azure Speech Services key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, region: str = "eastus") -> None:
|
||||||
|
self._api_key = api_key
|
||||||
|
self._region = region
|
||||||
|
self._endpoint = f"https://{region}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1"
|
||||||
|
|
||||||
|
async def transcribe(
|
||||||
|
self,
|
||||||
|
audio_data: bytes,
|
||||||
|
*,
|
||||||
|
language: str = "en-US",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str | None:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Ocp-Apim-Subscription-Key": self._api_key,
|
||||||
|
"Content-Type": "audio/wav",
|
||||||
|
}
|
||||||
|
params = {"language": language}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
self._endpoint,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
content=audio_data,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("DisplayText") or data.get("RecognitionStatus")
|
||||||
|
except ImportError:
|
||||||
|
logger.error("httpx not installed; pip install httpx")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Azure STT failed", extra={"error": str(e)})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"STTAdapter",
|
||||||
|
"StubSTTAdapter",
|
||||||
|
"WhisperSTTAdapter",
|
||||||
|
"AzureSTTAdapter",
|
||||||
|
]
|
||||||
234
fusionagi/adapters/tensorflow_adapter.py
Normal file
234
fusionagi/adapters/tensorflow_adapter.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""TensorFlow adapter: local model inference via TF/Keras with TensorCore.
|
||||||
|
|
||||||
|
Requires: pip install fusionagi[gpu]
|
||||||
|
|
||||||
|
Provides LLMAdapter-compatible interface for locally-hosted TensorFlow/Keras
|
||||||
|
models. Supports TensorCore mixed-precision, XLA compilation, and GPU memory
|
||||||
|
management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
import tensorflow as tf
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"TensorFlow is required for TensorFlowAdapter. "
|
||||||
|
"Install with: pip install fusionagi[gpu]"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
class TensorFlowAdapter(LLMAdapter):
|
||||||
|
"""LLM adapter for local TensorFlow/Keras model inference.
|
||||||
|
|
||||||
|
Loads a saved Keras model or TF SavedModel and runs inference with
|
||||||
|
TensorCore acceleration when available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_path: Path to a saved Keras model (.keras) or SavedModel directory.
|
||||||
|
tokenizer: Optional tokenizer callable (text -> token IDs).
|
||||||
|
max_length: Maximum sequence length for generation.
|
||||||
|
temperature: Sampling temperature.
|
||||||
|
mixed_precision: Enable FP16 mixed-precision for TensorCore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_path: str | None = None,
|
||||||
|
model: Any | None = None,
|
||||||
|
tokenizer: Any | None = None,
|
||||||
|
max_length: int = 512,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
mixed_precision: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._model: Any = None
|
||||||
|
self._tokenizer = tokenizer
|
||||||
|
self._max_length = max_length
|
||||||
|
self._temperature = temperature
|
||||||
|
self._model_path = model_path
|
||||||
|
|
||||||
|
if mixed_precision:
|
||||||
|
try:
|
||||||
|
tf.keras.mixed_precision.set_global_policy("mixed_float16")
|
||||||
|
logger.info("TensorFlowAdapter: TensorCore mixed-precision enabled")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("TensorFlowAdapter: mixed-precision not available")
|
||||||
|
|
||||||
|
if model is not None:
|
||||||
|
self._model = model
|
||||||
|
logger.info("TensorFlowAdapter initialized with provided model")
|
||||||
|
elif model_path:
|
||||||
|
self._load_model(model_path)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"TensorFlowAdapter initialized without model "
|
||||||
|
"(will use embedding-based synthesis)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_model(self, path: str) -> None:
|
||||||
|
"""Load a TF SavedModel or Keras model from disk."""
|
||||||
|
try:
|
||||||
|
self._model = tf.saved_model.load(path)
|
||||||
|
logger.info("TensorFlowAdapter: loaded SavedModel", extra={"path": path})
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._model = tf.keras.models.load_model(path)
|
||||||
|
logger.info("TensorFlowAdapter: loaded Keras model", extra={"path": path})
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"TensorFlowAdapter: no model loaded; "
|
||||||
|
"falling back to embedding synthesis",
|
||||||
|
extra={"path": path},
|
||||||
|
)
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Generate completion using the loaded TF model.
|
||||||
|
|
||||||
|
If no model is loaded, falls back to embedding-based synthesis
|
||||||
|
that uses GPU-accelerated similarity scoring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of message dicts with 'role' and 'content'.
|
||||||
|
**kwargs: Additional parameters (temperature, max_length).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated response text.
|
||||||
|
"""
|
||||||
|
if self._model is not None and self._tokenizer is not None:
|
||||||
|
return self._model_inference(messages, **kwargs)
|
||||||
|
return self._embedding_synthesis(messages)
|
||||||
|
|
||||||
|
def complete_structured(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
schema: dict[str, Any] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
"""Attempt structured JSON output from the model.
|
||||||
|
|
||||||
|
Falls back to parsing the raw completion if the model doesn't
|
||||||
|
natively support structured output.
|
||||||
|
"""
|
||||||
|
raw = self.complete(messages, **kwargs)
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _model_inference(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Run inference through the loaded TF/Keras model."""
|
||||||
|
prompt = self._messages_to_prompt(messages)
|
||||||
|
temperature = kwargs.get("temperature", self._temperature)
|
||||||
|
max_length = kwargs.get("max_length", self._max_length)
|
||||||
|
|
||||||
|
tokenizer = self._tokenizer
|
||||||
|
assert tokenizer is not None
|
||||||
|
tokens = tokenizer(prompt)
|
||||||
|
if isinstance(tokens, (list, np.ndarray)):
|
||||||
|
input_tensor = tf.constant([tokens[:max_length]], dtype=tf.int32)
|
||||||
|
else:
|
||||||
|
input_tensor = tokens
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(self._model, "generate"):
|
||||||
|
output = self._model.generate(
|
||||||
|
input_tensor,
|
||||||
|
max_length=max_length,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
elif hasattr(self._model, "predict"):
|
||||||
|
output = self._model.predict(input_tensor)
|
||||||
|
elif callable(self._model):
|
||||||
|
output = self._model(input_tensor)
|
||||||
|
else:
|
||||||
|
logger.warning("TensorFlowAdapter: model has no callable interface")
|
||||||
|
return self._embedding_synthesis(messages)
|
||||||
|
|
||||||
|
if isinstance(output, tf.Tensor):
|
||||||
|
output = output.numpy()
|
||||||
|
if hasattr(output, "tolist"):
|
||||||
|
output = output.tolist()
|
||||||
|
if isinstance(output, list) and output:
|
||||||
|
if isinstance(output[0], list):
|
||||||
|
output = output[0]
|
||||||
|
if isinstance(output[0], (int, float)):
|
||||||
|
if tokenizer and hasattr(tokenizer, "decode"):
|
||||||
|
return str(tokenizer.decode(output))
|
||||||
|
return str(output) # type: ignore[no-any-return]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"TensorFlowAdapter: model inference failed, using synthesis",
|
||||||
|
extra={"error": str(e)},
|
||||||
|
)
|
||||||
|
return self._embedding_synthesis(messages)
|
||||||
|
|
||||||
|
def _embedding_synthesis(self, messages: list[dict[str, str]]) -> str:
|
||||||
|
"""Fallback: synthesize response using GPU-accelerated embeddings.
|
||||||
|
|
||||||
|
Embeds message content and produces a summary based on
|
||||||
|
semantic similarity between parts.
|
||||||
|
"""
|
||||||
|
content_parts: list[str] = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content", "")
|
||||||
|
if isinstance(content, str) and content.strip():
|
||||||
|
content_parts.append(content.strip())
|
||||||
|
|
||||||
|
if not content_parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from fusionagi.gpu.backend import get_backend
|
||||||
|
|
||||||
|
be = get_backend()
|
||||||
|
embeddings = be.embed_texts(content_parts)
|
||||||
|
emb_np = be.to_numpy(embeddings)
|
||||||
|
|
||||||
|
mean_emb = np.mean(emb_np, axis=0, keepdims=True)
|
||||||
|
sims = be.to_numpy(
|
||||||
|
be.cosine_similarity_matrix(be.from_numpy(mean_emb), embeddings)
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
ranked_indices = np.argsort(sims)[::-1]
|
||||||
|
summary_parts: list[str] = []
|
||||||
|
for idx in ranked_indices[:5]:
|
||||||
|
part = content_parts[idx]
|
||||||
|
summary_parts.append(part[:300])
|
||||||
|
|
||||||
|
return "\n\n".join(summary_parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _messages_to_prompt(messages: list[dict[str, str]]) -> str:
|
||||||
|
"""Convert message list to a flat prompt string."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "user")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
parts.append(f"<|{role}|>\n{content}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
def device_summary(self) -> dict[str, Any]:
|
||||||
|
"""Return device and model information."""
|
||||||
|
gpus = tf.config.list_physical_devices("GPU")
|
||||||
|
return {
|
||||||
|
"adapter": "tensorflow",
|
||||||
|
"model_path": self._model_path,
|
||||||
|
"has_model": self._model is not None,
|
||||||
|
"has_tokenizer": self._tokenizer is not None,
|
||||||
|
"gpu_count": len(gpus),
|
||||||
|
"tf_version": tf.__version__,
|
||||||
|
}
|
||||||
24
fusionagi/adapters/tts.py
Normal file
24
fusionagi/adapters/tts.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""TTS adapter factory for VoiceManager integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fusionagi.adapters.tts_adapter import ElevenLabsTTSAdapter, StubTTSAdapter, TTSAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def get_tts_adapter(provider: str = "stub") -> TTSAdapter:
|
||||||
|
"""Get a TTS adapter for the given provider name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Provider identifier (stub, elevenlabs, system).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured TTSAdapter instance.
|
||||||
|
"""
|
||||||
|
if provider == "elevenlabs":
|
||||||
|
api_key = os.environ.get("ELEVENLABS_API_KEY", "")
|
||||||
|
if api_key:
|
||||||
|
return ElevenLabsTTSAdapter(api_key=api_key)
|
||||||
|
return StubTTSAdapter()
|
||||||
|
return StubTTSAdapter()
|
||||||
122
fusionagi/adapters/tts_adapter.py
Normal file
122
fusionagi/adapters/tts_adapter.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""TTS adapter protocol and implementations for speech synthesis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fusionagi._logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class TTSAdapter(ABC):
|
||||||
|
"""Abstract adapter for text-to-speech synthesis.
|
||||||
|
|
||||||
|
Implementations handle provider-specific API calls (ElevenLabs,
|
||||||
|
Azure Cognitive Services, Google Cloud TTS, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
voice_id: str | None = None,
|
||||||
|
language: str = "en",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Synthesize text to audio bytes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to synthesize.
|
||||||
|
voice_id: Provider-specific voice identifier.
|
||||||
|
language: Language code (BCP-47).
|
||||||
|
**kwargs: Provider-specific options.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw audio bytes (mp3/wav) or None on failure.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class StubTTSAdapter(TTSAdapter):
|
||||||
|
"""Stub TTS adapter for testing; returns empty audio."""
|
||||||
|
|
||||||
|
async def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
voice_id: str | None = None,
|
||||||
|
language: str = "en",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Return empty bytes for testing."""
|
||||||
|
logger.debug("StubTTS: synthesize called", extra={"text": text[:50], "voice_id": voice_id})
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
class ElevenLabsTTSAdapter(TTSAdapter):
|
||||||
|
"""ElevenLabs TTS adapter.
|
||||||
|
|
||||||
|
Requires the ``httpx`` package and an ElevenLabs API key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
API_BASE = "https://api.elevenlabs.io/v1"
|
||||||
|
DEFAULT_VOICE = "21m00Tcm4TlvDq8ikWAM" # Rachel
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
*,
|
||||||
|
default_voice_id: str | None = None,
|
||||||
|
model_id: str = "eleven_monolingual_v1",
|
||||||
|
) -> None:
|
||||||
|
self._api_key = api_key
|
||||||
|
self._default_voice = default_voice_id or self.DEFAULT_VOICE
|
||||||
|
self._model_id = model_id
|
||||||
|
|
||||||
|
async def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
voice_id: str | None = None,
|
||||||
|
language: str = "en",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> bytes | None:
|
||||||
|
"""Call ElevenLabs TTS API."""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
logger.error("httpx not installed; pip install httpx")
|
||||||
|
return None
|
||||||
|
|
||||||
|
vid = voice_id or self._default_voice
|
||||||
|
url = f"{self.API_BASE}/text-to-speech/{vid}"
|
||||||
|
headers = {"xi-api-key": self._api_key, "Content-Type": "application/json"}
|
||||||
|
payload = {
|
||||||
|
"text": text,
|
||||||
|
"model_id": self._model_id,
|
||||||
|
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(url, json=payload, headers=headers, timeout=30.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("ElevenLabs TTS failed", extra={"error": str(e)})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def audio_to_base64(audio_bytes: bytes) -> str:
|
||||||
|
"""Encode raw audio bytes to base64 string."""
|
||||||
|
return base64.b64encode(audio_bytes).decode()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TTSAdapter",
|
||||||
|
"StubTTSAdapter",
|
||||||
|
"ElevenLabsTTSAdapter",
|
||||||
|
"audio_to_base64",
|
||||||
|
]
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"""Agents: base, planner, reasoner, executor, critic, adversarial reviewer, head, witness. See fusionagi.multi_agent for Supervisor, Coordinator, Pool."""
|
"""Agents: base, planner, reasoner, executor, critic, adversarial reviewer, head, witness. See fusionagi.multi_agent for Supervisor, Coordinator, Pool."""
|
||||||
|
|
||||||
|
from fusionagi.agents.adversarial_reviewer import AdversarialReviewerAgent
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
|
from fusionagi.agents.critic import CriticAgent
|
||||||
|
from fusionagi.agents.executor import ExecutorAgent
|
||||||
|
from fusionagi.agents.head_agent import HeadAgent
|
||||||
from fusionagi.agents.planner import PlannerAgent
|
from fusionagi.agents.planner import PlannerAgent
|
||||||
from fusionagi.agents.reasoner import ReasonerAgent
|
from fusionagi.agents.reasoner import ReasonerAgent
|
||||||
from fusionagi.agents.executor import ExecutorAgent
|
|
||||||
from fusionagi.agents.critic import CriticAgent
|
|
||||||
from fusionagi.agents.adversarial_reviewer import AdversarialReviewerAgent
|
|
||||||
from fusionagi.agents.head_agent import HeadAgent
|
|
||||||
from fusionagi.agents.witness_agent import WitnessAgent
|
from fusionagi.agents.witness_agent import WitnessAgent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
|
||||||
from fusionagi._logger import logger
|
|
||||||
import json
|
|
||||||
|
|
||||||
class AdversarialReviewerAgent(BaseAgent):
|
class AdversarialReviewerAgent(BaseAgent):
|
||||||
def __init__(self, identity="adversarial_reviewer", adapter=None):
|
def __init__(self, identity="adversarial_reviewer", adapter=None):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Base agent interface: identity, role, objective, memory/tool scope, handle_message."""
|
"""Base agent interface: identity, role, objective, memory/tool scope, handle_message."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fusionagi.schemas.messages import AgentMessageEnvelope
|
from fusionagi.schemas.messages import AgentMessageEnvelope
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
|
||||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
|
||||||
from fusionagi._logger import logger
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
|
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||||
|
|
||||||
|
|
||||||
class CriticAgent(BaseAgent):
|
class CriticAgent(BaseAgent):
|
||||||
@@ -78,13 +78,13 @@ class CriticAgent(BaseAgent):
|
|||||||
{"role": "user", "content": context},
|
{"role": "user", "content": context},
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
raw = self._adapter.complete(messages)
|
raw = self._adapter.complete(messages) # type: ignore[union-attr]
|
||||||
for start in ("```json", "```"):
|
for start in ("```json", "```"):
|
||||||
if raw.strip().startswith(start):
|
if raw.strip().startswith(start):
|
||||||
raw = raw.strip()[len(start):].strip()
|
raw = raw.strip()[len(start):].strip()
|
||||||
if raw.endswith("```"):
|
if raw.endswith("```"):
|
||||||
raw = raw[:-3].strip()
|
raw = raw[:-3].strip()
|
||||||
return json.loads(raw)
|
return json.loads(raw) # type: ignore[no-any-return]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Critic evaluation parse failed, using fallback")
|
logger.exception("Critic evaluation parse failed, using fallback")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from fusionagi._logger import logger
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
|
from fusionagi.planning import get_step
|
||||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||||
from fusionagi.schemas.plan import Plan
|
from fusionagi.schemas.plan import Plan
|
||||||
from fusionagi.planning import get_step
|
|
||||||
from fusionagi.tools.registry import ToolRegistry
|
from fusionagi.tools.registry import ToolRegistry
|
||||||
from fusionagi.tools.runner import run_tool
|
from fusionagi.tools.runner import run_tool
|
||||||
from fusionagi._logger import logger
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fusionagi.core.state_manager import StateManager
|
from fusionagi.core.state_manager import StateManager
|
||||||
from fusionagi.governance.guardrails import Guardrails
|
|
||||||
from fusionagi.governance.rate_limiter import RateLimiter
|
|
||||||
from fusionagi.governance.access_control import AccessControl
|
from fusionagi.governance.access_control import AccessControl
|
||||||
|
from fusionagi.governance.guardrails import Guardrails
|
||||||
from fusionagi.governance.override import OverrideHooks
|
from fusionagi.governance.override import OverrideHooks
|
||||||
|
from fusionagi.governance.rate_limiter import RateLimiter
|
||||||
from fusionagi.memory.episodic import EpisodicMemory
|
from fusionagi.memory.episodic import EpisodicMemory
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
from typing import Any, Protocol, runtime_checkable
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
|
||||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
|
||||||
from fusionagi.schemas.head import HeadId, HeadOutput, HeadClaim, HeadRisk
|
|
||||||
from fusionagi.schemas.grounding import Citation
|
|
||||||
from fusionagi._logger import logger
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
|
from fusionagi.schemas.grounding import Citation
|
||||||
|
from fusionagi.schemas.head import HeadClaim, HeadId, HeadOutput, HeadRisk
|
||||||
|
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
@@ -98,6 +98,38 @@ class HeadAgent(BaseAgent):
|
|||||||
self._system_prompt = system_prompt
|
self._system_prompt = system_prompt
|
||||||
self._adapter = adapter
|
self._adapter = adapter
|
||||||
self._reasoning_provider = reasoning_provider
|
self._reasoning_provider = reasoning_provider
|
||||||
|
self._ethics_hooks: list[Any] = []
|
||||||
|
self._consequence_hooks: list[Any] = []
|
||||||
|
|
||||||
|
def on_ethical_feedback(self, feedback: dict[str, Any]) -> None:
|
||||||
|
"""Receive ethical feedback from the adaptive ethics engine.
|
||||||
|
|
||||||
|
Custom heads can override this to learn from ethical outcomes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback: Dict with action_type, outcome_positive, weight, etc.
|
||||||
|
"""
|
||||||
|
for hook in self._ethics_hooks:
|
||||||
|
hook(feedback)
|
||||||
|
|
||||||
|
def on_consequence(self, consequence: dict[str, Any]) -> None:
|
||||||
|
"""Receive consequence data from the consequence engine.
|
||||||
|
|
||||||
|
Custom heads can override this to learn from action outcomes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
consequence: Dict with choice_id, outcome_positive, surprise_factor, etc.
|
||||||
|
"""
|
||||||
|
for hook in self._consequence_hooks:
|
||||||
|
hook(consequence)
|
||||||
|
|
||||||
|
def add_ethics_hook(self, hook: Any) -> None:
|
||||||
|
"""Register a callback for ethical feedback events."""
|
||||||
|
self._ethics_hooks.append(hook)
|
||||||
|
|
||||||
|
def add_consequence_hook(self, hook: Any) -> None:
|
||||||
|
"""Register a callback for consequence events."""
|
||||||
|
self._consequence_hooks.append(hook)
|
||||||
|
|
||||||
def handle_message(self, envelope: AgentMessageEnvelope) -> AgentMessageEnvelope | None:
|
def handle_message(self, envelope: AgentMessageEnvelope) -> AgentMessageEnvelope | None:
|
||||||
"""On head_request, produce HeadOutput and return head_output envelope."""
|
"""On head_request, produce HeadOutput and return head_output envelope."""
|
||||||
|
|||||||
336
fusionagi/agents/head_registry.py
Normal file
336
fusionagi/agents/head_registry.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""Plugin system — head registry for custom heads.
|
||||||
|
|
||||||
|
Provides a registry-based architecture for dynamically registering,
|
||||||
|
discovering, and creating head agents. Replaces the hardcoded head
|
||||||
|
creation in ``agents/heads/__init__.py`` with an extensible system.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
from fusionagi.agents.head_registry import HeadRegistry
|
||||||
|
|
||||||
|
registry = HeadRegistry()
|
||||||
|
|
||||||
|
# Built-in heads are pre-registered
|
||||||
|
head = registry.create("logic")
|
||||||
|
|
||||||
|
# Register a custom head
|
||||||
|
@registry.register_factory("my_domain")
|
||||||
|
def create_my_head(adapter, **kwargs):
|
||||||
|
return HeadAgent(head_id=HeadId.LOGIC, role="My Domain", ...)
|
||||||
|
|
||||||
|
# Discover all available heads
|
||||||
|
registry.list_heads()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
from fusionagi.agents.head_agent import HeadAgent
|
||||||
|
from fusionagi.prompts.heads import get_head_prompt
|
||||||
|
from fusionagi.reasoning.native import NativeReasoningProvider
|
||||||
|
from fusionagi.schemas.head import HeadId
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HeadSpec:
|
||||||
|
"""Specification for a registered head type."""
|
||||||
|
|
||||||
|
head_id: str
|
||||||
|
role: str
|
||||||
|
objective: str
|
||||||
|
factory: Callable[..., HeadAgent]
|
||||||
|
description: str = ""
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
builtin: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class HeadRegistry:
|
||||||
|
"""Extensible registry for head agent types.
|
||||||
|
|
||||||
|
Pre-registers all 11 built-in Dvādaśa content heads on creation.
|
||||||
|
Custom heads can be added via ``register()`` or ``register_factory()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, auto_register_builtins: bool = True) -> None:
|
||||||
|
self._specs: dict[str, HeadSpec] = {}
|
||||||
|
if auto_register_builtins:
|
||||||
|
self._register_builtins()
|
||||||
|
|
||||||
|
def _register_builtins(self) -> None:
|
||||||
|
"""Register all built-in Dvādaśa content heads."""
|
||||||
|
role_map: dict[HeadId, tuple[str, str]] = {
|
||||||
|
HeadId.LOGIC: ("Logic", "Correctness, contradictions, formal checks"),
|
||||||
|
HeadId.RESEARCH: ("Research", "Retrieval, source quality, citations"),
|
||||||
|
HeadId.SYSTEMS: ("Systems", "Architecture, dependencies, scalability"),
|
||||||
|
HeadId.STRATEGY: ("Strategy", "Roadmap, prioritization, tradeoffs"),
|
||||||
|
HeadId.PRODUCT: ("Product/UX", "Interaction design, user flows"),
|
||||||
|
HeadId.SECURITY: ("Security", "Threats, auth, secrets, abuse vectors"),
|
||||||
|
HeadId.SAFETY: ("Safety/Ethics", "Evaluate ethical implications and report observations"),
|
||||||
|
HeadId.RELIABILITY: ("Reliability", "SLOs, failover, load testing, observability"),
|
||||||
|
HeadId.COST: ("Cost/Performance", "Token budgets, caching, model routing"),
|
||||||
|
HeadId.DATA: ("Data/Memory", "Schemas, privacy, retention, personalization"),
|
||||||
|
HeadId.DEVEX: ("DevEx", "CI/CD, testing strategy, local tooling"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for head_id, (role, objective) in role_map.items():
|
||||||
|
self._register_builtin_head(head_id, role, objective)
|
||||||
|
|
||||||
|
def _register_builtin_head(
|
||||||
|
self, head_id: HeadId, role: str, objective: str
|
||||||
|
) -> None:
|
||||||
|
"""Register a single built-in head."""
|
||||||
|
|
||||||
|
def factory(
|
||||||
|
adapter: LLMAdapter | None = None,
|
||||||
|
tool_permissions: list[str] | None = None,
|
||||||
|
reasoning_provider: NativeReasoningProvider | None = None,
|
||||||
|
use_native_reasoning: bool = True,
|
||||||
|
_hid: HeadId = head_id,
|
||||||
|
_role: str = role,
|
||||||
|
_obj: str = objective,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> HeadAgent:
|
||||||
|
provider = reasoning_provider
|
||||||
|
if provider is None and use_native_reasoning and adapter is None:
|
||||||
|
provider = NativeReasoningProvider()
|
||||||
|
|
||||||
|
return HeadAgent(
|
||||||
|
head_id=_hid,
|
||||||
|
role=_role,
|
||||||
|
objective=_obj,
|
||||||
|
system_prompt=get_head_prompt(_hid),
|
||||||
|
adapter=adapter,
|
||||||
|
tool_permissions=tool_permissions,
|
||||||
|
reasoning_provider=provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._specs[head_id.value] = HeadSpec(
|
||||||
|
head_id=head_id.value,
|
||||||
|
role=role,
|
||||||
|
objective=objective,
|
||||||
|
factory=factory,
|
||||||
|
description=f"Built-in {role} head",
|
||||||
|
tags=["builtin", "dvadasa"],
|
||||||
|
builtin=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
head_id: str,
|
||||||
|
role: str,
|
||||||
|
objective: str,
|
||||||
|
factory: Callable[..., HeadAgent],
|
||||||
|
*,
|
||||||
|
description: str = "",
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Register a custom head type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
head_id: Unique identifier for the head.
|
||||||
|
role: Head's role name.
|
||||||
|
objective: What the head does.
|
||||||
|
factory: Callable that creates a HeadAgent.
|
||||||
|
description: Human-readable description.
|
||||||
|
tags: Optional tags for discovery.
|
||||||
|
"""
|
||||||
|
if head_id in self._specs:
|
||||||
|
logger.warning(
|
||||||
|
"Overwriting existing head registration",
|
||||||
|
extra={"head_id": head_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._specs[head_id] = HeadSpec(
|
||||||
|
head_id=head_id,
|
||||||
|
role=role,
|
||||||
|
objective=objective,
|
||||||
|
factory=factory,
|
||||||
|
description=description,
|
||||||
|
tags=tags or [],
|
||||||
|
builtin=False,
|
||||||
|
)
|
||||||
|
logger.info("Custom head registered", extra={"head_id": head_id, "role": role})
|
||||||
|
|
||||||
|
def register_factory(
|
||||||
|
self,
|
||||||
|
head_id: str,
|
||||||
|
*,
|
||||||
|
role: str = "",
|
||||||
|
objective: str = "",
|
||||||
|
description: str = "",
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
) -> Callable[[Callable[..., HeadAgent]], Callable[..., HeadAgent]]:
|
||||||
|
"""Decorator to register a head factory function.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
head_id: Unique identifier.
|
||||||
|
role: Head's role name.
|
||||||
|
objective: What the head does.
|
||||||
|
description: Human-readable description.
|
||||||
|
tags: Optional tags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorator function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(fn: Callable[..., HeadAgent]) -> Callable[..., HeadAgent]:
|
||||||
|
self.register(
|
||||||
|
head_id=head_id,
|
||||||
|
role=role or head_id.replace("_", " ").title(),
|
||||||
|
objective=objective or fn.__doc__ or "",
|
||||||
|
factory=fn,
|
||||||
|
description=description,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
head_id: str,
|
||||||
|
adapter: LLMAdapter | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> HeadAgent:
|
||||||
|
"""Create a head agent by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
head_id: Registered head identifier.
|
||||||
|
adapter: Optional LLM adapter.
|
||||||
|
**kwargs: Additional arguments passed to factory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created HeadAgent.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If head_id is not registered.
|
||||||
|
"""
|
||||||
|
if head_id not in self._specs:
|
||||||
|
raise KeyError(
|
||||||
|
f"Head '{head_id}' not registered. "
|
||||||
|
f"Available: {', '.join(sorted(self._specs.keys()))}"
|
||||||
|
)
|
||||||
|
spec = self._specs[head_id]
|
||||||
|
return spec.factory(adapter=adapter, **kwargs)
|
||||||
|
|
||||||
|
def create_all(
|
||||||
|
self,
|
||||||
|
adapter: LLMAdapter | None = None,
|
||||||
|
*,
|
||||||
|
include_tags: list[str] | None = None,
|
||||||
|
exclude_tags: list[str] | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> dict[str, HeadAgent]:
|
||||||
|
"""Create all registered heads (optionally filtered by tags).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
adapter: Optional LLM adapter.
|
||||||
|
include_tags: Only create heads matching these tags.
|
||||||
|
exclude_tags: Skip heads matching these tags.
|
||||||
|
**kwargs: Additional arguments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of head_id -> HeadAgent.
|
||||||
|
"""
|
||||||
|
heads: dict[str, HeadAgent] = {}
|
||||||
|
for hid, spec in self._specs.items():
|
||||||
|
if include_tags and not any(t in spec.tags for t in include_tags):
|
||||||
|
continue
|
||||||
|
if exclude_tags and any(t in spec.tags for t in exclude_tags):
|
||||||
|
continue
|
||||||
|
heads[hid] = spec.factory(adapter=adapter, **kwargs)
|
||||||
|
return heads
|
||||||
|
|
||||||
|
def list_heads(self) -> list[dict[str, Any]]:
|
||||||
|
"""List all registered heads.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of head specifications.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"head_id": spec.head_id,
|
||||||
|
"role": spec.role,
|
||||||
|
"objective": spec.objective,
|
||||||
|
"description": spec.description,
|
||||||
|
"tags": spec.tags,
|
||||||
|
"builtin": spec.builtin,
|
||||||
|
}
|
||||||
|
for spec in self._specs.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_spec(self, head_id: str) -> HeadSpec | None:
|
||||||
|
"""Get the spec for a registered head."""
|
||||||
|
return self._specs.get(head_id)
|
||||||
|
|
||||||
|
def unregister(self, head_id: str) -> bool:
|
||||||
|
"""Remove a head registration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
head_id: Head to remove.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if removed, False if not found.
|
||||||
|
"""
|
||||||
|
if head_id in self._specs:
|
||||||
|
del self._specs[head_id]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def broadcast_ethical_feedback(
|
||||||
|
self,
|
||||||
|
heads: dict[str, Any],
|
||||||
|
feedback: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast ethical feedback to all active heads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
heads: Dict of head_id -> HeadAgent instances.
|
||||||
|
feedback: Ethical feedback data.
|
||||||
|
"""
|
||||||
|
for hid, head in heads.items():
|
||||||
|
if hasattr(head, "on_ethical_feedback"):
|
||||||
|
head.on_ethical_feedback(feedback)
|
||||||
|
|
||||||
|
def broadcast_consequence(
|
||||||
|
self,
|
||||||
|
heads: dict[str, Any],
|
||||||
|
consequence: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast consequence data to all active heads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
heads: Dict of head_id -> HeadAgent instances.
|
||||||
|
consequence: Consequence data.
|
||||||
|
"""
|
||||||
|
for hid, head in heads.items():
|
||||||
|
if hasattr(head, "on_consequence"):
|
||||||
|
head.on_consequence(consequence)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def registered_count(self) -> int:
|
||||||
|
"""Number of registered heads."""
|
||||||
|
return len(self._specs)
|
||||||
|
|
||||||
|
|
||||||
|
# Global default registry
|
||||||
|
_default_registry: HeadRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_registry() -> HeadRegistry:
|
||||||
|
"""Get or create the default global head registry."""
|
||||||
|
global _default_registry # noqa: PLW0603
|
||||||
|
if _default_registry is None:
|
||||||
|
_default_registry = HeadRegistry()
|
||||||
|
return _default_registry
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HeadRegistry",
|
||||||
|
"HeadSpec",
|
||||||
|
"get_default_registry",
|
||||||
|
]
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
"""Dvādaśa content head agents: Logic, Research, Systems, Strategy, etc."""
|
"""Dvādaśa content head agents: Logic, Research, Systems, Strategy, etc."""
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fusionagi.agents.head_agent import HeadAgent
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
from fusionagi.agents.head_agent import HeadAgent
|
||||||
|
from fusionagi.prompts.heads import get_head_prompt
|
||||||
from fusionagi.reasoning.native import NativeReasoningProvider
|
from fusionagi.reasoning.native import NativeReasoningProvider
|
||||||
from fusionagi.schemas.head import HeadId
|
from fusionagi.schemas.head import HeadId
|
||||||
from fusionagi.prompts.heads import get_head_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def create_head_agent(
|
def create_head_agent(
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
|
||||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
|
||||||
from fusionagi._logger import logger
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
|
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||||
|
|
||||||
PLAN_REQUEST_SYSTEM = """You are a planner. Given a goal and optional constraints, output a JSON object with this exact structure:
|
PLAN_REQUEST_SYSTEM = """You are a planner. Given a goal and optional constraints, output a JSON object with this exact structure:
|
||||||
{"steps": [{"id": "step_1", "description": "...", "dependencies": []}, ...], "fallback_paths": []}
|
{"steps": [{"id": "step_1", "description": "...", "dependencies": []}, ...], "fallback_paths": []}
|
||||||
@@ -102,11 +102,13 @@ class PlannerAgent(BaseAgent):
|
|||||||
match = re.search(r"\{[\s\S]*\}", raw)
|
match = re.search(r"\{[\s\S]*\}", raw)
|
||||||
if match:
|
if match:
|
||||||
try:
|
try:
|
||||||
return json.loads(match.group())
|
result: dict[str, Any] = json.loads(match.group())
|
||||||
|
return result
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.debug("Planner JSON parse failed (match)", extra={"error": str(e)})
|
logger.debug("Planner JSON parse failed (match)", extra={"error": str(e)})
|
||||||
try:
|
try:
|
||||||
return json.loads(raw)
|
result = json.loads(raw)
|
||||||
|
return result # type: ignore[return-value]
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.debug("Planner JSON parse failed (raw)", extra={"error": str(e)})
|
logger.debug("Planner JSON parse failed (raw)", extra={"error": str(e)})
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ The Reasoner agent:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fusionagi.agents.base_agent import BaseAgent
|
|
||||||
from fusionagi.adapters.base import LLMAdapter
|
|
||||||
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
|
||||||
from fusionagi.reasoning import run_chain_of_thought
|
|
||||||
from fusionagi._logger import logger
|
from fusionagi._logger import logger
|
||||||
|
from fusionagi.adapters.base import LLMAdapter
|
||||||
|
from fusionagi.agents.base_agent import BaseAgent
|
||||||
|
from fusionagi.reasoning import run_chain_of_thought
|
||||||
|
from fusionagi.schemas.messages import AgentMessage, AgentMessageEnvelope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fusionagi.memory.working import WorkingMemory
|
|
||||||
from fusionagi.memory.episodic import EpisodicMemory
|
from fusionagi.memory.episodic import EpisodicMemory
|
||||||
|
from fusionagi.memory.working import WorkingMemory
|
||||||
|
|
||||||
|
|
||||||
class ReasonerAgent(BaseAgent):
|
class ReasonerAgent(BaseAgent):
|
||||||
@@ -174,11 +174,11 @@ class ReasonerAgent(BaseAgent):
|
|||||||
f"- Step {r.get('step_id', '?')}: {r.get('response', '')[:100]}"
|
f"- Step {r.get('step_id', '?')}: {r.get('response', '')[:100]}"
|
||||||
for r in recent_reasoning
|
for r in recent_reasoning
|
||||||
]
|
]
|
||||||
enriched_parts.append(f"\nRecent reasoning:\n" + "\n".join(recent_summaries))
|
enriched_parts.append("\nRecent reasoning:\n" + "\n".join(recent_summaries))
|
||||||
|
|
||||||
return "\n".join(enriched_parts)
|
return "\n".join(enriched_parts)
|
||||||
|
|
||||||
def _calculate_confidence(self, trace: list[dict[str, Any]]) -> float:
|
def _calculate_confidence(self, trace: list[str] | list[dict[str, Any]]) -> float:
|
||||||
"""Calculate confidence score based on reasoning trace."""
|
"""Calculate confidence score based on reasoning trace."""
|
||||||
if not trace:
|
if not trace:
|
||||||
return 0.5 # Default confidence without trace
|
return 0.5 # Default confidence without trace
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user