mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 21:44:38 +00:00
1870 lines
75 KiB
TypeScript
1870 lines
75 KiB
TypeScript
/**
|
|
* Model Council tab — configure a small council of agent-flavored model seats,
|
|
* ask one question, then let a judge model synthesize the responses.
|
|
*
|
|
* The Rust core still owns orchestration through `openhuman.model_council_run`.
|
|
* This surface gives each seat an agent profile, Rive presence, and council
|
|
* settings, then resolves the roster to model ids for the existing RPC.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import {
|
|
getMascotPalette,
|
|
hexToArgbInt,
|
|
type MascotFace,
|
|
RiveMascot,
|
|
} from '../../features/human/Mascot';
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
import { BubbleMarkdown } from '../../pages/conversations/components/AgentMessageBubble';
|
|
import {
|
|
listProviderModels,
|
|
loadAISettings,
|
|
loadLocalProviderSnapshot,
|
|
type ModelInfo,
|
|
} from '../../services/api/aiSettingsApi';
|
|
import { type CouncilDefinition, councilRegistryApi } from '../../services/api/councilRegistryApi';
|
|
import {
|
|
type CouncilMemberResult,
|
|
modelCouncilApi,
|
|
type ModelCouncilResult,
|
|
} from '../../services/api/modelCouncilApi';
|
|
import {
|
|
type AgentProfilesStatus,
|
|
loadAgentProfiles,
|
|
selectAgentProfiles,
|
|
} from '../../store/agentProfileSlice';
|
|
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
|
import type { AgentProfile } from '../../types/agentProfile';
|
|
|
|
/** Matches the server-side MAX_COUNCIL_MEMBERS cap. */
|
|
const MAX_MEMBERS = 5;
|
|
const MIN_MEMBERS = 1;
|
|
const MAX_DEBATE_ROUNDS = 4;
|
|
const MIN_DEBATE_ROUNDS = 2;
|
|
const DEFAULT_REASONING_MODEL = 'reasoning-v1';
|
|
|
|
type SeatMode = 'default' | 'profile' | 'custom';
|
|
|
|
interface CouncilSeat {
|
|
id: number;
|
|
mode: SeatMode;
|
|
profileId: string;
|
|
name: string;
|
|
model: string;
|
|
brief: string;
|
|
}
|
|
|
|
interface ResolvedSeat {
|
|
label: string;
|
|
model: string;
|
|
brief: string;
|
|
}
|
|
|
|
interface LiveMemberThought {
|
|
status: 'pending' | 'answered' | 'failed';
|
|
member: CouncilMemberResult | null;
|
|
turns: CouncilDebateTurn[];
|
|
}
|
|
|
|
interface CouncilDebateTurn {
|
|
round: number;
|
|
response: string | null;
|
|
error: string | null;
|
|
}
|
|
|
|
interface DebateUsageEstimate {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
totalTokens: number;
|
|
}
|
|
|
|
interface ModelPickerState {
|
|
title: string;
|
|
value: string;
|
|
onSelect: (model: string) => void;
|
|
}
|
|
|
|
const MODEL_HINTS = [
|
|
{ value: 'default', label: 'Default' },
|
|
{ value: 'hint:chat', label: 'Chat' },
|
|
{ value: 'hint:reasoning', label: 'Reasoning' },
|
|
{ value: 'hint:code', label: 'Code' },
|
|
{ value: 'hint:summarize', label: 'Summarize' },
|
|
] as const;
|
|
|
|
interface ConnectedModelProvider {
|
|
slug: string;
|
|
label: string;
|
|
models?: ModelInfo[];
|
|
}
|
|
|
|
function parseProviderModel(value: string): { provider: string; model: string } {
|
|
const trimmed = value.trim();
|
|
const colon = trimmed.indexOf(':');
|
|
if (colon <= 0) {
|
|
return { provider: '', model: trimmed };
|
|
}
|
|
return { provider: trimmed.slice(0, colon), model: trimmed.slice(colon + 1) };
|
|
}
|
|
|
|
async function loadConnectedModelProviders(): Promise<ConnectedModelProvider[]> {
|
|
const [settings, localSnapshot] = await Promise.all([
|
|
loadAISettings(),
|
|
loadLocalProviderSnapshot().catch(() => null),
|
|
]);
|
|
const providers: ConnectedModelProvider[] = [{ slug: 'openhuman', label: 'Managed' }];
|
|
const seen = new Set(providers.map(provider => provider.slug));
|
|
|
|
for (const provider of settings.cloudProviders) {
|
|
const slug = provider.slug.trim();
|
|
if (!slug || seen.has(slug)) continue;
|
|
if (!provider.has_api_key && provider.auth_style !== 'none') continue;
|
|
providers.push({ slug, label: provider.label || slug });
|
|
seen.add(slug);
|
|
}
|
|
|
|
const localModels =
|
|
localSnapshot?.installedModels
|
|
.filter(model => model.chat_capable !== false)
|
|
.map(model => ({ id: model.name, context_window: model.context_length ?? null })) ?? [];
|
|
if (localModels.length > 0 && !seen.has('ollama')) {
|
|
providers.push({ slug: 'ollama', label: 'Ollama', models: localModels });
|
|
}
|
|
|
|
return providers;
|
|
}
|
|
|
|
const Icon = ({
|
|
name,
|
|
size = 16,
|
|
}: {
|
|
name: 'arrow-left' | 'plus' | 'settings' | 'trash';
|
|
size?: number;
|
|
}) => {
|
|
const common = {
|
|
fill: 'none',
|
|
stroke: 'currentColor',
|
|
strokeLinecap: 'round' as const,
|
|
strokeLinejoin: 'round' as const,
|
|
strokeWidth: 2,
|
|
};
|
|
return (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" {...common}>
|
|
{name === 'arrow-left' && (
|
|
<>
|
|
<path d="M19 12H5" />
|
|
<path d="m12 19-7-7 7-7" />
|
|
</>
|
|
)}
|
|
{name === 'plus' && (
|
|
<>
|
|
<path d="M12 5v14" />
|
|
<path d="M5 12h14" />
|
|
</>
|
|
)}
|
|
{name === 'settings' && (
|
|
<>
|
|
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z" />
|
|
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09A1.7 1.7 0 0 0 19.4 15Z" />
|
|
</>
|
|
)}
|
|
{name === 'trash' && (
|
|
<>
|
|
<path d="M3 6h18" />
|
|
<path d="M8 6V4h8v2" />
|
|
<path d="m6 6 1 16h10l1-16" />
|
|
<path d="M10 11v6" />
|
|
<path d="M14 11v6" />
|
|
</>
|
|
)}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
const ModelPickerDialog = ({
|
|
picker,
|
|
onClose,
|
|
}: {
|
|
picker: ModelPickerState;
|
|
onClose: () => void;
|
|
}) => {
|
|
const { t } = useT();
|
|
const initial = parseProviderModel(picker.value);
|
|
const initialHint = MODEL_HINTS.some(hint => hint.value === picker.value);
|
|
const [selectionMode, setSelectionMode] = useState<'hint' | 'custom'>(
|
|
initial.provider && !initialHint ? 'custom' : 'hint'
|
|
);
|
|
const [providers, setProviders] = useState<ConnectedModelProvider[]>([]);
|
|
const [providersLoading, setProvidersLoading] = useState(false);
|
|
const [providersError, setProvidersError] = useState<string | null>(null);
|
|
const [provider, setProvider] = useState(initial.provider);
|
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
|
const [modelsLoading, setModelsLoading] = useState(false);
|
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
|
const [model, setModel] = useState(initial.model);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
setProvidersLoading(true);
|
|
setProvidersError(null);
|
|
loadConnectedModelProviders()
|
|
.then(loaded => {
|
|
if (!active) return;
|
|
setProviders(loaded);
|
|
setProvidersLoading(false);
|
|
setProvider(current => {
|
|
if (current && loaded.some(item => item.slug === current)) return current;
|
|
return loaded[0]?.slug ?? '';
|
|
});
|
|
})
|
|
.catch(err => {
|
|
if (!active) return;
|
|
setProvidersError(err instanceof Error ? err.message : String(err));
|
|
setProvidersLoading(false);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (selectionMode !== 'custom' || !provider) {
|
|
setModels([]);
|
|
setModelsError(null);
|
|
return;
|
|
}
|
|
|
|
const connectedProvider = providers.find(item => item.slug === provider);
|
|
if (!connectedProvider) {
|
|
setModels([]);
|
|
setModelsError(null);
|
|
return;
|
|
}
|
|
|
|
if (connectedProvider.models) {
|
|
setModels(connectedProvider.models);
|
|
setModelsError(null);
|
|
setModelsLoading(false);
|
|
setModel(current => current || connectedProvider.models?.[0]?.id || '');
|
|
return;
|
|
}
|
|
|
|
let active = true;
|
|
setModelsLoading(true);
|
|
setModels([]);
|
|
setModelsError(null);
|
|
listProviderModels(provider)
|
|
.then(loaded => {
|
|
if (!active) return;
|
|
setModels(loaded);
|
|
setModelsLoading(false);
|
|
setModel(current => {
|
|
if (current && loaded.some(item => item.id === current)) return current;
|
|
return loaded[0]?.id ?? '';
|
|
});
|
|
})
|
|
.catch(err => {
|
|
if (!active) return;
|
|
setModelsError(err instanceof Error ? err.message : String(err));
|
|
setModelsLoading(false);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [provider, providers, selectionMode]);
|
|
|
|
const saveProviderModel = () => {
|
|
const trimmedModel = model.trim();
|
|
if (!trimmedModel) return;
|
|
const trimmedProvider = provider.trim();
|
|
picker.onSelect(trimmedProvider ? `${trimmedProvider}:${trimmedModel}` : trimmedModel);
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="model-council-model-picker-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
|
<div className="w-full max-w-md rounded-lg border border-stone-200 bg-white p-4 shadow-xl dark:border-neutral-800 dark:bg-neutral-950">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3
|
|
id="model-council-model-picker-title"
|
|
className="text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{picker.title}
|
|
</h3>
|
|
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.modelPickerHelp')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded-md px-2 py-1 text-xs font-semibold text-stone-500 hover:bg-stone-100 dark:text-neutral-400 dark:hover:bg-neutral-800">
|
|
{t('modelCouncil.closeModelPicker')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-2">
|
|
<p className="text-[11px] font-semibold uppercase text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.modelPickerHints')}
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{MODEL_HINTS.map(hint => (
|
|
<button
|
|
key={hint.value}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectionMode('hint');
|
|
picker.onSelect(hint.value);
|
|
onClose();
|
|
}}
|
|
className={`rounded-lg border px-3 py-2 text-left text-sm ${
|
|
picker.value === hint.value
|
|
? 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-200'
|
|
: 'border-stone-200 text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-900'
|
|
}`}>
|
|
{hint.label}
|
|
<span className="block font-mono text-[11px] text-stone-500 dark:text-neutral-400">
|
|
{hint.value}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-3 rounded-lg border border-stone-200 bg-stone-50 p-3 dark:border-neutral-800 dark:bg-neutral-900">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectionMode('custom')}
|
|
aria-pressed={selectionMode === 'custom'}
|
|
className={`w-full rounded-lg border px-3 py-2 text-left text-sm font-semibold ${
|
|
selectionMode === 'custom'
|
|
? 'border-primary-500 bg-white text-primary-700 dark:bg-neutral-950 dark:text-primary-200'
|
|
: 'border-stone-200 bg-white text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-200 dark:hover:bg-neutral-800'
|
|
}`}>
|
|
{t('modelCouncil.modelPickerProviderModel')}
|
|
<span className="block text-[11px] font-normal text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.mode.custom')}
|
|
</span>
|
|
</button>
|
|
|
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
|
<select
|
|
value={provider}
|
|
onChange={e => setProvider(e.target.value)}
|
|
aria-label={t('modelCouncil.modelProviderLabel')}
|
|
disabled={selectionMode !== 'custom' || providersLoading || providers.length === 0}
|
|
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100">
|
|
{providers.map(item => (
|
|
<option key={item.slug} value={item.slug}>
|
|
{`${item.slug === 'openhuman' ? t('settings.ai.routing.managed') : item.label} (${item.slug})`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={model}
|
|
onChange={e => setModel(e.target.value)}
|
|
aria-label={t('modelCouncil.modelIdLabel')}
|
|
disabled={selectionMode !== 'custom' || modelsLoading || models.length === 0}
|
|
className="rounded-lg border border-stone-200 bg-white px-3 py-2 font-mono text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100">
|
|
{models.map(item => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{(providersLoading || modelsLoading) && (
|
|
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
|
{t('skills.resource.preview.loading')}
|
|
</p>
|
|
)}
|
|
{(providersError || modelsError) && (
|
|
<p role="alert" className="text-[11px] text-coral-700 dark:text-coral-300">
|
|
{providersError || modelsError}
|
|
</p>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={saveProviderModel}
|
|
disabled={selectionMode !== 'custom' || !provider.trim() || !model.trim()}
|
|
className="w-full rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50">
|
|
{t('modelCouncil.useProviderModel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DEFAULT_MODEL = DEFAULT_REASONING_MODEL;
|
|
const DEFAULT_JUDGE_MODEL = DEFAULT_REASONING_MODEL;
|
|
const SHARED_REASONING_FILE = 'shared_reasoning.md';
|
|
const DEFAULT_SHARED_REASONING = [
|
|
'# Shared reasoning',
|
|
'- Claims the council agrees on:',
|
|
'- Open disagreements:',
|
|
'- Evidence or constraints to preserve:',
|
|
'- Judge synthesis notes:',
|
|
].join('\n');
|
|
const DEFAULT_SEATS: CouncilSeat[] = [
|
|
{
|
|
id: 0,
|
|
mode: 'default',
|
|
profileId: '',
|
|
name: 'Analyst',
|
|
model: DEFAULT_MODEL,
|
|
brief: 'Evidence, assumptions, and risk.',
|
|
},
|
|
{
|
|
id: 1,
|
|
mode: 'default',
|
|
profileId: '',
|
|
name: 'Builder',
|
|
model: DEFAULT_MODEL,
|
|
brief: 'Practical implementation path.',
|
|
},
|
|
{
|
|
id: 2,
|
|
mode: 'default',
|
|
profileId: '',
|
|
name: 'Skeptic',
|
|
model: DEFAULT_MODEL,
|
|
brief: 'Failure modes and missing context.',
|
|
},
|
|
];
|
|
|
|
const SEAT_COLORS = ['yellow', 'burgundy', 'navy', 'black', 'yellow'] as const;
|
|
const SEAT_FACES: MascotFace[] = ['thinking', 'writing', 'reading', 'curious', 'proud'];
|
|
const ACTIVE_SEAT_FACES: MascotFace[] = ['thinking', 'writing', 'thinking', 'reading', 'curious'];
|
|
|
|
const nextSeatId = (seats: CouncilSeat[]): number =>
|
|
seats.reduce((max, seat) => Math.max(max, seat.id), -1) + 1;
|
|
|
|
function profileLabel(profile: AgentProfile): string {
|
|
return profile.modelOverride ? `${profile.name} · ${profile.modelOverride}` : profile.name;
|
|
}
|
|
|
|
function profileModel(profile: AgentProfile | undefined): string {
|
|
return profile?.modelOverride?.trim() || profile?.agentId?.trim() || profile?.id?.trim() || '';
|
|
}
|
|
|
|
function resolveSeat(seat: CouncilSeat, profiles: AgentProfile[], index: number): ResolvedSeat {
|
|
const profile = profiles.find(p => p.id === seat.profileId);
|
|
const fallbackName =
|
|
seat.mode === 'profile' && profile ? profile.name : seat.name.trim() || `Juror ${index + 1}`;
|
|
const fallbackModel = seat.mode === 'profile' ? profileModel(profile) : DEFAULT_MODEL;
|
|
|
|
return {
|
|
label: fallbackName,
|
|
model: seat.model.trim() || fallbackModel,
|
|
brief: seat.brief.trim(),
|
|
};
|
|
}
|
|
|
|
function mascotColors(index: number) {
|
|
const palette = getMascotPalette(SEAT_COLORS[index % SEAT_COLORS.length]);
|
|
return {
|
|
primaryColor: hexToArgbInt(palette.bodyFill),
|
|
secondaryColor: hexToArgbInt(palette.neckShadowColor),
|
|
};
|
|
}
|
|
|
|
function deliberationThought(
|
|
seat: ResolvedSeat,
|
|
index: number,
|
|
t: (key: string) => string
|
|
): string {
|
|
const brief = seat.brief.trim();
|
|
if (brief) {
|
|
return t('modelCouncil.thinkingWithBrief').replace('{brief}', brief);
|
|
}
|
|
|
|
const keys = [
|
|
'modelCouncil.thought.evidence',
|
|
'modelCouncil.thought.plan',
|
|
'modelCouncil.thought.risk',
|
|
'modelCouncil.thought.tradeoffs',
|
|
'modelCouncil.thought.synthesis',
|
|
];
|
|
return t(keys[index % keys.length]);
|
|
}
|
|
|
|
function buildCouncilQuestion(
|
|
question: string,
|
|
sharedReasoning: string,
|
|
seats: ResolvedSeat[],
|
|
judgeName: string
|
|
): string {
|
|
const trimmedQuestion = question.trim();
|
|
const trimmedSharedReasoning = sharedReasoning.trim();
|
|
const roster = seats
|
|
.map((seat, index) => {
|
|
const brief = seat.brief ? ` — ${seat.brief}` : '';
|
|
return `${index + 1}. ${seat.label} (${seat.model})${brief}`;
|
|
})
|
|
.join('\n');
|
|
const commonPrefix = [
|
|
`Council workspace: ${SHARED_REASONING_FILE}`,
|
|
'Use this shared reasoning file as the common deliberation scratchpad.',
|
|
'',
|
|
'Council roster:',
|
|
roster,
|
|
'',
|
|
`Judge agent: ${judgeName}`,
|
|
];
|
|
|
|
if (!trimmedSharedReasoning) {
|
|
return [...commonPrefix, '', 'User question:', trimmedQuestion].join('\n');
|
|
}
|
|
|
|
return [
|
|
...commonPrefix,
|
|
'',
|
|
`${SHARED_REASONING_FILE}:`,
|
|
trimmedSharedReasoning,
|
|
'',
|
|
'User question:',
|
|
trimmedQuestion,
|
|
].join('\n');
|
|
}
|
|
|
|
function buildDebateTurnQuestion(
|
|
baseQuestion: string,
|
|
seat: ResolvedSeat,
|
|
round: number,
|
|
totalRounds: number,
|
|
transcript: CouncilDebateTurn[][],
|
|
t: (key: string) => string
|
|
): string {
|
|
const previousTurns = transcript
|
|
.map((turns, seatIndex) => {
|
|
if (turns.length === 0) return '';
|
|
const body = turns
|
|
.map(turn => {
|
|
const text = turn.response || `[${turn.error || 'no response'}]`;
|
|
return `Round ${turn.round}: ${text}`;
|
|
})
|
|
.join('\n');
|
|
return `Juror ${seatIndex + 1} previous turns:\n${body}`;
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n\n');
|
|
|
|
const phase =
|
|
round === totalRounds
|
|
? t('modelCouncil.debateFinalInstruction')
|
|
: t('modelCouncil.debateRoundInstruction');
|
|
|
|
return [
|
|
baseQuestion,
|
|
'',
|
|
`Debate round ${round} of ${totalRounds}.`,
|
|
`You are ${seat.label}. Perspective: ${seat.brief || 'independent council juror'}.`,
|
|
phase,
|
|
previousTurns ? ['', 'Debate so far:', previousTurns].join('\n') : '',
|
|
'',
|
|
'Write this turn as a concise council thought plus your current conclusion.',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
function appendScratchpadRound(
|
|
scratchpad: string,
|
|
round: number,
|
|
seats: ResolvedSeat[],
|
|
roundResults: Array<{ index: number; turn: CouncilDebateTurn }>,
|
|
t: (key: string) => string
|
|
): string {
|
|
const existing = scratchpad.trim() || '# Shared reasoning';
|
|
const lines = [
|
|
'',
|
|
'',
|
|
`## ${t('modelCouncil.scratchpadRoundHeading').replace('{round}', String(round))}`,
|
|
];
|
|
for (const { index, turn } of [...roundResults].sort((a, b) => a.index - b.index)) {
|
|
const seat = seats[index];
|
|
lines.push('', `### ${seat?.label || `Juror ${index + 1}`}`);
|
|
if (turn.response) {
|
|
lines.push(turn.response.trim());
|
|
} else {
|
|
lines.push(`_${t('modelCouncil.scratchpadNoResponse')}: ${turn.error || 'unknown'}_`);
|
|
}
|
|
}
|
|
return `${existing}${lines.join('\n')}`;
|
|
}
|
|
|
|
function estimateTokens(text: string): number {
|
|
return Math.max(1, Math.ceil(text.length / 4));
|
|
}
|
|
|
|
function formatTokenCount(value: number): string {
|
|
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value);
|
|
}
|
|
|
|
function buildMemberSynthesisInput(
|
|
seat: ResolvedSeat,
|
|
model: string,
|
|
turns: CouncilDebateTurn[]
|
|
): CouncilMemberResult {
|
|
const answeredTurns = turns.filter(turn => turn.response);
|
|
if (answeredTurns.length === 0) {
|
|
return {
|
|
model,
|
|
response: null,
|
|
error: turns.find(turn => turn.error)?.error || 'no debate turns completed',
|
|
};
|
|
}
|
|
|
|
return {
|
|
model,
|
|
response: [
|
|
`${seat.label} debate record:`,
|
|
...turns.map(turn => {
|
|
const text = turn.response || `[failed: ${turn.error || 'unknown'}]`;
|
|
return `Round ${turn.round}: ${text}`;
|
|
}),
|
|
].join('\n\n'),
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
function councilSeatsFromDefinition(council: CouncilDefinition): CouncilSeat[] {
|
|
return council.seats.map(seat => ({
|
|
id: seat.id,
|
|
mode: seat.mode,
|
|
profileId: seat.profile_id,
|
|
name: seat.name,
|
|
model: seat.model,
|
|
brief: seat.brief,
|
|
}));
|
|
}
|
|
|
|
function createDraftCouncil(): CouncilDefinition {
|
|
const now = Date.now();
|
|
return {
|
|
id: '',
|
|
name: 'New council',
|
|
description: '',
|
|
jury_count: 3,
|
|
debate_rounds: 3,
|
|
seats: DEFAULT_SEATS.map(seat => ({
|
|
id: seat.id,
|
|
mode: seat.mode,
|
|
profile_id: seat.profileId,
|
|
name: seat.name,
|
|
model: seat.model,
|
|
brief: seat.brief,
|
|
})),
|
|
judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: DEFAULT_JUDGE_MODEL },
|
|
shared_reasoning: DEFAULT_SHARED_REASONING,
|
|
created_at_ms: now,
|
|
updated_at_ms: now,
|
|
};
|
|
}
|
|
|
|
const ModelCouncilTab = () => {
|
|
const { t } = useT();
|
|
const dispatch = useAppDispatch();
|
|
const profiles = useAppSelector(selectAgentProfiles);
|
|
const profileStatus = useAppSelector(state => state.agentProfiles.status as AgentProfilesStatus);
|
|
|
|
const [question, setQuestion] = useState('');
|
|
const [view, setView] = useState<'list' | 'run' | 'edit'>('list');
|
|
const [councils, setCouncils] = useState<CouncilDefinition[]>([]);
|
|
const [selectedCouncil, setSelectedCouncil] = useState<CouncilDefinition | null>(null);
|
|
const [councilName, setCouncilName] = useState('Default council');
|
|
const [councilDescription, setCouncilDescription] = useState('');
|
|
const [registryLoading, setRegistryLoading] = useState(true);
|
|
const [registrySaving, setRegistrySaving] = useState(false);
|
|
const [registryError, setRegistryError] = useState<string | null>(null);
|
|
const [sharedReasoning, setSharedReasoning] = useState(DEFAULT_SHARED_REASONING);
|
|
const [liveScratchpad, setLiveScratchpad] = useState<string | null>(null);
|
|
const [juryCount, setJuryCount] = useState(3);
|
|
const [debateRounds, setDebateRounds] = useState(3);
|
|
const [seats, setSeats] = useState<CouncilSeat[]>(DEFAULT_SEATS);
|
|
const [judgeMode, setJudgeMode] = useState<SeatMode>('default');
|
|
const [judgeProfileId, setJudgeProfileId] = useState('');
|
|
const [judgeName, setJudgeName] = useState('Chief Judge');
|
|
const [judgeModel, setJudgeModel] = useState(DEFAULT_JUDGE_MODEL);
|
|
const [running, setRunning] = useState(false);
|
|
const [liveMembers, setLiveMembers] = useState<LiveMemberThought[]>([]);
|
|
const [judgeSynthesizing, setJudgeSynthesizing] = useState(false);
|
|
const [usageEstimate, setUsageEstimate] = useState<DebateUsageEstimate | null>(null);
|
|
const [modelPicker, setModelPicker] = useState<ModelPickerState | null>(null);
|
|
const [result, setResult] = useState<ModelCouncilResult | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (profileStatus === 'idle' && profiles.length === 0) {
|
|
void dispatch(loadAgentProfiles());
|
|
}
|
|
}, [dispatch, profileStatus, profiles.length]);
|
|
|
|
const applyCouncilDefinition = useCallback((council: CouncilDefinition) => {
|
|
setSelectedCouncil(council);
|
|
setCouncilName(council.name || 'Untitled council');
|
|
setCouncilDescription(council.description || '');
|
|
setJuryCount(Math.min(MAX_MEMBERS, Math.max(MIN_MEMBERS, council.jury_count || 3)));
|
|
setDebateRounds(
|
|
Math.min(MAX_DEBATE_ROUNDS, Math.max(MIN_DEBATE_ROUNDS, council.debate_rounds || 3))
|
|
);
|
|
setSeats(councilSeatsFromDefinition(council).slice(0, council.jury_count || 3));
|
|
setJudgeMode(council.judge.mode);
|
|
setJudgeProfileId(council.judge.profile_id || '');
|
|
setJudgeName(council.judge.name || 'Chief Judge');
|
|
setJudgeModel(council.judge.model ?? DEFAULT_JUDGE_MODEL);
|
|
setSharedReasoning(council.shared_reasoning || DEFAULT_SHARED_REASONING);
|
|
setQuestion('');
|
|
setLiveMembers([]);
|
|
setLiveScratchpad(null);
|
|
setJudgeSynthesizing(false);
|
|
setUsageEstimate(null);
|
|
setResult(null);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const loadCouncils = useCallback(async () => {
|
|
setRegistryLoading(true);
|
|
setRegistryError(null);
|
|
try {
|
|
const loaded = await councilRegistryApi.list();
|
|
setCouncils(loaded);
|
|
} catch (err) {
|
|
setRegistryError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setRegistryLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadCouncils();
|
|
}, [loadCouncils]);
|
|
|
|
useEffect(() => {
|
|
setSeats(prev => {
|
|
if (prev.length === juryCount) return prev;
|
|
if (prev.length > juryCount) return prev.slice(0, juryCount);
|
|
|
|
const next = [...prev];
|
|
while (next.length < juryCount) {
|
|
const index = next.length;
|
|
next.push({
|
|
id: nextSeatId(next),
|
|
mode: 'default',
|
|
profileId: '',
|
|
name: `${t('modelCouncil.jurorFallback')} ${index + 1}`,
|
|
model: DEFAULT_MODEL,
|
|
brief: '',
|
|
});
|
|
}
|
|
return next;
|
|
});
|
|
}, [juryCount, t]);
|
|
|
|
const judgeProfile = useMemo(
|
|
() => profiles.find(profile => profile.id === judgeProfileId),
|
|
[profiles, judgeProfileId]
|
|
);
|
|
|
|
const resolvedSeats = useMemo(
|
|
() => seats.map((seat, index) => resolveSeat(seat, profiles, index)),
|
|
[profiles, seats]
|
|
);
|
|
|
|
const resolvedJudgeModel =
|
|
judgeModel.trim() ||
|
|
(judgeMode === 'profile' ? profileModel(judgeProfile) : '') ||
|
|
DEFAULT_JUDGE_MODEL;
|
|
const resolvedJudgeName =
|
|
judgeMode === 'profile' && judgeProfile ? judgeProfile.name : judgeName.trim() || 'Chief Judge';
|
|
|
|
const canRun =
|
|
!running &&
|
|
question.trim().length > 0 &&
|
|
resolvedSeats.some(seat => seat.model.trim().length > 0) &&
|
|
resolvedJudgeModel.trim().length > 0;
|
|
|
|
const updateSeat = useCallback((id: number, patch: Partial<CouncilSeat>) => {
|
|
setSeats(prev => prev.map(seat => (seat.id === id ? { ...seat, ...patch } : seat)));
|
|
}, []);
|
|
|
|
const buildCouncilDefinition = useCallback(
|
|
(base: CouncilDefinition | null): CouncilDefinition => {
|
|
const now = Date.now();
|
|
return {
|
|
id: base?.id || '',
|
|
name: councilName.trim() || 'Untitled council',
|
|
description: councilDescription.trim(),
|
|
jury_count: juryCount,
|
|
debate_rounds: debateRounds,
|
|
seats: seats
|
|
.slice(0, juryCount)
|
|
.map(seat => ({
|
|
id: seat.id,
|
|
mode: seat.mode,
|
|
profile_id: seat.profileId,
|
|
name: seat.name,
|
|
model: seat.model,
|
|
brief: seat.brief,
|
|
})),
|
|
judge: { mode: judgeMode, profile_id: judgeProfileId, name: judgeName, model: judgeModel },
|
|
shared_reasoning: sharedReasoning,
|
|
created_at_ms: base?.created_at_ms || now,
|
|
updated_at_ms: now,
|
|
};
|
|
},
|
|
[
|
|
councilDescription,
|
|
councilName,
|
|
debateRounds,
|
|
judgeMode,
|
|
judgeModel,
|
|
judgeName,
|
|
judgeProfileId,
|
|
juryCount,
|
|
seats,
|
|
sharedReasoning,
|
|
]
|
|
);
|
|
|
|
const saveCouncil = useCallback(async () => {
|
|
setRegistrySaving(true);
|
|
setRegistryError(null);
|
|
try {
|
|
const saved = await councilRegistryApi.upsert(buildCouncilDefinition(selectedCouncil));
|
|
setCouncils(prev => {
|
|
const without = prev.filter(council => council.id !== saved.id);
|
|
return [saved, ...without].sort((a, b) => a.name.localeCompare(b.name));
|
|
});
|
|
applyCouncilDefinition(saved);
|
|
setView('run');
|
|
} catch (err) {
|
|
setRegistryError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setRegistrySaving(false);
|
|
}
|
|
}, [applyCouncilDefinition, buildCouncilDefinition, selectedCouncil]);
|
|
|
|
const handleSelectCouncil = useCallback(
|
|
(council: CouncilDefinition) => {
|
|
applyCouncilDefinition(council);
|
|
setView('run');
|
|
},
|
|
[applyCouncilDefinition]
|
|
);
|
|
|
|
const handleCreateCouncil = useCallback(() => {
|
|
applyCouncilDefinition(createDraftCouncil());
|
|
setView('edit');
|
|
}, [applyCouncilDefinition]);
|
|
|
|
const selectedCouncilId = selectedCouncil?.id;
|
|
const handleDeleteCouncil = useCallback(
|
|
async (council: CouncilDefinition) => {
|
|
setRegistryError(null);
|
|
try {
|
|
await councilRegistryApi.delete(council.id);
|
|
setCouncils(prev => prev.filter(item => item.id !== council.id));
|
|
if (selectedCouncilId === council.id) {
|
|
setSelectedCouncil(null);
|
|
setView('list');
|
|
}
|
|
} catch (err) {
|
|
setRegistryError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
},
|
|
[selectedCouncilId]
|
|
);
|
|
|
|
const setSeatMode = useCallback(
|
|
(seat: CouncilSeat, mode: SeatMode) => {
|
|
updateSeat(seat.id, {
|
|
mode,
|
|
profileId: mode === 'profile' ? seat.profileId || profiles[0]?.id || '' : '',
|
|
name: mode === 'custom' ? seat.name : seat.name || '',
|
|
model: mode === 'profile' ? '' : seat.model || DEFAULT_MODEL,
|
|
});
|
|
},
|
|
[profiles, updateSeat]
|
|
);
|
|
|
|
const handleRun = useCallback(async () => {
|
|
if (running) return;
|
|
const memberModels = resolvedSeats.map(seat => seat.model.trim()).filter(Boolean);
|
|
const chairModel = resolvedJudgeModel.trim();
|
|
if (question.trim().length === 0 || memberModels.length === 0 || chairModel.length === 0) {
|
|
return;
|
|
}
|
|
setRunning(true);
|
|
setJudgeSynthesizing(false);
|
|
setLiveMembers(memberModels.map(() => ({ status: 'pending', member: null, turns: [] })));
|
|
setLiveScratchpad(sharedReasoning);
|
|
setUsageEstimate(null);
|
|
setError(null);
|
|
setResult(null);
|
|
try {
|
|
const transcript: CouncilDebateTurn[][] = memberModels.map(() => []);
|
|
let currentScratchpad = sharedReasoning;
|
|
let estimatedInputTokens = 0;
|
|
let estimatedOutputTokens = 0;
|
|
|
|
for (let round = 1; round <= debateRounds; round += 1) {
|
|
setLiveMembers(prev => prev.map(entry => ({ ...entry, status: 'pending' })));
|
|
|
|
const roundResults = await Promise.all(
|
|
memberModels.map(async (model, index) => {
|
|
const councilQuestion = buildCouncilQuestion(
|
|
question,
|
|
currentScratchpad,
|
|
resolvedSeats,
|
|
resolvedJudgeName
|
|
);
|
|
const turnQuestion = buildDebateTurnQuestion(
|
|
councilQuestion,
|
|
resolvedSeats[index],
|
|
round,
|
|
debateRounds,
|
|
transcript,
|
|
t
|
|
);
|
|
estimatedInputTokens += estimateTokens(turnQuestion);
|
|
try {
|
|
const member = await modelCouncilApi.answerMember({ question: turnQuestion, model });
|
|
const turn: CouncilDebateTurn = {
|
|
round,
|
|
response: member.response,
|
|
error: member.error,
|
|
};
|
|
estimatedOutputTokens += estimateTokens(member.response || member.error || '');
|
|
setLiveMembers(prev =>
|
|
prev.map((entry, entryIndex) =>
|
|
entryIndex === index
|
|
? {
|
|
status: member.error ? 'failed' : 'answered',
|
|
member,
|
|
turns: [...entry.turns, turn],
|
|
}
|
|
: entry
|
|
)
|
|
);
|
|
return { index, turn };
|
|
} catch (memberError) {
|
|
const errorText =
|
|
memberError instanceof Error ? memberError.message : String(memberError);
|
|
const failedMember: CouncilMemberResult = { model, response: null, error: errorText };
|
|
const turn: CouncilDebateTurn = { round, response: null, error: errorText };
|
|
estimatedOutputTokens += estimateTokens(errorText);
|
|
setLiveMembers(prev =>
|
|
prev.map((entry, entryIndex) =>
|
|
entryIndex === index
|
|
? { status: 'failed', member: failedMember, turns: [...entry.turns, turn] }
|
|
: entry
|
|
)
|
|
);
|
|
return { index, turn };
|
|
}
|
|
})
|
|
);
|
|
|
|
for (const { index, turn } of roundResults) {
|
|
transcript[index].push(turn);
|
|
}
|
|
currentScratchpad = appendScratchpadRound(
|
|
currentScratchpad,
|
|
round,
|
|
resolvedSeats,
|
|
roundResults,
|
|
t
|
|
);
|
|
setLiveScratchpad(currentScratchpad);
|
|
setSharedReasoning(currentScratchpad);
|
|
}
|
|
|
|
const councilQuestion = buildCouncilQuestion(
|
|
question,
|
|
currentScratchpad,
|
|
resolvedSeats,
|
|
resolvedJudgeName
|
|
);
|
|
const memberResults = memberModels.map((model, index) =>
|
|
buildMemberSynthesisInput(resolvedSeats[index], model, transcript[index])
|
|
);
|
|
const synthesisInputTokens = estimateTokens(
|
|
`${councilQuestion}\n${JSON.stringify(memberResults)}`
|
|
);
|
|
estimatedInputTokens += synthesisInputTokens;
|
|
setJudgeSynthesizing(true);
|
|
const res = await modelCouncilApi.synthesizeCouncil({
|
|
question: councilQuestion,
|
|
members: memberResults,
|
|
chair_model: chairModel,
|
|
});
|
|
estimatedOutputTokens += estimateTokens(res.synthesis);
|
|
const totalTokens = estimatedInputTokens + estimatedOutputTokens;
|
|
setUsageEstimate({
|
|
inputTokens: estimatedInputTokens,
|
|
outputTokens: estimatedOutputTokens,
|
|
totalTokens,
|
|
});
|
|
setResult(res);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setJudgeSynthesizing(false);
|
|
setRunning(false);
|
|
setLiveScratchpad(null);
|
|
}
|
|
}, [
|
|
debateRounds,
|
|
resolvedJudgeModel,
|
|
resolvedJudgeName,
|
|
resolvedSeats,
|
|
question,
|
|
running,
|
|
sharedReasoning,
|
|
t,
|
|
]);
|
|
|
|
if (view === 'list') {
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-50">
|
|
{t('modelCouncil.listTitle')}
|
|
</h2>
|
|
<p className="mt-1 max-w-3xl text-sm text-stone-600 dark:text-neutral-300">
|
|
{t('modelCouncil.listIntro')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleCreateCouncil}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-600">
|
|
<Icon name="plus" size={16} />
|
|
{t('modelCouncil.addCouncil')}
|
|
</button>
|
|
</div>
|
|
|
|
{registryError && (
|
|
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
|
|
{t('modelCouncil.registryErrorPrefix')} {registryError}
|
|
</p>
|
|
)}
|
|
|
|
{registryLoading ? (
|
|
<div className="rounded-lg border border-stone-200 bg-white p-4 text-sm text-stone-500 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
|
|
{t('modelCouncil.loadingCouncils')}
|
|
</div>
|
|
) : councils.length === 0 ? (
|
|
<div className="rounded-lg border border-stone-200 bg-white p-4 text-sm text-stone-500 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
|
|
{t('modelCouncil.noCouncils')}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{councils.map(council => (
|
|
<article
|
|
key={council.id}
|
|
className="rounded-lg border border-stone-200 bg-white p-4 shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900 dark:hover:border-primary-500/50">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{council.name}
|
|
</h3>
|
|
<p className="mt-1 line-clamp-2 text-xs text-stone-500 dark:text-neutral-400">
|
|
{council.description || t('modelCouncil.noCouncilDescription')}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
applyCouncilDefinition(council);
|
|
setView('edit');
|
|
}}
|
|
aria-label={t('modelCouncil.editCouncilAria').replace('{name}', council.name)}
|
|
className="rounded-md p-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
|
|
<Icon name="settings" size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleDeleteCouncil(council)}
|
|
aria-label={t('modelCouncil.deleteCouncilAria').replace(
|
|
'{name}',
|
|
council.name
|
|
)}
|
|
className="rounded-md p-1.5 text-stone-500 hover:bg-coral-50 hover:text-coral-700 dark:text-neutral-400 dark:hover:bg-coral-500/10 dark:hover:text-coral-300">
|
|
<Icon name="trash" size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<dl className="mt-4 grid grid-cols-2 gap-2 text-xs">
|
|
<div className="rounded-md bg-stone-50 px-2 py-1.5 dark:bg-neutral-950">
|
|
<dt className="text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.juryCountLabel')}
|
|
</dt>
|
|
<dd className="font-mono font-semibold text-stone-800 dark:text-neutral-100">
|
|
{council.jury_count}
|
|
</dd>
|
|
</div>
|
|
<div className="rounded-md bg-stone-50 px-2 py-1.5 dark:bg-neutral-950">
|
|
<dt className="text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.debateRoundsLabel')}
|
|
</dt>
|
|
<dd className="font-mono font-semibold text-stone-800 dark:text-neutral-100">
|
|
{council.debate_rounds}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectCouncil(council)}
|
|
className="mt-4 w-full rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm font-semibold text-primary-700 hover:bg-primary-100 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-200 dark:hover:bg-primary-500/20">
|
|
{t('modelCouncil.openCouncil')}
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setView('list')}
|
|
aria-label={t('modelCouncil.backToCouncils')}
|
|
className="rounded-md p-1.5 text-stone-500 hover:bg-stone-100 hover:text-stone-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
|
|
<Icon name="arrow-left" size={18} />
|
|
</button>
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-lg font-semibold text-stone-900 dark:text-neutral-50">
|
|
{view === 'edit' ? t('modelCouncil.editCouncil') : councilName}
|
|
</h2>
|
|
{selectedCouncil && view === 'run' && (
|
|
<p className="truncate text-xs text-stone-500 dark:text-neutral-400">
|
|
{councilDescription || t('modelCouncil.noCouncilDescription')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{view === 'edit' ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (selectedCouncil) applyCouncilDefinition(selectedCouncil);
|
|
setView(selectedCouncil ? 'run' : 'list');
|
|
}}
|
|
className="rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800">
|
|
{t('modelCouncil.cancelEdit')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void saveCouncil()}
|
|
disabled={registrySaving}
|
|
className="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50">
|
|
{registrySaving ? t('modelCouncil.savingCouncil') : t('modelCouncil.saveCouncil')}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setView('edit')}
|
|
aria-label={t('modelCouncil.editCurrentCouncil')}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold text-stone-700 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800">
|
|
<Icon name="settings" size={16} />
|
|
{t('modelCouncil.editCouncil')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{registryError && (
|
|
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
|
|
{t('modelCouncil.registryErrorPrefix')} {registryError}
|
|
</p>
|
|
)}
|
|
|
|
{view === 'edit' && (
|
|
<section className="grid gap-3 rounded-lg border border-stone-200 bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 md:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="model-council-name"
|
|
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('modelCouncil.councilNameLabel')}
|
|
</label>
|
|
<input
|
|
id="model-council-name"
|
|
value={councilName}
|
|
onChange={e => setCouncilName(e.target.value)}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label
|
|
htmlFor="model-council-description"
|
|
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('modelCouncil.councilDescriptionLabel')}
|
|
</label>
|
|
<textarea
|
|
id="model-council-description"
|
|
value={councilDescription}
|
|
onChange={e => setCouncilDescription(e.target.value)}
|
|
placeholder={t('modelCouncil.councilDescriptionPlaceholder')}
|
|
rows={3}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 shadow-sm resize-y focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100"
|
|
/>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{view === 'run' && (
|
|
<section className="space-y-3">
|
|
<label
|
|
htmlFor="model-council-question"
|
|
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
|
|
{t('modelCouncil.questionLabel')}
|
|
</label>
|
|
<textarea
|
|
id="model-council-question"
|
|
value={question}
|
|
onChange={e => setQuestion(e.target.value)}
|
|
rows={4}
|
|
placeholder={t('modelCouncil.questionPlaceholder')}
|
|
aria-label={t('modelCouncil.questionLabel')}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-800 shadow-sm resize-y focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
|
|
/>
|
|
</section>
|
|
)}
|
|
|
|
{view === 'edit' && (
|
|
<aside className="space-y-3 rounded-lg border border-stone-200 bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.settingsTitle')}
|
|
</p>
|
|
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.settingsSummary')
|
|
.replace('{count}', String(juryCount))
|
|
.replace('{judge}', resolvedJudgeName)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<label
|
|
htmlFor="model-council-jury-count"
|
|
className="text-xs font-medium text-stone-700 dark:text-neutral-200">
|
|
{t('modelCouncil.juryCountLabel')}
|
|
</label>
|
|
<output className="rounded-md bg-stone-100 px-2 py-0.5 text-xs font-semibold text-stone-700 dark:bg-neutral-800 dark:text-neutral-200">
|
|
{juryCount}
|
|
</output>
|
|
</div>
|
|
<input
|
|
id="model-council-jury-count"
|
|
type="range"
|
|
min={MIN_MEMBERS}
|
|
max={MAX_MEMBERS}
|
|
value={juryCount}
|
|
aria-label={t('modelCouncil.juryCountLabel')}
|
|
onChange={e => setJuryCount(Number(e.target.value))}
|
|
className="w-full accent-primary-500"
|
|
/>
|
|
<div className="grid grid-cols-5 gap-1">
|
|
{Array.from({ length: MAX_MEMBERS }, (_, index) => index + 1).map(count => (
|
|
<button
|
|
key={count}
|
|
type="button"
|
|
onClick={() => setJuryCount(count)}
|
|
aria-pressed={juryCount === count}
|
|
className={`rounded-md border px-2 py-1 text-xs font-medium ${
|
|
juryCount === count
|
|
? 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-200'
|
|
: 'border-stone-200 text-stone-500 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800'
|
|
}`}>
|
|
{count}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<label
|
|
htmlFor="model-council-debate-rounds"
|
|
className="text-xs font-medium text-stone-700 dark:text-neutral-200">
|
|
{t('modelCouncil.debateRoundsLabel')}
|
|
</label>
|
|
<output className="rounded-md bg-stone-100 px-2 py-0.5 text-xs font-semibold text-stone-700 dark:bg-neutral-800 dark:text-neutral-200">
|
|
{debateRounds}
|
|
</output>
|
|
</div>
|
|
<input
|
|
id="model-council-debate-rounds"
|
|
type="range"
|
|
min={MIN_DEBATE_ROUNDS}
|
|
max={MAX_DEBATE_ROUNDS}
|
|
value={debateRounds}
|
|
aria-label={t('modelCouncil.debateRoundsLabel')}
|
|
onChange={e => setDebateRounds(Number(e.target.value))}
|
|
className="w-full accent-primary-500"
|
|
/>
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{Array.from(
|
|
{ length: MAX_DEBATE_ROUNDS - MIN_DEBATE_ROUNDS + 1 },
|
|
(_, index) => index + MIN_DEBATE_ROUNDS
|
|
).map(rounds => (
|
|
<button
|
|
key={rounds}
|
|
type="button"
|
|
onClick={() => setDebateRounds(rounds)}
|
|
aria-pressed={debateRounds === rounds}
|
|
className={`rounded-md border px-2 py-1 text-xs font-medium ${
|
|
debateRounds === rounds
|
|
? 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-200'
|
|
: 'border-stone-200 text-stone-500 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800'
|
|
}`}>
|
|
{rounds}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-[11px] leading-4 text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.debateRoundsHelp')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label
|
|
htmlFor="model-council-judge-mode"
|
|
className="text-xs font-medium text-stone-700 dark:text-neutral-200">
|
|
{t('modelCouncil.judgeAgentLabel')}
|
|
</label>
|
|
<select
|
|
id="model-council-judge-mode"
|
|
value={judgeMode}
|
|
onChange={e => {
|
|
const mode = e.target.value as SeatMode;
|
|
setJudgeMode(mode);
|
|
setJudgeModel(mode === 'default' ? DEFAULT_JUDGE_MODEL : '');
|
|
}}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100">
|
|
<option value="default">{t('modelCouncil.defaultJudge')}</option>
|
|
<option value="profile">{t('modelCouncil.savedProfile')}</option>
|
|
<option value="custom">{t('modelCouncil.customAgent')}</option>
|
|
</select>
|
|
|
|
{judgeMode === 'profile' && (
|
|
<select
|
|
value={judgeProfileId}
|
|
aria-label={t('modelCouncil.judgeProfileLabel')}
|
|
onChange={e => {
|
|
setJudgeProfileId(e.target.value);
|
|
setJudgeModel('');
|
|
}}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100">
|
|
<option value="">{t('modelCouncil.chooseProfile')}</option>
|
|
{profiles.map(profile => (
|
|
<option key={profile.id} value={profile.id}>
|
|
{profileLabel(profile)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{judgeMode === 'custom' && (
|
|
<input
|
|
type="text"
|
|
value={judgeName}
|
|
onChange={e => setJudgeName(e.target.value)}
|
|
aria-label={t('modelCouncil.judgeNameLabel')}
|
|
placeholder={t('modelCouncil.judgeNamePlaceholder')}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100"
|
|
/>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setModelPicker({
|
|
title: t('modelCouncil.chairLabel'),
|
|
value: judgeModel,
|
|
onSelect: setJudgeModel,
|
|
})
|
|
}
|
|
aria-label={t('modelCouncil.chairLabel')}
|
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-left font-mono text-sm text-stone-800 hover:bg-stone-50 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 dark:hover:bg-neutral-900">
|
|
<span className="truncate">{judgeModel || DEFAULT_JUDGE_MODEL}</span>
|
|
<span className="shrink-0 text-[11px] font-semibold text-primary-600 dark:text-primary-300">
|
|
{t('modelCouncil.selectModel')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
)}
|
|
|
|
{view === 'edit' && (
|
|
<section aria-labelledby="model-council-roster-heading" className="space-y-3">
|
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
|
<div>
|
|
<h3
|
|
id="model-council-roster-heading"
|
|
className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
|
{t('modelCouncil.rosterHeading')}
|
|
</h3>
|
|
<p className="text-xs text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.rosterHelp')}
|
|
</p>
|
|
</div>
|
|
{profileStatus === 'loading' && (
|
|
<span className="text-xs text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.loadingProfiles')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{seats.map((seat, index) => {
|
|
const resolved = resolvedSeats[index];
|
|
const colors = mascotColors(index);
|
|
const activeFace = running
|
|
? ACTIVE_SEAT_FACES[index % ACTIVE_SEAT_FACES.length]
|
|
: SEAT_FACES[index % SEAT_FACES.length];
|
|
return (
|
|
<article
|
|
key={seat.id}
|
|
className={`rounded-lg border bg-white p-3 shadow-sm transition-colors dark:bg-neutral-900 ${
|
|
running
|
|
? 'border-primary-300 ring-2 ring-primary-100 dark:border-primary-500/50 dark:ring-primary-500/10'
|
|
: 'border-stone-200 dark:border-neutral-800'
|
|
}`}>
|
|
<div className="flex gap-3">
|
|
<div
|
|
className={`h-20 w-20 shrink-0 overflow-hidden rounded-lg bg-stone-100 dark:bg-neutral-800 ${
|
|
running ? 'animate-pulse' : ''
|
|
}`}>
|
|
<RiveMascot
|
|
size="100%"
|
|
face={activeFace}
|
|
primaryColor={colors.primaryColor}
|
|
secondaryColor={colors.secondaryColor}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="truncate text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{resolved.label}
|
|
</p>
|
|
<span className="rounded-md bg-stone-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-stone-500 dark:bg-neutral-800 dark:text-neutral-400">
|
|
{t('modelCouncil.jurorLabel').replace('{n}', String(index + 1))}
|
|
</span>
|
|
</div>
|
|
|
|
<div
|
|
role="tablist"
|
|
aria-label={t('modelCouncil.profileModeLabel')}
|
|
className="grid grid-cols-3 gap-1">
|
|
{(['default', 'profile', 'custom'] as SeatMode[]).map(mode => (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={seat.mode === mode}
|
|
onClick={() => setSeatMode(seat, mode)}
|
|
className={`rounded-md px-2 py-1 text-[11px] font-medium ${
|
|
seat.mode === mode
|
|
? 'bg-primary-500 text-white'
|
|
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700'
|
|
}`}>
|
|
{t(`modelCouncil.mode.${mode}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-2">
|
|
{seat.mode === 'profile' ? (
|
|
<select
|
|
value={seat.profileId}
|
|
aria-label={t('modelCouncil.memberProfileAria').replace(
|
|
'{n}',
|
|
String(index + 1)
|
|
)}
|
|
onChange={e =>
|
|
updateSeat(seat.id, { profileId: e.target.value, model: '' })
|
|
}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100">
|
|
<option value="">{t('modelCouncil.chooseProfile')}</option>
|
|
{profiles.map(profile => (
|
|
<option key={profile.id} value={profile.id}>
|
|
{profileLabel(profile)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={seat.name}
|
|
onChange={e => updateSeat(seat.id, { name: e.target.value })}
|
|
aria-label={t('modelCouncil.memberNameAria').replace(
|
|
'{n}',
|
|
String(index + 1)
|
|
)}
|
|
placeholder={t('modelCouncil.memberNamePlaceholder')}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-sm text-stone-800 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100"
|
|
/>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setModelPicker({
|
|
title: t('modelCouncil.memberAria').replace('{n}', String(index + 1)),
|
|
value: seat.model,
|
|
onSelect: model => updateSeat(seat.id, { model }),
|
|
})
|
|
}
|
|
aria-label={t('modelCouncil.memberAria').replace('{n}', String(index + 1))}
|
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-left font-mono text-sm text-stone-800 hover:bg-stone-50 focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-100 dark:hover:bg-neutral-900">
|
|
<span className="truncate">{seat.model || DEFAULT_MODEL}</span>
|
|
<span className="shrink-0 text-[11px] font-semibold text-primary-600 dark:text-primary-300">
|
|
{t('modelCouncil.selectModel')}
|
|
</span>
|
|
</button>
|
|
|
|
<textarea
|
|
value={seat.brief}
|
|
onChange={e => updateSeat(seat.id, { brief: e.target.value })}
|
|
rows={2}
|
|
aria-label={t('modelCouncil.memberBriefAria').replace(
|
|
'{n}',
|
|
String(index + 1)
|
|
)}
|
|
placeholder={t('modelCouncil.memberBriefPlaceholder')}
|
|
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-1.5 text-xs text-stone-700 resize-none focus:outline-none focus:ring-2 focus:ring-primary-400 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-200"
|
|
/>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{view === 'run' && running && (
|
|
<section
|
|
aria-labelledby="model-council-deliberation-heading"
|
|
className="space-y-3 rounded-lg border border-primary-200 bg-primary-50/60 p-3 dark:border-primary-500/30 dark:bg-primary-500/10">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h3
|
|
id="model-council-deliberation-heading"
|
|
className="text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{t('modelCouncil.deliberationHeading')}
|
|
</h3>
|
|
<p className="text-xs text-stone-600 dark:text-neutral-300">
|
|
{t('modelCouncil.deliberationHelp')}
|
|
</p>
|
|
</div>
|
|
<span
|
|
role="status"
|
|
aria-live="polite"
|
|
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-primary-700 shadow-sm dark:bg-neutral-950 dark:text-primary-200">
|
|
{t('modelCouncil.runningHint')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
|
{resolvedSeats.map((seat, index) => {
|
|
const colors = mascotColors(index);
|
|
const liveMember = liveMembers[index];
|
|
const answered = liveMember?.status === 'answered';
|
|
const failed = liveMember?.status === 'failed';
|
|
const turns = liveMember?.turns ?? [];
|
|
const hasTurns = turns.length > 0;
|
|
const waitingText = deliberationThought(seat, index, t);
|
|
return (
|
|
<div
|
|
key={`${seat.label}-${index}`}
|
|
className={`rounded-lg border bg-white/90 p-3 shadow-sm dark:bg-neutral-950/80 ${
|
|
failed
|
|
? 'border-coral-200 dark:border-coral-500/30'
|
|
: answered
|
|
? 'border-sage-200 dark:border-sage-500/30'
|
|
: 'border-white/80 dark:border-neutral-800'
|
|
}`}>
|
|
<div className="flex items-start gap-3">
|
|
<div
|
|
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-stone-100 dark:bg-neutral-800 ${
|
|
liveMember?.status === 'pending' || !liveMember ? 'animate-pulse' : ''
|
|
}`}>
|
|
<RiveMascot
|
|
size="100%"
|
|
face={
|
|
failed
|
|
? 'curious'
|
|
: answered
|
|
? 'proud'
|
|
: ACTIVE_SEAT_FACES[index % ACTIVE_SEAT_FACES.length]
|
|
}
|
|
primaryColor={colors.primaryColor}
|
|
secondaryColor={colors.secondaryColor}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="truncate text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{seat.label}
|
|
</p>
|
|
<span
|
|
className={`shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase ${
|
|
failed
|
|
? 'bg-coral-100 text-coral-700 dark:bg-coral-500/20 dark:text-coral-300'
|
|
: answered
|
|
? 'bg-sage-100 text-sage-700 dark:bg-sage-500/20 dark:text-sage-300'
|
|
: 'bg-primary-100 text-primary-700 dark:bg-primary-500/20 dark:text-primary-200'
|
|
}`}>
|
|
{failed
|
|
? t('modelCouncil.memberFailed')
|
|
: answered
|
|
? t('modelCouncil.memberAnswered')
|
|
: t('modelCouncil.thinkingBadge')}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 max-h-52 space-y-1.5 overflow-y-auto pr-1">
|
|
{hasTurns ? (
|
|
turns.map(turn => (
|
|
<div
|
|
key={`${seat.label}-${index}-${turn.round}`}
|
|
className={`rounded-md border px-2 py-1.5 ${
|
|
turn.error
|
|
? 'border-coral-100 bg-coral-50/70 dark:border-coral-500/20 dark:bg-coral-500/10'
|
|
: 'border-stone-200 bg-stone-50 dark:border-neutral-800 dark:bg-neutral-900'
|
|
}`}>
|
|
<p className="text-[10px] font-semibold uppercase text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.roundLabel').replace(
|
|
'{round}',
|
|
String(turn.round)
|
|
)}
|
|
</p>
|
|
{turn.error ? (
|
|
<p className="mt-0.5 line-clamp-4 whitespace-pre-wrap text-xs text-coral-600 dark:text-coral-300">
|
|
{turn.error}
|
|
</p>
|
|
) : (
|
|
<div className="mt-0.5 text-stone-600 dark:text-neutral-300 [&_.prose]:text-xs [&_.prose]:leading-5 [&_.prose_p]:my-0">
|
|
<BubbleMarkdown content={turn.response || ''} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="line-clamp-5 whitespace-pre-wrap text-xs text-stone-600 dark:text-neutral-300">
|
|
{waitingText}
|
|
</p>
|
|
)}
|
|
{liveMember?.status === 'pending' && hasTurns && (
|
|
<p className="text-[11px] text-primary-700 dark:text-primary-200">
|
|
{t('modelCouncil.currentRoundThinking')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<div className="rounded-lg border border-primary-200 bg-white p-3 shadow-sm dark:border-primary-500/30 dark:bg-neutral-950">
|
|
<div className="flex items-start gap-3">
|
|
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-stone-100 dark:bg-neutral-800">
|
|
<RiveMascot size="100%" face="reading" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="truncate text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{resolvedJudgeName}
|
|
</p>
|
|
<span className="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase text-amber-700 dark:bg-amber-500/20 dark:text-amber-200">
|
|
{judgeSynthesizing
|
|
? t('modelCouncil.judgeSynthesizingBadge')
|
|
: t('modelCouncil.judgeWaitingBadge')}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-stone-600 dark:text-neutral-300">
|
|
{judgeSynthesizing
|
|
? t('modelCouncil.judgeSynthesizingThought')
|
|
: t('modelCouncil.judgeWaitingThought')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-stone-200 bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-950">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h4 className="font-mono text-sm font-semibold text-stone-900 dark:text-neutral-50">
|
|
{SHARED_REASONING_FILE}
|
|
</h4>
|
|
<p className="text-xs text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.liveScratchpadHelp')}
|
|
</p>
|
|
</div>
|
|
<span className="rounded bg-primary-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase text-primary-700 dark:bg-primary-500/20 dark:text-primary-200">
|
|
{t('modelCouncil.liveScratchpadBadge')}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 max-h-72 overflow-y-auto rounded-md border border-stone-200 bg-stone-50 px-3 py-2 text-stone-700 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-200 [&_.prose]:text-xs [&_.prose]:leading-5">
|
|
<BubbleMarkdown content={liveScratchpad || sharedReasoning} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{view === 'run' && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRun()}
|
|
disabled={!canRun}
|
|
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-50">
|
|
{running ? t('modelCouncil.running') : t('modelCouncil.run')}
|
|
</button>
|
|
{running && (
|
|
<span
|
|
role="status"
|
|
aria-live="polite"
|
|
className="text-xs text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.runningHint')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{view === 'run' && error && (
|
|
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
|
|
{t('modelCouncil.errorPrefix')} {error}
|
|
</p>
|
|
)}
|
|
|
|
{view === 'run' && result && (
|
|
<section aria-labelledby="model-council-results-heading" className="space-y-3 pt-1">
|
|
<h3
|
|
id="model-council-results-heading"
|
|
className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
|
{t('modelCouncil.resultsHeading')}
|
|
</h3>
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{result.members.map((member, index) => (
|
|
<div
|
|
key={`${member.model}-${index}`}
|
|
className="rounded-lg border border-stone-200 p-3 space-y-1.5 dark:border-neutral-800">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="truncate font-mono text-xs font-medium text-stone-700 dark:text-neutral-200">
|
|
{member.model}
|
|
</span>
|
|
<span
|
|
className={`inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide ${
|
|
member.error
|
|
? 'bg-coral-100 text-coral-700 dark:bg-coral-500/20 dark:text-coral-300'
|
|
: 'bg-sage-100 text-sage-700 dark:bg-sage-500/20 dark:text-sage-300'
|
|
}`}>
|
|
{member.error
|
|
? t('modelCouncil.memberFailed')
|
|
: t('modelCouncil.memberAnswered')}
|
|
</span>
|
|
</div>
|
|
{member.error ? (
|
|
<p className="text-xs text-coral-600 dark:text-coral-400">{member.error}</p>
|
|
) : (
|
|
<div className="break-words text-stone-600 dark:text-neutral-300 [&_.prose]:text-xs [&_.prose]:leading-5 [&_.prose_p]:my-0">
|
|
<BubbleMarkdown content={member.response || ''} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-primary-200 bg-primary-50 p-3 space-y-1 dark:border-primary-500/30 dark:bg-primary-500/10">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h4 className="text-xs font-semibold text-stone-800 dark:text-neutral-100">
|
|
{t('modelCouncil.synthesisHeading')}
|
|
</h4>
|
|
<span className="truncate font-mono text-[10px] text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.synthesisBy').replace('{model}', result.chair_model)}
|
|
</span>
|
|
</div>
|
|
<div className="break-words text-stone-700 dark:text-neutral-200 [&_.prose]:text-sm [&_.prose]:leading-6">
|
|
<BubbleMarkdown content={result.synthesis} />
|
|
</div>
|
|
</div>
|
|
|
|
{usageEstimate && (
|
|
<div className="rounded-lg border border-stone-200 bg-white p-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-stone-800 dark:text-neutral-100">
|
|
{t('modelCouncil.usageHeading')}
|
|
</h4>
|
|
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.usageEstimated')}
|
|
</p>
|
|
</div>
|
|
<span className="rounded-md bg-stone-100 px-2 py-1 text-xs font-semibold text-stone-700 dark:bg-neutral-800 dark:text-neutral-200">
|
|
{t('modelCouncil.usageEstimatedBadge')}
|
|
</span>
|
|
</div>
|
|
<dl className="mt-3 grid gap-2 sm:grid-cols-3">
|
|
<div className="rounded-md bg-stone-50 px-2 py-1.5 dark:bg-neutral-950">
|
|
<dt className="text-[10px] uppercase text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.usageInputTokens')}
|
|
</dt>
|
|
<dd className="font-mono text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
|
{formatTokenCount(usageEstimate.inputTokens)}
|
|
</dd>
|
|
</div>
|
|
<div className="rounded-md bg-stone-50 px-2 py-1.5 dark:bg-neutral-950">
|
|
<dt className="text-[10px] uppercase text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.usageOutputTokens')}
|
|
</dt>
|
|
<dd className="font-mono text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
|
{formatTokenCount(usageEstimate.outputTokens)}
|
|
</dd>
|
|
</div>
|
|
<div className="rounded-md bg-stone-50 px-2 py-1.5 dark:bg-neutral-950">
|
|
<dt className="text-[10px] uppercase text-stone-500 dark:text-neutral-400">
|
|
{t('modelCouncil.usageTotalTokens')}
|
|
</dt>
|
|
<dd className="font-mono text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
|
{formatTokenCount(usageEstimate.totalTokens)}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{modelPicker && (
|
|
<ModelPickerDialog picker={modelPicker} onClose={() => setModelPicker(null)} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ModelCouncilTab;
|