NeonClary Cursor commited on
Commit
af5d1df
·
1 Parent(s): 1ec996f

feat(participants): add "Select N Automatically" option

Browse files

When the user picks the new top-of-dropdown "Select N Automatically"
option, the trigger label switches to "Participants Auto Selected" and
manual persona checkboxes are hidden. Clicking either start button
calls a new /api/chat/auto-select-participants endpoint that uses the
orchestrator LLM to rank all available candidates by relevance to the
question, populates the sidebar with the top N, then proceeds with
/chat/start as usual. Manual selection is restored if auto mode is
turned back off.

Co-authored-by: Cursor <cursoragent@cursor.com>

backend/app/api/chat.py CHANGED
@@ -32,6 +32,7 @@ from app.services.models import (
32
  Session,
33
  clamp_conversation_limits,
34
  )
 
35
  from app.services.orchestrator import (
36
  create_session,
37
  get_session,
@@ -97,6 +98,27 @@ class ParticipantSelectionPayload(BaseModel):
97
  model_id_override: str | None = None
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  class StartChatRequest(BaseModel):
101
  question: str | None = None
102
 
@@ -172,6 +194,39 @@ async def api_generate_role_freeform(req: GenerateRoleFreeformRequest):
172
  return result
173
 
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  # ---------------------------------------------------------------------------
176
  # Conversation limits (steppers in the settings menu)
177
  # ---------------------------------------------------------------------------
 
32
  Session,
33
  clamp_conversation_limits,
34
  )
