feat(web): provider management UI — list, test, model capabilities (#123) #142
17
apps/gateway/src/agent/provider.dto.ts
Normal file
17
apps/gateway/src/agent/provider.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface TestConnectionDto {
|
||||||
|
/** Provider identifier to test (e.g. 'ollama', custom provider id) */
|
||||||
|
providerId: string;
|
||||||
|
/** Optional base URL override for ad-hoc testing */
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestConnectionResultDto {
|
||||||
|
providerId: string;
|
||||||
|
reachable: boolean;
|
||||||
|
/** Round-trip latency in milliseconds (present when reachable) */
|
||||||
|
latencyMs?: number;
|
||||||
|
/** Human-readable error when unreachable */
|
||||||
|
error?: string;
|
||||||
|
/** Model ids discovered at the remote endpoint (present when reachable) */
|
||||||
|
discoveredModels?: string[];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
|||||||
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
import { ModelRegistry, AuthStorage } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Model, Api } from '@mariozechner/pi-ai';
|
import type { Model, Api } from '@mariozechner/pi-ai';
|
||||||
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
import type { ModelInfo, ProviderInfo, CustomProviderConfig } from '@mosaic/types';
|
||||||
|
import type { TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProviderService implements OnModuleInit {
|
export class ProviderService implements OnModuleInit {
|
||||||
@@ -64,6 +65,63 @@ export class ProviderService implements OnModuleInit {
|
|||||||
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
return this.registry.getAvailable().map((m) => this.toModelInfo(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async testConnection(providerId: string, baseUrl?: string): Promise<TestConnectionResultDto> {
|
||||||
|
// Resolve baseUrl: explicit override > registered provider > ollama env
|
||||||
|
let resolvedUrl = baseUrl;
|
||||||
|
|
||||||
|
if (!resolvedUrl) {
|
||||||
|
const allModels = this.registry.getAll();
|
||||||
|
const providerModels = allModels.filter((m) => m.provider === providerId);
|
||||||
|
if (providerModels.length === 0) {
|
||||||
|
return { providerId, reachable: false, error: `Provider '${providerId}' not found` };
|
||||||
|
}
|
||||||
|
// For Ollama, derive the base URL from environment
|
||||||
|
if (providerId === 'ollama') {
|
||||||
|
const ollamaUrl = process.env['OLLAMA_BASE_URL'] ?? process.env['OLLAMA_HOST'];
|
||||||
|
if (!ollamaUrl) {
|
||||||
|
return { providerId, reachable: false, error: 'OLLAMA_BASE_URL not configured' };
|
||||||
|
}
|
||||||
|
resolvedUrl = `${ollamaUrl}/v1/models`;
|
||||||
|
} else {
|
||||||
|
// For other providers, we can only do a basic check
|
||||||
|
return { providerId, reachable: true, discoveredModels: providerModels.map((m) => m.id) };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedUrl = resolvedUrl.replace(/\/?$/, '') + '/models';
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(resolvedUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return { providerId, reachable: false, latencyMs, error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let discoveredModels: string[] | undefined;
|
||||||
|
try {
|
||||||
|
const json = (await res.json()) as { models?: Array<{ id?: string; name?: string }> };
|
||||||
|
if (Array.isArray(json.models)) {
|
||||||
|
discoveredModels = json.models.map((m) => m.id ?? m.name ?? '').filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors — endpoint was reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providerId, reachable: true, latencyMs, discoveredModels };
|
||||||
|
} catch (err) {
|
||||||
|
const latencyMs = Date.now() - start;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { providerId, reachable: false, latencyMs, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
registerCustomProvider(config: CustomProviderConfig): void {
|
registerCustomProvider(config: CustomProviderConfig): void {
|
||||||
this.registry.registerProvider(config.id, {
|
this.registry.registerProvider(config.id, {
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RoutingCriteria } from '@mosaic/types';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { RoutingService } from './routing.service.js';
|
import { RoutingService } from './routing.service.js';
|
||||||
|
import type { TestConnectionDto, TestConnectionResultDto } from './provider.dto.js';
|
||||||
|
|
||||||
@Controller('api/providers')
|
@Controller('api/providers')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -22,6 +23,11 @@ export class ProvidersController {
|
|||||||
return this.providerService.listAvailableModels();
|
return this.providerService.listAvailableModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('test')
|
||||||
|
testConnection(@Body() body: TestConnectionDto): Promise<TestConnectionResultDto> {
|
||||||
|
return this.providerService.testConnection(body.providerId, body.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('route')
|
@Post('route')
|
||||||
route(@Body() criteria: RoutingCriteria) {
|
route(@Body() criteria: RoutingCriteria) {
|
||||||
return this.routingService.route(criteria);
|
return this.routingService.route(criteria);
|
||||||
|
|||||||
@@ -1,42 +1,85 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useSession } from '@/lib/auth-client';
|
import { useSession } from '@/lib/auth-client';
|
||||||
|
|
||||||
interface ProviderInfo {
|
|
||||||
name: string;
|
|
||||||
enabled: boolean;
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
provider: string;
|
provider: string;
|
||||||
contextWindow: number;
|
name: string;
|
||||||
reasoning: boolean;
|
reasoning: boolean;
|
||||||
cost: { input: number; output: number };
|
contextWindow: number;
|
||||||
|
maxTokens: number;
|
||||||
|
inputTypes: ('text' | 'image')[];
|
||||||
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
models: ModelInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestConnectionResult {
|
||||||
|
providerId: string;
|
||||||
|
reachable: boolean;
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
discoveredModels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
||||||
|
|
||||||
|
interface ProviderTestStatus {
|
||||||
|
state: TestState;
|
||||||
|
result?: TestConnectionResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage(): React.ReactElement {
|
export default function SettingsPage(): React.ReactElement {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
api<ProviderInfo[]>('/api/providers')
|
||||||
api<ProviderInfo[]>('/api/providers').catch(() => []),
|
.catch(() => [] as ProviderInfo[])
|
||||||
api<ModelInfo[]>('/api/providers/models').catch(() => []),
|
.then((p) => setProviders(p))
|
||||||
])
|
|
||||||
.then(([p, m]) => {
|
|
||||||
setProviders(p);
|
|
||||||
setModels(m);
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
||||||
|
setTestStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { state: 'testing' },
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const result = await api<TestConnectionResult>('/api/providers/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { providerId },
|
||||||
|
});
|
||||||
|
setTestStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
setTestStatuses((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
state: 'error',
|
||||||
|
result: { providerId, reachable: false, error: 'Request failed' },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derive default model: first available model across all providers
|
||||||
|
const defaultModel: ModelInfo | undefined = providers
|
||||||
|
.flatMap((p) => p.models)
|
||||||
|
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||||
@@ -64,82 +107,250 @@ export default function SettingsPage(): React.ReactElement {
|
|||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
<p className="text-sm text-text-muted">Loading providers...</p>
|
||||||
) : providers.length === 0 ? (
|
) : providers.length === 0 ? (
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
||||||
<p className="text-sm text-text-muted">No providers configured</p>
|
<p className="text-sm text-text-muted">
|
||||||
|
No providers configured. Set{' '}
|
||||||
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||||
|
OLLAMA_BASE_URL
|
||||||
|
</code>{' '}
|
||||||
|
or{' '}
|
||||||
|
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
||||||
|
MOSAIC_CUSTOM_PROVIDERS
|
||||||
|
</code>{' '}
|
||||||
|
to add providers.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{providers.map((p) => (
|
{providers.map((provider) => (
|
||||||
<div
|
<ProviderCard
|
||||||
key={p.name}
|
key={provider.id}
|
||||||
className="flex items-center justify-between rounded-lg border border-surface-border bg-surface-card p-4"
|
provider={provider}
|
||||||
>
|
defaultModel={defaultModel}
|
||||||
<div>
|
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
||||||
<p className="text-sm font-medium text-text-primary">{p.name}</p>
|
onTest={() => void testConnection(provider.id)}
|
||||||
<p className="text-xs text-text-muted">{p.modelCount} models available</p>
|
/>
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
|
||||||
p.enabled ? 'bg-success/20 text-success' : 'bg-gray-600/20 text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p.enabled ? 'Active' : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Models */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Available Models</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading models...</p>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">No models available</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Model</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Provider</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{models.map((m) => (
|
|
||||||
<tr
|
|
||||||
key={`${m.provider}-${m.id}`}
|
|
||||||
className="border-b border-surface-border last:border-b-0"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 text-sm text-text-primary">
|
|
||||||
{m.name}
|
|
||||||
{m.reasoning && (
|
|
||||||
<span className="ml-2 text-xs text-purple-400">reasoning</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-xs text-text-muted">{m.provider}</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{(m.contextWindow / 1000).toFixed(0)}k
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
${m.cost.input} / ${m.cost.output}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderCardProps {
|
||||||
|
provider: ProviderInfo;
|
||||||
|
defaultModel: ModelInfo | undefined;
|
||||||
|
testStatus: ProviderTestStatus;
|
||||||
|
onTest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({
|
||||||
|
provider,
|
||||||
|
defaultModel,
|
||||||
|
testStatus,
|
||||||
|
onTest,
|
||||||
|
}: ProviderCardProps): React.ReactElement {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-surface-border bg-surface-card">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ProviderAvatar id={provider.id} />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
||||||
|
<ProviderStatusBadge available={provider.available} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TestConnectionButton status={testStatus} onTest={onTest} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
||||||
|
>
|
||||||
|
{expanded ? '▲ Hide' : '▼ Models'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test result banner */}
|
||||||
|
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
||||||
|
<TestResultBanner result={testStatus.result} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model list */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-surface-border">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Model</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
||||||
|
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Default</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{provider.models.map((model) => (
|
||||||
|
<ModelRow
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
isDefault={
|
||||||
|
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelRowProps {
|
||||||
|
model: ModelInfo;
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<tr className="border-t border-surface-border">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="text-sm text-text-primary">{model.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-2 md:table-cell">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<CapabilityBadge label="chat" />
|
||||||
|
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
||||||
|
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||||
|
{formatContext(model.contextWindow)}
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
||||||
|
{model.cost.input === 0 && model.cost.output === 0
|
||||||
|
? 'free'
|
||||||
|
: `$${model.cost.input} / $${model.cost.output}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{isDefault && (
|
||||||
|
<span
|
||||||
|
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
||||||
|
title="Default model used for new sessions"
|
||||||
|
>
|
||||||
|
default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
||||||
|
const letter = id.charAt(0).toUpperCase();
|
||||||
|
return (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{available ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestConnectionButtonProps {
|
||||||
|
status: ProviderTestStatus;
|
||||||
|
onTest: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
||||||
|
const isTesting = status.state === 'testing';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={isTesting}
|
||||||
|
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title="Test connection"
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
<span className="text-text-muted">Testing…</span>
|
||||||
|
) : status.state === 'success' ? (
|
||||||
|
<span className="text-success">✓ Reachable</span>
|
||||||
|
) : status.state === 'error' ? (
|
||||||
|
<span className="text-error">✗ Unreachable</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">Test</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-4 py-2 text-xs ${
|
||||||
|
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.reachable ? (
|
||||||
|
<>
|
||||||
|
Connected
|
||||||
|
{result.latencyMs !== undefined && (
|
||||||
|
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
||||||
|
)}
|
||||||
|
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
||||||
|
<span className="ml-2 opacity-70">
|
||||||
|
— {result.discoveredModels.length} model
|
||||||
|
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapabilityBadge({
|
||||||
|
label,
|
||||||
|
color = 'default',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
color?: 'default' | 'purple' | 'blue';
|
||||||
|
}): React.ReactElement {
|
||||||
|
const colorClass =
|
||||||
|
color === 'purple'
|
||||||
|
? 'bg-purple-500/20 text-purple-400'
|
||||||
|
: color === 'blue'
|
||||||
|
? 'bg-blue-500/20 text-blue-400'
|
||||||
|
: 'bg-surface-elevated text-text-muted';
|
||||||
|
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
|
function Field({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -148,3 +359,9 @@ function Field({ label, value }: { label: string; value: string }): React.ReactE
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatContext(tokens: number): string {
|
||||||
|
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
||||||
|
return String(tokens);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | milestone | description | pr | notes |
|
||||||
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | --------------------------- |
|
| ------ | ----------- | --------- | ------------------------------------------------------------------- | ---- | ------------ |
|
||||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
||||||
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
||||||
@@ -55,14 +55,14 @@
|
|||||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
||||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||||
| P7-009 | not-started | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | — | #120 Wave-1 |
|
| P7-009 | done | Phase 7 | Web chat — WebSocket integration, streaming, conversation switching | #136 | #120 W1 done |
|
||||||
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 Wave-1 |
|
| P7-001 | done | Phase 7 | MCP endpoint hardening — streamable HTTP transport | #137 | #52 W1 done |
|
||||||
| P7-010 | not-started | Phase 7 | Web conversation management — list, search, rename, delete, archive | — | #121 Wave-2, depends:P7-009 |
|
| P7-010 | done | Phase 7 | Web conversation management — list, search, rename, delete, archive | #139 | #121 W2 done |
|
||||||
| P7-015 | not-started | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | — | #126 Wave-2 |
|
| P7-015 | done | Phase 7 | Agent tool expansion — file ops, git, shell exec, web fetch | #138 | #126 W2 done |
|
||||||
| P7-011 | not-started | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | — | #122 Wave-3 |
|
| P7-011 | done | Phase 7 | Web project detail views — missions, tasks, PRDs, dashboards | #140 | #122 W3 done |
|
||||||
| P7-016 | not-started | Phase 7 | MCP client — gateway connects to external MCP servers as tools | — | #127 Wave-3, depends:P7-001 |
|
| P7-016 | done | Phase 7 | MCP client — gateway connects to external MCP servers as tools | #141 | #127 W3 done |
|
||||||
| P7-012 | not-started | Phase 7 | Web provider management UI — add, configure, test LLM providers | — | #123 Wave-4 |
|
| P7-012 | in-progress | Phase 7 | Web provider management UI — add, configure, test LLM providers | — | #123 Wave-4 |
|
||||||
| P7-017 | not-started | Phase 7 | Agent skill invocation — load and execute skills from catalog | — | #128 Wave-4 |
|
| P7-017 | in-progress | Phase 7 | Agent skill invocation — load and execute skills from catalog | — | #128 Wave-4 |
|
||||||
| P7-013 | not-started | Phase 7 | Web settings persistence — profile, preferences save to DB | — | #124 Wave-5 |
|
| P7-013 | not-started | Phase 7 | Web settings persistence — profile, preferences save to DB | — | #124 Wave-5 |
|
||||||
| P7-018 | not-started | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | — | #129 Wave-5 |
|
| P7-018 | not-started | Phase 7 | CLI model/provider switching — --model, --provider, /model in TUI | — | #129 Wave-5 |
|
||||||
| P7-014 | not-started | Phase 7 | Web admin panel — user CRUD, role assignment, system health | — | #125 Wave-6 |
|
| P7-014 | not-started | Phase 7 | Web admin panel — user CRUD, role assignment, system health | — | #125 Wave-6 |
|
||||||
|
|||||||
Reference in New Issue
Block a user