Compare commits
1 Commits
6c3ede5c86
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 66dd3ee995 |
29
.env.example
29
.env.example
@@ -62,15 +62,9 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
||||
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||
|
||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
||||
# OpenAI — required for embedding and log-summarization features
|
||||
# OPENAI_API_KEY=sk-...
|
||||
|
||||
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
|
||||
# ZAI_API_KEY=...
|
||||
|
||||
# Custom providers — JSON array of provider configs
|
||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||
# MOSAIC_CUSTOM_PROVIDERS=
|
||||
@@ -129,26 +123,7 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||
|
||||
|
||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
||||
|
||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
||||
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||
# AUTHENTIK_CLIENT_ID=
|
||||
# AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
|
||||
# WORKOS_ISSUER=https://your-company.authkit.app
|
||||
# WORKOS_CLIENT_ID=client_...
|
||||
# WORKOS_CLIENT_SECRET=sk_live_...
|
||||
|
||||
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
|
||||
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
# Legacy alternative if you prefer to compose the issuer from separate vars:
|
||||
# KEYCLOAK_URL=https://auth.example.com
|
||||
# KEYCLOAK_REALM=master
|
||||
# KEYCLOAK_CLIENT_ID=mosaic
|
||||
# KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
|
||||
# NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@@ -53,28 +53,3 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
||||
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||
- NodeNext module resolution in all tsconfigs
|
||||
- Scratchpads are mandatory for non-trivial tasks
|
||||
|
||||
## docs/TASKS.md — Schema (CANONICAL)
|
||||
|
||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||
|
||||
| Value | When to use | Budget |
|
||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||
|
||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||
|
||||
**Full schema:**
|
||||
|
||||
```
|
||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||
```
|
||||
|
||||
- `status`: `not-started` | `in-progress` | `done` | `failed` | `blocked` | `needs-qa`
|
||||
- `agent`: model value from table above (set before spawning)
|
||||
- `estimate`: token budget e.g. `8K`, `25K`
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
|
||||
import { ProviderService } from '../provider.service.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ZAI_API_KEY',
|
||||
'OLLAMA_BASE_URL',
|
||||
'OLLAMA_HOST',
|
||||
'OLLAMA_MODELS',
|
||||
'MOSAIC_CUSTOM_PROVIDERS',
|
||||
] as const;
|
||||
|
||||
type EnvKey = (typeof ENV_KEYS)[number];
|
||||
|
||||
describe('ProviderService', () => {
|
||||
const savedEnv = new Map<EnvKey, string | undefined>();
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
savedEnv.set(key, process.env[key]);
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('skips API-key providers when env vars are missing (no models become available)', () => {
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
// Pi's built-in registry may include model definitions for all providers, but
|
||||
// without API keys none of them should be available (usable).
|
||||
const availableModels = service.listAvailableModels();
|
||||
const availableProviderIds = new Set(availableModels.map((m) => m.provider));
|
||||
|
||||
expect(availableProviderIds).not.toContain('anthropic');
|
||||
expect(availableProviderIds).not.toContain('openai');
|
||||
expect(availableProviderIds).not.toContain('zai');
|
||||
|
||||
// Providers list may show built-in providers, but they should not be marked available
|
||||
const providers = service.listProviders();
|
||||
for (const p of providers.filter((p) => ['anthropic', 'openai', 'zai'].includes(p.id))) {
|
||||
expect(p.available).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers Anthropic provider with correct models when ANTHROPIC_API_KEY is set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providers = service.listProviders();
|
||||
const anthropic = providers.find((p) => p.id === 'anthropic');
|
||||
expect(anthropic).toBeDefined();
|
||||
expect(anthropic!.available).toBe(true);
|
||||
expect(anthropic!.models.map((m) => m.id)).toEqual([
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6',
|
||||
'claude-haiku-4-5',
|
||||
]);
|
||||
// contextWindow override from Pi built-in (200000)
|
||||
for (const m of anthropic!.models) {
|
||||
expect(m.contextWindow).toBe(200000);
|
||||
// maxTokens capped at 8192 per task spec
|
||||
expect(m.maxTokens).toBe(8192);
|
||||
}
|
||||
});
|
||||
|
||||
it('registers OpenAI provider with correct models when OPENAI_API_KEY is set', () => {
|
||||
process.env['OPENAI_API_KEY'] = 'test-openai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providers = service.listProviders();
|
||||
const openai = providers.find((p) => p.id === 'openai');
|
||||
expect(openai).toBeDefined();
|
||||
expect(openai!.available).toBe(true);
|
||||
expect(openai!.models.map((m) => m.id)).toEqual(['gpt-4o', 'gpt-4o-mini', 'o3-mini']);
|
||||
});
|
||||
|
||||
it('registers Z.ai provider with correct models when ZAI_API_KEY is set', () => {
|
||||
process.env['ZAI_API_KEY'] = 'test-zai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providers = service.listProviders();
|
||||
const zai = providers.find((p) => p.id === 'zai');
|
||||
expect(zai).toBeDefined();
|
||||
expect(zai!.available).toBe(true);
|
||||
expect(zai!.models.map((m) => m.id)).toEqual(['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash']);
|
||||
});
|
||||
|
||||
it('registers all three providers when all keys are set', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
|
||||
process.env['OPENAI_API_KEY'] = 'test-openai';
|
||||
process.env['ZAI_API_KEY'] = 'test-zai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const providerIds = service.listProviders().map((p) => p.id);
|
||||
expect(providerIds).toContain('anthropic');
|
||||
expect(providerIds).toContain('openai');
|
||||
expect(providerIds).toContain('zai');
|
||||
});
|
||||
|
||||
it('can find registered Anthropic models by provider+id', () => {
|
||||
process.env['ANTHROPIC_API_KEY'] = 'test-anthropic';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const sonnet = service.findModel('anthropic', 'claude-sonnet-4-6');
|
||||
expect(sonnet).toBeDefined();
|
||||
expect(sonnet!.provider).toBe('anthropic');
|
||||
expect(sonnet!.id).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('can find registered Z.ai models by provider+id', () => {
|
||||
process.env['ZAI_API_KEY'] = 'test-zai';
|
||||
|
||||
const service = new ProviderService();
|
||||
service.onModuleInit();
|
||||
|
||||
const glm = service.findModel('zai', 'glm-4.5');
|
||||
expect(glm).toBeDefined();
|
||||
expect(glm!.provider).toBe('zai');
|
||||
expect(glm!.id).toBe('glm-4.5');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
||||
import { getModel, type Model, type Api } from '@mariozechner/pi-ai';
|
||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||
|
||||
@@ -14,9 +14,6 @@ export class ProviderService implements OnModuleInit {
|
||||
this.registry = new ModelRegistry(authStorage);
|
||||
|
||||
this.registerOllamaProvider();
|
||||
this.registerAnthropicProvider();
|
||||
this.registerOpenAIProvider();
|
||||
this.registerZaiProvider();
|
||||
this.registerCustomProviders();
|
||||
|
||||
const available = this.registry.getAvailable();
|
||||
@@ -143,66 +140,6 @@ export class ProviderService implements OnModuleInit {
|
||||
this.logger.log(`Registered custom provider: ${config.id} (${config.models.length} models)`);
|
||||
}
|
||||
|
||||
private registerAnthropicProvider(): void {
|
||||
const apiKey = process.env['ANTHROPIC_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping Anthropic provider registration: ANTHROPIC_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'].map((id) =>
|
||||
this.cloneBuiltInModel('anthropic', id, { maxTokens: 8192 }),
|
||||
);
|
||||
|
||||
this.registry.registerProvider('anthropic', {
|
||||
apiKey,
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
models,
|
||||
});
|
||||
|
||||
this.logger.log('Anthropic provider registered with 3 models');
|
||||
}
|
||||
|
||||
private registerOpenAIProvider(): void {
|
||||
const apiKey = process.env['OPENAI_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = ['gpt-4o', 'gpt-4o-mini', 'o3-mini'].map((id) =>
|
||||
this.cloneBuiltInModel('openai', id),
|
||||
);
|
||||
|
||||
this.registry.registerProvider('openai', {
|
||||
apiKey,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
models,
|
||||
});
|
||||
|
||||
this.logger.log('OpenAI provider registered with 3 models');
|
||||
}
|
||||
|
||||
private registerZaiProvider(): void {
|
||||
const apiKey = process.env['ZAI_API_KEY'];
|
||||
if (!apiKey) {
|
||||
this.logger.debug('Skipping Z.ai provider registration: ZAI_API_KEY not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = ['glm-4.5', 'glm-4.5-air', 'glm-4.5-flash'].map((id) =>
|
||||
this.cloneBuiltInModel('zai', id),
|
||||
);
|
||||
|
||||
this.registry.registerProvider('zai', {
|
||||
apiKey,
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
models,
|
||||
});
|
||||
|
||||
this.logger.log('Z.ai provider registered with 3 models');
|
||||
}
|
||||
|
||||
private registerOllamaProvider(): void {
|
||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||
if (!ollamaUrl) return;
|
||||
@@ -247,19 +184,6 @@ export class ProviderService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private cloneBuiltInModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
overrides: Partial<Model<Api>> = {},
|
||||
): Model<Api> {
|
||||
const model = getModel(provider as never, modelId as never) as Model<Api> | undefined;
|
||||
if (!model) {
|
||||
throw new Error(`Built-in model not found: ${provider}:${modelId}`);
|
||||
}
|
||||
|
||||
return { ...model, ...overrides };
|
||||
}
|
||||
|
||||
private toModelInfo(model: Model<Api>): ModelInfo {
|
||||
return {
|
||||
id: model.id,
|
||||
|
||||
@@ -36,24 +36,16 @@ export class SessionGCService implements OnModuleInit {
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
// Fire-and-forget: run full GC asynchronously so it does not block the
|
||||
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||
// depending on Valkey key count; deferring it removes that latency from
|
||||
// the TTFB of the first HTTP request.
|
||||
this.fullCollect()
|
||||
.then((result) => {
|
||||
this.logger.log(
|
||||
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||
`${result.logsDemoted} logs demoted, ` +
|
||||
`${result.jobsPurged} jobs purged, ` +
|
||||
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||
`(${result.duration}ms)`,
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
|
||||
});
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.logger.log('Running full GC on cold start...');
|
||||
const result = await this.fullCollect();
|
||||
this.logger.log(
|
||||
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||
`${result.logsDemoted} logs demoted, ` +
|
||||
`${result.jobsPurged} jobs purged, ` +
|
||||
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||
`(${result.duration}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,28 +5,34 @@ import type { Db } from '@mosaic/db';
|
||||
/**
|
||||
* Build a mock Drizzle DB where the select chain supports:
|
||||
* db.select().from().where() → resolves to `listRows`
|
||||
* db.insert().values().onConflictDoUpdate() → resolves to []
|
||||
* db.select().from().where().limit(n) → resolves to `singleRow`
|
||||
*/
|
||||
function makeMockDb(listRows: Array<{ key: string; value: unknown }> = []): Db {
|
||||
function makeMockDb(
|
||||
listRows: Array<{ key: string; value: unknown }> = [],
|
||||
singleRow: Array<{ id: string }> = [],
|
||||
): Db {
|
||||
const chainWithLimit = {
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
limit: vi.fn().mockResolvedValue(singleRow),
|
||||
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||
};
|
||||
const selectFrom = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||
};
|
||||
const updateResult = {
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const deleteResult = {
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
// Single-round-trip upsert chain: insert().values().onConflictDoUpdate()
|
||||
const insertResult = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue([]),
|
||||
values: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
return {
|
||||
select: vi.fn().mockReturnValue(selectFrom),
|
||||
update: vi.fn().mockReturnValue(updateResult),
|
||||
delete: vi.fn().mockReturnValue(deleteResult),
|
||||
insert: vi.fn().mockReturnValue(insertResult),
|
||||
} as unknown as Db;
|
||||
@@ -92,14 +98,23 @@ describe('PreferencesService', () => {
|
||||
expect(result.message).toContain('platform enforcement');
|
||||
});
|
||||
|
||||
it('upserts a mutable preference and returns success', async () => {
|
||||
// Single-round-trip INSERT … ON CONFLICT DO UPDATE path.
|
||||
const db = makeMockDb([]);
|
||||
it('upserts a mutable preference and returns success — insert path', async () => {
|
||||
// singleRow=[] → no existing row → insert path
|
||||
const db = makeMockDb([], []);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||
});
|
||||
|
||||
it('upserts a mutable preference and returns success — update path', async () => {
|
||||
// singleRow has an id → existing row → update path
|
||||
const db = makeMockDb([], [{ id: 'existing-id' }]);
|
||||
const service = new PreferencesService(db);
|
||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'low');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { eq, and, sql, type Db, preferences as preferencesTable } from '@mosaic/db';
|
||||
import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
||||
@@ -88,24 +88,25 @@ export class PreferencesService {
|
||||
}
|
||||
|
||||
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
||||
// Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE.
|
||||
// Previously this was two queries (SELECT + INSERT/UPDATE), which doubled
|
||||
// the DB round-trips and introduced a TOCTOU window under concurrent writes.
|
||||
await this.db
|
||||
.insert(preferencesTable)
|
||||
.values({
|
||||
const existing = await this.db
|
||||
.select({ id: preferencesTable.id })
|
||||
.from(preferencesTable)
|
||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await this.db
|
||||
.update(preferencesTable)
|
||||
.set({ value: value as never, updatedAt: new Date() })
|
||||
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)));
|
||||
} else {
|
||||
await this.db.insert(preferencesTable).values({
|
||||
userId,
|
||||
key,
|
||||
value: value as never,
|
||||
mutable: true,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [preferencesTable.userId, preferencesTable.key],
|
||||
set: {
|
||||
value: sql`excluded.value`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,30 +3,6 @@ import type { NextConfig } from 'next';
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['@mosaic/design-tokens'],
|
||||
|
||||
// Enable gzip/brotli compression for all responses.
|
||||
compress: true,
|
||||
|
||||
// Reduce bundle size: disable source maps in production builds.
|
||||
productionBrowserSourceMaps: false,
|
||||
|
||||
// Image optimisation: allow the gateway origin as an external image source.
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Experimental: enable React compiler for automatic memoisation (Next 15+).
|
||||
// Falls back gracefully if the compiler plugin is not installed.
|
||||
experimental: {
|
||||
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
|
||||
// turbo: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const ssoProviders = getEnabledSsoProviders();
|
||||
const hasSsoProviders = ssoProviders.length > 0;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
@@ -47,26 +44,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSsoProviders && (
|
||||
<div className="mt-6 space-y-3">
|
||||
{ssoProviders.map((provider) => (
|
||||
<Link
|
||||
key={provider.id}
|
||||
href={provider.href}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-surface-border bg-surface-elevated px-4 py-2.5 text-sm font-medium text-text-primary transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card"
|
||||
>
|
||||
{provider.buttonLabel}
|
||||
</Link>
|
||||
))}
|
||||
<div className="relative flex items-center">
|
||||
<div className="flex-1 border-t border-surface-border" />
|
||||
<span className="mx-3 text-xs text-text-muted">or</span>
|
||||
<div className="flex-1 border-t border-surface-border" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className={hasSsoProviders ? 'space-y-4' : 'mt-6 space-y-4'} onSubmit={handleSubmit}>
|
||||
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||
Email
|
||||
|
||||
@@ -4,42 +4,18 @@ 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 {
|
||||
ConversationSidebar,
|
||||
type ConversationSidebarRef,
|
||||
} from '@/components/chat/conversation-sidebar';
|
||||
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';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
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 [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [selectedModelId, setSelectedModelId] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
||||
|
||||
// Track the active conversation ID in a ref so socket event handlers always
|
||||
// see the current value without needing to be re-registered.
|
||||
@@ -50,30 +26,11 @@ export default function ChatPage(): React.ReactElement {
|
||||
// without stale-closure issues.
|
||||
const streamingTextRef = useRef('');
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
||||
if (savedState !== null) {
|
||||
setIsSidebarOpen(savedState === 'true');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
api<ProviderInfo[]>('/api/providers')
|
||||
.then((providers) => {
|
||||
const availableModels = providers
|
||||
.filter((provider) => provider.available)
|
||||
.flatMap((provider) => provider.models);
|
||||
setModels(availableModels);
|
||||
setSelectedModelId((current) => current || availableModels[0]?.id || '');
|
||||
})
|
||||
.catch(() => {
|
||||
setModels([]);
|
||||
setSelectedModelId('');
|
||||
});
|
||||
api<Conversation[]>('/api/conversations')
|
||||
.then(setConversations)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load messages when active conversation changes
|
||||
@@ -134,7 +91,6 @@ export default function ChatPage(): React.ReactElement {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
sidebarRef.current?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,27 +131,58 @@ export default function ChatPage(): React.ReactElement {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNewConversation = useCallback(async (projectId?: string | null) => {
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
const conv = await api<Conversation>('/api/conversations', {
|
||||
method: 'POST',
|
||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
||||
body: { title: 'New conversation' },
|
||||
});
|
||||
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
|
||||
setConversations((prev) => [conv, ...prev]);
|
||||
setActiveId(conv.id);
|
||||
setMessages([]);
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
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) => {
|
||||
try {
|
||||
await api<void>(`/api/conversations/${id}`, { method: 'DELETE' });
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
if (activeId === id) {
|
||||
setActiveId(null);
|
||||
setMessages([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ChatPage] Failed to delete conversation:', err);
|
||||
}
|
||||
},
|
||||
[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, options?: { modelId?: string }) => {
|
||||
async (content: string) => {
|
||||
let convId = activeId;
|
||||
|
||||
// Auto-create conversation if none selected
|
||||
@@ -205,24 +192,25 @@ export default function ChatPage(): React.ReactElement {
|
||||
method: 'POST',
|
||||
body: { title: autoTitle },
|
||||
});
|
||||
sidebarRef.current?.addConversation({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
projectId: conv.projectId,
|
||||
updatedAt: conv.updatedAt,
|
||||
archived: conv.archived,
|
||||
});
|
||||
setConversations((prev) => [conv, ...prev]);
|
||||
setActiveId(conv.id);
|
||||
convId = conv.id;
|
||||
} else if (messages.length === 0) {
|
||||
// Auto-title the initial placeholder conversation from the first user message.
|
||||
const autoTitle = content.slice(0, 60);
|
||||
api<Conversation>(`/api/conversations/${convId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title: autoTitle },
|
||||
})
|
||||
.then(() => sidebarRef.current?.refresh())
|
||||
.catch(() => {});
|
||||
} 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
|
||||
@@ -253,67 +241,24 @@ export default function ChatPage(): React.ReactElement {
|
||||
if (!socket.connected) {
|
||||
socket.connect();
|
||||
}
|
||||
socket.emit('message', {
|
||||
conversationId: convId,
|
||||
content,
|
||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
||||
});
|
||||
socket.emit('message', { conversationId: convId, content });
|
||||
},
|
||||
[activeId, messages, selectedModelId],
|
||||
[activeId, conversations, messages],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
||||
>
|
||||
<ConversationSidebar
|
||||
ref={sidebarRef}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
currentConversationId={activeId}
|
||||
onSelectConversation={(conversationId) => {
|
||||
setActiveId(conversationId);
|
||||
setMessages([]);
|
||||
if (conversationId && window.innerWidth < 768) {
|
||||
setIsSidebarOpen(false);
|
||||
}
|
||||
}}
|
||||
onNewConversation={(projectId) => {
|
||||
void handleNewConversation(projectId);
|
||||
}}
|
||||
<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 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen((open) => !open)}
|
||||
className="rounded-lg border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Mosaic Chat
|
||||
</h1>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
{activeId ? (
|
||||
<>
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
||||
@@ -323,36 +268,19 @@ export default function ChatPage(): React.ReactElement {
|
||||
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
isStreaming={isStreaming}
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
onModelChange={setSelectedModelId}
|
||||
/>
|
||||
<ChatInput onSend={handleSend} disabled={isStreaming} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-6">
|
||||
<div
|
||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
|
||||
Welcome to Mosaic Chat
|
||||
</h2>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
|
||||
<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={() => {
|
||||
void handleNewConversation();
|
||||
}}
|
||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ background: 'var(--primary)' }}
|
||||
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>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { getSsoProvider } from '@/lib/sso-providers';
|
||||
|
||||
export default function AuthProviderRedirectPage(): React.ReactElement {
|
||||
const params = useParams<{ provider: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const providerId = typeof params.provider === 'string' ? params.provider : '';
|
||||
const provider = getSsoProvider(providerId);
|
||||
const callbackURL = searchParams.get('callbackURL') ?? '/chat';
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentProvider = provider;
|
||||
|
||||
if (!currentProvider) {
|
||||
setError('Unknown SSO provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProvider.enabled) {
|
||||
setError(`${currentProvider.buttonLabel} is not enabled in this deployment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProvider = currentProvider;
|
||||
let cancelled = false;
|
||||
|
||||
async function redirectToProvider(): Promise<void> {
|
||||
const result = await signIn.oauth2({
|
||||
providerId: activeProvider.id,
|
||||
callbackURL,
|
||||
});
|
||||
|
||||
if (!cancelled && result?.error) {
|
||||
setError(result.error.message ?? `${activeProvider.buttonLabel} sign in failed.`);
|
||||
}
|
||||
}
|
||||
|
||||
void redirectToProvider();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [callbackURL, provider]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{provider
|
||||
? `Redirecting you to ${provider.buttonLabel.replace('Continue with ', '')}...`
|
||||
: 'Preparing your sign-in request...'}
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error">
|
||||
<p>{error}</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Return to login
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
|
||||
If the redirect does not start automatically, return to the login page and try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,22 +86,6 @@
|
||||
--spacing-sidebar: 16rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: var(--color-surface-bg);
|
||||
--bg-deep: var(--color-gray-950);
|
||||
--surface: var(--color-surface-card);
|
||||
--surface-2: var(--color-surface-elevated);
|
||||
--border: var(--color-surface-border);
|
||||
--text: var(--color-text-primary);
|
||||
--text-2: var(--color-text-secondary);
|
||||
--muted: var(--color-text-muted);
|
||||
--primary: var(--color-blue-500);
|
||||
--danger: var(--color-error);
|
||||
--ms-blue-500: var(--color-blue-500);
|
||||
--sidebar-w: 260px;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ─── Base styles ─── */
|
||||
body {
|
||||
background-color: var(--color-surface-bg);
|
||||
|
||||
@@ -1,192 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ModelInfo } from '@/lib/types';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string, options?: { modelId?: string }) => void;
|
||||
onStop?: () => void;
|
||||
isStreaming?: boolean;
|
||||
models: ModelInfo[];
|
||||
selectedModelId: string;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onRequestEditLastMessage?: () => string | null;
|
||||
onSend: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MAX_HEIGHT = 220;
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
isStreaming = false,
|
||||
models,
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
onRequestEditLastMessage,
|
||||
}: ChatInputProps): React.ReactElement {
|
||||
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((model) => model.id === selectedModelId) ?? models[0],
|
||||
[models, selectedModelId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, MAX_HEIGHT)}px`;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleGlobalFocus(event: KeyboardEvent): void {
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
(event.key === '/' || event.key.toLowerCase() === 'k')
|
||||
) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||
event.preventDefault();
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleGlobalFocus);
|
||||
return () => document.removeEventListener('keydown', handleGlobalFocus);
|
||||
}, []);
|
||||
|
||||
function handleSubmit(event: React.FormEvent): void {
|
||||
event.preventDefault();
|
||||
function handleSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
onSend(trimmed, { modelId: selectedModel?.id });
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue('');
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) {
|
||||
const lastMessage = onRequestEditLastMessage();
|
||||
if (lastMessage) {
|
||||
event.preventDefault();
|
||||
setValue(lastMessage);
|
||||
}
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}
|
||||
|
||||
const charCount = value.length;
|
||||
const tokenEstimate = Math.ceil(charCount / 4);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="border-t px-4 py-4 backdrop-blur-xl md:px-6"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<label className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-muted)]">
|
||||
<span className="uppercase tracking-[0.18em]">Model</span>
|
||||
<select
|
||||
value={selectedModelId}
|
||||
onChange={(event) => onModelChange(event.target.value)}
|
||||
className="rounded-full border px-3 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<option key={`${model.provider}:${model.id}`} value={model.id}>
|
||||
{model.name} · {model.provider}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto hidden items-center gap-2 text-xs text-[var(--color-muted)] md:flex">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
||||
⌘/ focus
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
||||
⌘K focus
|
||||
</span>
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-1">
|
||||
⌘↵ send
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
placeholder="Ask Mosaic something..."
|
||||
className="min-h-[3.25rem] flex-1 resize-none bg-transparent px-1 py-2 text-sm outline-none placeholder:text-[var(--color-muted)] disabled:opacity-60"
|
||||
style={{
|
||||
color: 'var(--color-text)',
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full border px-4 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-[var(--color-danger)]" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim()}
|
||||
className="inline-flex h-11 items-center gap-2 rounded-full px-4 text-sm font-semibold text-white transition-all disabled:cursor-not-allowed disabled:opacity-45"
|
||||
style={{ backgroundColor: 'var(--color-ms-blue-500)' }}
|
||||
>
|
||||
<span>Send</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-muted)]">
|
||||
<span>{charCount.toLocaleString()} chars</span>
|
||||
<span>·</span>
|
||||
<span>~{tokenEstimate.toLocaleString()} tokens</span>
|
||||
{selectedModel ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{selectedModel.reasoning ? 'Reasoning on' : 'Fast response'}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="ml-auto">Shift+Enter newline · Arrow ↑ edit last</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -7,8 +7,6 @@ import type { Conversation } from '@/lib/types';
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
activeId: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
@@ -22,6 +20,7 @@ interface ContextMenuState {
|
||||
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();
|
||||
@@ -41,8 +40,6 @@ function formatRelativeTime(dateStr: string): string {
|
||||
export function ConversationList({
|
||||
conversations,
|
||||
activeId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
onNew,
|
||||
onRename,
|
||||
@@ -57,24 +54,24 @@ export function ConversationList({
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const activeConversations = conversations.filter((conversation) => !conversation.archived);
|
||||
const archivedConversations = conversations.filter((conversation) => conversation.archived);
|
||||
const activeConversations = conversations.filter((c) => !c.archived);
|
||||
const archivedConversations = conversations.filter((c) => c.archived);
|
||||
|
||||
const filteredActive = searchQuery
|
||||
? activeConversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
? activeConversations.filter((c) =>
|
||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: activeConversations;
|
||||
|
||||
const filteredArchived = searchQuery
|
||||
? archivedConversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
? archivedConversations.filter((c) =>
|
||||
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: archivedConversations;
|
||||
|
||||
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({ conversationId, x: event.clientX, y: event.clientY });
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
|
||||
setDeleteConfirmId(null);
|
||||
}, []);
|
||||
|
||||
@@ -100,7 +97,7 @@ export function ConversationList({
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}, [onRename, renameValue, renamingId]);
|
||||
}, [renamingId, renameValue, onRename]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingId(null);
|
||||
@@ -108,20 +105,24 @@ export function ConversationList({
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') commitRename();
|
||||
if (event.key === 'Escape') cancelRename();
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
if (e.key === 'Escape') cancelRename();
|
||||
},
|
||||
[cancelRename, commitRename],
|
||||
[commitRename, cancelRename],
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteConfirmId(id);
|
||||
}, []);
|
||||
|
||||
const confirmDelete = useCallback(
|
||||
(id: string) => {
|
||||
onDelete(id);
|
||||
setDeleteConfirmId(null);
|
||||
closeContextMenu();
|
||||
},
|
||||
[closeContextMenu, onDelete],
|
||||
[onDelete, closeContextMenu],
|
||||
);
|
||||
|
||||
const handleArchiveToggle = useCallback(
|
||||
@@ -129,59 +130,47 @@ export function ConversationList({
|
||||
onArchive(id, archived);
|
||||
closeContextMenu();
|
||||
},
|
||||
[closeContextMenu, onArchive],
|
||||
[onArchive, closeContextMenu],
|
||||
);
|
||||
|
||||
const contextConversation = contextMenu
|
||||
? conversations.find((conversation) => conversation.id === contextMenu.conversationId)
|
||||
const contextConv = contextMenu
|
||||
? conversations.find((c) => c.id === contextMenu.conversationId)
|
||||
: null;
|
||||
|
||||
function renderConversationItem(conversation: Conversation): React.ReactElement {
|
||||
const isActive = activeId === conversation.id;
|
||||
const isRenaming = renamingId === conversation.id;
|
||||
function renderConversationItem(conv: Conversation): React.ReactElement {
|
||||
const isActive = activeId === conv.id;
|
||||
const isRenaming = renamingId === conv.id;
|
||||
|
||||
return (
|
||||
<div key={conversation.id} className="group relative">
|
||||
<div key={conv.id} className="group relative">
|
||||
{isRenaming ? (
|
||||
<div className="px-3 py-2">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
className="w-full rounded-xl border px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
borderColor: 'var(--color-ms-blue-500)',
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
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(conversation.id);
|
||||
if (window.innerWidth < 768) onClose();
|
||||
}}
|
||||
onDoubleClick={() => startRename(conversation.id, conversation.title)}
|
||||
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
|
||||
onClick={() => onSelect(conv.id)}
|
||||
onDoubleClick={() => startRename(conv.id, conv.title)}
|
||||
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors',
|
||||
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5',
|
||||
'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',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'color-mix(in srgb, var(--color-ms-blue-500) 22%, transparent)'
|
||||
: 'transparent',
|
||||
color: isActive ? 'var(--color-text)' : 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
<span className="block truncate font-medium">{conversation.title ?? 'Untitled'}</span>
|
||||
<span className="block text-xs text-[var(--color-muted)]">
|
||||
{formatRelativeTime(conversation.updatedAt)}
|
||||
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
||||
<span className="block text-xs text-text-muted">
|
||||
{formatRelativeTime(conv.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -191,138 +180,127 @@ export function ConversationList({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-20 bg-black/45 md:hidden"
|
||||
onClick={onClose}
|
||||
aria-label="Close conversation sidebar"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{contextMenu ? (
|
||||
{/* Backdrop to close context menu */}
|
||||
{contextMenu && (
|
||||
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-30 flex h-full w-[18.5rem] flex-col border-r px-3 py-3 transition-transform duration-200 md:static md:z-auto',
|
||||
isOpen
|
||||
? 'translate-x-0'
|
||||
: '-translate-x-full md:w-0 md:min-w-0 md:overflow-hidden md:border-r-0 md:px-0 md:py-0',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-1 pb-3">
|
||||
<h2 className="text-sm font-medium text-[var(--color-text-2)]">Conversations</h2>
|
||||
<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-full px-3 py-1 text-xs transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-ms-blue-400)' }}
|
||||
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
{/* Search input */}
|
||||
<div className="px-3 pb-2">
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search conversations…"
|
||||
className="w-full rounded-2xl border px-3 py-2 text-xs placeholder:text-[var(--color-muted)] focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-1">
|
||||
{filteredActive.length === 0 && !searchQuery ? (
|
||||
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p>
|
||||
) : null}
|
||||
{filteredActive.length === 0 && searchQuery ? (
|
||||
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">
|
||||
No results for “{searchQuery}”
|
||||
{/* 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>
|
||||
) : null}
|
||||
{filteredActive.map((conversation) => renderConversationItem(conversation))}
|
||||
)}
|
||||
{filteredActive.map((conv) => renderConversationItem(conv))}
|
||||
|
||||
{archivedConversations.length > 0 ? (
|
||||
<div className="pt-2">
|
||||
{/* Archived section */}
|
||||
{archivedConversations.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived((prev) => !prev)}
|
||||
className="flex w-full items-center gap-2 px-1 py-1 text-xs text-[var(--color-muted)] transition-colors hover:text-[var(--color-text-2)]"
|
||||
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')}
|
||||
className={cn(
|
||||
'inline-block transition-transform',
|
||||
showArchived ? 'rotate-90' : '',
|
||||
)}
|
||||
>
|
||||
▶
|
||||
►
|
||||
</span>
|
||||
Archived ({archivedConversations.length})
|
||||
</button>
|
||||
{showArchived ? (
|
||||
<div className="mt-1 space-y-1 opacity-70">
|
||||
{filteredArchived.map((conversation) => renderConversationItem(conversation))}
|
||||
{showArchived && (
|
||||
<div className="opacity-60">
|
||||
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextMenu && contextConversation ? (
|
||||
{/* Context menu */}
|
||||
{contextMenu && contextConv && (
|
||||
<div
|
||||
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
top: contextMenu.y,
|
||||
left: contextMenu.x,
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
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-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => startRename(contextConversation.id, contextConversation.title)}
|
||||
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-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() =>
|
||||
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
|
||||
}
|
||||
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)}
|
||||
>
|
||||
{contextConversation.archived ? 'Restore' : 'Archive'}
|
||||
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
{deleteConfirmId === contextConversation.id ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
||||
onClick={() => confirmDelete(contextConversation.id)}
|
||||
>
|
||||
Confirm delete
|
||||
</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-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setDeleteConfirmId(contextConversation.id)}
|
||||
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
||||
onClick={() => handleDeleteClick(contextConv.id)}
|
||||
>
|
||||
Delete…
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Conversation, Project } from '@/lib/types';
|
||||
|
||||
export interface ConversationSummary {
|
||||
id: string;
|
||||
title: string | null;
|
||||
projectId: string | null;
|
||||
updatedAt: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationSidebarRef {
|
||||
refresh: () => void;
|
||||
addConversation: (conversation: ConversationSummary) => void;
|
||||
}
|
||||
|
||||
interface ConversationSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (conversationId: string | null) => void;
|
||||
onNewConversation: (projectId?: string | null) => void;
|
||||
}
|
||||
|
||||
interface GroupedConversations {
|
||||
key: string;
|
||||
label: string;
|
||||
projectId: string | null;
|
||||
conversations: ConversationSummary[];
|
||||
}
|
||||
|
||||
function toSummary(conversation: Conversation): ConversationSummary {
|
||||
return {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
projectId: conversation.projectId,
|
||||
updatedAt: conversation.updatedAt,
|
||||
archived: conversation.archived,
|
||||
};
|
||||
}
|
||||
|
||||
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(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(
|
||||
function ConversationSidebar(
|
||||
{ isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation },
|
||||
ref,
|
||||
): React.ReactElement {
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadSidebarData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const [loadedConversations, loadedProjects] = await Promise.all([
|
||||
api<Conversation[]>('/api/conversations'),
|
||||
api<Project[]>('/api/projects').catch(() => [] as Project[]),
|
||||
]);
|
||||
|
||||
setConversations(
|
||||
loadedConversations
|
||||
.filter((conversation) => !conversation.archived)
|
||||
.map(toSummary)
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
||||
);
|
||||
setProjects(loadedProjects);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load conversations');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSidebarData();
|
||||
}, [loadSidebarData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingId) return;
|
||||
const timer = window.setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [renamingId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
refresh: () => {
|
||||
void loadSidebarData();
|
||||
},
|
||||
addConversation: (conversation) => {
|
||||
setConversations((prev) => {
|
||||
const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
|
||||
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
||||
});
|
||||
},
|
||||
}),
|
||||
[loadSidebarData],
|
||||
);
|
||||
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return conversations;
|
||||
|
||||
return conversations.filter((conversation) =>
|
||||
(conversation.title ?? 'Untitled conversation').toLowerCase().includes(query),
|
||||
);
|
||||
}, [conversations, searchQuery]);
|
||||
|
||||
const groupedConversations = useMemo<GroupedConversations[]>(() => {
|
||||
if (projects.length === 0) {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All conversations',
|
||||
projectId: null,
|
||||
conversations: filteredConversations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const byProject = new Map<string | null, ConversationSummary[]>();
|
||||
for (const conversation of filteredConversations) {
|
||||
const key = conversation.projectId ?? null;
|
||||
const items = byProject.get(key) ?? [];
|
||||
items.push(conversation);
|
||||
byProject.set(key, items);
|
||||
}
|
||||
|
||||
const groups: GroupedConversations[] = [];
|
||||
|
||||
for (const project of projects) {
|
||||
const projectConversations = byProject.get(project.id);
|
||||
if (!projectConversations?.length) continue;
|
||||
|
||||
groups.push({
|
||||
key: project.id,
|
||||
label: project.name,
|
||||
projectId: project.id,
|
||||
conversations: projectConversations,
|
||||
});
|
||||
}
|
||||
|
||||
const ungrouped = byProject.get(null);
|
||||
if (ungrouped?.length) {
|
||||
groups.push({
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
projectId: null,
|
||||
conversations: ungrouped,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
groups.push({
|
||||
key: 'all',
|
||||
label: 'All conversations',
|
||||
projectId: null,
|
||||
conversations: filteredConversations,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredConversations, projects]);
|
||||
|
||||
const startRename = useCallback((conversation: ConversationSummary): void => {
|
||||
setPendingDeleteId(null);
|
||||
setRenamingId(conversation.id);
|
||||
setRenameValue(conversation.title ?? '');
|
||||
}, []);
|
||||
|
||||
const cancelRename = useCallback((): void => {
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(async (): Promise<void> => {
|
||||
if (!renamingId) return;
|
||||
|
||||
const title = renameValue.trim() || 'Untitled conversation';
|
||||
|
||||
try {
|
||||
const updated = await api<Conversation>(`/api/conversations/${renamingId}`, {
|
||||
method: 'PATCH',
|
||||
body: { title },
|
||||
});
|
||||
|
||||
const summary = toSummary(updated);
|
||||
setConversations((prev) =>
|
||||
prev
|
||||
.map((conversation) => (conversation.id === renamingId ? summary : conversation))
|
||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to rename conversation');
|
||||
} finally {
|
||||
setRenamingId(null);
|
||||
setRenameValue('');
|
||||
}
|
||||
}, [renameValue, renamingId]);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (conversationId: string): Promise<void> => {
|
||||
try {
|
||||
await api<void>(`/api/conversations/${conversationId}`, { method: 'DELETE' });
|
||||
setConversations((prev) =>
|
||||
prev.filter((conversation) => conversation.id !== conversationId),
|
||||
);
|
||||
if (currentConversationId === conversationId) {
|
||||
onSelectConversation(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete conversation');
|
||||
} finally {
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
},
|
||||
[currentConversationId, onSelectConversation],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation sidebar"
|
||||
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
aria-label="Conversation sidebar"
|
||||
className="fixed left-0 top-0 z-40 flex h-full flex-col border-r md:relative md:z-0"
|
||||
style={{
|
||||
width: 'var(--sidebar-w)',
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var(--border)',
|
||||
transform: isOpen ? 'translateX(0)' : 'translateX(calc(-1 * var(--sidebar-w)))',
|
||||
transition: 'transform 220ms var(--ease)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-3"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||
Conversations
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
||||
Search, rename, and manage threads
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 md:hidden"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-b p-3" style={{ borderColor: 'var(--border)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewConversation(null)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--primary)',
|
||||
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
||||
<path strokeWidth="2" strokeLinecap="round" d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
New conversation
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" strokeWidth="2" />
|
||||
<path d="m20 20-3.5-3.5" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search conversations"
|
||||
className="w-full rounded-lg border px-9 py-2 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm" style={{ color: 'var(--muted)' }}>
|
||||
Loading conversations...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div
|
||||
className="space-y-3 rounded-xl border p-4 text-sm"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
borderColor: 'color-mix(in srgb, var(--danger) 35%, var(--border))',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadSidebarData()}
|
||||
className="rounded-md px-3 py-1.5 text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-2)' }}>
|
||||
{searchQuery ? 'No matching conversations' : 'No conversations yet'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedConversations.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
{projects.length > 0 ? (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h3
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
{group.label}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNewConversation(group.projectId)}
|
||||
className="rounded-md px-2 py-1 text-[11px] font-medium"
|
||||
style={{ color: 'var(--ms-blue-500)' }}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1">
|
||||
{group.conversations.map((conversation) => {
|
||||
const isActive = currentConversationId === conversation.id;
|
||||
const isRenaming = renamingId === conversation.id;
|
||||
const showActions =
|
||||
hoveredId === conversation.id ||
|
||||
isRenaming ||
|
||||
pendingDeleteId === conversation.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onMouseEnter={() => setHoveredId(conversation.id)}
|
||||
onMouseLeave={() =>
|
||||
setHoveredId((current) =>
|
||||
current === conversation.id ? null : current,
|
||||
)
|
||||
}
|
||||
className="rounded-xl border p-2 transition-colors"
|
||||
style={{
|
||||
borderColor: isActive
|
||||
? 'color-mix(in srgb, var(--primary) 60%, var(--border))'
|
||||
: 'transparent',
|
||||
background: isActive ? 'var(--surface-2)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onBlur={() => void commitRename()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
maxLength={255}
|
||||
className="w-full rounded-md border px-2 py-1.5 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--ms-blue-500)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="truncate text-sm font-medium"
|
||||
style={{
|
||||
color: isActive ? 'var(--text)' : 'var(--text-2)',
|
||||
}}
|
||||
>
|
||||
{conversation.title ?? 'Untitled conversation'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
||||
{formatRelativeTime(conversation.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showActions ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startRename(conversation);
|
||||
}}
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
aria-label={`Rename ${conversation.title ?? 'conversation'}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 20h4l10.5-10.5a1.4 1.4 0 0 0 0-2L16.5 5.5a1.4 1.4 0 0 0-2 0L4 16v4Z"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setPendingDeleteId((current) =>
|
||||
current === conversation.id ? null : conversation.id,
|
||||
);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
aria-label={`Delete ${conversation.title ?? 'conversation'}`}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M4 7h16M10 11v6M14 11v6M6 7l1 12h10l1-12M9 7V4h6v3"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pendingDeleteId === conversation.id ? (
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between rounded-lg border px-2 py-2"
|
||||
style={{
|
||||
borderColor:
|
||||
'color-mix(in srgb, var(--danger) 45%, var(--border))',
|
||||
background:
|
||||
'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
||||
}}
|
||||
>
|
||||
<p className="text-xs" style={{ color: 'var(--text-2)' }}>
|
||||
Delete this conversation?
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDeleteId(null)}
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteConversation(conversation.id)}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium"
|
||||
style={{ background: 'var(--danger)', color: 'white' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Message } from '@/lib/types';
|
||||
|
||||
@@ -11,261 +9,27 @@ interface MessageBubbleProps {
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
||||
const { response, thinking } = useMemo(
|
||||
() => parseThinking(message.content, message.thinking),
|
||||
[message.content, message.thinking],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(response);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch (error) {
|
||||
console.error('[MessageBubble] Failed to copy message:', error);
|
||||
}
|
||||
}, [response]);
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="max-w-[42rem] rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 70%, transparent)',
|
||||
color: 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
<span>{response}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-[min(78ch,85%)] flex-col gap-2',
|
||||
isUser ? 'items-end' : 'items-start',
|
||||
'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={cn('flex items-center gap-2 text-[11px]', isUser && 'flex-row-reverse')}>
|
||||
<span className="font-medium text-[var(--color-text-2)]">
|
||||
{isUser ? 'You' : 'Assistant'}
|
||||
</span>
|
||||
{!isUser && message.model ? (
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 font-medium text-[var(--color-text-2)]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
title={message.provider ? `Provider: ${message.provider}` : undefined}
|
||||
>
|
||||
{message.model}
|
||||
</span>
|
||||
) : null}
|
||||
{!isUser && typeof message.totalTokens === 'number' && message.totalTokens > 0 ? (
|
||||
<span
|
||||
className="rounded-full border px-2 py-0.5 text-[var(--color-muted)]"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
{formatTokenCount(message.totalTokens)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--color-muted)]">{formatTimestamp(message.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{thinking && !isUser ? (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-2xl border"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface-2) 88%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkingExpanded((prev) => !prev)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-[var(--color-text-2)] transition-colors hover:bg-black/5"
|
||||
aria-expanded={thinkingExpanded}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block text-[10px] transition-transform',
|
||||
thinkingExpanded && 'rotate-90',
|
||||
)}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span>Chain of thought</span>
|
||||
<span className="ml-auto text-[var(--color-muted)]">
|
||||
{thinkingExpanded ? 'Hide' : 'Show'}
|
||||
</span>
|
||||
</button>
|
||||
{thinkingExpanded ? (
|
||||
<pre
|
||||
className="overflow-x-auto border-t px-3 py-3 font-mono text-xs leading-6 whitespace-pre-wrap"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-bg-deep)',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{thinking}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full rounded-3xl px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]',
|
||||
!isUser && 'border',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isUser ? 'var(--color-ms-blue-500)' : 'var(--color-surface)',
|
||||
color: isUser ? '#fff' : 'var(--color-text)',
|
||||
borderColor: isUser ? 'transparent' : 'var(--color-border)',
|
||||
}}
|
||||
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
|
||||
>
|
||||
<div className="max-w-none">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-3 leading-7 last:mb-0">{children}</p>,
|
||||
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 last:mb-0">{children}</ul>,
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-3 list-decimal pl-5 last:mb-0">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="font-medium underline underline-offset-4"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
pre: ({ children }) => <div className="mb-3 last:mb-0">{children}</div>,
|
||||
code: ({ className, children, ...props }) => {
|
||||
const language = className?.replace('language-', '');
|
||||
const content = String(children).replace(/\n$/, '');
|
||||
const isInline = !className;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="rounded-md px-1.5 py-0.5 font-mono text-[0.9em]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in srgb, var(--color-bg-deep) 76%, transparent)',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-deep)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="border-b px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-[var(--color-muted)]"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
{language || 'code'}
|
||||
</div>
|
||||
<pre className="overflow-x-auto p-3">
|
||||
<code
|
||||
className={cn('font-mono text-[13px] leading-6', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className="mb-3 border-l-2 pl-4 italic last:mb-0"
|
||||
style={{ borderColor: 'var(--color-ms-blue-500)' }}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{response}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopy()}
|
||||
className="absolute -right-2 -top-2 rounded-full border p-2 opacity-0 shadow-[var(--shadow-ms-md)] transition-all group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: copied ? 'var(--color-success)' : 'var(--color-text-2)',
|
||||
}}
|
||||
aria-label={copied ? 'Copied' : 'Copy message'}
|
||||
title={copied ? 'Copied' : 'Copy message'}
|
||||
>
|
||||
{copied ? '✓' : '⧉'}
|
||||
</button>
|
||||
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseThinking(
|
||||
content: string,
|
||||
thinking?: string,
|
||||
): { response: string; thinking: string | null } {
|
||||
if (thinking) {
|
||||
return { response: content, thinking };
|
||||
}
|
||||
|
||||
const regex = /<(?:thinking|think)>([\s\S]*?)<\/(?:thinking|think)>/gi;
|
||||
const matches = [...content.matchAll(regex)];
|
||||
if (matches.length === 0) {
|
||||
return { response: content, thinking: null };
|
||||
}
|
||||
|
||||
return {
|
||||
response: content.replace(regex, '').trim(),
|
||||
thinking:
|
||||
matches
|
||||
.map((match) => match[1]?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join('\n\n') || null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatTimestamp(createdAt: string): string {
|
||||
return new Date(createdAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTokenCount(totalTokens: number): string {
|
||||
if (totalTokens >= 1_000_000) return `${(totalTokens / 1_000_000).toFixed(1)}M tokens`;
|
||||
if (totalTokens >= 1_000) return `${(totalTokens / 1_000).toFixed(1)}k tokens`;
|
||||
return `${totalTokens} tokens`;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
/** Renders an in-progress assistant message from streaming text. */
|
||||
interface StreamingMessageProps {
|
||||
text: string;
|
||||
modelName?: string | null;
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
const WAITING_QUIPS = [
|
||||
'The AI is warming up... give it a moment.',
|
||||
'Brewing some thoughts...',
|
||||
'Summoning intelligence from the void...',
|
||||
'Consulting the silicon oracle...',
|
||||
'Teaching electrons to think...',
|
||||
];
|
||||
|
||||
const TIMEOUT_QUIPS = [
|
||||
'The model wandered off. Let’s try to find it again.',
|
||||
'Response is taking the scenic route.',
|
||||
'That answer is clearly overthinking things.',
|
||||
'Still working. Either brilliance or a detour.',
|
||||
];
|
||||
|
||||
export function StreamingMessage({
|
||||
text,
|
||||
modelName,
|
||||
thinking,
|
||||
}: StreamingMessageProps): React.ReactElement {
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setElapsedMs(0);
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [text, modelName, thinking]);
|
||||
|
||||
const quip = useMemo(() => {
|
||||
if (elapsedMs >= 18_000) {
|
||||
return TIMEOUT_QUIPS[Math.floor((elapsedMs / 1000) % TIMEOUT_QUIPS.length)];
|
||||
}
|
||||
if (elapsedMs >= 4_000) {
|
||||
return WAITING_QUIPS[Math.floor((elapsedMs / 1000) % WAITING_QUIPS.length)];
|
||||
}
|
||||
return null;
|
||||
}, [elapsedMs]);
|
||||
|
||||
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className="max-w-[min(78ch,85%)] rounded-3xl border px-4 py-3 text-sm shadow-[var(--shadow-ms-sm)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">Assistant</span>
|
||||
{modelName ? (
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[var(--color-text-2)]">
|
||||
{modelName}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-[var(--color-muted)]">{text ? 'Responding…' : 'Thinking…'}</span>
|
||||
</div>
|
||||
<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-[var(--color-muted)]">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.2s]" />
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[var(--color-ms-blue-500)] [animation-delay:0.4s]" />
|
||||
<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>
|
||||
)}
|
||||
{thinking ? (
|
||||
<div
|
||||
className="mt-3 rounded-2xl border px-3 py-2 font-mono text-xs whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-deep)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-2)',
|
||||
}}
|
||||
>
|
||||
{thinking}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--color-muted)]">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--color-ms-blue-500)]" />
|
||||
<span>{quip ?? (text ? 'Responding…' : 'Thinking…')}</span>
|
||||
<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,239 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
|
||||
interface AppHeaderProps {
|
||||
conversationTitle?: string | null;
|
||||
isSidebarOpen: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
const THEME_STORAGE_KEY = 'mosaic-chat-theme';
|
||||
|
||||
export function AppHeader({
|
||||
conversationTitle,
|
||||
isSidebarOpen,
|
||||
onToggleSidebar,
|
||||
}: AppHeaderProps): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
function updateTime(): void {
|
||||
setCurrentTime(
|
||||
new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateTime();
|
||||
const interval = window.setInterval(updateTime, 60_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then(async (res) => res.json() as Promise<{ version?: string; commit?: string }>)
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setVersion(data.commit ? `${data.version}+${data.commit}` : data.version);
|
||||
}
|
||||
})
|
||||
.catch(() => setVersion(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const nextTheme = storedTheme === 'light' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = useCallback(() => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(nextTheme);
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
setTheme(nextTheme);
|
||||
}, [theme]);
|
||||
|
||||
const handleSignOut = useCallback(async (): Promise<void> => {
|
||||
await signOut();
|
||||
window.location.href = '/login';
|
||||
}, []);
|
||||
|
||||
const userLabel = session?.user.name ?? session?.user.email ?? 'Mosaic User';
|
||||
const initials = useMemo(() => getInitials(userLabel), [userLabel]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-20 border-b backdrop-blur-xl"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSidebar}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-2xl border transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle conversation sidebar"
|
||||
aria-expanded={isSidebarOpen}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
|
||||
<Link href="/chat" className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-semibold text-white shadow-[var(--shadow-ms-md)]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, var(--color-ms-blue-500), var(--color-ms-teal-500))',
|
||||
}}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--color-text)]">Mosaic</div>
|
||||
<div className="hidden h-5 w-px bg-[var(--color-border)] md:block" />
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[var(--color-ms-teal-500)] opacity-60" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-[var(--color-ms-teal-500)]" />
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]">
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden min-w-0 items-center gap-3 md:flex">
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-2)]">
|
||||
{currentTime || '--:--'}
|
||||
</div>
|
||||
<div className="max-w-[24rem] truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{conversationTitle?.trim() || 'New Session'}
|
||||
</div>
|
||||
{version ? (
|
||||
<div className="rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
v{version}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<ShortcutHint label="⌘/" text="focus" />
|
||||
<ShortcutHint label="⌘K" text="focus" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
className="inline-flex h-10 items-center justify-center rounded-2xl border px-3 text-sm transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text)' }}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? '☀︎' : '☾'}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border text-sm font-semibold transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-2)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Open user menu"
|
||||
>
|
||||
{session?.user.image ? (
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={userLabel}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div
|
||||
className="absolute right-0 top-12 min-w-56 rounded-3xl border p-2 shadow-[var(--shadow-ms-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div className="border-b px-3 py-2" style={{ borderColor: 'var(--color-border)' }}>
|
||||
<div className="text-sm font-medium text-[var(--color-text)]">{userLabel}</div>
|
||||
{session?.user.email ? (
|
||||
<div className="text-xs text-[var(--color-muted)]">{session.user.email}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="flex rounded-2xl px-3 py-2 text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSignOut()}
|
||||
className="flex w-full rounded-2xl px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutHint({ label, text }: { label: string; text: string }): React.ReactElement {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-muted)]">
|
||||
<span className="font-medium text-[var(--color-text-2)]">{label}</span>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitials(label: string): string {
|
||||
const words = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (words.length === 0) return 'M';
|
||||
return words.map((word) => word.charAt(0).toUpperCase()).join('');
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeMode): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.setAttribute('data-theme', 'light');
|
||||
root.classList.remove('dark');
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession, signOut } from '@/lib/auth-client';
|
||||
|
||||
export function Topbar(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname.startsWith('/chat')) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
import { adminClient, genericOAuthClient } from 'better-auth/client/plugins';
|
||||
import { adminClient } from 'better-auth/client/plugins';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||
plugins: [adminClient(), genericOAuthClient()],
|
||||
plugins: [adminClient()],
|
||||
});
|
||||
|
||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnabledSsoProviders, getSsoProvider } from './sso-providers';
|
||||
|
||||
describe('sso-providers', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('returns the enabled providers in login button order', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'true');
|
||||
|
||||
expect(getEnabledSsoProviders()).toEqual([
|
||||
{
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
enabled: true,
|
||||
href: '/auth/provider/workos',
|
||||
},
|
||||
{
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: true,
|
||||
href: '/auth/provider/keycloak',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks disabled providers without exposing them in the enabled list', () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_WORKOS_ENABLED', 'true');
|
||||
vi.stubEnv('NEXT_PUBLIC_KEYCLOAK_ENABLED', 'false');
|
||||
|
||||
expect(getEnabledSsoProviders().map((provider) => provider.id)).toEqual(['workos']);
|
||||
expect(getSsoProvider('keycloak')).toEqual({
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
enabled: false,
|
||||
href: '/auth/provider/keycloak',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown providers', () => {
|
||||
expect(getSsoProvider('authentik')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
export type SsoProviderId = 'workos' | 'keycloak';
|
||||
|
||||
export interface SsoProvider {
|
||||
id: SsoProviderId;
|
||||
buttonLabel: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const PROVIDER_METADATA: Record<SsoProviderId, Omit<SsoProvider, 'enabled' | 'href'>> = {
|
||||
workos: {
|
||||
id: 'workos',
|
||||
buttonLabel: 'Continue with WorkOS',
|
||||
description: 'Enterprise SSO via WorkOS',
|
||||
},
|
||||
keycloak: {
|
||||
id: 'keycloak',
|
||||
buttonLabel: 'Continue with Keycloak',
|
||||
description: 'Enterprise SSO via Keycloak',
|
||||
},
|
||||
};
|
||||
|
||||
export function getEnabledSsoProviders(): SsoProvider[] {
|
||||
return (Object.keys(PROVIDER_METADATA) as SsoProviderId[])
|
||||
.map((providerId) => getSsoProvider(providerId))
|
||||
.filter((provider): provider is SsoProvider => provider?.enabled === true);
|
||||
}
|
||||
|
||||
export function getSsoProvider(providerId: string): SsoProvider | null {
|
||||
if (!isSsoProviderId(providerId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...PROVIDER_METADATA[providerId],
|
||||
enabled: isSsoProviderEnabled(providerId),
|
||||
href: `/auth/provider/${providerId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isSsoProviderId(value: string): value is SsoProviderId {
|
||||
return value === 'workos' || value === 'keycloak';
|
||||
}
|
||||
|
||||
function isSsoProviderEnabled(providerId: SsoProviderId): boolean {
|
||||
switch (providerId) {
|
||||
case 'workos':
|
||||
return process.env['NEXT_PUBLIC_WORKOS_ENABLED'] === 'true';
|
||||
case 'keycloak':
|
||||
return process.env['NEXT_PUBLIC_KEYCLOAK_ENABLED'] === 'true';
|
||||
}
|
||||
}
|
||||
@@ -15,41 +15,10 @@ export interface Message {
|
||||
conversationId: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
thinking?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Model definition returned by provider APIs. */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
inputTypes: Array<'text' | 'image'>;
|
||||
cost: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider with associated models. */
|
||||
export interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
available: boolean;
|
||||
models: ModelInfo[];
|
||||
}
|
||||
|
||||
/** Task statuses. */
|
||||
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# Performance Optimization — P8-003
|
||||
|
||||
**Branch:** `feat/p8-003-performance`
|
||||
**Target metrics:** <200 ms TTFB, <2 s page loads
|
||||
|
||||
---
|
||||
|
||||
## What Was Profiled
|
||||
|
||||
The following areas were reviewed through static analysis and code-path tracing
|
||||
(no production traffic available; findings are based on measurable code-level patterns):
|
||||
|
||||
| Area | Findings |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/db` | Connection pool unbounded (default 10, no idle/connect timeout) |
|
||||
| `apps/gateway/src/preferences` | N+1 round-trip on every pref upsert (SELECT + INSERT/UPDATE) |
|
||||
| `packages/brain/src/conversations` | Unbounded list queries — no `LIMIT` or `ORDER BY` |
|
||||
| `packages/db/src/schema` | Missing hot-path indexes: auth session lookup, OAuth callback, conversation list, agent-log tier queries |
|
||||
| `apps/gateway/src/gc` | Cold-start GC blocked NestJS bootstrap (synchronous `await` in `onModuleInit`) |
|
||||
| `apps/web/next.config.ts` | Missing `compress: true`, no `productionBrowserSourceMaps: false`, no image format config |
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. DB Connection Pool — `packages/db/src/client.ts`
|
||||
|
||||
**Problem:** `postgres()` was called with no pool config. The default max of 10 connections
|
||||
and no idle/connect timeouts meant the pool could hang indefinitely on a stale TCP connection.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `max`: 20 connections (configurable via `DB_POOL_MAX`)
|
||||
- `idle_timeout`: 30 s (configurable via `DB_IDLE_TIMEOUT`) — recycle stale connections
|
||||
- `connect_timeout`: 5 s (configurable via `DB_CONNECT_TIMEOUT`) — fail fast on unreachable DB
|
||||
|
||||
**Expected impact:** Eliminates pool exhaustion under moderate concurrency; removes indefinite
|
||||
hangs when the DB is temporarily unreachable.
|
||||
|
||||
---
|
||||
|
||||
### 2. Preferences Upsert — `apps/gateway/src/preferences/preferences.service.ts`
|
||||
|
||||
**Problem:** `upsertPref` executed two serial DB round-trips on every preference write:
|
||||
|
||||
```
|
||||
1. SELECT id FROM preferences WHERE user_id = ? AND key = ? (→ check exists)
|
||||
2a. UPDATE preferences SET value = ? … (→ if found)
|
||||
2b. INSERT INTO preferences … (→ if not found)
|
||||
```
|
||||
|
||||
Under concurrency this also had a TOCTOU race window.
|
||||
|
||||
**Fix:** Replaced with single-statement `INSERT … ON CONFLICT DO UPDATE`:
|
||||
|
||||
```sql
|
||||
INSERT INTO preferences (user_id, key, value, mutable)
|
||||
VALUES (?, ?, ?, true)
|
||||
ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = now();
|
||||
```
|
||||
|
||||
This required promoting `preferences_user_key_idx` from a plain index to a `UNIQUE INDEX`
|
||||
(see migration `0003_p8003_perf_indexes.sql`).
|
||||
|
||||
**Expected impact:** ~50% reduction in DB round-trips for preference writes; eliminates
|
||||
the race window.
|
||||
|
||||
---
|
||||
|
||||
### 3. Missing DB Indexes — `packages/db/src/schema.ts` + migration
|
||||
|
||||
The following indexes were added or replaced to cover common query patterns:
|
||||
|
||||
| Table | Old indexes | New / changed |
|
||||
| --------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `sessions` | _(none)_ | `sessions_user_id_idx(user_id)`, `sessions_expires_at_idx(expires_at)` |
|
||||
| `accounts` | _(none)_ | `accounts_provider_account_idx(provider_id, account_id)`, `accounts_user_id_idx(user_id)` |
|
||||
| `conversations` | `(user_id)`, `(archived)` separate | `conversations_user_archived_idx(user_id, archived)` compound |
|
||||
| `agent_logs` | `(session_id)`, `(tier)`, `(created_at)` separate | `agent_logs_session_tier_idx(session_id, tier)`, `agent_logs_tier_created_at_idx(tier, created_at)` |
|
||||
| `preferences` | non-unique `(user_id, key)` | **unique** `(user_id, key)` — required for `ON CONFLICT` |
|
||||
|
||||
**Expected impact:**
|
||||
|
||||
- Auth session validation (hot path on every request): from seq scan → index scan
|
||||
- OAuth callback account lookup: from seq scan → index scan
|
||||
- Conversation list (dashboard load): compound index covers `WHERE user_id = ? ORDER BY updated_at`
|
||||
- Log summarisation cron: `(tier, created_at)` index enables efficient hot→warm promotion query
|
||||
|
||||
All changes are in `packages/db/drizzle/0003_p8003_perf_indexes.sql`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Conversation Queries — `packages/brain/src/conversations.ts`
|
||||
|
||||
**Problem:** `findAll(userId)` and `findMessages(conversationId)` were unbounded — no `LIMIT`
|
||||
and `findAll` had no `ORDER BY`, so the DB planner may not use the index efficiently.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `findAll`: `ORDER BY updated_at DESC LIMIT 200` — returns most-recent conversations first
|
||||
- `findMessages`: `ORDER BY created_at ASC LIMIT 500` — chronological message history
|
||||
|
||||
**Expected impact:** Prevents accidental full-table scans on large datasets; ensures the
|
||||
frontend receives a usable, ordered result set regardless of table growth.
|
||||
|
||||
---
|
||||
|
||||
### 5. Cold-Start GC — `apps/gateway/src/gc/session-gc.service.ts`
|
||||
|
||||
**Problem:** `onModuleInit()` was `async` and `await`-ed `fullCollect()`, which blocked the
|
||||
NestJS module initialization chain. Full GC — which calls `redis.keys('mosaic:session:*')` and
|
||||
a DB query — typically takes 100–500 ms. This directly added to startup TTFB.
|
||||
|
||||
**Fix:** Made `onModuleInit()` synchronous and used `.then().catch()` to run GC in the
|
||||
background. The first HTTP request is no longer delayed by GC work.
|
||||
|
||||
**Expected impact:** Removes 100–500 ms from cold-start TTFB.
|
||||
|
||||
---
|
||||
|
||||
### 6. Next.js Config — `apps/web/next.config.ts`
|
||||
|
||||
**Problem:** `compress: true` was not set, so response payloads were uncompressed. No image
|
||||
format optimization or source-map suppression was configured.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `compress: true` — enables gzip/brotli for all Next.js responses
|
||||
- `productionBrowserSourceMaps: false` — reduces build output size
|
||||
- `images.formats: ['image/avif', 'image/webp']` — Next.js Image component will serve modern
|
||||
formats to browsers that support them (typically 40–60% smaller than JPEG/PNG)
|
||||
|
||||
**Expected impact:** Typical HTML/JSON gzip savings of 60–80%; image serving cost reduced
|
||||
for any `<Image>` components added in the future.
|
||||
|
||||
---
|
||||
|
||||
## What Was Not Changed (Intentionally)
|
||||
|
||||
- **Caching layer (Valkey/Redis):** The `SystemOverrideService` and GC already use Redis
|
||||
pipelines. `PreferencesService.getEffective()` reads all user prefs in one query — this
|
||||
is appropriate for the data size and doesn't warrant an additional cache layer yet.
|
||||
- **WebSocket backpressure:** The `ChatGateway` already drops events for disconnected clients
|
||||
(`client.connected` check) and cleans up listeners on disconnect. No memory leak was found.
|
||||
- **Plugin/skill loader startup:** `SkillLoaderService.loadForSession()` is called on first
|
||||
session creation, not on startup. Already non-blocking.
|
||||
- **Frontend React memoization:** No specific hot components were identified as causing
|
||||
excessive re-renders without profiling data. No speculative `memo()` calls added.
|
||||
|
||||
---
|
||||
|
||||
## How to Apply
|
||||
|
||||
```bash
|
||||
# Run the DB migration (requires a live DB)
|
||||
pnpm --filter @mosaic/db exec drizzle-kit migrate
|
||||
|
||||
# Or, in Docker/Swarm — migrations run automatically on gateway startup
|
||||
# via runMigrations() in packages/db/src/migrate.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Generated by P8-003 performance optimization task — 2026-03-18_
|
||||
@@ -1,111 +0,0 @@
|
||||
# SSO Providers
|
||||
|
||||
Mosaic Stack supports optional enterprise single sign-on through Better Auth's generic OAuth flow. The gateway mounts Better Auth under `/api/auth`, so every provider callback terminates at:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/{providerId}
|
||||
```
|
||||
|
||||
For the providers in this document:
|
||||
|
||||
- Authentik: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/authentik`
|
||||
- WorkOS: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos`
|
||||
- Keycloak: `{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak`
|
||||
|
||||
## Required environment variables
|
||||
|
||||
### Authentik
|
||||
|
||||
```bash
|
||||
AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic
|
||||
AUTHENTIK_CLIENT_ID=...
|
||||
AUTHENTIK_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
### WorkOS
|
||||
|
||||
```bash
|
||||
WORKOS_ISSUER=https://your-company.authkit.app
|
||||
WORKOS_CLIENT_ID=client_...
|
||||
WORKOS_CLIENT_SECRET=...
|
||||
NEXT_PUBLIC_WORKOS_ENABLED=true
|
||||
```
|
||||
|
||||
`WORKOS_ISSUER` should be the WorkOS AuthKit issuer or custom auth domain, not the raw REST API hostname. Mosaic derives the OIDC discovery URL from that issuer.
|
||||
|
||||
### Keycloak
|
||||
|
||||
```bash
|
||||
KEYCLOAK_ISSUER=https://auth.example.com/realms/master
|
||||
KEYCLOAK_CLIENT_ID=mosaic
|
||||
KEYCLOAK_CLIENT_SECRET=...
|
||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
```
|
||||
|
||||
If you prefer, you can keep the issuer split as:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_URL=https://auth.example.com
|
||||
KEYCLOAK_REALM=master
|
||||
```
|
||||
|
||||
The auth package will derive `KEYCLOAK_ISSUER` from those two values.
|
||||
|
||||
## WorkOS setup
|
||||
|
||||
1. In WorkOS, create or select the application that will back Mosaic login.
|
||||
2. Configure an AuthKit domain or custom authentication domain for the application.
|
||||
3. Add the redirect URI:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/workos
|
||||
```
|
||||
|
||||
4. Copy the application's `client_id` and `client_secret` into `WORKOS_CLIENT_ID` and `WORKOS_CLIENT_SECRET`.
|
||||
5. Set `WORKOS_ISSUER` to the AuthKit domain from step 2.
|
||||
6. Create the WorkOS organization and attach the enterprise SSO connection you want Mosaic to use.
|
||||
7. Set `NEXT_PUBLIC_WORKOS_ENABLED=true` in the web deployment so the login button is rendered.
|
||||
|
||||
## Keycloak setup
|
||||
|
||||
1. Start from an existing Keycloak realm or create a dedicated realm for Mosaic.
|
||||
2. Create a confidential OIDC client named `mosaic` or your preferred client ID.
|
||||
3. Set the valid redirect URI to:
|
||||
|
||||
```text
|
||||
{BETTER_AUTH_URL}/api/auth/oauth2/callback/keycloak
|
||||
```
|
||||
|
||||
4. Set the web origin to the public Mosaic web URL.
|
||||
5. Copy the client secret into `KEYCLOAK_CLIENT_SECRET`.
|
||||
6. Set either `KEYCLOAK_ISSUER` directly or `KEYCLOAK_URL` + `KEYCLOAK_REALM`.
|
||||
7. Set `NEXT_PUBLIC_KEYCLOAK_ENABLED=true` in the web deployment so the login button is rendered.
|
||||
|
||||
### Local Keycloak smoke test
|
||||
|
||||
If you want to test locally with Docker:
|
||||
|
||||
```bash
|
||||
docker run --rm --name mosaic-keycloak \
|
||||
-p 8080:8080 \
|
||||
-e KEYCLOAK_ADMIN=admin \
|
||||
-e KEYCLOAK_ADMIN_PASSWORD=admin \
|
||||
quay.io/keycloak/keycloak:26.1 start-dev
|
||||
```
|
||||
|
||||
Then configure:
|
||||
|
||||
```bash
|
||||
KEYCLOAK_ISSUER=http://localhost:8080/realms/master
|
||||
KEYCLOAK_CLIENT_ID=mosaic
|
||||
KEYCLOAK_CLIENT_SECRET=...
|
||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=true
|
||||
```
|
||||
|
||||
## Web flow
|
||||
|
||||
The web login page renders provider buttons from `NEXT_PUBLIC_*_ENABLED` flags. Each button links to `/auth/provider/{providerId}`, and that page initiates Better Auth's `signIn.oauth2` flow before handing off to the provider.
|
||||
|
||||
## Failure mode
|
||||
|
||||
Provider config is optional, but partial config is rejected at startup. If any provider-specific env var is present without the full required set, `@mosaic/auth` throws a bootstrap error with the missing keys instead of silently registering a broken provider.
|
||||
@@ -93,8 +93,8 @@
|
||||
| P8-017 | done | Phase 8 | TUI Phase 8 — autocomplete sidebar, fuzzy match, arg hints, up-arrow history | #184 | #170 |
|
||||
| P8-018 | done | Phase 8 | Spin-off plan stubs — Gatekeeper, Task Queue Unification, Chroot Sandboxing | — | #171 |
|
||||
| P8-019 | done | Phase 8 | Verify Platform Architecture — integration + E2E verification | #185 | #172 |
|
||||
| P8-001 | in-progress | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | #210 | #53 |
|
||||
| P8-002 | in-progress | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||
| P8-003 | in-progress | codex | Phase 8 | Performance optimization | — | #56 |
|
||||
| P8-004 | done | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||
| P8-001 | not-started | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||
| P8-002 | not-started | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||
| P8-003 | not-started | codex | Phase 8 | Performance optimization | — | #56 |
|
||||
| P8-004 | not-started | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
||||
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# BUG-CLI Scratchpad
|
||||
|
||||
## Objective
|
||||
|
||||
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||
|
||||
## Issues
|
||||
|
||||
- #192: Ctrl+T leaks 't' into input
|
||||
- #193: Duplicate React keys in CommandAutocomplete
|
||||
- #194: /provider login false clipboard claim
|
||||
@@ -14,33 +12,28 @@ Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||
## Plan and Fixes
|
||||
|
||||
### Bug #192 — Ctrl+T character leak
|
||||
|
||||
- Location: `packages/cli/src/tui/app.tsx`
|
||||
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
||||
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||
leaked character and return early.
|
||||
|
||||
### Bug #193 — Duplicate React keys
|
||||
|
||||
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
||||
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||
that share a name with local commands. Local commands take precedence.
|
||||
|
||||
### Bug #194 — False clipboard claim
|
||||
|
||||
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||
|
||||
### Bug #199 — Hardcoded version "0.0.0"
|
||||
|
||||
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
||||
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
|
||||
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
|
||||
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
- CLI typecheck: PASSED
|
||||
- CLI lint: PASSED
|
||||
- Prettier format:check: PASSED
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# P8-001 — WorkOS + Keycloak SSO Providers
|
||||
|
||||
**Branch:** feat/p8-001-sso-providers
|
||||
**Started:** 2026-03-18
|
||||
**Mode:** Delivery
|
||||
|
||||
## Objective
|
||||
|
||||
Add WorkOS and Keycloak as optional SSO providers to the BetterAuth configuration, following the existing Authentik pattern.
|
||||
|
||||
## Scope
|
||||
|
||||
| Surface | Change |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `packages/auth/src/auth.ts` | Refactor provider array, add WorkOS + Keycloak conditional registration |
|
||||
| `apps/web/src/lib/auth-client.ts` | Add `genericOAuthClient()` plugin |
|
||||
| `apps/web/src/app/(auth)/login/page.tsx` | WorkOS + Keycloak SSO buttons gated by `NEXT_PUBLIC_*` env vars |
|
||||
| `.env.example` | Document WorkOS + Keycloak env vars |
|
||||
| `packages/auth/src/auth.test.ts` | Unit tests verifying env-var gating |
|
||||
|
||||
## Plan
|
||||
|
||||
1. ✅ Refactor `createAuth` to build `oauthProviders[]` conditionally
|
||||
2. ✅ Add WorkOS provider (explicit URLs, no discovery)
|
||||
3. ✅ Add Keycloak provider (discoveryUrl pattern)
|
||||
4. ✅ Add `genericOAuthClient()` to auth-client.ts
|
||||
5. ✅ Add SSO buttons to login page gated by `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED`
|
||||
6. ✅ Update `.env.example`
|
||||
7. ⏳ Write `auth.test.ts` with env-var gating tests
|
||||
8. ⏳ Quality gates: typecheck + lint + format:check + test
|
||||
9. ⏳ Commit + push + PR
|
||||
|
||||
## Decisions
|
||||
|
||||
- **WorkOS**: Uses explicit `authorizationUrl`, `tokenUrl`, `userInfoUrl` (no discovery endpoint available)
|
||||
- **Keycloak**: Uses `discoveryUrl` pattern (`{URL}/realms/{REALM}/.well-known/openid-configuration`)
|
||||
- **UI gating**: Login page uses `NEXT_PUBLIC_WORKOS_ENABLED` / `NEXT_PUBLIC_KEYCLOAK_ENABLED` feature flags (safer than exposing secret env var names client-side)
|
||||
- **Refactor**: Authentik moved into same `oauthProviders[]` array pattern — cleaner, more extensible
|
||||
- **Feature flag design**: `NEXT_PUBLIC_*` flags are opt-in alongside credentials (prevents accidental button render when creds not set)
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `ASSUMPTION:` WorkOS OIDC discovery URL is not publicly documented; using direct URL pattern from WorkOS SSO docs.
|
||||
- `ASSUMPTION:` `NEXT_PUBLIC_WORKOS_ENABLED=true` must be explicitly set — this is intentional (credential presence alone doesn't enable the button since NEXT_PUBLIC vars are baked at build time).
|
||||
|
||||
## Tests
|
||||
|
||||
- `auth.test.ts`: Mocks betterAuth stack, verifies WorkOS included/excluded based on env var
|
||||
- `auth.test.ts`: Verifies Keycloak discoveryUrl constructed correctly
|
||||
|
||||
## Quality Gate Results
|
||||
|
||||
| Gate | Status |
|
||||
| ------------------- | -------------------------------------------- |
|
||||
| typecheck | ✅ 32/32 cached green |
|
||||
| lint | ✅ 18/18 cached green |
|
||||
| format:check | ✅ All matched files use Prettier code style |
|
||||
| test (@mosaic/auth) | ✅ 8/8 tests passed |
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `pnpm typecheck` — FULL TURBO, 32 tasks successful
|
||||
- `pnpm lint` — FULL TURBO, 18 tasks successful
|
||||
- `pnpm format:check` — All matched files use Prettier code style!
|
||||
- `pnpm --filter=@mosaic/auth test` — 8 tests passed, 0 failed
|
||||
@@ -1,161 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { buildOAuthProviders } from './auth.js';
|
||||
|
||||
describe('buildOAuthProviders', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['AUTHENTIK_CLIENT_ID'];
|
||||
delete process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
delete process.env['AUTHENTIK_ISSUER'];
|
||||
delete process.env['WORKOS_CLIENT_ID'];
|
||||
delete process.env['WORKOS_CLIENT_SECRET'];
|
||||
delete process.env['WORKOS_ISSUER'];
|
||||
delete process.env['KEYCLOAK_CLIENT_ID'];
|
||||
delete process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||
delete process.env['KEYCLOAK_ISSUER'];
|
||||
delete process.env['KEYCLOAK_URL'];
|
||||
delete process.env['KEYCLOAK_REALM'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('returns empty array when no SSO env vars are set', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('WorkOS', () => {
|
||||
it('includes workos provider when all required env vars are set', () => {
|
||||
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||
process.env['WORKOS_CLIENT_SECRET'] = 'sk_live_test';
|
||||
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const workos = providers.find((p) => p.providerId === 'workos');
|
||||
|
||||
expect(workos).toBeDefined();
|
||||
expect(workos?.clientId).toBe('client_test123');
|
||||
expect(workos?.issuer).toBe('https://example.authkit.app');
|
||||
expect(workos?.discoveryUrl).toBe(
|
||||
'https://example.authkit.app/.well-known/openid-configuration',
|
||||
);
|
||||
expect(workos?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||
});
|
||||
|
||||
it('throws when WorkOS is partially configured', () => {
|
||||
process.env['WORKOS_CLIENT_ID'] = 'client_test123';
|
||||
|
||||
expect(() => buildOAuthProviders()).toThrow(
|
||||
'@mosaic/auth: WorkOS SSO requires WORKOS_ISSUER, WORKOS_CLIENT_ID, WORKOS_CLIENT_SECRET.',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes workos provider when WorkOS is not configured', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
const workos = providers.find((p) => p.providerId === 'workos');
|
||||
expect(workos).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keycloak', () => {
|
||||
it('includes keycloak provider when KEYCLOAK_ISSUER is set', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
process.env['KEYCLOAK_ISSUER'] = 'https://auth.example.com/realms/myrealm/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
|
||||
expect(keycloakProvider).toBeDefined();
|
||||
expect(keycloakProvider?.clientId).toBe('mosaic');
|
||||
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||
);
|
||||
expect(keycloakProvider?.scopes).toEqual(['openid', 'email', 'profile']);
|
||||
});
|
||||
|
||||
it('supports deriving the Keycloak issuer from KEYCLOAK_URL and KEYCLOAK_REALM', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
process.env['KEYCLOAK_URL'] = 'https://auth.example.com/';
|
||||
process.env['KEYCLOAK_REALM'] = 'myrealm';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
|
||||
expect(keycloakProvider?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/realms/myrealm/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when Keycloak is partially configured', () => {
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'mosaic';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'secret123';
|
||||
|
||||
expect(() => buildOAuthProviders()).toThrow(
|
||||
'@mosaic/auth: Keycloak SSO requires KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER.',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes keycloak provider when Keycloak is not configured', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
const keycloakProvider = providers.find((p) => p.providerId === 'keycloak');
|
||||
expect(keycloakProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentik', () => {
|
||||
it('includes authentik provider when all required env vars are set', () => {
|
||||
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||
process.env['AUTHENTIK_CLIENT_SECRET'] = 'authentik-secret';
|
||||
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic/';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||
|
||||
expect(authentik).toBeDefined();
|
||||
expect(authentik?.clientId).toBe('authentik-client');
|
||||
expect(authentik?.issuer).toBe('https://auth.example.com/application/o/mosaic');
|
||||
expect(authentik?.discoveryUrl).toBe(
|
||||
'https://auth.example.com/application/o/mosaic/.well-known/openid-configuration',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when Authentik is partially configured', () => {
|
||||
process.env['AUTHENTIK_CLIENT_ID'] = 'authentik-client';
|
||||
|
||||
expect(() => buildOAuthProviders()).toThrow(
|
||||
'@mosaic/auth: Authentik SSO requires AUTHENTIK_ISSUER, AUTHENTIK_CLIENT_ID, AUTHENTIK_CLIENT_SECRET.',
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes authentik provider when Authentik is not configured', () => {
|
||||
const providers = buildOAuthProviders();
|
||||
const authentik = providers.find((p) => p.providerId === 'authentik');
|
||||
expect(authentik).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('registers all three providers when all env vars are set', () => {
|
||||
process.env['AUTHENTIK_CLIENT_ID'] = 'a-id';
|
||||
process.env['AUTHENTIK_CLIENT_SECRET'] = 'a-secret';
|
||||
process.env['AUTHENTIK_ISSUER'] = 'https://auth.example.com/application/o/mosaic';
|
||||
process.env['WORKOS_CLIENT_ID'] = 'w-id';
|
||||
process.env['WORKOS_CLIENT_SECRET'] = 'w-secret';
|
||||
process.env['WORKOS_ISSUER'] = 'https://example.authkit.app';
|
||||
process.env['KEYCLOAK_CLIENT_ID'] = 'k-id';
|
||||
process.env['KEYCLOAK_CLIENT_SECRET'] = 'k-secret';
|
||||
process.env['KEYCLOAK_ISSUER'] = 'https://kc.example.com/realms/test';
|
||||
|
||||
const providers = buildOAuthProviders();
|
||||
expect(providers).toHaveLength(3);
|
||||
const ids = providers.map((p) => p.providerId);
|
||||
expect(ids).toContain('authentik');
|
||||
expect(ids).toContain('workos');
|
||||
expect(ids).toContain('keycloak');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { admin } from 'better-auth/plugins';
|
||||
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
||||
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||
import type { Db } from '@mosaic/db';
|
||||
|
||||
export interface AuthConfig {
|
||||
@@ -10,118 +9,35 @@ export interface AuthConfig {
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
/** Builds the list of enabled OAuth providers from environment variables. Exported for testing. */
|
||||
export function buildOAuthProviders(): GenericOAuthConfig[] {
|
||||
const providers: GenericOAuthConfig[] = [];
|
||||
|
||||
const authentikIssuer = normalizeIssuer(process.env['AUTHENTIK_ISSUER']);
|
||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
assertOptionalProviderConfig('Authentik SSO', {
|
||||
required: ['AUTHENTIK_ISSUER', 'AUTHENTIK_CLIENT_ID', 'AUTHENTIK_CLIENT_SECRET'],
|
||||
values: [authentikIssuer, authentikClientId, authentikClientSecret],
|
||||
});
|
||||
|
||||
if (authentikIssuer && authentikClientId && authentikClientSecret) {
|
||||
providers.push({
|
||||
providerId: 'authentik',
|
||||
clientId: authentikClientId,
|
||||
clientSecret: authentikClientSecret,
|
||||
issuer: authentikIssuer,
|
||||
discoveryUrl: buildDiscoveryUrl(authentikIssuer),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
requireIssuerValidation: true,
|
||||
});
|
||||
}
|
||||
|
||||
const workosIssuer = normalizeIssuer(process.env['WORKOS_ISSUER']);
|
||||
const workosClientId = process.env['WORKOS_CLIENT_ID'];
|
||||
const workosClientSecret = process.env['WORKOS_CLIENT_SECRET'];
|
||||
assertOptionalProviderConfig('WorkOS SSO', {
|
||||
required: ['WORKOS_ISSUER', 'WORKOS_CLIENT_ID', 'WORKOS_CLIENT_SECRET'],
|
||||
values: [workosIssuer, workosClientId, workosClientSecret],
|
||||
});
|
||||
|
||||
if (workosIssuer && workosClientId && workosClientSecret) {
|
||||
providers.push({
|
||||
providerId: 'workos',
|
||||
clientId: workosClientId,
|
||||
clientSecret: workosClientSecret,
|
||||
issuer: workosIssuer,
|
||||
discoveryUrl: buildDiscoveryUrl(workosIssuer),
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
requireIssuerValidation: true,
|
||||
});
|
||||
}
|
||||
|
||||
const keycloakIssuer = resolveKeycloakIssuer();
|
||||
const keycloakClientId = process.env['KEYCLOAK_CLIENT_ID'];
|
||||
const keycloakClientSecret = process.env['KEYCLOAK_CLIENT_SECRET'];
|
||||
assertOptionalProviderConfig('Keycloak SSO', {
|
||||
required: ['KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ISSUER'],
|
||||
values: [keycloakClientId, keycloakClientSecret, keycloakIssuer],
|
||||
});
|
||||
|
||||
if (keycloakIssuer && keycloakClientId && keycloakClientSecret) {
|
||||
providers.push(
|
||||
keycloak({
|
||||
clientId: keycloakClientId,
|
||||
clientSecret: keycloakClientSecret,
|
||||
issuer: keycloakIssuer,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
function resolveKeycloakIssuer(): string | undefined {
|
||||
const issuer = normalizeIssuer(process.env['KEYCLOAK_ISSUER']);
|
||||
if (issuer) {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeIssuer(process.env['KEYCLOAK_URL']);
|
||||
const realm = process.env['KEYCLOAK_REALM']?.trim();
|
||||
|
||||
if (!baseUrl || !realm) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${baseUrl}/realms/${realm}`;
|
||||
}
|
||||
|
||||
function buildDiscoveryUrl(issuer: string): string {
|
||||
return `${issuer}/.well-known/openid-configuration`;
|
||||
}
|
||||
|
||||
function normalizeIssuer(value: string | undefined): string | undefined {
|
||||
return value?.trim().replace(/\/+$/, '') || undefined;
|
||||
}
|
||||
|
||||
function assertOptionalProviderConfig(
|
||||
providerName: string,
|
||||
config: { required: string[]; values: Array<string | undefined> },
|
||||
): void {
|
||||
const hasAnyValue = config.values.some((value) => Boolean(value?.trim()));
|
||||
const hasAllValues = config.values.every((value) => Boolean(value?.trim()));
|
||||
|
||||
if (!hasAnyValue || hasAllValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`@mosaic/auth: ${providerName} requires ${config.required.join(', ')}. Set them in your config or via the listed environment variables.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createAuth(config: AuthConfig) {
|
||||
const { db, baseURL, secret } = config;
|
||||
|
||||
const oauthProviders = buildOAuthProviders();
|
||||
const plugins =
|
||||
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
||||
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||
const plugins = authentikClientId
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'authentik',
|
||||
clientId: authentikClientId,
|
||||
clientSecret: authentikClientSecret ?? '',
|
||||
discoveryUrl: authentikIssuer
|
||||
? `${authentikIssuer}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
authorizationUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/authorize/`
|
||||
: undefined,
|
||||
tokenUrl: authentikIssuer ? `${authentikIssuer}/application/o/token/` : undefined,
|
||||
userInfoUrl: authentikIssuer
|
||||
? `${authentikIssuer}/application/o/userinfo/`
|
||||
: undefined,
|
||||
scopes: ['openid', 'email', 'profile'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
|
||||
const corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { eq, asc, desc, type Db, conversations, messages } from '@mosaic/db';
|
||||
|
||||
/** Maximum number of conversations returned per list query. */
|
||||
const MAX_CONVERSATIONS = 200;
|
||||
/** Maximum number of messages returned per conversation history query. */
|
||||
const MAX_MESSAGES = 500;
|
||||
import { eq, type Db, conversations, messages } from '@mosaic/db';
|
||||
|
||||
export type Conversation = typeof conversations.$inferSelect;
|
||||
export type NewConversation = typeof conversations.$inferInsert;
|
||||
@@ -13,12 +8,7 @@ export type NewMessage = typeof messages.$inferInsert;
|
||||
export function createConversationsRepo(db: Db) {
|
||||
return {
|
||||
async findAll(userId: string): Promise<Conversation[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(eq(conversations.userId, userId))
|
||||
.orderBy(desc(conversations.updatedAt))
|
||||
.limit(MAX_CONVERSATIONS);
|
||||
return db.select().from(conversations).where(eq(conversations.userId, userId));
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Conversation | undefined> {
|
||||
@@ -46,12 +36,7 @@ export function createConversationsRepo(db: Db) {
|
||||
},
|
||||
|
||||
async findMessages(conversationId: string): Promise<Message[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, conversationId))
|
||||
.orderBy(asc(messages.createdAt))
|
||||
.limit(MAX_MESSAGES);
|
||||
return db.select().from(messages).where(eq(messages.conversationId, conversationId));
|
||||
},
|
||||
|
||||
async addMessage(data: NewMessage): Promise<Message> {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
DROP INDEX "agent_logs_session_id_idx";--> statement-breakpoint
|
||||
DROP INDEX "agent_logs_tier_idx";--> statement-breakpoint
|
||||
DROP INDEX "agent_logs_created_at_idx";--> statement-breakpoint
|
||||
DROP INDEX "conversations_user_id_idx";--> statement-breakpoint
|
||||
DROP INDEX "conversations_archived_idx";--> statement-breakpoint
|
||||
DROP INDEX "preferences_user_key_idx";--> statement-breakpoint
|
||||
CREATE INDEX "accounts_provider_account_idx" ON "accounts" USING btree ("provider_id","account_id");--> statement-breakpoint
|
||||
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "agent_logs_session_tier_idx" ON "agent_logs" USING btree ("session_id","tier");--> statement-breakpoint
|
||||
CREATE INDEX "agent_logs_tier_created_at_idx" ON "agent_logs" USING btree ("tier","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "conversations_user_archived_idx" ON "conversations" USING btree ("user_id","archived");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_expires_at_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "preferences_user_key_idx" ON "preferences" USING btree ("user_id","key");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,13 +22,6 @@
|
||||
"when": 1773625181629,
|
||||
"tag": "0002_nebulous_mimic",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1773887085247,
|
||||
"tag": "0003_p8003_perf_indexes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,15 +12,7 @@ export interface DbHandle {
|
||||
|
||||
export function createDb(url?: string): DbHandle {
|
||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
const sql = postgres(connectionString, {
|
||||
// Pool sizing: allow up to 20 concurrent connections per gateway instance.
|
||||
// Each NestJS module (brain, preferences, memory, coord) shares this pool.
|
||||
max: Number(process.env['DB_POOL_MAX'] ?? 20),
|
||||
// Recycle idle connections after 30 s to avoid stale TCP state.
|
||||
idle_timeout: Number(process.env['DB_IDLE_TIMEOUT'] ?? 30),
|
||||
// Fail fast (5 s) on connection problems rather than hanging indefinitely.
|
||||
connect_timeout: Number(process.env['DB_CONNECT_TIMEOUT'] ?? 5),
|
||||
});
|
||||
const sql = postgres(connectionString);
|
||||
const db = drizzle(sql, { schema });
|
||||
return { db, close: () => sql.end() };
|
||||
}
|
||||
|
||||
@@ -33,54 +33,36 @@ export const users = pgTable('users', {
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Auth hot path: look up all sessions for a user (BetterAuth session list).
|
||||
index('sessions_user_id_idx').on(t.userId),
|
||||
// Session expiry cleanup queries.
|
||||
index('sessions_expires_at_idx').on(t.expiresAt),
|
||||
],
|
||||
);
|
||||
export const sessions = pgTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
token: text('token').notNull().unique(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const accounts = pgTable(
|
||||
'accounts',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// BetterAuth looks up accounts by (provider_id, account_id) on OAuth callback.
|
||||
index('accounts_provider_account_idx').on(t.providerId, t.accountId),
|
||||
// Also used in session validation to find linked accounts for a user.
|
||||
index('accounts_user_id_idx').on(t.userId),
|
||||
],
|
||||
);
|
||||
export const accounts = pgTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
accessToken: text('access_token'),
|
||||
refreshToken: text('refresh_token'),
|
||||
idToken: text('id_token'),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||||
scope: text('scope'),
|
||||
password: text('password'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const verifications = pgTable('verifications', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -324,10 +306,10 @@ export const conversations = pgTable(
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Compound index for the most common query: conversations for a user filtered by archived.
|
||||
index('conversations_user_archived_idx').on(t.userId, t.archived),
|
||||
index('conversations_user_id_idx').on(t.userId),
|
||||
index('conversations_project_id_idx').on(t.projectId),
|
||||
index('conversations_agent_id_idx').on(t.agentId),
|
||||
index('conversations_archived_idx').on(t.archived),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -387,8 +369,7 @@ export const preferences = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
index('preferences_user_id_idx').on(t.userId),
|
||||
// Unique constraint enables single-round-trip INSERT … ON CONFLICT DO UPDATE.
|
||||
uniqueIndex('preferences_user_key_idx').on(t.userId, t.key),
|
||||
index('preferences_user_key_idx').on(t.userId, t.key),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -450,11 +431,10 @@ export const agentLogs = pgTable(
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
// Compound index for session log queries (most common: session + tier filter).
|
||||
index('agent_logs_session_tier_idx').on(t.sessionId, t.tier),
|
||||
index('agent_logs_session_id_idx').on(t.sessionId),
|
||||
index('agent_logs_user_id_idx').on(t.userId),
|
||||
// Used by summarization cron to find hot logs older than a cutoff.
|
||||
index('agent_logs_tier_created_at_idx').on(t.tier, t.createdAt),
|
||||
index('agent_logs_tier_idx').on(t.tier),
|
||||
index('agent_logs_created_at_idx').on(t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
662
pnpm-lock.yaml
generated
662
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user