Compare commits
3 Commits
v0.0.8
...
58ba99f3eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ba99f3eb | |||
| 3c8e7fd6b2 | |||
| b86bfebe1f |
129
.env.example
129
.env.example
@@ -1,129 +1,20 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Database (port 5433 avoids conflict with host PostgreSQL)
|
||||||
# Mosaic — Environment Variables Reference
|
|
||||||
# Copy this file to .env and fill in the values for your deployment.
|
|
||||||
# Lines beginning with # are comments; optional vars are commented out.
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
|
|
||||||
# Full connection string used by the gateway, ORM, and migration runner.
|
|
||||||
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
|
|
||||||
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||||
|
|
||||||
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
|
# Valkey (Redis-compatible, port 6380 avoids conflict with host Redis/Valkey)
|
||||||
# PG_HOST_PORT=5433
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
|
|
||||||
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
|
|
||||||
VALKEY_URL=redis://localhost:6380
|
VALKEY_URL=redis://localhost:6380
|
||||||
|
|
||||||
# Docker Compose host-port override for the Valkey container (default: 6380)
|
# Docker Compose host port overrides (optional)
|
||||||
|
# PG_HOST_PORT=5433
|
||||||
# VALKEY_HOST_PORT=6380
|
# VALKEY_HOST_PORT=6380
|
||||||
|
|
||||||
|
# OpenTelemetry
|
||||||
# ─── Gateway ─────────────────────────────────────────────────────────────────
|
|
||||||
# TCP port the NestJS/Fastify gateway listens on (default: 4000)
|
|
||||||
GATEWAY_PORT=4000
|
|
||||||
|
|
||||||
# Comma-separated list of allowed CORS origins.
|
|
||||||
# Must include the web app origin in production.
|
|
||||||
GATEWAY_CORS_ORIGIN=http://localhost:3000
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
|
|
||||||
# REQUIRED — random secret used to sign sessions and tokens.
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
|
||||||
|
|
||||||
# Public base URL of the gateway (used by BetterAuth for callback URLs)
|
|
||||||
BETTER_AUTH_URL=http://localhost:4000
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
|
|
||||||
# Public gateway URL — accessible from the browser, not just the server.
|
|
||||||
NEXT_PUBLIC_GATEWAY_URL=http://localhost:4000
|
|
||||||
|
|
||||||
|
|
||||||
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
|
|
||||||
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
|
|
||||||
# Service name shown in traces
|
|
||||||
OTEL_SERVICE_NAME=mosaic-gateway
|
OTEL_SERVICE_NAME=mosaic-gateway
|
||||||
|
|
||||||
|
# Auth (BetterAuth)
|
||||||
|
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
|
||||||
|
BETTER_AUTH_URL=http://localhost:4000
|
||||||
|
|
||||||
# ─── AI Providers ────────────────────────────────────────────────────────────
|
# Gateway
|
||||||
|
GATEWAY_PORT=4000
|
||||||
# Ollama (local models — set OLLAMA_BASE_URL to enable)
|
|
||||||
# OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
|
|
||||||
# OLLAMA_HOST=http://localhost:11434
|
|
||||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
|
||||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
|
||||||
|
|
||||||
# OpenAI — required for embedding and log-summarization features
|
|
||||||
# OPENAI_API_KEY=sk-...
|
|
||||||
|
|
||||||
# Custom providers — JSON array of provider configs
|
|
||||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
|
||||||
# MOSAIC_CUSTOM_PROVIDERS=
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Embedding Service ───────────────────────────────────────────────────────
|
|
||||||
# OpenAI-compatible embeddings endpoint (default: OpenAI)
|
|
||||||
# EMBEDDING_API_URL=https://api.openai.com/v1
|
|
||||||
# EMBEDDING_MODEL=text-embedding-3-small
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Log Summarization Service ───────────────────────────────────────────────
|
|
||||||
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
|
|
||||||
# SUMMARIZATION_API_URL=https://api.openai.com/v1
|
|
||||||
# SUMMARIZATION_MODEL=gpt-4o-mini
|
|
||||||
|
|
||||||
# Cron schedule for summarization job (default: every 6 hours)
|
|
||||||
# SUMMARIZATION_CRON=0 */6 * * *
|
|
||||||
|
|
||||||
# Cron schedule for log tier management (default: daily at 03:00)
|
|
||||||
# TIER_MANAGEMENT_CRON=0 3 * * *
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Agent ───────────────────────────────────────────────────────────────────
|
|
||||||
# Filesystem sandbox root for agent file tools (default: process.cwd())
|
|
||||||
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
|
|
||||||
|
|
||||||
# Comma-separated list of tool names available to non-admin users.
|
|
||||||
# Leave unset to allow all tools for all authenticated users.
|
|
||||||
# AGENT_USER_TOOLS=read_file,list_directory,search_files
|
|
||||||
|
|
||||||
# System prompt injected into every agent session (optional)
|
|
||||||
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
|
|
||||||
|
|
||||||
|
|
||||||
# ─── MCP Servers ─────────────────────────────────────────────────────────────
|
|
||||||
# JSON array of MCP server configs — set to enable MCP tool integration.
|
|
||||||
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
|
|
||||||
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Coordinator ─────────────────────────────────────────────────────────────
|
|
||||||
# Root directory used to scope coordinator (worktree/repo) operations.
|
|
||||||
# Defaults to the monorepo root auto-detected from process.cwd().
|
|
||||||
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
|
|
||||||
# DISCORD_BOT_TOKEN=
|
|
||||||
# DISCORD_GUILD_ID=
|
|
||||||
# DISCORD_GATEWAY_URL=http://localhost:4000
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
|
|
||||||
# TELEGRAM_BOT_TOKEN=
|
|
||||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
|
||||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
|
||||||
# AUTHENTIK_CLIENT_ID=
|
|
||||||
# AUTHENTIK_CLIENT_SECRET=
|
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
|
||||||
|
|||||||
@@ -1,80 +1,57 @@
|
|||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'node:22-alpine'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &install_deps |
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
- event: [push, pull_request, manual]
|
||||||
|
|
||||||
# Turbo remote cache is at turbo.mosaicstack.dev (ducktors/turborepo-remote-cache).
|
|
||||||
# TURBO_TOKEN is a Woodpecker secret injected via from_secret into the environment.
|
|
||||||
# Turbo picks up TURBO_API, TURBO_TOKEN, and TURBO_TEAM automatically.
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
install:
|
install:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- *install_deps
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm lint
|
- pnpm lint
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- install
|
||||||
|
|
||||||
format:
|
format:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm format:check
|
- pnpm format:check
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- install
|
||||||
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm test
|
- pnpm test
|
||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- install
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *install_deps
|
||||||
- pnpm build
|
- pnpm build
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- typecheck
|
||||||
- lint
|
- lint
|
||||||
- format
|
- format
|
||||||
- test
|
- test
|
||||||
|
|||||||
@@ -15,16 +15,10 @@
|
|||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@mariozechner/pi-ai": "~0.57.1",
|
"@mariozechner/pi-ai": "~0.57.1",
|
||||||
"@mariozechner/pi-coding-agent": "~0.57.1",
|
"@mariozechner/pi-coding-agent": "~0.57.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
||||||
"@mosaic/auth": "workspace:^",
|
"@mosaic/auth": "workspace:^",
|
||||||
"@mosaic/queue": "workspace:^",
|
|
||||||
"@mosaic/brain": "workspace:^",
|
"@mosaic/brain": "workspace:^",
|
||||||
"@mosaic/coord": "workspace:^",
|
"@mosaic/coord": "workspace:^",
|
||||||
"@mosaic/db": "workspace:^",
|
"@mosaic/db": "workspace:^",
|
||||||
"@mosaic/discord-plugin": "workspace:^",
|
|
||||||
"@mosaic/log": "workspace:^",
|
|
||||||
"@mosaic/memory": "workspace:^",
|
|
||||||
"@mosaic/telegram-plugin": "workspace:^",
|
|
||||||
"@mosaic/types": "workspace:^",
|
"@mosaic/types": "workspace:^",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
@@ -43,18 +37,14 @@
|
|||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0",
|
"uuid": "^11.0.0"
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -86,52 +86,4 @@ describe('Resource ownership checks', () => {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forbids creating a task with an unowned project', async () => {
|
|
||||||
const brain = createBrain();
|
|
||||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
|
||||||
const controller = new TasksController(brain as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.create(
|
|
||||||
{
|
|
||||||
title: 'Task',
|
|
||||||
projectId: 'project-1',
|
|
||||||
},
|
|
||||||
{ id: 'user-1' },
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids listing tasks for an unowned project', async () => {
|
|
||||||
const brain = createBrain();
|
|
||||||
brain.projects.findById.mockResolvedValue({ id: 'project-1', ownerId: 'user-2' });
|
|
||||||
const controller = new TasksController(brain as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.list({ id: 'user-1' }, 'project-1', undefined, undefined),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists only tasks for the current user owned projects when no filter is provided', async () => {
|
|
||||||
const brain = createBrain();
|
|
||||||
brain.projects.findAll.mockResolvedValue([
|
|
||||||
{ id: 'project-1', ownerId: 'user-1' },
|
|
||||||
{ id: 'project-2', ownerId: 'user-2' },
|
|
||||||
]);
|
|
||||||
brain.missions.findAll.mockResolvedValue([{ id: 'mission-1', projectId: 'project-1' }]);
|
|
||||||
brain.tasks.findAll.mockResolvedValue([
|
|
||||||
{ id: 'task-1', projectId: 'project-1' },
|
|
||||||
{ id: 'task-2', missionId: 'mission-1' },
|
|
||||||
{ id: 'task-3', projectId: 'project-2' },
|
|
||||||
]);
|
|
||||||
const controller = new TasksController(brain as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.list({ id: 'user-1' }, undefined, undefined, undefined),
|
|
||||||
).resolves.toEqual([
|
|
||||||
{ id: 'task-1', projectId: 'project-1' },
|
|
||||||
{ id: 'task-2', missionId: 'mission-1' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
|
||||||
import { sql, type Db } from '@mosaic/db';
|
|
||||||
import { createQueue } from '@mosaic/queue';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
|
||||||
import { ProviderService } from '../agent/provider.service.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
|
||||||
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/admin/health')
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
export class AdminHealthController {
|
|
||||||
constructor(
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async check(): Promise<HealthStatusDto> {
|
|
||||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
|
||||||
|
|
||||||
const sessions = this.agentService.listSessions();
|
|
||||||
const providers = this.providerService.listProviders();
|
|
||||||
|
|
||||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: allOk ? 'ok' : 'degraded',
|
|
||||||
database,
|
|
||||||
cache,
|
|
||||||
agentPool: { activeSessions: sessions.length },
|
|
||||||
providers: providers.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
available: p.available,
|
|
||||||
modelCount: p.models.length,
|
|
||||||
})),
|
|
||||||
checkedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkDatabase(): Promise<ServiceStatusDto> {
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
await this.db.execute(sql`SELECT 1`);
|
|
||||||
return { status: 'ok', latencyMs: Date.now() - start };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
latencyMs: Date.now() - start,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkCache(): Promise<ServiceStatusDto> {
|
|
||||||
const start = Date.now();
|
|
||||||
const handle = createQueue();
|
|
||||||
try {
|
|
||||||
await handle.redis.ping();
|
|
||||||
return { status: 'ok', latencyMs: Date.now() - start };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
latencyMs: Date.now() - start,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
await handle.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
InternalServerErrorException,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { eq, type Db, users as usersTable } from '@mosaic/db';
|
|
||||||
import type { Auth } from '@mosaic/auth';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
|
||||||
import type {
|
|
||||||
BanUserDto,
|
|
||||||
CreateUserDto,
|
|
||||||
UpdateUserRoleDto,
|
|
||||||
UserDto,
|
|
||||||
UserListDto,
|
|
||||||
} from './admin.dto.js';
|
|
||||||
|
|
||||||
type UserRow = typeof usersTable.$inferSelect;
|
|
||||||
|
|
||||||
function toUserDto(u: UserRow): UserDto {
|
|
||||||
return {
|
|
||||||
id: u.id,
|
|
||||||
name: u.name,
|
|
||||||
email: u.email,
|
|
||||||
role: u.role,
|
|
||||||
banned: u.banned ?? false,
|
|
||||||
banReason: u.banReason ?? null,
|
|
||||||
createdAt: u.createdAt.toISOString(),
|
|
||||||
updatedAt: u.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requireUpdated(
|
|
||||||
db: Db,
|
|
||||||
id: string,
|
|
||||||
update: Partial<Omit<UserRow, 'id' | 'createdAt'>>,
|
|
||||||
): Promise<UserDto> {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(usersTable)
|
|
||||||
.set({ ...update, updatedAt: new Date() })
|
|
||||||
.where(eq(usersTable.id, id))
|
|
||||||
.returning();
|
|
||||||
if (!updated) throw new InternalServerErrorException('Update returned no rows');
|
|
||||||
return toUserDto(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/admin/users')
|
|
||||||
@UseGuards(AdminGuard)
|
|
||||||
export class AdminController {
|
|
||||||
constructor(
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
@Inject(AUTH) private readonly auth: Auth,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async listUsers(): Promise<UserListDto> {
|
|
||||||
const rows = await this.db.select().from(usersTable).orderBy(usersTable.createdAt);
|
|
||||||
const userList: UserDto[] = rows.map(toUserDto);
|
|
||||||
return { users: userList, total: userList.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
async getUser(@Param('id') id: string): Promise<UserDto> {
|
|
||||||
const [user] = await this.db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
|
||||||
if (!user) throw new NotFoundException('User not found');
|
|
||||||
return toUserDto(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async createUser(@Body() body: CreateUserDto): Promise<UserDto> {
|
|
||||||
// Use auth API to create user so password is properly hashed
|
|
||||||
const authApi = this.auth.api as unknown as {
|
|
||||||
createUser: (opts: {
|
|
||||||
body: { name: string; email: string; password: string; role?: string };
|
|
||||||
}) => Promise<{
|
|
||||||
user: { id: string; name: string; email: string; createdAt: unknown; updatedAt: unknown };
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await authApi.createUser({
|
|
||||||
body: {
|
|
||||||
name: body.name,
|
|
||||||
email: body.email,
|
|
||||||
password: body.password,
|
|
||||||
role: body.role ?? 'member',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-fetch from DB to get full row with our schema
|
|
||||||
const [user] = await this.db
|
|
||||||
.select()
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, result.user.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) throw new InternalServerErrorException('User created but not found in DB');
|
|
||||||
return toUserDto(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id/role')
|
|
||||||
async setRole(@Param('id') id: string, @Body() body: UpdateUserRoleDto): Promise<UserDto> {
|
|
||||||
await this.ensureExists(id);
|
|
||||||
return requireUpdated(this.db, id, { role: body.role });
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/ban')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async banUser(@Param('id') id: string, @Body() body: BanUserDto): Promise<UserDto> {
|
|
||||||
await this.ensureExists(id);
|
|
||||||
return requireUpdated(this.db, id, { banned: true, banReason: body.reason ?? null });
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/unban')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
async unbanUser(@Param('id') id: string): Promise<UserDto> {
|
|
||||||
await this.ensureExists(id);
|
|
||||||
return requireUpdated(this.db, id, { banned: false, banReason: null, banExpires: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async deleteUser(@Param('id') id: string): Promise<void> {
|
|
||||||
await this.ensureExists(id);
|
|
||||||
await this.db.delete(usersTable).where(eq(usersTable.id, id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureExists(id: string): Promise<void> {
|
|
||||||
const [existing] = await this.db
|
|
||||||
.select({ id: usersTable.id })
|
|
||||||
.from(usersTable)
|
|
||||||
.where(eq(usersTable.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) throw new NotFoundException('User not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
export interface UserDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
banned: boolean;
|
|
||||||
banReason: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserListDto {
|
|
||||||
users: UserDto[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateUserDto {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserRoleDto {
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BanUserDto {
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthStatusDto {
|
|
||||||
status: 'ok' | 'degraded' | 'error';
|
|
||||||
database: ServiceStatusDto;
|
|
||||||
cache: ServiceStatusDto;
|
|
||||||
agentPool: AgentPoolStatusDto;
|
|
||||||
providers: ProviderStatusDto[];
|
|
||||||
checkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceStatusDto {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentPoolStatusDto {
|
|
||||||
activeSessions: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderStatusDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
|
||||||
import type { Auth } from '@mosaic/auth';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
|
|
||||||
interface UserWithRole {
|
|
||||||
id: string;
|
|
||||||
role?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AdminGuard implements CanActivate {
|
|
||||||
constructor(@Inject(AUTH) private readonly auth: Auth) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
|
||||||
const headers = fromNodeHeaders(request.raw.headers);
|
|
||||||
|
|
||||||
const result = await this.auth.api.getSession({ headers });
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new UnauthorizedException('Invalid or expired session');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = result.user as UserWithRole;
|
|
||||||
|
|
||||||
if (user.role !== 'admin') {
|
|
||||||
throw new ForbiddenException('Admin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
(request as FastifyRequest & { user: unknown; session: unknown }).user = result.user;
|
|
||||||
(request as FastifyRequest & { user: unknown; session: unknown }).session = result.session;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AdminController } from './admin.controller.js';
|
|
||||||
import { AdminHealthController } from './admin-health.controller.js';
|
|
||||||
import { AdminGuard } from './admin.guard.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [AdminController, AdminHealthController],
|
|
||||||
providers: [AdminGuard],
|
|
||||||
})
|
|
||||||
export class AdminModule {}
|
|
||||||
@@ -2,18 +2,15 @@ import { Global, Module } from '@nestjs/common';
|
|||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { RoutingService } from './routing.service.js';
|
import { RoutingService } from './routing.service.js';
|
||||||
import { SkillLoaderService } from './skill-loader.service.js';
|
|
||||||
import { ProvidersController } from './providers.controller.js';
|
import { ProvidersController } from './providers.controller.js';
|
||||||
import { SessionsController } from './sessions.controller.js';
|
import { SessionsController } from './sessions.controller.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule],
|
imports: [CoordModule],
|
||||||
providers: [ProviderService, RoutingService, SkillLoaderService, AgentService],
|
providers: [ProviderService, RoutingService, AgentService],
|
||||||
controllers: [ProvidersController, SessionsController],
|
controllers: [ProvidersController, SessionsController],
|
||||||
exports: [AgentService, ProviderService, RoutingService, SkillLoaderService],
|
exports: [AgentService, ProviderService, RoutingService],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -1,54 +1,22 @@
|
|||||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
|
||||||
SessionManager,
|
SessionManager,
|
||||||
type AgentSession as PiAgentSession,
|
type AgentSession as PiAgentSession,
|
||||||
type AgentSessionEvent,
|
type AgentSessionEvent,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from '@mariozechner/pi-coding-agent';
|
} from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaic/brain';
|
import type { Brain } from '@mosaic/brain';
|
||||||
import type { Memory } from '@mosaic/memory';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
|
||||||
import { SkillLoaderService } from './skill-loader.service.js';
|
|
||||||
import { createBrainTools } from './tools/brain-tools.js';
|
import { createBrainTools } from './tools/brain-tools.js';
|
||||||
import { createCoordTools } from './tools/coord-tools.js';
|
import { createCoordTools } from './tools/coord-tools.js';
|
||||||
import { createMemoryTools } from './tools/memory-tools.js';
|
|
||||||
import { createFileTools } from './tools/file-tools.js';
|
|
||||||
import { createGitTools } from './tools/git-tools.js';
|
|
||||||
import { createShellTools } from './tools/shell-tools.js';
|
|
||||||
import { createWebTools } from './tools/web-tools.js';
|
|
||||||
import type { SessionInfoDto } from './session.dto.js';
|
import type { SessionInfoDto } from './session.dto.js';
|
||||||
|
|
||||||
export interface AgentSessionOptions {
|
export interface AgentSessionOptions {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
/**
|
|
||||||
* Sandbox working directory for the session.
|
|
||||||
* File, git, and shell tools will be restricted to this directory.
|
|
||||||
* Falls back to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
|
|
||||||
*/
|
|
||||||
sandboxDir?: string;
|
|
||||||
/**
|
|
||||||
* Platform-level system prompt for this session.
|
|
||||||
* Merged with skill prompt additions (platform prompt first, then skills).
|
|
||||||
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
|
|
||||||
*/
|
|
||||||
systemPrompt?: string;
|
|
||||||
/**
|
|
||||||
* Explicit allowlist of tool names available in this session.
|
|
||||||
* When set, only listed tools are registered with the agent.
|
|
||||||
* When omitted for non-admin users, falls back to AGENT_USER_TOOLS env var.
|
|
||||||
* Admins (isAdmin=true) always receive the full tool set unless explicitly restricted.
|
|
||||||
*/
|
|
||||||
allowedTools?: string[];
|
|
||||||
/** Whether the requesting user has admin privileges. Controls default tool access. */
|
|
||||||
isAdmin?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSession {
|
export interface AgentSession {
|
||||||
@@ -61,12 +29,6 @@ export interface AgentSession {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
promptCount: number;
|
promptCount: number;
|
||||||
channels: Set<string>;
|
channels: Set<string>;
|
||||||
/** System prompt additions injected from enabled prompt-type skills. */
|
|
||||||
skillPromptAdditions: string[];
|
|
||||||
/** Resolved sandbox directory for this session. */
|
|
||||||
sandboxDir: string;
|
|
||||||
/** Tool names available in this session, or null when all tools are available. */
|
|
||||||
allowedTools: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -75,57 +37,15 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private readonly sessions = new Map<string, AgentSession>();
|
private readonly sessions = new Map<string, AgentSession>();
|
||||||
private readonly creating = new Map<string, Promise<AgentSession>>();
|
private readonly creating = new Map<string, Promise<AgentSession>>();
|
||||||
|
|
||||||
|
private readonly customTools: ToolDefinition[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
@Inject(BRAIN) private readonly brain: Brain,
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
|
||||||
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
|
||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
) {
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
this.customTools = [...createBrainTools(brain), ...createCoordTools(coordService)];
|
||||||
) {}
|
this.logger.log(`Registered ${this.customTools.length} custom tools`);
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the full set of custom tools scoped to the given sandbox directory.
|
|
||||||
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
|
|
||||||
* tools receive the resolved sandboxDir so they operate within the sandbox.
|
|
||||||
*/
|
|
||||||
private buildToolsForSandbox(sandboxDir: string): ToolDefinition[] {
|
|
||||||
return [
|
|
||||||
...createBrainTools(this.brain),
|
|
||||||
...createCoordTools(this.coordService),
|
|
||||||
...createMemoryTools(
|
|
||||||
this.memory,
|
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
|
||||||
),
|
|
||||||
...createFileTools(sandboxDir),
|
|
||||||
...createGitTools(sandboxDir),
|
|
||||||
...createShellTools(sandboxDir),
|
|
||||||
...createWebTools(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the tool allowlist for a session.
|
|
||||||
* - Admin users: all tools unless an explicit allowedTools list is passed.
|
|
||||||
* - Regular users: use allowedTools if provided, otherwise parse AGENT_USER_TOOLS env var.
|
|
||||||
* Returns null when all tools should be available.
|
|
||||||
*/
|
|
||||||
private resolveAllowedTools(isAdmin: boolean, allowedTools?: string[]): string[] | null {
|
|
||||||
if (allowedTools !== undefined) {
|
|
||||||
return allowedTools.length === 0 ? [] : allowedTools;
|
|
||||||
}
|
|
||||||
if (isAdmin) {
|
|
||||||
return null; // admins get everything
|
|
||||||
}
|
|
||||||
const envTools = process.env['AGENT_USER_TOOLS'];
|
|
||||||
if (!envTools) {
|
|
||||||
return null; // no restriction configured
|
|
||||||
}
|
|
||||||
return envTools
|
|
||||||
.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter((t) => t.length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
||||||
@@ -150,76 +70,18 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
const providerName = model?.provider ?? 'default';
|
const providerName = model?.provider ?? 'default';
|
||||||
const modelId = model?.id ?? 'default';
|
const modelId = model?.id ?? 'default';
|
||||||
|
|
||||||
// Resolve sandbox directory: option > env var > process.cwd()
|
|
||||||
const sandboxDir =
|
|
||||||
options?.sandboxDir ?? process.env['AGENT_FILE_SANDBOX_DIR'] ?? process.cwd();
|
|
||||||
|
|
||||||
// Resolve allowed tool set
|
|
||||||
const allowedTools = this.resolveAllowedTools(options?.isAdmin ?? false, options?.allowedTools);
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId}, sandbox=${sandboxDir}, tools=${allowedTools === null ? 'all' : allowedTools.join(',') || 'none'})`,
|
`Creating agent session: ${sessionId} (provider=${providerName}, model=${modelId})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load skill tools from the catalog
|
|
||||||
const { metaTools: skillMetaTools, promptAdditions } =
|
|
||||||
await this.skillLoaderService.loadForSession();
|
|
||||||
if (skillMetaTools.length > 0) {
|
|
||||||
this.logger.log(`Attaching ${skillMetaTools.length} skill tool(s) to session ${sessionId}`);
|
|
||||||
}
|
|
||||||
if (promptAdditions.length > 0) {
|
|
||||||
this.logger.log(
|
|
||||||
`Injecting ${promptAdditions.length} skill prompt addition(s) into session ${sessionId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory
|
|
||||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir);
|
|
||||||
|
|
||||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
|
||||||
let allCustomTools = [...sandboxTools, ...skillMetaTools, ...mcpTools];
|
|
||||||
if (mcpTools.length > 0) {
|
|
||||||
this.logger.log(`Attaching ${mcpTools.length} MCP client tool(s) to session ${sessionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter tools by allowlist when a restriction is in effect
|
|
||||||
if (allowedTools !== null) {
|
|
||||||
const allowedSet = new Set(allowedTools);
|
|
||||||
const before = allCustomTools.length;
|
|
||||||
allCustomTools = allCustomTools.filter((t) => allowedSet.has(t.name));
|
|
||||||
this.logger.log(
|
|
||||||
`Tool restriction applied: ${allCustomTools.length}/${before} tools allowed for session ${sessionId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build system prompt: platform prompt + skill additions appended
|
|
||||||
const platformPrompt = options?.systemPrompt ?? process.env['AGENT_SYSTEM_PROMPT'] ?? undefined;
|
|
||||||
const appendSystemPrompt =
|
|
||||||
promptAdditions.length > 0 ? promptAdditions.join('\n\n') : undefined;
|
|
||||||
|
|
||||||
// Construct a resource loader that injects the configured system prompt
|
|
||||||
const resourceLoader = new DefaultResourceLoader({
|
|
||||||
cwd: sandboxDir,
|
|
||||||
noExtensions: true,
|
|
||||||
noSkills: true,
|
|
||||||
noPromptTemplates: true,
|
|
||||||
noThemes: true,
|
|
||||||
systemPrompt: platformPrompt,
|
|
||||||
appendSystemPrompt: appendSystemPrompt,
|
|
||||||
});
|
|
||||||
await resourceLoader.reload();
|
|
||||||
|
|
||||||
let piSession: PiAgentSession;
|
let piSession: PiAgentSession;
|
||||||
try {
|
try {
|
||||||
const result = await createAgentSession({
|
const result = await createAgentSession({
|
||||||
sessionManager: SessionManager.inMemory(),
|
sessionManager: SessionManager.inMemory(),
|
||||||
modelRegistry: this.providerService.getRegistry(),
|
modelRegistry: this.providerService.getRegistry(),
|
||||||
model: model ?? undefined,
|
model: model ?? undefined,
|
||||||
cwd: sandboxDir,
|
|
||||||
tools: [],
|
tools: [],
|
||||||
customTools: allCustomTools,
|
customTools: this.customTools,
|
||||||
resourceLoader,
|
|
||||||
});
|
});
|
||||||
piSession = result.session;
|
piSession = result.session;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -252,9 +114,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
promptCount: 0,
|
promptCount: 0,
|
||||||
channels: new Set(),
|
channels: new Set(),
|
||||||
skillPromptAdditions: promptAdditions,
|
|
||||||
sandboxDir,
|
|
||||||
allowedTools,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.sessions.set(sessionId, session);
|
this.sessions.set(sessionId, session);
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
export interface TestConnectionDto {
|
|
||||||
/** Provider identifier to test (e.g. 'ollama', custom provider id) */
|
|
||||||
providerId: string;
|
|
||||||
/** Optional base URL override for ad-hoc testing */
|
|
||||||
baseUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestConnectionResultDto {
|
|
||||||
providerId: string;
|
|
||||||
reachable: boolean;
|
|
||||||
/** Round-trip latency in milliseconds (present when reachable) */
|
|
||||||
latencyMs?: number;
|
|
||||||
/** Human-readable error when unreachable */
|
|
||||||
error?: string;
|
|
||||||
/** Model ids discovered at the remote endpoint (present when reachable) */
|
|
||||||
discoveredModels?: string[];
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,14 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
|||||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||||
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderService implements OnModuleInit {
|
export class ProviderService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ProviderService.name);
|
private readonly logger = new Logger(ProviderService.name);
|
||||||
private registry!: ModelRegistry;
|
private registry!: ModelRegistry;
|
||||||
|
|
||||||
onModuleInit(): void {
|
async onModuleInit(): Promise<void> {
|
||||||
const authStorage = AuthStorage.inMemory();
|
const authStorage = AuthStorage.create();
|
||||||
this.registry = new ModelRegistry(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
this.registerOllamaProvider();
|
this.registerOllamaProvider();
|
||||||
@@ -65,63 +64,6 @@ export class ProviderService implements OnModuleInit {
|
|||||||
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
async testConnection(providerId: string, baseUrl?: string): Promise<TestConnectionResultDto> {
|
|
||||||
// Resolve baseUrl: explicit override > registered provider > ollama env
|
|
||||||
let resolvedUrl = baseUrl;
|
|
||||||
|
|
||||||
if (!resolvedUrl) {
|
|
||||||
const allModels = this.registry.getAll();
|
|
||||||
const providerModels = allModels.filter((m) => m.provider === providerId);
|
|
||||||
if (providerModels.length === 0) {
|
|
||||||
return { providerId, reachable: false, error: `Provider '${providerId}' not found` };
|
|
||||||
}
|
|
||||||
// For Ollama, derive the base URL from environment
|
|
||||||
if (providerId === 'ollama') {
|
|
||||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
|
||||||
if (!ollamaUrl) {
|
|
||||||
return { providerId, reachable: false, error: 'OLLAMA_BASE_URL not configured' };
|
|
||||||
}
|
|
||||||
resolvedUrl = `${ollamaUrl}/v1/models`;
|
|
||||||
} else {
|
|
||||||
// For other providers, we can only do a basic check
|
|
||||||
return { providerId, reachable: true, discoveredModels: providerModels.map((m) => m.id) };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resolvedUrl = resolvedUrl.replace(/\/?$/, '') + '/models';
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
try {
|
|
||||||
const res = await fetch(resolvedUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
|
|
||||||
const latencyMs = Date.now() - start;
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return { providerId, reachable: false, latencyMs, error: `HTTP ${res.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
let discoveredModels: string[] | undefined;
|
|
||||||
try {
|
|
||||||
const json = (await res.json()) as { models?: Array<{ id?: string; name?: string }> };
|
|
||||||
if (Array.isArray(json.models)) {
|
|
||||||
discoveredModels = json.models.map((m) => m.id ?? m.name ?? '').filter(Boolean);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors — endpoint was reachable
|
|
||||||
}
|
|
||||||
|
|
||||||
return { providerId, reachable: true, latencyMs, discoveredModels };
|
|
||||||
} catch (err) {
|
|
||||||
const latencyMs = Date.now() - start;
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
return { providerId, reachable: false, latencyMs, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerCustomProvider(config: CustomProviderConfig): void {
|
registerCustomProvider(config: CustomProviderConfig): void {
|
||||||
this.registry.registerProvider(config.id, {
|
this.registry.registerProvider(config.id, {
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
@@ -150,16 +92,14 @@ export class ProviderService implements OnModuleInit {
|
|||||||
.map((modelId: string) => modelId.trim())
|
.map((modelId: string) => modelId.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
this.registry.registerProvider('ollama', {
|
this.registerCustomProvider({
|
||||||
|
id: 'ollama',
|
||||||
|
name: 'Ollama',
|
||||||
baseUrl: `${ollamaUrl}/v1`,
|
baseUrl: `${ollamaUrl}/v1`,
|
||||||
apiKey: 'ollama',
|
|
||||||
api: 'openai-completions' as never,
|
|
||||||
models: modelIds.map((id) => ({
|
models: modelIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
name: id,
|
name: id,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
input: ['text'] as ('text' | 'image')[],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 8192,
|
contextWindow: 8192,
|
||||||
maxTokens: 4096,
|
maxTokens: 4096,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { RoutingCriteria } from '@mosaic/types';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { RoutingService } from './routing.service.js';
|
import { RoutingService } from './routing.service.js';
|
||||||
import type { TestConnectionDto, TestConnectionResultDto } from './provider.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/providers')
|
@Controller('api/providers')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -23,11 +22,6 @@ export class ProvidersController {
|
|||||||
return this.providerService.listAvailableModels();
|
return this.providerService.listAvailableModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('test')
|
|
||||||
testConnection(@Body() body: TestConnectionDto): Promise<TestConnectionResultDto> {
|
|
||||||
return this.providerService.testConnection(body.providerId, body.baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('route')
|
@Post('route')
|
||||||
route(@Body() criteria: RoutingCriteria) {
|
route(@Body() criteria: RoutingCriteria) {
|
||||||
return this.routingService.route(criteria);
|
return this.routingService.route(criteria);
|
||||||
|
|||||||
@@ -12,33 +12,3 @@ export interface SessionListDto {
|
|||||||
sessions: SessionInfoDto[];
|
sessions: SessionInfoDto[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Options accepted when creating an agent session.
|
|
||||||
* All fields are optional; omitting them falls back to env-var or process defaults.
|
|
||||||
*/
|
|
||||||
export interface CreateSessionOptionsDto {
|
|
||||||
/** Provider name (e.g. "anthropic", "openai"). */
|
|
||||||
provider?: string;
|
|
||||||
/** Model ID to use for this session. */
|
|
||||||
modelId?: string;
|
|
||||||
/**
|
|
||||||
* Sandbox working directory for the session.
|
|
||||||
* File, git, and shell tools will be restricted to this directory.
|
|
||||||
* Defaults to AGENT_FILE_SANDBOX_DIR env var or process.cwd().
|
|
||||||
*/
|
|
||||||
sandboxDir?: string;
|
|
||||||
/**
|
|
||||||
* Platform-level system prompt for this session.
|
|
||||||
* Merged with skill prompt additions (platform prompt first, then skills).
|
|
||||||
* Falls back to AGENT_SYSTEM_PROMPT env var when omitted.
|
|
||||||
*/
|
|
||||||
systemPrompt?: string;
|
|
||||||
/**
|
|
||||||
* Explicit allowlist of tool names available in this session.
|
|
||||||
* When provided, only listed tools are registered with the agent.
|
|
||||||
* Admins receive all tools; regular users fall back to AGENT_USER_TOOLS
|
|
||||||
* env var (comma-separated) when this field is not supplied.
|
|
||||||
*/
|
|
||||||
allowedTools?: string[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { SkillsService } from '../skills/skills.service.js';
|
|
||||||
import { createSkillTools } from './tools/skill-tools.js';
|
|
||||||
|
|
||||||
export interface LoadedSkills {
|
|
||||||
/** Meta-tools: skill_list + skill_invoke */
|
|
||||||
metaTools: ToolDefinition[];
|
|
||||||
/**
|
|
||||||
* System prompt additions from enabled prompt-type skills.
|
|
||||||
* Callers may prepend these to the session system prompt.
|
|
||||||
*/
|
|
||||||
promptAdditions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SkillLoaderService is responsible for:
|
|
||||||
* 1. Providing the skill meta-tools (skill_list, skill_invoke) to agent sessions.
|
|
||||||
* 2. Collecting system-prompt additions from enabled prompt-type skills.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SkillLoaderService {
|
|
||||||
private readonly logger = new Logger(SkillLoaderService.name);
|
|
||||||
|
|
||||||
constructor(@Inject(SkillsService) private readonly skillsService: SkillsService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load enabled skills and return tools + prompt additions for a new session.
|
|
||||||
*/
|
|
||||||
async loadForSession(): Promise<LoadedSkills> {
|
|
||||||
const metaTools = createSkillTools(this.skillsService);
|
|
||||||
|
|
||||||
let promptAdditions: string[] = [];
|
|
||||||
try {
|
|
||||||
const enabledSkills = await this.skillsService.findEnabled();
|
|
||||||
promptAdditions = enabledSkills.flatMap((skill) => {
|
|
||||||
const config = (skill.config ?? {}) as Record<string, unknown>;
|
|
||||||
const skillType = (config['type'] as string | undefined) ?? 'prompt';
|
|
||||||
if (skillType === 'prompt') {
|
|
||||||
const addition = (config['prompt'] as string | undefined) ?? skill.description;
|
|
||||||
return addition ? [addition] : [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Loaded ${enabledSkills.length} enabled skill(s), ` +
|
|
||||||
`${promptAdditions.length} prompt addition(s)`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// Non-fatal: log and continue without prompt additions
|
|
||||||
this.logger.warn(
|
|
||||||
`Failed to load skill prompt additions: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { metaTools, promptAdditions };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
||||||
import { resolve, relative, join } from 'node:path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safety constraint: all file operations are restricted to a base directory.
|
|
||||||
* Paths that escape the sandbox via ../ traversal are rejected.
|
|
||||||
*/
|
|
||||||
function resolveSafe(baseDir: string, inputPath: string): string {
|
|
||||||
const resolved = resolve(baseDir, inputPath);
|
|
||||||
const rel = relative(baseDir, resolved);
|
|
||||||
if (rel.startsWith('..') || resolve(resolved) !== resolve(join(baseDir, rel))) {
|
|
||||||
throw new Error(`Path escape detected: "${inputPath}" resolves outside base directory`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_READ_BYTES = 512 * 1024; // 512 KB read limit
|
|
||||||
const MAX_WRITE_BYTES = 1024 * 1024; // 1 MB write limit
|
|
||||||
|
|
||||||
export function createFileTools(baseDir: string): ToolDefinition[] {
|
|
||||||
const readFileTool: ToolDefinition = {
|
|
||||||
name: 'fs_read_file',
|
|
||||||
label: 'Read File',
|
|
||||||
description:
|
|
||||||
'Read the contents of a file. Path is resolved relative to the sandbox base directory.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
path: Type.String({
|
|
||||||
description: 'File path (relative to sandbox base or absolute within it)',
|
|
||||||
}),
|
|
||||||
encoding: Type.Optional(
|
|
||||||
Type.String({ description: 'Encoding: utf8 (default), base64, hex' }),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { path, encoding } = params as { path: string; encoding?: string };
|
|
||||||
let safePath: string;
|
|
||||||
try {
|
|
||||||
safePath = resolveSafe(baseDir, path);
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const info = await stat(safePath);
|
|
||||||
if (!info.isFile()) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (info.size > MAX_READ_BYTES) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `Error: file too large (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
|
||||||
const content = await readFile(safePath, { encoding: enc });
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: String(content) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeFileTool: ToolDefinition = {
|
|
||||||
name: 'fs_write_file',
|
|
||||||
label: 'Write File',
|
|
||||||
description:
|
|
||||||
'Write content to a file. Path is resolved relative to the sandbox base directory. Overwrites existing file.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
path: Type.String({
|
|
||||||
description: 'File path (relative to sandbox base or absolute within it)',
|
|
||||||
}),
|
|
||||||
content: Type.String({ description: 'Content to write' }),
|
|
||||||
encoding: Type.Optional(Type.String({ description: 'Encoding: utf8 (default), base64' })),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { path, content, encoding } = params as {
|
|
||||||
path: string;
|
|
||||||
content: string;
|
|
||||||
encoding?: string;
|
|
||||||
};
|
|
||||||
let safePath: string;
|
|
||||||
try {
|
|
||||||
safePath = resolveSafe(baseDir, path);
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `Error: content too large (limit ${MAX_WRITE_BYTES} bytes)`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const enc = (encoding ?? 'utf8') as BufferEncoding;
|
|
||||||
await writeFile(safePath, content, { encoding: enc });
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `File written successfully: ${path}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const listDirectoryTool: ToolDefinition = {
|
|
||||||
name: 'fs_list_directory',
|
|
||||||
label: 'List Directory',
|
|
||||||
description: 'List files and directories at a given path within the sandbox base directory.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
path: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Directory path (relative to sandbox base). Defaults to base directory.',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { path } = params as { path?: string };
|
|
||||||
const target = path ?? '.';
|
|
||||||
let safePath: string;
|
|
||||||
try {
|
|
||||||
safePath = resolveSafe(baseDir, target);
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const info = await stat(safePath);
|
|
||||||
if (!info.isDirectory()) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: path is not a directory: ${target}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const entries = await readdir(safePath, { withFileTypes: true });
|
|
||||||
const items = entries.map((e) => ({
|
|
||||||
name: e.name,
|
|
||||||
type: e.isDirectory() ? 'directory' : e.isSymbolicLink() ? 'symlink' : 'file',
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(items, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error listing directory: ${String(err)}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [readFileTool, writeFileTool, listDirectoryTool];
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { exec } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { resolve, relative } from 'node:path';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
const GIT_TIMEOUT_MS = 15_000;
|
|
||||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
|
||||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
|
||||||
* falls back to the sandbox directory itself.
|
|
||||||
*/
|
|
||||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
|
||||||
if (!requestedCwd) return sandboxDir;
|
|
||||||
const resolved = resolve(sandboxDir, requestedCwd);
|
|
||||||
const rel = relative(sandboxDir, resolved);
|
|
||||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
|
||||||
// Escape attempt — fall back to sandbox root
|
|
||||||
return sandboxDir;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runGit(
|
|
||||||
args: string[],
|
|
||||||
cwd?: string,
|
|
||||||
): Promise<{ stdout: string; stderr: string; error?: string }> {
|
|
||||||
// Only allow specific safe read-only git subcommands
|
|
||||||
const allowedSubcommands = ['status', 'log', 'diff', 'show', 'branch', 'tag', 'ls-files'];
|
|
||||||
const subcommand = args[0];
|
|
||||||
if (!subcommand || !allowedSubcommands.includes(subcommand)) {
|
|
||||||
return {
|
|
||||||
stdout: '',
|
|
||||||
stderr: '',
|
|
||||||
error: `Blocked: git subcommand "${subcommand}" is not allowed. Permitted: ${allowedSubcommands.join(', ')}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmd = `git ${args.map((a) => JSON.stringify(a)).join(' ')}`;
|
|
||||||
try {
|
|
||||||
const { stdout, stderr } = await execAsync(cmd, {
|
|
||||||
cwd,
|
|
||||||
timeout: GIT_TIMEOUT_MS,
|
|
||||||
maxBuffer: MAX_OUTPUT_BYTES,
|
|
||||||
});
|
|
||||||
return { stdout, stderr };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
||||||
return {
|
|
||||||
stdout: e.stdout ?? '',
|
|
||||||
stderr: e.stderr ?? '',
|
|
||||||
error: e.message ?? String(err),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGitTools(sandboxDir?: string): ToolDefinition[] {
|
|
||||||
const defaultCwd = sandboxDir ?? process.cwd();
|
|
||||||
|
|
||||||
const gitStatus: ToolDefinition = {
|
|
||||||
name: 'git_status',
|
|
||||||
label: 'Git Status',
|
|
||||||
description: 'Show the working tree status (staged, unstaged, untracked files).',
|
|
||||||
parameters: Type.Object({
|
|
||||||
cwd: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { cwd } = params as { cwd?: string };
|
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
|
||||||
const result = await runGit(['status', '--short', '--branch'], safeCwd);
|
|
||||||
const text = result.error
|
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
|
||||||
: result.stdout || '(no output)';
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: text }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const gitLog: ToolDefinition = {
|
|
||||||
name: 'git_log',
|
|
||||||
label: 'Git Log',
|
|
||||||
description: 'Show recent commit history.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
limit: Type.Optional(Type.Number({ description: 'Number of commits to show (default 20)' })),
|
|
||||||
oneline: Type.Optional(
|
|
||||||
Type.Boolean({ description: 'Compact one-line format (default true)' }),
|
|
||||||
),
|
|
||||||
cwd: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { limit, oneline, cwd } = params as {
|
|
||||||
limit?: number;
|
|
||||||
oneline?: boolean;
|
|
||||||
cwd?: string;
|
|
||||||
};
|
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
|
||||||
const args = ['log', `--max-count=${limit ?? 20}`];
|
|
||||||
if (oneline !== false) args.push('--oneline');
|
|
||||||
const result = await runGit(args, safeCwd);
|
|
||||||
const text = result.error
|
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
|
||||||
: result.stdout || '(no commits)';
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: text }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const gitDiff: ToolDefinition = {
|
|
||||||
name: 'git_diff',
|
|
||||||
label: 'Git Diff',
|
|
||||||
description: 'Show changes between commits, working tree, or staged changes.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
staged: Type.Optional(
|
|
||||||
Type.Boolean({ description: 'Show staged (cached) changes instead of unstaged' }),
|
|
||||||
),
|
|
||||||
ref: Type.Optional(
|
|
||||||
Type.String({ description: 'Compare against this ref (commit SHA, branch, or tag)' }),
|
|
||||||
),
|
|
||||||
path: Type.Optional(
|
|
||||||
Type.String({ description: 'Limit diff to a specific file or directory' }),
|
|
||||||
),
|
|
||||||
cwd: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Repository working directory (relative to sandbox or absolute within it).',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { staged, ref, path, cwd } = params as {
|
|
||||||
staged?: boolean;
|
|
||||||
ref?: string;
|
|
||||||
path?: string;
|
|
||||||
cwd?: string;
|
|
||||||
};
|
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
|
||||||
const args = ['diff'];
|
|
||||||
if (staged) args.push('--cached');
|
|
||||||
if (ref) args.push(ref);
|
|
||||||
args.push('--');
|
|
||||||
if (path) args.push(path);
|
|
||||||
const result = await runGit(args, safeCwd);
|
|
||||||
const text = result.error
|
|
||||||
? `Error: ${result.error}\n${result.stderr}`
|
|
||||||
: result.stdout || '(no diff)';
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: text }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [gitStatus, gitLog, gitDiff];
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,2 @@
|
|||||||
export { createBrainTools } from './brain-tools.js';
|
export { createBrainTools } from './brain-tools.js';
|
||||||
export { createCoordTools } from './coord-tools.js';
|
export { createCoordTools } from './coord-tools.js';
|
||||||
export { createFileTools } from './file-tools.js';
|
|
||||||
export { createGitTools } from './git-tools.js';
|
|
||||||
export { createShellTools } from './shell-tools.js';
|
|
||||||
export { createWebTools } from './web-tools.js';
|
|
||||||
export { createSkillTools } from './skill-tools.js';
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import type { Memory } from '@mosaic/memory';
|
|
||||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
|
||||||
|
|
||||||
export function createMemoryTools(
|
|
||||||
memory: Memory,
|
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
|
||||||
): ToolDefinition[] {
|
|
||||||
const searchMemory: ToolDefinition = {
|
|
||||||
name: 'memory_search',
|
|
||||||
label: 'Search Memory',
|
|
||||||
description:
|
|
||||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
userId: Type.String({ description: 'User ID to search memory for' }),
|
|
||||||
query: Type.String({ description: 'Natural language search query' }),
|
|
||||||
limit: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { userId, query, limit } = params as {
|
|
||||||
userId: string;
|
|
||||||
query: string;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!embeddingProvider) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: 'Semantic search unavailable — no embedding provider configured',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const embedding = await embeddingProvider.embed(query);
|
|
||||||
const results = await memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPreferences: ToolDefinition = {
|
|
||||||
name: 'memory_get_preferences',
|
|
||||||
label: 'Get User Preferences',
|
|
||||||
description: 'Retrieve stored preferences for a user.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
userId: Type.String({ description: 'User ID' }),
|
|
||||||
category: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Filter by category: communication, coding, workflow, appearance, general',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { userId, category } = params as { userId: string; category?: string };
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
|
||||||
const prefs = category
|
|
||||||
? await memory.preferences.findByUserAndCategory(userId, category as Cat)
|
|
||||||
: await memory.preferences.findByUser(userId);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const savePreference: ToolDefinition = {
|
|
||||||
name: 'memory_save_preference',
|
|
||||||
label: 'Save User Preference',
|
|
||||||
description:
|
|
||||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
|
||||||
parameters: Type.Object({
|
|
||||||
userId: Type.String({ description: 'User ID' }),
|
|
||||||
key: Type.String({ description: 'Preference key' }),
|
|
||||||
value: Type.String({ description: 'Preference value (JSON string)' }),
|
|
||||||
category: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Category: communication, coding, workflow, appearance, general',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { userId, key, value, category } = params as {
|
|
||||||
userId: string;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
category?: string;
|
|
||||||
};
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
|
||||||
let parsedValue: unknown;
|
|
||||||
try {
|
|
||||||
parsedValue = JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
parsedValue = value;
|
|
||||||
}
|
|
||||||
const pref = await memory.preferences.upsert({
|
|
||||||
userId,
|
|
||||||
key,
|
|
||||||
value: parsedValue,
|
|
||||||
category: (category as Cat) ?? 'general',
|
|
||||||
source: 'agent',
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(pref, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveInsight: ToolDefinition = {
|
|
||||||
name: 'memory_save_insight',
|
|
||||||
label: 'Save Insight',
|
|
||||||
description:
|
|
||||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
userId: Type.String({ description: 'User ID' }),
|
|
||||||
content: Type.String({ description: 'The insight or knowledge to store' }),
|
|
||||||
category: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: 'Category: decision, learning, preference, fact, pattern, general',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { userId, content, category } = params as {
|
|
||||||
userId: string;
|
|
||||||
content: string;
|
|
||||||
category?: string;
|
|
||||||
};
|
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
|
||||||
|
|
||||||
let embedding: number[] | null = null;
|
|
||||||
if (embeddingProvider) {
|
|
||||||
embedding = await embeddingProvider.embed(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
const insight = await memory.insights.create({
|
|
||||||
userId,
|
|
||||||
content,
|
|
||||||
embedding,
|
|
||||||
source: 'agent',
|
|
||||||
category: (category as Cat) ?? 'learning',
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [searchMemory, getPreferences, savePreference, saveInsight];
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { resolve, relative } from 'node:path';
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
||||||
const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Commands that are outright blocked for safety.
|
|
||||||
* This is a denylist; the agent should be instructed to use
|
|
||||||
* the least-privilege command necessary.
|
|
||||||
*/
|
|
||||||
const BLOCKED_COMMANDS = new Set([
|
|
||||||
'rm',
|
|
||||||
'rmdir',
|
|
||||||
'mkfs',
|
|
||||||
'dd',
|
|
||||||
'format',
|
|
||||||
'fdisk',
|
|
||||||
'parted',
|
|
||||||
'shred',
|
|
||||||
'wipefs',
|
|
||||||
'sudo',
|
|
||||||
'su',
|
|
||||||
'chown',
|
|
||||||
'chmod',
|
|
||||||
'passwd',
|
|
||||||
'useradd',
|
|
||||||
'userdel',
|
|
||||||
'groupadd',
|
|
||||||
'shutdown',
|
|
||||||
'reboot',
|
|
||||||
'halt',
|
|
||||||
'poweroff',
|
|
||||||
'kill',
|
|
||||||
'killall',
|
|
||||||
'pkill',
|
|
||||||
'curl',
|
|
||||||
'wget',
|
|
||||||
'nc',
|
|
||||||
'netcat',
|
|
||||||
'ncat',
|
|
||||||
'ssh',
|
|
||||||
'scp',
|
|
||||||
'sftp',
|
|
||||||
'rsync',
|
|
||||||
'iptables',
|
|
||||||
'ip6tables',
|
|
||||||
'nft',
|
|
||||||
'ufw',
|
|
||||||
'firewall-cmd',
|
|
||||||
'docker',
|
|
||||||
'podman',
|
|
||||||
'kubectl',
|
|
||||||
'helm',
|
|
||||||
'terraform',
|
|
||||||
'ansible',
|
|
||||||
'crontab',
|
|
||||||
'at',
|
|
||||||
'batch',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function extractBaseCommand(command: string): string {
|
|
||||||
// Extract the first word (the binary name), stripping path
|
|
||||||
const trimmed = command.trim();
|
|
||||||
const firstToken = trimmed.split(/\s+/)[0] ?? '';
|
|
||||||
return firstToken.split('/').pop() ?? firstToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clamp a user-supplied cwd to within the sandbox directory.
|
|
||||||
* If the resolved path escapes the sandbox (via ../ or absolute path outside),
|
|
||||||
* falls back to the sandbox directory itself.
|
|
||||||
*/
|
|
||||||
function clampCwd(sandboxDir: string, requestedCwd?: string): string {
|
|
||||||
if (!requestedCwd) return sandboxDir;
|
|
||||||
const resolved = resolve(sandboxDir, requestedCwd);
|
|
||||||
const rel = relative(sandboxDir, resolved);
|
|
||||||
if (rel.startsWith('..') || rel.startsWith('/')) {
|
|
||||||
// Escape attempt — fall back to sandbox root
|
|
||||||
return sandboxDir;
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommand(
|
|
||||||
command: string,
|
|
||||||
options: { timeoutMs: number; cwd?: string },
|
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn('sh', ['-c', command], {
|
|
||||||
cwd: options.cwd,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
detached: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
let timedOut = false;
|
|
||||||
let totalBytes = 0;
|
|
||||||
let truncated = false;
|
|
||||||
|
|
||||||
child.stdout?.on('data', (chunk: Buffer) => {
|
|
||||||
if (truncated) return;
|
|
||||||
totalBytes += chunk.length;
|
|
||||||
if (totalBytes > MAX_OUTPUT_BYTES) {
|
|
||||||
stdout += chunk.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - chunk.length)).toString();
|
|
||||||
stdout += '\n[output truncated at 100 KB limit]';
|
|
||||||
truncated = true;
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
} else {
|
|
||||||
stdout += chunk.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on('data', (chunk: Buffer) => {
|
|
||||||
if (stderr.length < MAX_OUTPUT_BYTES) {
|
|
||||||
stderr += chunk.toString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
child.kill('SIGTERM');
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
} catch {
|
|
||||||
// already exited
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}, options.timeoutMs);
|
|
||||||
|
|
||||||
child.on('close', (exitCode) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve({ stdout, stderr, exitCode, timedOut });
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve({ stdout, stderr: stderr + String(err), exitCode: null, timedOut: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createShellTools(sandboxDir?: string): ToolDefinition[] {
|
|
||||||
const defaultCwd = sandboxDir ?? process.cwd();
|
|
||||||
|
|
||||||
const shellExec: ToolDefinition = {
|
|
||||||
name: 'shell_exec',
|
|
||||||
label: 'Shell Execute',
|
|
||||||
description:
|
|
||||||
'Execute a shell command with timeout and output limits. Dangerous commands (rm, sudo, docker, etc.) are blocked. Working directory is restricted to the session sandbox.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
command: Type.String({ description: 'Shell command to execute' }),
|
|
||||||
cwd: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description:
|
|
||||||
'Working directory for the command (relative to sandbox or absolute within it).',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
timeout: Type.Optional(
|
|
||||||
Type.Number({ description: 'Timeout in milliseconds (default 30000, max 60000)' }),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { command, cwd, timeout } = params as {
|
|
||||||
command: string;
|
|
||||||
cwd?: string;
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const base = extractBaseCommand(command);
|
|
||||||
if (BLOCKED_COMMANDS.has(base)) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `Error: command "${base}" is blocked for safety reasons.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000);
|
|
||||||
const safeCwd = clampCwd(defaultCwd, cwd);
|
|
||||||
|
|
||||||
const result = await runCommand(command, {
|
|
||||||
timeoutMs,
|
|
||||||
cwd: safeCwd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.timedOut) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `Command timed out after ${timeoutMs}ms.\nPartial stdout:\n${result.stdout}\nPartial stderr:\n${result.stderr}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (result.stdout) parts.push(`stdout:\n${result.stdout}`);
|
|
||||||
if (result.stderr) parts.push(`stderr:\n${result.stderr}`);
|
|
||||||
parts.push(`exit code: ${result.exitCode ?? 'null'}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: parts.join('\n') }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [shellExec];
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import type { SkillsService } from '../../skills/skills.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates meta-tools that allow agents to list and invoke skills from the catalog.
|
|
||||||
*
|
|
||||||
* skill_list — list all enabled skills
|
|
||||||
* skill_invoke — execute a skill by name with parameters
|
|
||||||
*/
|
|
||||||
export function createSkillTools(skillsService: SkillsService): ToolDefinition[] {
|
|
||||||
const skillList: ToolDefinition = {
|
|
||||||
name: 'skill_list',
|
|
||||||
label: 'List Skills',
|
|
||||||
description:
|
|
||||||
'List all enabled skills available in the catalog. Returns name, description, type, and config for each skill.',
|
|
||||||
parameters: Type.Object({}),
|
|
||||||
async execute() {
|
|
||||||
const skills = await skillsService.findEnabled();
|
|
||||||
const summary = skills.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
description: s.description,
|
|
||||||
version: s.version,
|
|
||||||
source: s.source,
|
|
||||||
config: s.config,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text:
|
|
||||||
summary.length > 0
|
|
||||||
? JSON.stringify(summary, null, 2)
|
|
||||||
: 'No enabled skills found in catalog.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const skillInvoke: ToolDefinition = {
|
|
||||||
name: 'skill_invoke',
|
|
||||||
label: 'Invoke Skill',
|
|
||||||
description:
|
|
||||||
'Invoke a skill from the catalog by name. For prompt skills, returns the prompt addition. ' +
|
|
||||||
'For tool skills, executes the embedded logic. For workflow skills, returns the workflow steps.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
name: Type.String({ description: 'Skill name to invoke' }),
|
|
||||||
params: Type.Optional(
|
|
||||||
Type.Record(Type.String(), Type.Unknown(), {
|
|
||||||
description: 'Parameters to pass to the skill (if applicable)',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, rawParams) {
|
|
||||||
const { name, params } = rawParams as {
|
|
||||||
name: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const skill = await skillsService.findByName(name);
|
|
||||||
if (!skill) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Skill not found: ${name}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!skill.enabled) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Skill is disabled: ${name}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = (skill.config ?? {}) as Record<string, unknown>;
|
|
||||||
const skillType = (config['type'] as string | undefined) ?? 'prompt';
|
|
||||||
|
|
||||||
switch (skillType) {
|
|
||||||
case 'prompt': {
|
|
||||||
const promptAddition =
|
|
||||||
(config['prompt'] as string | undefined) ?? skill.description ?? '';
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: promptAddition
|
|
||||||
? `[Skill: ${name}] ${promptAddition}`
|
|
||||||
: `[Skill: ${name}] No prompt content defined.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool': {
|
|
||||||
const toolLogic = config['logic'] as string | undefined;
|
|
||||||
if (!toolLogic) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `[Skill: ${name}] Tool skill has no logic defined.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Inline tool skill execution: the logic field holds a JS expression or template
|
|
||||||
// For safety, treat it as a template that can reference params
|
|
||||||
const result = renderTemplate(toolLogic, { params: params ?? {}, skill });
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `[Skill: ${name}]\n${result}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'workflow': {
|
|
||||||
const steps = config['steps'] as unknown[] | undefined;
|
|
||||||
if (!steps || steps.length === 0) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `[Skill: ${name}] Workflow has no steps defined.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `[Skill: ${name}] Workflow steps:\n${JSON.stringify(steps, null, 2)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
// Unknown type — return full config so the agent can decide what to do
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `[Skill: ${name}] (type: ${skillType})\n${JSON.stringify(config, null, 2)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [skillList, skillInvoke];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal template renderer — replaces {{key}} with values from the context.
|
|
||||||
* Used for tool skill logic templates.
|
|
||||||
*/
|
|
||||||
function renderTemplate(template: string, context: Record<string, unknown>): string {
|
|
||||||
return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_match, path: string) => {
|
|
||||||
const parts = path.split('.');
|
|
||||||
let value: unknown = context;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (value != null && typeof value === 'object') {
|
|
||||||
value = (value as Record<string, unknown>)[part];
|
|
||||||
} else {
|
|
||||||
value = undefined;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value !== undefined && value !== null ? String(value) : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
||||||
const MAX_RESPONSE_BYTES = 512 * 1024; // 512 KB
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blocked URL patterns (private IP ranges, localhost, link-local).
|
|
||||||
*/
|
|
||||||
const BLOCKED_HOSTNAMES = [
|
|
||||||
/^localhost$/i,
|
|
||||||
/^127\./,
|
|
||||||
/^10\./,
|
|
||||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
||||||
/^192\.168\./,
|
|
||||||
/^::1$/,
|
|
||||||
/^fc[0-9a-f][0-9a-f]:/i,
|
|
||||||
/^fe80:/i,
|
|
||||||
/^0\.0\.0\.0$/,
|
|
||||||
/^169\.254\./,
|
|
||||||
];
|
|
||||||
|
|
||||||
function isBlockedUrl(urlString: string): string | null {
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(urlString);
|
|
||||||
} catch {
|
|
||||||
return `Invalid URL: ${urlString}`;
|
|
||||||
}
|
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
||||||
return `Unsupported protocol: ${parsed.protocol}. Only http and https are allowed.`;
|
|
||||||
}
|
|
||||||
const hostname = parsed.hostname;
|
|
||||||
for (const pattern of BLOCKED_HOSTNAMES) {
|
|
||||||
if (pattern.test(hostname)) {
|
|
||||||
return `Blocked: requests to "${hostname}" are not allowed (private/local addresses).`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithLimit(
|
|
||||||
url: string,
|
|
||||||
options: RequestInit,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<{ text: string; status: number; contentType: string }> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
||||||
const contentType = response.headers.get('content-type') ?? '';
|
|
||||||
|
|
||||||
// Stream response and enforce size limit
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) {
|
|
||||||
return { text: '', status: response.status, contentType };
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let totalBytes = 0;
|
|
||||||
let truncated = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
totalBytes += value.length;
|
|
||||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
|
||||||
const remaining = MAX_RESPONSE_BYTES - (totalBytes - value.length);
|
|
||||||
chunks.push(value.subarray(0, remaining));
|
|
||||||
truncated = true;
|
|
||||||
reader.cancel();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
chunks.push(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0));
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
combined.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = new TextDecoder().decode(combined);
|
|
||||||
if (truncated) {
|
|
||||||
text += '\n[response truncated at 512 KB limit]';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { text, status: response.status, contentType };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createWebTools(): ToolDefinition[] {
|
|
||||||
const webGet: ToolDefinition = {
|
|
||||||
name: 'web_get',
|
|
||||||
label: 'HTTP GET',
|
|
||||||
description:
|
|
||||||
'Perform an HTTP GET request and return the response body. Private/local addresses are blocked.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
url: Type.String({ description: 'URL to fetch (http/https only)' }),
|
|
||||||
headers: Type.Optional(
|
|
||||||
Type.Record(Type.String(), Type.String(), {
|
|
||||||
description: 'Optional request headers as key-value pairs',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
timeout: Type.Optional(
|
|
||||||
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { url, headers, timeout } = params as {
|
|
||||||
url: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocked = isBlockedUrl(url);
|
|
||||||
if (blocked) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await fetchWithLimit(
|
|
||||||
url,
|
|
||||||
{ method: 'GET', headers: headers ?? {} },
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error fetching URL: ${msg}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const webPost: ToolDefinition = {
|
|
||||||
name: 'web_post',
|
|
||||||
label: 'HTTP POST',
|
|
||||||
description:
|
|
||||||
'Perform an HTTP POST request with a JSON or text body. Private/local addresses are blocked.',
|
|
||||||
parameters: Type.Object({
|
|
||||||
url: Type.String({ description: 'URL to POST to (http/https only)' }),
|
|
||||||
body: Type.String({ description: 'Request body (JSON string or plain text)' }),
|
|
||||||
contentType: Type.Optional(
|
|
||||||
Type.String({ description: 'Content-Type header (default: application/json)' }),
|
|
||||||
),
|
|
||||||
headers: Type.Optional(
|
|
||||||
Type.Record(Type.String(), Type.String(), {
|
|
||||||
description: 'Optional additional request headers',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
timeout: Type.Optional(
|
|
||||||
Type.Number({ description: 'Timeout in milliseconds (default 15000, max 30000)' }),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
const { url, body, contentType, headers, timeout } = params as {
|
|
||||||
url: string;
|
|
||||||
body: string;
|
|
||||||
contentType?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const blocked = isBlockedUrl(url);
|
|
||||||
if (blocked) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error: ${blocked}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 30_000);
|
|
||||||
const ct = contentType ?? 'application/json';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await fetchWithLimit(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': ct, ...(headers ?? {}) },
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `HTTP ${result.status} (${result.contentType})\n\n${result.text}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: `Error posting to URL: ${msg}` }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return [webGet, webPost];
|
|
||||||
}
|
|
||||||
@@ -11,12 +11,6 @@ import { ProjectsModule } from './projects/projects.module.js';
|
|||||||
import { MissionsModule } from './missions/missions.module.js';
|
import { MissionsModule } from './missions/missions.module.js';
|
||||||
import { TasksModule } from './tasks/tasks.module.js';
|
import { TasksModule } from './tasks/tasks.module.js';
|
||||||
import { CoordModule } from './coord/coord.module.js';
|
import { CoordModule } from './coord/coord.module.js';
|
||||||
import { MemoryModule } from './memory/memory.module.js';
|
|
||||||
import { LogModule } from './log/log.module.js';
|
|
||||||
import { SkillsModule } from './skills/skills.module.js';
|
|
||||||
import { PluginModule } from './plugin/plugin.module.js';
|
|
||||||
import { McpModule } from './mcp/mcp.module.js';
|
|
||||||
import { AdminModule } from './admin/admin.module.js';
|
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -32,12 +26,6 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|||||||
MissionsModule,
|
MissionsModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
CoordModule,
|
CoordModule,
|
||||||
MemoryModule,
|
|
||||||
LogModule,
|
|
||||||
SkillsModule,
|
|
||||||
PluginModule,
|
|
||||||
McpModule,
|
|
||||||
AdminModule,
|
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -7,17 +7,16 @@ import { AUTH } from './auth.tokens.js';
|
|||||||
export function mountAuthHandler(app: NestFastifyApplication): void {
|
export function mountAuthHandler(app: NestFastifyApplication): void {
|
||||||
const auth = app.get<Auth>(AUTH);
|
const auth = app.get<Auth>(AUTH);
|
||||||
const nodeHandler = toNodeHandler(auth);
|
const nodeHandler = toNodeHandler(auth);
|
||||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
|
||||||
|
|
||||||
const fastify = app.getHttpAdapter().getInstance();
|
const fastify = app.getHttpAdapter().getInstance();
|
||||||
|
|
||||||
// BetterAuth is mounted at the raw HTTP level via Fastify's onRequest hook,
|
// Use Fastify's addHook to intercept auth requests at the raw HTTP level,
|
||||||
// bypassing NestJS middleware (including CORS). We must set CORS headers
|
// before Fastify's body parser runs. This avoids conflicts with NestJS's
|
||||||
// manually on the raw response before handing off to BetterAuth.
|
// custom content-type parser.
|
||||||
fastify.addHook(
|
fastify.addHook(
|
||||||
'onRequest',
|
'onRequest',
|
||||||
(
|
(
|
||||||
req: { raw: IncomingMessage; url: string; method: string },
|
req: { raw: IncomingMessage; url: string },
|
||||||
reply: { raw: ServerResponse; hijack: () => void },
|
reply: { raw: ServerResponse; hijack: () => void },
|
||||||
done: () => void,
|
done: () => void,
|
||||||
) => {
|
) => {
|
||||||
@@ -26,27 +25,6 @@ export function mountAuthHandler(app: NestFastifyApplication): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin = req.raw.headers.origin;
|
|
||||||
const allowed = corsOrigin.split(',').map((o) => o.trim());
|
|
||||||
|
|
||||||
if (origin && allowed.includes(origin)) {
|
|
||||||
reply.raw.setHeader('Access-Control-Allow-Origin', origin);
|
|
||||||
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
||||||
reply.raw.setHeader(
|
|
||||||
'Access-Control-Allow-Methods',
|
|
||||||
'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
||||||
);
|
|
||||||
reply.raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle preflight
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
reply.hijack();
|
|
||||||
reply.raw.writeHead(204);
|
|
||||||
reply.raw.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
|
nodeHandler(req.raw as IncomingMessage, reply.raw as ServerResponse)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { IsIn, IsObject, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
IsBoolean,
|
|
||||||
IsIn,
|
|
||||||
IsObject,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
MaxLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateConversationDto {
|
export class CreateConversationDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -28,10 +20,6 @@ export class UpdateConversationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
archived?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SendMessageDto {
|
export class SendMessageDto {
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
Inject,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import type {
|
|
||||||
CreateDbMissionDto,
|
|
||||||
UpdateDbMissionDto,
|
|
||||||
CreateMissionTaskDto,
|
|
||||||
UpdateMissionTaskDto,
|
|
||||||
} from './coord.dto.js';
|
|
||||||
|
|
||||||
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
/** Walk up from cwd to find the monorepo root (has pnpm-workspace.yaml). */
|
||||||
function findMonorepoRoot(start: string): string {
|
function findMonorepoRoot(start: string): string {
|
||||||
@@ -62,8 +49,6 @@ function resolveAndValidatePath(raw: string | undefined): string {
|
|||||||
export class CoordController {
|
export class CoordController {
|
||||||
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
constructor(@Inject(CoordService) private readonly coordService: CoordService) {}
|
||||||
|
|
||||||
// ── File-based coord endpoints (legacy) ──
|
|
||||||
|
|
||||||
@Get('status')
|
@Get('status')
|
||||||
async missionStatus(@Query('projectPath') projectPath?: string) {
|
async missionStatus(@Query('projectPath') projectPath?: string) {
|
||||||
const resolvedPath = resolveAndValidatePath(projectPath);
|
const resolvedPath = resolveAndValidatePath(projectPath);
|
||||||
@@ -85,121 +70,4 @@ export class CoordController {
|
|||||||
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
if (!detail) throw new NotFoundException(`Task ${taskId} not found in coord mission`);
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB-backed mission endpoints ──
|
|
||||||
|
|
||||||
@Get('missions')
|
|
||||||
async listDbMissions(@CurrentUser() user: { id: string }) {
|
|
||||||
return this.coordService.getMissionsByUser(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('missions/:id')
|
|
||||||
async getDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
|
||||||
const mission = await this.coordService.getMissionByIdAndUser(id, user.id);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
return mission;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('missions')
|
|
||||||
async createDbMission(@Body() dto: CreateDbMissionDto, @CurrentUser() user: { id: string }) {
|
|
||||||
return this.coordService.createDbMission({
|
|
||||||
name: dto.name,
|
|
||||||
description: dto.description,
|
|
||||||
projectId: dto.projectId,
|
|
||||||
userId: user.id,
|
|
||||||
phase: dto.phase,
|
|
||||||
milestones: dto.milestones,
|
|
||||||
config: dto.config,
|
|
||||||
status: dto.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('missions/:id')
|
|
||||||
async updateDbMission(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() dto: UpdateDbMissionDto,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
) {
|
|
||||||
const mission = await this.coordService.updateDbMission(id, user.id, dto);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
return mission;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('missions/:id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async deleteDbMission(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
|
||||||
const deleted = await this.coordService.deleteDbMission(id, user.id);
|
|
||||||
if (!deleted) throw new NotFoundException('Mission not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DB-backed mission task endpoints ──
|
|
||||||
|
|
||||||
@Get('missions/:missionId/mission-tasks')
|
|
||||||
async listMissionTasks(
|
|
||||||
@Param('missionId') missionId: string,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
) {
|
|
||||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
return this.coordService.getMissionTasksByMissionAndUser(missionId, user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('missions/:missionId/mission-tasks/:taskId')
|
|
||||||
async getMissionTask(
|
|
||||||
@Param('missionId') missionId: string,
|
|
||||||
@Param('taskId') taskId: string,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
) {
|
|
||||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
const task = await this.coordService.getMissionTaskByIdAndUser(taskId, user.id);
|
|
||||||
if (!task) throw new NotFoundException('Mission task not found');
|
|
||||||
return task;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('missions/:missionId/mission-tasks')
|
|
||||||
async createMissionTask(
|
|
||||||
@Param('missionId') missionId: string,
|
|
||||||
@Body() dto: CreateMissionTaskDto,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
) {
|
|
||||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
return this.coordService.createMissionTask({
|
|
||||||
missionId,
|
|
||||||
taskId: dto.taskId,
|
|
||||||
userId: user.id,
|
|
||||||
status: dto.status,
|
|
||||||
description: dto.description,
|
|
||||||
notes: dto.notes,
|
|
||||||
pr: dto.pr,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('missions/:missionId/mission-tasks/:taskId')
|
|
||||||
async updateMissionTask(
|
|
||||||
@Param('missionId') missionId: string,
|
|
||||||
@Param('taskId') taskId: string,
|
|
||||||
@Body() dto: UpdateMissionTaskDto,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
) {
|
|
||||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
const updated = await this.coordService.updateMissionTask(taskId, user.id, dto);
|
|
||||||
if (!updated) throw new NotFoundException('Mission task not found');
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('missions/:missionId/mission-tasks/:taskId')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async deleteMissionTask(
|
|
||||||
@Param('missionId') missionId: string,
|
|
||||||
@Param('taskId') taskId: string,
|
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
) {
|
|
||||||
const mission = await this.coordService.getMissionByIdAndUser(missionId, user.id);
|
|
||||||
if (!mission) throw new NotFoundException('Mission not found');
|
|
||||||
const deleted = await this.coordService.deleteMissionTask(taskId, user.id);
|
|
||||||
if (!deleted) throw new NotFoundException('Mission task not found');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// ── File-based coord DTOs (legacy file-system backed) ──
|
|
||||||
|
|
||||||
export interface CoordMissionStatusDto {
|
export interface CoordMissionStatusDto {
|
||||||
mission: {
|
mission: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,42 +47,3 @@ export interface CoordTaskDetailDto {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB-backed coord DTOs ──
|
|
||||||
|
|
||||||
export interface CreateDbMissionDto {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
projectId?: string;
|
|
||||||
phase?: string;
|
|
||||||
milestones?: Record<string, unknown>[];
|
|
||||||
config?: Record<string, unknown>;
|
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDbMissionDto {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
projectId?: string;
|
|
||||||
phase?: string;
|
|
||||||
milestones?: Record<string, unknown>[];
|
|
||||||
config?: Record<string, unknown>;
|
|
||||||
status?: 'planning' | 'active' | 'paused' | 'completed' | 'failed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateMissionTaskDto {
|
|
||||||
missionId: string;
|
|
||||||
taskId?: string;
|
|
||||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
|
||||||
description?: string;
|
|
||||||
notes?: string;
|
|
||||||
pr?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateMissionTaskDto {
|
|
||||||
taskId?: string;
|
|
||||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
|
||||||
description?: string;
|
|
||||||
notes?: string;
|
|
||||||
pr?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import type { Brain } from '@mosaic/brain';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import {
|
import {
|
||||||
loadMission,
|
loadMission,
|
||||||
getMissionStatus,
|
getMissionStatus,
|
||||||
@@ -18,8 +16,6 @@ import path from 'node:path';
|
|||||||
export class CoordService {
|
export class CoordService {
|
||||||
private readonly logger = new Logger(CoordService.name);
|
private readonly logger = new Logger(CoordService.name);
|
||||||
|
|
||||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
|
||||||
|
|
||||||
async loadMission(projectPath: string): Promise<Mission | null> {
|
async loadMission(projectPath: string): Promise<Mission | null> {
|
||||||
try {
|
try {
|
||||||
return await loadMission(projectPath);
|
return await loadMission(projectPath);
|
||||||
@@ -74,68 +70,4 @@ export class CoordService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB-backed methods for multi-tenant mission management ──
|
|
||||||
|
|
||||||
async getMissionsByUser(userId: string) {
|
|
||||||
return this.brain.missions.findAllByUser(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMissionByIdAndUser(id: string, userId: string) {
|
|
||||||
return this.brain.missions.findByIdAndUser(id, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMissionsByProjectAndUser(projectId: string, userId: string) {
|
|
||||||
return this.brain.missions.findByProjectAndUser(projectId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createDbMission(data: Parameters<Brain['missions']['create']>[0]) {
|
|
||||||
return this.brain.missions.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateDbMission(
|
|
||||||
id: string,
|
|
||||||
userId: string,
|
|
||||||
data: Parameters<Brain['missions']['update']>[1],
|
|
||||||
) {
|
|
||||||
const existing = await this.brain.missions.findByIdAndUser(id, userId);
|
|
||||||
if (!existing) return null;
|
|
||||||
return this.brain.missions.update(id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteDbMission(id: string, userId: string) {
|
|
||||||
const existing = await this.brain.missions.findByIdAndUser(id, userId);
|
|
||||||
if (!existing) return false;
|
|
||||||
return this.brain.missions.remove(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DB-backed methods for mission tasks (coord tracking) ──
|
|
||||||
|
|
||||||
async getMissionTasksByMissionAndUser(missionId: string, userId: string) {
|
|
||||||
return this.brain.missionTasks.findByMissionAndUser(missionId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMissionTaskByIdAndUser(id: string, userId: string) {
|
|
||||||
return this.brain.missionTasks.findByIdAndUser(id, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createMissionTask(data: Parameters<Brain['missionTasks']['create']>[0]) {
|
|
||||||
return this.brain.missionTasks.create(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateMissionTask(
|
|
||||||
id: string,
|
|
||||||
userId: string,
|
|
||||||
data: Parameters<Brain['missionTasks']['update']>[1],
|
|
||||||
) {
|
|
||||||
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
|
|
||||||
if (!existing) return null;
|
|
||||||
return this.brain.missionTasks.update(id, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMissionTask(id: string, userId: string) {
|
|
||||||
const existing = await this.brain.missionTasks.findByIdAndUser(id, userId);
|
|
||||||
if (!existing) return false;
|
|
||||||
return this.brain.missionTasks.remove(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import {
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
type OnModuleInit,
|
|
||||||
type OnModuleDestroy,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import cron from 'node-cron';
|
|
||||||
import { SummarizationService } from './summarization.service.js';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(CronService.name);
|
|
||||||
private readonly tasks: cron.ScheduledTask[] = [];
|
|
||||||
|
|
||||||
constructor(@Inject(SummarizationService) private readonly summarization: SummarizationService) {}
|
|
||||||
|
|
||||||
onModuleInit(): void {
|
|
||||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
|
||||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
|
||||||
|
|
||||||
this.tasks.push(
|
|
||||||
cron.schedule(summarizationSchedule, () => {
|
|
||||||
this.summarization.runSummarization().catch((err) => {
|
|
||||||
this.logger.error(`Scheduled summarization failed: ${err}`);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.tasks.push(
|
|
||||||
cron.schedule(tierManagementSchedule, () => {
|
|
||||||
this.summarization.runTierManagement().catch((err) => {
|
|
||||||
this.logger.error(`Scheduled tier management failed: ${err}`);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onModuleDestroy(): void {
|
|
||||||
for (const task of this.tasks) {
|
|
||||||
task.stop();
|
|
||||||
}
|
|
||||||
this.tasks.length = 0;
|
|
||||||
this.logger.log('Cron tasks stopped');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
|
|
||||||
import type { LogService } from '@mosaic/log';
|
|
||||||
import { LOG_SERVICE } from './log.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import type { IngestLogDto, QueryLogsDto } from './log.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/logs')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class LogController {
|
|
||||||
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async ingest(@Query('userId') userId: string, @Body() dto: IngestLogDto) {
|
|
||||||
return this.logService.logs.ingest({
|
|
||||||
sessionId: dto.sessionId,
|
|
||||||
userId,
|
|
||||||
level: dto.level,
|
|
||||||
category: dto.category,
|
|
||||||
content: dto.content,
|
|
||||||
metadata: dto.metadata,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('batch')
|
|
||||||
async ingestBatch(@Query('userId') userId: string, @Body() dtos: IngestLogDto[]) {
|
|
||||||
const entries = dtos.map((dto) => ({
|
|
||||||
sessionId: dto.sessionId,
|
|
||||||
userId,
|
|
||||||
level: dto.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
|
||||||
category: dto.category as
|
|
||||||
| 'decision'
|
|
||||||
| 'tool_use'
|
|
||||||
| 'learning'
|
|
||||||
| 'error'
|
|
||||||
| 'general'
|
|
||||||
| undefined,
|
|
||||||
content: dto.content,
|
|
||||||
metadata: dto.metadata,
|
|
||||||
}));
|
|
||||||
return this.logService.logs.ingestBatch(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async query(@Query('userId') userId: string, @Query() params: QueryLogsDto) {
|
|
||||||
return this.logService.logs.query({
|
|
||||||
userId,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
level: params.level,
|
|
||||||
category: params.category,
|
|
||||||
tier: params.tier,
|
|
||||||
since: params.since ? new Date(params.since) : undefined,
|
|
||||||
until: params.until ? new Date(params.until) : undefined,
|
|
||||||
limit: params.limit ? Number(params.limit) : undefined,
|
|
||||||
offset: params.offset ? Number(params.offset) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
async findOne(@Param('id') id: string) {
|
|
||||||
return this.logService.logs.findById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export interface IngestLogDto {
|
|
||||||
sessionId: string;
|
|
||||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
category?: 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
|
|
||||||
content: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryLogsDto {
|
|
||||||
sessionId?: string;
|
|
||||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
||||||
category?: 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
|
|
||||||
tier?: 'hot' | 'warm' | 'cold';
|
|
||||||
since?: string;
|
|
||||||
until?: string;
|
|
||||||
limit?: string;
|
|
||||||
offset?: string;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { createLogService, type LogService } from '@mosaic/log';
|
|
||||||
import type { Db } from '@mosaic/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { LOG_SERVICE } from './log.tokens.js';
|
|
||||||
import { LogController } from './log.controller.js';
|
|
||||||
import { SummarizationService } from './summarization.service.js';
|
|
||||||
import { CronService } from './cron.service.js';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: LOG_SERVICE,
|
|
||||||
useFactory: (db: Db): LogService => createLogService(db),
|
|
||||||
inject: [DB],
|
|
||||||
},
|
|
||||||
SummarizationService,
|
|
||||||
CronService,
|
|
||||||
],
|
|
||||||
controllers: [LogController],
|
|
||||||
exports: [LOG_SERVICE, SummarizationService],
|
|
||||||
})
|
|
||||||
export class LogModule {}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const LOG_SERVICE = 'LOG_SERVICE';
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import type { LogService } from '@mosaic/log';
|
|
||||||
import type { Memory } from '@mosaic/memory';
|
|
||||||
import { LOG_SERVICE } from './log.tokens.js';
|
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
|
||||||
import type { Db } from '@mosaic/db';
|
|
||||||
import { sql, summarizationJobs } from '@mosaic/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
|
|
||||||
const SUMMARIZATION_PROMPT = `You are a knowledge extraction assistant. Given the following agent interaction logs, extract the key decisions, learnings, and patterns. Output a concise summary (2-4 sentences) that captures the most important information for future reference. Focus on actionable insights, not raw events.
|
|
||||||
|
|
||||||
Logs:
|
|
||||||
{logs}
|
|
||||||
|
|
||||||
Summary:`;
|
|
||||||
|
|
||||||
interface ChatCompletion {
|
|
||||||
choices: Array<{ message: { content: string } }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SummarizationService {
|
|
||||||
private readonly logger = new Logger(SummarizationService.name);
|
|
||||||
private readonly apiKey: string | undefined;
|
|
||||||
private readonly baseUrl: string;
|
|
||||||
private readonly model: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
|
||||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
) {
|
|
||||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
|
||||||
this.baseUrl = process.env['SUMMARIZATION_API_URL'] ?? 'https://api.openai.com/v1';
|
|
||||||
this.model = process.env['SUMMARIZATION_MODEL'] ?? 'gpt-4o-mini';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one summarization cycle:
|
|
||||||
* 1. Find hot logs older than 24h with decision/learning/tool_use categories
|
|
||||||
* 2. Group by session
|
|
||||||
* 3. Summarize each group via cheap LLM
|
|
||||||
* 4. Store as insights with embeddings
|
|
||||||
* 5. Transition processed logs to warm tier
|
|
||||||
*/
|
|
||||||
async runSummarization(): Promise<{ logsProcessed: number; insightsCreated: number }> {
|
|
||||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24h ago
|
|
||||||
|
|
||||||
// Create job record
|
|
||||||
const [job] = await this.db
|
|
||||||
.insert(summarizationJobs)
|
|
||||||
.values({ status: 'running', startedAt: new Date() })
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const logs = await this.logService.logs.getLogsForSummarization(cutoff, 200);
|
|
||||||
if (logs.length === 0) {
|
|
||||||
await this.db
|
|
||||||
.update(summarizationJobs)
|
|
||||||
.set({ status: 'completed', completedAt: new Date() })
|
|
||||||
.where(sql`id = ${job!.id}`);
|
|
||||||
return { logsProcessed: 0, insightsCreated: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group logs by session
|
|
||||||
const bySession = new Map<string, typeof logs>();
|
|
||||||
for (const log of logs) {
|
|
||||||
const group = bySession.get(log.sessionId) ?? [];
|
|
||||||
group.push(log);
|
|
||||||
bySession.set(log.sessionId, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
let insightsCreated = 0;
|
|
||||||
|
|
||||||
for (const [sessionId, sessionLogs] of bySession) {
|
|
||||||
const userId = sessionLogs[0]?.userId;
|
|
||||||
if (!userId) continue;
|
|
||||||
|
|
||||||
const logsText = sessionLogs.map((l) => `[${l.category}] ${l.content}`).join('\n');
|
|
||||||
|
|
||||||
const summary = await this.summarize(logsText);
|
|
||||||
if (!summary) continue;
|
|
||||||
|
|
||||||
const embedding = this.embeddings.available
|
|
||||||
? await this.embeddings.embed(summary)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await this.memory.insights.create({
|
|
||||||
userId,
|
|
||||||
content: summary,
|
|
||||||
embedding: embedding ?? null,
|
|
||||||
source: 'summarization',
|
|
||||||
category: 'learning',
|
|
||||||
metadata: { sessionId, logCount: sessionLogs.length },
|
|
||||||
});
|
|
||||||
insightsCreated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transition processed logs to warm
|
|
||||||
await this.logService.logs.promoteToWarm(cutoff);
|
|
||||||
|
|
||||||
await this.db
|
|
||||||
.update(summarizationJobs)
|
|
||||||
.set({
|
|
||||||
status: 'completed',
|
|
||||||
logsProcessed: logs.length,
|
|
||||||
insightsCreated,
|
|
||||||
completedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(sql`id = ${job!.id}`);
|
|
||||||
|
|
||||||
this.logger.log(`Summarization complete: ${logs.length} logs → ${insightsCreated} insights`);
|
|
||||||
return { logsProcessed: logs.length, insightsCreated };
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
await this.db
|
|
||||||
.update(summarizationJobs)
|
|
||||||
.set({ status: 'failed', errorMessage: message, completedAt: new Date() })
|
|
||||||
.where(sql`id = ${job!.id}`);
|
|
||||||
this.logger.error(`Summarization failed: ${message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run tier management:
|
|
||||||
* - Warm logs older than 30 days → cold
|
|
||||||
* - Cold logs older than 90 days → purged
|
|
||||||
* - Decay old insight relevance scores
|
|
||||||
*/
|
|
||||||
async runTierManagement(): Promise<void> {
|
|
||||||
const warmCutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
const coldCutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
||||||
const decayCutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const promoted = await this.logService.logs.promoteToCold(warmCutoff);
|
|
||||||
const purged = await this.logService.logs.purge(coldCutoff);
|
|
||||||
const decayed = await this.memory.insights.decayOldInsights(decayCutoff);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Tier management: ${promoted} logs→cold, ${purged} purged, ${decayed} insights decayed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async summarize(logsText: string): Promise<string | null> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
this.logger.warn('No API key configured — skipping summarization');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = SUMMARIZATION_PROMPT.replace('{logs}', logsText);
|
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: this.model,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
|
||||||
max_tokens: 300,
|
|
||||||
temperature: 0.3,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text();
|
|
||||||
this.logger.error(`Summarization API error: ${response.status} ${body}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await response.json()) as ChatCompletion;
|
|
||||||
return json.choices[0]?.message.content ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
import { config } from 'dotenv';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
// Load .env from monorepo root (cwd is apps/gateway when run via pnpm filter)
|
|
||||||
config({ path: resolve(process.cwd(), '../../.env') });
|
|
||||||
config(); // Also load apps/gateway/.env if present (overrides)
|
|
||||||
|
|
||||||
import './tracing.js';
|
import './tracing.js';
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -13,35 +6,18 @@ import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fa
|
|||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { mountAuthHandler } from './auth/auth.controller.js';
|
import { mountAuthHandler } from './auth/auth.controller.js';
|
||||||
import { mountMcpHandler } from './mcp/mcp.controller.js';
|
|
||||||
import { McpService } from './mcp/mcp.service.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
|
||||||
|
|
||||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const logger = new Logger('Bootstrap');
|
||||||
process.env['AUTHENTIK_CLIENT_ID'] &&
|
|
||||||
(!process.env['AUTHENTIK_CLIENT_SECRET'] || !process.env['AUTHENTIK_ISSUER'])
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
'[warn] AUTHENTIK_CLIENT_ID is set but AUTHENTIK_CLIENT_SECRET or AUTHENTIK_ISSUER is missing — Authentik SSO will not work',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter({ bodyLimit: 1_048_576 }),
|
new FastifyAdapter({ bodyLimit: 1_048_576 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.enableCors({
|
|
||||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.register(helmet as never, { contentSecurityPolicy: false });
|
await app.register(helmet as never, { contentSecurityPolicy: false });
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
@@ -52,7 +28,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
mountAuthHandler(app);
|
mountAuthHandler(app);
|
||||||
mountMcpHandler(app, app.get(McpService));
|
|
||||||
|
|
||||||
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
const port = Number(process.env['GATEWAY_PORT'] ?? 4000);
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTOs for MCP client configuration and tool discovery.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface McpServerConfigDto {
|
|
||||||
/** Unique name identifying this MCP server */
|
|
||||||
name: string;
|
|
||||||
/** URL of the MCP server (streamable HTTP or SSE endpoint) */
|
|
||||||
url: string;
|
|
||||||
/** Optional HTTP headers to send with requests (e.g., Authorization) */
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface McpToolDto {
|
|
||||||
/** Namespaced tool name: "<serverName>__<toolName>" */
|
|
||||||
name: string;
|
|
||||||
/** Human-readable description of the tool */
|
|
||||||
description: string;
|
|
||||||
/** JSON Schema for tool input parameters */
|
|
||||||
inputSchema: Record<string, unknown>;
|
|
||||||
/** MCP server this tool belongs to */
|
|
||||||
serverName: string;
|
|
||||||
/** Original tool name on the remote server */
|
|
||||||
remoteName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface McpServerStatusDto {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
connected: boolean;
|
|
||||||
toolCount: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { McpClientService } from './mcp-client.service.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [McpClientService],
|
|
||||||
exports: [McpClientService],
|
|
||||||
})
|
|
||||||
export class McpClientModule {}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
||||||
import { Type } from '@sinclair/typebox';
|
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|
||||||
import type { McpServerConfigDto, McpToolDto, McpServerStatusDto } from './mcp-client.dto.js';
|
|
||||||
|
|
||||||
interface ConnectedServer {
|
|
||||||
config: McpServerConfigDto;
|
|
||||||
client: Client;
|
|
||||||
tools: McpToolDto[];
|
|
||||||
connected: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* McpClientService connects to external MCP servers, discovers their tools,
|
|
||||||
* and bridges them into Pi SDK ToolDefinition format for agent sessions.
|
|
||||||
*
|
|
||||||
* Configuration is read from the MCP_SERVERS environment variable:
|
|
||||||
* MCP_SERVERS='[{"name":"my-server","url":"http://localhost:3001/mcp","headers":{"Authorization":"Bearer token"}}]'
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class McpClientService implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(McpClientService.name);
|
|
||||||
private readonly servers = new Map<string, ConnectedServer>();
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
const configs = this.loadConfigs();
|
|
||||||
if (configs.length === 0) {
|
|
||||||
this.logger.log('No external MCP servers configured (MCP_SERVERS not set)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Connecting to ${configs.length} external MCP server(s)`);
|
|
||||||
await Promise.allSettled(configs.map((cfg) => this.connectServer(cfg)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
this.logger.log(`Disconnecting from ${this.servers.size} MCP server(s)`);
|
|
||||||
const disconnects = Array.from(this.servers.values()).map((s) => this.disconnectServer(s));
|
|
||||||
await Promise.allSettled(disconnects);
|
|
||||||
this.servers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all bridged Pi SDK ToolDefinitions from all connected MCP servers.
|
|
||||||
*/
|
|
||||||
getToolDefinitions(): ToolDefinition[] {
|
|
||||||
const tools: ToolDefinition[] = [];
|
|
||||||
for (const server of this.servers.values()) {
|
|
||||||
if (!server.connected) continue;
|
|
||||||
for (const mcpTool of server.tools) {
|
|
||||||
tools.push(this.bridgeTool(server.client, mcpTool));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns status information for all configured MCP servers.
|
|
||||||
*/
|
|
||||||
getServerStatuses(): McpServerStatusDto[] {
|
|
||||||
return Array.from(this.servers.values()).map((s) => ({
|
|
||||||
name: s.config.name,
|
|
||||||
url: s.config.url,
|
|
||||||
connected: s.connected,
|
|
||||||
toolCount: s.tools.length,
|
|
||||||
error: s.error,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to reconnect a server that has been disconnected.
|
|
||||||
*/
|
|
||||||
async reconnectServer(serverName: string): Promise<void> {
|
|
||||||
const existing = this.servers.get(serverName);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error(`MCP server not found: ${serverName}`);
|
|
||||||
}
|
|
||||||
if (existing.connected) return;
|
|
||||||
|
|
||||||
this.logger.log(`Reconnecting to MCP server: ${serverName}`);
|
|
||||||
await this.connectServer(existing.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private loadConfigs(): McpServerConfigDto[] {
|
|
||||||
const raw = process.env['MCP_SERVERS'];
|
|
||||||
if (!raw) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
this.logger.warn('MCP_SERVERS must be a JSON array — ignoring');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const configs: McpServerConfigDto[] = [];
|
|
||||||
for (const item of parsed) {
|
|
||||||
if (
|
|
||||||
typeof item === 'object' &&
|
|
||||||
item !== null &&
|
|
||||||
'name' in item &&
|
|
||||||
typeof (item as Record<string, unknown>)['name'] === 'string' &&
|
|
||||||
'url' in item &&
|
|
||||||
typeof (item as Record<string, unknown>)['url'] === 'string'
|
|
||||||
) {
|
|
||||||
const cfg = item as McpServerConfigDto;
|
|
||||||
configs.push({
|
|
||||||
name: cfg.name,
|
|
||||||
url: cfg.url,
|
|
||||||
headers: cfg.headers,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Skipping invalid MCP server config entry: ${JSON.stringify(item)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return configs;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to parse MCP_SERVERS: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async connectServer(config: McpServerConfigDto): Promise<void> {
|
|
||||||
const serverEntry: ConnectedServer = {
|
|
||||||
config,
|
|
||||||
client: new Client({ name: 'mosaic-gateway', version: '1.0.0' }),
|
|
||||||
tools: [],
|
|
||||||
connected: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preserve existing entry if reconnecting
|
|
||||||
this.servers.set(config.name, serverEntry);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(config.url);
|
|
||||||
const headers = config.headers ?? {};
|
|
||||||
|
|
||||||
// Attempt StreamableHTTP first, fall back to SSE
|
|
||||||
let connected = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } });
|
|
||||||
await serverEntry.client.connect(transport);
|
|
||||||
connected = true;
|
|
||||||
this.logger.log(`Connected to MCP server "${config.name}" via StreamableHTTP`);
|
|
||||||
} catch (streamErr) {
|
|
||||||
this.logger.warn(
|
|
||||||
`StreamableHTTP failed for "${config.name}", trying SSE: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset client for SSE attempt
|
|
||||||
serverEntry.client = new Client({ name: 'mosaic-gateway', version: '1.0.0' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transport = new SSEClientTransport(url, { requestInit: { headers } });
|
|
||||||
await serverEntry.client.connect(transport);
|
|
||||||
connected = true;
|
|
||||||
this.logger.log(`Connected to MCP server "${config.name}" via SSE`);
|
|
||||||
} catch (sseErr) {
|
|
||||||
throw new Error(
|
|
||||||
`Both transports failed for "${config.name}": SSE error: ${sseErr instanceof Error ? sseErr.message : String(sseErr)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connected) return;
|
|
||||||
|
|
||||||
// Discover tools
|
|
||||||
const toolsResult = await serverEntry.client.listTools();
|
|
||||||
serverEntry.tools = toolsResult.tools.map((t) => ({
|
|
||||||
name: `${config.name}__${t.name}`,
|
|
||||||
description: t.description ?? `Tool ${t.name} from MCP server ${config.name}`,
|
|
||||||
inputSchema: (t.inputSchema as Record<string, unknown>) ?? {},
|
|
||||||
serverName: config.name,
|
|
||||||
remoteName: t.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
serverEntry.connected = true;
|
|
||||||
this.logger.log(
|
|
||||||
`Discovered ${serverEntry.tools.length} tool(s) from MCP server "${config.name}"`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
serverEntry.error = message;
|
|
||||||
serverEntry.connected = false;
|
|
||||||
this.logger.error(`Failed to connect to MCP server "${config.name}": ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async disconnectServer(server: ConnectedServer): Promise<void> {
|
|
||||||
try {
|
|
||||||
await server.client.close();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Error closing MCP client for "${server.config.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridges a single McpToolDto into a Pi SDK ToolDefinition.
|
|
||||||
* The MCP inputSchema is converted to a TypeBox schema representation.
|
|
||||||
*/
|
|
||||||
private bridgeTool(client: Client, mcpTool: McpToolDto): ToolDefinition {
|
|
||||||
const schema = this.inputSchemaToTypeBox(mcpTool.inputSchema);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: mcpTool.name,
|
|
||||||
label: mcpTool.remoteName,
|
|
||||||
description: mcpTool.description,
|
|
||||||
parameters: schema,
|
|
||||||
execute: async (_toolCallId: string, params: unknown) => {
|
|
||||||
try {
|
|
||||||
const result = await client.callTool({
|
|
||||||
name: mcpTool.remoteName,
|
|
||||||
arguments: (params as Record<string, unknown>) ?? {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// MCP callTool returns { content: [...], isError?: boolean }
|
|
||||||
const content = Array.isArray(result.content) ? result.content : [];
|
|
||||||
const textParts = content
|
|
||||||
.filter((c): c is { type: 'text'; text: string } => c.type === 'text')
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
if (result.isError) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `MCP tool error from "${mcpTool.serverName}/${mcpTool.remoteName}": ${textParts || 'Unknown error'}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content:
|
|
||||||
content.length > 0
|
|
||||||
? (content as { type: 'text'; text: string }[])
|
|
||||||
: [{ type: 'text' as const, text: '' }],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(
|
|
||||||
`MCP tool call failed: ${mcpTool.serverName}/${mcpTool.remoteName}: ${message}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: `Failed to call MCP tool "${mcpTool.name}": ${message}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a JSON Schema object to a TypeBox-compatible schema.
|
|
||||||
* For simplicity, maps the inputSchema properties to TypeBox Type.Object.
|
|
||||||
* Unknown/complex schemas fall back to Type.Object with Type.Unknown values.
|
|
||||||
*/
|
|
||||||
private inputSchemaToTypeBox(
|
|
||||||
inputSchema: Record<string, unknown>,
|
|
||||||
): ReturnType<typeof Type.Object> {
|
|
||||||
const properties = inputSchema['properties'];
|
|
||||||
|
|
||||||
if (!properties || typeof properties !== 'object') {
|
|
||||||
return Type.Object({});
|
|
||||||
}
|
|
||||||
|
|
||||||
const required: string[] = Array.isArray(inputSchema['required'])
|
|
||||||
? (inputSchema['required'] as string[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const tbProps: Record<string, ReturnType<typeof Type.String>> = {};
|
|
||||||
|
|
||||||
for (const [key, schemaDef] of Object.entries(properties as Record<string, unknown>)) {
|
|
||||||
const def = schemaDef as Record<string, unknown>;
|
|
||||||
const desc = typeof def['description'] === 'string' ? def['description'] : undefined;
|
|
||||||
const isOptional = !required.includes(key);
|
|
||||||
const base = this.jsonSchemaToTypeBox(def);
|
|
||||||
tbProps[key] = isOptional
|
|
||||||
? (Type.Optional(base) as unknown as ReturnType<typeof Type.String>)
|
|
||||||
: (base as unknown as ReturnType<typeof Type.String>);
|
|
||||||
if (desc && tbProps[key]) {
|
|
||||||
// Attach description via metadata
|
|
||||||
(tbProps[key] as Record<string, unknown>)['description'] = desc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Type.Object(tbProps as Parameters<typeof Type.Object>[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private jsonSchemaToTypeBox(
|
|
||||||
def: Record<string, unknown>,
|
|
||||||
):
|
|
||||||
| ReturnType<typeof Type.String>
|
|
||||||
| ReturnType<typeof Type.Number>
|
|
||||||
| ReturnType<typeof Type.Boolean>
|
|
||||||
| ReturnType<typeof Type.Unknown> {
|
|
||||||
const type = def['type'];
|
|
||||||
const desc = typeof def['description'] === 'string' ? { description: def['description'] } : {};
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'string':
|
|
||||||
return Type.String(desc);
|
|
||||||
case 'number':
|
|
||||||
case 'integer':
|
|
||||||
return Type.Number(desc);
|
|
||||||
case 'boolean':
|
|
||||||
return Type.Boolean(desc);
|
|
||||||
default:
|
|
||||||
return Type.Unknown(desc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const MCP_CLIENT_SERVICE = 'MCP_CLIENT_SERVICE';
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
|
||||||
import type { Auth } from '@mosaic/auth';
|
|
||||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import type { McpService } from './mcp.service.js';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mounts the MCP streamable HTTP transport endpoint at /mcp on the Fastify instance.
|
|
||||||
*
|
|
||||||
* This follows the same low-level Fastify hook pattern used by the auth controller,
|
|
||||||
* bypassing NestJS routing to directly delegate to the MCP SDK transport handlers.
|
|
||||||
*
|
|
||||||
* Endpoint: POST /mcp (and GET /mcp for SSE stream reconnect)
|
|
||||||
* Auth: Requires a valid BetterAuth session (cookie or Authorization header).
|
|
||||||
* Session: Stateful — each initialized client gets a session ID via Mcp-Session-Id header.
|
|
||||||
*/
|
|
||||||
export function mountMcpHandler(app: NestFastifyApplication, mcpService: McpService): void {
|
|
||||||
const auth = app.get<Auth>(AUTH);
|
|
||||||
const logger = new Logger('McpController');
|
|
||||||
const fastify = app.getHttpAdapter().getInstance();
|
|
||||||
|
|
||||||
fastify.addHook(
|
|
||||||
'onRequest',
|
|
||||||
(
|
|
||||||
req: { raw: IncomingMessage; url: string; method: string },
|
|
||||||
reply: { raw: ServerResponse; hijack: () => void },
|
|
||||||
done: () => void,
|
|
||||||
) => {
|
|
||||||
if (!req.url.startsWith('/mcp')) {
|
|
||||||
done();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reply.hijack();
|
|
||||||
|
|
||||||
handleMcpRequest(req, reply, auth, mcpService, logger).catch((err: unknown) => {
|
|
||||||
logger.error(
|
|
||||||
`MCP request handler error: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
if (!reply.raw.headersSent) {
|
|
||||||
reply.raw.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
}
|
|
||||||
if (!reply.raw.writableEnded) {
|
|
||||||
reply.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMcpRequest(
|
|
||||||
req: { raw: IncomingMessage; url: string; method: string },
|
|
||||||
reply: { raw: ServerResponse; hijack: () => void },
|
|
||||||
auth: Auth,
|
|
||||||
mcpService: McpService,
|
|
||||||
logger: Logger,
|
|
||||||
): Promise<void> {
|
|
||||||
// ─── Authentication ─────────────────────────────────────────────────────
|
|
||||||
const headers = fromNodeHeaders(req.raw.headers);
|
|
||||||
const result = await auth.api.getSession({ headers });
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
reply.raw.writeHead(401, { 'Content-Type': 'application/json' });
|
|
||||||
reply.raw.end(JSON.stringify({ error: 'Unauthorized: valid session required' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = result.user.id;
|
|
||||||
|
|
||||||
// ─── Session routing ─────────────────────────────────────────────────────
|
|
||||||
const sessionId = req.raw.headers['mcp-session-id'];
|
|
||||||
|
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
||||||
// Existing session request
|
|
||||||
const transport = mcpService.getSession(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
logger.warn(`MCP session not found: ${sessionId}`);
|
|
||||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
|
||||||
reply.raw.end(JSON.stringify({ error: 'Session not found' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await transport.handleRequest(req.raw, reply.raw);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Initialize new session ───────────────────────────────────────────────
|
|
||||||
// Only POST requests can initialize a new session (must be initialize message)
|
|
||||||
if (req.method !== 'POST') {
|
|
||||||
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
reply.raw.end(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'New session must be established via POST with initialize message',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse body to verify this is an initialize request before creating a session
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
body = await readRequestBody(req.raw);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
`Failed to parse MCP request body: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
reply.raw.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
reply.raw.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new session and handle this initializing request
|
|
||||||
const { transport } = mcpService.createSession(userId);
|
|
||||||
logger.log(`New MCP session created for user ${userId}`);
|
|
||||||
|
|
||||||
await transport.handleRequest(req.raw, reply.raw, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads and parses the JSON body from a Node.js IncomingMessage.
|
|
||||||
*/
|
|
||||||
function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
req.on('end', () => {
|
|
||||||
const raw = Buffer.concat(chunks).toString('utf8');
|
|
||||||
if (!raw) {
|
|
||||||
resolve(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(raw));
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* MCP (Model Context Protocol) DTOs
|
|
||||||
*
|
|
||||||
* Defines the data transfer objects for the MCP streamable HTTP transport.
|
|
||||||
* See: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface McpToolDescriptor {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
inputSchema: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface McpServerInfo {
|
|
||||||
name: string;
|
|
||||||
version: string;
|
|
||||||
protocolVersion: string;
|
|
||||||
tools: McpToolDescriptor[];
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { McpService } from './mcp.service.js';
|
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [CoordModule],
|
|
||||||
providers: [McpService],
|
|
||||||
exports: [McpService],
|
|
||||||
})
|
|
||||||
export class McpModule {}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
import { Injectable, Logger, Inject, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { Brain } from '@mosaic/brain';
|
|
||||||
import type { Memory } from '@mosaic/memory';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import { MEMORY } from '../memory/memory.tokens.js';
|
|
||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
|
||||||
|
|
||||||
interface SessionEntry {
|
|
||||||
server: McpServer;
|
|
||||||
transport: StreamableHTTPServerTransport;
|
|
||||||
createdAt: Date;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class McpService implements OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(McpService.name);
|
|
||||||
private readonly sessions = new Map<string, SessionEntry>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(BRAIN) private readonly brain: Brain,
|
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
|
||||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
|
||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new MCP session with its own server + transport pair.
|
|
||||||
* Returns the transport for use by the controller.
|
|
||||||
*/
|
|
||||||
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
|
||||||
const sessionId = randomUUID();
|
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => sessionId,
|
|
||||||
onsessioninitialized: (id) => {
|
|
||||||
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = new McpServer(
|
|
||||||
{ name: 'mosaic-gateway', version: '1.0.0' },
|
|
||||||
{ capabilities: { tools: {} } },
|
|
||||||
);
|
|
||||||
|
|
||||||
this.registerTools(server, userId);
|
|
||||||
|
|
||||||
transport.onclose = () => {
|
|
||||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
server.connect(transport).catch((err: unknown) => {
|
|
||||||
this.logger.error(
|
|
||||||
`MCP server connect error for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
|
||||||
return { sessionId, transport };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the transport for an existing session, or null if not found.
|
|
||||||
*/
|
|
||||||
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
|
||||||
return this.sessions.get(sessionId)?.transport ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers all platform tools on the given McpServer instance.
|
|
||||||
*/
|
|
||||||
private registerTools(server: McpServer, _userId: string): void {
|
|
||||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_list_projects',
|
|
||||||
{
|
|
||||||
description: 'List all projects in the brain.',
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const projects = await this.brain.projects.findAll();
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_get_project',
|
|
||||||
{
|
|
||||||
description: 'Get a project by ID.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
id: z.string().describe('Project ID (UUID)'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ id }) => {
|
|
||||||
const project = await this.brain.projects.findById(id);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Brain: Task tools ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_list_tasks',
|
|
||||||
{
|
|
||||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
|
||||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
|
||||||
status: z.string().optional().describe('Filter by status'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ projectId, missionId, status }) => {
|
|
||||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
|
||||||
let tasks;
|
|
||||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
|
||||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
|
||||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
|
||||||
else tasks = await this.brain.tasks.findAll();
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_create_task',
|
|
||||||
{
|
|
||||||
description: 'Create a new task in the brain.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
title: z.string().describe('Task title'),
|
|
||||||
description: z.string().optional().describe('Task description'),
|
|
||||||
projectId: z.string().optional().describe('Project ID'),
|
|
||||||
missionId: z.string().optional().describe('Mission ID'),
|
|
||||||
priority: z.string().optional().describe('Priority: low, medium, high, critical'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async (params) => {
|
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
const task = await this.brain.tasks.create({
|
|
||||||
...params,
|
|
||||||
priority: params.priority as Priority | undefined,
|
|
||||||
});
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(task, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_update_task',
|
|
||||||
{
|
|
||||||
description: 'Update an existing task.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
id: z.string().describe('Task ID'),
|
|
||||||
title: z.string().optional(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
status: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
|
||||||
priority: z.string().optional(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ id, ...updates }) => {
|
|
||||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
|
||||||
const task = await this.brain.tasks.update(id, {
|
|
||||||
...updates,
|
|
||||||
status: updates.status as TaskStatus | undefined,
|
|
||||||
priority: updates.priority as Priority | undefined,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: task ? JSON.stringify(task, null, 2) : `Task not found: ${id}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Brain: Mission tools ────────────────────────────────────────────
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_list_missions',
|
|
||||||
{
|
|
||||||
description: 'List all missions, optionally filtered by project.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ projectId }) => {
|
|
||||||
const missions = projectId
|
|
||||||
? await this.brain.missions.findByProject(projectId)
|
|
||||||
: await this.brain.missions.findAll();
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'brain_list_conversations',
|
|
||||||
{
|
|
||||||
description: 'List conversations for a user.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ userId }) => {
|
|
||||||
const conversations = await this.brain.conversations.findAll(userId);
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Memory tools ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'memory_search',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
userId: z.string().describe('User ID to search memory for'),
|
|
||||||
query: z.string().describe('Natural language search query'),
|
|
||||||
limit: z.number().optional().describe('Max results (default 5)'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ userId, query, limit }) => {
|
|
||||||
if (!this.embeddings.available) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: 'Semantic search unavailable — no embedding provider configured',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const embedding = await this.embeddings.embed(query);
|
|
||||||
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'memory_get_preferences',
|
|
||||||
{
|
|
||||||
description: 'Retrieve stored preferences for a user.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
category: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ userId, category }) => {
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
|
||||||
const prefs = category
|
|
||||||
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
|
||||||
: await this.memory.preferences.findByUser(userId);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'memory_save_preference',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
|
||||||
inputSchema: z.object({
|
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
key: z.string().describe('Preference key'),
|
|
||||||
value: z.string().describe('Preference value (JSON string)'),
|
|
||||||
category: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ userId, key, value, category }) => {
|
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
|
||||||
let parsedValue: unknown;
|
|
||||||
try {
|
|
||||||
parsedValue = JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
parsedValue = value;
|
|
||||||
}
|
|
||||||
const pref = await this.memory.preferences.upsert({
|
|
||||||
userId,
|
|
||||||
key,
|
|
||||||
value: parsedValue,
|
|
||||||
category: (category as Cat) ?? 'general',
|
|
||||||
source: 'agent',
|
|
||||||
});
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(pref, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'memory_save_insight',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
content: z.string().describe('The insight or knowledge to store'),
|
|
||||||
category: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ userId, content, category }) => {
|
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
|
||||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
|
||||||
const insight = await this.memory.insights.create({
|
|
||||||
userId,
|
|
||||||
content,
|
|
||||||
embedding,
|
|
||||||
source: 'agent',
|
|
||||||
category: (category as Cat) ?? 'learning',
|
|
||||||
});
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Coord tools ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'coord_mission_status',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
projectPath: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Project path. Defaults to gateway working directory.'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ projectPath }) => {
|
|
||||||
const resolvedPath = projectPath ?? process.cwd();
|
|
||||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: status ? JSON.stringify(status, null, 2) : 'No active coord mission found.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'coord_list_tasks',
|
|
||||||
{
|
|
||||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
projectPath: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Project path. Defaults to gateway working directory.'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ projectPath }) => {
|
|
||||||
const resolvedPath = projectPath ?? process.cwd();
|
|
||||||
const tasks = await this.coordService.listTasks(resolvedPath);
|
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'coord_task_detail',
|
|
||||||
{
|
|
||||||
description: 'Get detailed status for a specific orchestration task.',
|
|
||||||
inputSchema: z.object({
|
|
||||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
|
||||||
projectPath: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Project path. Defaults to gateway working directory.'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ taskId, projectPath }) => {
|
|
||||||
const resolvedPath = projectPath ?? process.cwd();
|
|
||||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text' as const,
|
|
||||||
text: detail
|
|
||||||
? JSON.stringify(detail, null, 2)
|
|
||||||
: `Task ${taskId} not found in coord mission.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
this.logger.log(`Closing ${this.sessions.size} MCP sessions on shutdown`);
|
|
||||||
const closePromises = Array.from(this.sessions.values()).map(({ transport }) =>
|
|
||||||
transport.close().catch((err: unknown) => {
|
|
||||||
this.logger.warn(
|
|
||||||
`Error closing MCP transport: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await Promise.all(closePromises);
|
|
||||||
this.sessions.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const MCP_SERVICE = 'MCP_SERVICE';
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'text-embedding-3-small';
|
|
||||||
const DEFAULT_DIMENSIONS = 1536;
|
|
||||||
|
|
||||||
interface EmbeddingResponse {
|
|
||||||
data: Array<{ embedding: number[]; index: number }>;
|
|
||||||
model: string;
|
|
||||||
usage: { prompt_tokens: number; total_tokens: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates embeddings via the OpenAI-compatible embeddings API.
|
|
||||||
* Supports OpenAI, Azure OpenAI, and any provider with a compatible endpoint.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class EmbeddingService implements EmbeddingProvider {
|
|
||||||
private readonly logger = new Logger(EmbeddingService.name);
|
|
||||||
private readonly apiKey: string | undefined;
|
|
||||||
private readonly baseUrl: string;
|
|
||||||
private readonly model: string;
|
|
||||||
|
|
||||||
readonly dimensions = DEFAULT_DIMENSIONS;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
|
||||||
this.baseUrl = process.env['EMBEDDING_API_URL'] ?? 'https://api.openai.com/v1';
|
|
||||||
this.model = process.env['EMBEDDING_MODEL'] ?? DEFAULT_MODEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
get available(): boolean {
|
|
||||||
return !!this.apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async embed(text: string): Promise<number[]> {
|
|
||||||
const results = await this.embedBatch([text]);
|
|
||||||
return results[0]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
this.logger.warn('No OPENAI_API_KEY configured — returning zero vectors');
|
|
||||||
return texts.map(() => new Array<number>(this.dimensions).fill(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: this.model,
|
|
||||||
input: texts,
|
|
||||||
dimensions: this.dimensions,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text();
|
|
||||||
this.logger.error(`Embedding API error: ${response.status} ${body}`);
|
|
||||||
throw new Error(`Embedding API returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await response.json()) as EmbeddingResponse;
|
|
||||||
return json.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { Memory } from '@mosaic/memory';
|
|
||||||
import { MEMORY } from './memory.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { EmbeddingService } from './embedding.service.js';
|
|
||||||
import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './memory.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/memory')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class MemoryController {
|
|
||||||
constructor(
|
|
||||||
@Inject(MEMORY) private readonly memory: Memory,
|
|
||||||
@Inject(EmbeddingService) private readonly embeddings: EmbeddingService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ─── Preferences ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Get('preferences')
|
|
||||||
async listPreferences(@CurrentUser() user: { id: string }, @Query('category') category?: string) {
|
|
||||||
if (category) {
|
|
||||||
return this.memory.preferences.findByUserAndCategory(
|
|
||||||
user.id,
|
|
||||||
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.memory.preferences.findByUser(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('preferences/:key')
|
|
||||||
async getPreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
|
||||||
const pref = await this.memory.preferences.findByUserAndKey(user.id, key);
|
|
||||||
if (!pref) throw new NotFoundException('Preference not found');
|
|
||||||
return pref;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('preferences')
|
|
||||||
async upsertPreference(@CurrentUser() user: { id: string }, @Body() dto: UpsertPreferenceDto) {
|
|
||||||
return this.memory.preferences.upsert({
|
|
||||||
userId: user.id,
|
|
||||||
key: dto.key,
|
|
||||||
value: dto.value,
|
|
||||||
category: dto.category,
|
|
||||||
source: dto.source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('preferences/:key')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async removePreference(@CurrentUser() user: { id: string }, @Param('key') key: string) {
|
|
||||||
const deleted = await this.memory.preferences.remove(user.id, key);
|
|
||||||
if (!deleted) throw new NotFoundException('Preference not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Insights ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Get('insights')
|
|
||||||
async listInsights(@CurrentUser() user: { id: string }, @Query('limit') limit?: string) {
|
|
||||||
return this.memory.insights.findByUser(user.id, limit ? Number(limit) : undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('insights/:id')
|
|
||||||
async getInsight(@Param('id') id: string) {
|
|
||||||
const insight = await this.memory.insights.findById(id);
|
|
||||||
if (!insight) throw new NotFoundException('Insight not found');
|
|
||||||
return insight;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('insights')
|
|
||||||
async createInsight(@CurrentUser() user: { id: string }, @Body() dto: CreateInsightDto) {
|
|
||||||
const embedding = this.embeddings.available
|
|
||||||
? await this.embeddings.embed(dto.content)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return this.memory.insights.create({
|
|
||||||
userId: user.id,
|
|
||||||
content: dto.content,
|
|
||||||
source: dto.source,
|
|
||||||
category: dto.category,
|
|
||||||
metadata: dto.metadata,
|
|
||||||
embedding: embedding ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('insights/:id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async removeInsight(@Param('id') id: string) {
|
|
||||||
const deleted = await this.memory.insights.remove(id);
|
|
||||||
if (!deleted) throw new NotFoundException('Insight not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Search ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Post('search')
|
|
||||||
async searchMemory(@CurrentUser() user: { id: string }, @Body() dto: SearchMemoryDto) {
|
|
||||||
if (!this.embeddings.available) {
|
|
||||||
return {
|
|
||||||
query: dto.query,
|
|
||||||
results: [],
|
|
||||||
message: 'Semantic search requires OPENAI_API_KEY for embeddings',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryEmbedding = await this.embeddings.embed(dto.query);
|
|
||||||
const results = await this.memory.insights.searchByEmbedding(
|
|
||||||
user.id,
|
|
||||||
queryEmbedding,
|
|
||||||
dto.limit ?? 10,
|
|
||||||
dto.maxDistance ?? 0.8,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { query: dto.query, results };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export interface UpsertPreferenceDto {
|
|
||||||
key: string;
|
|
||||||
value: unknown;
|
|
||||||
category?: 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
|
||||||
source?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateInsightDto {
|
|
||||||
content: string;
|
|
||||||
source?: 'agent' | 'user' | 'summarization' | 'system';
|
|
||||||
category?: 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchMemoryDto {
|
|
||||||
query: string;
|
|
||||||
limit?: number;
|
|
||||||
maxDistance?: number;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { createMemory, type Memory } from '@mosaic/memory';
|
|
||||||
import type { Db } from '@mosaic/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
import { MEMORY } from './memory.tokens.js';
|
|
||||||
import { MemoryController } from './memory.controller.js';
|
|
||||||
import { EmbeddingService } from './embedding.service.js';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: MEMORY,
|
|
||||||
useFactory: (db: Db): Memory => createMemory(db),
|
|
||||||
inject: [DB],
|
|
||||||
},
|
|
||||||
EmbeddingService,
|
|
||||||
],
|
|
||||||
controllers: [MemoryController],
|
|
||||||
exports: [MEMORY, EmbeddingService],
|
|
||||||
})
|
|
||||||
export class MemoryModule {}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const MEMORY = 'MEMORY';
|
|
||||||
@@ -36,10 +36,7 @@ export class MissionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateMissionDto, @CurrentUser() user: { id: string }) {
|
async create(@Body() dto: CreateMissionDto) {
|
||||||
if (dto.projectId) {
|
|
||||||
await this.getOwnedProject(dto.projectId, user.id, 'Mission');
|
|
||||||
}
|
|
||||||
return this.brain.missions.create({
|
return this.brain.missions.create({
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface IChannelPlugin {
|
|
||||||
readonly name: string;
|
|
||||||
start(): Promise<void>;
|
|
||||||
stop(): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import {
|
|
||||||
Global,
|
|
||||||
Inject,
|
|
||||||
Logger,
|
|
||||||
Module,
|
|
||||||
type OnModuleDestroy,
|
|
||||||
type OnModuleInit,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DiscordPlugin } from '@mosaic/discord-plugin';
|
|
||||||
import { TelegramPlugin } from '@mosaic/telegram-plugin';
|
|
||||||
import { PluginService } from './plugin.service.js';
|
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
|
||||||
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
|
||||||
|
|
||||||
class DiscordChannelPluginAdapter implements IChannelPlugin {
|
|
||||||
readonly name = 'discord';
|
|
||||||
|
|
||||||
constructor(private readonly plugin: DiscordPlugin) {}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
await this.plugin.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
await this.plugin.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TelegramChannelPluginAdapter implements IChannelPlugin {
|
|
||||||
readonly name = 'telegram';
|
|
||||||
|
|
||||||
constructor(private readonly plugin: TelegramPlugin) {}
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
await this.plugin.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
await this.plugin.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = 'http://localhost:4000';
|
|
||||||
|
|
||||||
function createPluginRegistry(): IChannelPlugin[] {
|
|
||||||
const plugins: IChannelPlugin[] = [];
|
|
||||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
|
||||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
|
||||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
|
||||||
|
|
||||||
if (discordToken) {
|
|
||||||
plugins.push(
|
|
||||||
new DiscordChannelPluginAdapter(
|
|
||||||
new DiscordPlugin({
|
|
||||||
token: discordToken,
|
|
||||||
guildId: discordGuildId,
|
|
||||||
gatewayUrl: discordGatewayUrl,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramToken = process.env['TELEGRAM_BOT_TOKEN'];
|
|
||||||
const telegramGatewayUrl = process.env['TELEGRAM_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
|
||||||
|
|
||||||
if (telegramToken) {
|
|
||||||
plugins.push(
|
|
||||||
new TelegramChannelPluginAdapter(
|
|
||||||
new TelegramPlugin({
|
|
||||||
token: telegramToken,
|
|
||||||
gatewayUrl: telegramGatewayUrl,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: PLUGIN_REGISTRY,
|
|
||||||
useFactory: (): IChannelPlugin[] => createPluginRegistry(),
|
|
||||||
},
|
|
||||||
PluginService,
|
|
||||||
],
|
|
||||||
exports: [PluginService, PLUGIN_REGISTRY],
|
|
||||||
})
|
|
||||||
export class PluginModule implements OnModuleInit, OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(PluginModule.name);
|
|
||||||
|
|
||||||
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
|
||||||
for (const plugin of this.plugins) {
|
|
||||||
this.logger.log(`Starting plugin: ${plugin.name}`);
|
|
||||||
await plugin.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
|
||||||
for (const plugin of [...this.plugins].reverse()) {
|
|
||||||
this.logger.log(`Stopping plugin: ${plugin.name}`);
|
|
||||||
await plugin.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PluginService {
|
|
||||||
constructor(@Inject(PLUGIN_REGISTRY) private readonly plugins: IChannelPlugin[]) {}
|
|
||||||
|
|
||||||
getPlugins(): IChannelPlugin[] {
|
|
||||||
return this.plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
getPlugin(name: string): IChannelPlugin | undefined {
|
|
||||||
return this.plugins.find((plugin: IChannelPlugin) => plugin.name === name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Inject,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { SkillsService } from './skills.service.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import type { CreateSkillDto, UpdateSkillDto } from './skills.dto.js';
|
|
||||||
|
|
||||||
@Controller('api/skills')
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class SkillsController {
|
|
||||||
constructor(@Inject(SkillsService) private readonly skills: SkillsService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
async list() {
|
|
||||||
return this.skills.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
|
||||||
async findOne(@Param('id') id: string) {
|
|
||||||
const skill = await this.skills.findById(id);
|
|
||||||
if (!skill) throw new NotFoundException('Skill not found');
|
|
||||||
return skill;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
|
||||||
async create(@Body() dto: CreateSkillDto) {
|
|
||||||
return this.skills.create({
|
|
||||||
name: dto.name,
|
|
||||||
description: dto.description,
|
|
||||||
version: dto.version,
|
|
||||||
source: dto.source,
|
|
||||||
config: dto.config,
|
|
||||||
enabled: dto.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id')
|
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateSkillDto) {
|
|
||||||
const skill = await this.skills.update(id, dto);
|
|
||||||
if (!skill) throw new NotFoundException('Skill not found');
|
|
||||||
return skill;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id/toggle')
|
|
||||||
async toggle(@Param('id') id: string, @Body() body: { enabled: boolean }) {
|
|
||||||
const skill = await this.skills.toggle(id, body.enabled);
|
|
||||||
if (!skill) throw new NotFoundException('Skill not found');
|
|
||||||
return skill;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async remove(@Param('id') id: string) {
|
|
||||||
const deleted = await this.skills.remove(id);
|
|
||||||
if (!deleted) throw new NotFoundException('Skill not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export interface CreateSkillDto {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
version?: string;
|
|
||||||
source?: 'builtin' | 'community' | 'custom';
|
|
||||||
config?: Record<string, unknown>;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateSkillDto {
|
|
||||||
description?: string;
|
|
||||||
version?: string;
|
|
||||||
config?: Record<string, unknown>;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { SkillsService } from './skills.service.js';
|
|
||||||
import { SkillsController } from './skills.controller.js';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [SkillsService],
|
|
||||||
controllers: [SkillsController],
|
|
||||||
exports: [SkillsService],
|
|
||||||
})
|
|
||||||
export class SkillsModule {}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { eq, type Db, skills } from '@mosaic/db';
|
|
||||||
import { DB } from '../database/database.module.js';
|
|
||||||
|
|
||||||
type Skill = typeof skills.$inferSelect;
|
|
||||||
type NewSkill = typeof skills.$inferInsert;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SkillsService {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async findAll(): Promise<Skill[]> {
|
|
||||||
return this.db.select().from(skills);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findEnabled(): Promise<Skill[]> {
|
|
||||||
return this.db.select().from(skills).where(eq(skills.enabled, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: string): Promise<Skill | undefined> {
|
|
||||||
const rows = await this.db.select().from(skills).where(eq(skills.id, id));
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByName(name: string): Promise<Skill | undefined> {
|
|
||||||
const rows = await this.db.select().from(skills).where(eq(skills.name, name));
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(data: NewSkill): Promise<Skill> {
|
|
||||||
const rows = await this.db.insert(skills).values(data).returning();
|
|
||||||
return rows[0]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, data: Partial<NewSkill>): Promise<Skill | undefined> {
|
|
||||||
const rows = await this.db
|
|
||||||
.update(skills)
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(skills.id, id))
|
|
||||||
.returning();
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string): Promise<boolean> {
|
|
||||||
const rows = await this.db.delete(skills).where(eq(skills.id, id)).returning();
|
|
||||||
return rows.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggle(id: string, enabled: boolean): Promise<Skill | undefined> {
|
|
||||||
return this.update(id, { enabled });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,48 +28,17 @@ export class TasksController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(
|
async list(
|
||||||
@CurrentUser() user: { id: string },
|
|
||||||
@Query('projectId') projectId?: string,
|
@Query('projectId') projectId?: string,
|
||||||
@Query('missionId') missionId?: string,
|
@Query('missionId') missionId?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
) {
|
) {
|
||||||
if (projectId) {
|
if (projectId) return this.brain.tasks.findByProject(projectId);
|
||||||
await this.getOwnedProject(projectId, user.id, 'Task');
|
if (missionId) return this.brain.tasks.findByMission(missionId);
|
||||||
return this.brain.tasks.findByProject(projectId);
|
if (status)
|
||||||
}
|
return this.brain.tasks.findByStatus(
|
||||||
if (missionId) {
|
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
|
||||||
await this.getOwnedMission(missionId, user.id, 'Task');
|
);
|
||||||
return this.brain.tasks.findByMission(missionId);
|
return this.brain.tasks.findAll();
|
||||||
}
|
|
||||||
|
|
||||||
const [projects, missions, tasks] = await Promise.all([
|
|
||||||
this.brain.projects.findAll(),
|
|
||||||
this.brain.missions.findAll(),
|
|
||||||
status
|
|
||||||
? this.brain.tasks.findByStatus(
|
|
||||||
status as Parameters<typeof this.brain.tasks.findByStatus>[0],
|
|
||||||
)
|
|
||||||
: this.brain.tasks.findAll(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const ownedProjectIds = new Set(
|
|
||||||
projects.filter((project) => project.ownerId === user.id).map((project) => project.id),
|
|
||||||
);
|
|
||||||
const ownedMissionIds = new Set(
|
|
||||||
missions
|
|
||||||
.filter(
|
|
||||||
(ownedMission) =>
|
|
||||||
typeof ownedMission.projectId === 'string' &&
|
|
||||||
ownedProjectIds.has(ownedMission.projectId),
|
|
||||||
)
|
|
||||||
.map((ownedMission) => ownedMission.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
return tasks.filter(
|
|
||||||
(task) =>
|
|
||||||
(task.projectId ? ownedProjectIds.has(task.projectId) : false) ||
|
|
||||||
(task.missionId ? ownedMissionIds.has(task.missionId) : false),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@@ -78,13 +47,7 @@ export class TasksController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() dto: CreateTaskDto, @CurrentUser() user: { id: string }) {
|
async create(@Body() dto: CreateTaskDto) {
|
||||||
if (dto.projectId) {
|
|
||||||
await this.getOwnedProject(dto.projectId, user.id, 'Task');
|
|
||||||
}
|
|
||||||
if (dto.missionId) {
|
|
||||||
await this.getOwnedMission(dto.missionId, user.id, 'Task');
|
|
||||||
}
|
|
||||||
return this.brain.tasks.create({
|
return this.brain.tasks.create({
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
|
|||||||
@@ -8,11 +8,7 @@
|
|||||||
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
|
"@mosaic/brain": ["../../packages/brain/src/index.ts"],
|
||||||
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
|
"@mosaic/coord": ["../../packages/coord/src/index.ts"],
|
||||||
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
"@mosaic/db": ["../../packages/db/src/index.ts"],
|
||||||
"@mosaic/log": ["../../packages/log/src/index.ts"],
|
"@mosaic/types": ["../../packages/types/src/index.ts"]
|
||||||
"@mosaic/memory": ["../../packages/memory/src/index.ts"],
|
|
||||||
"@mosaic/types": ["../../packages/types/src/index.ts"],
|
|
||||||
"@mosaic/discord-plugin": ["../../plugins/discord/src/index.ts"],
|
|
||||||
"@mosaic/telegram-plugin": ["../../plugins/telegram/src/index.ts"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
test.describe('Admin page — admin user', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin page loads with the Admin Panel heading', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
await expect(page.getByRole('heading', { name: /admin panel/i })).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows User Management and System Health tabs', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
await expect(page.getByRole('button', { name: /user management/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /system health/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('User Management tab is active by default', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
// The users tab shows a "+ New User" button
|
|
||||||
await expect(page.getByRole('button', { name: /new user/i })).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking System Health tab switches to health view', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
await page.getByRole('button', { name: /system health/i }).click();
|
|
||||||
// Health cards or loading indicator should appear
|
|
||||||
const hasLoading = await page
|
|
||||||
.getByText(/loading health/i)
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
const hasCard = await page
|
|
||||||
.getByText(/database/i)
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasLoading || hasCard).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Admin page — non-admin user', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
// Either redirected away or shown an access-denied message
|
|
||||||
const onAdmin = page.url().includes('/admin');
|
|
||||||
if (onAdmin) {
|
|
||||||
// Should show some access-denied content rather than the full admin panel
|
|
||||||
const hasPanel = await page
|
|
||||||
.getByRole('heading', { name: /admin panel/i })
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
// If heading is visible, the guard allowed access (user may have admin role in this env)
|
|
||||||
// — not a failure, just informational
|
|
||||||
if (!hasPanel) {
|
|
||||||
// access denied message, redirect, or guard placeholder
|
|
||||||
const url = page.url();
|
|
||||||
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
// ── Login page ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Login page', () => {
|
|
||||||
test('loads and shows the sign-in heading', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
await expect(page).toHaveTitle(/mosaic/i);
|
|
||||||
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows email and password fields', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
await expect(page.getByLabel('Email')).toBeVisible();
|
|
||||||
await expect(page.getByLabel('Password')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows submit button', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows link to registration page', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
const signUpLink = page.getByRole('link', { name: /sign up/i });
|
|
||||||
await expect(signUpLink).toBeVisible();
|
|
||||||
await signUpLink.click();
|
|
||||||
await expect(page).toHaveURL(/\/register/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows an error alert for invalid credentials', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
await page.getByLabel('Email').fill('nobody@nowhere.invalid');
|
|
||||||
await page.getByLabel('Password').fill('wrongpassword');
|
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
// The error banner should appear; it has role="alert"
|
|
||||||
await expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('email field requires valid format (HTML5 validation)', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
// Fill a non-email value — browser prevents submission
|
|
||||||
await page.getByLabel('Email').fill('notanemail');
|
|
||||||
await page.getByLabel('Password').fill('somepass');
|
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
// Still on the login page
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('redirects to /chat after successful login', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
|
||||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
// Either reaches /chat or shows an error (if credentials are wrong in this env).
|
|
||||||
// We assert a navigation away from /login, or the alert is shown.
|
|
||||||
await Promise.race([
|
|
||||||
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
|
|
||||||
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
|
|
||||||
]).catch(() => {
|
|
||||||
// Acceptable — environment may not have seeded credentials
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Registration page ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Registration page', () => {
|
|
||||||
test('loads and shows the create account heading', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
await expect(page.getByRole('heading', { name: /create account/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows name, email and password fields', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
await expect(page.getByLabel('Name')).toBeVisible();
|
|
||||||
await expect(page.getByLabel('Email')).toBeVisible();
|
|
||||||
await expect(page.getByLabel('Password')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows submit button', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
await expect(page.getByRole('button', { name: /create account/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows link to login page', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
const signInLink = page.getByRole('link', { name: /sign in/i });
|
|
||||||
await expect(signInLink).toBeVisible();
|
|
||||||
await signInLink.click();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('name field is required — empty form stays on page', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
// Submit with nothing filled in — browser required validation blocks it
|
|
||||||
await page.getByRole('button', { name: /create account/i }).click();
|
|
||||||
await expect(page).toHaveURL(/\/register/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all required fields must be filled (HTML5 validation)', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
await page.getByLabel('Name').fill('Test User');
|
|
||||||
// Do NOT fill email or password — still on page
|
|
||||||
await page.getByRole('button', { name: /create account/i }).click();
|
|
||||||
await expect(page).toHaveURL(/\/register/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Root redirect ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test.describe('Root route', () => {
|
|
||||||
test('visiting / redirects to /login or /chat', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
// Unauthenticated users should land on /login; authenticated on /chat
|
|
||||||
await expect(page).toHaveURL(/\/(login|chat)/, { timeout: 10_000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
test.describe('Chat page', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
// If login failed (no seeded user in env) we may be on /login — skip
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// Either there are conversations listed or the welcome empty-state is shown
|
|
||||||
const hasWelcome = await page
|
|
||||||
.getByRole('heading', { name: /welcome to mosaic chat/i })
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
const hasConversationPanel = await page
|
|
||||||
.locator('[data-testid="conversation-list"], nav, aside')
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(hasWelcome || hasConversationPanel).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('new conversation button is visible', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// "Start new conversation" button or a "+" button in the sidebar
|
|
||||||
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
|
||||||
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking new conversation shows a chat input area', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// Find any button that creates a new conversation
|
|
||||||
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
|
|
||||||
await newBtn.click();
|
|
||||||
// After creating, a text input for sending messages should appear
|
|
||||||
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
|
|
||||||
await expect(chatInput).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar navigation is present on chat page', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// The app-shell sidebar should be visible
|
|
||||||
await expect(page.getByRole('link', { name: /chat/i }).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
export const TEST_USER = {
|
|
||||||
email: process.env['E2E_USER_EMAIL'] ?? 'e2e@example.com',
|
|
||||||
password: process.env['E2E_USER_PASSWORD'] ?? 'password123',
|
|
||||||
name: 'E2E Test User',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ADMIN_USER = {
|
|
||||||
email: process.env['E2E_ADMIN_EMAIL'] ?? 'admin@example.com',
|
|
||||||
password: process.env['E2E_ADMIN_PASSWORD'] ?? 'adminpass123',
|
|
||||||
name: 'E2E Admin User',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill the login form and submit. Waits for navigation after success.
|
|
||||||
*/
|
|
||||||
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
|
|
||||||
await page.goto('/login');
|
|
||||||
await page.getByLabel('Email').fill(email);
|
|
||||||
await page.getByLabel('Password').fill(password);
|
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
test.describe('Sidebar navigation', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar shows Mosaic brand link', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Chat nav link navigates to /chat', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await page
|
|
||||||
.getByRole('link', { name: /^chat$/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/chat/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Projects nav link navigates to /projects', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
await page
|
|
||||||
.getByRole('link', { name: /projects/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/projects/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Settings nav link navigates to /settings', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
await page
|
|
||||||
.getByRole('link', { name: /settings/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await expect(page).toHaveURL(/\/settings/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Tasks nav link navigates to /tasks', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
await page.getByRole('link', { name: /tasks/i }).first().click();
|
|
||||||
await expect(page).toHaveURL(/\/tasks/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('active link is visually highlighted', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
// The active link should have a distinct class — check that the Chat link
|
|
||||||
// has the active style class (bg-blue-600/20 text-blue-400)
|
|
||||||
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
|
|
||||||
const cls = await chatLink.getAttribute('class');
|
|
||||||
expect(cls).toContain('blue');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Route transitions', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
await expect(page).toHaveURL(/\/chat/);
|
|
||||||
|
|
||||||
await page.goto('/projects');
|
|
||||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
|
||||||
|
|
||||||
await page.goto('/settings');
|
|
||||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
|
||||||
|
|
||||||
await page.goto('/chat');
|
|
||||||
await expect(page).toHaveURL(/\/chat/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('back-button navigation works between pages', async ({ page }) => {
|
|
||||||
await page.goto('/chat');
|
|
||||||
await page.goto('/projects');
|
|
||||||
await page.goBack();
|
|
||||||
await expect(page).toHaveURL(/\/chat/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
test.describe('Projects page', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('projects page loads with heading', async ({ page }) => {
|
|
||||||
await page.goto('/projects');
|
|
||||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows empty state or project cards when loaded', async ({ page }) => {
|
|
||||||
await page.goto('/projects');
|
|
||||||
// Wait for loading state to clear
|
|
||||||
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
|
|
||||||
|
|
||||||
const hasProjects = await page
|
|
||||||
.locator('[class*="grid"]')
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
const hasEmpty = await page
|
|
||||||
.getByText(/no projects yet/i)
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(hasProjects || hasEmpty).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows Active Mission section', async ({ page }) => {
|
|
||||||
await page.goto('/projects');
|
|
||||||
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar navigation is present', async ({ page }) => {
|
|
||||||
await page.goto('/projects');
|
|
||||||
await expect(page.getByRole('link', { name: /projects/i }).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { loginAs, TEST_USER } from './helpers/auth.js';
|
|
||||||
|
|
||||||
test.describe('Settings page', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await loginAs(page, TEST_USER.email, TEST_USER.password);
|
|
||||||
const url = page.url();
|
|
||||||
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('settings page loads with heading', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await expect(page.getByRole('heading', { name: /^settings$/i })).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows the four settings tabs', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await expect(page.getByRole('button', { name: /profile/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /appearance/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /notifications/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /providers/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('profile tab is active by default', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await expect(page.getByRole('heading', { name: /^profile$/i })).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking Appearance tab switches content', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await page.getByRole('button', { name: /appearance/i }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: /appearance/i })).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking Notifications tab switches content', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await page.getByRole('button', { name: /notifications/i }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: /notifications/i })).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking Providers tab switches content', async ({ page }) => {
|
|
||||||
await page.goto('/settings');
|
|
||||||
await page.getByRole('button', { name: /providers/i }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: /llm providers/i })).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import './.next/types/routes.d.ts';
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { NextConfig } from 'next';
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
transpilePackages: ['@mosaic/design-tokens'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -8,22 +8,14 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaic/design-tokens": "workspace:^",
|
|
||||||
"better-auth": "^1.5.5",
|
|
||||||
"clsx": "^2.1.0",
|
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"socket.io-client": "^4.8.0",
|
|
||||||
"tailwind-merge": "^3.5.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Playwright E2E configuration for Mosaic web app.
|
|
||||||
*
|
|
||||||
* Assumes:
|
|
||||||
* - Next.js web app running on http://localhost:3000
|
|
||||||
* - NestJS gateway running on http://localhost:4000
|
|
||||||
*
|
|
||||||
* Run with: pnpm --filter @mosaic/web test:e2e
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './e2e',
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env['CI'],
|
|
||||||
retries: process.env['CI'] ? 2 : 0,
|
|
||||||
workers: process.env['CI'] ? 1 : undefined,
|
|
||||||
reporter: 'html',
|
|
||||||
use: {
|
|
||||||
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
screenshot: 'only-on-failure',
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Do NOT auto-start the dev server — tests assume it is already running.
|
|
||||||
// webServer is intentionally omitted so tests can run against a live env.
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
'@tailwindcss/postcss': {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { GuestGuard } from '@/components/guest-guard';
|
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<GuestGuard>
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
|
|
||||||
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GuestGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { signIn } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
|
||||||
const router = useRouter();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(e.currentTarget);
|
|
||||||
const email = form.get('email') as string;
|
|
||||||
const password = form.get('password') as string;
|
|
||||||
|
|
||||||
const result = await signIn.email({ email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Sign in failed');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push('/chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link href="/register" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { signUp } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export default function RegisterPage(): React.ReactElement {
|
|
||||||
const router = useRouter();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const form = new FormData(e.currentTarget);
|
|
||||||
const name = form.get('name') as string;
|
|
||||||
const email = form.get('email') as string;
|
|
||||||
const password = form.get('password') as string;
|
|
||||||
|
|
||||||
const result = await signUp.email({ name, email, password });
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
setError(result.error.message ?? 'Registration failed');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push('/chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">Create account</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="Your name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? 'Creating account...' : 'Create account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-center text-sm text-text-muted">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link href="/login" className="text-blue-400 hover:text-blue-300">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { AdminRoleGuard } from '@/components/admin-role-guard';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
banned: boolean;
|
|
||||||
banReason: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserListDto {
|
|
||||||
users: UserDto[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceStatusDto {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderStatusDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthStatusDto {
|
|
||||||
status: 'ok' | 'degraded' | 'error';
|
|
||||||
database: ServiceStatusDto;
|
|
||||||
cache: ServiceStatusDto;
|
|
||||||
agentPool: { activeSessions: number };
|
|
||||||
providers: ProviderStatusDto[];
|
|
||||||
checkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function AdminPage(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<AdminRoleGuard>
|
|
||||||
<AdminContent />
|
|
||||||
</AdminRoleGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminContent(): React.ReactElement {
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{(['users', 'health'] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
|
||||||
activeTab === tab
|
|
||||||
? 'border-b-2 border-blue-500 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:text-text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab === 'users' ? 'User Management' : 'System Health'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function UsersTab(): React.ReactElement {
|
|
||||||
const [users, setUsers] = useState<UserDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await api<UserListDto>('/api/admin/users');
|
|
||||||
setUsers(data.users);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadUsers();
|
|
||||||
}, [loadUsers]);
|
|
||||||
|
|
||||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
|
||||||
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}/role`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { role: newRole },
|
|
||||||
});
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to update role');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBanToggle(user: UserDto): Promise<void> {
|
|
||||||
const endpoint = user.banned ? 'unban' : 'ban';
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(user: UserDto): Promise<void> {
|
|
||||||
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading users...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadUsers()}
|
|
||||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
+ New User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<CreateUserForm
|
|
||||||
onCancel={() => setShowCreate(false)}
|
|
||||||
onCreated={() => {
|
|
||||||
setShowCreate(false);
|
|
||||||
void loadUsers();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No users found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Name / Email</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Role</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
|
||||||
<div className="text-xs text-text-muted">{user.email}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
|
||||||
user.role === 'admin'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: 'bg-surface-elevated text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-3 md:table-cell">
|
|
||||||
{user.banned ? (
|
|
||||||
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
|
||||||
Banned
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleRoleToggle(user)}
|
|
||||||
className="text-xs text-blue-400 hover:text-blue-300"
|
|
||||||
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
|
||||||
>
|
|
||||||
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleBanToggle(user)}
|
|
||||||
className={cn(
|
|
||||||
'text-xs',
|
|
||||||
user.banned
|
|
||||||
? 'text-green-400 hover:text-green-300'
|
|
||||||
: 'text-yellow-400 hover:text-yellow-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.banned ? 'Unban' : 'Ban'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleDelete(user)}
|
|
||||||
className="text-xs text-red-400 hover:text-red-300"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create User Form ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface CreateUserFormProps {
|
|
||||||
onCancel: () => void;
|
|
||||||
onCreated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [role, setRole] = useState('member');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await api('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name, email, password, role },
|
|
||||||
});
|
|
||||||
onCreated();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
|
||||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
|
||||||
<select
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="member">member</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? 'Creating...' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Health Tab ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function HealthTab(): React.ReactElement {
|
|
||||||
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadHealth = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await api<HealthStatusDto>('/api/admin/health');
|
|
||||||
setHealth(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load health');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadHealth();
|
|
||||||
}, [loadHealth]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHealth()}
|
|
||||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!health) return <></>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={health.status} />
|
|
||||||
<span className="text-sm text-text-muted">
|
|
||||||
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHealth()}
|
|
||||||
className="text-xs text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
{/* Database */}
|
|
||||||
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
|
||||||
{health.database.latencyMs !== undefined && (
|
|
||||||
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
|
||||||
)}
|
|
||||||
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Cache */}
|
|
||||||
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
|
||||||
{health.cache.latencyMs !== undefined && (
|
|
||||||
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
|
||||||
)}
|
|
||||||
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Agent Pool */}
|
|
||||||
<HealthCard title="Agent Pool" status="ok">
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
Active sessions: {health.agentPool.activeSessions}
|
|
||||||
</p>
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Providers */}
|
|
||||||
<HealthCard
|
|
||||||
title="LLM Providers"
|
|
||||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
|
||||||
>
|
|
||||||
{health.providers.length === 0 ? (
|
|
||||||
<p className="text-xs text-text-muted">No providers configured</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{health.providers.map((p) => (
|
|
||||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-text-secondary">{p.name}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-1.5 py-0.5',
|
|
||||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</HealthCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
|
||||||
const map = {
|
|
||||||
ok: 'bg-green-500/20 text-green-400',
|
|
||||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
|
||||||
error: 'bg-red-500/20 text-red-400',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthCardProps {
|
|
||||||
title: string;
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
|
||||||
<span
|
|
||||||
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { destroySocket, getSocket } from '@/lib/socket';
|
|
||||||
import type { Conversation, Message } from '@/lib/types';
|
|
||||||
import { ConversationList } from '@/components/chat/conversation-list';
|
|
||||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
|
||||||
import { ChatInput } from '@/components/chat/chat-input';
|
|
||||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
|
||||||
|
|
||||||
export default function ChatPage(): React.ReactElement {
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [streamingText, setStreamingText] = useState('');
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Track the active conversation ID in a ref so socket event handlers always
|
|
||||||
// see the current value without needing to be re-registered.
|
|
||||||
const activeIdRef = useRef<string | null>(null);
|
|
||||||
activeIdRef.current = activeId;
|
|
||||||
|
|
||||||
// Accumulate streamed text in a ref so agent:end can read the full content
|
|
||||||
// without stale-closure issues.
|
|
||||||
const streamingTextRef = useRef('');
|
|
||||||
|
|
||||||
// Load conversations on mount
|
|
||||||
useEffect(() => {
|
|
||||||
api<Conversation[]>('/api/conversations')
|
|
||||||
.then(setConversations)
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load messages when active conversation changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeId) {
|
|
||||||
setMessages([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Clear streaming state when switching conversations
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
api<Message[]>(`/api/conversations/${activeId}/messages`)
|
|
||||||
.then(setMessages)
|
|
||||||
.catch(() => {});
|
|
||||||
}, [activeId]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages, streamingText]);
|
|
||||||
|
|
||||||
// Socket.io setup — connect once for the page lifetime
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = getSocket();
|
|
||||||
|
|
||||||
function onAgentStart(data: { conversationId: string }): void {
|
|
||||||
// Only update state if the event belongs to the currently viewed conversation
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
|
||||||
setIsStreaming(true);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAgentText(data: { conversationId: string; text: string }): void {
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
|
||||||
streamingTextRef.current += data.text;
|
|
||||||
setStreamingText((prev) => prev + data.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAgentEnd(data: { conversationId: string }): void {
|
|
||||||
if (activeIdRef.current !== data.conversationId) return;
|
|
||||||
const finalText = streamingTextRef.current;
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
// Append the completed assistant message to the local message list.
|
|
||||||
// The Pi agent session is in-memory so the assistant response is not
|
|
||||||
// persisted to the DB — we build the local UI state instead.
|
|
||||||
if (finalText) {
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `assistant-${Date.now()}`,
|
|
||||||
conversationId: data.conversationId,
|
|
||||||
role: 'assistant' as const,
|
|
||||||
content: finalText,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onError(data: { error: string; conversationId?: string }): void {
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingText('');
|
|
||||||
streamingTextRef.current = '';
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `error-${Date.now()}`,
|
|
||||||
conversationId: data.conversationId ?? '',
|
|
||||||
role: 'system' as const,
|
|
||||||
content: `Error: ${data.error}`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('agent:start', onAgentStart);
|
|
||||||
socket.on('agent:text', onAgentText);
|
|
||||||
socket.on('agent:end', onAgentEnd);
|
|
||||||
socket.on('error', onError);
|
|
||||||
|
|
||||||
// Connect if not already connected
|
|
||||||
if (!socket.connected) {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.off('agent:start', onAgentStart);
|
|
||||||
socket.off('agent:text', onAgentText);
|
|
||||||
socket.off('agent:end', onAgentEnd);
|
|
||||||
socket.off('error', onError);
|
|
||||||
// Fully tear down the socket when the chat page unmounts so we get a
|
|
||||||
// fresh authenticated connection next time the page is visited.
|
|
||||||
destroySocket();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNewConversation = useCallback(async () => {
|
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title: 'New conversation' },
|
|
||||||
});
|
|
||||||
setConversations((prev) => [conv, ...prev]);
|
|
||||||
setActiveId(conv.id);
|
|
||||||
setMessages([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRename = useCallback(async (id: string, title: string) => {
|
|
||||||
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { title },
|
|
||||||
});
|
|
||||||
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
|
||||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
|
||||||
if (activeId === id) {
|
|
||||||
setActiveId(null);
|
|
||||||
setMessages([]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleArchive = useCallback(
|
|
||||||
async (id: string, archived: boolean) => {
|
|
||||||
const updated = await api<Conversation>(`/api/conversations/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { archived },
|
|
||||||
});
|
|
||||||
setConversations((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
|
||||||
// If archiving the active conversation, deselect it
|
|
||||||
if (archived && activeId === id) {
|
|
||||||
setActiveId(null);
|
|
||||||
setMessages([]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSend = useCallback(
|
|
||||||
async (content: string) => {
|
|
||||||
let convId = activeId;
|
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
|
||||||
if (!convId) {
|
|
||||||
const autoTitle = content.slice(0, 60);
|
|
||||||
const conv = await api<Conversation>('/api/conversations', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { title: autoTitle },
|
|
||||||
});
|
|
||||||
setConversations((prev) => [conv, ...prev]);
|
|
||||||
setActiveId(conv.id);
|
|
||||||
convId = conv.id;
|
|
||||||
} else {
|
|
||||||
// Auto-title: if the active conversation still has the default "New
|
|
||||||
// conversation" title and this is the first message, update the title
|
|
||||||
// from the message content.
|
|
||||||
const activeConv = conversations.find((c) => c.id === convId);
|
|
||||||
if (activeConv?.title === 'New conversation' && messages.length === 0) {
|
|
||||||
const autoTitle = content.slice(0, 60);
|
|
||||||
api<Conversation>(`/api/conversations/${convId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { title: autoTitle },
|
|
||||||
})
|
|
||||||
.then((updated) => {
|
|
||||||
setConversations((prev) => prev.map((c) => (c.id === convId ? updated : c)));
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optimistic user message in local UI state
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: `user-${Date.now()}`,
|
|
||||||
conversationId: convId,
|
|
||||||
role: 'user' as const,
|
|
||||||
content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Persist the user message to the DB so conversation history is
|
|
||||||
// available when the page is reloaded or a new session starts.
|
|
||||||
api<Message>(`/api/conversations/${convId}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { role: 'user', content },
|
|
||||||
}).catch(() => {
|
|
||||||
// Non-fatal: the agent can still process the message even if
|
|
||||||
// REST persistence fails.
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send to WebSocket — gateway creates/resumes the agent session and
|
|
||||||
// streams the response back via agent:start / agent:text / agent:end.
|
|
||||||
const socket = getSocket();
|
|
||||||
if (!socket.connected) {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
socket.emit('message', { conversationId: convId, content });
|
|
||||||
},
|
|
||||||
[activeId, conversations, messages],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
|
|
||||||
<ConversationList
|
|
||||||
conversations={conversations}
|
|
||||||
activeId={activeId}
|
|
||||||
onSelect={setActiveId}
|
|
||||||
onNew={handleNewConversation}
|
|
||||||
onRename={handleRename}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onArchive={handleArchive}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
{activeId ? (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<MessageBubble key={msg.id} message={msg} />
|
|
||||||
))}
|
|
||||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
<ChatInput onSend={handleSend} disabled={isStreaming} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
|
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
|
||||||
Select a conversation or start a new one
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNewConversation}
|
|
||||||
className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Start new conversation
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { AppShell } from '@/components/layout/app-shell';
|
|
||||||
import { AuthGuard } from '@/components/auth-guard';
|
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<AuthGuard>
|
|
||||||
<AppShell>{children}</AppShell>
|
|
||||||
</AuthGuard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
|
||||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
|
||||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
|
||||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
|
||||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
|
||||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
|
||||||
|
|
||||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
active: 'bg-success/20 text-success',
|
|
||||||
paused: 'bg-warning/20 text-warning',
|
|
||||||
completed: 'bg-blue-600/20 text-blue-400',
|
|
||||||
archived: 'bg-gray-600/20 text-gray-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TabButtonProps {
|
|
||||||
id: Tab;
|
|
||||||
label: string;
|
|
||||||
activeTab: Tab;
|
|
||||||
onClick: (tab: Tab) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onClick(id)}
|
|
||||||
className={cn(
|
|
||||||
'border-b-2 px-4 py-2 text-sm transition-colors',
|
|
||||||
activeTab === id
|
|
||||||
? 'border-text-primary text-text-primary'
|
|
||||||
: 'border-transparent text-text-muted hover:text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectDetailPage(): React.ReactElement {
|
|
||||||
const params = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const id = typeof params['id'] === 'string' ? params['id'] : '';
|
|
||||||
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
const [missions, setMissions] = useState<Mission[]>([]);
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
|
||||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
Promise.all([
|
|
||||||
api<Project>(`/api/projects/${id}`),
|
|
||||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
|
||||||
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
|
|
||||||
])
|
|
||||||
.then(([proj, allMissions, tks]) => {
|
|
||||||
setProject(proj);
|
|
||||||
setMissions(allMissions.filter((m) => m.projectId === id));
|
|
||||||
setTasks(tks);
|
|
||||||
})
|
|
||||||
.catch((err: Error) => {
|
|
||||||
setError(err.message ?? 'Failed to load project');
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const handleTaskClick = useCallback((task: Task) => {
|
|
||||||
setSelectedTask(task);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseTaskModal = useCallback(() => {
|
|
||||||
setSelectedTask(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="py-16 text-center">
|
|
||||||
<p className="text-sm text-text-muted">Loading project...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !project) {
|
|
||||||
return (
|
|
||||||
<div className="py-16 text-center">
|
|
||||||
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/projects')}
|
|
||||||
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
Back to projects
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
|
|
||||||
|
|
||||||
const prdContent = getPrdContent(project);
|
|
||||||
const hasPrd = Boolean(prdContent);
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
|
||||||
{ id: 'overview', label: 'Overview' },
|
|
||||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
|
||||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
|
||||||
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push('/projects')}
|
|
||||||
className="hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
Projects
|
|
||||||
</button>
|
|
||||||
<span>/</span>
|
|
||||||
<span className="text-text-primary">{project.name}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Project header */}
|
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-2 py-0.5 text-xs',
|
|
||||||
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{project.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{project.description && (
|
|
||||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-text-muted">
|
|
||||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
|
||||||
{new Date(project.updatedAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats bar */}
|
|
||||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
||||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
|
||||||
<StatCard
|
|
||||||
label="Done"
|
|
||||||
value={String(tasks.filter((t) => t.status === 'done').length)}
|
|
||||||
valueClass="text-success"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="In Progress"
|
|
||||||
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
|
|
||||||
valueClass="text-blue-400"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Blocked"
|
|
||||||
value={String(tasks.filter((t) => t.status === 'blocked').length)}
|
|
||||||
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="mb-6 flex gap-0 border-b border-surface-border">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<TabButton
|
|
||||||
key={tab.id}
|
|
||||||
id={tab.id}
|
|
||||||
label={tab.label}
|
|
||||||
activeTab={activeTab}
|
|
||||||
onClick={setActiveTab}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'tasks' && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<TaskStatusSummary
|
|
||||||
tasks={tasks}
|
|
||||||
activeFilter={taskFilter}
|
|
||||||
onFilterChange={setTaskFilter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
|
|
||||||
|
|
||||||
{activeTab === 'prd' && prdContent && (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
|
||||||
<PrdViewer content={prdContent} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task detail modal */}
|
|
||||||
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverviewTabProps {
|
|
||||||
project: Project;
|
|
||||||
missions: Mission[];
|
|
||||||
tasks: Task[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
|
|
||||||
const recentTasks = [...tasks]
|
|
||||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* Recent tasks */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
|
||||||
{recentTasks.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{recentTasks.map((task) => (
|
|
||||||
<TaskSummaryRow key={task.id} task={task} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mission summary */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
|
||||||
{missions.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No missions yet</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<MissionTimeline missions={missions.slice(0, 4)} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
{project.metadata && Object.keys(project.metadata).length > 0 && (
|
|
||||||
<section className="lg:col-span-2">
|
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<pre className="overflow-x-auto text-xs text-text-muted">
|
|
||||||
{JSON.stringify(project.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskStatusColors: Record<string, string> = {
|
|
||||||
'not-started': 'bg-gray-600/20 text-gray-300',
|
|
||||||
'in-progress': 'bg-blue-600/20 text-blue-400',
|
|
||||||
blocked: 'bg-error/20 text-error',
|
|
||||||
done: 'bg-success/20 text-success',
|
|
||||||
cancelled: 'bg-gray-600/20 text-gray-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
|
|
||||||
<span className="truncate text-sm text-text-primary">{task.title}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded-full px-2 py-0.5 text-xs',
|
|
||||||
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{task.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
valueClass,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
valueClass?: string;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
|
|
||||||
<p className="text-xs text-text-muted">{label}</p>
|
|
||||||
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPrdContent(project: Project): string | null {
|
|
||||||
if (!project.metadata) return null;
|
|
||||||
|
|
||||||
const prd = project.metadata['prd'];
|
|
||||||
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
|
|
||||||
|
|
||||||
const prdContent = project.metadata['prdContent'];
|
|
||||||
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import type { Project } from '@/lib/types';
|
|
||||||
import { ProjectCard } from '@/components/projects/project-card';
|
|
||||||
|
|
||||||
export default function ProjectsPage(): React.ReactElement {
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Project[]>('/api/projects')
|
|
||||||
.then(setProjects)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleProjectClick = useCallback(
|
|
||||||
(project: Project) => {
|
|
||||||
router.push(`/projects/${project.id}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
|
||||||
) : projects.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
|
||||||
Projects will appear here when created via the gateway API
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mission status section */}
|
|
||||||
<MissionStatus />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MissionStatus(): React.ReactElement {
|
|
||||||
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Record<string, unknown>>('/api/coord/status')
|
|
||||||
.then(setMission)
|
|
||||||
.catch(() => setMission(null))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mt-8">
|
|
||||||
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading mission status...</p>
|
|
||||||
) : !mission ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No active mission detected</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
|
|
||||||
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
|
|
||||||
<StatCard
|
|
||||||
label="Tasks"
|
|
||||||
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
|
|
||||||
/>
|
|
||||||
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg bg-surface-elevated p-3">
|
|
||||||
<p className="text-xs text-text-muted">{label}</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,808 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ModelInfo {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
inputTypes: ('text' | 'image')[];
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
models: ModelInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestConnectionResult {
|
|
||||||
providerId: string;
|
|
||||||
reachable: boolean;
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
discoveredModels?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
|
||||||
|
|
||||||
interface ProviderTestStatus {
|
|
||||||
state: TestState;
|
|
||||||
result?: TestConnectionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Preference {
|
|
||||||
key: string;
|
|
||||||
value: unknown;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
||||||
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
|
||||||
const p = prefs.find((x) => x.key === key);
|
|
||||||
if (p === undefined) return fallback;
|
|
||||||
return p.value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
|
||||||
{ id: 'profile', label: 'Profile' },
|
|
||||||
{ id: 'appearance', label: 'Appearance' },
|
|
||||||
{ id: 'notifications', label: 'Notifications' },
|
|
||||||
{ id: 'providers', label: 'Providers' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
|
||||||
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-b-2 border-accent text-accent'
|
|
||||||
: 'text-text-secondary hover:text-text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'profile' && <ProfileTab session={session} />}
|
|
||||||
{activeTab === 'appearance' && <AppearanceTab />}
|
|
||||||
{activeTab === 'notifications' && <NotificationsTab />}
|
|
||||||
{activeTab === 'providers' && <ProvidersTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProfileTab({
|
|
||||||
session,
|
|
||||||
}: {
|
|
||||||
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const [name, setName] = useState(session?.user.name ?? '');
|
|
||||||
const [image, setImage] = useState(session?.user.image ?? '');
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
// Sync from session when it loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user) {
|
|
||||||
setName(session.user.name ?? '');
|
|
||||||
setImage(session.user.image ?? '');
|
|
||||||
}
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
const result = await authClient.updateUser({ name, image: image || null });
|
|
||||||
if (result.error) {
|
|
||||||
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
|
||||||
setSaveState('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
|
||||||
<FormField label="Display Name" id="profile-name">
|
|
||||||
<input
|
|
||||||
id="profile-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Your name"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Email" id="profile-email">
|
|
||||||
<input
|
|
||||||
id="profile-email"
|
|
||||||
type="email"
|
|
||||||
value={session?.user.email ?? ''}
|
|
||||||
disabled
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Avatar URL" id="profile-image">
|
|
||||||
<input
|
|
||||||
id="profile-image"
|
|
||||||
type="url"
|
|
||||||
value={image}
|
|
||||||
onChange={(e) => setImage(e.target.value)}
|
|
||||||
placeholder="https://example.com/avatar.png"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AppearanceTab(): React.ReactElement {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [theme, setTheme] = useState<Theme>('system');
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
||||||
const [defaultModel, setDefaultModel] = useState('');
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
|
||||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
|
||||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'ui.sidebar_collapsed',
|
|
||||||
value: sidebarCollapsed,
|
|
||||||
category: 'appearance',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(defaultModel
|
|
||||||
? [
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'ui.default_model',
|
|
||||||
value: defaultModel,
|
|
||||||
category: 'appearance',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
||||||
{/* Theme */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme(t)}
|
|
||||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
|
||||||
theme === t
|
|
||||||
? 'border-accent bg-accent/10 text-accent'
|
|
||||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar collapsed default */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
|
||||||
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default model */}
|
|
||||||
<FormField label="Default Model" id="default-model">
|
|
||||||
<input
|
|
||||||
id="default-model"
|
|
||||||
type="text"
|
|
||||||
value={defaultModel}
|
|
||||||
onChange={(e) => setDefaultModel(e.target.value)}
|
|
||||||
placeholder="e.g. ollama/llama3.2"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">
|
|
||||||
Model ID to pre-select for new conversations.
|
|
||||||
</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function NotificationsTab(): React.ReactElement {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
|
||||||
const [emailMentions, setEmailMentions] = useState(true);
|
|
||||||
const [emailDigest, setEmailDigest] = useState(false);
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
|
||||||
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
|
||||||
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_agent_complete',
|
|
||||||
value: emailAgentComplete,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_mentions',
|
|
||||||
value: emailMentions,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_digest',
|
|
||||||
value: emailDigest,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
||||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
|
||||||
|
|
||||||
<NotifyRow
|
|
||||||
label="Agent task completed"
|
|
||||||
description="Email when an agent finishes a task"
|
|
||||||
checked={emailAgentComplete}
|
|
||||||
onChange={setEmailAgentComplete}
|
|
||||||
/>
|
|
||||||
<NotifyRow
|
|
||||||
label="Mentions"
|
|
||||||
description="Email when you are mentioned in a conversation"
|
|
||||||
checked={emailMentions}
|
|
||||||
onChange={setEmailMentions}
|
|
||||||
/>
|
|
||||||
<NotifyRow
|
|
||||||
label="Weekly digest"
|
|
||||||
description="Weekly summary of activity"
|
|
||||||
checked={emailDigest}
|
|
||||||
onChange={setEmailDigest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProvidersTab(): React.ReactElement {
|
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<ProviderInfo[]>('/api/providers')
|
|
||||||
.catch(() => [] as ProviderInfo[])
|
|
||||||
.then((p) => setProviders(p))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: { state: 'testing' },
|
|
||||||
}));
|
|
||||||
try {
|
|
||||||
const result = await api<TestConnectionResult>('/api/providers/test', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { providerId },
|
|
||||||
});
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: {
|
|
||||||
state: 'error',
|
|
||||||
result: { providerId, reachable: false, error: 'Request failed' },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const defaultModel: ModelInfo | undefined = providers
|
|
||||||
.flatMap((p) => p.models)
|
|
||||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
|
||||||
) : providers.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
No providers configured. Set{' '}
|
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">OLLAMA_BASE_URL</code>{' '}
|
|
||||||
or{' '}
|
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
||||||
MOSAIC_CUSTOM_PROVIDERS
|
|
||||||
</code>{' '}
|
|
||||||
to add providers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{providers.map((provider) => (
|
|
||||||
<ProviderCard
|
|
||||||
key={provider.id}
|
|
||||||
provider={provider}
|
|
||||||
defaultModel={defaultModel}
|
|
||||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
|
||||||
onTest={() => void testConnection(provider.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function FormField({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
|
||||||
checked ? 'bg-accent' : 'bg-surface-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
checked ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotifyRow({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
|
||||||
<p className="text-xs text-text-muted">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={checked} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveButton({
|
|
||||||
state,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
state: SaveState;
|
|
||||||
onClick: () => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={state === 'saving'}
|
|
||||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Provider Card (from original page) ──────────────────────────────────────
|
|
||||||
|
|
||||||
interface ProviderCardProps {
|
|
||||||
provider: ProviderInfo;
|
|
||||||
defaultModel: ModelInfo | undefined;
|
|
||||||
testStatus: ProviderTestStatus;
|
|
||||||
onTest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderCard({
|
|
||||||
provider,
|
|
||||||
defaultModel,
|
|
||||||
testStatus,
|
|
||||||
onTest,
|
|
||||||
}: ProviderCardProps): React.ReactElement {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card">
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ProviderAvatar id={provider.id} />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
|
||||||
<ProviderStatusBadge available={provider.available} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TestConnectionButton status={testStatus} onTest={onTest} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
|
||||||
>
|
|
||||||
{expanded ? '▲ Hide' : '▼ Models'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test result banner */}
|
|
||||||
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
|
||||||
<TestResultBanner result={testStatus.result} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Model list */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Model</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Default</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{provider.models.map((model) => (
|
|
||||||
<ModelRow
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
isDefault={
|
|
||||||
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelRowProps {
|
|
||||||
model: ModelInfo;
|
|
||||||
isDefault: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<tr className="border-t border-surface-border">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className="text-sm text-text-primary">{model.name}</span>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 md:table-cell">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<CapabilityBadge label="chat" />
|
|
||||||
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
|
||||||
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{formatContext(model.contextWindow)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{model.cost.input === 0 && model.cost.output === 0
|
|
||||||
? 'free'
|
|
||||||
: `$${model.cost.input} / $${model.cost.output}`}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
{isDefault && (
|
|
||||||
<span
|
|
||||||
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
|
||||||
title="Default model used for new sessions"
|
|
||||||
>
|
|
||||||
default
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
|
||||||
const letter = id.charAt(0).toUpperCase();
|
|
||||||
return (
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
|
||||||
{letter}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
||||||
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{available ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestConnectionButtonProps {
|
|
||||||
status: ProviderTestStatus;
|
|
||||||
onTest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
|
||||||
const isTesting = status.state === 'testing';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onTest}
|
|
||||||
disabled={isTesting}
|
|
||||||
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
title="Test connection"
|
|
||||||
>
|
|
||||||
{isTesting ? (
|
|
||||||
<span className="text-text-muted">Testing…</span>
|
|
||||||
) : status.state === 'success' ? (
|
|
||||||
<span className="text-success">✓ Reachable</span>
|
|
||||||
) : status.state === 'error' ? (
|
|
||||||
<span className="text-error">✗ Unreachable</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-muted">Test</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-2 text-xs ${
|
|
||||||
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{result.reachable ? (
|
|
||||||
<>
|
|
||||||
Connected
|
|
||||||
{result.latencyMs !== undefined && (
|
|
||||||
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
|
||||||
)}
|
|
||||||
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
|
||||||
<span className="ml-2 opacity-70">
|
|
||||||
— {result.discoveredModels.length} model
|
|
||||||
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CapabilityBadge({
|
|
||||||
label,
|
|
||||||
color = 'default',
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
color?: 'default' | 'purple' | 'blue';
|
|
||||||
}): React.ReactElement {
|
|
||||||
const colorClass =
|
|
||||||
color === 'purple'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: color === 'blue'
|
|
||||||
? 'bg-blue-500/20 text-blue-400'
|
|
||||||
: 'bg-surface-elevated text-text-muted';
|
|
||||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContext(tokens: number): string {
|
|
||||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
||||||
return String(tokens);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Task } from '@/lib/types';
|
|
||||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
|
||||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
|
||||||
|
|
||||||
type ViewMode = 'list' | 'kanban';
|
|
||||||
|
|
||||||
export default function TasksPage(): React.ReactElement {
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
|
||||||
const [view, setView] = useState<ViewMode>('kanban');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Task[]>('/api/tasks')
|
|
||||||
.then(setTasks)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTaskClick = useCallback((task: Task) => {
|
|
||||||
// Task detail view will be added in future iteration
|
|
||||||
console.log('Task clicked:', task.id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex rounded-lg border border-surface-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView('list')}
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-1.5 text-xs transition-colors',
|
|
||||||
view === 'list'
|
|
||||||
? 'bg-surface-elevated text-text-primary'
|
|
||||||
: 'text-text-muted hover:text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
List
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView('kanban')}
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-1.5 text-xs transition-colors',
|
|
||||||
view === 'kanban'
|
|
||||||
? 'bg-surface-elevated text-text-primary'
|
|
||||||
: 'text-text-muted hover:text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Kanban
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
|
||||||
) : view === 'kanban' ? (
|
|
||||||
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
|
|
||||||
) : (
|
|
||||||
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Mosaic Stack design tokens mapped to Tailwind v4 theme.
|
|
||||||
* Source: @mosaic/design-tokens (AD-13)
|
|
||||||
* Fonts: Outfit (sans), Fira Code (mono)
|
|
||||||
* Palette: deep blue-grays + blue/purple/teal accents
|
|
||||||
* Default: dark theme
|
|
||||||
*/
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
/* ─── Fonts ─── */
|
|
||||||
--font-sans: 'Outfit', system-ui, -apple-system, sans-serif;
|
|
||||||
--font-mono: 'Fira Code', ui-monospace, Menlo, monospace;
|
|
||||||
|
|
||||||
/* ─── Neutral blue-gray scale ─── */
|
|
||||||
--color-gray-50: #f0f2f5;
|
|
||||||
--color-gray-100: #dce0e8;
|
|
||||||
--color-gray-200: #b8c0cc;
|
|
||||||
--color-gray-300: #8e99a9;
|
|
||||||
--color-gray-400: #6b7a8d;
|
|
||||||
--color-gray-500: #4e5d70;
|
|
||||||
--color-gray-600: #3b4859;
|
|
||||||
--color-gray-700: #2a3544;
|
|
||||||
--color-gray-800: #1c2433;
|
|
||||||
--color-gray-900: #111827;
|
|
||||||
--color-gray-950: #0a0f1a;
|
|
||||||
|
|
||||||
/* ─── Primary — blue ─── */
|
|
||||||
--color-blue-50: #eff4ff;
|
|
||||||
--color-blue-100: #dae5ff;
|
|
||||||
--color-blue-200: #bdd1ff;
|
|
||||||
--color-blue-300: #8fb4ff;
|
|
||||||
--color-blue-400: #5b8bff;
|
|
||||||
--color-blue-500: #3b6cf7;
|
|
||||||
--color-blue-600: #2551e0;
|
|
||||||
--color-blue-700: #1d40c0;
|
|
||||||
--color-blue-800: #1e369c;
|
|
||||||
--color-blue-900: #1e317b;
|
|
||||||
--color-blue-950: #162050;
|
|
||||||
|
|
||||||
/* ─── Accent — purple ─── */
|
|
||||||
--color-purple-50: #f3f0ff;
|
|
||||||
--color-purple-100: #e7dfff;
|
|
||||||
--color-purple-200: #d2c3ff;
|
|
||||||
--color-purple-300: #b49aff;
|
|
||||||
--color-purple-400: #9466ff;
|
|
||||||
--color-purple-500: #7c3aed;
|
|
||||||
--color-purple-600: #6d28d9;
|
|
||||||
--color-purple-700: #5b21b6;
|
|
||||||
--color-purple-800: #4c1d95;
|
|
||||||
--color-purple-900: #3b1578;
|
|
||||||
--color-purple-950: #230d4d;
|
|
||||||
|
|
||||||
/* ─── Accent — teal ─── */
|
|
||||||
--color-teal-50: #effcf9;
|
|
||||||
--color-teal-100: #d0f7ef;
|
|
||||||
--color-teal-200: #a4eddf;
|
|
||||||
--color-teal-300: #6fddcb;
|
|
||||||
--color-teal-400: #3ec5b2;
|
|
||||||
--color-teal-500: #25aa99;
|
|
||||||
--color-teal-600: #1c897e;
|
|
||||||
--color-teal-700: #1b6e66;
|
|
||||||
--color-teal-800: #1a5853;
|
|
||||||
--color-teal-900: #194945;
|
|
||||||
--color-teal-950: #082d2b;
|
|
||||||
|
|
||||||
/* ─── Semantic surface tokens ─── */
|
|
||||||
--color-surface-bg: #0a0f1a;
|
|
||||||
--color-surface-card: #111827;
|
|
||||||
--color-surface-elevated: #1c2433;
|
|
||||||
--color-surface-border: #2a3544;
|
|
||||||
|
|
||||||
/* ─── Semantic text tokens ─── */
|
|
||||||
--color-text-primary: #f0f2f5;
|
|
||||||
--color-text-secondary: #8e99a9;
|
|
||||||
--color-text-muted: #6b7a8d;
|
|
||||||
|
|
||||||
/* ─── Status colors ─── */
|
|
||||||
--color-success: #22c55e;
|
|
||||||
--color-warning: #f59e0b;
|
|
||||||
--color-error: #ef4444;
|
|
||||||
--color-info: #3b82f6;
|
|
||||||
|
|
||||||
/* ─── Sidebar width ─── */
|
|
||||||
--spacing-sidebar: 16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Base styles ─── */
|
|
||||||
body {
|
|
||||||
background-color: var(--color-surface-bg);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Scrollbar styling ─── */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--color-gray-600);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-gray-500);
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,13 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Outfit, Fira_Code } from 'next/font/google';
|
|
||||||
import './globals.css';
|
|
||||||
|
|
||||||
const outfit = Outfit({
|
|
||||||
subsets: ['latin'],
|
|
||||||
variable: '--font-sans',
|
|
||||||
display: 'swap',
|
|
||||||
weight: ['300', '400', '500', '600', '700'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const firaCode = Fira_Code({
|
|
||||||
subsets: ['latin'],
|
|
||||||
variable: '--font-mono',
|
|
||||||
display: 'swap',
|
|
||||||
weight: ['400', '500', '700'],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Mosaic',
|
title: 'Mosaic',
|
||||||
description: 'Mosaic Stack Dashboard',
|
description: 'Mosaic Stack Dashboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
|
export default function RootLayout({ children }: { children: ReactNode }): ReactNode {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`dark ${outfit.variable} ${firaCode.variable}`}>
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from 'next/navigation';
|
export default function HomePage(): React.ReactElement {
|
||||||
|
return (
|
||||||
export default function HomePage(): never {
|
<main>
|
||||||
redirect('/chat');
|
<h1>Mosaic Stack</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface AdminRoleGuardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminRoleGuard({ children }: AdminRoleGuardProps): React.ReactElement | null {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const user = session?.user as
|
|
||||||
| (NonNullable<typeof session>['user'] & { role?: string })
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPending && !session) {
|
|
||||||
router.replace('/login');
|
|
||||||
} else if (!isPending && session && user?.role !== 'admin') {
|
|
||||||
router.replace('/');
|
|
||||||
}
|
|
||||||
}, [isPending, session, user?.role, router]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session || user?.role !== 'admin') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface AuthGuardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthGuard({ children }: AuthGuardProps): React.ReactElement | null {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPending && !session) {
|
|
||||||
router.replace('/login');
|
|
||||||
}
|
|
||||||
}, [isPending, session, router]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
|
||||||
onSend: (content: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
|
|
||||||
const [value, setValue] = useState('');
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent): void {
|
|
||||||
e.preventDefault();
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed || disabled) return;
|
|
||||||
onSend(trimmed);
|
|
||||||
setValue('');
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={disabled}
|
|
||||||
rows={1}
|
|
||||||
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
|
|
||||||
className="max-h-32 min-h-[2.5rem] flex-1 resize-none rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={disabled || !value.trim()}
|
|
||||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Conversation } from '@/lib/types';
|
|
||||||
|
|
||||||
interface ConversationListProps {
|
|
||||||
conversations: Conversation[];
|
|
||||||
activeId: string | null;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onNew: () => void;
|
|
||||||
onRename: (id: string, title: string) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
onArchive: (id: string, archived: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContextMenuState {
|
|
||||||
conversationId: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
||||||
|
|
||||||
if (diffMinutes < 1) return 'Just now';
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
|
||||||
if (diffDays === 1) return 'Yesterday';
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
|
||||||
return date.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConversationList({
|
|
||||||
conversations,
|
|
||||||
activeId,
|
|
||||||
onSelect,
|
|
||||||
onNew,
|
|
||||||
onRename,
|
|
||||||
onDelete,
|
|
||||||
onArchive,
|
|
||||||
}: ConversationListProps): React.ReactElement {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
||||||
const [renameValue, setRenameValue] = useState('');
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const activeConversations = conversations.filter((c) => !c.archived);
|
|
||||||
const archivedConversations = conversations.filter((c) => c.archived);
|
|
||||||
|
|
||||||
const filteredActive = searchQuery
|
|
||||||
? activeConversations.filter((c) =>
|
|
||||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
: activeConversations;
|
|
||||||
|
|
||||||
const filteredArchived = searchQuery
|
|
||||||
? archivedConversations.filter((c) =>
|
|
||||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
: archivedConversations;
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeContextMenu = useCallback(() => {
|
|
||||||
setContextMenu(null);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startRename = useCallback(
|
|
||||||
(id: string, currentTitle: string | null) => {
|
|
||||||
setRenamingId(id);
|
|
||||||
setRenameValue(currentTitle ?? '');
|
|
||||||
closeContextMenu();
|
|
||||||
setTimeout(() => renameInputRef.current?.focus(), 0);
|
|
||||||
},
|
|
||||||
[closeContextMenu],
|
|
||||||
);
|
|
||||||
|
|
||||||
const commitRename = useCallback(() => {
|
|
||||||
if (renamingId) {
|
|
||||||
const trimmed = renameValue.trim();
|
|
||||||
onRename(renamingId, trimmed || 'Untitled');
|
|
||||||
}
|
|
||||||
setRenamingId(null);
|
|
||||||
setRenameValue('');
|
|
||||||
}, [renamingId, renameValue, onRename]);
|
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
|
||||||
setRenamingId(null);
|
|
||||||
setRenameValue('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRenameKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === 'Enter') commitRename();
|
|
||||||
if (e.key === 'Escape') cancelRename();
|
|
||||||
},
|
|
||||||
[commitRename, cancelRename],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteClick = useCallback((id: string) => {
|
|
||||||
setDeleteConfirmId(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const confirmDelete = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
onDelete(id);
|
|
||||||
setDeleteConfirmId(null);
|
|
||||||
closeContextMenu();
|
|
||||||
},
|
|
||||||
[onDelete, closeContextMenu],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleArchiveToggle = useCallback(
|
|
||||||
(id: string, archived: boolean) => {
|
|
||||||
onArchive(id, archived);
|
|
||||||
closeContextMenu();
|
|
||||||
},
|
|
||||||
[onArchive, closeContextMenu],
|
|
||||||
);
|
|
||||||
|
|
||||||
const contextConv = contextMenu
|
|
||||||
? conversations.find((c) => c.id === contextMenu.conversationId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
function renderConversationItem(conv: Conversation): React.ReactElement {
|
|
||||||
const isActive = activeId === conv.id;
|
|
||||||
const isRenaming = renamingId === conv.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={conv.id} className="group relative">
|
|
||||||
{isRenaming ? (
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<input
|
|
||||||
ref={renameInputRef}
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
onBlur={commitRename}
|
|
||||||
onKeyDown={handleRenameKeyDown}
|
|
||||||
className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(conv.id)}
|
|
||||||
onDoubleClick={() => startRename(conv.id, conv.title)}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
|
||||||
className={cn(
|
|
||||||
'w-full px-3 py-2 text-left text-sm transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-600/20 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:bg-surface-elevated',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
|
||||||
<span className="block text-xs text-text-muted">
|
|
||||||
{formatRelativeTime(conv.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Backdrop to close context menu */}
|
|
||||||
{contextMenu && (
|
|
||||||
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-3">
|
|
||||||
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onNew}
|
|
||||||
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
|
||||||
>
|
|
||||||
+ New
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search input */}
|
|
||||||
<div className="px-3 pb-2">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search conversations\u2026"
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conversation list */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{filteredActive.length === 0 && !searchQuery && (
|
|
||||||
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
|
||||||
)}
|
|
||||||
{filteredActive.length === 0 && searchQuery && (
|
|
||||||
<p className="px-3 py-2 text-xs text-text-muted">
|
|
||||||
No results for “{searchQuery}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{filteredActive.map((conv) => renderConversationItem(conv))}
|
|
||||||
|
|
||||||
{/* Archived section */}
|
|
||||||
{archivedConversations.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowArchived((v) => !v)}
|
|
||||||
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-block transition-transform',
|
|
||||||
showArchived ? 'rotate-90' : '',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
►
|
|
||||||
</span>
|
|
||||||
Archived ({archivedConversations.length})
|
|
||||||
</button>
|
|
||||||
{showArchived && (
|
|
||||||
<div className="opacity-60">
|
|
||||||
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Context menu */}
|
|
||||||
{contextMenu && contextConv && (
|
|
||||||
<div
|
|
||||||
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
|
|
||||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
|
||||||
onClick={() => startRename(contextConv.id, contextConv.title)}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
|
||||||
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
|
|
||||||
>
|
|
||||||
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
|
||||||
</button>
|
|
||||||
<hr className="my-1 border-surface-border" />
|
|
||||||
{deleteConfirmId === contextConv.id ? (
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
|
|
||||||
onClick={() => confirmDelete(contextConv.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-surface-elevated"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
|
||||||
onClick={() => handleDeleteClick(contextConv.id)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Message } from '@/lib/types';
|
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
|
||||||
message: Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
|
||||||
const isUser = message.role === 'user';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
|
|
||||||
isUser
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'border border-surface-border bg-surface-elevated text-text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
|
||||||
<div
|
|
||||||
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
|
|
||||||
>
|
|
||||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/** Renders an in-progress assistant message from streaming text. */
|
|
||||||
interface StreamingMessageProps {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
|
|
||||||
{text ? (
|
|
||||||
<div className="whitespace-pre-wrap break-words">{text}</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 text-text-muted">
|
|
||||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" />
|
|
||||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.2s]" />
|
|
||||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.4s]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
|
|
||||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
|
||||||
{text ? 'Responding...' : 'Thinking...'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSession } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
interface GuestGuardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Redirects authenticated users away from auth pages. */
|
|
||||||
export function GuestGuard({ children }: GuestGuardProps): React.ReactElement | null {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPending && session) {
|
|
||||||
router.replace('/chat');
|
|
||||||
}
|
|
||||||
}, [isPending, session, router]);
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Sidebar } from './sidebar';
|
|
||||||
import { Topbar } from './topbar';
|
|
||||||
|
|
||||||
interface AppShellProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Sidebar />
|
|
||||||
<div className="pl-sidebar">
|
|
||||||
<Topbar />
|
|
||||||
<main className="p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{ label: 'Chat', href: '/chat', icon: '💬' },
|
|
||||||
{ label: 'Tasks', href: '/tasks', icon: '📋' },
|
|
||||||
{ label: 'Projects', href: '/projects', icon: '📁' },
|
|
||||||
{ label: 'Settings', href: '/settings', icon: '⚙️' },
|
|
||||||
{ label: 'Admin', href: '/admin', icon: '🛡️' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar(): React.ReactElement {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-sidebar flex-col border-r border-surface-border bg-surface-card">
|
|
||||||
<div className="flex h-14 items-center px-4">
|
|
||||||
<Link href="/" className="text-lg font-semibold text-text-primary">
|
|
||||||
Mosaic
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 px-2 py-2">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-600/20 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-base" aria-hidden="true">
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="border-t border-surface-border p-4">
|
|
||||||
<p className="text-xs text-text-muted">Mosaic Stack v0.0.4</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useSession, signOut } from '@/lib/auth-client';
|
|
||||||
|
|
||||||
export function Topbar(): React.ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function handleSignOut(): Promise<void> {
|
|
||||||
await signOut();
|
|
||||||
router.replace('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-surface-border bg-surface-card/80 px-6 backdrop-blur-sm">
|
|
||||||
<div />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{session?.user ? (
|
|
||||||
<>
|
|
||||||
<span className="text-sm text-text-secondary">
|
|
||||||
{session.user.name ?? session.user.email}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-text-muted">Not signed in</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
import type { Mission, MissionStatus } from '@/lib/types';
|
|
||||||
|
|
||||||
interface MissionTimelineProps {
|
|
||||||
missions: Mission[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusOrder: MissionStatus[] = ['planning', 'active', 'paused', 'completed', 'failed'];
|
|
||||||
|
|
||||||
const statusStyles: Record<MissionStatus, { dot: string; label: string; badge: string }> = {
|
|
||||||
planning: {
|
|
||||||
dot: 'bg-gray-400',
|
|
||||||
label: 'text-gray-400',
|
|
||||||
badge: 'bg-gray-600/20 text-gray-300',
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
dot: 'bg-blue-400',
|
|
||||||
label: 'text-blue-400',
|
|
||||||
badge: 'bg-blue-600/20 text-blue-400',
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
dot: 'bg-warning',
|
|
||||||
label: 'text-warning',
|
|
||||||
badge: 'bg-warning/20 text-warning',
|
|
||||||
},
|
|
||||||
completed: {
|
|
||||||
dot: 'bg-success',
|
|
||||||
label: 'text-success',
|
|
||||||
badge: 'bg-success/20 text-success',
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
dot: 'bg-error',
|
|
||||||
label: 'text-error',
|
|
||||||
badge: 'bg-error/20 text-error',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function sortMissions(missions: Mission[]): Mission[] {
|
|
||||||
return [...missions].sort((a, b) => {
|
|
||||||
const aIdx = statusOrder.indexOf(a.status);
|
|
||||||
const bIdx = statusOrder.indexOf(b.status);
|
|
||||||
if (aIdx !== bIdx) return aIdx - bIdx;
|
|
||||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MissionTimeline({ missions }: MissionTimelineProps): React.ReactElement {
|
|
||||||
if (missions.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No missions for this project</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = sortMissions(missions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ol className="relative space-y-0">
|
|
||||||
{sorted.map((mission, idx) => {
|
|
||||||
const styles = statusStyles[mission.status] ?? statusStyles.planning;
|
|
||||||
const isLast = idx === sorted.length - 1;
|
|
||||||
return (
|
|
||||||
<li key={mission.id} className="relative flex gap-4 pb-6 last:pb-0">
|
|
||||||
{/* Vertical line */}
|
|
||||||
{!isLast && <div className="absolute left-2.5 top-5 h-full w-px bg-surface-border" />}
|
|
||||||
{/* Status dot */}
|
|
||||||
<div
|
|
||||||
className={cn('mt-1 h-5 w-5 shrink-0 rounded-full border-2 border-bg', styles.dot)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 rounded-lg border border-surface-border bg-surface-card p-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<span className="text-sm font-medium text-text-primary">{mission.name}</span>
|
|
||||||
<span className={cn('shrink-0 rounded-full px-2 py-0.5 text-xs', styles.badge)}>
|
|
||||||
{mission.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{mission.description && (
|
|
||||||
<p className="mt-1 text-xs text-text-muted">{mission.description}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-text-muted">
|
|
||||||
Created {new Date(mission.createdAt).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user