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)
|
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
|
||||||
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
# OLLAMA_MODELS=llama3.2,codellama,mistral
|
||||||
|
|
||||||
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
|
# OpenAI — required for embedding and log-summarization features
|
||||||
# ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
|
|
||||||
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
|
|
||||||
# OPENAI_API_KEY=sk-...
|
# 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
|
# Custom providers — JSON array of provider configs
|
||||||
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
|
||||||
# MOSAIC_CUSTOM_PROVIDERS=
|
# MOSAIC_CUSTOM_PROVIDERS=
|
||||||
@@ -129,26 +123,7 @@ OTEL_SERVICE_NAME=mosaic-gateway
|
|||||||
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
# TELEGRAM_GATEWAY_URL=http://localhost:4000
|
||||||
|
|
||||||
|
|
||||||
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
|
# ─── Authentik SSO (optional — set AUTHENTIK_CLIENT_ID to enable) ────────────
|
||||||
|
|
||||||
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
|
|
||||||
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
|
||||||
# AUTHENTIK_CLIENT_ID=
|
# AUTHENTIK_CLIENT_ID=
|
||||||
# AUTHENTIK_CLIENT_SECRET=
|
# 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)
|
- ESM everywhere (`"type": "module"`, `.js` extensions in imports)
|
||||||
- NodeNext module resolution in all tsconfigs
|
- NodeNext module resolution in all tsconfigs
|
||||||
- Scratchpads are mandatory for non-trivial tasks
|
- 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 { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
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 { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||||
import type { TestConnectionResultDto } from './provider.dto.js';
|
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@@ -14,9 +14,6 @@ export class ProviderService implements OnModuleInit {
|
|||||||
this.registry = new ModelRegistry(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
this.registerOllamaProvider();
|
this.registerOllamaProvider();
|
||||||
this.registerAnthropicProvider();
|
|
||||||
this.registerOpenAIProvider();
|
|
||||||
this.registerZaiProvider();
|
|
||||||
this.registerCustomProviders();
|
this.registerCustomProviders();
|
||||||
|
|
||||||
const available = this.registry.getAvailable();
|
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)`);
|
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 {
|
private registerOllamaProvider(): void {
|
||||||
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||||
if (!ollamaUrl) return;
|
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 {
|
private toModelInfo(model: Model<Api>): ModelInfo {
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
|
|||||||
@@ -36,24 +36,16 @@ export class SessionGCService implements OnModuleInit {
|
|||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
async onModuleInit(): Promise<void> {
|
||||||
// Fire-and-forget: run full GC asynchronously so it does not block the
|
this.logger.log('Running full GC on cold start...');
|
||||||
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
const result = await this.fullCollect();
|
||||||
// depending on Valkey key count; deferring it removes that latency from
|
this.logger.log(
|
||||||
// the TTFB of the first HTTP request.
|
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||||
this.fullCollect()
|
`${result.logsDemoted} logs demoted, ` +
|
||||||
.then((result) => {
|
`${result.jobsPurged} jobs purged, ` +
|
||||||
this.logger.log(
|
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||||
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
`(${result.duration}ms)`,
|
||||||
`${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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,28 +5,34 @@ import type { Db } from '@mosaic/db';
|
|||||||
/**
|
/**
|
||||||
* Build a mock Drizzle DB where the select chain supports:
|
* Build a mock Drizzle DB where the select chain supports:
|
||||||
* db.select().from().where() → resolves to `listRows`
|
* 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 = {
|
const chainWithLimit = {
|
||||||
limit: vi.fn().mockResolvedValue([]),
|
limit: vi.fn().mockResolvedValue(singleRow),
|
||||||
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve),
|
||||||
};
|
};
|
||||||
const selectFrom = {
|
const selectFrom = {
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn().mockReturnValue(chainWithLimit),
|
where: vi.fn().mockReturnValue(chainWithLimit),
|
||||||
};
|
};
|
||||||
|
const updateResult = {
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
const deleteResult = {
|
const deleteResult = {
|
||||||
where: vi.fn().mockResolvedValue([]),
|
where: vi.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
// Single-round-trip upsert chain: insert().values().onConflictDoUpdate()
|
|
||||||
const insertResult = {
|
const insertResult = {
|
||||||
values: vi.fn().mockReturnThis(),
|
values: vi.fn().mockResolvedValue([]),
|
||||||
onConflictDoUpdate: vi.fn().mockResolvedValue([]),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
select: vi.fn().mockReturnValue(selectFrom),
|
select: vi.fn().mockReturnValue(selectFrom),
|
||||||
|
update: vi.fn().mockReturnValue(updateResult),
|
||||||
delete: vi.fn().mockReturnValue(deleteResult),
|
delete: vi.fn().mockReturnValue(deleteResult),
|
||||||
insert: vi.fn().mockReturnValue(insertResult),
|
insert: vi.fn().mockReturnValue(insertResult),
|
||||||
} as unknown as Db;
|
} as unknown as Db;
|
||||||
@@ -92,14 +98,23 @@ describe('PreferencesService', () => {
|
|||||||
expect(result.message).toContain('platform enforcement');
|
expect(result.message).toContain('platform enforcement');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('upserts a mutable preference and returns success', async () => {
|
it('upserts a mutable preference and returns success — insert path', async () => {
|
||||||
// Single-round-trip INSERT … ON CONFLICT DO UPDATE path.
|
// singleRow=[] → no existing row → insert path
|
||||||
const db = makeMockDb([]);
|
const db = makeMockDb([], []);
|
||||||
const service = new PreferencesService(db);
|
const service = new PreferencesService(db);
|
||||||
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
const result = await service.set('user-1', 'agent.thinkingLevel', 'high');
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('"agent.thinkingLevel"');
|
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', () => {
|
describe('reset', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
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';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
export const PLATFORM_DEFAULTS: Record<string, unknown> = {
|
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> {
|
private async upsertPref(userId: string, key: string, value: unknown): Promise<void> {
|
||||||
// Single-round-trip upsert using INSERT … ON CONFLICT DO UPDATE.
|
const existing = await this.db
|
||||||
// Previously this was two queries (SELECT + INSERT/UPDATE), which doubled
|
.select({ id: preferencesTable.id })
|
||||||
// the DB round-trips and introduced a TOCTOU window under concurrent writes.
|
.from(preferencesTable)
|
||||||
await this.db
|
.where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key)))
|
||||||
.insert(preferencesTable)
|
.limit(1);
|
||||||
.values({
|
|
||||||
|
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,
|
userId,
|
||||||
key,
|
key,
|
||||||
value: value as never,
|
value: value as never,
|
||||||
mutable: true,
|
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}`);
|
this.logger.debug(`Upserted preference "${key}" for user ${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,6 @@ import type { NextConfig } from 'next';
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
transpilePackages: ['@mosaic/design-tokens'],
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import { signIn } from '@/lib/auth-client';
|
import { signIn } from '@/lib/auth-client';
|
||||||
import { getEnabledSsoProviders } from '@/lib/sso-providers';
|
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const ssoProviders = getEnabledSsoProviders();
|
|
||||||
const hasSsoProviders = ssoProviders.length > 0;
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -47,26 +44,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSsoProviders && (
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
<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}>
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
|
||||||
Email
|
Email
|
||||||
|
|||||||
@@ -4,42 +4,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { destroySocket, getSocket } from '@/lib/socket';
|
import { destroySocket, getSocket } from '@/lib/socket';
|
||||||
import type { Conversation, Message } from '@/lib/types';
|
import type { Conversation, Message } from '@/lib/types';
|
||||||
import {
|
import { ConversationList } from '@/components/chat/conversation-list';
|
||||||
ConversationSidebar,
|
|
||||||
type ConversationSidebarRef,
|
|
||||||
} from '@/components/chat/conversation-sidebar';
|
|
||||||
import { MessageBubble } from '@/components/chat/message-bubble';
|
import { MessageBubble } from '@/components/chat/message-bubble';
|
||||||
import { ChatInput } from '@/components/chat/chat-input';
|
import { ChatInput } from '@/components/chat/chat-input';
|
||||||
import { StreamingMessage } from '@/components/chat/streaming-message';
|
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 {
|
export default function ChatPage(): React.ReactElement {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [streamingText, setStreamingText] = useState('');
|
const [streamingText, setStreamingText] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const sidebarRef = useRef<ConversationSidebarRef>(null);
|
|
||||||
|
|
||||||
// Track the active conversation ID in a ref so socket event handlers always
|
// Track the active conversation ID in a ref so socket event handlers always
|
||||||
// see the current value without needing to be re-registered.
|
// see the current value without needing to be re-registered.
|
||||||
@@ -50,30 +26,11 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
// without stale-closure issues.
|
// without stale-closure issues.
|
||||||
const streamingTextRef = useRef('');
|
const streamingTextRef = useRef('');
|
||||||
|
|
||||||
|
// Load conversations on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
|
api<Conversation[]>('/api/conversations')
|
||||||
if (savedState !== null) {
|
.then(setConversations)
|
||||||
setIsSidebarOpen(savedState === 'true');
|
.catch(() => {});
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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('');
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load messages when active conversation changes
|
// Load messages when active conversation changes
|
||||||
@@ -134,7 +91,6 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
createdAt: new Date().toISOString(),
|
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', {
|
const conv = await api<Conversation>('/api/conversations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: 'New conversation', projectId: projectId ?? null },
|
body: { title: 'New conversation' },
|
||||||
});
|
});
|
||||||
|
setConversations((prev) => [conv, ...prev]);
|
||||||
sidebarRef.current?.addConversation({
|
|
||||||
id: conv.id,
|
|
||||||
title: conv.title,
|
|
||||||
projectId: conv.projectId,
|
|
||||||
updatedAt: conv.updatedAt,
|
|
||||||
archived: conv.archived,
|
|
||||||
});
|
|
||||||
|
|
||||||
setActiveId(conv.id);
|
setActiveId(conv.id);
|
||||||
setMessages([]);
|
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(
|
const handleSend = useCallback(
|
||||||
async (content: string, options?: { modelId?: string }) => {
|
async (content: string) => {
|
||||||
let convId = activeId;
|
let convId = activeId;
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
// Auto-create conversation if none selected
|
||||||
@@ -205,24 +192,25 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { title: autoTitle },
|
body: { title: autoTitle },
|
||||||
});
|
});
|
||||||
sidebarRef.current?.addConversation({
|
setConversations((prev) => [conv, ...prev]);
|
||||||
id: conv.id,
|
|
||||||
title: conv.title,
|
|
||||||
projectId: conv.projectId,
|
|
||||||
updatedAt: conv.updatedAt,
|
|
||||||
archived: conv.archived,
|
|
||||||
});
|
|
||||||
setActiveId(conv.id);
|
setActiveId(conv.id);
|
||||||
convId = conv.id;
|
convId = conv.id;
|
||||||
} else if (messages.length === 0) {
|
} else {
|
||||||
// Auto-title the initial placeholder conversation from the first user message.
|
// Auto-title: if the active conversation still has the default "New
|
||||||
const autoTitle = content.slice(0, 60);
|
// conversation" title and this is the first message, update the title
|
||||||
api<Conversation>(`/api/conversations/${convId}`, {
|
// from the message content.
|
||||||
method: 'PATCH',
|
const activeConv = conversations.find((c) => c.id === convId);
|
||||||
body: { title: autoTitle },
|
if (activeConv?.title === 'New conversation' && messages.length === 0) {
|
||||||
})
|
const autoTitle = content.slice(0, 60);
|
||||||
.then(() => sidebarRef.current?.refresh())
|
api<Conversation>(`/api/conversations/${convId}`, {
|
||||||
.catch(() => {});
|
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
|
// Optimistic user message in local UI state
|
||||||
@@ -253,67 +241,24 @@ export default function ChatPage(): React.ReactElement {
|
|||||||
if (!socket.connected) {
|
if (!socket.connected) {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
}
|
}
|
||||||
socket.emit('message', {
|
socket.emit('message', { conversationId: convId, content });
|
||||||
conversationId: convId,
|
|
||||||
content,
|
|
||||||
modelId: (options?.modelId ?? selectedModelId) || undefined,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[activeId, messages, selectedModelId],
|
[activeId, conversations, messages],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="-m-6 flex h-[calc(100vh-3.5rem)]">
|
||||||
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
|
<ConversationList
|
||||||
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
|
conversations={conversations}
|
||||||
>
|
activeId={activeId}
|
||||||
<ConversationSidebar
|
onSelect={setActiveId}
|
||||||
ref={sidebarRef}
|
onNew={handleNewConversation}
|
||||||
isOpen={isSidebarOpen}
|
onRename={handleRename}
|
||||||
onClose={() => setIsSidebarOpen(false)}
|
onDelete={handleDelete}
|
||||||
currentConversationId={activeId}
|
onArchive={handleArchive}
|
||||||
onSelectConversation={(conversationId) => {
|
|
||||||
setActiveId(conversationId);
|
|
||||||
setMessages([]);
|
|
||||||
if (conversationId && window.innerWidth < 768) {
|
|
||||||
setIsSidebarOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onNewConversation={(projectId) => {
|
|
||||||
void handleNewConversation(projectId);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex 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>
|
|
||||||
|
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-6">
|
<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} />}
|
{isStreaming && <StreamingMessage text={streamingText} />}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
<ChatInput
|
<ChatInput onSend={handleSend} disabled={isStreaming} />
|
||||||
onSend={handleSend}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
models={models}
|
|
||||||
selectedModelId={selectedModelId}
|
|
||||||
onModelChange={setSelectedModelId}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 items-center justify-center px-6">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<div
|
<div className="text-center">
|
||||||
className="max-w-md rounded-2xl border px-8 py-10 text-center"
|
<h2 className="text-lg font-medium text-text-secondary">Welcome to Mosaic Chat</h2>
|
||||||
style={{
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
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)' }}>
|
|
||||||
Select a conversation or start a new one
|
Select a conversation or start a new one
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleNewConversation}
|
||||||
void 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"
|
||||||
}}
|
|
||||||
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
|
|
||||||
style={{ background: 'var(--primary)' }}
|
|
||||||
>
|
>
|
||||||
Start new conversation
|
Start new conversation
|
||||||
</button>
|
</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;
|
--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 ─── */
|
/* ─── Base styles ─── */
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-surface-bg);
|
background-color: var(--color-surface-bg);
|
||||||
|
|||||||
@@ -1,192 +1,52 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { ModelInfo } from '@/lib/types';
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (content: string, options?: { modelId?: string }) => void;
|
onSend: (content: string) => void;
|
||||||
onStop?: () => void;
|
disabled?: boolean;
|
||||||
isStreaming?: boolean;
|
|
||||||
models: ModelInfo[];
|
|
||||||
selectedModelId: string;
|
|
||||||
onModelChange: (modelId: string) => void;
|
|
||||||
onRequestEditLastMessage?: () => string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_HEIGHT = 220;
|
export function ChatInput({ onSend, disabled }: ChatInputProps): React.ReactElement {
|
||||||
|
|
||||||
export function ChatInput({
|
|
||||||
onSend,
|
|
||||||
onStop,
|
|
||||||
isStreaming = false,
|
|
||||||
models,
|
|
||||||
selectedModelId,
|
|
||||||
onModelChange,
|
|
||||||
onRequestEditLastMessage,
|
|
||||||
}: ChatInputProps): React.ReactElement {
|
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const selectedModel = useMemo(
|
|
||||||
() => models.find((model) => model.id === selectedModelId) ?? models[0],
|
|
||||||
[models, selectedModelId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
function handleSubmit(e: React.FormEvent): void {
|
||||||
const textarea = textareaRef.current;
|
e.preventDefault();
|
||||||
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();
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed || isStreaming) return;
|
if (!trimmed || disabled) return;
|
||||||
onSend(trimmed, { modelId: selectedModel?.id });
|
onSend(trimmed);
|
||||||
setValue('');
|
setValue('');
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
event.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit(event);
|
handleSubmit(e);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowUp' && value.length === 0 && onRequestEditLastMessage) {
|
|
||||||
const lastMessage = onRequestEditLastMessage();
|
|
||||||
if (lastMessage) {
|
|
||||||
event.preventDefault();
|
|
||||||
setValue(lastMessage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const charCount = value.length;
|
|
||||||
const tokenEstimate = Math.ceil(charCount / 4);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmit} className="border-t border-surface-border bg-surface-card p-4">
|
||||||
onSubmit={handleSubmit}
|
<div className="flex items-end gap-3">
|
||||||
className="border-t px-4 py-4 backdrop-blur-xl md:px-6"
|
<textarea
|
||||||
style={{
|
ref={textareaRef}
|
||||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 88%, transparent)',
|
value={value}
|
||||||
borderColor: 'var(--color-border)',
|
onChange={(e) => setValue(e.target.value)}
|
||||||
}}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
disabled={disabled}
|
||||||
<div
|
rows={1}
|
||||||
className="rounded-[28px] border p-3 shadow-[var(--shadow-ms-lg)]"
|
placeholder="Type a message... (Enter to send, Shift+Enter for newline)"
|
||||||
style={{
|
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"
|
||||||
backgroundColor: 'var(--color-surface-2)',
|
/>
|
||||||
borderColor: 'var(--color-border)',
|
<button
|
||||||
}}
|
type="submit"
|
||||||
>
|
disabled={disabled || !value.trim()}
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
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"
|
||||||
<label className="flex min-w-0 items-center gap-2 text-xs text-[var(--color-muted)]">
|
>
|
||||||
<span className="uppercase tracking-[0.18em]">Model</span>
|
Send
|
||||||
<select
|
</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import type { Conversation } from '@/lib/types';
|
|||||||
interface ConversationListProps {
|
interface ConversationListProps {
|
||||||
conversations: Conversation[];
|
conversations: Conversation[];
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onRename: (id: string, title: string) => void;
|
onRename: (id: string, title: string) => void;
|
||||||
@@ -22,6 +20,7 @@ interface ContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
|
||||||
function formatRelativeTime(dateStr: string): string {
|
function formatRelativeTime(dateStr: string): string {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -41,8 +40,6 @@ function formatRelativeTime(dateStr: string): string {
|
|||||||
export function ConversationList({
|
export function ConversationList({
|
||||||
conversations,
|
conversations,
|
||||||
activeId,
|
activeId,
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onNew,
|
onNew,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -57,24 +54,24 @@ export function ConversationList({
|
|||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const activeConversations = conversations.filter((conversation) => !conversation.archived);
|
const activeConversations = conversations.filter((c) => !c.archived);
|
||||||
const archivedConversations = conversations.filter((conversation) => conversation.archived);
|
const archivedConversations = conversations.filter((c) => c.archived);
|
||||||
|
|
||||||
const filteredActive = searchQuery
|
const filteredActive = searchQuery
|
||||||
? activeConversations.filter((conversation) =>
|
? activeConversations.filter((c) =>
|
||||||
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
)
|
)
|
||||||
: activeConversations;
|
: activeConversations;
|
||||||
|
|
||||||
const filteredArchived = searchQuery
|
const filteredArchived = searchQuery
|
||||||
? archivedConversations.filter((conversation) =>
|
? archivedConversations.filter((c) =>
|
||||||
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
)
|
)
|
||||||
: archivedConversations;
|
: archivedConversations;
|
||||||
|
|
||||||
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => {
|
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
||||||
event.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ conversationId, x: event.clientX, y: event.clientY });
|
setContextMenu({ conversationId, x: e.clientX, y: e.clientY });
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -100,7 +97,7 @@ export function ConversationList({
|
|||||||
}
|
}
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
setRenameValue('');
|
setRenameValue('');
|
||||||
}, [onRename, renameValue, renamingId]);
|
}, [renamingId, renameValue, onRename]);
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancelRename = useCallback(() => {
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
@@ -108,20 +105,24 @@ export function ConversationList({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRenameKeyDown = useCallback(
|
const handleRenameKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'Enter') commitRename();
|
if (e.key === 'Enter') commitRename();
|
||||||
if (event.key === 'Escape') cancelRename();
|
if (e.key === 'Escape') cancelRename();
|
||||||
},
|
},
|
||||||
[cancelRename, commitRename],
|
[commitRename, cancelRename],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((id: string) => {
|
||||||
|
setDeleteConfirmId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const confirmDelete = useCallback(
|
const confirmDelete = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
onDelete(id);
|
onDelete(id);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
},
|
},
|
||||||
[closeContextMenu, onDelete],
|
[onDelete, closeContextMenu],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveToggle = useCallback(
|
const handleArchiveToggle = useCallback(
|
||||||
@@ -129,59 +130,47 @@ export function ConversationList({
|
|||||||
onArchive(id, archived);
|
onArchive(id, archived);
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
},
|
},
|
||||||
[closeContextMenu, onArchive],
|
[onArchive, closeContextMenu],
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextConversation = contextMenu
|
const contextConv = contextMenu
|
||||||
? conversations.find((conversation) => conversation.id === contextMenu.conversationId)
|
? conversations.find((c) => c.id === contextMenu.conversationId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
function renderConversationItem(conversation: Conversation): React.ReactElement {
|
function renderConversationItem(conv: Conversation): React.ReactElement {
|
||||||
const isActive = activeId === conversation.id;
|
const isActive = activeId === conv.id;
|
||||||
const isRenaming = renamingId === conversation.id;
|
const isRenaming = renamingId === conv.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={conversation.id} className="group relative">
|
<div key={conv.id} className="group relative">
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
onChange={(event) => setRenameValue(event.target.value)}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
onBlur={commitRename}
|
onBlur={commitRename}
|
||||||
onKeyDown={handleRenameKeyDown}
|
onKeyDown={handleRenameKeyDown}
|
||||||
className="w-full rounded-xl border px-3 py-2 text-sm outline-none"
|
className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--color-ms-blue-500)',
|
|
||||||
backgroundColor: 'var(--color-surface-2)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
maxLength={255}
|
maxLength={255}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onSelect(conv.id)}
|
||||||
onSelect(conversation.id);
|
onDoubleClick={() => startRename(conv.id, conv.title)}
|
||||||
if (window.innerWidth < 768) onClose();
|
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
||||||
}}
|
|
||||||
onDoubleClick={() => startRename(conversation.id, conversation.title)}
|
|
||||||
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors',
|
'w-full px-3 py-2 text-left text-sm transition-colors',
|
||||||
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5',
|
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 truncate">{conv.title ?? 'Untitled'}</span>
|
||||||
<span className="block text-xs text-[var(--color-muted)]">
|
<span className="block text-xs text-text-muted">
|
||||||
{formatRelativeTime(conversation.updatedAt)}
|
{formatRelativeTime(conv.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -191,138 +180,127 @@ export function ConversationList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen ? (
|
{/* Backdrop to close context menu */}
|
||||||
<button
|
{contextMenu && (
|
||||||
type="button"
|
|
||||||
className="fixed inset-0 z-20 bg-black/45 md:hidden"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close conversation sidebar"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{contextMenu ? (
|
|
||||||
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
||||||
className={cn(
|
{/* Header */}
|
||||||
'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',
|
<div className="flex items-center justify-between p-3">
|
||||||
isOpen
|
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
||||||
? '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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNew}
|
onClick={onNew}
|
||||||
className="rounded-full px-3 py-1 text-xs transition-colors hover:bg-white/5"
|
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
||||||
style={{ color: 'var(--color-ms-blue-400)' }}
|
|
||||||
>
|
>
|
||||||
+ New
|
+ New
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pb-3">
|
{/* Search input */}
|
||||||
|
<div className="px-3 pb-2">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => setSearchQuery(event.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Search conversations…"
|
placeholder="Search conversations\u2026"
|
||||||
className="w-full rounded-2xl border px-3 py-2 text-xs placeholder:text-[var(--color-muted)] focus:outline-none"
|
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"
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--color-surface-2)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-1">
|
{/* Conversation list */}
|
||||||
{filteredActive.length === 0 && !searchQuery ? (
|
<div className="flex-1 overflow-y-auto">
|
||||||
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p>
|
{filteredActive.length === 0 && !searchQuery && (
|
||||||
) : null}
|
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
||||||
{filteredActive.length === 0 && searchQuery ? (
|
)}
|
||||||
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">
|
{filteredActive.length === 0 && searchQuery && (
|
||||||
No results for “{searchQuery}”
|
<p className="px-3 py-2 text-xs text-text-muted">
|
||||||
|
No results for “{searchQuery}”
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
)}
|
||||||
{filteredActive.map((conversation) => renderConversationItem(conversation))}
|
{filteredActive.map((conv) => renderConversationItem(conv))}
|
||||||
|
|
||||||
{archivedConversations.length > 0 ? (
|
{/* Archived section */}
|
||||||
<div className="pt-2">
|
{archivedConversations.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowArchived((prev) => !prev)}
|
onClick={() => setShowArchived((v) => !v)}
|
||||||
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)]"
|
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('inline-block transition-transform', showArchived && 'rotate-90')}
|
className={cn(
|
||||||
|
'inline-block transition-transform',
|
||||||
|
showArchived ? 'rotate-90' : '',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
▶
|
►
|
||||||
</span>
|
</span>
|
||||||
Archived ({archivedConversations.length})
|
Archived ({archivedConversations.length})
|
||||||
</button>
|
</button>
|
||||||
{showArchived ? (
|
{showArchived && (
|
||||||
<div className="mt-1 space-y-1 opacity-70">
|
<div className="opacity-60">
|
||||||
{filteredArchived.map((conversation) => renderConversationItem(conversation))}
|
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contextMenu && contextConversation ? (
|
{/* Context menu */}
|
||||||
|
{contextMenu && contextConv && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]"
|
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
|
||||||
style={{
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
top: contextMenu.y,
|
|
||||||
left: contextMenu.x,
|
|
||||||
backgroundColor: 'var(--color-surface)',
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
||||||
onClick={() => startRename(contextConversation.id, contextConversation.title)}
|
onClick={() => startRename(contextConv.id, contextConv.title)}
|
||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
||||||
onClick={() =>
|
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
|
||||||
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{contextConversation.archived ? 'Restore' : 'Archive'}
|
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
||||||
</button>
|
</button>
|
||||||
{deleteConfirmId === contextConversation.id ? (
|
<hr className="my-1 border-surface-border" />
|
||||||
<button
|
{deleteConfirmId === contextConv.id ? (
|
||||||
type="button"
|
<div className="px-3 py-1.5">
|
||||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
|
||||||
onClick={() => confirmDelete(contextConversation.id)}
|
<div className="flex gap-2">
|
||||||
>
|
<button
|
||||||
Confirm delete
|
type="button"
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
||||||
onClick={() => setDeleteConfirmId(contextConversation.id)}
|
onClick={() => handleDeleteClick(contextConv.id)}
|
||||||
>
|
>
|
||||||
Delete…
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Message } from '@/lib/types';
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
@@ -11,261 +9,27 @@ interface MessageBubbleProps {
|
|||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
export function MessageBubble({ message }: MessageBubbleProps): React.ReactElement {
|
||||||
const isUser = message.role === 'user';
|
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 (
|
return (
|
||||||
<div className={cn('group flex', isUser ? 'justify-end' : 'justify-start')}>
|
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex max-w-[min(78ch,85%)] flex-col gap-2',
|
'max-w-[75%] rounded-xl px-4 py-3 text-sm',
|
||||||
isUser ? 'items-end' : 'items-start',
|
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')}>
|
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
||||||
<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
|
<div
|
||||||
className={cn(
|
className={cn('mt-1 text-right text-xs', isUser ? 'text-blue-200' : 'text-text-muted')}
|
||||||
'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)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="max-w-none">
|
{new Date(message.createdAt).toLocaleTimeString([], {
|
||||||
<ReactMarkdown
|
hour: '2-digit',
|
||||||
components={{
|
minute: '2-digit',
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
/** Renders an in-progress assistant message from streaming text. */
|
||||||
|
|
||||||
interface StreamingMessageProps {
|
interface StreamingMessageProps {
|
||||||
text: string;
|
text: string;
|
||||||
modelName?: string | null;
|
|
||||||
thinking?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const WAITING_QUIPS = [
|
export function StreamingMessage({ text }: StreamingMessageProps): React.ReactElement {
|
||||||
'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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div
|
<div className="max-w-[75%] rounded-xl border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-primary">
|
||||||
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>
|
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words">{text}</div>
|
<div className="whitespace-pre-wrap break-words">{text}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-[var(--color-muted)]">
|
<div className="flex items-center gap-2 text-text-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-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-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]" />
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500 [animation-delay:0.4s]" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{thinking ? (
|
<div className="mt-1 flex items-center gap-1 text-xs text-text-muted">
|
||||||
<div
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500" />
|
||||||
className="mt-3 rounded-2xl border px-3 py-2 font-mono text-xs whitespace-pre-wrap"
|
{text ? 'Responding...' : 'Thinking...'}
|
||||||
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>
|
</div>
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useSession, signOut } from '@/lib/auth-client';
|
import { useSession, signOut } from '@/lib/auth-client';
|
||||||
|
|
||||||
export function Topbar(): React.ReactElement {
|
export function Topbar(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
if (pathname.startsWith('/chat')) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSignOut(): Promise<void> {
|
async function handleSignOut(): Promise<void> {
|
||||||
await signOut();
|
await signOut();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createAuthClient } from 'better-auth/react';
|
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({
|
export const authClient = createAuthClient({
|
||||||
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
baseURL: process.env['NEXT_PUBLIC_GATEWAY_URL'] ?? 'http://localhost:4000',
|
||||||
plugins: [adminClient(), genericOAuthClient()],
|
plugins: [adminClient()],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
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;
|
conversationId: string;
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
thinking?: string;
|
|
||||||
model?: string;
|
|
||||||
provider?: string;
|
|
||||||
promptTokens?: number;
|
|
||||||
completionTokens?: number;
|
|
||||||
totalTokens?: number;
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
createdAt: string;
|
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. */
|
/** Task statuses. */
|
||||||
export type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
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-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-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-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-001 | not-started | codex | Phase 8 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||||
| P8-002 | in-progress | codex | Phase 8 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
| P8-002 | not-started | 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-003 | not-started | codex | Phase 8 | Performance optimization | — | #56 |
|
||||||
| P8-004 | done | haiku | Phase 8 | Beta release gate — v0.1.0 tag | — | #59 |
|
| 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 |
|
| FIX-01 | done | Backlog | Call piSession.dispose() in AgentService.destroySession | #78 | #62 |
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
# BUG-CLI Scratchpad
|
# BUG-CLI Scratchpad
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
- #192: Ctrl+T leaks 't' into input
|
- #192: Ctrl+T leaks 't' into input
|
||||||
- #193: Duplicate React keys in CommandAutocomplete
|
- #193: Duplicate React keys in CommandAutocomplete
|
||||||
- #194: /provider login false clipboard claim
|
- #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
|
## Plan and Fixes
|
||||||
|
|
||||||
### Bug #192 — Ctrl+T character leak
|
### Bug #192 — Ctrl+T character leak
|
||||||
|
|
||||||
- Location: `packages/cli/src/tui/app.tsx`
|
- Location: `packages/cli/src/tui/app.tsx`
|
||||||
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
|
- 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
|
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
|
||||||
leaked character and return early.
|
leaked character and return early.
|
||||||
|
|
||||||
### Bug #193 — Duplicate React keys
|
### Bug #193 — Duplicate React keys
|
||||||
|
|
||||||
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
|
||||||
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
|
- 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
|
- Also: `packages/cli/src/tui/commands/registry.ts` — `getAll()` now deduplicates gateway commands
|
||||||
that share a name with local commands. Local commands take precedence.
|
that share a name with local commands. Local commands take precedence.
|
||||||
|
|
||||||
### Bug #194 — False clipboard claim
|
### Bug #194 — False clipboard claim
|
||||||
|
|
||||||
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
- Location: `apps/gateway/src/commands/command-executor.service.ts`
|
||||||
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
|
||||||
|
|
||||||
### Bug #199 — Hardcoded version "0.0.0"
|
### Bug #199 — Hardcoded version "0.0.0"
|
||||||
|
|
||||||
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
|
- 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`
|
- 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'),
|
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"`.
|
passes it to TopBar instead of hardcoded `"0.0.0"`.
|
||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
|
|
||||||
- CLI typecheck: PASSED
|
- CLI typecheck: PASSED
|
||||||
- CLI lint: PASSED
|
- CLI lint: PASSED
|
||||||
- Prettier format:check: 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 { betterAuth } from 'better-auth';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { admin } from 'better-auth/plugins';
|
import { admin, genericOAuth } from 'better-auth/plugins';
|
||||||
import { genericOAuth, keycloak, type GenericOAuthConfig } from 'better-auth/plugins/generic-oauth';
|
|
||||||
import type { Db } from '@mosaic/db';
|
import type { Db } from '@mosaic/db';
|
||||||
|
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
@@ -10,118 +9,35 @@ export interface AuthConfig {
|
|||||||
secret?: string;
|
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) {
|
export function createAuth(config: AuthConfig) {
|
||||||
const { db, baseURL, secret } = config;
|
const { db, baseURL, secret } = config;
|
||||||
|
const authentikIssuer = process.env['AUTHENTIK_ISSUER'];
|
||||||
const oauthProviders = buildOAuthProviders();
|
const authentikClientId = process.env['AUTHENTIK_CLIENT_ID'];
|
||||||
const plugins =
|
const authentikClientSecret = process.env['AUTHENTIK_CLIENT_SECRET'];
|
||||||
oauthProviders.length > 0 ? [genericOAuth({ config: oauthProviders })] : undefined;
|
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 corsOrigin = process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000';
|
||||||
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
const trustedOrigins = corsOrigin.split(',').map((o) => o.trim());
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { eq, asc, desc, type Db, conversations, messages } from '@mosaic/db';
|
import { eq, 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;
|
|
||||||
|
|
||||||
export type Conversation = typeof conversations.$inferSelect;
|
export type Conversation = typeof conversations.$inferSelect;
|
||||||
export type NewConversation = typeof conversations.$inferInsert;
|
export type NewConversation = typeof conversations.$inferInsert;
|
||||||
@@ -13,12 +8,7 @@ export type NewMessage = typeof messages.$inferInsert;
|
|||||||
export function createConversationsRepo(db: Db) {
|
export function createConversationsRepo(db: Db) {
|
||||||
return {
|
return {
|
||||||
async findAll(userId: string): Promise<Conversation[]> {
|
async findAll(userId: string): Promise<Conversation[]> {
|
||||||
return db
|
return db.select().from(conversations).where(eq(conversations.userId, userId));
|
||||||
.select()
|
|
||||||
.from(conversations)
|
|
||||||
.where(eq(conversations.userId, userId))
|
|
||||||
.orderBy(desc(conversations.updatedAt))
|
|
||||||
.limit(MAX_CONVERSATIONS);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async findById(id: string): Promise<Conversation | undefined> {
|
async findById(id: string): Promise<Conversation | undefined> {
|
||||||
@@ -46,12 +36,7 @@ export function createConversationsRepo(db: Db) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async findMessages(conversationId: string): Promise<Message[]> {
|
async findMessages(conversationId: string): Promise<Message[]> {
|
||||||
return db
|
return db.select().from(messages).where(eq(messages.conversationId, conversationId));
|
||||||
.select()
|
|
||||||
.from(messages)
|
|
||||||
.where(eq(messages.conversationId, conversationId))
|
|
||||||
.orderBy(asc(messages.createdAt))
|
|
||||||
.limit(MAX_MESSAGES);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async addMessage(data: NewMessage): Promise<Message> {
|
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,
|
"when": 1773625181629,
|
||||||
"tag": "0002_nebulous_mimic",
|
"tag": "0002_nebulous_mimic",
|
||||||
"breakpoints": true
|
"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 {
|
export function createDb(url?: string): DbHandle {
|
||||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||||
const sql = postgres(connectionString, {
|
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 db = drizzle(sql, { schema });
|
const db = drizzle(sql, { schema });
|
||||||
return { db, close: () => sql.end() };
|
return { db, close: () => sql.end() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,54 +33,36 @@ export const users = pgTable('users', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessions = pgTable(
|
export const sessions = pgTable('sessions', {
|
||||||
'sessions',
|
id: text('id').primaryKey(),
|
||||||
{
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
id: text('id').primaryKey(),
|
token: text('token').notNull().unique(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
ipAddress: text('ip_address'),
|
||||||
token: text('token').notNull().unique(),
|
userAgent: text('user_agent'),
|
||||||
ipAddress: text('ip_address'),
|
userId: text('user_id')
|
||||||
userAgent: text('user_agent'),
|
.notNull()
|
||||||
userId: text('user_id')
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
.notNull()
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
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 accounts = pgTable(
|
export const accounts = pgTable('accounts', {
|
||||||
'accounts',
|
id: text('id').primaryKey(),
|
||||||
{
|
accountId: text('account_id').notNull(),
|
||||||
id: text('id').primaryKey(),
|
providerId: text('provider_id').notNull(),
|
||||||
accountId: text('account_id').notNull(),
|
userId: text('user_id')
|
||||||
providerId: text('provider_id').notNull(),
|
.notNull()
|
||||||
userId: text('user_id')
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
.notNull()
|
accessToken: text('access_token'),
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
refreshToken: text('refresh_token'),
|
||||||
accessToken: text('access_token'),
|
idToken: text('id_token'),
|
||||||
refreshToken: text('refresh_token'),
|
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
||||||
idToken: text('id_token'),
|
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
||||||
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
|
scope: text('scope'),
|
||||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
|
password: text('password'),
|
||||||
scope: text('scope'),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
password: text('password'),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
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 verifications = pgTable('verifications', {
|
export const verifications = pgTable('verifications', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -324,10 +306,10 @@ export const conversations = pgTable(
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
// Compound index for the most common query: conversations for a user filtered by archived.
|
index('conversations_user_id_idx').on(t.userId),
|
||||||
index('conversations_user_archived_idx').on(t.userId, t.archived),
|
|
||||||
index('conversations_project_id_idx').on(t.projectId),
|
index('conversations_project_id_idx').on(t.projectId),
|
||||||
index('conversations_agent_id_idx').on(t.agentId),
|
index('conversations_agent_id_idx').on(t.agentId),
|
||||||
|
index('conversations_archived_idx').on(t.archived),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -387,8 +369,7 @@ export const preferences = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index('preferences_user_id_idx').on(t.userId),
|
index('preferences_user_id_idx').on(t.userId),
|
||||||
// Unique constraint enables single-round-trip INSERT … ON CONFLICT DO UPDATE.
|
index('preferences_user_key_idx').on(t.userId, t.key),
|
||||||
uniqueIndex('preferences_user_key_idx').on(t.userId, t.key),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -450,11 +431,10 @@ export const agentLogs = pgTable(
|
|||||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
// Compound index for session log queries (most common: session + tier filter).
|
index('agent_logs_session_id_idx').on(t.sessionId),
|
||||||
index('agent_logs_session_tier_idx').on(t.sessionId, t.tier),
|
|
||||||
index('agent_logs_user_id_idx').on(t.userId),
|
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_idx').on(t.tier),
|
||||||
index('agent_logs_tier_created_at_idx').on(t.tier, t.createdAt),
|
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