Spaces:
Sleeping
Sleeping
NeonClary Cursor commited on
Commit ·
af5d1df
1
Parent(s): 1ec996f
feat(participants): add "Select N Automatically" option
Browse filesWhen 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 +55 -0
- backend/app/services/auto_select.py +142 -0
- backend/app/services/prompts/__init__.py +2 -0
- backend/app/services/prompts/auto_select.py +28 -0
- frontend/src/App.js +127 -13
- frontend/src/components/Header.js +4 -0
- frontend/src/components/ParticipantDropdown.js +83 -10
- frontend/src/components/ParticipantSidebar.js +21 -1
- frontend/src/styles/ccai.css +56 -0
- frontend/src/utils/api.js +20 -0
- frontend/src/utils/storage.js +8 -0
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,
|
| 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 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
const
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 409 |
|
| 410 |
const controller = new AbortController();
|
| 411 |
abortRef.current = controller;
|
| 412 |
setIsRunning(true);
|
| 413 |
setMessages([]);
|
| 414 |
setSystemMessages([]);
|
| 415 |
-
setStatusText(
|
|
|
|
|
|
|
| 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 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
>
|
| 45 |
<Users size={14} />
|
| 46 |
-
<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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
onToggle={() => onToggleParticipant(p, 'neon')}
|
| 65 |
/>
|
| 66 |
))}
|
| 67 |
</div>
|
| 68 |
<div className="ccai-dropdown-divider" />
|
| 69 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
onToggle={() => onToggleParticipant(p, 'extra')}
|
| 78 |
/>
|
| 79 |
))}
|
| 80 |
</div>
|
| 81 |
<div className="ccai-dropdown-divider" />
|
| 82 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
| 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 |
+
}
|