Jordan Miller
Harden Expert Persona builder and add model suggestion.
1961f74
Raw
History Blame Contribute Delete
15.3 kB
// Default to '' (relative URLs) so any production-style build - whether
// done inside Docker (Dockerfile sets REACT_APP_API_URL=) or on the host
// without that env var - hits the same origin that served the page. This
// avoids the cross-origin trap where a `npm run build` on the host
// without REACT_APP_API_URL would silently bake `http://localhost:8000`
// into the bundle and break every API call from a Docker deployment.
//
// If you want the CRA dev server (`npm start` on :3000) to talk to a
// FastAPI backend on :8000, set REACT_APP_API_URL=http://localhost:8000
// in `frontend/.env.development` or your shell.
const API_BASE = process.env.REACT_APP_API_URL !== undefined
? process.env.REACT_APP_API_URL
: '';
export async function fetchModels() {
const resp = await fetch(`${API_BASE}/api/models`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`Failed to fetch models: ${resp.status}`);
return resp.json();
}
export async function fetchPersonas() {
const resp = await fetch(`${API_BASE}/api/personas`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`Failed to fetch personas: ${resp.status}`);
return resp.json();
}
export async function fetchDemoQuestions() {
const resp = await fetch(`${API_BASE}/api/demo-questions`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`Failed to fetch demo questions: ${resp.status}`);
return resp.json();
}
export async function generateRole({
name,
profile,
identity,
samples,
role_style,
orchestrator_model_id,
}) {
const resp = await fetch(`${API_BASE}/api/chat/generate-role`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
profile,
identity,
samples,
role_style,
orchestrator_model_id,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Role generation failed');
}
return resp.json();
}
export async function generateRoleFreeform({
name,
text,
role_style,
orchestrator_model_id,
}) {
const resp = await fetch(`${API_BASE}/api/chat/generate-role-freeform`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
text,
role_style,
orchestrator_model_id,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Role generation failed');
}
return resp.json();
}
/**
* Suggest an LLM for an Expert Persona from the builder's live model list.
*
* Body: { persona_name, source_text, role_prompt, available_models,
* panel_context, orchestrator_model_id }
*
* Returns: { recommended_model_id, rationale }
*/
export async function suggestModel({
persona_name,
source_text,
role_prompt,
available_models,
panel_context,
orchestrator_model_id,
}) {
const resp = await fetch(`${API_BASE}/api/chat/suggest-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
persona_name,
source_text,
role_prompt,
available_models,
panel_context,
orchestrator_model_id,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
const detail = err.detail;
const message = typeof detail === 'string'
? detail
: Array.isArray(detail)
? detail.map(d => d.msg || JSON.stringify(d)).join('; ')
: 'Model suggestion failed';
throw new Error(message);
}
return resp.json();
}
/**
* Start a CCAI conversation and consume the SSE stream.
*
* Body: { question, participants[], expert_personas[], model_assignments,
* orchestrator_model_id, summarizer_model_id, max_participants }
*/
export async function startChat(body, handlers, abortSignal) {
const resp = await fetch(`${API_BASE}/api/chat/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortSignal,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Chat start failed');
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
if (abortSignal?.aborted) break;
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop();
for (const part of parts) {
const lines = part.trim().split('\n');
let eventType = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6);
}
if (!data) continue;
try {
const parsed = JSON.parse(data);
const handler = handlers[eventHandlerKey(eventType)];
if (handler) handler(parsed);
} catch (e) {
console.warn('SSE parse error', e, data);
}
}
}
} finally {
reader.releaseLock();
}
handlers.onDone?.();
}
function eventHandlerKey(eventType) {
switch (eventType) {
case 'session': return 'onSession';
case 'message': return 'onMessage';
case 'message_stream_start': return 'onMessageStreamStart';
case 'message_delta': return 'onMessageDelta';
case 'orchestrator': return 'onOrchestrator';
case 'system': return 'onSystem';
case 'status': return 'onStatus';
case 'error': return 'onError';
case 'done': return 'onDone';
case 'failsafe_pause': return 'onFailsafePause';
case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
case 'participant_error': return 'onParticipantError';
case 'participant_substituted': return 'onParticipantSubstituted';
case 'participant_replaced': return 'onParticipantReplaced';
case 'vote_cast': return 'onVoteCast';
case 'vote_tally': return 'onVoteTally';
case 'credentials_updated': return 'onCredentialsUpdated';
case 'human_turn_needed': return 'onHumanTurnNeeded';
case 'human_turn_cleared': return 'onHumanTurnCleared';
default: return null;
}
}
export async function continueChat(sessionId, reason) {
const resp = await fetch(
`${API_BASE}/api/chat/${sessionId}/continue?reason=${encodeURIComponent(reason)}`,
{ method: 'POST' },
);
if (!resp.ok) throw new Error('Continue failed');
return resp.json();
}
export async function getOrchestrator() {
const resp = await fetch(`${API_BASE}/api/chat/orchestrator`);
if (!resp.ok) throw new Error('Failed to get orchestrator');
return resp.json();
}
export async function setOrchestrator(modelId) {
const resp = await fetch(`${API_BASE}/api/chat/orchestrator`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId }),
});
if (!resp.ok) throw new Error('Failed to set orchestrator');
return resp.json();
}
export async function getSpeedPriority() {
const resp = await fetch(`${API_BASE}/api/chat/speed-priority`);
if (!resp.ok) throw new Error('Failed to get speed priority');
return resp.json();
}
export async function setSpeedPriority(enabled) {
const resp = await fetch(`${API_BASE}/api/chat/speed-priority`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!resp.ok) throw new Error('Failed to set speed priority');
return resp.json();
}
/**
* Fetch the catalog of available conversation structures and
* decision-making methods. The Settings menu's "Conversation format"
* accordion populates from this so adding a new plugin server-side
* doesn't require frontend code changes.
*
* Returns: { structures: [{id, name, description}, ...],
* decisions: [{id, name, description}, ...],
* default_structure_id, default_decision_id }
*/
export async function fetchConversationFormats() {
const resp = await fetch(`${API_BASE}/api/chat/conversation-formats`, { cache: 'no-store' });
if (!resp.ok) throw new Error('Failed to fetch conversation formats');
return resp.json();
}
export async function exportChat(sessionId, fmt = 'txt') {
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/export?fmt=${fmt}`);
if (!resp.ok) throw new Error('Export failed');
return resp.json();
}
export async function exportApiLog(sessionId) {
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/api-log`);
if (!resp.ok) throw new Error('API log export failed');
return resp.json();
}
export async function fetchTableView(sessionId) {
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/table`);
if (!resp.ok) throw new Error('Table view fetch failed');
return resp.json();
}
/**
* Fetch the catalog of every prompt template the orchestrator and
* participants use, grouped by phase and annotated with purpose and
* runtime variables. Backs the "View current chat prompts" modal.
*/
export async function fetchPromptCatalog() {
const resp = await fetch(`${API_BASE}/api/chat/prompts/catalog`, { cache: 'no-store' });
if (!resp.ok) throw new Error('Failed to fetch prompt catalog');
return resp.json();
}
/**
* Ask the backend to pick the top `count` participants from the
* candidate pool by relevance to the question. Used by the
* "Select N Automatically" toggle in the participants dropdown.
*
* Returns: { selected: [participant_id, ...], rationale: "..." }
*/
export async function autoSelectParticipants({ question, count, candidates, orchestrator_model_id }) {
const resp = await fetch(`${API_BASE}/api/chat/auto-select-participants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, count, candidates, orchestrator_model_id }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Auto-select failed');
}
return resp.json();
}
/**
* Fetch the user-tunable conversation-limit defaults, bounds, and
* descriptions. The frontend uses this to render the "Conversation
* limits" settings modal entirely from the server schema, so adding
* a new knob in the backend doesn't require a frontend change.
*
* Shape: { defaults: {field: int}, bounds: {field: {min, max}},
* descriptions: {field: {group, label, help}} }
*/
export async function fetchConversationLimitsDefaults() {
const resp = await fetch(`${API_BASE}/api/chat/limits/defaults`, { cache: 'no-store' });
if (!resp.ok) throw new Error('Failed to fetch conversation-limit defaults');
return resp.json();
}
export async function fetchCredentials(sessionId) {
const resp = await fetch(
`${API_BASE}/api/chat/${sessionId}/credentials`,
{ cache: 'no-store' },
);
if (!resp.ok) throw new Error('Credentials fetch failed');
return resp.json();
}
/**
* Submit the human participant's response to the orchestrator for the
* currently pending turn. `skip=true` flips the turn into a "declined
* to comment" note rather than a message.
*/
export async function submitHumanResponse(sessionId, { text, skip = false } = {}) {
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/human-response`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text || '', skip: !!skip }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Submit failed');
}
return resp.json();
}
/**
* Patch the in-the-loop human's credential summary. Used by the
* CredentialSummaryModal's edit affordance on the human's row. The
* backend rejects fields it doesn't know about; we send only the
* fields the user actually changed (sparse patch).
*/
export async function patchHumanCredential(sessionId, patch) {
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/credentials/human`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Edit failed');
}
return resp.json();
}
/**
* Start the AI-assisted credential intake Q&A flow. Returns either a
* first question or (rarely) a final summary if the LLM bails. The
* draft_id is needed for subsequent /answer calls.
*/
/**
* Generate a structured credential summary from a human's freeform
* profile text (experience, personality, etc.). Uses the orchestrator
* the same way it assesses an LLM participant's role prompt.
*/
export async function generateHumanCredentialFromProfile({
name, question, profile_text, participant_id, orchestrator_model_id,
}) {
const resp = await fetch(`${API_BASE}/api/chat/credentials/from-profile`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
question,
profile_text,
participant_id: participant_id || '',
orchestrator_model_id: orchestrator_model_id || null,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Credential generation failed');
}
return resp.json();
}
export async function startCredentialDraft({
name, question, max_questions = 6, orchestrator_model_id = null,
}) {
const resp = await fetch(`${API_BASE}/api/chat/credentials/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, question, max_questions, orchestrator_model_id }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Credential draft start failed');
}
return resp.json();
}
export async function answerCredentialDraft(draftId, answer) {
const resp = await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: answer || '' }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Credential draft answer failed');
}
return resp.json();
}
export async function cancelCredentialDraft(draftId) {
try {
await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}`, {
method: 'DELETE',
});
} catch (_) { /* fire-and-forget cleanup; ignore */ }
}
export async function getAuthStatus() {
const resp = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
return resp.json();
}
export async function getRateLimitStatus() {
const resp = await fetch(`${API_BASE}/api/rate-limit/status`, { credentials: 'include' });
if (!resp.ok) return { remaining: -1, daily_limit: 30 };
return resp.json();
}