35
+ from app.services.auto_select import auto_select_participants
36
  from app.services.orchestrator import (
37
  create_session,
38
  get_session,
 
98
  model_id_override: str | None = None
99
 
100
 
101
+ class AutoSelectCandidate(BaseModel):
102
+ """One row of the candidate pool sent to the auto-select endpoint."""
103
+
104
+ participant_id: str
105
+ name: str
106
+ role_prompt: str = ""
107
+ kind: str = ""
108
+ model_id: str = ""
109
+
110
+
111
+ class AutoSelectRequest(BaseModel):
112
+ """Body of POST /api/chat/auto-select-participants."""
113
+
114
+ question: str
115
+ count: int = 5
116
+ candidates: list[AutoSelectCandidate]
117
+ # Optional: pin the orchestrator model used for ranking. Defaults
118
+ # to the configured global orchestrator model.
119
+ orchestrator_model_id: str | None = None
120
+
121
+
122
  class StartChatRequest(BaseModel):
123
  question: str | None = None
124
 
 
194
  return result
195
 
196
 
197
+ # ---------------------------------------------------------------------------
198
+ # Auto-select participants (LLM-based ranking for "Select N Automatically")
199
+ # ---------------------------------------------------------------------------
200
+
201
+ @router.post("/chat/auto-select-participants")
202
+ async def api_auto_select_participants(req: AutoSelectRequest):
203
+ """Rank the candidate pool by relevance to the question and return
204
+ the top `count` participant_ids. The frontend calls this just
205
+ before /chat/start when the user has the auto-select toggle on.
206
+
207
+ Returns: {"selected": [id, ...], "rationale": "short string"}.
208
+ `selected` is exactly `count` long unless the candidate pool is
209
+ smaller. Invalid / hallucinated ids are silently dropped and
210
+ padded with the next unused candidates.
211
+ """
212
+ if not req.question or not req.question.strip():
213
+ raise HTTPException(400, "Question is required")
214
+ if not req.candidates:
215
+ raise HTTPException(400, "At least one candidate is required")
216
+ if req.count < 1:
217
+ raise HTTPException(400, "count must be >= 1")
218
+
219
+ candidates_payload = [c.dict() for c in req.candidates]
220
+ orchestrator_id = req.orchestrator_model_id or settings.orchestrator_model
221
+ result = await auto_select_participants(
222
+ orchestrator_model_id=orchestrator_id,
223
+ question=req.question,
224
+ candidates=candidates_payload,
225
+ count=req.count,
226
+ )
227
+ return result
228
+
229
+
230
  # ---------------------------------------------------------------------------
231
  # Conversation limits (steppers in the settings menu)
232
  # ---------------------------------------------------------------------------
backend/app/services/auto_select.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auto-select N participants for a question.
2
+
3
+ Backs the optional "Select N Automatically" toggle in the participants
4
+ dropdown. The orchestrator LLM ranks the full candidate pool for
5
+ relevance to the question and returns the top N. The service:
6
+
7
+ - formats a compact candidates block (id + name + role role-prompt
8
+ snippet) so the LLM can pick deliberately;
9
+ - runs the call through `orchestrator_call` (which strips think
10
+ traces and is JSON-tolerant);
11
+ - validates every returned id against the candidate pool, drops
12
+ invented ones, and pads with the next-best unused candidates if the
13
+ LLM under-delivered.
14
+
15
+ If the orchestrator call fails entirely, we fall back to the first N
16
+ candidates in the order received, so the user still gets a working
17
+ chat instead of a hard error.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ from app.services.json_calls import orchestrator_call
25
+ from app.services.prompts import AUTO_SELECT_PARTICIPANTS_PROMPT
26
+
27
+ LOG = logging.getLogger(__name__)
28
+
29
+ _ROLE_SNIPPET_CHARS = 320
30
+
31
+
32
+ def _candidate_block(candidates: list[dict[str, Any]]) -> str:
33
+ """Render one line per candidate: id, name, kind, model, role snippet.
34
+
35
+ Role prompts are truncated so a roster of ~30 candidates fits in
36
+ a single orchestrator call without crowding out the question.
37
+ """
38
+ lines: list[str] = []
39
+ for i, c in enumerate(candidates, start=1):
40
+ role = (c.get("role_prompt") or "").strip()
41
+ if len(role) > _ROLE_SNIPPET_CHARS:
42
+ role = role[:_ROLE_SNIPPET_CHARS].rstrip() + "..."
43
+ lines.append(
44
+ f"{i}. id={c.get('participant_id')} | name={c.get('name')} "
45
+ f"| kind={c.get('kind')} | model={c.get('model_id')}\n"
46
+ f" role: {role or '(no role description)'}"
47
+ )
48
+ return "\n".join(lines)
49
+
50
+
51
+ def _validate_and_pad(
52
+ selected_raw: list[str] | None,
53
+ candidates: list[dict[str, Any]],
54
+ count: int,
55
+ ) -> list[str]:
56
+ """Keep only LLM-returned ids that exist in the candidate pool,
57
+ de-dupe while preserving the LLM's ranking, and pad with the next
58
+ unused candidates (in input order) up to `count`.
59
+ """
60
+ valid_ids = {c.get("participant_id") for c in candidates if c.get("participant_id")}
61
+ chosen: list[str] = []
62
+ seen: set[str] = set()
63
+ for sid in selected_raw or []:
64
+ if not isinstance(sid, str):
65
+ continue
66
+ if sid in valid_ids and sid not in seen:
67
+ chosen.append(sid)
68
+ seen.add(sid)
69
+ if len(chosen) == count:
70
+ break
71
+
72
+ if len(chosen) < count:
73
+ # Pad with the first unused candidates in the order received.
74
+ for c in candidates:
75
+ pid = c.get("participant_id")
76
+ if not pid or pid in seen:
77
+ continue
78
+ chosen.append(pid)
79
+ seen.add(pid)
80
+ if len(chosen) == count:
81
+ break
82
+
83
+ return chosen[:count]
84
+
85
+
86
+ async def auto_select_participants(
87
+ *,
88
+ orchestrator_model_id: str,
89
+ question: str,
90
+ candidates: list[dict[str, Any]],
91
+ count: int,
92
+ api_log: list[dict[str, Any]] | None = None,
93
+ ) -> dict[str, Any]:
94
+ """Return {"selected": [participant_id, ...], "rationale": str}.
95
+
96
+ `selected` is always exactly `count` long (padded from the
97
+ candidate pool if the LLM under-delivers). Never raises on LLM
98
+ errors - those degrade to a first-N fallback so the caller can
99
+ proceed to /chat/start.
100
+ """
101
+ n_target = max(1, min(count, len(candidates)))
102
+
103
+ if not candidates:
104
+ return {"selected": [], "rationale": "No candidates provided."}
105
+
106
+ # Single-candidate / under-supplied pools have nothing to pick from.
107
+ if len(candidates) <= n_target:
108
+ return {
109
+ "selected": [c["participant_id"] for c in candidates if c.get("participant_id")],
110
+ "rationale": "Candidate pool was at or below the requested count; using all.",
111
+ }
112
+
113
+ prompt = AUTO_SELECT_PARTICIPANTS_PROMPT.format(
114
+ question=question.strip(),
115
+ candidates_block=_candidate_block(candidates),
116
+ count=n_target,
117
+ )
118
+ _raw, parsed = await orchestrator_call(
119
+ orchestrator_model_id=orchestrator_model_id,
120
+ user_prompt=prompt,
121
+ label="auto_select_participants",
122
+ api_log=api_log,
123
+ max_tokens=512,
124
+ temperature=0.2,
125
+ )
126
+
127
+ selected_raw: list[str] | None = None
128
+ rationale = ""
129
+ if isinstance(parsed, dict):
130
+ if isinstance(parsed.get("selected"), list):
131
+ selected_raw = [str(x) for x in parsed["selected"]]
132
+ if isinstance(parsed.get("rationale"), str):
133
+ rationale = parsed["rationale"].strip()
134
+
135
+ selected = _validate_and_pad(selected_raw, candidates, n_target)
136
+
137
+ if not rationale:
138
+ rationale = "Selected by relevance to the question."
139
+ if not selected_raw:
140
+ rationale = "Auto-select fell back to the first candidates (LLM unavailable)."
141
+
142
+ return {"selected": selected, "rationale": rationale}
backend/app/services/prompts/__init__.py CHANGED
@@ -35,6 +35,7 @@ from app.services.prompts.closure import (
35
  NO_CONSENSUS_REPORT_PROMPT,
36
  CONTRIBUTION_SUMMARY_PROMPT,
37
  )
 
38
 
39
  __all__ = [
40
  "PARTICIPANT_BASE_DIRECTIVE",
@@ -57,4 +58,5 @@ __all__ = [
57
  "MAJORITY_REPORT_PROMPT",
58
  "NO_CONSENSUS_REPORT_PROMPT",
59
  "CONTRIBUTION_SUMMARY_PROMPT",
 
60
  ]
 
35
  NO_CONSENSUS_REPORT_PROMPT,
36
  CONTRIBUTION_SUMMARY_PROMPT,
37
  )
38
+ from app.services.prompts.auto_select import AUTO_SELECT_PARTICIPANTS_PROMPT
39
 
40
  __all__ = [
41
  "PARTICIPANT_BASE_DIRECTIVE",
 
58
  "MAJORITY_REPORT_PROMPT",
59
  "NO_CONSENSUS_REPORT_PROMPT",
60
  "CONTRIBUTION_SUMMARY_PROMPT",
61
+ "AUTO_SELECT_PARTICIPANTS_PROMPT",
62
  ]
backend/app/services/prompts/auto_select.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prompt for the optional "Select N Automatically" mode.
2
+
3
+ The user can enable an auto-select toggle in the participants dropdown;
4
+ when they start a chat, the orchestrator LLM ranks every available
5
+ candidate persona for relevance to the question and returns the top N.
6
+ """
7
+
8
+ AUTO_SELECT_PARTICIPANTS_PROMPT = (
9
+ "You are helping pick the most relevant participants for a CCAI "
10
+ "group discussion. The user has asked the following question:\n\n"
11
+ "<<<\n{question}\n>>>\n\n"
12
+ "Here are the candidate participants. Each has a `participant_id`, "
13
+ "a display `name`, and a role description (`role_prompt`).\n\n"
14
+ "{candidates_block}\n\n"
15
+ "Pick the {count} participants whose expertise and perspective are "
16
+ "MOST relevant and complementary for this question. Aim for "
17
+ "diversity of viewpoint as well as topical fit - a group of five "
18
+ "experts who all think the same way is less useful than five who "
19
+ "will productively disagree.\n\n"
20
+ "Reply with ONLY a JSON object of the exact shape:\n"
21
+ "{{\n"
22
+ " \"selected\": [\"participant_id_1\", \"participant_id_2\", ...],\n"
23
+ " \"rationale\": \"one short sentence explaining the mix\"\n"
24
+ "}}\n\n"
25
+ "`selected` MUST contain exactly {count} ids drawn ONLY from the "
26
+ "candidate list above, in order of relevance (most relevant first). "
27
+ "Do not invent ids."
28
+ )
frontend/src/App.js CHANGED
@@ -11,8 +11,9 @@ import {
11
  fetchModels, fetchPersonas, fetchDemoQuestions,
12
  startChat, continueChat, getOrchestrator, setOrchestrator,
13
  getSpeedPriority, setSpeedPriority, getAuthStatus,
14
- exportChat, exportApiLog, fetchTableView, fetchCredentials,
15
  fetchConversationLimitsDefaults,
 
16
  getRateLimitStatus,
17
  } from './utils/api';
18
  import * as storage from './utils/storage';
@@ -84,6 +85,14 @@ export default function App() {
84
  persisted.conversation_limits || {},
85
  );
86
  const [limitsOpen, setLimitsOpen] = useState(false);
 
 
 
 
 
 
 
 
87
 
88
  const abortRef = useRef(null);
89
 
@@ -230,6 +239,21 @@ export default function App() {
230
  });
231
  }, []);
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  // ─── Expert persona ops ─────────────────────────────────────────
234
  const handleOpenExpertModal = useCallback((personaOrNull) => {
235
  setExpertEditing(personaOrNull);
@@ -353,11 +377,13 @@ export default function App() {
353
  }, []);
354
 
355
  // ─── Build start payload ────────────────────────────────────────
356
- const buildStartPayload = useCallback((theQuestion) => {
357
- const enabledParticipants = selectedParticipants.filter(
358
- p => enabledMap[p.participant_id] !== false,
359
- );
360
- const participants = enabledParticipants.map(p => ({
 
 
361
  participant_id: p.participant_id,
362
  kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
363
  : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
@@ -365,7 +391,7 @@ export default function App() {
365
  role_prompt: p.role_prompt || null,
366
  model_id_override: modelAssignments[p.participant_id] || null,
367
  }));
368
- const expert_payload = enabledParticipants
369
  .filter(p => (p.kind || '').startsWith('expert'))
370
  .map(p => ({
371
  participant_id: p.participant_id,
@@ -405,23 +431,94 @@ export default function App() {
405
  // ─── Start chat ─────────────────────────────────────────────────
406
  const handleStart = useCallback(async (theQuestion) => {
407
  if (!theQuestion || !theQuestion.trim()) return;
408
- if (enabledSelectedCount < 2) return;
 
 
409
 
410
  const controller = new AbortController();
411
  abortRef.current = controller;
412
  setIsRunning(true);
413
  setMessages([]);
414
  setSystemMessages([]);
415
- setStatusText('Starting conversation...');
 
 
416
  setSessionId(null);
417
  setSessionParticipants([]);
418
  setPause(null);
419
  setActiveQuestion(theQuestion.trim());
420
  setCredentialsData(null);
421
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  try {
423
  await startChat(
424
- buildStartPayload(theQuestion),
425
  {
426
  onSession: (data) => {
427
  setSessionId(data.session_id);
@@ -495,7 +592,11 @@ export default function App() {
495
  abortRef.current = null;
496
  getAuthStatus().then(setAuth).catch(() => {});
497
  }
498
- }, [buildStartPayload, enabledSelectedCount, dailyLimit]);
 
 
 
 
499
 
500
  const handleStartRandom = useCallback(() => {
501
  if (demoQuestions.length === 0) {
@@ -506,8 +607,17 @@ export default function App() {
506
  handleStart(q.text);
507
  }, [demoQuestions, handleStart]);
508
 
509
- const startDisabled = isRunning || enabledSelectedCount < 2;
510
- const startDisabledReason = enabledSelectedCount < 2
 
 
 
 
 
 
 
 
 
511
  ? 'Add at least 2 active participants to start.'
512
  : '';
513
 
@@ -524,6 +634,8 @@ export default function App() {
524
  maxParticipants={maxParticipants}
525
  onToggleParticipant={handleToggleParticipant}
526
  onOpenExpertModal={handleOpenExpertModal}
 
 
527
 
528
  allModels={allModelsFlat}
529
  orchestratorModel={orchestratorModel}
@@ -560,6 +672,8 @@ export default function App() {
560
  modelAssignments={modelAssignments}
561
  onToggleEnabled={handleSidebarToggleEnabled}
562
  onRemove={handleSidebarRemove}
 
 
563
  />
564
  <div className="content">
565
  <ChatControls
 
11
  fetchModels, fetchPersonas, fetchDemoQuestions,
12
  startChat, continueChat, getOrchestrator, setOrchestrator,
13
  getSpeedPriority, setSpeedPriority, getAuthStatus,
14
+ exportChat, exportApiLog, fetchTableView, fetchCredentials,
15
  fetchConversationLimitsDefaults,
16
+ autoSelectParticipants,
17
  getRateLimitStatus,
18
  } from './utils/api';
19
  import * as storage from './utils/storage';
 
85
  persisted.conversation_limits || {},
86
  );
87
  const [limitsOpen, setLimitsOpen] = useState(false);
88
+ // Auto-select toggle: when on, the participant dropdown defers
89
+ // selection to the orchestrator LLM at /chat/start time. We also
90
+ // snapshot the user's manual selection before turning it on so we
91
+ // can restore it when it's turned back off.
92
+ const [autoSelectMode, setAutoSelectMode] = useState(
93
+ !!persisted.auto_select_mode,
94
+ );
95
+ const [priorManualSelection, setPriorManualSelection] = useState(null);
96
 
97
  const abortRef = useRef(null);
98
 
 
239
  });
240
  }, []);
241
 
242
+ // ─── Auto-select toggle ─────────────────────────────────────────
243
+ // When turning ON, snapshot the current manual selection so we can
244
+ // restore it on OFF. The actual LLM ranking happens in handleStart
245
+ // (so the user's question is available); this just flips the mode.
246
+ const handleToggleAutoSelectMode = useCallback((on) => {
247
+ if (on && !autoSelectMode) {
248
+ setPriorManualSelection([...selectedIds]);
249
+ } else if (!on && autoSelectMode && priorManualSelection !== null) {
250
+ setSelectedIds(priorManualSelection);
251
+ setPriorManualSelection(null);
252
+ }
253
+ setAutoSelectMode(!!on);
254
+ storage.setAutoSelectMode(!!on);
255
+ }, [autoSelectMode, selectedIds, priorManualSelection]);
256
+
257
  // ─── Expert persona ops ─────────────────────────────────────────
258
  const handleOpenExpertModal = useCallback((personaOrNull) => {
259
  setExpertEditing(personaOrNull);
 
377
  }, []);
378
 
379
  // ─── Build start payload ────────────────────────────────────────
380
+ // `participantsOverride`, if provided, replaces the
381
+ // selectedParticipants-derived list (used by the auto-select flow
382
+ // because the freshly-chosen list isn't in state yet when we need it).
383
+ const buildStartPayload = useCallback((theQuestion, participantsOverride) => {
384
+ const baseList = participantsOverride
385
+ ?? selectedParticipants.filter(p => enabledMap[p.participant_id] !== false);
386
+ const participants = baseList.map(p => ({
387
  participant_id: p.participant_id,
388
  kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
389
  : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
 
391
  role_prompt: p.role_prompt || null,
392
  model_id_override: modelAssignments[p.participant_id] || null,
393
  }));
394
+ const expert_payload = baseList
395
  .filter(p => (p.kind || '').startsWith('expert'))
396
  .map(p => ({
397
  participant_id: p.participant_id,
 
431
  // ─── Start chat ─────────────────────────────────────────────────
432
  const handleStart = useCallback(async (theQuestion) => {
433
  if (!theQuestion || !theQuestion.trim()) return;
434
+ // In auto-select mode the dropdown has no manual picks - skip the
435
+ // pre-flight count check and validate the chosen pool below instead.
436
+ if (!autoSelectMode && enabledSelectedCount < 2) return;
437
 
438
  const controller = new AbortController();
439
  abortRef.current = controller;
440
  setIsRunning(true);
441
  setMessages([]);
442
  setSystemMessages([]);
443
+ setStatusText(
444
+ autoSelectMode ? 'Picking participants...' : 'Starting conversation...',
445
+ );
446
  setSessionId(null);
447
  setSessionParticipants([]);
448
  setPause(null);
449
  setActiveQuestion(theQuestion.trim());
450
  setCredentialsData(null);
451
 
452
+ // Resolve the final participant list. When auto-select is on, ask
453
+ // the orchestrator to rank every available candidate; otherwise
454
+ // fall through to the user's manual selection.
455
+ let resolvedParticipants = null;
456
+ if (autoSelectMode) {
457
+ const candidatePool = Object.values(allCatalogParticipants);
458
+ if (candidatePool.length < 2) {
459
+ setIsRunning(false);
460
+ setStatusText('');
461
+ setSystemMessages(prev => [...prev, {
462
+ text: 'Auto-select needs at least 2 candidate participants available.',
463
+ }]);
464
+ return;
465
+ }
466
+ const candidatesPayload = candidatePool.map(p => ({
467
+ participant_id: p.participant_id,
468
+ name: p.name,
469
+ role_prompt: p.role_prompt || '',
470
+ kind: p.kind || (p.participant_id.startsWith('neon:') ? 'neon'
471
+ : (p.participant_id.startsWith('extra_') ? 'extra' : 'expert')),
472
+ model_id: modelAssignments[p.participant_id]
473
+ || p.model_id || p.default_model_id || '',
474
+ }));
475
+ try {
476
+ const result = await autoSelectParticipants({
477
+ question: theQuestion.trim(),
478
+ count: maxParticipants,
479
+ candidates: candidatesPayload,
480
+ orchestrator_model_id: orchestratorModel,
481
+ });
482
+ const chosenIds = result.selected || [];
483
+ resolvedParticipants = chosenIds
484
+ .map(id => allCatalogParticipants[id])
485
+ .filter(Boolean);
486
+ if (resolvedParticipants.length < 2) {
487
+ setIsRunning(false);
488
+ setStatusText('');
489
+ setSystemMessages(prev => [...prev, {
490
+ text: 'Auto-select returned too few participants. '
491
+ + 'Turn auto-select off and pick manually.',
492
+ }]);
493
+ return;
494
+ }
495
+ // Reflect the pick in the sidebar.
496
+ setSelectedIds(chosenIds);
497
+ setEnabledMap(prev => {
498
+ const next = { ...prev };
499
+ for (const id of chosenIds) next[id] = true;
500
+ return next;
501
+ });
502
+ if (result.rationale) {
503
+ setSystemMessages(prev => [...prev, {
504
+ text: `Auto-select rationale: ${result.rationale}`,
505
+ }]);
506
+ }
507
+ setStatusText('Starting conversation...');
508
+ } catch (err) {
509
+ console.error('Auto-select failed:', err);
510
+ setIsRunning(false);
511
+ setStatusText('');
512
+ setSystemMessages(prev => [...prev, {
513
+ text: `Auto-select failed: ${err.message}`,
514
+ }]);
515
+ return;
516
+ }
517
+ }
518
+
519
  try {
520
  await startChat(
521
+ buildStartPayload(theQuestion, resolvedParticipants),
522
  {
523
  onSession: (data) => {
524
  setSessionId(data.session_id);
 
592
  abortRef.current = null;
593
  getAuthStatus().then(setAuth).catch(() => {});
594
  }
595
+ }, [
596
+ buildStartPayload, enabledSelectedCount, dailyLimit,
597
+ autoSelectMode, allCatalogParticipants, modelAssignments,
598
+ maxParticipants, orchestratorModel,
599
+ ]);
600
 
601
  const handleStartRandom = useCallback(() => {
602
  if (demoQuestions.length === 0) {
 
607
  handleStart(q.text);
608
  }, [demoQuestions, handleStart]);
609
 
610
+ // In auto-select mode we don't require manual picks - the orchestrator
611
+ // will choose them at /chat/start time, so just need 2+ candidates
612
+ // available in the catalog.
613
+ const autoSelectReady = autoSelectMode
614
+ && Object.keys(allCatalogParticipants).length >= 2;
615
+ const startDisabled = isRunning
616
+ || (!autoSelectMode && enabledSelectedCount < 2)
617
+ || (autoSelectMode && !autoSelectReady);
618
+ const startDisabledReason = autoSelectMode
619
+ ? (!autoSelectReady ? 'No candidate participants available for auto-select.' : '')
620
+ : enabledSelectedCount < 2
621
  ? 'Add at least 2 active participants to start.'
622
  : '';
623
 
 
634
  maxParticipants={maxParticipants}
635
  onToggleParticipant={handleToggleParticipant}
636
  onOpenExpertModal={handleOpenExpertModal}
637
+ autoSelectMode={autoSelectMode}
638
+ onToggleAutoSelectMode={handleToggleAutoSelectMode}
639
 
640
  allModels={allModelsFlat}
641
  orchestratorModel={orchestratorModel}
 
672
  modelAssignments={modelAssignments}
673
  onToggleEnabled={handleSidebarToggleEnabled}
674
  onRemove={handleSidebarRemove}
675
+ autoSelectMode={autoSelectMode}
676
+ maxParticipants={maxParticipants}
677
  />
678
  <div className="content">
679
  <ChatControls
frontend/src/components/Header.js CHANGED
@@ -21,6 +21,8 @@ export default function Header({
21
  maxParticipants,
22
  onToggleParticipant,
23
  onOpenExpertModal,
 
 
24
 
25
  // dev menu props passed straight through
26
  ...devProps
@@ -46,6 +48,8 @@ export default function Header({
46
  maxParticipants={maxParticipants}
47
  onToggleParticipant={onToggleParticipant}
48
  onOpenExpertModal={onOpenExpertModal}
 
 
49
  />
50
  <button
51
  className="icon-btn"
 
21
  maxParticipants,
22
  onToggleParticipant,
23
  onOpenExpertModal,
24
+ autoSelectMode,
25
+ onToggleAutoSelectMode,
26
 
27
  // dev menu props passed straight through
28
  ...devProps
 
48
  maxParticipants={maxParticipants}
49
  onToggleParticipant={onToggleParticipant}
50
  onOpenExpertModal={onOpenExpertModal}
51
+ autoSelectMode={autoSelectMode}
52
+ onToggleAutoSelectMode={onToggleAutoSelectMode}
53
  />
54
  <button
55
  className="icon-btn"
frontend/src/components/ParticipantDropdown.js CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState, useRef, useEffect } from 'react';
2
- import { Users, Plus, ChevronDown } from 'lucide-react';
3
 
4
  /**
5
  * Header dropdown that lists every available participant the user can
@@ -8,6 +8,11 @@ import { Users, Plus, ChevronDown } from 'lucide-react';
8
  * - Extra (the four bundled non-Neon-LLM personas)
9
  * - Expert (user-created, stored in localStorage)
10
  *
 
 
 
 
 
11
  * Selecting a participant adds them to the active conversation list. The
12
  * "Create Expert Persona..." entry opens the modal.
13
  */
@@ -18,6 +23,8 @@ export default function ParticipantDropdown({
18
  maxParticipants,
19
  onToggleParticipant,
20
  onOpenExpertModal,
 
 
21
  }) {
22
  const [open, setOpen] = useState(false);
23
  const ref = useRef(null);
@@ -34,21 +41,61 @@ export default function ParticipantDropdown({
34
 
35
  const isSelected = (id) => selectedIds.includes(id);
36
  const atCap = selectedIds.length >= maxParticipants;
 
 
 
37
 
38
  return (
39
  <div className="ccai-dropdown-wrap" ref={ref}>
40
  <button
41
  className="btn-sm btn-outline ccai-dropdown-trigger"
42
  onClick={() => setOpen(o => !o)}
43
- title="Add or remove participants"
 
 
 
 
44
  >
45
  <Users size={14} />
46
- <span>Participants ({selectedIds.length}/{maxParticipants})</span>
 
 
 
 
47
  <ChevronDown size={12} />
48
  </button>
49
  {open && (
50
  <div className="ccai-dropdown-panel">
51
  <div className="ccai-dropdown-section">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  <div className="ccai-dropdown-section-title">Neon.ai Personas</div>
53
  {(catalog?.neon || []).length === 0 && (
54
  <div className="ccai-dropdown-empty">
@@ -60,26 +107,44 @@ export default function ParticipantDropdown({
60
  key={p.participant_id}
61
  participant={p}
62
  checked={isSelected(p.participant_id)}
63
- disabledForAdd={atCap && !isSelected(p.participant_id)}
 
 
 
 
64
  onToggle={() => onToggleParticipant(p, 'neon')}
65
  />
66
  ))}
67
  </div>
68
  <div className="ccai-dropdown-divider" />
69
- <div className="ccai-dropdown-section">
 
 
 
 
 
70
  <div className="ccai-dropdown-section-title">Extra Personas</div>
71
  {(catalog?.extra || []).map((p) => (
72
  <DropdownItem
73
  key={p.participant_id}
74
  participant={p}
75
  checked={isSelected(p.participant_id)}
76
- disabledForAdd={atCap && !isSelected(p.participant_id)}
 
 
 
 
77
  onToggle={() => onToggleParticipant(p, 'extra')}
78
  />
79
  ))}
80
  </div>
81
  <div className="ccai-dropdown-divider" />
82
- <div className="ccai-dropdown-section">
 
 
 
 
 
83
  <div className="ccai-dropdown-section-title">Expert Personas</div>
84
  {(expertPersonas || []).length === 0 && (
85
  <div className="ccai-dropdown-empty">
@@ -91,7 +156,11 @@ export default function ParticipantDropdown({
91
  key={p.participant_id}
92
  participant={p}
93
  checked={isSelected(p.participant_id)}
94
- disabledForAdd={atCap && !isSelected(p.participant_id)}
 
 
 
 
95
  onToggle={() => onToggleParticipant(p, 'expert')}
96
  />
97
  ))}
@@ -109,7 +178,7 @@ export default function ParticipantDropdown({
109
  );
110
  }
111
 
112
- function DropdownItem({ participant, checked, disabledForAdd, onToggle }) {
113
  return (
114
  <label
115
  className={
@@ -117,7 +186,11 @@ function DropdownItem({ participant, checked, disabledForAdd, onToggle }) {
117
  (checked ? ' ccai-dropdown-item-checked' : '') +
118
  (disabledForAdd ? ' ccai-dropdown-item-disabled' : '')
119
  }
120
- title={disabledForAdd ? 'Participant cap reached' : ''}
 
 
 
 
121
  >
122
  <input
123
  type="checkbox"
 
1
  import React, { useState, useRef, useEffect } from 'react';
2
+ import { Users, Plus, ChevronDown, Wand2, Check } from 'lucide-react';
3
 
4
  /**
5
  * Header dropdown that lists every available participant the user can
 
8
  * - Extra (the four bundled non-Neon-LLM personas)
9
  * - Expert (user-created, stored in localStorage)
10
  *
11
+ * A top-of-list "Select N Automatically" toggle defers the choice to
12
+ * the orchestrator LLM: when enabled, manual checkboxes are visually
13
+ * disabled and the actual picks happen at /chat/start time via the
14
+ * /api/chat/auto-select-participants endpoint.
15
+ *
16
  * Selecting a participant adds them to the active conversation list. The
17
  * "Create Expert Persona..." entry opens the modal.
18
  */
 
23
  maxParticipants,
24
  onToggleParticipant,
25
  onOpenExpertModal,
26
+ autoSelectMode,
27
+ onToggleAutoSelectMode,
28
  }) {
29
  const [open, setOpen] = useState(false);
30
  const ref = useRef(null);
 
41
 
42
  const isSelected = (id) => selectedIds.includes(id);
43
  const atCap = selectedIds.length >= maxParticipants;
44
+ // When auto-select is on, checkboxes are deferred entirely - the
45
+ // orchestrator picks at start time, so user picks are ignored.
46
+ const checkboxDisabledForAuto = !!autoSelectMode;
47
 
48
  return (
49
  <div className="ccai-dropdown-wrap" ref={ref}>
50
  <button
51
  className="btn-sm btn-outline ccai-dropdown-trigger"
52
  onClick={() => setOpen(o => !o)}
53
+ title={
54
+ autoSelectMode
55
+ ? 'Auto-select mode: participants picked at chat start by the orchestrator'
56
+ : 'Add or remove participants'
57
+ }
58
  >
59
  <Users size={14} />
60
+ <span>
61
+ {autoSelectMode
62
+ ? 'Participants Auto Selected'
63
+ : `Participants (${selectedIds.length}/${maxParticipants})`}
64
+ </span>
65
  <ChevronDown size={12} />
66
  </button>
67
  {open && (
68
  <div className="ccai-dropdown-panel">
69
  <div className="ccai-dropdown-section">
70
+ <button
71
+ type="button"
72
+ className={
73
+ 'ccai-dropdown-autoselect' +
74
+ (autoSelectMode ? ' ccai-dropdown-autoselect-on' : '')
75
+ }
76
+ onClick={() => onToggleAutoSelectMode?.(!autoSelectMode)}
77
+ title={
78
+ autoSelectMode
79
+ ? 'Turn off auto-select and resume manual picking'
80
+ : 'Defer participant selection until chat start; the orchestrator will pick the most relevant.'
81
+ }
82
+ >
83
+ {autoSelectMode ? <Check size={14} /> : <Wand2 size={14} />}
84
+ <span>Select {maxParticipants} Automatically</span>
85
+ </button>
86
+ <div className="ccai-dropdown-autoselect-help">
87
+ {autoSelectMode
88
+ ? 'The orchestrator will pick the most relevant participants for your question when you start the chat.'
89
+ : 'Or pick manually from the lists below.'}
90
+ </div>
91
+ </div>
92
+ <div className="ccai-dropdown-divider" />
93
+ <div
94
+ className={
95
+ 'ccai-dropdown-section' +
96
+ (checkboxDisabledForAuto ? ' ccai-dropdown-section-muted' : '')
97
+ }
98
+ >
99
  <div className="ccai-dropdown-section-title">Neon.ai Personas</div>
100
  {(catalog?.neon || []).length === 0 && (
101
  <div className="ccai-dropdown-empty">
 
107
  key={p.participant_id}
108
  participant={p}
109
  checked={isSelected(p.participant_id)}
110
+ disabledForAdd={
111
+ checkboxDisabledForAuto
112
+ || (atCap && !isSelected(p.participant_id))
113
+ }
114
+ autoSelectActive={checkboxDisabledForAuto}
115
  onToggle={() => onToggleParticipant(p, 'neon')}
116
  />
117
  ))}
118
  </div>
119
  <div className="ccai-dropdown-divider" />
120
+ <div
121
+ className={
122
+ 'ccai-dropdown-section' +
123
+ (checkboxDisabledForAuto ? ' ccai-dropdown-section-muted' : '')
124
+ }
125
+ >
126
  <div className="ccai-dropdown-section-title">Extra Personas</div>
127
  {(catalog?.extra || []).map((p) => (
128
  <DropdownItem
129
  key={p.participant_id}
130
  participant={p}
131
  checked={isSelected(p.participant_id)}
132
+ disabledForAdd={
133
+ checkboxDisabledForAuto
134
+ || (atCap && !isSelected(p.participant_id))
135
+ }
136
+ autoSelectActive={checkboxDisabledForAuto}
137
  onToggle={() => onToggleParticipant(p, 'extra')}
138
  />
139
  ))}
140
  </div>
141
  <div className="ccai-dropdown-divider" />
142
+ <div
143
+ className={
144
+ 'ccai-dropdown-section' +
145
+ (checkboxDisabledForAuto ? ' ccai-dropdown-section-muted' : '')
146
+ }
147
+ >
148
  <div className="ccai-dropdown-section-title">Expert Personas</div>
149
  {(expertPersonas || []).length === 0 && (
150
  <div className="ccai-dropdown-empty">
 
156
  key={p.participant_id}
157
  participant={p}
158
  checked={isSelected(p.participant_id)}
159
+ disabledForAdd={
160
+ checkboxDisabledForAuto
161
+ || (atCap && !isSelected(p.participant_id))
162
+ }
163
+ autoSelectActive={checkboxDisabledForAuto}
164
  onToggle={() => onToggleParticipant(p, 'expert')}
165
  />
166
  ))}
 
178
  );
179
  }
180
 
181
+ function DropdownItem({ participant, checked, disabledForAdd, autoSelectActive, onToggle }) {
182
  return (
183
  <label
184
  className={
 
186
  (checked ? ' ccai-dropdown-item-checked' : '') +
187
  (disabledForAdd ? ' ccai-dropdown-item-disabled' : '')
188
  }
189
+ title={
190
+ autoSelectActive
191
+ ? 'Disabled while auto-select is on'
192
+ : (disabledForAdd ? 'Participant cap reached' : '')
193
+ }
194
  >
195
  <input
196
  type="checkbox"
frontend/src/components/ParticipantSidebar.js CHANGED
@@ -14,19 +14,39 @@ export default function ParticipantSidebar({
14
  modelAssignments,
15
  onToggleEnabled,
16
  onRemove,
 
 
17
  }) {
 
 
 
 
 
 
18
  return (
19
  <aside className="sidebar ccai-sidebar">
20
  <div className="ccai-sidebar-header">
21
  <h2 className="sidebar-title">Participants</h2>
22
  <div className="ccai-sidebar-help">
23
- {participants.length === 0 ? (
 
 
24
  <em>Use the Participants dropdown in the header to add some.</em>
25
  ) : (
26
  <em>Drag to reorder is not supported yet — order is by selection.</em>
27
  )}
28
  </div>
29
  </div>
 
 
 
 
 
 
 
 
 
 
30
  {participants.map((p) => {
31
  const enabled = enabledMap[p.participant_id] !== false;
32
  const modelOverride = modelAssignments[p.participant_id];
 
14
  modelAssignments,
15
  onToggleEnabled,
16
  onRemove,
17
+ autoSelectMode,
18
+ maxParticipants,
19
  }) {
20
+ // In auto-select mode with no chat in progress, the sidebar shows a
21
+ // placeholder explaining the deferred selection. Once the chat
22
+ // starts, App.js populates `participants` with the LLM-chosen set
23
+ // and the regular cards render normally.
24
+ const showAutoPlaceholder = autoSelectMode && participants.length === 0;
25
+
26
  return (
27
  <aside className="sidebar ccai-sidebar">
28
  <div className="ccai-sidebar-header">
29
  <h2 className="sidebar-title">Participants</h2>
30
  <div className="ccai-sidebar-help">
31
+ {showAutoPlaceholder ? (
32
+ <em>Auto-select is on.</em>
33
+ ) : participants.length === 0 ? (
34
  <em>Use the Participants dropdown in the header to add some.</em>
35
  ) : (
36
  <em>Drag to reorder is not supported yet — order is by selection.</em>
37
  )}
38
  </div>
39
  </div>
40
+ {showAutoPlaceholder && (
41
+ <div className="ccai-sidebar-autoselect-empty">
42
+ <strong>Auto-select: {maxParticipants} participants</strong>
43
+ <div style={{ marginTop: 4 }}>
44
+ When you start the chat, the orchestrator will pick the
45
+ {' '}<strong>{maxParticipants}</strong> participants whose
46
+ expertise best fits your question.
47
+ </div>
48
+ </div>
49
+ )}
50
  {participants.map((p) => {
51
  const enabled = enabledMap[p.participant_id] !== false;
52
  const modelOverride = modelAssignments[p.participant_id];
frontend/src/styles/ccai.css CHANGED
@@ -1119,3 +1119,59 @@
1119
  color: var(--text-secondary);
1120
  line-height: 1.45;
1121
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1119
  color: var(--text-secondary);
1120
  line-height: 1.45;
1121
  }
1122
+
1123
+
1124
+ /* ── Auto-select participants (dropdown + sidebar placeholder) ──── */
1125
+
1126
+ .ccai-dropdown-autoselect {
1127
+ display: flex;
1128
+ align-items: center;
1129
+ gap: 8px;
1130
+ width: 100%;
1131
+ padding: 8px 10px;
1132
+ background: var(--bg-secondary);
1133
+ border: 1px solid var(--border-primary);
1134
+ border-radius: 8px;
1135
+ color: var(--text-primary);
1136
+ font-size: 13px;
1137
+ font-weight: 600;
1138
+ cursor: pointer;
1139
+ text-align: left;
1140
+ }
1141
+
1142
+ .ccai-dropdown-autoselect:hover {
1143
+ border-color: var(--accent-primary, var(--text-primary));
1144
+ }
1145
+
1146
+ .ccai-dropdown-autoselect-on {
1147
+ background: var(--accent-primary, var(--bg-primary));
1148
+ color: var(--bg-primary, var(--text-primary));
1149
+ border-color: transparent;
1150
+ }
1151
+
1152
+ .ccai-dropdown-autoselect-help {
1153
+ font-size: 11.5px;
1154
+ color: var(--text-secondary);
1155
+ padding: 6px 4px 0;
1156
+ line-height: 1.4;
1157
+ }
1158
+
1159
+ .ccai-dropdown-section-muted {
1160
+ opacity: 0.55;
1161
+ pointer-events: none;
1162
+ }
1163
+
1164
+ .ccai-sidebar-autoselect-empty {
1165
+ margin: 12px;
1166
+ padding: 12px;
1167
+ border: 1px dashed var(--border-primary);
1168
+ border-radius: 8px;
1169
+ color: var(--text-secondary);
1170
+ font-size: 12.5px;
1171
+ line-height: 1.45;
1172
+ }
1173
+
1174
+ .ccai-sidebar-autoselect-empty strong {
1175
+ color: var(--text-primary);
1176
+ font-size: 13px;
1177
+ }
frontend/src/utils/api.js CHANGED
@@ -179,6 +179,26 @@ export async function fetchTableView(sessionId) {
179
  return resp.json();
180
  }
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  /**
183
  * Fetch the user-tunable conversation-limit defaults, bounds, and
184
  * descriptions. The frontend uses this to render the "Conversation
 
179
  return resp.json();
180
  }
181
 
182
+ /**
183
+ * Ask the backend to pick the top `count` participants from the
184
+ * candidate pool by relevance to the question. Used by the
185
+ * "Select N Automatically" toggle in the participants dropdown.
186
+ *
187
+ * Returns: { selected: [participant_id, ...], rationale: "..." }
188
+ */
189
+ export async function autoSelectParticipants({ question, count, candidates, orchestrator_model_id }) {
190
+ const resp = await fetch(`${API_BASE}/api/chat/auto-select-participants`, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json' },
193
+ body: JSON.stringify({ question, count, candidates, orchestrator_model_id }),
194
+ });
195
+ if (!resp.ok) {
196
+ const err = await resp.json().catch(() => ({ detail: resp.statusText }));
197
+ throw new Error(err.detail || 'Auto-select failed');
198
+ }
199
+ return resp.json();
200
+ }
201
+
202
  /**
203
  * Fetch the user-tunable conversation-limit defaults, bounds, and
204
  * descriptions. The frontend uses this to render the "Conversation
frontend/src/utils/storage.js CHANGED
@@ -24,6 +24,10 @@ const DEFAULTS = {
24
  // so storing whatever the user typed (including stale values from
25
  // an earlier session) is safe.
26
  conversation_limits: {},
 
 
 
 
27
  };
28
 
29
  function readAll() {
@@ -96,3 +100,7 @@ export function setTheme(theme) {
96
  export function setConversationLimits(limitsMap) {
97
  return patchState({ conversation_limits: limitsMap || {} });
98
  }
 
 
 
 
 
24
  // so storing whatever the user typed (including stale values from
25
  // an earlier session) is safe.
26
  conversation_limits: {},
27
+ // When true, the participants dropdown shows "Select N Automatically"
28
+ // as the active mode and the per-persona checkboxes are hidden.
29
+ // The auto-select happens just before /chat/start.
30
+ auto_select_mode: false,
31
  };
32
 
33
  function readAll() {
 
100
  export function setConversationLimits(limitsMap) {
101
  return patchState({ conversation_limits: limitsMap || {} });
102
  }
103
+
104
+ export function setAutoSelectMode(on) {
105
+ return patchState({ auto_select_mode: !!on });
106
+ }